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