Files
deck_in_a_dash/CLAUDE.md
T
ml 41422adf98 Add narrative text and a day-significance rank to the interpreter
Each of the day's top significant transits now gets a short sentence
describing what it classically means and which area of life its house
governs, condensed from Sepharial's Transits and Planetary Periods
(1920, public domain) plus a small table of traditional house
significations. The day-level line also shows its rank out of the 4
defined levels, e.g. "Notable (2/4)". Docs (CLAUDE.md, README,
docs/input-output-format.md, docs/reading.md) updated to match,
including a full worked example in docs/reading.md.
2026-07-05 21:05:53 +02:00

328 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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 — 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 FisherYates 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, MercuryPluto (`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* 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:
```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
# 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
```
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).
- `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` — 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.
- **`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.
- **`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).
`"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.
## 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).