Add JSON output format and a separate interpreter binary

deck-engine gains --format json (language-independent, slug-based)
so a new dist/interpreter-cli can score how significant a day's
transits are without linking the engine itself — it depends only on
the JSON shape, via its own minimal parser. Also documents the full
reading pipeline end to end (CLAUDE.md, README, docs/) and adds
docs/reading.md, a non-technical explanation of what a daily reading
contains and means.
This commit is contained in:
ml
2026-07-05 15:55:43 +02:00
parent 57deb62b51
commit bd739ccfdf
25 changed files with 1497 additions and 11 deletions
+1
View File
@@ -2,6 +2,7 @@
# data once filled in — never commit it.
/dist
/engine/build
/interpreter/build
*.o
core
+69 -1
View File
@@ -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 <path>]
[--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),
@@ -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`):
+14 -1
View File
@@ -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
+97 -5
View File
@@ -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 <string> --birth-date YYYY-MM-DD --birth-time HH:MM
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
[--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 <string> --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 `<code>.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>.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)
```
+100
View File
@@ -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.)*
+5
View File
@@ -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]; }
+9
View File
@@ -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
+9 -4
View File
@@ -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 <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html]\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
" [--lang <code>] [--i18n-dir <path>]\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/<file>, 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 <code>.lang in --i18n-dir (see engine/i18n/).\n"
" --i18n-dir Directory to look up <lang>.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);
}
+52
View File
@@ -142,3 +142,55 @@ void reading_print_html(const DailyReading *r, FILE *out) {
}
fprintf(out, "</div>\n</body></html>\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");
}
+6
View File
@@ -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
+7
View File
@@ -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
+3
View File
@@ -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]; }
+44
View File
@@ -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/<code>.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;
+54
View File
@@ -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)
+368
View File
@@ -0,0 +1,368 @@
#include "json.h"
#include <stdlib.h>
#include <string.h>
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;
}
+55
View File
@@ -0,0 +1,55 @@
#ifndef DECK_JSON_H
#define DECK_JSON_H
#include <stdbool.h>
#include <stddef.h>
/* 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
+123
View File
@@ -0,0 +1,123 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+83
View File
@@ -0,0 +1,83 @@
#include "reading_io.h"
#include "json.h"
#include <string.h>
/* 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;
}
+23
View File
@@ -0,0 +1,23 @@
#ifndef DECK_READING_IO_H
#define DECK_READING_IO_H
#include <stdbool.h>
#include <stddef.h>
/* 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
+94
View File
@@ -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;
}
+57
View File
@@ -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 <stdbool.h>
#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
+121
View File
@@ -0,0 +1,121 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}
+103
View File
@@ -0,0 +1,103 @@
#include <assert.h>
#include <stdio.h>
#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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB