diff --git a/docs/STATUS.md b/docs/STATUS.md
index ea1a8f4..28ab970 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -179,7 +179,7 @@ never by hand.
| Command | Result |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) |
-| `pnpm run test` | 556 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) |
+| `pnpm run test` | 675 tests / 33 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) |
| `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) |
| `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) |
| `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) |
@@ -216,9 +216,10 @@ runnable from a clean copy outside the repo, offline, deterministic.
## Open risks
- Western angles/houses are derived in-house (not astronomy-engine); they are validated against
- the MC=RAMC and eastern-horizon oracles, but an independent golden house-table (e.g. a Swiss
- Ephemeris reference chart) would further harden them. (Planetary longitudes now HAVE an
- independent JPL Horizons golden; only the house layer still lacks one.)
+ the MC=RAMC and eastern-horizon oracles and an independent Swiss Ephemeris house golden
+ (swetest 2.10.03, 5 synthetic cases x 5 systems, 12 cusps + angles each, worst measured
+ deviation 0.69 arc-minutes; see packages/western/goldens/README.md). The golden covers
+ normal latitudes; circumpolar instants are contract-tested (HOUSE_SYSTEM_UNAVAILABLE).
- LMT-era (early 1901+) offsets are whole-minute precision (moment-timezone limitation).
- Equation of time is an approximation (~tenths of a minute); fine for display, not high-precision.
- TypeScript pinned to 5.9 while registry `latest` is 7.0; revisit after TS 7 soaks.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
index dd62121..8ad1f31 100644
--- a/docs/VALIDATION.md
+++ b/docs/VALIDATION.md
@@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h
resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts`
re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run.
-- Typecheck: clean. Tests: **556 tests / 31 files ?all passing**. The Western provider
+- Typecheck: clean. Tests: **675 tests / 33 files ?all passing**. The Western provider
(astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency
(vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3
technical epochs fetched from the NASA/JPL Horizons service, query recorded in
@@ -133,11 +133,11 @@ canonical-JSON order independence; deterministic hashing; calculate determinism.
why it is trustworthy.
- Western: at least one set cross-checked against JPL/Swiss; main-body positions within ?
arc-minute; discrete classifications (sign/house changes) must match exactly. (Planetary
- longitudes: met via the JPL Horizons golden; an independent house table is still open.)
+ longitudes: met via the JPL Horizons golden; house cusps: met via the Swiss Ephemeris house golden.)
- BaZi/Zi Wei: sourced references, NOT another wrapper of the same core library.
- Snapshots guard against regressions only; they never become ground truth.
-- Minimum targets: time/location 30 (met: 36), Western 20 (met: 30 ?JPL Horizons golden
- longitudes), BaZi 40, Zi Wei 20.
+- Minimum targets: time/location 30 (met: 36), Western 20 (met: 55 ?JPL Horizons golden
+ longitudes + 25 Swiss Ephemeris house-cusp sets), BaZi 40, Zi Wei 20.
## Must-test boundaries (tracked to Phase 2)
diff --git a/packages/western/goldens/README.md b/packages/western/goldens/README.md
index 827bc08..4ac9a94 100644
--- a/packages/western/goldens/README.md
+++ b/packages/western/goldens/README.md
@@ -1,4 +1,4 @@
-# Western independent golden fixtures (JPL Horizons)
+# Western independent golden fixtures (JPL Horizons + Swiss Ephemeris)
This directory holds the **independent** ephemeris cross-check of the Western provider
against authoritative [JPL Horizons](https://ssd.jpl.nasa.gov/horizons/) geocentric
@@ -21,9 +21,91 @@ that layer, plus the Sun cardinal-point anchors).
Do **not** fabricate values here. When adding rows or epochs, copy them from a real,
reproducible JPL Horizons query and record the exact query in the fixture's `source` block.
+## Swiss Ephemeris house golden — POPULATED
+
+`swiss-ephemeris-houses.json` is the independent house-cusp golden (placidus / koch /
+porphyry / equal / whole-sign) referenced against the Swiss Ephemeris `swetest`
+command-line tool. **Populated** (swetest `Version: 2.10.03`, captured 2026-07-28,
+input time scale UTC via `-utc`): 5 synthetic technical epochs × 5 systems × 12 cusps
+plus ASC/MC/ARMC, transcribed deterministically from a reviewed local capture whose
+per-file SHA-256 (stdout, stderr and argv of all 25 calls + version banner) is recorded
+in the fixture's `source.rawSha256`. Measured worst deviation at population time:
+placidus 0.286′, koch 0.688′, porphyry/equal/whole-sign 0.187′ — per-system
+`toleranceArcmin` is the measured maximum plus headroom (0.5′ / 0.9′ / 0.35′), all under
+the ≤1′ gate (rationale recorded in `toleranceRationale`; the residual is dominated by
+mean-vs-true obliquity). `western-house-golden.test.ts` keeps its fail-closed capture
+gate: a de-populated or half-filled fixture turns the suite red again.
+
+Do **not** edit golden values by hand; regenerate via `tools/generate-house-golden.ts`
+and re-review.
+
+### Source & licensing boundary
+
+- `swetest` is used **only** as a one-off external reference generator. Its numeric output
+ (factual astronomical data for given inputs) is recorded here; the swetest program,
+ sources, binaries and ephemeris data files never enter this repository, the lockfile,
+ the SBOM, the release bundle or CI. Tests run fully offline from the tracked fixture.
+- The reference commands use `-emos` (Moshier). This round's reference commands do not
+ require bringing external ephemeris data files into the repository; no additional
+ precision claim is made here — measured tolerances are recorded in the fixture only
+ after a real capture.
+
+### Capture workflow (two-phase)
+
+1. On a machine with a local `swetest` install, run:
+
+ PowerShell:
+
+ ```
+ $env:SWETEST_PATH = 'C:\path\to\swetest.exe'; node --experimental-strip-types tools/generate-house-golden.ts
+ ```
+
+ POSIX:
+
+ ```
+ SWETEST_PATH=/path/to/swetest node --experimental-strip-types tools/generate-house-golden.ts
+ ```
+
+ The script refuses to run without `SWETEST_PATH` (it never downloads anything) and
+ invokes `swetest -b
-utc -house,, -emos -head`
+ once per sample × system (letters: P=placidus, K=koch, O=porphyry, E=equal(Asc),
+ W=whole-sign), for 5 synthetic technical epochs (see the sample matrix in the script;
+ none of them is real birth data).
+
+2. All output is first written to an untracked staging directory
+ (`.tmp/house-golden-raw.staging-/`) and atomically renamed to
+ `.tmp/house-golden-raw/` only after every invocation succeeds and parses; a
+ pre-existing capture directory is renamed aside (never deleted, never merged), so
+ stale raw files cannot mix into a fresh capture. Contents:
+ `case--.stdout.txt` / `.stderr.txt` per invocation,
+ `version.stdout.txt` / `version.stderr.txt`, `manifest.json` (full argv per call,
+ swetest version line, UTC capture time, separate SHA-256 for every stdout/stderr/argv,
+ sample matrix) and `draft-fixture.json` (parsed draft). The script exits non-zero —
+ producing no final directory — on any spawn error, signal, non-zero exit,
+ error-looking stderr, parse failure, missing field, or a quadrant-system output
+ mentioning "Porphyry" on either stream (swetest's silent high-latitude fallback,
+ which this project **rejects**).
+3. The capture is then reviewed (manifest ↔ raw SHA-256 cross-check, draft ↔ raw
+ cross-check, order-of-magnitude sanity against engine output) before being transcribed
+ into the tracked fixture with `status: "populated"`, real `source.version` /
+ `captureDateUtc` / `rawSha256`, and per-system `toleranceArcmin` set to the smallest
+ justified value ≤ 1 arc-minute (rationale recorded in `toleranceRationale`).
+
+### High-latitude boundary
+
+The high-latitude tests in `western-house-golden.test.ts` are fixture-independent engine
+self-assertions. The contract for the quadrant systems (Placidus, Koch) is: whenever the
+geometry is undefined they must throw `HOUSE_SYSTEM_UNAVAILABLE` — never silently fall
+back (swetest falls back to Porphyry there; this project rejects that fallback). Koch's
+guard is the MC degree's own semi-arc (undefined when the MC degree is circumpolar,
+|tan φ · tan δ_MC| > 1); the suite asserts it on synthetic instants VERIFIED against the
+implementation to hit that condition. Whether koch is defined at a given high-latitude
+instant depends on λ_MC at that moment — no blanket "always fails" claim is made.
+
## Not covered (still open)
-- An independent golden **house table** (e.g. a Swiss Ephemeris reference chart) for the
- in-house angles/houses — tracked in `docs/STATUS.md` Open risks.
+- The house golden covers normal latitudes only (five synthetic cases between 33.9°S and
+ 59.9°N); circumpolar instants are covered by contract tests (throw, no fallback), not
+ by golden values.
- The sidereal zodiac, true node and asteroids remain `precision: approximate` with their
own continuity regression; they are intentionally outside this ≤1′ golden.
diff --git a/packages/western/goldens/swiss-ephemeris-houses.json b/packages/western/goldens/swiss-ephemeris-houses.json
new file mode 100644
index 0000000..bd09a1a
--- /dev/null
+++ b/packages/western/goldens/swiss-ephemeris-houses.json
@@ -0,0 +1,362 @@
+{
+ "status": "populated",
+ "note": "Independent Swiss Ephemeris house golden. Populated from a locally reviewed swetest capture (see source block; raw files live in the untracked .tmp/house-golden-raw/ and are identified by SHA-256 below). All sample instants/coordinates are SYNTHETIC technical epochs, not real birth data. Do NOT edit values by hand; regenerate via tools/generate-house-golden.ts and re-review.",
+ "source": {
+ "tool": "swetest (Swiss Ephemeris command-line)",
+ "version": "Version: 2.10.03",
+ "captureDateUtc": "2026-07-28T10:10:32.404Z",
+ "commandTemplate": "SWETEST_PATH= node --experimental-strip-types tools/generate-house-golden.ts (per call: swetest -b -utc -house,, -emos -head)",
+ "referenceInputTimeScale": "UTC",
+ "houseLetterMap": {
+ "placidus": "P",
+ "koch": "K",
+ "porphyry": "O",
+ "equal": "E",
+ "whole-sign": "W"
+ },
+ "coordinateSemantics": "tropical ecliptic of date, geocentric; input longitude east-positive; -emos Moshier (reference command requires no ephemeris data files in the repository)",
+ "docs": [
+ "https://www.astro.com/swisseph-download/doc/swisseph.pdf",
+ "https://www.astro.com/cgi/prep.cgi/swisseph/ae/hcs_P_e.pdf"
+ ],
+ "rawSha256": {
+ "version.stdout.txt": "c6e2c69a85d3e300a14cf7d172450acd201bdb85d7f98d7eabb949dcb94f2ec6",
+ "version.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s1-placidus.stdout.txt": "ac477e7b6d7c334a40afcb5ae3c830e316cfd6e7e1f73a704fbe0950029d2628",
+ "case-s1-placidus.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s1-koch.stdout.txt": "0e9095d2edae135d840d316bb70c22aef80f48f959ed1c84968902ab0a598bc1",
+ "case-s1-koch.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s1-porphyry.stdout.txt": "ae04d038d4f156088c422c3d23fd9652d544fa202beb4d04c34c4769785e1d50",
+ "case-s1-porphyry.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s1-equal.stdout.txt": "da710fca8d93b4ac5d792a7028c6c07bad7af6a295be905013b8976f654314e5",
+ "case-s1-equal.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s1-whole-sign.stdout.txt": "7d56360aa81cb86efea93339974d5d5d5a165f4a33c29f4eccbc4ee4b7eafbd5",
+ "case-s1-whole-sign.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s2-placidus.stdout.txt": "0a3977c275774c81a4b43aceb82ee425d1e8255a0877a712dc644727df5653f8",
+ "case-s2-placidus.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s2-koch.stdout.txt": "9161261f0dd4ed5b06e85d4980fcc5164be21f34dc362703abf64af68df0d91a",
+ "case-s2-koch.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s2-porphyry.stdout.txt": "d90854cc59cf3d5bfaefbab362a9916424209f0517d7cb94e7732bafeb3f0754",
+ "case-s2-porphyry.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s2-equal.stdout.txt": "45cf879dbf453c0f1d671668c0fd5d9b379682734bb761a2274ee87a115ae13f",
+ "case-s2-equal.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s2-whole-sign.stdout.txt": "9c0419d1de429e13e0d7ec5cc169e38fd5223e4e3ad3cf547fea44d55adc8d2e",
+ "case-s2-whole-sign.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s3-placidus.stdout.txt": "5d7b0ae6e79fd3aa6c6e3a3ff7bf1bede8577847366ff9522462c48d34a2e26b",
+ "case-s3-placidus.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s3-koch.stdout.txt": "6985310c22a730ab1bf496cf739e97f8a5ad757a79b61d5c8b5d2dfb6ee2333e",
+ "case-s3-koch.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s3-porphyry.stdout.txt": "c943dfb33d4173c1a1ef14e589e6d276f326c9b42aa4e5114c1604cdac3720a9",
+ "case-s3-porphyry.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s3-equal.stdout.txt": "ce481e783c19722e0b8cdaf5c855d2a91adde78835a2f402132571840faff410",
+ "case-s3-equal.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s3-whole-sign.stdout.txt": "d3c624476ed8dff558ac03892cfdc01c423f14ca0203ee1e7a537b57b09bc66c",
+ "case-s3-whole-sign.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s4-placidus.stdout.txt": "95d5836279f25dec67fd5ea186c60442a28c671eb71aba21688ce1bd82576880",
+ "case-s4-placidus.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s4-koch.stdout.txt": "929084640272484942c041b7b375d361c1d74ddcd5eb9619972812a9ef23e230",
+ "case-s4-koch.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s4-porphyry.stdout.txt": "fbc7fa78929fdbf635a87bd23a00c089519261566e31177988f36dc555f45aec",
+ "case-s4-porphyry.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s4-equal.stdout.txt": "94299d051cc4c4911340bd7834a06976d409d4f8ca270a824be5c600fd29fabe",
+ "case-s4-equal.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s4-whole-sign.stdout.txt": "ec2be8d7ac5d0220f8d356a76064b97ce19e7d0a5e7789fa2dad3334ff982f46",
+ "case-s4-whole-sign.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s5-placidus.stdout.txt": "a84f5674198ddee8ca5df8db08477c32afb66cc05bcfd45c5962d26005f4c373",
+ "case-s5-placidus.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s5-koch.stdout.txt": "86cecc03362300897c2bc45c1901a58e33e378afcba6b2b2e6cb4f1a4912609c",
+ "case-s5-koch.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s5-porphyry.stdout.txt": "d395a9c79d728ba9c21b0d81cb4af49cd96b5064e461a2e54f80fc6b748e6632",
+ "case-s5-porphyry.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s5-equal.stdout.txt": "6c53c3a9019bbefebf1754e4e17064a3eaa3c697c7b982a5ac61dec70d6731a2",
+ "case-s5-equal.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "case-s5-whole-sign.stdout.txt": "06fc8be1d0bfc1421d633afd6b2458d7ef6dbdd4a153bac6fabb819a5fbc18cb",
+ "case-s5-whole-sign.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ }
+ },
+ "toleranceArcmin": {
+ "placidus": 0.5,
+ "koch": 0.9,
+ "porphyry": 0.35,
+ "equal": 0.35,
+ "whole-sign": 0.35
+ },
+ "toleranceRationale": "Measured max wrapped deviation across all 5 cases x (12 cusps + ASC/MC/IC/DESC) at population time, adopted tolerance, and exact margin (tolerance - measured max) per system: placidus 0.285887' -> 0.50' (margin 0.214113'); koch 0.687815' -> 0.90' (margin 0.212185'); porphyry 0.187365' -> 0.35' (margin 0.162635'); equal 0.187365' -> 0.35' (margin 0.162635'); whole-sign 0.187365' -> 0.35' (margin 0.162635'). Tolerance values lie on a 0.05' grid; the margins are exact differences and are NOT themselves rounded. All tolerances stay under the 1' spec ceiling. The residual comes mainly from this engine using the MEAN obliquity (astronomy-engine e_tilt().mobl) while the Swiss reference uses the true obliquity; koch's residual is larger because its cusps are ascendants at shifted sidereal times, which amplifies the obliquity difference at higher latitude (worst case s5, 59.9N).",
+ "cases": [
+ {
+ "id": "s1",
+ "description": "Northern mid-latitude, east longitude (synthetic)",
+ "utcIso": "1990-03-10T00:15:00Z",
+ "latDeg": 30.5,
+ "lonEastDeg": 114.3,
+ "systems": {
+ "placidus": {
+ "cusps": [
+ 22.31782822222222, 55.68810133333333, 81.11792216666666, 104.24916869444445,
+ 129.37590605555556, 161.07921425, 202.31782822222223, 235.68810133333335,
+ 261.1179221666667, 284.24916869444445, 309.37590605555556, 341.07921425
+ ],
+ "ascendant": 22.31782822222222,
+ "mc": 284.24916869444445,
+ "armc": 285.47205291666666
+ },
+ "koch": {
+ "cusps": [
+ 22.31782822222222, 54.75415352777778, 81.33188575, 104.24916869444445,
+ 132.00789947222222, 165.77248597222223, 202.31782822222223, 234.7541535277778,
+ 261.33188574999997, 284.24916869444445, 312.0078994722222, 345.7724859722222
+ ],
+ "ascendant": 22.31782822222222,
+ "mc": 284.24916869444445,
+ "armc": 285.47205291666666
+ },
+ "porphyry": {
+ "cusps": [
+ 22.31782822222222, 49.628275055555555, 76.93872188888889, 104.24916869444445,
+ 136.9387218888889, 169.62827505555558, 202.31782822222223, 229.62827505555558,
+ 256.9387218888889, 284.24916869444445, 316.9387218888889, 349.62827505555555
+ ],
+ "ascendant": 22.31782822222222,
+ "mc": 284.24916869444445,
+ "armc": 285.47205291666666
+ },
+ "equal": {
+ "cusps": [
+ 22.31782822222222, 52.317828222222225, 82.31782822222222, 112.31782822222222,
+ 142.31782822222223, 172.31782822222223, 202.31782822222223, 232.31782822222223,
+ 262.3178282222222, 292.3178282222222, 322.3178282222222, 352.3178282222222
+ ],
+ "ascendant": 22.31782822222222,
+ "mc": 284.24916869444445,
+ "armc": 285.47205291666666
+ },
+ "whole-sign": {
+ "cusps": [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
+ "ascendant": 22.31782822222222,
+ "mc": 284.24916869444445,
+ "armc": 285.47205291666666
+ }
+ }
+ },
+ {
+ "id": "s2",
+ "description": "Northern mid-latitude, west longitude (synthetic)",
+ "utcIso": "2000-07-04T16:00:00Z",
+ "latDeg": 40.7,
+ "lonEastDeg": -74,
+ "systems": {
+ "placidus": {
+ "cusps": [
+ 179.17972466666666, 205.15575705555557, 235.51365169444443, 269.0519991388889,
+ 302.6237772777778, 333.0682624722222, 359.1797246666667, 25.155757055555554,
+ 55.51365169444444, 89.05199913888889, 122.62377727777778, 153.06826247222222
+ ],
+ "ascendant": 179.17972466666666,
+ "mc": 89.05199913888889,
+ "armc": 88.96676416666666
+ },
+ "koch": {
+ "cusps": [
+ 179.17972466666666, 208.66641625, 237.89458152777777, 269.0519991388889,
+ 300.4680452222222, 329.71232008333334, 359.1797246666667, 28.666416249999997,
+ 57.894581527777774, 89.05199913888889, 120.46804522222223, 149.7123200833333
+ ],
+ "ascendant": 179.17972466666666,
+ "mc": 89.05199913888889,
+ "armc": 88.96676416666666
+ },
+ "porphyry": {
+ "cusps": [
+ 179.17972466666666, 209.1371494722222, 239.09457430555557, 269.0519991388889,
+ 299.0945743055555, 329.13714947222223, 359.1797246666667, 29.137149472222223,
+ 59.09457430555556, 89.05199913888889, 119.09457430555555, 149.1371494722222
+ ],
+ "ascendant": 179.17972466666666,
+ "mc": 89.05199913888889,
+ "armc": 88.96676416666666
+ },
+ "equal": {
+ "cusps": [
+ 179.17972466666666, 209.17972466666666, 239.17972466666666, 269.1797246666667,
+ 299.1797246666667, 329.1797246666667, 359.1797246666667, 29.17972466666667,
+ 59.179724666666665, 89.17972466666667, 119.17972466666667, 149.17972466666666
+ ],
+ "ascendant": 179.17972466666666,
+ "mc": 89.05199913888889,
+ "armc": 88.96676416666666
+ },
+ "whole-sign": {
+ "cusps": [150, 180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120],
+ "ascendant": 179.17972466666666,
+ "mc": 89.05199913888889,
+ "armc": 88.96676416666666
+ }
+ }
+ },
+ {
+ "id": "s3",
+ "description": "Southern mid-latitude, east longitude, December solstice (synthetic)",
+ "utcIso": "2010-12-21T03:30:00Z",
+ "latDeg": -33.9,
+ "lonEastDeg": 151.2,
+ "systems": {
+ "placidus": {
+ "cusps": [
+ 19.617875472222224, 47.795211055555555, 78.77088069444444, 111.56475544444444,
+ 144.08685108333333, 173.89121566666665, 199.61787547222224, 227.79521105555554,
+ 258.77088069444443, 291.5647554444445, 324.08685108333333, 353.89121566666665
+ ],
+ "ascendant": 19.617875472222224,
+ "mc": 291.5647554444445,
+ "armc": 293.3043200833333
+ },
+ "koch": {
+ "cusps": [
+ 19.617875472222224, 48.77214158333333, 78.17887161111112, 111.56475544444444,
+ 140.64917383333332, 169.989634, 199.61787547222224, 228.77214158333334,
+ 258.1788716111111, 291.5647554444445, 320.64917383333335, 349.989634
+ ],
+ "ascendant": 19.617875472222224,
+ "mc": 291.5647554444445,
+ "armc": 293.3043200833333
+ },
+ "porphyry": {
+ "cusps": [
+ 19.617875472222224, 50.26683547222222, 80.91579544444446, 111.56475544444444,
+ 140.91579544444446, 170.26683547222223, 199.61787547222224, 230.26683547222223,
+ 260.9157954444444, 291.5647554444445, 320.9157954444444, 350.2668354722222
+ ],
+ "ascendant": 19.617875472222224,
+ "mc": 291.5647554444445,
+ "armc": 293.3043200833333
+ },
+ "equal": {
+ "cusps": [
+ 19.617875472222224, 49.617875472222224, 79.61787547222221, 109.61787547222221,
+ 139.61787547222224, 169.61787547222224, 199.61787547222224, 229.61787547222224,
+ 259.61787547222224, 289.61787547222224, 319.61787547222224, 349.61787547222224
+ ],
+ "ascendant": 19.617875472222224,
+ "mc": 291.5647554444445,
+ "armc": 293.3043200833333
+ },
+ "whole-sign": {
+ "cusps": [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
+ "ascendant": 19.617875472222224,
+ "mc": 291.5647554444445,
+ "armc": 293.3043200833333
+ }
+ }
+ },
+ {
+ "id": "s4",
+ "description": "Near-equator low latitude, earlier era (synthetic)",
+ "utcIso": "1975-01-30T12:00:00Z",
+ "latDeg": 1.35,
+ "lonEastDeg": 103.8,
+ "systems": {
+ "placidus": {
+ "cusps": [
+ 140.86329644444444, 172.32664066666666, 204.64760541666666, 235.2494834722222,
+ 263.6638773333333, 291.52627530555554, 320.8632964444445, 352.32664066666666,
+ 24.647605416666668, 55.249483472222224, 83.66387733333335, 111.52627530555556
+ ],
+ "ascendant": 140.86329644444444,
+ "mc": 55.249483472222224,
+ "armc": 52.90541897222222
+ },
+ "koch": {
+ "cusps": [
+ 140.86329644444444, 172.5199063888889, 204.81547502777778, 235.2494834722222,
+ 263.7339943055556, 291.54841052777783, 320.8632964444445, 352.5199063888889,
+ 24.81547502777778, 55.249483472222224, 83.73399430555556, 111.54841052777778
+ ],
+ "ascendant": 140.86329644444444,
+ "mc": 55.249483472222224,
+ "armc": 52.90541897222222
+ },
+ "porphyry": {
+ "cusps": [
+ 140.86329644444444, 172.32535877777778, 203.78742113888887, 235.2494834722222,
+ 263.78742113888893, 292.32535877777775, 320.8632964444445, 352.32535877777775,
+ 23.78742113888889, 55.249483472222224, 83.78742113888889, 112.32535877777778
+ ],
+ "ascendant": 140.86329644444444,
+ "mc": 55.249483472222224,
+ "armc": 52.90541897222222
+ },
+ "equal": {
+ "cusps": [
+ 140.86329644444444, 170.86329644444444, 200.86329644444444, 230.86329644444444,
+ 260.8632964444445, 290.8632964444445, 320.8632964444445, 350.8632964444445,
+ 20.863296444444448, 50.863296444444444, 80.86329644444444, 110.86329644444444
+ ],
+ "ascendant": 140.86329644444444,
+ "mc": 55.249483472222224,
+ "armc": 52.90541897222222
+ },
+ "whole-sign": {
+ "cusps": [120, 150, 180, 210, 240, 270, 300, 330, 0, 30, 60, 90],
+ "ascendant": 140.86329644444444,
+ "mc": 55.249483472222224,
+ "armc": 52.90541897222222
+ }
+ }
+ },
+ {
+ "id": "s5",
+ "description": "Higher (but well-defined) northern latitude, summer (synthetic)",
+ "utcIso": "2024-06-15T18:45:00Z",
+ "latDeg": 59.9,
+ "lonEastDeg": 10.75,
+ "systems": {
+ "placidus": {
+ "cusps": [
+ 246.09074258333334, 284.6197638055556, 340.61999636111113, 17.935423305555556,
+ 39.91684583333333, 54.69598072222222, 66.09074258333332, 104.61976380555555,
+ 160.6199963611111, 197.93542330555556, 219.91684583333333, 234.69598072222223
+ ],
+ "ascendant": 246.09074258333334,
+ "mc": 197.93542330555556,
+ "armc": 196.5396851388889
+ },
+ "koch": {
+ "cusps": [
+ 246.09074258333334, 264.81100141666667, 294.2910993055556, 17.935423305555556,
+ 33.881787833333334, 49.744796666666666, 66.09074258333332, 84.81100141666667,
+ 114.29109930555555, 197.93542330555556, 213.88178783333333, 229.74479666666664
+ ],
+ "ascendant": 246.09074258333334,
+ "mc": 197.93542330555556,
+ "armc": 196.5396851388889
+ },
+ "porphyry": {
+ "cusps": [
+ 246.09074258333334, 290.0389695, 333.9871963888889, 17.935423305555556,
+ 33.98719638888889, 50.0389695, 66.09074258333332, 110.0389695, 153.98719638888886,
+ 197.93542330555556, 213.98719638888886, 230.0389695
+ ],
+ "ascendant": 246.09074258333334,
+ "mc": 197.93542330555556,
+ "armc": 196.5396851388889
+ },
+ "equal": {
+ "cusps": [
+ 246.09074258333334, 276.09074258333334, 306.09074258333334, 336.09074258333334,
+ 6.090742583333333, 36.09074258333334, 66.09074258333332, 96.09074258333332,
+ 126.09074258333332, 156.09074258333334, 186.09074258333334, 216.09074258333334
+ ],
+ "ascendant": 246.09074258333334,
+ "mc": 197.93542330555556,
+ "armc": 196.5396851388889
+ },
+ "whole-sign": {
+ "cusps": [240, 270, 300, 330, 0, 30, 60, 90, 120, 150, 180, 210],
+ "ascendant": 246.09074258333334,
+ "mc": 197.93542330555556,
+ "armc": 196.5396851388889
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/western/src/houses.ts b/packages/western/src/houses.ts
index 633facf..17cbf28 100644
--- a/packages/western/src/houses.ts
+++ b/packages/western/src/houses.ts
@@ -120,15 +120,36 @@ export function computeHouseCusps(
break;
}
case 'koch': {
- // Birthplace houses: trisect the ASCENDANT's own semi-arc (constant), so the
- // cusp right ascensions are linear in RAMC — no iteration needed.
- const declAsc = declinationOf(ascendant, eps);
- const sda = semiDiurnalArc(declAsc, latDeg);
- const sna = 180 - sda;
- cusps[10] = raToLambda(ramc + sda / 3, eps); // house 11
- cusps[11] = raToLambda(ramc + (2 * sda) / 3, eps); // house 12
- cusps[1] = raToLambda(ramc + sda + sna / 3, eps); // house 2
- cusps[2] = raToLambda(ramc + sda + (2 * sna) / 3, eps); // house 3
+ // Koch (GOH / "Birthplace") houses — independent derivation, no Swiss
+ // Ephemeris code used or consulted (Swiss serves only as an external
+ // numeric acceptance oracle via the tracked golden fixture).
+ //
+ // Definition (Koch & Schäck, "Häusertabellen des Geburtsortes", 1971;
+ // described in swisseph.pdf §house systems and Holden, "The Elements of
+ // House Division"): the MC ecliptic degree needed SDA degrees of
+ // sidereal time (its own semi-diurnal arc) to travel from the eastern
+ // horizon to the meridian. Trisecting that TIME interval, the
+ // intermediate cusps are the ASCENDANTS at the shifted sidereal times:
+ // cusp 11 = Asc(RAMC − 2·SDA/3) cusp 12 = Asc(RAMC − SDA/3)
+ // cusp 2 = Asc(RAMC + SDA/3) cusp 3 = Asc(RAMC + 2·SDA/3)
+ // with SDA = acos(−tan φ · tan δ_MC) and δ_MC = asin(sin ε · sin λ_MC)
+ // (standard spherical-astronomy formulas). Endpoint self-consistency:
+ // Asc(RAMC − SDA) = λ_MC (the MC degree was rising) and
+ // Asc(RAMC + SDA) = λ_IC (the IC degree rises as the MC degree sets),
+ // so the trisection interpolates exactly between the four angles.
+ //
+ // The system is geometrically undefined when the MC degree is
+ // circumpolar (|tan φ · tan δ_MC| > 1, beyond the polar circles);
+ // semiDiurnalArc then raises HOUSE_SYSTEM_UNAVAILABLE — no silent
+ // fallback. Known limitation: this engine uses the MEAN obliquity
+ // (e_tilt().mobl) while the reference uses the true obliquity, leaving
+ // a sub-arcminute residual of the same order as Placidus (~0.3').
+ const declMc = declinationOf(mc, eps);
+ const sda = semiDiurnalArc(declMc, latDeg);
+ cusps[10] = ascendantLongitude(norm360(ramc - (2 * sda) / 3), latDeg, eps); // house 11
+ cusps[11] = ascendantLongitude(norm360(ramc - sda / 3), latDeg, eps); // house 12
+ cusps[1] = ascendantLongitude(norm360(ramc + sda / 3), latDeg, eps); // house 2
+ cusps[2] = ascendantLongitude(norm360(ramc + (2 * sda) / 3), latDeg, eps); // house 3
break;
}
case 'placidus': {
diff --git a/packages/western/test/houses.test.ts b/packages/western/test/houses.test.ts
index d9cfe37..0e2f02e 100644
--- a/packages/western/test/houses.test.ts
+++ b/packages/western/test/houses.test.ts
@@ -130,15 +130,19 @@ describe('house systems', () => {
});
it('quadrant systems fail at high latitude instead of fabricating cusps', () => {
- // At 78°N, some semi-arc becomes undefined (|tan φ·tan δ| > 1), so Placidus/Koch
- // must raise HOUSE_SYSTEM_UNAVAILABLE rather than emit a made-up cusp.
- let threw = false;
- try {
- computeHouseCusps('placidus', dateMs, 78, lon);
- } catch (err) {
- threw = err instanceof EngineError && err.code === ERROR_CODES.HOUSE_SYSTEM_UNAVAILABLE;
+ // At 78°N on this instant, the relevant semi-arc is undefined
+ // (|tan φ·tan δ| > 1): Placidus hits it in its per-cusp iteration and
+ // koch on the MC degree itself (verified: both throw here), so they must
+ // raise HOUSE_SYSTEM_UNAVAILABLE rather than emit a made-up cusp.
+ for (const system of ['placidus', 'koch'] as const) {
+ let threw = false;
+ try {
+ computeHouseCusps(system, dateMs, 78, lon);
+ } catch (err) {
+ threw = err instanceof EngineError && err.code === ERROR_CODES.HOUSE_SYSTEM_UNAVAILABLE;
+ }
+ expect(threw, `${system} must throw HOUSE_SYSTEM_UNAVAILABLE`).toBe(true);
}
- expect(threw).toBe(true);
});
});
diff --git a/packages/western/test/western-house-golden.test.ts b/packages/western/test/western-house-golden.test.ts
new file mode 100644
index 0000000..dd906d2
--- /dev/null
+++ b/packages/western/test/western-house-golden.test.ts
@@ -0,0 +1,234 @@
+// Independent Swiss Ephemeris house golden — all fixture samples are
+// SYNTHETIC technical epochs (not real birth data). This suite is
+// FAIL-CLOSED: while the tracked fixture is still the PENDING_CAPTURE
+// skeleton, the gate test below FAILS (never skips), so the suite cannot
+// silently look green without real captured reference data.
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { ERROR_CODES, EngineError } from '@ming/contracts';
+import { deltaLon, norm360 } from '../src/ephemeris.ts';
+import { computeHouseCusps, houseOfLongitude, type HouseSystemId } from '../src/houses.ts';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const fixturePath = join(here, '..', 'goldens', 'swiss-ephemeris-houses.json');
+
+const SYSTEMS: HouseSystemId[] = ['placidus', 'koch', 'porphyry', 'equal', 'whole-sign'];
+
+interface GoldenSystem {
+ cusps: number[]; // index 0 = house 1 ... index 11 = house 12
+ ascendant: number;
+ mc: number;
+ armc: number | null;
+}
+interface GoldenCase {
+ id: string;
+ description: string;
+ utcIso: string;
+ latDeg: number;
+ lonEastDeg: number;
+ systems: Record;
+}
+interface GoldenFixture {
+ status: string;
+ source: {
+ version: string | null;
+ captureDateUtc: string | null;
+ rawSha256: Record;
+ };
+ toleranceArcmin: Record;
+ toleranceRationale: string | null;
+ cases: GoldenCase[];
+}
+
+const fixture: GoldenFixture = JSON.parse(readFileSync(fixturePath, 'utf8'));
+
+/** Wrapped angular distance in ARCMINUTES. */
+function wrappedArcmin(a: number, b: number): number {
+ return Math.abs(deltaLon(a, b)) * 60;
+}
+
+describe('western house golden: fail-closed capture gate', () => {
+ it('fixture is populated with captured Swiss Ephemeris data (fails while PENDING_CAPTURE)', () => {
+ // This test MUST stay red until the fixture is populated from a real,
+ // reviewed swetest capture. Never skip it, never soften it: a pending or
+ // half-filled fixture is NOT a passing state.
+ expect(fixture.status).toBe('populated');
+ expect(fixture.source.version).toBeTruthy();
+ expect(fixture.source.captureDateUtc).toBeTruthy();
+ expect(Object.keys(fixture.source.rawSha256).length).toBeGreaterThan(0);
+ expect(fixture.cases.length).toBeGreaterThanOrEqual(5);
+ for (const c of fixture.cases) {
+ for (const sys of SYSTEMS) {
+ expect(c.systems[sys], `case ${c.id} missing system ${sys}`).toBeDefined();
+ expect(c.systems[sys]!.cusps).toHaveLength(12);
+ }
+ }
+ for (const sys of SYSTEMS) {
+ const tol = fixture.toleranceArcmin[sys];
+ expect(
+ typeof tol === 'number' && tol > 0 && tol <= 1,
+ `toleranceArcmin.${sys} must be a positive number <= 1 arc-minute, got ${tol}`,
+ ).toBe(true);
+ }
+ expect(fixture.toleranceRationale).toBeTruthy();
+ });
+});
+
+// Golden comparisons: only meaningful once populated. Guarded by a plain
+// conditional (NOT it.skip) so the pending state yields zero green golden
+// assertions while the gate test above is red.
+if (fixture.status === 'populated') {
+ describe('western house golden: engine vs Swiss Ephemeris', () => {
+ for (const c of fixture.cases) {
+ const dateMs = Date.parse(c.utcIso);
+ describe(`${c.id} (${c.description})`, () => {
+ for (const sys of SYSTEMS) {
+ const golden = c.systems[sys]!;
+ const tol = fixture.toleranceArcmin[sys] as number;
+
+ it(`${sys}: angles (ASC/MC/IC/DESC) within ${tol}'`, () => {
+ const r = computeHouseCusps(sys, dateMs, c.latDeg, c.lonEastDeg);
+ expect(wrappedArcmin(r.ascendant, golden.ascendant)).toBeLessThanOrEqual(tol);
+ expect(wrappedArcmin(r.mc, golden.mc)).toBeLessThanOrEqual(tol);
+ // IC and Descendant are derived (+180°) — assert against the
+ // golden-derived values so a sign/quadrant error cannot hide.
+ expect(wrappedArcmin(r.ic, norm360(golden.mc + 180))).toBeLessThanOrEqual(tol);
+ expect(
+ wrappedArcmin(r.descendant, norm360(golden.ascendant + 180)),
+ ).toBeLessThanOrEqual(tol);
+ });
+
+ it(`${sys}: all 12 cusps within ${tol}'`, () => {
+ const r = computeHouseCusps(sys, dateMs, c.latDeg, c.lonEastDeg);
+ for (let i = 0; i < 12; i++) {
+ expect(
+ wrappedArcmin(r.cusps[i]!, golden.cusps[i]!),
+ `house ${i + 1}: engine=${r.cusps[i]} golden=${golden.cusps[i]}`,
+ ).toBeLessThanOrEqual(tol);
+ }
+ });
+
+ it(`${sys}: golden cusp ring closes to 360 degrees`, () => {
+ // Ring consistency of the REFERENCE data itself: successive cusp
+ // gaps (forward along the ecliptic) must sum to a full circle.
+ let sum = 0;
+ for (let i = 0; i < 12; i++) {
+ const a = golden.cusps[i]!;
+ const b = golden.cusps[(i + 1) % 12]!;
+ sum += norm360(b - a);
+ }
+ expect(Math.abs(sum - 360)).toBeLessThanOrEqual(1e-6);
+ });
+ }
+
+ it('equal: cusps are exactly ASC + 30k (discrete definition)', () => {
+ const r = computeHouseCusps('equal', dateMs, c.latDeg, c.lonEastDeg);
+ for (let i = 0; i < 12; i++) {
+ expect(wrappedArcmin(r.cusps[i]!, norm360(r.ascendant + 30 * i))).toBeLessThanOrEqual(
+ 1e-6,
+ );
+ }
+ });
+
+ it('whole-sign: every cusp is an exact 30-degree sign boundary containing ASC in house 1', () => {
+ const r = computeHouseCusps('whole-sign', dateMs, c.latDeg, c.lonEastDeg);
+ for (let i = 0; i < 12; i++) {
+ expect(r.cusps[i]! % 30).toBeCloseTo(0, 9);
+ }
+ const ascSignStart = Math.floor(r.ascendant / 30) * 30;
+ expect(r.cusps[0]).toBeCloseTo(ascSignStart, 9);
+ });
+
+ it('houseOfLongitude: a point exactly on a cusp belongs to that house (equal + placidus)', () => {
+ for (const sys of ['equal', 'placidus'] as const) {
+ const r = computeHouseCusps(sys, dateMs, c.latDeg, c.lonEastDeg);
+ // Exactly on cusp of house 4 -> must report house 4.
+ expect(houseOfLongitude(r.cusps, r.cusps[3]!)).toBe(4);
+ }
+ });
+ });
+ }
+ });
+}
+
+describe('western house golden: high-latitude boundary (engine self-assertion, fixture-independent)', () => {
+ // Contract for quadrant systems (Placidus, Koch): whenever the geometry is
+ // undefined they must raise HOUSE_SYSTEM_UNAVAILABLE — never silently fall
+ // back (Swiss Ephemeris swetest falls back to Porphyry there; this project
+ // rejects that fallback). Koch's guard is the MC degree's own semi-arc
+ // (undefined when the MC degree is circumpolar); the koch samples below
+ // were VERIFIED against the implementation to hit that condition. This
+ // does not claim koch fails at every high-latitude instant — whether it
+ // does depends on λ_MC at the moment. These assertions deliberately do
+ // NOT depend on the golden fixture.
+ const HIGH_LAT_CASES = [
+ {
+ name: 'north circumpolar (synthetic)',
+ dateMs: Date.UTC(2001, 5, 21, 0, 0),
+ lat: 66.8,
+ lon: 25.7,
+ },
+ {
+ name: 'south deep-polar (synthetic)',
+ dateMs: Date.UTC(2013, 0, 10, 12, 0),
+ lat: -75.0,
+ lon: 0.0,
+ },
+ ];
+
+ for (const { name, dateMs, lat, lon } of HIGH_LAT_CASES) {
+ it(`${name}: placidus throws HOUSE_SYSTEM_UNAVAILABLE (no Porphyry fallback accepted)`, () => {
+ let thrown: unknown;
+ try {
+ computeHouseCusps('placidus', dateMs, lat, lon);
+ } catch (e) {
+ thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(EngineError);
+ expect((thrown as EngineError).code).toBe(ERROR_CODES.HOUSE_SYSTEM_UNAVAILABLE);
+ });
+
+ for (const sys of ['whole-sign', 'equal', 'porphyry'] as const) {
+ it(`${name}: ${sys} still returns a full cusp ring`, () => {
+ const r = computeHouseCusps(sys, dateMs, lat, lon);
+ expect(r.cusps).toHaveLength(12);
+ for (const cusp of r.cusps) {
+ expect(Number.isFinite(cusp)).toBe(true);
+ }
+ });
+ }
+ }
+
+ // Koch circumpolar-MC samples: at these synthetic instants the MC degree's
+ // declination satisfies |tan φ · tan δ_MC| > 1 (verified by probing the
+ // implementation across the day at each site), so koch is geometrically
+ // undefined and must throw — not fall back.
+ const KOCH_CIRCUMPOLAR_CASES = [
+ {
+ name: 'north circumpolar MC (synthetic, 66.8N 2001-06-21T10:00Z)',
+ dateMs: Date.UTC(2001, 5, 21, 10, 0),
+ lat: 66.8,
+ lon: 25.7,
+ },
+ {
+ name: 'south circumpolar MC (synthetic, 75S 2013-01-10T12:00Z)',
+ dateMs: Date.UTC(2013, 0, 10, 12, 0),
+ lat: -75.0,
+ lon: 0.0,
+ },
+ ];
+ for (const { name, dateMs, lat, lon } of KOCH_CIRCUMPOLAR_CASES) {
+ it(`${name}: koch throws HOUSE_SYSTEM_UNAVAILABLE (no Porphyry fallback accepted)`, () => {
+ let thrown: unknown;
+ try {
+ computeHouseCusps('koch', dateMs, lat, lon);
+ } catch (e) {
+ thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(EngineError);
+ expect((thrown as EngineError).code).toBe(ERROR_CODES.HOUSE_SYSTEM_UNAVAILABLE);
+ });
+ }
+});
diff --git a/skills/calculate-birth-charts/scripts/dist/engine.mjs b/skills/calculate-birth-charts/scripts/dist/engine.mjs
index e947014..33afa74 100644
--- a/skills/calculate-birth-charts/scripts/dist/engine.mjs
+++ b/skills/calculate-birth-charts/scripts/dist/engine.mjs
@@ -51519,13 +51519,12 @@ function computeHouseCusps(system, dateMs, latDeg, lonEastDeg) {
break;
}
case "koch": {
- const declAsc = declinationOf(ascendant, eps);
- const sda = semiDiurnalArc(declAsc, latDeg);
- const sna = 180 - sda;
- cusps[10] = raToLambda(ramc + sda / 3, eps);
- cusps[11] = raToLambda(ramc + 2 * sda / 3, eps);
- cusps[1] = raToLambda(ramc + sda + sna / 3, eps);
- cusps[2] = raToLambda(ramc + sda + 2 * sna / 3, eps);
+ const declMc = declinationOf(mc, eps);
+ const sda = semiDiurnalArc(declMc, latDeg);
+ cusps[10] = ascendantLongitude(norm360(ramc - 2 * sda / 3), latDeg, eps);
+ cusps[11] = ascendantLongitude(norm360(ramc - sda / 3), latDeg, eps);
+ cusps[1] = ascendantLongitude(norm360(ramc + sda / 3), latDeg, eps);
+ cusps[2] = ascendantLongitude(norm360(ramc + 2 * sda / 3), latDeg, eps);
break;
}
case "placidus": {
diff --git a/tools/generate-house-golden.ts b/tools/generate-house-golden.ts
new file mode 100644
index 0000000..115e4be
--- /dev/null
+++ b/tools/generate-house-golden.ts
@@ -0,0 +1,357 @@
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseSwetestHouses } from './swetest-parse.ts';
+
+/**
+ * ONE-TIME external reference generator for the Western house golden.
+ *
+ * Runs a locally-installed Swiss Ephemeris `swetest` binary (path supplied
+ * EXPLICITLY via the SWETEST_PATH environment variable — this script never
+ * downloads anything, never adds a dependency, and is not part of the verify
+ * chain or the published Skill) and captures raw house-cusp output for the
+ * synthetic sample matrix below.
+ *
+ * Output protocol (everything under the UNTRACKED `.tmp/` tree):
+ * 1. All files are first written to a fresh staging directory
+ * `.tmp/house-golden-raw.staging-/`.
+ * 2. Only after ALL invocations succeed, ALL output parses, and both
+ * manifest.json and draft-fixture.json are written, the staging
+ * directory is atomically renamed to `.tmp/house-golden-raw/`. An
+ * existing `.tmp/house-golden-raw/` is first renamed aside to
+ * `.tmp/house-golden-raw.replaced-/` — never deleted, and stale
+ * raw files can never mix into a fresh capture.
+ * 3. On ANY failure the script exits 1, the staging directory stays where
+ * it is, and the final directory is never created or touched.
+ *
+ * Per-invocation artifacts inside the directory:
+ * - case--.stdout.txt / case--.stderr.txt
+ * - version.stdout.txt / version.stderr.txt (banner run)
+ * - manifest.json full argv per call, version line, capture UTC instant,
+ * separate SHA-256 for each stdout/stderr/argv, matrix
+ * - draft-fixture.json parsed draft (cusps 1-12, Asc, MC, ARMC) for HUMAN
+ * REVIEW before being copied into the tracked fixture
+ *
+ * The tracked fixture `packages/western/goldens/swiss-ephemeris-houses.json`
+ * is NEVER written by this script — populating it is a deliberate, reviewed
+ * step (see packages/western/goldens/README.md).
+ *
+ * Fail-closed: a spawn error, a signal, a non-zero exit, an error-looking
+ * stderr, a Porphyry-fallback hint for a quadrant system, any parse failure
+ * or any missing field aborts with exit 1. Failure messages are structural
+ * only — raw external output is written to the untracked staging files, not
+ * echoed into logs.
+ *
+ * All sample instants/coordinates are SYNTHETIC technical epochs — not any
+ * real person's birth data. Sample instants are UTC and are passed to swetest
+ * via `-utc` (NOT `-ut`, which means UT1).
+ */
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = join(here, '..');
+const tmpRoot = join(root, '.tmp');
+const finalDir = join(tmpRoot, 'house-golden-raw');
+
+/** Swiss Ephemeris house-system letters for the five systems this engine implements. */
+const HOUSE_LETTER: Record = {
+ placidus: 'P',
+ koch: 'K',
+ porphyry: 'O',
+ equal: 'E',
+ 'whole-sign': 'W',
+};
+
+interface SampleCase {
+ id: string;
+ description: string;
+ utc: { y: number; mo: number; d: number; h: number; mi: number; s: number };
+ latDeg: number;
+ lonEastDeg: number;
+}
+
+/** Synthetic technical sample matrix (NOT real birth data). */
+const CASES: SampleCase[] = [
+ {
+ id: 's1',
+ description: 'Northern mid-latitude, east longitude (synthetic)',
+ utc: { y: 1990, mo: 3, d: 10, h: 0, mi: 15, s: 0 },
+ latDeg: 30.5,
+ lonEastDeg: 114.3,
+ },
+ {
+ id: 's2',
+ description: 'Northern mid-latitude, west longitude (synthetic)',
+ utc: { y: 2000, mo: 7, d: 4, h: 16, mi: 0, s: 0 },
+ latDeg: 40.7,
+ lonEastDeg: -74.0,
+ },
+ {
+ id: 's3',
+ description: 'Southern mid-latitude, east longitude, December solstice (synthetic)',
+ utc: { y: 2010, mo: 12, d: 21, h: 3, mi: 30, s: 0 },
+ latDeg: -33.9,
+ lonEastDeg: 151.2,
+ },
+ {
+ id: 's4',
+ description: 'Near-equator low latitude, earlier era (synthetic)',
+ utc: { y: 1975, mo: 1, d: 30, h: 12, mi: 0, s: 0 },
+ latDeg: 1.35,
+ lonEastDeg: 103.8,
+ },
+ {
+ id: 's5',
+ description: 'Higher (but well-defined) northern latitude, summer (synthetic)',
+ utc: { y: 2024, mo: 6, d: 15, h: 18, mi: 45, s: 0 },
+ latDeg: 59.9,
+ lonEastDeg: 10.75,
+ },
+];
+
+function sha256(buf: string | Buffer): string {
+ return createHash('sha256').update(buf).digest('hex');
+}
+
+function pad2(n: number): string {
+ return String(n).padStart(2, '0');
+}
+
+/** swetest date argument: -bDD.MM.YYYY (gregorian) */
+function dateArg(c: SampleCase): string {
+ return `-b${pad2(c.utc.d)}.${pad2(c.utc.mo)}.${c.utc.y}`;
+}
+/**
+ * swetest UTC argument: -utcHH:MM:SS — input time scale is UTC (matches the
+ * samples' utcIso field), NOT -ut which means UT1.
+ */
+function utcArg(c: SampleCase): string {
+ return `-utc${pad2(c.utc.h)}:${pad2(c.utc.mi)}:${pad2(c.utc.s)}`;
+}
+/** swetest house argument: -house,, (east-positive longitude) */
+function houseArg(c: SampleCase, letter: string): string {
+ return `-house${c.lonEastDeg},${c.latDeg},${letter}`;
+}
+
+interface RunResult {
+ stdout: string;
+ stderr: string;
+}
+
+/**
+ * Run swetest once, fail-closed. Any spawn error, signal (incl. timeout) or
+ * non-zero exit aborts immediately — a non-empty stdout is NOT a pass.
+ * Failure messages stay structural; raw output is never echoed into logs.
+ */
+function runSwetestOrExit(swetestPath: string, argv: string[], label: string): RunResult {
+ const res = spawnSync(swetestPath, argv, { encoding: 'utf8', timeout: 60_000 });
+ if (res.error) {
+ process.stdout.write(`[FAIL] swetest could not be spawned for ${label} (spawn error).\n`);
+ process.exit(1);
+ }
+ if (res.signal !== null) {
+ process.stdout.write(
+ `[FAIL] swetest was terminated by a signal for ${label} (possible timeout).\n`,
+ );
+ process.exit(1);
+ }
+ if (res.status !== 0) {
+ process.stdout.write(`[FAIL] swetest exited with status ${res.status} for ${label}.\n`);
+ process.exit(1);
+ }
+ return { stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
+}
+
+function main(): void {
+ const swetestPath = process.env.SWETEST_PATH;
+ if (!swetestPath) {
+ process.stdout.write(
+ '[FAIL] SWETEST_PATH is not set.\n' +
+ '\n' +
+ 'This one-time generator requires a locally installed Swiss Ephemeris\n' +
+ '`swetest` binary supplied EXPLICITLY via the environment:\n' +
+ '\n' +
+ ' PowerShell:\n' +
+ " $env:SWETEST_PATH = 'C:\\path\\to\\swetest.exe'; node --experimental-strip-types tools/generate-house-golden.ts\n" +
+ ' POSIX:\n' +
+ ' SWETEST_PATH=/path/to/swetest node --experimental-strip-types tools/generate-house-golden.ts\n' +
+ '\n' +
+ 'The script never downloads anything and adds no dependency. See\n' +
+ 'packages/western/goldens/README.md for the full capture workflow.\n',
+ );
+ process.exit(1);
+ }
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const stagingDir = join(tmpRoot, `house-golden-raw.staging-${stamp}`);
+ mkdirSync(stagingDir, { recursive: true });
+
+ // Capture the tool banner for provenance (swetest -h prints version+usage).
+ const versionArgv = ['-h'];
+ const versionRun = runSwetestOrExit(swetestPath, versionArgv, 'version banner');
+ writeFileSync(join(stagingDir, 'version.stdout.txt'), versionRun.stdout, 'utf8');
+ writeFileSync(join(stagingDir, 'version.stderr.txt'), versionRun.stderr, 'utf8');
+ // Only an explicit `Version:` line is an auditable tool identifier — never
+ // fall back to descriptive lines that merely mention swetest.
+ const versionLine =
+ `${versionRun.stdout}\n${versionRun.stderr}`
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .find((l) => /^Version:\s*\S/.test(l)) ?? null;
+ if (!versionLine) {
+ process.stdout.write('[FAIL] could not find a `Version:` line in the swetest help output.\n');
+ process.exit(1);
+ }
+
+ const manifest: {
+ generatedAtUtc: string;
+ swetestPathNote: string;
+ versionLine: string;
+ version: {
+ argv: string[];
+ argvSha256: string;
+ stdoutFile: string;
+ stdoutSha256: string;
+ stderrFile: string;
+ stderrSha256: string;
+ };
+ calls: {
+ caseId: string;
+ system: string;
+ argv: string[];
+ argvSha256: string;
+ stdoutFile: string;
+ stdoutSha256: string;
+ stderrFile: string;
+ stderrSha256: string;
+ }[];
+ cases: SampleCase[];
+ referenceInputTimeScale: string;
+ coordinateSemantics: string;
+ } = {
+ generatedAtUtc: new Date().toISOString(),
+ swetestPathNote:
+ 'local path supplied via SWETEST_PATH env; binary is NOT part of this repository',
+ versionLine,
+ version: {
+ argv: versionArgv,
+ argvSha256: sha256(JSON.stringify(versionArgv)),
+ stdoutFile: 'version.stdout.txt',
+ stdoutSha256: sha256(versionRun.stdout),
+ stderrFile: 'version.stderr.txt',
+ stderrSha256: sha256(versionRun.stderr),
+ },
+ calls: [],
+ cases: CASES,
+ referenceInputTimeScale: 'UTC',
+ coordinateSemantics:
+ 'tropical ecliptic of date, geocentric; input longitude east-positive; -emos Moshier (reference command requires no ephemeris data files in the repository)',
+ };
+
+ const draft: {
+ note: string;
+ cases: {
+ id: string;
+ description: string;
+ utcIso: string;
+ latDeg: number;
+ lonEastDeg: number;
+ systems: Record<
+ string,
+ { cusps: number[]; ascendant: number; mc: number; armc: number | null }
+ >;
+ }[];
+ } = {
+ note: 'DRAFT parsed from raw swetest output — requires human review before entering the tracked fixture. All samples are synthetic technical epochs.',
+ cases: [],
+ };
+
+ for (const c of CASES) {
+ const utcIso = `${c.utc.y}-${pad2(c.utc.mo)}-${pad2(c.utc.d)}T${pad2(c.utc.h)}:${pad2(c.utc.mi)}:${pad2(c.utc.s)}Z`;
+ const systems: Record<
+ string,
+ { cusps: number[]; ascendant: number; mc: number; armc: number | null }
+ > = {};
+ for (const [system, letter] of Object.entries(HOUSE_LETTER)) {
+ const label = `${c.id}/${system}`;
+ const argv = [dateArg(c), utcArg(c), houseArg(c, letter), '-emos', '-head'];
+ const { stdout, stderr } = runSwetestOrExit(swetestPath, argv, label);
+
+ // Raw streams go to the untracked staging dir FIRST so a failing call
+ // still leaves its evidence on disk for local inspection.
+ const stdoutFile = `case-${c.id}-${system}.stdout.txt`;
+ const stderrFile = `case-${c.id}-${system}.stderr.txt`;
+ writeFileSync(join(stagingDir, stdoutFile), stdout, 'utf8');
+ writeFileSync(join(stagingDir, stderrFile), stderr, 'utf8');
+
+ // Fail-closed: for quadrant systems, refuse output that mentions a
+ // silent Porphyry fallback (swetest does this at circumpolar
+ // latitudes) — on EITHER stream.
+ if (
+ (system === 'placidus' || system === 'koch') &&
+ /porphyry/i.test(`${stdout}\n${stderr}`)
+ ) {
+ process.stdout.write(
+ `[FAIL] swetest silently fell back to Porphyry for ${label}; ` +
+ 'this sample cannot be used for a quadrant-system golden.\n',
+ );
+ process.exit(1);
+ }
+ // Any error-looking stderr aborts, whatever the exit code said.
+ if (/error|warning|invalid|cannot/i.test(stderr)) {
+ process.stdout.write(
+ `[FAIL] swetest wrote an error-looking message on stderr for ${label} ` +
+ `(see ${stderrFile} in the staging directory).\n`,
+ );
+ process.exit(1);
+ }
+
+ manifest.calls.push({
+ caseId: c.id,
+ system,
+ argv,
+ argvSha256: sha256(JSON.stringify(argv)),
+ stdoutFile,
+ stdoutSha256: sha256(stdout),
+ stderrFile,
+ stderrSha256: sha256(stderr),
+ });
+ systems[system] = parseSwetestHouses(stdout, label);
+ }
+ draft.cases.push({
+ id: c.id,
+ description: c.description,
+ utcIso,
+ latDeg: c.latDeg,
+ lonEastDeg: c.lonEastDeg,
+ systems,
+ });
+ }
+
+ writeFileSync(
+ join(stagingDir, 'manifest.json'),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ 'utf8',
+ );
+ writeFileSync(
+ join(stagingDir, 'draft-fixture.json'),
+ `${JSON.stringify(draft, null, 2)}\n`,
+ 'utf8',
+ );
+
+ // Atomic publish: only a COMPLETE capture ever becomes .tmp/house-golden-raw/.
+ // A pre-existing directory is renamed aside (never deleted, never merged).
+ if (existsSync(finalDir)) {
+ renameSync(finalDir, join(tmpRoot, `house-golden-raw.replaced-${stamp}`));
+ }
+ renameSync(stagingDir, finalDir);
+
+ process.stdout.write(
+ `[OK] captured ${manifest.calls.length} swetest invocations into .tmp/house-golden-raw/\n` +
+ ' Review draft-fixture.json + manifest.json, then hand them to the\n' +
+ ' populate step (see packages/western/goldens/README.md).\n',
+ );
+}
+
+main();
diff --git a/tools/swetest-parse.test.ts b/tools/swetest-parse.test.ts
new file mode 100644
index 0000000..2877783
--- /dev/null
+++ b/tools/swetest-parse.test.ts
@@ -0,0 +1,167 @@
+// Fully OFFLINE unit tests for the strict swetest output parser. Every input
+// below is a SYNTHETIC string written by hand — no real external tool output
+// is referenced, stored or replayed here.
+import { describe, expect, it } from 'vitest';
+import { parseSwetestAngle, parseSwetestHouses } from './swetest-parse.ts';
+
+describe('parseSwetestAngle', () => {
+ it('parses a DMS field to full precision (official -house shape)', () => {
+ expect(parseSwetestAngle("93°10'50.7734")).toBeCloseTo(93 + 10 / 60 + 50.7734 / 3600, 9);
+ });
+
+ it('parses a DMS field with a space before the seconds', () => {
+ expect(parseSwetestAngle("94°45' 1.3614")).toBeCloseTo(94 + 45 / 60 + 1.3614 / 3600, 9);
+ });
+
+ it('parses plain decimal degrees', () => {
+ expect(parseSwetestAngle('123.4567890')).toBeCloseTo(123.456789, 9);
+ expect(parseSwetestAngle('0')).toBe(0);
+ });
+
+ it('accepts the top of the valid range but rejects 360 itself', () => {
+ expect(parseSwetestAngle("359°59'59.999")).toBeLessThan(360);
+ expect(() => parseSwetestAngle("360°00'00.0")).toThrow();
+ expect(() => parseSwetestAngle('361.5')).toThrow();
+ });
+
+ it('rejects incomplete DMS fields (missing minutes or seconds)', () => {
+ expect(() => parseSwetestAngle('93°')).toThrow();
+ expect(() => parseSwetestAngle("93°10'")).toThrow();
+ });
+
+ it('rejects garbage, signs and empty input', () => {
+ expect(() => parseSwetestAngle('abc')).toThrow();
+ expect(() => parseSwetestAngle("93°10'5x.3")).toThrow();
+ expect(() => parseSwetestAngle('-5.0')).toThrow();
+ expect(() => parseSwetestAngle('')).toThrow();
+ expect(() => parseSwetestAngle(' ')).toThrow();
+ });
+
+ it('rejects out-of-range minutes and seconds components', () => {
+ expect(() => parseSwetestAngle("93°61'10.0")).toThrow();
+ expect(() => parseSwetestAngle("93°10'75.0")).toThrow();
+ });
+
+ it('rejects partial matches — the whole field must be consumed', () => {
+ expect(() => parseSwetestAngle("93°10'50.7734 junk")).toThrow();
+ expect(() => parseSwetestAngle('123.456 extra')).toThrow();
+ });
+
+ it('never echoes the raw field content in error messages', () => {
+ for (const bad of ["93°10'50.7734 junk", 'abc', '-5.0', "93°61'10.0"]) {
+ let message = '';
+ try {
+ parseSwetestAngle(bad, 'ctx');
+ } catch (e) {
+ message = (e as Error).message;
+ }
+ expect(message).not.toBe('');
+ expect(message).not.toContain(bad);
+ }
+ });
+});
+
+/**
+ * Build a synthetic, fully-populated TWO-COLUMN swetest -house stdout.
+ * Column 1 mixes DMS and decimal forms; column 2 is always a valid DMS
+ * lexeme (including one with an internal space before the seconds) whose
+ * value must never leak into the parsed result.
+ */
+function syntheticStdout(omit?: 'house7' | 'Ascendant' | 'MC'): string {
+ const lines: string[] = [];
+ for (let i = 1; i <= 12; i++) {
+ if (omit === 'house7' && i === 7) continue;
+ // Alternate DMS and decimal forms in column 1 to exercise both branches.
+ const base = (i * 30 + 3) % 360;
+ const col1 = i % 2 === 0 ? `${base}.5000000` : `${base}°15'30.0000`;
+ // One second column with an internal space before the seconds.
+ const col2 = i === 3 ? "94°45' 1.3614" : "12°34'56.7890";
+ lines.push(`house ${i} ${col1} ${col2}`);
+ }
+ if (omit !== 'Ascendant') lines.push("Ascendant 93°10'50.7734 8°15'20.0000");
+ if (omit !== 'MC') lines.push("MC 1.2345678 88°59'59.0000");
+ lines.push("ARMC 359°59'59.999 0°01'02.0000");
+ lines.push('Vertex 123.0000000'); // recognised-but-unused label
+ return `${lines.join('\n')}\n`;
+}
+
+describe('parseSwetestHouses', () => {
+ it('parses a complete two-column synthetic output (first column only enters the result)', () => {
+ const r = parseSwetestHouses(syntheticStdout(), 'synthetic/full');
+ expect(r.cusps).toHaveLength(12);
+ expect(r.cusps[0]).toBeCloseTo(33 + 15 / 60 + 30 / 3600, 9); // house 1 col1: 33°15'30.0000
+ expect(r.cusps[1]).toBeCloseTo(63.5, 9); // house 2 col1: 63.5000000
+ expect(r.cusps[2]).toBeCloseTo(93 + 15 / 60 + 30 / 3600, 9); // house 3 col1 (col2 has internal space)
+ expect(r.ascendant).toBeCloseTo(93 + 10 / 60 + 50.7734 / 3600, 9);
+ expect(r.mc).toBeCloseTo(1.2345678, 9);
+ expect(r.armc).toBeCloseTo(359 + 59 / 60 + 59.999 / 3600, 9);
+ });
+
+ it('rejects a house line missing its second column', () => {
+ const bad = syntheticStdout().replace(
+ "house 1 33°15'30.0000 12°34'56.7890",
+ "house 1 33°15'30.0000",
+ );
+ expect(() => parseSwetestHouses(bad, 'synthetic/one-col')).toThrow(/malformed two-column/);
+ });
+
+ it('rejects a house line with a third column', () => {
+ const bad = syntheticStdout().replace(
+ "house 1 33°15'30.0000 12°34'56.7890",
+ "house 1 33°15'30.0000 12°34'56.7890 7°08'09.0000",
+ );
+ expect(() => parseSwetestHouses(bad, 'synthetic/three-col')).toThrow(/malformed two-column/);
+ });
+
+ it('rejects a garbled second column', () => {
+ const bad = syntheticStdout().replace(
+ "house 1 33°15'30.0000 12°34'56.7890",
+ "house 1 33°15'30.0000 garbage",
+ );
+ expect(() => parseSwetestHouses(bad, 'synthetic/garbled-col2')).toThrow(/malformed two-column/);
+ });
+
+ it('rejects an incomplete DMS second column', () => {
+ const bad = syntheticStdout().replace(
+ "house 1 33°15'30.0000 12°34'56.7890",
+ "house 1 33°15'30.0000 94°45'",
+ );
+ expect(() => parseSwetestHouses(bad, 'synthetic/incomplete-col2')).toThrow(
+ /malformed two-column/,
+ );
+ });
+
+ it('rejects output with a missing house cusp', () => {
+ expect(() => parseSwetestHouses(syntheticStdout('house7'), 'synthetic/no-h7')).toThrow(
+ /missing house cusps 7/,
+ );
+ });
+
+ it('rejects output with a missing Ascendant or MC', () => {
+ expect(() => parseSwetestHouses(syntheticStdout('Ascendant'), 'synthetic/no-asc')).toThrow(
+ /missing Ascendant/,
+ );
+ expect(() => parseSwetestHouses(syntheticStdout('MC'), 'synthetic/no-mc')).toThrow(
+ /missing MC/,
+ );
+ });
+
+ it('rejects a malformed first-column value inside otherwise valid output', () => {
+ const bad = syntheticStdout().replace("house 1 33°15'30.0000", 'house 1 33°garbled');
+ expect(() => parseSwetestHouses(bad, 'synthetic/garbled')).toThrow();
+ });
+
+ it('never embeds raw line content in error messages', () => {
+ const sentinel = "33°15'30.0000GARBLEDSENTINEL";
+ const bad = syntheticStdout().replace("house 1 33°15'30.0000", `house 1 ${sentinel}`);
+ let message = '';
+ try {
+ parseSwetestHouses(bad, 'synthetic/sentinel');
+ } catch (e) {
+ message = (e as Error).message;
+ }
+ expect(message).not.toBe('');
+ expect(message).not.toContain(sentinel);
+ expect(message).not.toContain('GARBLEDSENTINEL');
+ });
+});
diff --git a/tools/swetest-parse.ts b/tools/swetest-parse.ts
new file mode 100644
index 0000000..77f499c
--- /dev/null
+++ b/tools/swetest-parse.ts
@@ -0,0 +1,170 @@
+/**
+ * Strict parser for Swiss Ephemeris `swetest -house` output.
+ *
+ * Pure module: no I/O, no environment access, no side effects — imported by
+ * both the one-time generator (tools/generate-house-golden.ts) and its fully
+ * OFFLINE unit tests (tools/swetest-parse.test.ts).
+ *
+ * Fail-closed by design: every numeric field must be consumed IN FULL (no
+ * partial matches), and every thrown error is structural only — it never
+ * echoes raw external output back, so nothing from a swetest run can leak
+ * into tracked logs via error messages.
+ */
+
+/** swetest DMS field, e.g. `93°10'50.7734` or `94°45' 1.3614` (space before seconds). */
+const DMS_RE = /^(\d{1,3})°\s*(\d{1,2})'\s*(\d{1,2}(?:\.\d+)?)"?$/;
+/** Plain decimal-degree field, e.g. `123.4567890`. */
+const DECIMAL_RE = /^\d{1,3}(?:\.\d+)?$/;
+
+// Non-anchored lexemes for splitting the TWO-COLUMN swetest line shape.
+// A DMS field may contain an internal space before the seconds, so columns
+// cannot be split on whitespace — the anchored two-column regex below
+// separates them by lexical shape (with backtracking), and its ^…$ anchors
+// reject a missing second column, a third column and any trailing garbage.
+const DMS_LEX = /\d{1,3}°\s*\d{1,2}'\s*\d{1,2}(?:\.\d+)?"?/;
+const FIELD_LEX = new RegExp(`(?:${DMS_LEX.source}|\\d{1,3}(?:\\.\\d+)?)`);
+const TWO_COL_RE = new RegExp(`^(${FIELD_LEX.source})\\s+(${DMS_LEX.source})$`);
+
+/**
+ * Parse a single swetest angle field into decimal degrees.
+ *
+ * Accepts exactly two shapes — DMS (`D°M'S[.frac]["]`) or decimal degrees —
+ * anchored over the WHOLE trimmed field. Anything else (missing minutes or
+ * seconds, stray characters, signs, trailing garbage, empty input) throws.
+ * The result must lie in [0, 360).
+ */
+export function parseSwetestAngle(field: string, context = 'angle'): number {
+ const trimmed = field.trim();
+ if (trimmed === '') {
+ throw new Error(`empty angle field (${context})`);
+ }
+
+ let value: number;
+ const dms = DMS_RE.exec(trimmed);
+ if (dms) {
+ const deg = Number(dms[1]);
+ const min = Number(dms[2]);
+ const sec = Number(dms[3]);
+ if (min >= 60) {
+ throw new Error(`minutes out of range (${context})`);
+ }
+ if (sec >= 60) {
+ throw new Error(`seconds out of range (${context})`);
+ }
+ value = deg + min / 60 + sec / 3600;
+ } else if (DECIMAL_RE.test(trimmed)) {
+ value = Number(trimmed);
+ } else {
+ throw new Error(`malformed angle field (${context})`);
+ }
+
+ if (!Number.isFinite(value) || value < 0 || value >= 360) {
+ throw new Error(`angle out of [0,360) range (${context})`);
+ }
+ return value;
+}
+
+export interface ParsedHouse {
+ cusps: number[]; // index 0 = house 1 ... index 11 = house 12
+ ascendant: number;
+ mc: number;
+ armc: number | null;
+}
+
+/**
+ * Split a recognised line's remainder into its two columns and return the
+ * parsed FIRST column. Column 1 must be a strictly valid DMS or decimal
+ * angle in [0,360) (re-checked by parseSwetestAngle's full-consumption
+ * contract) and is the value we keep. Column 2 must be present and fully
+ * match the swetest DMS lexeme, but is deliberately NOT captured as a golden
+ * value. Anything else — missing second column, a third column, trailing
+ * garbage, a garbled or incomplete second column — is rejected. The error
+ * stays structural: no raw external content is echoed.
+ */
+function parseTwoColumnValue(rest: string, where: string): number {
+ const m = TWO_COL_RE.exec(rest.trim());
+ if (!m) {
+ throw new Error(`malformed two-column line (${where})`);
+ }
+ return parseSwetestAngle(m[1]!, where);
+}
+
+/**
+ * Parse full swetest -house output. Standard lines are TWO-COLUMN (with
+ * -head suppressing the header):
+ * house 1 93°10'50.7734
+ * ...
+ * house 12 ... ...
+ * Ascendant ... ...
+ * MC ... ...
+ * ARMC ... ...
+ * The remainder after each recognised label must match the two-column shape
+ * exactly; only the first column enters the result. Errors carry only the
+ * caller-supplied context, the 1-based line number and structural facts —
+ * never raw line content.
+ */
+export function parseSwetestHouses(stdout: string, context: string): ParsedHouse {
+ const cusps: (number | undefined)[] = new Array(12).fill(undefined);
+ let ascendant: number | undefined;
+ let mc: number | undefined;
+ let armc: number | null = null;
+
+ const lines = stdout.split(/\r?\n/);
+ for (let n = 0; n < lines.length; n++) {
+ const line = lines[n]!.trim();
+ const where = `${context} line ${n + 1}`;
+ if (line === '') continue;
+
+ const h = /^house\s+(\d{1,2})\s+(.+)$/i.exec(line);
+ if (h) {
+ const idx = Number(h[1]) - 1;
+ if (idx < 0 || idx > 11) {
+ throw new Error(`house number out of range (${where})`);
+ }
+ if (cusps[idx] !== undefined) {
+ throw new Error(`duplicate house ${idx + 1} line (${where})`);
+ }
+ cusps[idx] = parseTwoColumnValue(h[2]!, where);
+ continue;
+ }
+ const asc = /^Ascendant\s+(.+)$/i.exec(line);
+ if (asc) {
+ if (ascendant !== undefined) {
+ throw new Error(`duplicate Ascendant line (${where})`);
+ }
+ ascendant = parseTwoColumnValue(asc[1]!, where);
+ continue;
+ }
+ const armcM = /^ARMC\s+(.+)$/i.exec(line);
+ if (armcM) {
+ if (armc !== null) {
+ throw new Error(`duplicate ARMC line (${where})`);
+ }
+ armc = parseTwoColumnValue(armcM[1]!, where);
+ continue;
+ }
+ const mcM = /^MC\s+(.+)$/i.exec(line);
+ if (mcM) {
+ if (mc !== undefined) {
+ throw new Error(`duplicate MC line (${where})`);
+ }
+ mc = parseTwoColumnValue(mcM[1]!, where);
+ continue;
+ }
+ // Vertex / equat. Asc. / co-Asc. / polar Asc. etc — recognised but unused.
+ }
+
+ const missing = cusps
+ .map((v, i) => (v === undefined ? i + 1 : null))
+ .filter((x): x is number => x !== null);
+ if (missing.length > 0) {
+ throw new Error(`missing house cusps ${missing.join(',')} (${context})`);
+ }
+ if (ascendant === undefined) {
+ throw new Error(`missing Ascendant (${context})`);
+ }
+ if (mc === undefined) {
+ throw new Error(`missing MC (${context})`);
+ }
+ return { cusps: cusps as number[], ascendant, mc, armc };
+}