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
+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;
}