# 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. Currently only the
**core engine** exists: 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).

## Build & run

```bash
cd engine
make          # builds dist/deck-engine, dist/img/, dist/i18n/, dist/run.sh, dist/user.properties
make test     # builds and runs engine/tests/smoke_test.c
make clean    # removes build/ and the generated binary/img/i18n (never touches dist/user.properties)
```

Build output goes to `dist/`, a sibling of `engine/` and `res/` (not
inside `engine/`). 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.sh [text|html] [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.) and is **never
overwritten by later builds** — `run.sh` refuses to run (with a clear
error) until the placeholders are replaced with real values.

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), 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.sh, scripts/user.properties.template
    Makefile
  interpreter/                  Separate module + binary; see "Interpretation" below.
  dist/                         Build output (gitignored-style; see Build & run).
```

### 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.

### 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'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.

### 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.

## 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* — the first piece of a larger "turn the
raw reading into an actual narrative" effort described in project chat
history, not yet producing any narrative text. `deck-engine` itself is
deliberately unchanged by this beyond gaining `--format json`; the two
binaries compose over that JSON, never by linking together:

```bash
cd engine      && make          # -> dist/deck-engine (unchanged; --format json is new)
cd interpreter && make          # -> dist/interpreter-cli
cd interpreter && make test     # builds and runs both interpreter/tests/*_test.c

dist/deck-engine ... --format json | dist/interpreter-cli
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
```

- `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.
  `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.
- 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.
- **`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 just that (everything else is left
  zeroed - `interpret_daily_reading` doesn't read `natal`/`spread`
  either). 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 rather than silently dropping data.
- **Deliberately header-only dependency on the engine, throughout**:
  `significance.h`/`reading_io.h` `#include engine/src/reading.h` for the
  struct/enum *definitions*, but `interpreter/Makefile` never compiles or
  links any engine `.c` file (or the vendored Astronomy Engine) — 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
  slugs) and `main.c` (planet/aspect 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.

## Licensing

- Engine code (`engine/src/`, `engine/scripts/`, `engine/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.
