diff --git a/.gitignore b/.gitignore index 36e541f..340ac32 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # data once filled in — never commit it. /dist /engine/build +/interpreter/build *.o core diff --git a/CLAUDE.md b/CLAUDE.md index 41090b1..bb4133c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Two ways to run a reading: 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] [--lang en|de] [--i18n-dir ] + [--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] [--i18n-dir ] # 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), @@ -76,6 +76,7 @@ deck_in_a_dash/ 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). ``` @@ -181,6 +182,73 @@ 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`): diff --git a/README.md b/README.md index 12dc4f1..f8c12f1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ engine/ scripts/ run.sh and the user.properties template. Makefile dist/ Build output (gitignored) — see below. +interpreter/ Separate module + binary (dist/interpreter-cli): + scores how significant a day's transits are, + reading deck-engine's --format json output + (see CLAUDE.md). docs/ Architecture/dataflow diagrams, input/output format reference. ``` @@ -84,7 +88,16 @@ dist/run.sh html de # override the language for this run 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] [--lang en|de] + [--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] +``` + +**Interpreter** (`interpreter/`, built separately with `cd interpreter && make`): +scores how significant a day's transits are, reading `deck-engine`'s +`--format json` output — the two binaries compose over that JSON, they're +never linked together: + +```bash +dist/deck-engine ... --format json | dist/interpreter-cli ``` ## License diff --git a/docs/input-output-format.md b/docs/input-output-format.md index 74e233e..d0cb4b0 100644 --- a/docs/input-output-format.md +++ b/docs/input-output-format.md @@ -2,9 +2,11 @@ See [`architecture.md`](architecture.md) for how these fit together. There are two ways to provide input (direct CLI flags, or `run.sh` + -`user.properties`) and two output formats (`text`, `html`); the -`DailyReading` struct is the programmatic form both outputs are rendered -from, and the one the future watchapp will consume directly. +`user.properties`) and three output formats (`text`, `html`, `json`); the +`DailyReading` struct is the programmatic form all three are rendered +from, and the one the future watchapp will consume directly. `json` is +also `interpreter-cli`'s input format — see "The interpreter" at the +bottom of this file. ## Input @@ -13,7 +15,7 @@ from, and the one the future watchapp will consume directly. ``` deck-engine --seed --birth-date YYYY-MM-DD --birth-time HH:MM --birth-utc-offset --birth-lat --birth-lon - [--date YYYY-MM-DDTHH:MM] [--format text|html] + [--date YYYY-MM-DDTHH:MM] [--format text|html|json] ``` | Flag | Required | Format | Meaning | @@ -25,7 +27,7 @@ deck-engine --seed --birth-date YYYY-MM-DD --birth-time HH:MM | `--birth-lat` | yes | decimal degrees | Birth latitude, north positive. | | `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. | | `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. | -| `--format` | no | `text` \| `html` | Output format, defaults to `text`. | +| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. | | `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. | | `--i18n-dir` | no | path | Directory to look for `.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). | @@ -156,3 +158,93 @@ cards rendered as an image: image links. - Reversed cards get `class="reversed"`, styled with `transform: rotate(180deg)` in the page's inline CSS. + +### `--format json` + +A single JSON object mirroring `DailyReading` exactly — `natal`, +`transits`, `spread` — using the **slug** accessors +(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`, +`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`) +rather than the display-name ones, so the output is identical regardless +of `--lang` — this is the one format meant for another program to parse +(`dist/interpreter-cli`, see "The interpreter" below), not for a human to +read. + +```json +{ + "natal": { + "bodies": [ + {"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3}, + ... + ], + "ascendant_longitude": 348.4123, + "houses": ["pisces", "aries", ...] + }, + "transits": { + "bodies": [ + {"body": "sun", "sign": "cancer", "degree_in_sign": 11.5012, "ecliptic_longitude": 101.5012, "house": 5}, + ... + ], + "moon_phase": "waning_gibbous", + "aspects": [ + {"transiting_planet": "sun", "natal_planet": "moon", "type": "opposition", "orb": 3.7021}, + ... + ] + }, + "spread": { + "positions": [ + {"position": "present", "card": "high_priestess", "reversed": true}, + ... + ] + } +} +``` + +- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum + order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in + `CelticCrossPosition` enum order (Present, Challenge, Crown, + Foundation, Recent Past, Near Future, Attitude, Environment, Hopes and + Fears, Outcome — see `CLAUDE.md`'s "Tarot" section for why that's not + the order most tutorials use). +- `natal.houses[i]` is the sign occupying whole-sign house `i + 1`; + `houses[0]` is the Ascendant's own sign. `transits.bodies[*].house` is + a house number (1-12) into the **natal** chart, not a fresh "houses for + right now" chart — same rule as the `text`/`html` output. +- All angles are decimal degrees, `%.4f`. `aspects` may be an empty array + (a valid "no aspects today"), but the key itself is always present. + +## The interpreter (`interpreter/`, `dist/interpreter-cli`) + +`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s +"Interpretation" section for the full architecture — that reads a +`--format json` document and scores how significant today's transits +are. It never links the engine; it only depends on the JSON shape +above. + +```bash +dist/deck-engine ... --format json | dist/interpreter-cli +# or: +dist/deck-engine ... --format json > reading.json +dist/interpreter-cli reading.json +``` + +- **Input**: a file path argument, or stdin if no argument (or `-`) is + given. Only `"transits"."aspects"` is read — `natal` and `spread` may + be present (and are ignored) or omitted entirely, except that + `"transits"."aspects"` itself must be present (an empty array is valid + and means "no notable transits today"; a missing key is treated as + invalid input). +- **Output**: a plain-text report — the day's overall significance level + (`Quiet`/`Notable`/`Significant`/`Major`), a "worth a deeper Celtic + Cross look" line on `Major` days only, and up to 5 of today's aspects + ranked by score: + + ``` + Day significance: Significant + (a major transit today - worth a deeper Celtic Cross look) + + Top 3 significant transits: + 1. Transiting Saturn Square natal Sun (orb 0.5°, score 8.10) + 2. Transiting Pluto Opposition natal Moon (orb 0.2°, score 7.92) + 3. Transiting Jupiter Trine natal Venus (orb 2.1°, score 3.40) + ``` diff --git a/docs/reading.md b/docs/reading.md new file mode 100644 index 0000000..1cb213b --- /dev/null +++ b/docs/reading.md @@ -0,0 +1,100 @@ +# Your daily reading, explained + +This page describes what actually happens when you get a reading — +no code, no jargon you haven't already agreed to. If you just want to +know "what am I looking at", start here. + +## The one-time setup: telling it who you are + +Before you can get a personal reading, you tell it a few things about +your birth, once: + +- the **date** and **time** you were born, and which time zone that + clock time was in +- **where** you were born (latitude/longitude) + +That's it. This information never changes and is only used to work out +exactly where the planets were, and which zodiac sign was rising, at the +moment you were born — your **natal chart**. Everything else the app +does is compared against that one fixed reference point. + +## What happens every day + +Each day, the app does two independent things and then combines them: + +1. **It looks at the sky right now** and compares it to your natal + chart from step one — this tells you what's changing today, + relative to your own personal baseline (not a generic horoscope). +2. **It shuffles a tarot deck**, using today's date as the shuffle + "seed". This means: if you check your reading three times on the + same day, you get the exact same 10 cards, in the same order, every + time — it only changes when the date changes. There's no randomness + involved beyond that; today's date fully determines today's spread. + +## The three parts of a reading + +### 1. Your natal chart + +A snapshot of the sky at the moment you were born: where the Sun, +Moon, and each planet were, which zodiac sign was rising on the +eastern horizon at that moment (your **Ascendant**), and which of the +12 astrological "houses" each planet falls into as a result. Think of +this as your personal baseline — it doesn't change day to day, and +every other part of the reading is measured against it. + +### 2. Today's sky (your "transits") + +Where the Sun, Moon, and planets are positioned *today*, plus: + +- the current **Moon phase** (New, Waxing Crescent, Full, and so on) +- any **aspects** — meaningful angles formed between where a planet is + today and where a planet sat in your natal chart. For example, "the + Sun is opposite your natal Moon today" is one aspect. These are the + traditional signals astrologers use to say a day is emotionally + charged, a good day for decisions, a day of tension, etc. Not every + day has aspects; some days are quiet. + +Each planet's position today is also placed into one of *your* 12 +houses (not a fresh chart for right now) — that's the standard way +astrologers read "what house is this transit happening in for me". + +### 3. Your tarot spread (the Celtic Cross) + +A classic 10-card tarot layout, drawn from the 22 Major Arcana cards +(The Fool, The Magician, and so on through The World). Each of the 10 +positions asks a different question about your day or situation — for +example, "what covers you right now", "what crosses/challenges you", +"your hopes and fears", "the likely outcome". Every card can land +**upright** or **reversed**, which traditionally shades or inverts its +meaning. The order and meanings of the 10 positions, and the meaning +of each card, come from A. E. Waite's original 1911 description of the +spread — the same source most modern tarot decks trace back to. + +## How "big" is today? + +Not every day is equally eventful astrologically. A separate, +optional step can look at today's aspects (part 2, above) and works +out an overall **significance level** for the day — Quiet, Notable, +Significant, or Major — based on which planets are involved, how exact +the angle is, and whether it touches your Sun or Moon (the two most +personally weighted points in a chart). On a genuinely Major day, it +also calls out the single most important thing happening, as a hint +that it's worth paying closer attention to your tarot spread that day +rather than treating it as routine. Quiet days still get a full +reading — this step only adds a "pay attention today" flag, it never +removes anything. + +## What a day's reading actually gives you + +Put together, one day's reading contains: your natal chart (for +reference), today's sky and how it's speaking to your chart, how +significant today is overall, and your 10-card Celtic Cross spread for +the day. Right now these sit side by side — the astrology tells you +*how eventful* today is and *where* to pay attention, and the tarot +spread gives you the actual guidance to sit with. They don't yet get +woven into a single narrative (e.g. a spread explained *in light of* +today's significant transit) — that combined storytelling is the next +piece to build, not something you get today. + +*(This page, like that piece, is still a work in progress — expect it +to grow as the reading itself does.)* diff --git a/engine/src/astro.c b/engine/src/astro.c index e9f8e62..b8f17d3 100644 --- a/engine/src/astro.c +++ b/engine/src/astro.c @@ -231,3 +231,8 @@ const char *astro_moon_phase_name(MoonPhaseName phase) { const char *astro_aspect_name(AspectType type) { return lookup("aspect.", k_aspect_slug[type], k_aspect_names[type]); } + +const char *astro_body_slug(Body body) { return k_body_slug[body]; } +const char *astro_sign_slug(ZodiacSign sign) { return k_sign_slug[sign]; } +const char *astro_moon_phase_slug(MoonPhaseName phase) { return k_moon_phase_slug[phase]; } +const char *astro_aspect_slug(AspectType type) { return k_aspect_slug[type]; } diff --git a/engine/src/astro.h b/engine/src/astro.h index 14c88df..47c683b 100644 --- a/engine/src/astro.h +++ b/engine/src/astro.h @@ -104,4 +104,13 @@ const char *astro_sign_name(ZodiacSign sign); const char *astro_moon_phase_name(MoonPhaseName phase); const char *astro_aspect_name(AspectType type); +/* Stable, language-independent identifiers (e.g. "sun", "waxing_crescent", + * "square") - the same strings i18n.c's lookup keys are built from. Used + * for machine-readable output (reading_print_json) that must stay + * identical regardless of --lang, unlike astro_*_name() above. */ +const char *astro_body_slug(Body body); +const char *astro_sign_slug(ZodiacSign sign); +const char *astro_moon_phase_slug(MoonPhaseName phase); +const char *astro_aspect_slug(AspectType type); + #endif diff --git a/engine/src/main.c b/engine/src/main.c index b0581b5..dd1884c 100644 --- a/engine/src/main.c +++ b/engine/src/main.c @@ -8,13 +8,13 @@ #include "reading.h" #include "i18n.h" -typedef enum { FORMAT_TEXT, FORMAT_HTML } OutputFormat; +typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat; static void print_usage(const char *prog) { fprintf(stderr, "Usage: %s --seed --birth-date YYYY-MM-DD --birth-time HH:MM\n" " --birth-utc-offset --birth-lat --birth-lon \n" - " [--date YYYY-MM-DDTHH:MM] [--format text|html]\n" + " [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n" " [--lang ] [--i18n-dir ]\n\n" " --seed Tarot seed string; same seed -> same Celtic Cross spread.\n" " --birth-date/time Birth date/time in local clock time.\n" @@ -23,7 +23,9 @@ static void print_usage(const char *prog) { " --date UTC moment for today's transits/tarot draw. Defaults to now.\n" " --format Output format, defaults to text. html links card art via\n" " img/, i.e. it expects to be saved next to the img/\n" - " directory that `make images` copies into dist/.\n" + " directory that `make images` copies into dist/. json uses\n" + " stable, language-independent identifiers (not --lang's\n" + " display text) - it's the interpreter/ module's input format.\n" " --lang Reading language code, defaults to en. Matches a file\n" " named .lang in --i18n-dir (see engine/i18n/).\n" " --i18n-dir Directory to look up .lang in. Defaults to the\n" @@ -128,8 +130,9 @@ int main(int argc, char **argv) { OutputFormat format; if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT; else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML; + else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON; else { - fprintf(stderr, "Invalid --format, expected text or html\n"); + fprintf(stderr, "Invalid --format, expected text, html, or json\n"); return 1; } @@ -149,6 +152,8 @@ int main(int argc, char **argv) { if (format == FORMAT_HTML) { reading_print_html(&reading, stdout); + } else if (format == FORMAT_JSON) { + reading_print_json(&reading, stdout); } else { reading_print_text(&reading, stdout); } diff --git a/engine/src/reading.c b/engine/src/reading.c index 21db421..f3c8eac 100644 --- a/engine/src/reading.c +++ b/engine/src/reading.c @@ -142,3 +142,55 @@ void reading_print_html(const DailyReading *r, FILE *out) { } fprintf(out, "\n\n"); } + +static void print_body_position_json(FILE *out, const char *indent, Body body, + const PlanetPosition *p) { + fprintf(out, + "%s{\"body\": \"%s\", \"sign\": \"%s\", \"degree_in_sign\": %.4f, " + "\"ecliptic_longitude\": %.4f, \"house\": %d}", + indent, astro_body_slug(body), astro_sign_slug(p->sign), + p->degree_in_sign, p->ecliptic_longitude, p->house); +} + +void reading_print_json(const DailyReading *r, FILE *out) { + fprintf(out, "{\n"); + + fprintf(out, " \"natal\": {\n \"bodies\": [\n"); + for (int b = 0; b < NUM_BODIES; b++) { + print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]); + fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : ""); + } + fprintf(out, " ],\n \"ascendant_longitude\": %.4f,\n \"houses\": [", + r->natal.ascendant_longitude); + for (int h = 0; h < 12; h++) { + fprintf(out, "\"%s\"%s", astro_sign_slug(r->natal.houses[h]), h + 1 < 12 ? ", " : ""); + } + fprintf(out, "]\n },\n"); + + fprintf(out, " \"transits\": {\n \"bodies\": [\n"); + for (int b = 0; b < NUM_BODIES; b++) { + print_body_position_json(out, " ", (Body)b, &r->transits.bodies[b]); + fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : ""); + } + fprintf(out, " ],\n \"moon_phase\": \"%s\",\n \"aspects\": [\n", + astro_moon_phase_slug(r->transits.moon_phase)); + for (int i = 0; i < r->transits.aspect_count; i++) { + const Aspect *a = &r->transits.aspects[i]; + fprintf(out, + " {\"transiting_planet\": \"%s\", \"natal_planet\": \"%s\", " + "\"type\": \"%s\", \"orb\": %.4f}%s\n", + astro_body_slug(a->transiting_planet), astro_body_slug(a->natal_planet), + astro_aspect_slug(a->type), a->orb, + i + 1 < r->transits.aspect_count ? "," : ""); + } + fprintf(out, " ]\n },\n"); + + fprintf(out, " \"spread\": {\n \"positions\": [\n"); + for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { + const TarotDraw *draw = &r->spread.positions[i]; + fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s}%s\n", + tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card), + draw->reversed ? "true" : "false", i + 1 < TAROT_SPREAD_SIZE ? "," : ""); + } + fprintf(out, " ]\n }\n}\n"); +} diff --git a/engine/src/reading.h b/engine/src/reading.h index 06c1e31..d295a75 100644 --- a/engine/src/reading.h +++ b/engine/src/reading.h @@ -23,4 +23,10 @@ void reading_generate(const char *tarot_seed, const BirthData *birth, void reading_print_text(const DailyReading *r, FILE *out); void reading_print_html(const DailyReading *r, FILE *out); +/* Machine-readable serialization of the full struct, using the + * language-independent *_slug() accessors (never astro_*_name()/ + * tarot_*_name()) so the output is identical regardless of --lang. This + * is the interchange format interpreter/ reads - see its json.c. */ +void reading_print_json(const DailyReading *r, FILE *out); + #endif diff --git a/engine/src/tarot.h b/engine/src/tarot.h index bde1756..1f897a4 100644 --- a/engine/src/tarot.h +++ b/engine/src/tarot.h @@ -70,4 +70,11 @@ const char *tarot_card_meaning(TarotCard card, bool reversed); const char *tarot_position_name(CelticCrossPosition position); const char *tarot_position_description(CelticCrossPosition position); +/* Stable, language-independent identifiers (e.g. "high_priestess", + * "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup + * keys are built from. Used for machine-readable output + * (reading_print_json) that must stay identical regardless of --lang. */ +const char *tarot_card_slug(TarotCard card); +const char *tarot_position_slug(CelticCrossPosition position); + #endif diff --git a/engine/src/tarot_data.c b/engine/src/tarot_data.c index 19b782a..1809be3 100644 --- a/engine/src/tarot_data.c +++ b/engine/src/tarot_data.c @@ -259,3 +259,6 @@ const char *tarot_position_name(CelticCrossPosition position) { const char *tarot_position_description(CelticCrossPosition position) { return position_field(k_position_slug[position], "desc", k_positions[position].description); } + +const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; } +const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; } diff --git a/engine/tests/smoke_test.c b/engine/tests/smoke_test.c index 7bdf339..6d4cfee 100644 --- a/engine/tests/smoke_test.c +++ b/engine/tests/smoke_test.c @@ -94,6 +94,49 @@ static void test_reading_generate_smoke(void) { printf("PASS test_reading_generate_smoke\n"); } +static void test_reading_print_json_shape(void) { + BirthData birth = { + .year = 1990, .month = 5, .day = 14, + .hour = 14, .minute = 32, + .utc_offset_hours = 2.0, + .latitude = 52.52, .longitude = 13.405, + }; + + DailyReading r; + reading_generate("smoke-test-seed", &birth, 1751500000, &r); + + FILE *tmp = tmpfile(); + assert(tmp); + reading_print_json(&r, tmp); + + rewind(tmp); + char buf[16384]; + size_t n = fread(buf, 1, sizeof buf - 1, tmp); + buf[n] = '\0'; + fclose(tmp); + + /* Loose structural checks - not a full JSON validator, just enough to + * catch a broken serializer (unbalanced braces, a missing section, or + * a stray astro_*_name()/tarot_*_name() call leaking translated text + * into what must stay language-independent regardless of --lang). */ + assert(buf[0] == '{'); + assert(strstr(buf, "\"natal\"") != NULL); + assert(strstr(buf, "\"transits\"") != NULL); + assert(strstr(buf, "\"spread\"") != NULL); + assert(strstr(buf, "\"aspects\"") != NULL); + assert(strstr(buf, "\"body\": \"sun\"") != NULL); /* slug, not the display name */ + assert(strstr(buf, "\"Sun\"") == NULL); /* never a *_name() value */ + + int braces = 0; + for (size_t i = 0; i < n; i++) { + if (buf[i] == '{') braces++; + if (buf[i] == '}') braces--; + } + assert(braces == 0); + + printf("PASS test_reading_print_json_shape\n"); +} + /* Run with cwd == engine/ (as `make test` does), so the shipped * engine/i18n/ .lang files are reachable as i18n/.lang. */ static void test_i18n_fallback_and_translation(void) { @@ -130,6 +173,7 @@ int main(void) { test_tarot_determinism(); test_natal_sun_sign(); test_reading_generate_smoke(); + test_reading_print_json_shape(); test_i18n_fallback_and_translation(); printf("All smoke tests passed.\n"); return 0; diff --git a/interpreter/Makefile b/interpreter/Makefile new file mode 100644 index 0000000..c4ab796 --- /dev/null +++ b/interpreter/Makefile @@ -0,0 +1,54 @@ +CC ?= cc +CFLAGS ?= -std=c99 -Wall -Wextra -O2 + +SRC_DIR := src +TEST_DIR := tests +BUILD_DIR := build +OBJ_DIR := $(BUILD_DIR)/obj + +# Shares deck-engine's build output directory (see ../engine/Makefile), +# so a daily run can find both binaries in one place: +# dist/deck-engine ... --format json | dist/interpreter-cli +DIST_DIR := ../dist +BINARY := $(DIST_DIR)/interpreter-cli + +# Only ever compiles this module's own sources - significance.h/ +# reading_io.h reach into engine/src/ for struct/enum *definitions* +# (plain #includes), but no engine/src/*.c (or the vendored Astronomy +# Engine) is compiled or linked here. That's what keeps this module (and +# its tests) independent of the engine's actual ephemeris/tarot-draw +# implementation - see significance.c's and reading_io.c's own comments +# for why each duplicates a small table instead of linking astro.c. +LIB_SRCS := $(SRC_DIR)/significance.c $(SRC_DIR)/json.c $(SRC_DIR)/reading_io.c +LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(LIB_SRCS)) +MAIN_OBJ := $(OBJ_DIR)/$(SRC_DIR)/main.o + +SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(TEST_DIR)/significance_test.o +JSON_TEST_OBJ := $(OBJ_DIR)/$(TEST_DIR)/json_test.o +SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test +JSON_TEST_BIN := $(BUILD_DIR)/json_test + +.PHONY: all test clean + +all: $(BINARY) + +$(BINARY): $(LIB_OBJS) $(MAIN_OBJ) + @mkdir -p $(DIST_DIR) + $(CC) $(CFLAGS) -o $@ $^ + +test: $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) + ./$(SIGNIFICANCE_TEST_BIN) + ./$(JSON_TEST_BIN) + +$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ) + $(CC) $(CFLAGS) -o $@ $^ + +$(JSON_TEST_BIN): $(OBJ_DIR)/$(SRC_DIR)/json.o $(OBJ_DIR)/$(SRC_DIR)/reading_io.o $(JSON_TEST_OBJ) + $(CC) $(CFLAGS) -o $@ $^ + +$(OBJ_DIR)/%.o: %.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +clean: + rm -rf $(BUILD_DIR) $(BINARY) diff --git a/interpreter/src/json.c b/interpreter/src/json.c new file mode 100644 index 0000000..c1267f2 --- /dev/null +++ b/interpreter/src/json.c @@ -0,0 +1,368 @@ +#include "json.h" + +#include +#include + +typedef struct { + const char *text; + size_t length; + size_t pos; + bool error; +} Cursor; + +static void skip_whitespace(Cursor *c) { + while (c->pos < c->length) { + char ch = c->text[c->pos]; + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') c->pos++; + else break; + } +} + +static char peek(Cursor *c) { + return c->pos < c->length ? c->text[c->pos] : '\0'; +} + +static JsonValue *json_value_new(JsonType type) { + JsonValue *v = calloc(1, sizeof(JsonValue)); + v->type = type; + return v; +} + +static JsonValue *parse_value(Cursor *c); + +/* Dynamically-growable byte buffer, used to build a parsed string + * (including \uXXXX escapes re-encoded as UTF-8). */ +typedef struct { + char *data; + size_t len; + size_t cap; +} StringBuf; + +static void sbuf_init(StringBuf *b) { + b->cap = 32; + b->len = 0; + b->data = malloc(b->cap); + b->data[0] = '\0'; +} + +static void sbuf_push(StringBuf *b, char ch) { + if (b->len + 2 > b->cap) { + b->cap *= 2; + b->data = realloc(b->data, b->cap); + } + b->data[b->len++] = ch; + b->data[b->len] = '\0'; +} + +static void sbuf_push_utf8(StringBuf *b, unsigned int codepoint) { + if (codepoint <= 0x7F) { + sbuf_push(b, (char)codepoint); + } else if (codepoint <= 0x7FF) { + sbuf_push(b, (char)(0xC0 | (codepoint >> 6))); + sbuf_push(b, (char)(0x80 | (codepoint & 0x3F))); + } else { + sbuf_push(b, (char)(0xE0 | (codepoint >> 12))); + sbuf_push(b, (char)(0x80 | ((codepoint >> 6) & 0x3F))); + sbuf_push(b, (char)(0x80 | (codepoint & 0x3F))); + } +} + +static int hex_digit(char ch) { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +static char *parse_string_raw(Cursor *c) { + if (peek(c) != '"') { + c->error = true; + return NULL; + } + c->pos++; + + StringBuf buf; + sbuf_init(&buf); + + while (c->pos < c->length) { + char ch = c->text[c->pos]; + if (ch == '"') { + c->pos++; + return buf.data; + } + if (ch == '\\') { + c->pos++; + if (c->pos >= c->length) break; + char esc = c->text[c->pos]; + switch (esc) { + case '"': sbuf_push(&buf, '"'); c->pos++; break; + case '\\': sbuf_push(&buf, '\\'); c->pos++; break; + case '/': sbuf_push(&buf, '/'); c->pos++; break; + case 'b': sbuf_push(&buf, '\b'); c->pos++; break; + case 'f': sbuf_push(&buf, '\f'); c->pos++; break; + case 'n': sbuf_push(&buf, '\n'); c->pos++; break; + case 'r': sbuf_push(&buf, '\r'); c->pos++; break; + case 't': sbuf_push(&buf, '\t'); c->pos++; break; + case 'u': { + c->pos++; + if (c->pos + 4 > c->length) { + c->error = true; + free(buf.data); + return NULL; + } + unsigned int cp = 0; + for (int i = 0; i < 4; i++) { + int d = hex_digit(c->text[c->pos + (size_t)i]); + if (d < 0) { + c->error = true; + free(buf.data); + return NULL; + } + cp = (cp << 4) | (unsigned int)d; + } + c->pos += 4; + sbuf_push_utf8(&buf, cp); + break; + } + default: + c->error = true; + free(buf.data); + return NULL; + } + } else { + sbuf_push(&buf, ch); + c->pos++; + } + } + + c->error = true; + free(buf.data); + return NULL; +} + +static JsonValue *parse_string(Cursor *c) { + char *s = parse_string_raw(c); + if (!s) return NULL; + JsonValue *v = json_value_new(JSON_STRING); + v->as.string = s; + return v; +} + +static JsonValue *parse_number(Cursor *c) { + const char *start = c->text + c->pos; + char *end = NULL; + double val = strtod(start, &end); + if (end == start) { + c->error = true; + return NULL; + } + c->pos += (size_t)(end - start); + JsonValue *v = json_value_new(JSON_NUMBER); + v->as.number = val; + return v; +} + +static bool match_literal(Cursor *c, const char *literal) { + size_t len = strlen(literal); + if (c->pos + len > c->length) return false; + if (strncmp(c->text + c->pos, literal, len) != 0) return false; + c->pos += len; + return true; +} + +static JsonValue *parse_array(Cursor *c) { + c->pos++; /* consume '[' */ + JsonValue *v = json_value_new(JSON_ARRAY); + int cap = 0; + + skip_whitespace(c); + if (peek(c) == ']') { + c->pos++; + return v; + } + + while (true) { + skip_whitespace(c); + JsonValue *item = parse_value(c); + if (!item) { + json_free(v); + return NULL; + } + + if (v->as.array.count >= cap) { + cap = cap == 0 ? 4 : cap * 2; + v->as.array.items = realloc(v->as.array.items, (size_t)cap * sizeof(JsonValue *)); + } + v->as.array.items[v->as.array.count++] = item; + + skip_whitespace(c); + char ch = peek(c); + if (ch == ',') { + c->pos++; + continue; + } + if (ch == ']') { + c->pos++; + break; + } + c->error = true; + json_free(v); + return NULL; + } + return v; +} + +static JsonValue *parse_object(Cursor *c) { + c->pos++; /* consume '{' */ + JsonValue *v = json_value_new(JSON_OBJECT); + int cap = 0; + + skip_whitespace(c); + if (peek(c) == '}') { + c->pos++; + return v; + } + + while (true) { + skip_whitespace(c); + if (peek(c) != '"') { + c->error = true; + json_free(v); + return NULL; + } + char *key = parse_string_raw(c); + if (!key) { + json_free(v); + return NULL; + } + + skip_whitespace(c); + if (peek(c) != ':') { + c->error = true; + free(key); + json_free(v); + return NULL; + } + c->pos++; + skip_whitespace(c); + + JsonValue *val = parse_value(c); + if (!val) { + free(key); + json_free(v); + return NULL; + } + + if (v->as.object.count >= cap) { + cap = cap == 0 ? 4 : cap * 2; + v->as.object.keys = realloc(v->as.object.keys, (size_t)cap * sizeof(char *)); + v->as.object.values = realloc(v->as.object.values, (size_t)cap * sizeof(JsonValue *)); + } + v->as.object.keys[v->as.object.count] = key; + v->as.object.values[v->as.object.count] = val; + v->as.object.count++; + + skip_whitespace(c); + char ch = peek(c); + if (ch == ',') { + c->pos++; + continue; + } + if (ch == '}') { + c->pos++; + break; + } + c->error = true; + json_free(v); + return NULL; + } + return v; +} + +static JsonValue *parse_value(Cursor *c) { + skip_whitespace(c); + char ch = peek(c); + if (ch == '{') return parse_object(c); + if (ch == '[') return parse_array(c); + if (ch == '"') return parse_string(c); + if (ch == '-' || (ch >= '0' && ch <= '9')) return parse_number(c); + if (match_literal(c, "true")) { + JsonValue *v = json_value_new(JSON_BOOL); + v->as.boolean = true; + return v; + } + if (match_literal(c, "false")) { + JsonValue *v = json_value_new(JSON_BOOL); + v->as.boolean = false; + return v; + } + if (match_literal(c, "null")) return json_value_new(JSON_NULL); + c->error = true; + return NULL; +} + +JsonValue *json_parse(const char *text, size_t length) { + Cursor c = {text, length, 0, false}; + JsonValue *root = parse_value(&c); + if (!root) return NULL; + + skip_whitespace(&c); + if (c.pos != c.length) { + json_free(root); + return NULL; + } + return root; +} + +void json_free(JsonValue *value) { + if (!value) return; + switch (value->type) { + case JSON_STRING: + free(value->as.string); + break; + case JSON_ARRAY: + for (int i = 0; i < value->as.array.count; i++) json_free(value->as.array.items[i]); + free(value->as.array.items); + break; + case JSON_OBJECT: + for (int i = 0; i < value->as.object.count; i++) { + free(value->as.object.keys[i]); + json_free(value->as.object.values[i]); + } + free(value->as.object.keys); + free(value->as.object.values); + break; + default: + break; + } + free(value); +} + +const JsonValue *json_object_get(const JsonValue *value, const char *key) { + if (!value || value->type != JSON_OBJECT) return NULL; + for (int i = 0; i < value->as.object.count; i++) { + if (strcmp(value->as.object.keys[i], key) == 0) return value->as.object.values[i]; + } + return NULL; +} + +int json_array_count(const JsonValue *value) { + if (!value || value->type != JSON_ARRAY) return 0; + return value->as.array.count; +} + +const JsonValue *json_array_get(const JsonValue *value, int index) { + if (!value || value->type != JSON_ARRAY) return NULL; + if (index < 0 || index >= value->as.array.count) return NULL; + return value->as.array.items[index]; +} + +double json_as_number(const JsonValue *value) { + if (!value || value->type != JSON_NUMBER) return 0.0; + return value->as.number; +} + +const char *json_as_string(const JsonValue *value) { + if (!value || value->type != JSON_STRING) return ""; + return value->as.string; +} diff --git a/interpreter/src/json.h b/interpreter/src/json.h new file mode 100644 index 0000000..606f3e1 --- /dev/null +++ b/interpreter/src/json.h @@ -0,0 +1,55 @@ +#ifndef DECK_JSON_H +#define DECK_JSON_H + +#include +#include + +/* Minimal, hand-rolled JSON reader - just enough to parse deck-engine's + * own --format json output (engine/src/reading.c's reading_print_json). + * Not a general-purpose/validating JSON library, and unlike the core + * engine this is desktop-only (uses malloc) - the interpreter binary was + * never meant to run on the watch itself. */ + +typedef enum { + JSON_NULL, + JSON_BOOL, + JSON_NUMBER, + JSON_STRING, + JSON_ARRAY, + JSON_OBJECT +} JsonType; + +typedef struct JsonValue JsonValue; + +struct JsonValue { + JsonType type; + union { + bool boolean; + double number; + char *string; + struct { JsonValue **items; int count; } array; + struct { char **keys; JsonValue **values; int count; } object; + } as; +}; + +/* Parses `text` into a JsonValue tree; `text` must be null-terminated at + * text[length] (every caller here reads a whole file/stream into a + * malloc'd buffer and NUL-terminates it before calling this). Returns + * NULL on malformed input. On success the caller owns the result and + * must free it with json_free(). */ +JsonValue *json_parse(const char *text, size_t length); +void json_free(JsonValue *value); + +/* NULL if `key` isn't present or `value` isn't an object. */ +const JsonValue *json_object_get(const JsonValue *value, const char *key); +/* 0 if `value` isn't an array. */ +int json_array_count(const JsonValue *value); +/* NULL if `value` isn't an array or `index` is out of range. */ +const JsonValue *json_array_get(const JsonValue *value, int index); +/* 0.0 / "" if `value` is NULL or the wrong type - callers that need to + * distinguish "missing" from "present but zero" should check + * json_object_get()'s/json_array_get()'s NULL return first. */ +double json_as_number(const JsonValue *value); +const char *json_as_string(const JsonValue *value); + +#endif diff --git a/interpreter/src/main.c b/interpreter/src/main.c new file mode 100644 index 0000000..373068f --- /dev/null +++ b/interpreter/src/main.c @@ -0,0 +1,123 @@ +#include +#include +#include +#include + +#include "reading_io.h" +#include "significance.h" + +static void print_usage(const char *prog) { + fprintf(stderr, + "Usage: %s [reading.json]\n\n" + "Reads a DailyReading in deck-engine's --format json shape - from the\n" + "given file, or from stdin if no file is given (or it is \"-\") - and\n" + "prints a significance report: the day's overall level and the top\n" + "aspects driving it.\n\n" + "Typical use:\n" + " dist/deck-engine ... --format json | %s\n" + " dist/deck-engine ... --format json > reading.json && %s reading.json\n", + prog, prog, prog); +} + +static char *read_all(FILE *f, size_t *out_length) { + size_t cap = 4096; + size_t len = 0; + char *buf = malloc(cap); + + size_t n; + while ((n = fread(buf + len, 1, cap - len, f)) > 0) { + len += n; + if (len == cap) { + cap *= 2; + buf = realloc(buf, cap); + } + } + buf = realloc(buf, len + 1); + buf[len] = '\0'; + *out_length = len; + return buf; +} + +/* Display names for the report - deliberately a small local table rather + * than linking engine/src/astro.c's astro_body_name()/astro_aspect_name() + * (which would pull in the vendored Astronomy Engine just for cosmetic + * text). Same policy as reading_io.c's slug tables. */ +static const char *body_display_name(Body body) { + static const char *const names[NUM_BODIES] = { + "Sun", "Moon", "Mercury", "Venus", "Mars", + "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", + }; + return names[body]; +} + +static const char *aspect_display_name(AspectType type) { + static const char *const names[5] = { + "Conjunction", "Sextile", "Square", "Trine", "Opposition", + }; + return names[type]; +} + +static const char *day_level_name(DaySignificance level) { + switch (level) { + case DAY_QUIET: return "Quiet"; + case DAY_NOTABLE: return "Notable"; + case DAY_SIGNIFICANT: return "Significant"; + case DAY_MAJOR: return "Major"; + } + return "Unknown"; +} + +int main(int argc, char **argv) { + if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0))) { + print_usage(argv[0]); + return argc > 2 ? 1 : 0; + } + + FILE *in = stdin; + bool opened_file = false; + if (argc == 2 && strcmp(argv[1], "-") != 0) { + in = fopen(argv[1], "r"); + if (!in) { + fprintf(stderr, "error: could not open %s\n", argv[1]); + return 1; + } + opened_file = true; + } + + size_t length; + char *text = read_all(in, &length); + if (opened_file) fclose(in); + + DailyReading reading; + bool loaded = reading_load_json(text, length, &reading); + free(text); + if (!loaded) { + fprintf(stderr, "error: could not parse input as deck-engine --format json output\n"); + return 1; + } + + DailyInterpretation interp; + interpret_daily_reading(&reading, &interp); + + printf("Day significance: %s\n", day_level_name(interp.day_level)); + if (interpretation_deserves_framing(&interp)) { + printf("(a major transit today - worth a deeper Celtic Cross look)\n"); + } + printf("\n"); + + if (interp.top_item_count == 0) { + printf("No notable transits today.\n"); + return 0; + } + + printf("Top %d significant transit%s:\n", interp.top_item_count, + interp.top_item_count == 1 ? "" : "s"); + for (int i = 0; i < interp.top_item_count; i++) { + const SignificantItem *item = &interp.top_items[i]; + printf(" %d. Transiting %s %s natal %s (orb %.1f\xc2\xb0, score %.2f)\n", i + 1, + body_display_name(item->aspect.transiting_planet), + aspect_display_name(item->aspect.type), + body_display_name(item->aspect.natal_planet), item->aspect.orb, item->score); + } + return 0; +} diff --git a/interpreter/src/reading_io.c b/interpreter/src/reading_io.c new file mode 100644 index 0000000..6410ca2 --- /dev/null +++ b/interpreter/src/reading_io.c @@ -0,0 +1,83 @@ +#include "reading_io.h" +#include "json.h" + +#include + +/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key + * tables), duplicated here rather than linking astro.c into this + * otherwise header-only, independently testable module - same policy + * and same reasoning as significance.c's max_orb_for_pair(). Keep in + * sync if those slugs ever change. */ +static const char *const k_body_slug[NUM_BODIES] = { + "sun", "moon", "mercury", "venus", "mars", + "jupiter", "saturn", "uranus", "neptune", "pluto", +}; + +static const char *const k_aspect_slug[5] = { + "conjunction", "sextile", "square", "trine", "opposition", +}; + +static bool body_from_slug(const char *slug, Body *out) { + for (int i = 0; i < NUM_BODIES; i++) { + if (strcmp(slug, k_body_slug[i]) == 0) { + *out = (Body)i; + return true; + } + } + return false; +} + +static bool aspect_type_from_slug(const char *slug, AspectType *out) { + for (int i = 0; i < 5; i++) { + if (strcmp(slug, k_aspect_slug[i]) == 0) { + *out = (AspectType)i; + return true; + } + } + return false; +} + +bool reading_load_json(const char *text, size_t length, DailyReading *out) { + memset(out, 0, sizeof(*out)); + + JsonValue *root = json_parse(text, length); + if (!root) return false; + + const JsonValue *transits = json_object_get(root, "transits"); + const JsonValue *aspects = transits ? json_object_get(transits, "aspects") : NULL; + if (!aspects) { + json_free(root); + return false; + } + + bool ok = true; + int filled = 0; + int count = json_array_count(aspects); + for (int i = 0; i < count && filled < MAX_ASPECTS; i++) { + const JsonValue *item = json_array_get(aspects, i); + const char *transiting_slug = json_as_string(json_object_get(item, "transiting_planet")); + const char *natal_slug = json_as_string(json_object_get(item, "natal_planet")); + const char *type_slug = json_as_string(json_object_get(item, "type")); + const JsonValue *orb_value = json_object_get(item, "orb"); + + Body transiting, natal; + AspectType type; + if (!body_from_slug(transiting_slug, &transiting) || + !body_from_slug(natal_slug, &natal) || + !aspect_type_from_slug(type_slug, &type) || + !orb_value) { + ok = false; + break; + } + + out->transits.aspects[filled].transiting_planet = transiting; + out->transits.aspects[filled].natal_planet = natal; + out->transits.aspects[filled].type = type; + out->transits.aspects[filled].orb = json_as_number(orb_value); + filled++; + } + out->transits.aspect_count = filled; + + json_free(root); + return ok; +} diff --git a/interpreter/src/reading_io.h b/interpreter/src/reading_io.h new file mode 100644 index 0000000..0cef02a --- /dev/null +++ b/interpreter/src/reading_io.h @@ -0,0 +1,23 @@ +#ifndef DECK_READING_IO_H +#define DECK_READING_IO_H + +#include +#include + +/* Header-only dependency on the engine, same policy as significance.h - + * see its comment for why. */ +#include "../../engine/src/reading.h" + +/* Parses deck-engine's `--format json` output (text, null-terminated at + * text[length]) and fills *out with just enough of a DailyReading for + * interpret_daily_reading() to work on: out->transits.aspects[]/ + * aspect_count. out->natal and out->spread are left zeroed - nothing + * here reads them (see significance.c), so there's no reason to parse + * the rest of the document yet. + * + * Returns false on malformed JSON, or if "transits"."aspects" isn't + * present as an array (an empty array is fine - that's a real "no + * aspects today" reading, not an error). */ +bool reading_load_json(const char *text, size_t length, DailyReading *out); + +#endif diff --git a/interpreter/src/significance.c b/interpreter/src/significance.c new file mode 100644 index 0000000..1aeadaa --- /dev/null +++ b/interpreter/src/significance.c @@ -0,0 +1,94 @@ +#include "significance.h" + +/* Higher = this transiting planet's aspects matter more when they occur. + * Slow/outer planets transit rarely, so an aspect from one is a bigger + * deal than the same aspect from a fast-moving personal planet - this is + * about how rare the *transiting* body's activity is, independent of + * which natal point it's touching (see natal_point_bonus for that). */ +static const double k_transiting_weight[NUM_BODIES] = { + [PLANET_SUN] = 2.0, + [PLANET_MOON] = 1.0, + [PLANET_MERCURY] = 1.5, + [PLANET_VENUS] = 1.5, + [PLANET_MARS] = 2.0, + [PLANET_JUPITER] = 4.0, + [PLANET_SATURN] = 6.0, + [PLANET_URANUS] = 8.0, + [PLANET_NEPTUNE] = 9.0, + [PLANET_PLUTO] = 10.0, +}; + +static double natal_point_bonus(Body natal_planet) { + /* A transit to a natal luminary (Sun/Moon) reads as more personally + * significant than one to any other planet. */ + return (natal_planet == PLANET_SUN || natal_planet == PLANET_MOON) ? 1.5 : 1.0; +} + +/* Mirrors astro.c's own orb_for_pair() (its detection-time orb rule), + * duplicated here as a single ternary rather than linking the full + * vendored Astronomy Engine into this otherwise header-only, + * independently testable module just to reuse one line. Keep this in + * sync if that rule in astro.c ever changes. */ +static double max_orb_for_pair(Body transiting, Body natal) { + bool luminary = transiting == PLANET_SUN || transiting == PLANET_MOON || + natal == PLANET_SUN || natal == PLANET_MOON; + return luminary ? 8.0 : 6.0; +} + +static double aspect_score(const Aspect *a) { + double max_orb = max_orb_for_pair(a->transiting_planet, a->natal_planet); + double tightness = 1.0 - (a->orb / max_orb); /* 1.0 = exact, ~0 = at the edge */ + if (tightness < 0.0) tightness = 0.0; + return k_transiting_weight[a->transiting_planet] * tightness * + natal_point_bonus(a->natal_planet); +} + +/* Inserts (kind, aspect, score) into out->top_items in descending-score + * order, keeping only the top MAX_SIGNIFICANT_ITEMS. O(n * 5), fine for + * n <= MAX_ASPECTS (100). */ +static void insert_ranked(DailyInterpretation *out, SignificantItemKind kind, + const Aspect *aspect, double score) { + int insert_at = out->top_item_count; + while (insert_at > 0 && out->top_items[insert_at - 1].score < score) insert_at--; + if (insert_at >= MAX_SIGNIFICANT_ITEMS) return; + + int shift_from = out->top_item_count < MAX_SIGNIFICANT_ITEMS + ? out->top_item_count + : MAX_SIGNIFICANT_ITEMS - 1; + for (int j = shift_from; j > insert_at; j--) { + out->top_items[j] = out->top_items[j - 1]; + } + + out->top_items[insert_at].kind = kind; + out->top_items[insert_at].aspect = *aspect; + out->top_items[insert_at].score = score; + if (out->top_item_count < MAX_SIGNIFICANT_ITEMS) out->top_item_count++; +} + +/* Thresholds are tunable, not derived from anything physical - chosen so + * an exact outer-planet-to-luminary hit (the classic "big transit") lands + * solidly in DAY_MAJOR while a single loose, minor-planet aspect stays + * DAY_QUIET. Adjust here if real usage says the cutoffs feel off. */ +static DaySignificance classify_day(int top_item_count, double top_score) { + if (top_item_count == 0) return DAY_QUIET; + if (top_score >= 9.0) return DAY_MAJOR; + if (top_score >= 5.0) return DAY_SIGNIFICANT; + if (top_score >= 2.5) return DAY_NOTABLE; + return DAY_QUIET; +} + +void interpret_daily_reading(const DailyReading *reading, DailyInterpretation *out) { + out->top_item_count = 0; + + for (int i = 0; i < reading->transits.aspect_count; i++) { + const Aspect *a = &reading->transits.aspects[i]; + insert_ranked(out, ITEM_ASPECT, a, aspect_score(a)); + } + + double top_score = out->top_item_count > 0 ? out->top_items[0].score : 0.0; + out->day_level = classify_day(out->top_item_count, top_score); +} + +bool interpretation_deserves_framing(const DailyInterpretation *interp) { + return interp->day_level == DAY_MAJOR; +} diff --git a/interpreter/src/significance.h b/interpreter/src/significance.h new file mode 100644 index 0000000..068d0b8 --- /dev/null +++ b/interpreter/src/significance.h @@ -0,0 +1,57 @@ +#ifndef DECK_SIGNIFICANCE_H +#define DECK_SIGNIFICANCE_H + +/* Header-only dependency on the engine: this module never compiles or + * links the engine's own .c files (or the vendored Astronomy Engine), + * only shares its struct/enum definitions. That's what makes it testable + * with a hand-built DailyReading instead of a real reading_generate() + * call - see interpreter/tests/significance_test.c. */ +#include "../../engine/src/reading.h" + +#include + +#define MAX_SIGNIFICANT_ITEMS 5 + +/* Only aspects compete for the top-5 today. Kept as an explicit tag + * (rather than assuming "item == aspect") so a future kind - e.g. a + * notable moon phase or a house ingress - can be added without + * reshaping this struct. */ +typedef enum { + ITEM_ASPECT = 0 +} SignificantItemKind; + +typedef struct { + SignificantItemKind kind; + Aspect aspect; /* valid when kind == ITEM_ASPECT */ + double score; /* higher = more significant; no fixed upper bound */ +} SignificantItem; + +/* Overall classification of the day, derived from the top item's score. + * A small enum, not a raw number, so callers (rendering, i18n, "does + * this deserve Celtic Cross framing") don't each have to invent their + * own thresholds. */ +typedef enum { + DAY_QUIET = 0, + DAY_NOTABLE, + DAY_SIGNIFICANT, + DAY_MAJOR +} DaySignificance; + +typedef struct { + SignificantItem top_items[MAX_SIGNIFICANT_ITEMS]; + int top_item_count; /* <= MAX_SIGNIFICANT_ITEMS; fewer if the day had + * fewer than 5 aspects in orb */ + DaySignificance day_level; +} DailyInterpretation; + +/* Reads reading->transits.aspects[] only - never touches natal/spread, + * and never calls back into the engine, so a hand-built DailyReading is + * a complete, valid input. */ +void interpret_daily_reading(const DailyReading *reading, DailyInterpretation *out); + +/* True once day_level reaches the threshold at which the Celtic Cross + * reading should get a "this matters" framing sentence naming + * interp->top_items[0]. */ +bool interpretation_deserves_framing(const DailyInterpretation *interp); + +#endif diff --git a/interpreter/tests/json_test.c b/interpreter/tests/json_test.c new file mode 100644 index 0000000..fe997c6 --- /dev/null +++ b/interpreter/tests/json_test.c @@ -0,0 +1,121 @@ +#include +#include +#include + +#include "../src/json.h" +#include "../src/reading_io.h" + +static void test_json_parse_basic_shapes(void) { + const char *text = "{\"a\": 1, \"b\": [true, false, null, \"text\"], \"c\": {\"nested\": 2.5}}"; + JsonValue *root = json_parse(text, strlen(text)); + assert(root && root->type == JSON_OBJECT); + + assert(json_as_number(json_object_get(root, "a")) == 1.0); + + const JsonValue *b = json_object_get(root, "b"); + assert(json_array_count(b) == 4); + assert(json_array_get(b, 0)->type == JSON_BOOL && json_array_get(b, 0)->as.boolean == true); + assert(json_array_get(b, 1)->type == JSON_BOOL && json_array_get(b, 1)->as.boolean == false); + assert(json_array_get(b, 2)->type == JSON_NULL); + assert(strcmp(json_as_string(json_array_get(b, 3)), "text") == 0); + + const JsonValue *c = json_object_get(root, "c"); + assert(json_as_number(json_object_get(c, "nested")) == 2.5); + + json_free(root); + printf("PASS test_json_parse_basic_shapes\n"); +} + +static void test_json_parse_rejects_malformed(void) { + const char *bad_inputs[] = { + "{not valid json", + "[1, 2,]", + "{\"a\": }", + "", + }; + for (size_t i = 0; i < sizeof(bad_inputs) / sizeof(bad_inputs[0]); i++) { + JsonValue *root = json_parse(bad_inputs[i], strlen(bad_inputs[i])); + assert(root == NULL); + } + printf("PASS test_json_parse_rejects_malformed\n"); +} + +/* A trimmed-but-structurally-faithful fixture matching deck-engine's real + * --format json shape: natal/spread sections are present with decoy + * content the loader must skip over without understanding their schema, + * to prove it navigates by key path rather than assuming any position. */ +static const char *k_fixture = + "{" + " \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\"}], \"houses\": []}," + " \"transits\": {" + " \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\"}]," + " \"moon_phase\": \"full\"," + " \"aspects\": [" + " {\"transiting_planet\": \"pluto\", \"natal_planet\": \"moon\", \"type\": \"opposition\", \"orb\": 0.2}," + " {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}" + " ]" + " }," + " \"spread\": {\"positions\": [{\"position\": \"present\", \"card\": \"devil\", \"reversed\": false}]}" + "}"; + +static void test_reading_load_json_extracts_aspects(void) { + DailyReading reading; + bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading); + + assert(ok); + assert(reading.transits.aspect_count == 2); + assert(reading.transits.aspects[0].transiting_planet == PLANET_PLUTO); + assert(reading.transits.aspects[0].natal_planet == PLANET_MOON); + assert(reading.transits.aspects[0].type == ASPECT_OPPOSITION); + assert(reading.transits.aspects[0].orb == 0.2); + assert(reading.transits.aspects[1].transiting_planet == PLANET_SATURN); + assert(reading.transits.aspects[1].natal_planet == PLANET_SUN); + assert(reading.transits.aspects[1].type == ASPECT_SQUARE); + + printf("PASS test_reading_load_json_extracts_aspects\n"); +} + +static void test_reading_load_json_empty_aspects_is_valid(void) { + const char *text = "{\"transits\": {\"aspects\": []}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(ok); + assert(reading.transits.aspect_count == 0); + + printf("PASS test_reading_load_json_empty_aspects_is_valid\n"); +} + +static void test_reading_load_json_rejects_missing_transits(void) { + const char *text = "{\"natal\": {}, \"spread\": {}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(!ok); + + printf("PASS test_reading_load_json_rejects_missing_transits\n"); +} + +static void test_reading_load_json_rejects_unknown_slug(void) { + const char *text = + "{\"transits\": {\"aspects\": [" + " {\"transiting_planet\": \"xenu\", \"natal_planet\": \"moon\", \"type\": \"square\", \"orb\": 1.0}" + "]}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(!ok); + + printf("PASS test_reading_load_json_rejects_unknown_slug\n"); +} + +int main(void) { + test_json_parse_basic_shapes(); + test_json_parse_rejects_malformed(); + test_reading_load_json_extracts_aspects(); + test_reading_load_json_empty_aspects_is_valid(); + test_reading_load_json_rejects_missing_transits(); + test_reading_load_json_rejects_unknown_slug(); + printf("All JSON tests passed.\n"); + return 0; +} diff --git a/interpreter/tests/significance_test.c b/interpreter/tests/significance_test.c new file mode 100644 index 0000000..5a017c9 --- /dev/null +++ b/interpreter/tests/significance_test.c @@ -0,0 +1,103 @@ +#include +#include + +#include "../src/significance.h" + +static Aspect make_aspect(Body transiting, Body natal, AspectType type, double orb) { + Aspect a; + a.transiting_planet = transiting; + a.natal_planet = natal; + a.type = type; + a.orb = orb; + return a; +} + +static void test_empty_day_is_quiet(void) { + DailyReading reading = {0}; + DailyInterpretation interp; + + interpret_daily_reading(&reading, &interp); + + assert(interp.top_item_count == 0); + assert(interp.day_level == DAY_QUIET); + assert(!interpretation_deserves_framing(&interp)); + + printf("PASS test_empty_day_is_quiet\n"); +} + +static void test_exact_outer_planet_to_luminary_is_major(void) { + DailyReading reading = {0}; + reading.transits.aspect_count = 1; + reading.transits.aspects[0] = make_aspect(PLANET_PLUTO, PLANET_SUN, ASPECT_SQUARE, 0.1); + + DailyInterpretation interp; + interpret_daily_reading(&reading, &interp); + + assert(interp.top_item_count == 1); + assert(interp.day_level == DAY_MAJOR); + assert(interpretation_deserves_framing(&interp)); + assert(interp.top_items[0].aspect.transiting_planet == PLANET_PLUTO); + + printf("PASS test_exact_outer_planet_to_luminary_is_major\n"); +} + +static void test_top_five_selected_and_ranked(void) { + /* 7 candidates spanning very tight/major to very loose/minor. The two + * weakest (a fast Moon transit and a wide-orb Venus-Mars trine, both + * with no luminary/slow-planet weight behind them) should be evicted, + * and the strongest (near-exact outer-planet opposition to natal Moon) + * should end up first. */ + DailyReading reading = {0}; + reading.transits.aspect_count = 7; + reading.transits.aspects[0] = make_aspect(PLANET_PLUTO, PLANET_MOON, ASPECT_OPPOSITION, 0.2); + reading.transits.aspects[1] = make_aspect(PLANET_SATURN, PLANET_SUN, ASPECT_SQUARE, 0.5); + reading.transits.aspects[2] = make_aspect(PLANET_URANUS, PLANET_MERCURY, ASPECT_TRINE, 1.0); + reading.transits.aspects[3] = make_aspect(PLANET_JUPITER, PLANET_VENUS, ASPECT_SEXTILE, 2.0); + reading.transits.aspects[4] = make_aspect(PLANET_MARS, PLANET_JUPITER, ASPECT_CONJUNCTION, 1.5); + reading.transits.aspects[5] = make_aspect(PLANET_MOON, PLANET_MERCURY, ASPECT_SEXTILE, 5.9); + reading.transits.aspects[6] = make_aspect(PLANET_VENUS, PLANET_MARS, ASPECT_TRINE, 5.8); + + DailyInterpretation interp; + interpret_daily_reading(&reading, &interp); + + assert(interp.top_item_count == 5); + + for (int i = 1; i < interp.top_item_count; i++) { + assert(interp.top_items[i - 1].score >= interp.top_items[i].score); + } + + for (int i = 0; i < interp.top_item_count; i++) { + Body t = interp.top_items[i].aspect.transiting_planet; + Body n = interp.top_items[i].aspect.natal_planet; + assert(!(t == PLANET_MOON && n == PLANET_MERCURY)); + assert(!(t == PLANET_VENUS && n == PLANET_MARS)); + } + + assert(interp.top_items[0].aspect.transiting_planet == PLANET_PLUTO); + assert(interp.top_items[0].aspect.natal_planet == PLANET_MOON); + + printf("PASS test_top_five_selected_and_ranked\n"); +} + +static void test_fewer_than_five_aspects_reports_actual_count(void) { + DailyReading reading = {0}; + reading.transits.aspect_count = 2; + reading.transits.aspects[0] = make_aspect(PLANET_VENUS, PLANET_MARS, ASPECT_TRINE, 3.0); + reading.transits.aspects[1] = make_aspect(PLANET_MERCURY, PLANET_VENUS, ASPECT_SEXTILE, 4.0); + + DailyInterpretation interp; + interpret_daily_reading(&reading, &interp); + + assert(interp.top_item_count == 2); + + printf("PASS test_fewer_than_five_aspects_reports_actual_count\n"); +} + +int main(void) { + test_empty_day_is_quiet(); + test_exact_outer_planet_to_luminary_is_major(); + test_top_five_selected_and_ranked(); + test_fewer_than_five_aspects_reports_actual_count(); + printf("All significance tests passed.\n"); + return 0; +} diff --git a/res/logo-small.png b/res/logo-small.png new file mode 100644 index 0000000..56abb5b Binary files /dev/null and b/res/logo-small.png differ diff --git a/res/logo.png b/res/logo.png index 231a1e9..3c61c43 100644 Binary files a/res/logo.png and b/res/logo.png differ