# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project goal

A Pebble smartwatch app that produces a daily personalized tarot (Celtic
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, deliberately built so it links
straight into Pebble C/UI watch code without rework (see "Portability to
the watch" below).

## Build & run

```bash
make          # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/,
              # dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test     # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make clean    # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
```

A single `Makefile` at the repo root builds both `engine/` and
`interpreter/` — there is no per-module Makefile, and no `cd` needed
before running `make`. Build output goes to `dist/`, a sibling of
`engine/`, `interpreter/`, and `res/`. There is no `make install`;
`dist/` is meant to be run in place.

Two ways to run a reading:

```bash
# 1. Direct CLI, all inputs explicit:
dist/deck-engine --seed "2026-07-03" \
  --birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
  --birth-lat 52.5200 --birth-lon 13.4050 \
  [--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] [--i18n-dir <path>]

# 2. dist/run-engine.sh [text|html|json] [lang] — wraps the binary for
#    daily use: seed and --date come from the OS clock (today's date /
#    current UTC time), birth data + default language are read from
#    dist/user.properties next to the script; the optional [lang] arg
#    overrides that language for a single run.
```

`dist/user.properties` is seeded once from `engine/scripts/user.properties.template`
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) — `run-engine.sh`/
`run-interpreter.sh` refuse to run (with a clear error) until the
placeholders are replaced with real values. Because `dist/` itself is
disposable (`make clean`, or just deleting the directory, wipes it), the
`scripts` Makefile target also keeps a durable backup outside `dist/`:
once `dist/user.properties` looks filled in (no leftover `YOUR_...`), every
build copies it out to `engine/scripts/user.properties.local`
(gitignored); if `dist/user.properties` is ever missing when a build
runs, it's restored from that backup instead of being reseeded from the
placeholder template. `dist/run-interpreter.sh` is the equivalent
daily-use wrapper for the interpreter: it runs `run-engine.sh`'s same
OS-clock seed/date and `user.properties` birth data through `deck-engine
--format json`, piped straight into `dist/interpreter-cli` — see
"Interpretation" below.

Run a single smoke test by editing `engine/tests/smoke_test.c`'s `main()`
temporarily, or just read its assertions — there's no test filter flag,
the whole suite runs in milliseconds.

## Architecture

```
deck_in_a_dash/
  res/                          Tarot card art (22 RWS Major Arcana JPEGs),
                                 Waite's "Pictorial Key to the Tarot" (PDF,
                                 public domain — source of all card/spread
                                 text), Sepharial's "Transits and Planetary
                                 Periods" (PDF, public domain — source of
                                 interpreter/src/narrative.c's text), logo,
                                 research notes.
  engine/
    third_party/astronomy/      Vendored cosinekitty/astronomy C library
                                 (MIT, pinned commit — see VENDORED.md).
                                 Unmodified from upstream.
    src/
      rng.h/.c                  Deterministic string-seeded PRNG.
      tarot.h/.c                Celtic Cross spread draw logic.
      tarot_data.c               22 cards' names/meanings + 10 position
                                 names/descriptions (Waite's own text).
      astro.h/.c                 Natal chart + daily transits.
      i18n.h/.c                   key=value translation catalog + English fallback.
      reading.h/.c               Ties tarot + astro together; text/HTML output.
      main.c                     CLI entry point.
    i18n/en.lang, i18n/de.lang   Translation source files, one per language.
    tests/smoke_test.c
    scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
  interpreter/                  Separate module + binary; see "Interpretation" below.
  dist/                         Build output (gitignored-style; see Build & run).
  Makefile                      Single root Makefile, builds both engine/ and interpreter/.
```

### Data flow / the real API

`reading_generate(seed, birth, utc_moment, &DailyReading)` in
`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`)

