Spec: Rectification Build — Phase 0 (harness) + Phase 1 (sect classifier)¶
Status |
Draft — the buildable unit |
Created |
2026-07-11 |
Owner |
Kate |
Type |
Implementation spec (SDD) |
Parent |
|
Corpus |
data/rectification-corpus-events.yaml (63 people, 888 events) |
1. Purpose & the one question it answers¶
Build the smallest honest thing that tells us whether the whole idea works: a sect classifier benchmarked on the 63-person corpus. If, given only a person’s dated events, we can recover day vs night better than chance, the rectifier has signal and the later phases are worth building. If we can’t, we learn that cheaply, before writing any calibration machinery.
Two build layers:
Phase 0 — the harness. A fast candidate-time re-cast + a benchmark driver that blanks a corpus person’s time, runs a technique across candidates, and scores the result against the known truth.
Phase 1 — the sect classifier. The first technique on that harness: walk the day-sect and night-sect interpretations of a life and score which one the events fit. Output
P(day)/P(night)+ a contrastive report.
2. Location & promotion (per Q1 = standalone-first)¶
Lives at
docs/development/specs/rectification/— a standalone package that imports stellium’s public API as a consumer (likeexamples/). Nothing goes intosrc/stellium/in this phase.Its own tests live beside it and run on their own (
python -m pytest docs/development/specs/rectification/), not wired into the package suite.Promotion gate →
stellium/rectification/: the sect classifier runs reproducibly on all 63 and beats the baseline (§6) by a pre-registered margin. Whatever the re-cast needed that the public API didn’t expose is the promotion checklist.
docs/development/specs/rectification/
__init__.py
recast.py # fast angle-only re-cast (Phase 0)
harness.py # corpus loader, blank-the-time, run-across-candidates, scoring
significators.py # event-type -> significators table (theory §6)
sect.py # Phase 1 sect classifier + contrastive report
models.py # LifeEvent, Evidence (standalone; mirror the parent spec)
run_benchmark.py # CLI: score the classifier over the 63, emit the report
tests/
test_recast.py # re-cast == full .calculate() to tolerance
test_sect.py # determinism + known-sect sanity
3. Resolved open questions (for this phase)¶
Q1 location — standalone-first (§2).
Q2 grid — a uniform fine grid, default 4 min (≈ 1° of ASC) across the day. Regime-aware cells (sect-flip / ASC-sign boundaries) are deferred to Phase 3, where the sharp continuous techniques need them. For sect specifically the grid barely matters — see §5.2.
Q3 significators — a fixed weighted table from theory §6 (
significators.py); marginalising over significators is a later refinement.Q4 soft evidence — out of Phase 1. The classifier uses only hard, dated events; temperament (the soft channel) is Phase 5. Keeps the first result defensible.
Q5 corpus size — empirical; 63 is what we have, report accuracy with a binomial confidence interval so we don’t over-read a small sample.
4. Phase 0 — the harness¶
4.1 Candidate evaluation — full recompute first, re-cast only if profiling demands¶
Baseline (v0): a full ChartBuilder…calculate() per candidate. Dead simple,
obviously correct, and — the key point — automatically correct on the two
things the hand-rolled re-cast would have to handle by hand: the Moon’s
~13°/day motion and the per-candidate sect both come straight from a real
calculation. Build this, then profile it on the 63 (a ~360-cell grid × 63
people ≈ 23k calcs; if that’s a minute or two for a one-off benchmark, we’re
done and the re-cast is never written).
evaluate(date, lat, lon, tz, times) -> list[CandidateChart] — each candidate a
fully-calculated chart (angles, Moon, Lots, sect, house placements). Built on the
public API; surface gaps → promotion checklist.
Optimization (profile-gated): the angle-only re-cast. Only if the benchmark is too slow. The speed trick from theory §2 — compute the day’s slow bodies once, sweep only the angles — with the one correction that makes it correct:
The Moon is not fixed (~12–15°/day, an order of magnitude over any other body). So: slow bodies once; Moon recomputed per candidate; angles/cusps recomputed per candidate; re-place all bodies. The Moon feeds Moon significators, the progressed Moon, and the Lot of Fortune/Spirit =
Asc ± (Moon − Sun)(moves with both the fast Asc and the drifting Moon).Blocking correctness test (applies when/if the re-cast is built): re-cast == full
.calculate()to tolerance (angles & Moon < 0.01°; sect & house placements exact) across a spread of times on several corpus births. The Moon tolerance is what would catch a regression to a naive all-fixed shortcut.
Deferring the re-cast doesn’t just save effort — it sidesteps the Moon-motion and 3-region-sect bug classes entirely until a profile forces the optimization.
4.2 Sect is a multi-region step function — compute it, don’t assume it¶
Sect is simply Sun above the horizon (day) or below it (night) at the
candidate time. Because the re-cast already places the fixed-longitude Sun into a
per-candidate house, sect falls out for free — and correctly at any date and
latitude. Never hardcode the region structure or fixed boundary clock times;
derive sect(t) from the Sun’s actual altitude.
At temperate latitudes — where the whole corpus sits (max ~55°N, London) — the day sweep does cross the horizon twice, giving the intuitive three regions, but the boundaries are the real sunrise/sunset for that date + place and their widths swing hard with season (a 55°N summer “day” region is ~17 h; in winter it inverts):
00:00 ──night── sunrise ──day── sunset ──night── 24:00 (typical temperate case)
(pre-dawn) (daylight) (post-dusk)
But this is not universal: above the polar circle you get midnight sun (all 24 h day, zero night regions) or polar night (all night), and the count of regions is 1, not 3. The corpus has no such cases, but the harness must be correct-by-construction rather than assume the temperate picture.
Consequences the harness must honour from the start:
The sect marginal sums cells by computed sect:
P(night) = Σ (cells where sect(t)=night),P(day) = Σ (cells where sect(t)=day)— however many contiguous runs there are (usually two night + one day here; possibly one of each at the extremes). Never a single midpoint split.The sect boundaries (Sun-altitude zero-crossings) are date/latitude dependent and become the first regime edges for the Phase-3 grid.
True-sect labeling of each corpus person = the re-cast’s sect at the true time. Get this exactly right — it is the benchmark’s ground truth.
The two firdaria orderings (day vs night) are unchanged; only the time → ordering mapping steps with sect. The v0 classifier still compares two hypotheses; “night” is the one covering all night cells, whatever their layout.
4.3 Benchmark driver (harness.py)¶
Load the corpus YAML → for each person:
birth_data(truth),events,temperament. Compute the true sect and true time frombirth_data.Blank the time → the candidate grid over the local day (§3 Q2).
Run-across-candidates: given a scoring function
f(chart_at_t, events) → score, evaluate it on every candidate (using the fast re-cast), returning the per-candidate scores.Scoring utilities: sect-classification accuracy (Phase 1); time MAE and calibration coverage are stubbed for later phases.
5. Phase 1 — the sect classifier¶
5.1 The signal¶
Sect flips two things that are (near-)independent of the exact time within the day, so they’re the honest first signal:
Firdaria order — day charts run Sun→Venus→…; night charts run Moon→… (theory /
core/planetary_years). The time-lord active at each life event differs between the two sect assumptions.(layer in only if firdaria alone is weak) sect-dependent dignity — the benefic/malefic of the sect and the sect light; and the Lot of Fortune/Spirit formula flip.
5.2 Method (v0 — firdaria + significators)¶
For each person, with time unknown:
Build two firdaria timelines from birth —
day-order andnight-order. (Period boundaries shift only trivially with time-of-day, so the grid is almost irrelevant here — that’s why sect is the safe first target.)For each dated event, find the active firdaria period lord (and sub-lord) under each timeline.
Score the match between that lord and the event’s significators (
significators.py): e.g. arelationshipevent scores high if the lord is Venus / ruler-of-7th;careerif Sun / Saturn / ruler-of-10th; etc.Sum across events →
S_day,S_night; normalise (softmax w/ a temperature) →P(day),P(night).predicted_sect = argmax. Compare to the true sect.
Deterministic, no calibration, no full posterior — the minimum that can show signal.
5.3 Output¶
classify_sect(person) -> {"day": p, "night": 1-p, "detail": [...per-event...]}A contrastive day/night report: walk both timelines side by side and show, per event, which sect’s time-lord fit better — the human-readable “why.”
6. Benchmark & baseline (the go/no-go)¶
run_benchmark.py over all 63:
Metric: sect-classification accuracy, with a binomial 95% CI (n=63 is small — report the interval, not just the point).
Baselines to beat:
majority-class (always predict the more common sect in the corpus), and
time-of-day null (chance ≈ 50%).
Pre-registered success: accuracy point estimate ≥ 65% and CI lower bound above the majority-class rate. (Modest on purpose — we’re testing for any signal, not tuning.)
Report: overall accuracy + CI, confusion (day↔night), and the per-person contrastive reports for spot-checking the ones it gets wrong.
7. Data models (models.py, standalone)¶
Mirror the parent spec’s LifeEvent / Evidence (frozen dataclasses) so
promotion is a move, not a rewrite. Loader maps the corpus YAML
(date/precision/type/description/significance/…) straight onto them.
8. Testing¶
test_sect.py— determinism (same input → same P), and a sanity case: feeding a known-time person’s real events makes the classifier favour the true sect. Run standalone, not in the package suite.test_recast.py— only if the re-cast optimization is built: re-cast == full calculation to tolerance (§4.1). Blocking for that optimization.
9. Build order¶
models.py+ corpus loader (+ a couple of load tests).harness.py— full recompute per candidate (§4.1) + the run-across- candidates driver. Simple and correct; no re-cast yet.significators.py(table from theory §6).sect.py— classifier + contrastive report.run_benchmark.py— accuracy + CI over the 63; profile the run.Read the number. Go (promote per §2) / no-go (rethink the signal).
Only if step 5 was too slow: add the angle-only re-cast (
recast.py+test_recast.py) as a drop-in acceleration behind the same interface.
10. Acceptance criteria¶
Harness blanks a corpus time, runs a scorer across candidates (full recompute), and scores sect accuracy over the 63.
classify_sect+ a contrastive day/night report.Benchmark report: accuracy + 95% CI + confusion, vs. both baselines, plus a profile (wall-clock; is the re-cast optimization warranted?).
A written go/no-go call against §6, and — if go — the promotion checklist of public-API gaps found.
(Only if profiling warranted it) fast re-cast verified against full calculation to tolerance.