Give the interpreter real Celtic Cross meanings and --format/--lang support
- guidance.c now prints last, after the full spread; both it and main.c's new Celtic Cross readout pull real Waite card meanings via tarot_data.c instead of just naming cards. - interpreter-cli gains --format text|html|json and --lang en|de, matching deck-engine - required linking engine/src/tarot_data.c and i18n.c into the interpreter binary (a narrow, documented exception to its earlier "no engine .c files" policy) and writing German translations for narrative.c's/guidance.c's own text.
This commit is contained in:
@@ -5,13 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Project goal
|
||||
|
||||
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
||||
Cross) and astrology (natal chart + transits) reading. Currently only the
|
||||
**core engine** exists: a plain-C, dependency-free library plus a
|
||||
Cross) and astrology (natal chart + transits) reading. This repository
|
||||
holds the **core engine**: a plain-C, dependency-free library plus a
|
||||
standalone CLI binary (`dist/deck-engine`) that can be built and run
|
||||
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI,
|
||||
resource packs) has not been started yet — the engine is deliberately
|
||||
built so it can be linked straight into it later without rework (see
|
||||
"Portability to the watch" below).
|
||||
without any Pebble SDK or emulator, deliberately built so it links
|
||||
straight into Pebble C/UI watch code without rework (see "Portability to
|
||||
the watch" below).
|
||||
|
||||
## Build & run
|
||||
|
||||
@@ -99,12 +98,11 @@ deck_in_a_dash/
|
||||
### Data flow / the real API
|
||||
|
||||
`reading_generate(seed, birth, utc_moment, &DailyReading)` in
|
||||
`reading.h` is the single entry point that matters — it's what the
|
||||
watchapp will call once it exists. It populates a plain struct
|
||||
(`NatalChart` + `DailyTransits` + `CelticCrossSpread`) that the caller
|
||||
walks directly to build a UI. `reading_print_text`/`reading_print_html`
|
||||
are desktop-only conveniences for inspecting a reading (used by the CLI);
|
||||
the watchapp will never call them.
|
||||
`reading.h` is the single entry point that matters — the real API
|
||||
surface. It populates a plain struct (`NatalChart` + `DailyTransits` +
|
||||
`CelticCrossSpread`) that a caller walks directly to build a UI.
|
||||
`reading_print_text`/`reading_print_html` are desktop-only text/HTML
|
||||
renderers of that same struct, used by the CLI.
|
||||
|
||||
### Determinism (`rng.c`)
|
||||
|
||||
@@ -186,44 +184,55 @@ translate the values — no source changes needed, `make` picks up any
|
||||
|
||||
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
|
||||
`reading_print_*` — it's a desktop-only entry point; `i18n_get` itself
|
||||
is a pure fixed-size-array lookup with no heap allocation, so it's fine
|
||||
to port to the watch once it has its own (non-file-based) way to
|
||||
populate the catalog, e.g. from a compiled-in resource.
|
||||
is a pure fixed-size-array lookup with no heap allocation, so it links
|
||||
into the watch as-is given a non-file-based way to populate the catalog,
|
||||
e.g. from a compiled-in resource.
|
||||
|
||||
### Portability to the watch
|
||||
|
||||
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
|
||||
are kept free of `stdio`/CLI assumptions specifically so they can link
|
||||
into the Pebble watchapp unchanged later. Only `reading_print_text`/
|
||||
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
|
||||
(reads a file from disk) are desktop-only and won't port as-is.
|
||||
are kept free of `stdio`/CLI assumptions specifically so they link into
|
||||
the Pebble watchapp unchanged. Only `reading_print_text`/`_html` (text
|
||||
formatting), `main.c` (CLI arg parsing), and `i18n_load` (reads a file
|
||||
from disk) are desktop-only and don't port as-is.
|
||||
|
||||
## Interpretation (`interpreter/`)
|
||||
|
||||
A second, separate top-level module and **its own binary**
|
||||
(`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how
|
||||
significant a day's transits are* and, for each of the top items, prints
|
||||
a short narrative sentence about what that transit classically means —
|
||||
the first piece of a larger "turn the raw reading into an actual
|
||||
narrative" effort described in project chat history; the Celtic Cross
|
||||
spread itself isn't woven into that narrative yet (see
|
||||
`docs/reading.md`). `deck-engine` itself is deliberately unchanged by
|
||||
this beyond gaining `--format json`; the two binaries compose over that
|
||||
JSON, never by linking together:
|
||||
significant a day's transits are*, prints a short narrative sentence for
|
||||
each of the top items about what that transit classically means, reads
|
||||
out the full Celtic Cross spread with every card's meaning, and finally
|
||||
prints guidance tying the day's top transit to the spread's
|
||||
Attitude/Outcome cards; see `docs/reading.md`. Like `deck-engine`, it
|
||||
supports `--format text|html|json` and `--lang <code>`/`--i18n-dir
|
||||
<path>` for its own output - see `main.c`'s bullets below for both.
|
||||
`deck-engine` itself is deliberately unchanged beyond gaining `--format
|
||||
json`; the two binaries compose over that JSON, never by linking
|
||||
`reading_generate()` or any I/O function together:
|
||||
|
||||
```bash
|
||||
make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
|
||||
make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c
|
||||
|
||||
dist/deck-engine ... --format json | dist/interpreter-cli
|
||||
dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang <code>]
|
||||
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
|
||||
# or, for daily use (OS clock + user.properties, same as run-engine.sh):
|
||||
dist/run-interpreter.sh
|
||||
dist/run-interpreter.sh [text|html|json] [lang]
|
||||
```
|
||||
|
||||
The root `Makefile` builds both from one invocation, but keeps them as
|
||||
separate compilation units throughout — no object file is ever shared
|
||||
between the two binaries (see the next bullet).
|
||||
Note `--lang` means something different on each side of the pipe:
|
||||
`deck-engine`'s own `--lang` (if passed at all) is irrelevant here, since
|
||||
`--format json` is deliberately language-independent (see
|
||||
`docs/input-output-format.md`); `dist/interpreter-cli --lang` controls
|
||||
*its own* text/html/json output language, independently.
|
||||
|
||||
The root `Makefile` builds both from one invocation, and keeps them
|
||||
independent compilation units for everything ephemeris/tarot-draw/
|
||||
random-related — the one exception is `engine/src/tarot_data.c` and its
|
||||
own `i18n.c` dependency (pure, deterministic card/position lookup text,
|
||||
see `INTERP_TAROT_TEXT_OBJS` in the `Makefile`), whose object files are
|
||||
built once and linked into *both* binaries.
|
||||
|
||||
- `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores
|
||||
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
|
||||
@@ -234,17 +243,15 @@ between the two binaries (see the next bullet).
|
||||
purely so callers can print the level as a rank, e.g. `main.c`'s
|
||||
`"%s (%d/%d)"` → `"Notable (2/4)"` — `interp.day_level + 1` out of
|
||||
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
|
||||
only at `DAY_MAJOR` — the intended hook for giving the Celtic Cross
|
||||
reading a "this matters" intro sentence naming `top_items[0]` on days
|
||||
that earn it; that rendering doesn't exist yet, `main.c`'s report is
|
||||
plain text only.
|
||||
only at `DAY_MAJOR` — `main.c` uses it solely to decide whether to
|
||||
print an extra "worth a deeper Celtic Cross look" line; it's not a
|
||||
gate on `guidance.c` (below), which runs on every day level.
|
||||
- Score = `transiting-planet weight (slow/outer planets score higher) ×
|
||||
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
|
||||
(transits to natal Sun/Moon score higher than to other planets)`. The
|
||||
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
|
||||
thresholds in `significance.c` are hand-tuned, not derived from
|
||||
anything physical — expect to retune them once real readings are
|
||||
compared against how "major" a day actually feels.
|
||||
anything physical.
|
||||
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
|
||||
description printed under each top item, condensed from Sepharial's
|
||||
*Transits and Planetary Periods* (1920, public domain —
|
||||
@@ -267,7 +274,81 @@ between the two binaries (see the next bullet).
|
||||
source (same status as the sign/aspect names already in `astro.c`).
|
||||
Passing a house outside 1-12 (the same "no data" sentinel `reading_io.c`
|
||||
uses elsewhere) omits that framing and prints the planet's narrative on
|
||||
its own.
|
||||
its own. Every string here (`k_narratives[]`'s `base`/`harmonious`/
|
||||
`discordant`, `k_house_area[]`, and the "In matters of ..." framing
|
||||
template itself) is looked up via `i18n_get()` under `narrative.*` keys
|
||||
before falling back to this English text - `engine/i18n/de.lang` has a
|
||||
full German translation under the same keys.
|
||||
- `main.c` calls `i18n_load()` once at startup (same pattern as
|
||||
`engine/src/main.c`, including the same `default_i18n_path()` helper,
|
||||
duplicated rather than shared since the two binaries are never linked
|
||||
- see the compilation-units bullet above) before producing any output,
|
||||
so every `i18n_get()` call anywhere in the interpreter - not just
|
||||
`tarot_data.c`'s, but `narrative.c`'s/`guidance.c`'s own (below) -
|
||||
respects `--lang`. `--format text`'s report is printed in a fixed
|
||||
order: significance level → top significant transits (each with its
|
||||
`narrative.c` sentence) → the full Celtic Cross spread
|
||||
(`print_celtic_cross_text()`, every position in Waite's own drawing
|
||||
order, each with its card, orientation, position description, and
|
||||
card meaning — via the real `tarot_position_name()`/`tarot_card_name()`/
|
||||
`tarot_position_description()`/`tarot_card_meaning()` accessors, not a
|
||||
duplicated table, since `tarot_data.c` is linked into this binary) →
|
||||
`guidance.c`'s paragraph, deliberately printed **last**, so it reads
|
||||
as the closing takeaway after the reader has seen the full spread it
|
||||
references. `--format html` mirrors `deck-engine`'s own HTML styling
|
||||
and reuses the same `img/<file>` card-art convention (`tarot_card_
|
||||
image_file()`); `--format json` embeds the same localized narrative/
|
||||
guidance prose as `--format text` (in whatever `--lang` was loaded)
|
||||
*alongside* stable, language-independent slugs (`transiting_planet`,
|
||||
`aspect`, `card`, `position`, etc., reusing the same small local slug
|
||||
tables `reading_io.c` needs for parsing) — a deliberate departure from
|
||||
`reading_print_json`'s "slugs only, never prose" policy (next bullet),
|
||||
justified because rendering that prose *is* this module's job, unlike
|
||||
`deck-engine`'s JSON which is purely a machine interchange format.
|
||||
`narrative_print()`/`guidance_print()` only know how to write to a
|
||||
`FILE *`, so `--format json` captures their output into a heap string
|
||||
via POSIX `open_memstream()` (`capture_narrative()`/`capture_guidance()`)
|
||||
rather than changing either module's public API just for this one
|
||||
caller.
|
||||
- **`guidance.c`/`guidance.h`** is the combined-storytelling piece: it
|
||||
ties `interp->top_items[0]` (the day's single most significant
|
||||
transit) to the Celtic Cross spread and prints a short "how to meet
|
||||
the day" paragraph on *every* day — `main.c` calls it unconditionally
|
||||
after every `interpret_daily_reading()`, with no `day_level` gate. The
|
||||
core guidance keys off two things: the top transit's aspect character
|
||||
(harmonious/discordant/neutral, same classification `narrative.c`
|
||||
uses) crossed with whether the spread's *Attitude* position — Waite's
|
||||
"Himself: his position or attitude in the circumstances", the
|
||||
position most directly about how the reader is meeting the day — fell
|
||||
upright or reversed (3 × 2 = 6 combinations); this text stays valid
|
||||
regardless of the day's intensity. What *does* vary with
|
||||
`interp->day_level` is only the sentence introducing the top transit
|
||||
(`k_intro[DAY_SIGNIFICANCE_COUNT]`, one `%s` each) — e.g. "With Saturn
|
||||
as today's dominant influence" on `DAY_MAJOR` vs. "Saturn is only
|
||||
faintly active today, but for what it's worth" on `DAY_QUIET`. A day
|
||||
with no aspects in orb at all (`top_item_count == 0`, always
|
||||
`DAY_QUIET`) has no transiting planet to introduce, so it falls back
|
||||
to `k_no_transit_stance`, keyed on the Attitude card's orientation
|
||||
alone. All of this guidance text is original, written for this
|
||||
project, since neither Waite nor Sepharial discuss combining astrology
|
||||
and tarot. The paragraph also names the Attitude and Outcome cards and
|
||||
states their actual meaning via `tarot_card_meaning()` (e.g. what
|
||||
Wheel of Fortune reversed means) — the real Waite text, not a
|
||||
duplicated table, for the same reason as `print_celtic_cross_text()`
|
||||
above. Every string here (stance/intro/no-transit/label text, plus a
|
||||
`guidance.planet.*` table used only for the intro sentence's planet
|
||||
name - separate from `body.*` because "the Sun"/"the Moon" take a
|
||||
definite article the other eight planets don't, in both English and
|
||||
German) is looked up via `i18n_get()` under `guidance.*` keys before
|
||||
falling back to this English text - `engine/i18n/de.lang` has a full
|
||||
German translation. The German `guidance.intro.*` templates are
|
||||
deliberately phrased so the planet name placeholder is always the
|
||||
sentence's grammatical subject (nominative case) across all four day
|
||||
levels, sidestepping the case-agreement problems a naive word-for-word
|
||||
translation of the English templates would hit (e.g. "with Saturn"
|
||||
needs dative in German, but "Saturn is only faintly active" needs
|
||||
nominative - the four German templates are worded so the placeholder
|
||||
never needs to change case).
|
||||
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
|
||||
the full `DailyReading` (natal, transits, spread), using the
|
||||
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
|
||||
@@ -283,31 +364,37 @@ between the two binaries (see the next bullet).
|
||||
and fills a `DailyReading` with those (used for scoring), plus
|
||||
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
|
||||
house, via `parse_bodies()` — used only for the sign/house context
|
||||
`main.c` prints alongside each significant event, never for scoring).
|
||||
`"spread"` is left zeroed entirely - nothing reads it. An aspect naming
|
||||
a planet/aspect-type slug it doesn't recognize, or a document missing
|
||||
`"transits"."aspects"` entirely (as opposed to a present-but-empty
|
||||
array, which is a valid "no aspects today"), makes the whole load fail;
|
||||
by contrast an unrecognized body slug or missing `house` inside
|
||||
`"bodies"` is silently skipped (that body's `house` stays `0`, an
|
||||
invalid whole-sign house number used as "no data" - `main.c` checks for
|
||||
it before printing sign/house), since that's supplementary display
|
||||
context rather than something scoring depends on.
|
||||
- **Deliberately header-only dependency on the engine, throughout**:
|
||||
`significance.h`/`reading_io.h`/`narrative.h` `#include` engine headers
|
||||
(`reading.h`/`astro.h`) for the struct/enum *definitions*, but the root
|
||||
`Makefile` never compiles or links any engine `.c` file (or the
|
||||
vendored Astronomy Engine) into the interpreter's binary or tests —
|
||||
every
|
||||
test in `interpreter/tests/` runs against hand-built JSON text or
|
||||
hand-built `DailyReading` values, with no real ephemeris/tarot-draw
|
||||
call involved anywhere. Consequently `reading_io.c` (planet/aspect/sign
|
||||
slugs) and `main.c` (planet/aspect/sign display names) each duplicate a
|
||||
small local table rather than linking `astro.c` to reuse its own -
|
||||
keep those in sync if the engine's slugs/names ever change.
|
||||
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is
|
||||
currently just `ITEM_ASPECT`); moon phase and house ingresses are
|
||||
candidate future item kinds but aren't scored yet.
|
||||
`main.c` prints alongside each significant event, never for scoring)
|
||||
and `"spread"."positions"` (each position's card + reversed flag, via
|
||||
`parse_spread()` — used by `main.c`'s full Celtic Cross readout and by
|
||||
`guidance.c`'s Attitude/Outcome framing, never for scoring). An
|
||||
aspect naming a planet/aspect-type slug it doesn't recognize, or a
|
||||
document missing `"transits"."aspects"` entirely (as opposed to a
|
||||
present-but-empty array, which is a valid "no aspects today"), makes
|
||||
the whole load fail; by contrast an unrecognized body/card/position
|
||||
slug, a missing `house` inside `"bodies"`, or a missing `"spread"` key
|
||||
entirely is silently skipped/zeroed (`main.c` checks for the body
|
||||
"no data" sentinel before printing sign/house), since all of that is
|
||||
supplementary display context rather than something scoring depends
|
||||
on.
|
||||
- **Mostly header-only dependency on the engine**: `significance.h`/
|
||||
`reading_io.h`/`narrative.h`/`guidance.h` `#include` engine headers
|
||||
(`reading.h`/`astro.h`/`tarot.h`) for the struct/enum *definitions*,
|
||||
and the root `Makefile` never compiles or links `astro.c`, `tarot.c`,
|
||||
`rng.c`, `reading.c`, or the vendored Astronomy Engine into the
|
||||
interpreter's binary or tests — every test in `interpreter/tests/`
|
||||
runs against hand-built JSON text or hand-built `DailyReading`/
|
||||
`DailyInterpretation` values, with no real ephemeris/tarot-draw call
|
||||
involved anywhere. Consequently `reading_io.c` (planet/aspect/sign/
|
||||
card/position slugs) and `main.c` (planet/aspect/sign display names)
|
||||
each duplicate a small local table rather than linking `astro.c` to
|
||||
reuse its own - keep those in sync if the engine's slugs ever change.
|
||||
`tarot_data.c`/`i18n.c` are the one exception, actually linked in (see
|
||||
above) - so card/position *names and meanings* are never duplicated;
|
||||
only the slugs `reading_io.c` needs for JSON parsing (which
|
||||
`tarot_data.c` doesn't expose a reverse lookup for) still are.
|
||||
- Only `Aspect`s compete for the top 5 (`SignificantItemKind` is
|
||||
currently just `ITEM_ASPECT`).
|
||||
|
||||
## Licensing
|
||||
|
||||
@@ -325,3 +412,14 @@ between the two binaries (see the next bullet).
|
||||
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
||||
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
||||
entries, which are original (see narrative.c's own comment for why).
|
||||
- Guidance text in `interpreter/src/guidance.c`: original, written for
|
||||
this project — no source to condense from, since neither Waite nor
|
||||
Sepharial discuss combining astrology and tarot. (The Attitude/Outcome
|
||||
card meanings that same paragraph quotes are Waite's own text via
|
||||
`tarot_card_meaning()`, covered by the `tarot_data.c` bullet above,
|
||||
not original text.)
|
||||
- `engine/i18n/de.lang`'s `narrative.*`/`guidance.*` entries are original
|
||||
German translations of the above two files' text, made for this
|
||||
project — same status as `de.lang`'s card/spread text (not taken from
|
||||
a specific published German edition of Sepharial, since none exists
|
||||
for this condensed, project-specific wording anyway).
|
||||
|
||||
Reference in New Issue
Block a user