The Celtic Cross draw must be exactly reproducible from a seed string, on
any platform (including the watch's ARM core later), so `rng.c` is pure
integer arithmetic: FNV-1a hashes the seed string, splitmix64 streams
from it, and a partial Fisher–Yates shuffle (`tarot.c`) draws the 10
cards plus a reversal bit per card from that same stream, in one pass.

**Known-sharp-edge**: `rng_next_bounded`'s rejection-sampling limit must
stay a `uint64_t`. When `bound` evenly divides 2^32 (true for `bound ==
2`, used by every reversed-card coin flip), the true rejection threshold
*is* 2^32, which silently truncates to 0 in a `uint32_t` and turns the
loop into an infinite one. This exact bug shipped once already — if
you touch this function, keep the intermediate type 64-bit.

### Astrology (`astro.c`)

- Bodies: Sun, Moon, Mercury–Pluto (`Body` enum in `astro.h`, prefixed
  `PLANET_*` — deliberately *not* `BODY_*`, because Astronomy Engine's
  own `astro_body_t` already defines `BODY_SUN`, `BODY_MOON`, etc., and
  both headers are included together in `astro.c`).
- **Positions must go through `Astronomy_GeoVector` + `Astronomy_Ecliptic`,
  not `Astronomy_EclipticLongitude`.** The latter computes *heliocentric*
  longitude and outright rejects `BODY_SUN` (`ASTRO_INVALID_BODY`) —
  wrong for astrology, which needs apparent geocentric position. This
  also shipped once and was caught by the natal-Sun-sign smoke test.
- Houses use the **Whole Sign** system (house 1 = the Ascendant's sign,
  houses follow zodiacally) — no Placidus/Koch cusp trig. Astronomy
  Engine has no Ascendant function; it's computed directly in `astro.c`
  from sidereal time + a standard low-precision obliquity polynomial +
  the RAMC/obliquity/latitude identity (Duffett-Smith & Zwart).
- Every `PlanetPosition` (`astro.h`) carries a whole-sign `house` (1-12),
  via `assign_houses()`/`whole_sign_house()`. **`DailyTransits.bodies[*].house`
  is always relative to the *natal* Ascendant**, not a fresh "houses for
  right now" chart — that's the standard, personalized way transits are
  read (e.g. "transiting Jupiter is in your 5th house"), and it's why
  `astro_compute_daily_transits` takes the natal chart as a parameter in
  the first place (also used for aspect detection).
- Aspects (conjunction/sextile/square/trine/opposition) use an 8° orb
  when a luminary (Sun/Moon) is involved, 6° otherwise.

### Tarot (`tarot.c`, `tarot_data.c`)

Only the 22 Major Arcana are modelled (`TAROT_DECK_SIZE`) — matches the
art actually in `res/img/`. The 10 Celtic Cross positions
(`CelticCrossPosition` in `tarot.h`) follow **Waite's own original
drawing order** from *The Pictorial Key to the Tarot*, Part III §7 ("An
Ancient Celtic Method of Divination"): Present → Challenge → **Crown →
Foundation → Recent Past** → Near Future → Attitude → Environment →
Hopes/Fears → Outcome. Note this differs from the reordering used by
most modern tutorials (which typically put Foundation/Recent Past before
Crown) — `res/2026_07_03_celtic_cross_spread.txt` has an example of that
more common (but less original) ordering; don't use it as a reference for
the position enum order. Card and position text in `tarot_data.c` is
condensed from Waite's own wording in Part III §3 (public domain).

### Translations (`i18n.c`, `engine/i18n/*.lang`)

All display text (planet/sign/moon-phase/aspect names, tarot card names
and meanings, Celtic Cross position names, and `reading.c`'s section
headers) is looked up in a small process-global catalog loaded by
`i18n_load(path)` from a `key=value` file, via `i18n_get(key, fallback)`.
Every call site passes the hardcoded English string as `fallback`, so a
missing file, a missing key, or an untranslated language degrades to
English rather than showing a raw key or nothing — this is what lets
`engine/i18n/de.lang` (or any future language) be filled in incrementally.

Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c`
(`card.<slug>.name`, `card.<slug>.upright/reversed`,
`position.<slug>.name/desc`, `body.<slug>`, `sign.<slug>`,
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
own labels — deliberately keyed off separate slug strings rather than
the C enum names, so renaming an enumerator can never silently break a
`.lang` file. **To add a language**: copy `engine/i18n/en.lang` to
`<code>.lang` in the same directory (keys must match exactly) and
translate the values — no source changes needed, `make` picks up any
`*.lang` file there automatically.

`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 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 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*, 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 [--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 [text|html|json] [lang]
```

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
  score in `DailyInterpretation.top_items[]` (descending, evicting the
  rest), and classifies the day into a `DaySignificance` enum (`DAY_QUIET`
  ... `DAY_MAJOR`) from the single highest-scoring item. The enum also
  carries a trailing `DAY_SIGNIFICANCE_COUNT` sentinel (not a real level)
  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` — `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.
- **`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 —
  `res/Transits_and_Planetary_Periods.pdf`), Chapter VIII "Effects of
  Transits". Per transiting planet it holds a `base` description plus a
  `harmonious`/`discordant` addendum, shown only under a trine/sextile or
  square/opposition respectively (either may be `""` if the source gives
  none); a conjunction is treated as neutral — base only — since
  Sepharial's own text says a conjunction "takes the nature of what it
  conjoins," which isn't modelled here. The Moon and Pluto aren't in that
  chapter (fast lunar transits were out of scope for a year-ahead
  forecasting book; Pluto wasn't discovered until 1930) — their text is
  original, written for this project, not drawn from Sepharial. Each
  narrative is also framed by *where* it's happening: `narrative_print()`
  takes the transiting planet's whole-sign house (1-12, relative to the
  natal chart, same as everywhere else in this codebase) and prefixes an
  "In matters of ..." line naming that house's traditional area of life,
  from a small `k_house_area[12]` table of standard whole-sign house
  significations — centuries-old convention, not attributed to any single
  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. 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
  (`astro.h`/`tarot.h`) rather than `astro_*_name()`/`tarot_*_name()` —
  the JSON must stay identical regardless of `--lang`, since it's a
  machine interchange format, not display text.
- **`json.c`/`json.h`** is a small hand-rolled recursive-descent JSON
  parser (object/array/string/number/bool/null) - not a general-purpose
  validating library, just enough to parse `deck-engine`'s own output.
  Unlike the core engine, it uses `malloc`; `interpreter-cli` was never
  meant to run on the watch itself.
- **`reading_io.c`** walks the parsed JSON down to `"transits"."aspects"`
  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)
  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

- Engine code (`engine/src/`, `engine/scripts/`) and the root `Makefile`:
  MIT, per the repo's top-level `LICENSE`.
- `engine/third_party/astronomy/`: vendored MIT code, unmodified — see
  `VENDORED.md` for the pinned upstream commit.
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
  from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
  domain.
- `engine/i18n/de.lang`'s card/spread text is an original translation of
  that same public-domain English text, made for this project — not
  taken from any specific published German edition.
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
  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).
