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:
ml
2026-07-11 19:39:30 +02:00
parent 41422adf98
commit 1662d550c7
23 changed files with 1894 additions and 296 deletions
+162 -64
View File
@@ -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).