From 72a0c9b20b6608c5af5f1ed15a0c83f97d582805 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:04:32 -0700 Subject: [PATCH 01/22] chore: add data-enrichment plan + gitignore workspace Plan covers 11 units across two tracks (zh canon + types + renderer + CLI on Track A; Legge cleanup + integration on Track B). data-acquisition/ holds verified pulls and intermediate scaffolding; the structured JSON outputs feed U2/U3/U8/U10 backfills but only the resulting data/ modules ship. Source pulls do not travel with the repo. --- .gitignore | 1 + ...026-05-30-001-feat-data-enrichment-plan.md | 416 ++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 docs/plans/2026-05-30-001-feat-data-enrichment-plan.md diff --git a/.gitignore b/.gitignore index 47b3c74..84cea1c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ publish/iching.js .prov/ logs/ .research/ +data-acquisition/ diff --git a/docs/plans/2026-05-30-001-feat-data-enrichment-plan.md b/docs/plans/2026-05-30-001-feat-data-enrichment-plan.md new file mode 100644 index 0000000..ddb18d5 --- /dev/null +++ b/docs/plans/2026-05-30-001-feat-data-enrichment-plan.md @@ -0,0 +1,416 @@ +--- +title: Data Enrichment — Canonical Wings + Legge +type: feat +status: active +date: 2026-05-30 +origin: data-acquisition/complementary-mapping.md +--- + +# Data Enrichment — Canonical Wings + Legge + +Second-wave canonical-text addition to the I Ching app data layer. Adds five canonical Chinese text layers (卦辭, 小象傳, 說卦傳, 序卦傳, 雜卦傳) and one English translation lineage (James Legge, SBE vol. XVI, 1882), all public domain. Extends the `Hexagram` and `TrigramInfo` types with optional fields, adds a text-bearing relations overlay (`connections`) on top of the existing pure-numeric derivations, and expands the detail renderer to surface the new layers. + +## Architecture Decision + +**Approach:** *Pure derivations + connections overlay + flat optional Hexagram fields.* + +1. **Existing `derivation/*.ts` modules stay pure** — signature `(lines: Line[]) => number`. No mutation, no I/O, no text bundling. They remain the structural / numeric layer. +2. **New `derivation/connections.ts` overlay** — signature `(cast: Cast) => CastConnections`. Takes a computed `Cast` and produces a text-bearing overlay carrying `xuGua` (sequence narrative), `zaGuaPair` (contrast pair with text), and `shuoguaCitations` (chapter references per derivation type). This is *not* a derivation in the existing sense — it's a join between derived KW numbers and canonical text data. +3. **All new `Hexagram` fields are flat and optional** — `gc?`, `gcEn?`, `yaoXiao?[6]`, `yaoXiaoEn?[6]`, `legge?: LeggeHexagram`. Matches the existing flat 11-field shape in `packages/core/src/types.ts:11-25`; preserves backward compatibility with every existing consumer (no required-field churn). +4. **`TrigramInfo` extended with `assoc?: TrigramAssoc`** — the structured 說卦 catalogue lives next to its existing carrier in `packages/core/src/data/trigrams.ts`, populated from `data/shuogua.ts#TRIGRAM_ASSOC`. Canonical zh stays in `shuogua.ts`; project-authored English glosses live in `trigrams.ts` (clearly labeled non-canonical). +5. **Stacked-voice rendering is voice-driven, content-gated** — each text node carries `{ zh, modernEn?, wilhelmEn?, leggeEn? }`; the renderer iterates voices and skips rows with no content. No special-cased branching per text type. Wings naturally render zh + Legge only (no Wilhelm voice exists for Wings); cast text renders all three when populated. + +**Rationale:** Decided by the **Consistency** criterion. The existing repo pattern is: pure-function derivations (`derivation/*.ts:6` all share `(lines: Line[]) => number`), flat `Hexagram` interface (`types.ts:11-25` — zero nesting), named-const data modules (`data/gua.ts:4`, `data/trigrams.ts:3`), all-required fields with optional add-ons via `?:` (precedent: `DailyCache.intention?` at `types.ts:84`). This approach extends each pattern in its own direction without breaking any of them. The "fold 序卦/雜卦 into a unified `relations.ts`" reframe is implemented at the *render* layer (UI groups numeric derivations + text-bearing connections under one "relations" block) — not at the data layer, which keeps separation of concerns. + +**Trade-offs accepted:** +- Two distinct data shapes in the relations block at render time (numeric derivation badges + text-bearing connection rows). Renderer handles the visual unification. +- New `Style` union grows (`gc`, `yaoXiao`) — touches every hardcoded `STYLES` / `VALID_STYLES` list in lockstep (3 sites). Mitigated by U7 batching all updates in one commit. +- Legge ships *after* a dedicated cleanup re-pull. The integration unit (U10) depends on the cleanup workflow (U9) completing first. + +## High-Level Technical Design + +### Composition Matrix — Hexagram model shape change + +Hexagram changes from a single 5-field commentary model to a composed canonical-text model. Surfaces that observe Hexagram must handle every combination. + +**Old scalar assumption:** "the commentary fields are `dx/tu/en/te/w`." Lives in: `commands/hexagram.ts:7` (`VALID_STYLES`), `data/trigrams.ts:14-15` (`STYLES`/`QUOTE_STYLES`), `format/reading.ts:31` (`g[style]` dynamic access), `output/json.ts:65-70` (enumeration), `output/plain.ts:64-68` (5-line emission), `scenes/dict/detail-renderer.ts:78-93` (sections array of 5 entries). + +**New composed model members:** + +| Member | Carrier | Provenance | Optional? | Languages | +|---|---|---|---|---| +| `dx`, `tu` | `Hexagram` | canonical zh — existing | required (legacy) | zh only | +| `en`, `te` | `Hexagram` | anonymous modern English — existing | required (legacy) | en only | +| `w` | `Hexagram` | Wilhelm-flavored synthesis — existing | required (legacy) | en only | +| `yao`, `yaoEn` | `Hexagram` | canonical zh + modern en — existing | required (legacy) | zh + en | +| **`gc`, `gcEn`** | `Hexagram` (NEW) | canonical zh 卦辭 + Legge en | optional | zh + en | +| **`yaoXiao`, `yaoXiaoEn`** | `Hexagram` (NEW) | canonical zh 小象傳 + Legge en | optional | zh + en | +| **`legge`** | `Hexagram.legge?` | Legge 1882, post-cleanup only | optional | en only | +| **`assoc`** | `TrigramInfo.assoc?` | canonical zh 說卦 + project-authored en gloss | optional | zh + en (gloss) | +| **connections** | `CastConnections` overlay (NOT Hexagram) | join over XU_GUA, ZA_GUA, SHUOGUA | always-built but rows may be empty | zh + en (where Legge ships) | + +**Priority lattice (when a surface must pick ONE):** +1. zh canonical (always present once shipped). +2. Legge en (when shipped post-cleanup). +3. Wilhelm-flavored en (`w`, existing). +4. Modern anonymous en (`en`/`te`/`yaoEn`). + +The stacked-voice renderer does NOT pick — it iterates all populated voices in this order. Only single-voice surfaces (CLI `--style` flag) pick. + +**Ownership boundary:** +- `packages/core/src/data/*.ts` — owns canonical text + structured catalogues. +- `packages/core/src/derivation/connections.ts` — owns the overlay join (Cast → CastConnections). +- `packages/core/src/detail.ts` — owns aggregation (`HexagramDetail` carries Hexagram + derivations + connections). +- `packages/terminal/src/scenes/dict/detail-renderer.ts` — owns visual composition / stacking. +- `apps/cli/src/output/{json,plain}.ts` — owns serialization parity. + +**Composition test matrix (named tests; lock in U6 and U11):** + +| Mixed case | Expected visible contract | Test name | +|---|---|---| +| Hexagram with no new fields populated (current state) | output identical to today's; no empty sections rendered | `detail-renderer renders legacy 5-field hexagram unchanged` | +| Hexagram with gc, yaoXiao populated but no legge | new canonical sections appear; voice stack shows zh + (modern/Wilhelm) only | `detail-renderer renders new zh canon without legge` | +| Hexagram with everything (incl. legge) | three-voice stack on Judgment, Image, every changing line | `detail-renderer renders full three-voice stack` | +| Cast where 雜卦 pair degenerates via polarity (hex 1, 27, 29, 61) | zaGuaPair row shows polarity-fallback partner; xuGua row shows previous in King Wen | `connections handles polarity-fallback hexagrams` | +| 雜卦 entry from the disordered final 8 | row renders the canonical disordered pairing; no orbit-integrity warning surfaced to user | `connections renders disordered-tail entries faithfully` | +| CLI `--style gc` for hexagram lacking gc population | typed error / fallback to existing field, NOT undefined access | `cli hexagram --style gc errors cleanly when absent` | + +### Layer diagram (directional) + +``` + ┌──────────────────────────────────────────┐ + │ packages/terminal/src/scenes/dict │ + │ detail-renderer.ts (sections stack) │ ← U6 + │ detail-model.ts (links + connections) │ + └────────────────┬─────────────────────────┘ + │ reads + ┌────────────────▼─────────────────────────┐ + │ packages/core/src │ + │ detail.ts (HexagramDetail aggregator) │ ← U5 + │ ├── nuclear / polarity / mirror / │ + │ │ diagonal / locked-pairs (PURE) │ unchanged + │ └── connections.ts (overlay) │ ← U5 new + └────────────────┬─────────────────────────┘ + │ joins + ┌──────────────────────┼──────────────────────────────┐ + ▼ ▼ ▼ + ┌──────────────┐ ┌────────────────────┐ ┌─────────────────┐ + │ data/gua.ts │ │ data/xugua.ts │ ← U2 │ data/shuogua.ts │ ← U3 + │ GUA │ │ data/zagua.ts │ ← U2 │ SHUOGUA │ + │ + gc? │ │ XU_GUA │ │ TRIGRAM_ASSOC │ + │ + gcEn? │ │ ZA_GUA │ └─────────────────┘ + │ + yaoXiao? │ │ ZA_GUA_BY_HEX │ + │ + legge? │ └────────────────────┘ + └──────────────┘ ┌─────────────────┐ + │ data/legge.ts │ ← U10 + │ LEGGE │ + │ LEGGE_*_EN │ + └─────────────────┘ +``` + +## Implementation Units + +### U1. Type extensions — types.ts + +- **Goal:** Land all new TypeScript types as optional extensions. Compiles green; no field populated yet. +- **Requirements:** Foundation for every subsequent unit. +- **Dependencies:** None. +- **Files:** + - Modify: `packages/core/src/types.ts` + - Test: `packages/core/src/__tests__/types.compile.test.ts` (new, type-only test) +- **Approach:** Add to `Hexagram` (lines 11-25 region): `gc?`, `gcEn?`, `yaoXiao?: string[]`, `yaoXiaoEn?: string[]`, `legge?: LeggeHexagram`. Add new interfaces `LeggeHexagram`, `TrigramAssoc`, `XuGuaEntry`, `ZaGuaEntry`, `ShuoguaChapter`, `ShuoguaCitation`, `CastConnections`. Extend `TrigramInfo` (line 65) with `assoc?: TrigramAssoc`. Extend `Style` union to `"dx" | "tu" | "en" | "te" | "w" | "st" | "gc" | "yaoXiao"`; update `QuoteStyle` derivation. Keep everything flat (no nested fields on Hexagram). +- **Patterns to follow:** `types.ts:84` (`DailyCache.intention?`) — optional-field convention; `types.ts:65-69` (`TrigramInfo`) — interface shape. +- **Test scenarios:** + - *Happy path:* a Hexagram literal with no new fields type-checks against the new interface. + - *Edge case:* a Hexagram literal that populates every new optional field type-checks. + - *Negative:* a Hexagram literal missing `dx` still fails compilation (proves the legacy required fields stay required). +- **Verification:** `bun run typecheck` passes; all existing 64 entries in `gua.ts` still type-check unchanged. + +### U2. 序卦 + 雜卦 data modules + +- **Goal:** Promote the verified `data-acquisition/xugua-zagua.json` (3/3 verdicts) into permanent data modules. +- **Requirements:** Source data for the connections overlay (U5). +- **Dependencies:** U1. +- **Files:** + - Create: `packages/core/src/data/xugua.ts` + - Create: `packages/core/src/data/zagua.ts` + - Modify: `packages/core/src/index.ts` (re-export the new constants) + - Test: `packages/core/src/__tests__/xugua-zagua.test.ts` +- **Approach:** Read `data-acquisition/xugua-zagua.json` and transcribe verbatim into: + - `xugua.ts`: `export const XU_GUA: XuGuaEntry[]` (64 entries; preserve the editorial notes on 乾/坤 shared preamble, 離 merge, 咸 lower-jing preamble that are documented in the JSON's `_meta`). + - `zagua.ts`: `export const ZA_GUA: ZaGuaEntry[]` (53 entries) + `export const ZA_GUA_BY_HEX: Record` (reverse index — many-to-one because the disordered tail collapses several hexagrams into single entries). + - Both modules also export the `_meta` block as a sibling const so source attribution lives next to the data. +- **Patterns to follow:** `data/gua.ts:4` (named const array shape); `data/trigrams.ts:3` (multiple exports per data file). +- **Test scenarios:** + - *Happy path:* `XU_GUA.length === 64`; every entry has a non-empty `text`. + - *Coverage:* all 64 hex numbers appear in some `ZA_GUA[*].pair` array (set-equality with `range(1,65)`). + - *Disordered tail preserved:* the entries the JSON `_meta.notes` flags as disordered are present in source order — assert specific indices match the JSON. + - *Editorial notes carried:* the `note` field on XU_GUA entries for hex 1, 2, 30, 31 matches the JSON. +- **Verification:** `ZA_GUA_BY_HEX[3].pair` resolves to a 2-element array containing 3 and 4 (屯/蒙 pair, the canonical first ordered pair). + +### U3. 說卦 data module + +- **Goal:** Promote the verified `data-acquisition/shuogua.json` (3/3 verdicts) into a permanent data module. +- **Requirements:** Source for trigram catalogue (U4) and derivation citations (U5). +- **Dependencies:** U1. +- **Files:** + - Create: `packages/core/src/data/shuogua.ts` + - Modify: `packages/core/src/index.ts` + - Test: `packages/core/src/__tests__/shuogua.test.ts` +- **Approach:** Transcribe `shuogua.json`'s `chapters[]` (11 entries) and `trigramAssociations` (8-trigram keyed map) into: + - `export const SHUOGUA: { chapters: ShuoguaChapter[] }` — canonical zh, verbatim. + - `export const TRIGRAM_ASSOC: Record` — keyed by trigram zh name (乾/坤/震/巽/坎/離/艮/兌), structured fields as canonical, plus a `note?` field on the editorial sub-fields (season, cosmologicalRole, other) explicitly typed as derived rather than canonical to honor the verifier warn. +- **Patterns to follow:** `data/large-glyphs.ts:9-19` (typed Record with explicit nested shape). +- **Test scenarios:** + - *Happy path:* `SHUOGUA.chapters.length === 11`; every chapter has non-empty text. + - *Coverage:* `Object.keys(TRIGRAM_ASSOC).length === 8`; each entry has `image`, `family`, `body`, `animal`, `direction`, `attribute`, `extendedImages`. + - *Canon vs editorial:* `TRIGRAM_ASSOC.qian.image` is zh single char (`'天'`); `season` and `cosmologicalRole` are flagged in the type as project-derived not canonical. + - *Verification anchor:* chapter 11 text contains the animal-association line; chapter 5 contains the directional cycle. +- **Verification:** `TRIGRAM_ASSOC['乾'].extendedImages` has 14 entries (the 說卦 ch. 11 catalogue for 乾). + +### U4. TrigramInfo enrichment + editorial English glosses + +- **Goal:** Wire `TRIGRAM_ASSOC` into each `TrigramInfo` so consumers can read trigram catalogue without reaching into `shuogua.ts`. Add project-authored English glosses for the structured fields. +- **Requirements:** Trigram catalogue surfacing in detail renderer (U6). +- **Dependencies:** U3. +- **Files:** + - Modify: `packages/core/src/data/trigrams.ts` + - Test: `packages/core/src/__tests__/trigrams.test.ts` (new) +- **Approach:** In `trigrams.ts:3-12`, populate each of the 8 `TrigramInfo` entries with `assoc` referencing the corresponding `TRIGRAM_ASSOC[zhName]`. Add a sibling export `TRIGRAM_ASSOC_EN: Record>` carrying short project-authored English glosses for `family` ("father"/"mother"/...), `body` ("head"/"belly"/...), `animal` ("horse"/"ox"/...), `direction` ("northwest"/"southwest"/...), and `attribute` ("strength"/"yielding"/...). Document at top of file: "TRIGRAM_ASSOC_EN is project-authored — not from Legge or any canonical source. Labeled non-canonical in UI." +- **Patterns to follow:** `data/trigrams.ts:17-23` (existing `DERIVED_LABELS` Record convention). +- **Test scenarios:** + - *Happy path:* `TRIGRAMS[7].assoc?.extendedImages` (乾 entry) has 14 items. + - *Edge case:* `TRIGRAM_ASSOC_EN` has all 8 keys; every entry has non-empty `family`, `body`, `animal`, `direction`, `attribute` strings. + - *Negative:* `TRIGRAM_ASSOC_EN` does NOT have an `extendedImages` field (those stay zh-only — too many for short glosses). +- **Verification:** Reading `TRIGRAMS[7]` returns the full canonical 乾 catalogue (zh) plus a side-loadable English gloss map. + +### U5. connections.ts overlay + HexagramDetail aggregation + +- **Goal:** Add the `connections(cast: Cast): CastConnections` overlay that joins derived KW numbers with canonical text data. Aggregate it into `HexagramDetail`. +- **Requirements:** Source for the relations block in the renderer (U6). +- **Dependencies:** U2, U3. +- **Files:** + - Create: `packages/core/src/derivation/connections.ts` + - Modify: `packages/core/src/detail.ts` + - Modify: `packages/core/src/index.ts` + - Test: `packages/core/src/__tests__/connections.test.ts` (new) +- **Approach:** `connections(cast)` returns `CastConnections { xuGua, zaGuaPair, shuoguaCitations }`. `xuGua` is `XU_GUA[cast.primary - 1]`. `zaGuaPair` is `ZA_GUA_BY_HEX[cast.primary]` (which is the entry containing the pair partner; many-to-one for the disordered tail is fine — typed accordingly). `shuoguaCitations` is a static `DERIVATION_CITATIONS` map (`{ nuclear: 3, polarity: 2, mirror: 6, diagonal: 6, becoming: 7 }`) wrapped per cast. Pure function; no I/O. Modify `detail.ts:37-40` aggregator to also call `connections(cast)` and embed the result in `HexagramDetail`. +- **Patterns to follow:** `derivation/nuclear.ts:6` (pure function signature); `detail.ts:37-40` (aggregation pattern). +- **Test scenarios:** + - *Happy path:* `connections({ primary: 3 })` returns `xuGua` for hex 3 屯 with the canonical "盈天地之間者…" text; `zaGuaPair` includes hex 4 蒙. + - *Edge — first hex:* `connections({ primary: 1 })`: `xuGua` for hex 1 returns the editorial note about shared cosmological preamble (no "previous" hex exists); test does not crash. + - *Edge — polarity-fallback hex:* `connections({ primary: 29 })`: `zaGuaPair` resolves to hex 30 (離, via the disordered tail or canonical entry); pair returned is the polarity-fallback partner. + - *Edge — disordered tail hex:* `connections({ primary: 60 })`: `zaGuaPair` returns the canonical disordered entry; no warning surfaced (silent preservation). + - *Cross-check (adversarial):* for every hex 1..64, compute mirror-or-polarity geometrically; assert the `zaGuaPair.pair` array contains the geometrically-derived partner for all *non-tail* entries. The famously disordered tail is whitelisted by index — disagreement on whitelisted indices is expected; disagreement elsewhere is a regression. Lock invariant: `iff hex ∉ DISORDERED_TAIL: geometricPair(hex) ∈ zaGuaPair(hex).pair`. +- **Verification:** All 64 hexagrams produce a `CastConnections` that loads non-empty `xuGua.text` and a populated `zaGuaPair`; the orbit-integrity invariant holds for the 56 non-tail hexagrams. + +### U6. Detail-renderer sections expansion + stacked-voice rendering scaffolding + +- **Goal:** Extend the dict detail renderer's sections array to surface the new canonical layers. Build the stacked-voice scaffolding (renders zh + each populated en voice in order), but only with currently-shipping voices (modern + wilhelm). Legge slot is wired but empty until U10. +- **Requirements:** Q1 (stack), Q3 (terse 說卦 citation), Q4 (extendedImages collapsed in cast). +- **Dependencies:** U2, U3, U4, U5. +- **Files:** + - Modify: `packages/terminal/src/scenes/dict/detail-renderer.ts` + - Modify: `packages/terminal/src/scenes/dict/detail-model.ts` + - Test: `packages/terminal/src/scenes/dict/__tests__/detail-renderer.test.ts` (extend; create if absent) +- **Approach:** + - Expand `buildContentLines` sections (current at lines 78-93). New sections: 卦辭 (top of cast, when populated), 大象傳 (existing), 彖傳 (existing), 序卦 (inline), 雜卦 (collapsed-by-default expander; render the pair partner glyph + name + text), 說卦 trigram catalogue (under upper/lower trigram display: family/body/animal/direction/attribute zh + project-en gloss; extendedImages compact: first 3 + "▸ +N more" expander). + - Add a `renderStackedVoices(node: VoiceNode)` helper that takes `{ zh, modernEn?, wilhelmEn?, leggeEn? }` and emits rows for each populated voice in priority order. Used by Judgment, Image, and per-changing-line yao readings. + - Update relations block (after derivations) to surface 序卦 + 雜卦 rows alongside the existing nuclear/polarity/mirror/diagonal badges, each annotated with the 說卦 citation chapter where applicable. + - All new sections gated on field presence (`if (gua.gc) { … }` etc.) so Hexagram entries without new fields render unchanged from today. +- **Patterns to follow:** `scenes/dict/detail-renderer.ts:78-93` (sections array shape); `scenes/dict/detail-renderer.ts:125-134` (derived links block). +- **Test scenarios:** + - *Happy path — legacy hexagram (no new fields):* rendered output is byte-identical to today's output. Lock with a snapshot or line-by-line comparison. + - *Happy path — fully-populated hexagram:* output contains 卦辭 section, 序卦 line, 雜卦 expander, 說卦 trigram catalogue under each trigram. Lock with named-section assertions. + - *Edge — extendedImages compact:* 乾 trigram displays first 3 images + "▸ +11 more" indicator; 坎 displays first 3 + "▸ +N". + - *Edge — relations row for hex 29 坎:* 雜卦 row shows pair partner 離 (polarity fallback); no error. + - *Edge — voice stack with no Legge:* `renderStackedVoices({ zh, modernEn, wilhelmEn })` emits 3 rows; no empty "legge:" row. + - *Error — disordered tail hex:* renders the canonical disordered entry; no user-visible warning. +- **Verification:** Hex 1 (乾), hex 3 (屯), hex 29 (坎) all render the new sections coherently; existing hex 50 (鼎) with no new fields populated yet still renders the legacy 5-section view. + +### U7. Style union + CLI hardcoded list updates (lockstep) + +- **Goal:** Bring every hardcoded `STYLES` / `VALID_STYLES` / enumeration site into agreement with the new Style union. Single atomic commit. +- **Requirements:** CLI `--style gc` works once data ships; JSON output schema is complete. +- **Dependencies:** U1. +- **Files:** + - Modify: `packages/core/src/data/trigrams.ts` (STYLES, QUOTE_STYLES at lines 14-15) + - Modify: `apps/cli/src/commands/hexagram.ts` (VALID_STYLES at line 7) + - Modify: `apps/cli/src/output/json.ts` (enumeration at lines 45-50, 65-70) + - Modify: `apps/cli/src/output/plain.ts` (5-line emission at lines 64-68) + - Test: `apps/cli/src/__tests__/style-union-parity.test.ts` (new) +- **Approach:** Add `"gc"` and `"yaoXiao"` to every list in lockstep. `hexagramToJson` emits every populated optional field (skip undefined keys). `formatHexagramPlain` adds a 卦辭 line when `gc` is populated. Style-union parity test asserts `STYLES`, `QUOTE_STYLES`, `VALID_STYLES`, and the JSON-output enumeration are identical (modulo `"st"` exclusion). +- **Patterns to follow:** `apps/cli/src/output/plain.ts:64-68` (existing per-style emission); `apps/cli/src/commands/hexagram.ts:7` (`VALID_STYLES` declaration). +- **Test scenarios:** + - *Happy path:* `STYLES`, `QUOTE_STYLES`, `VALID_STYLES`, and the JSON emission keys are all in agreement (parity test). + - *Edge — undefined optional:* `hexagramToJson` for a hex with no `gc` populated does not emit a `gc: undefined` field; the key is absent. + - *Error — invalid style:* `iching hexagram 1 --style unknown` exits with the existing typed error; the error message lists all valid styles including new ones. +- **Verification:** `iching hexagram 1 --style gc` errors cleanly (data not yet populated for hex 1), then succeeds after U8 lands. + +### U8. 卦辭 + 小象傳 acquisition workflow + data backfill into gua.ts + +- **Goal:** Pull the queued first-wave canonical Chinese (卦辭 root oracle + 小象傳 per-line commentary) from ctext.org primary + zh.wikisource cross-check, verify adversarially, and backfill into the `GUA` array's new optional fields. Bilingual: zh from canon, en from Legge (post-cleanup); for this unit, populate zh only and leave en for U10's backfill pass. +- **Requirements:** Foundational gap-filler; surfaces both fields in U6's renderer. +- **Dependencies:** U1, U2 (uses same workflow pattern). Can run in parallel with U3-U7. +- **Files:** + - Create: `data-acquisition/guaci-xiaoxiang.json` (verified pull artifact, gitignored or committed-as-fixture per workspace decision) + - Modify: `packages/core/src/data/gua.ts` (populate `gc` on all 64; populate `yaoXiao[6]` on all 64) + - Test: `packages/core/src/__tests__/guaci-xiaoxiang.test.ts` (new) +- **Approach:** Mirror the `iching-pull-verify-synthesize` workflow used this session for the second-wave corpora. Pull agent fetches ctext.org's per-hexagram pages, extracts the 卦辭 (the line beginning with `《卦名》:` or the prose immediately after) and the 6 小象傳 entries (the `象曰` lines per yao). Adversarial verifiers run completeness (64 / 384 counts) + source-fidelity (spot-check vs zh.wikisource) + structural cleanliness lenses. Verification anchors: hex 1 卦辭 should be `元亨,利貞`; hex 1 line 1 小象 should begin with `潛龍勿用,陽在下也`. Backfill agent reads the verified JSON and rewrites `gua.ts` in place, inserting `gc` and `yaoXiao` into every entry while preserving every existing field and the file's authored comment header. +- **Patterns to follow:** `data-acquisition/xugua-zagua.json` schema (this session's pull). `data/gua.ts:4` (existing array shape — backfill must preserve it). +- **Test scenarios:** + - *Happy path:* `GUA[0].gc === "元亨,利貞"`; `GUA[0].yaoXiao[0]` is the canonical 小象 for line 1 of 乾. + - *Coverage:* every `GUA[i].gc` is non-empty; every `GUA[i].yaoXiao` has 6 entries. + - *Existing fields untouched:* every existing `dx`/`tu`/`en`/`te`/`w`/`yao`/`yaoEn` field is identical to pre-backfill (diff-based assertion against a git blob hash, or against a frozen-fixture copy). + - *Disconfirming evidence:* if the canonical 卦辭 source disagrees on any hex's text between ctext.org and zh.wikisource by more than a documented variant, the unit fails and the textual variant is recorded. +- **Verification:** All 64 entries have populated `gc` and `yaoXiao`; no existing field changed. + +### U9. Legge cleanup re-pull workflow + +- **Goal:** Re-pull Legge from public-domain sources that don't carry the Morales/baharna editorial drift, producing a clean `data-acquisition/legge-cleaned.json` that passes verification. +- **Requirements:** Pre-req for shipping the Legge translation lineage. +- **Dependencies:** None (operates on raw sources, not the existing `data-acquisition/legge.json` blocker). +- **Files:** + - Create: `data-acquisition/legge-cleaned.json` (verified pull artifact) +- **Approach:** Multi-source workflow. Primary: pull from archive.org's `sacredbooksofchi16conf_djvu.txt` OCR (raw Legge, no Morales edits). Secondary: Wikisource's Sacred-Books-of-the-East Vol. XVI Legge pages. Tertiary: sacred-texts.com `ic*.htm` (needs browser-like User-Agent). For each blocker documented in this session's pull: + 1. **NINE/SIX substitution** in line statements: replace with Legge's canonical line-prefix language ("the first/second/third/fourth/fifth/sixth line, undivided" or "...divided"). Wikisource / archive.org plaintext are authoritative. + 2. **Missing 7th paragraph for hex 1 & 2** ("use of the number nine" / "use of the number six"): restore from Wikisource. Type `lines[]` as `string[]`, not `[string, string, string, string, string, string]` (variance accepted for hex 1 & 2). + 3. **Final 雜卦 nav chrome:** strip with regex against known site-chrome strings. + 4. **14 序卦 entries with fused footnote digits** (`Kun1`, `multitudes3`, `Li4`): regex-strip trailing digits where they follow capitalized roman text. + Run independent verifiers per blocker class: each verifier asserts the specific blocker is gone, and a final completeness verifier asserts 64 hexagrams + Wings coverage matches the existing baharna pull's structural counts. +- **Patterns to follow:** This session's `iching-pull-verify-synthesize` workflow. +- **Test scenarios:** + - *Blocker resolution:* a regex scan over `legge-cleaned.json.hexagrams[*].lines[*]` produces zero matches for `\b(NINE|SIX)\b` in positions where Legge's transmitted text uses "line". + - *Hex 1 & 2 completeness:* `legge-cleaned.json.hexagrams[0].lines.length === 7` and `[1].lines.length === 7`. + - *Nav chrome cleanup:* the final 雜卦 entry text does not contain `"Previous"`, `"Return to"`, or `"Baharna"`. + - *Footnote cleanup:* no entry in `legge-cleaned.json.wings.xuGua[*].text` matches `[A-Z][a-z]+\d\b`. + - *Canonical anchor:* hex 1 judgment equals "Khien (represents) what is great and originating, penetrating, advantageous, correct and firm." (the verified canonical phrasing) — match exactly. +- **Verification:** All four documented blockers are absent; 64 hexagrams + 3 Wings remain structurally complete; the workflow's verifiers all pass. + +### U10. Legge data module + Stacked-voice rendering activation + +- **Goal:** Promote `legge-cleaned.json` into a permanent data module; activate the third voice in the renderer's stacked-voice scaffolding (built in U6). +- **Requirements:** Q1 (stacked translation voices), Wings English availability. +- **Dependencies:** U6, U9. +- **Files:** + - Create: `packages/core/src/data/legge.ts` + - Modify: `packages/core/src/data/gua.ts` (populate `legge` on each entry from the cleaned data) + - Modify: `packages/terminal/src/scenes/dict/detail-renderer.ts` (light edits — wire the leggeEn voice through `renderStackedVoices`) + - Modify: `packages/core/src/data/xugua.ts`, `data/zagua.ts`, `data/shuogua.ts` (add `textEn?` field populated from Legge's appendices) + - Test: `packages/core/src/__tests__/legge.test.ts` (new); extend `detail-renderer.test.ts` +- **Approach:** `legge.ts` exports `LEGGE: Record` keyed by KW + `LEGGE_XUGUA_EN: Record` + `LEGGE_ZAGUA_EN: string[]` + `LEGGE_SHUOGUA_EN: Record`. Backfill `GUA[i].legge` references the corresponding `LEGGE[i+1]` entry. Backfill `XU_GUA[i].textEn` from `LEGGE_XUGUA_EN[i+1]`. For `ZA_GUA`, fall back to a `[zh-only]` badge for the 2 documented gap hexagrams (39 & 49) where Legge has no pair tag — per Q5 decision: fall back rather than omit. Activate the Legge row in `renderStackedVoices` (already wired in U6, just adds non-empty content). +- **Patterns to follow:** `data/gua.ts:4` (backfill respects the file's authored shape); `data/trigrams.ts:14-15` (multiple top-level exports per data file). +- **Test scenarios:** + - *Happy path:* `GUA[0].legge?.judgment` equals the canonical Khien judgment string. + - *Wings English:* `XU_GUA[0].textEn` is populated; `SHUOGUA.chapters[10].textEn` (chapter 11) is populated. + - *Edge — 雜卦 gap:* `ZA_GUA[*]` entries for hex 39 / 49 have no `textEn`; renderer surfaces a `[zh only]` badge instead of crashing. + - *Composition — three-voice stack:* hex 1 Judgment renders three rows: modern, Wilhelm-flavored, Legge. Test asserts all three are present in renderer output. + - *Composition — partial stack (Wings):* 序卦 hex 3 entry renders zh + Legge only (no Wilhelm voice for Wings exists). Test asserts the Wilhelm row is absent, not blank. +- **Verification:** `iching hexagram 1` plain output now includes Legge judgment; terminal detail scene shows three stacked voices on hex 1's Judgment. + +### U11. Orbit-integrity test + data-layers documentation + +- **Goal:** Land the satisfying invariant test from the architectural discussion (geometric pair check vs canonical 雜卦) and document the new data-layer ecology so future contributors understand the canonical/editorial split. +- **Requirements:** Closure on the model-shape change; documentation parity with `docs/vision/` style. +- **Dependencies:** U2, U5. +- **Files:** + - Create: `packages/core/src/__tests__/orbit-integrity.test.ts` + - Create: `docs/data-layers.md` +- **Approach:** The test enumerates all 64 hexagrams; for each non-tail hex, computes `mirror(h) !== h ? mirror(h) : polarity(h)` and asserts that result appears in the corresponding `ZA_GUA_BY_HEX[h].pair` array. The disordered tail (whitelisted by a `DISORDERED_TAIL_HEXAGRAMS` set sourced from the JSON's `_meta.notes`) is excluded. The doc `data-layers.md` covers: (a) the canonical vs editorial split (canonical text is verbatim from ctext/wikisource/Legge; editorial = TRIGRAM_ASSOC_EN glosses, structured-field synthesis, project-authored modern English); (b) the file-per-corpus convention; (c) the optional-field discipline on Hexagram; (d) how stacked-voice rendering composes; (e) the orbit-integrity invariant as the canonical sanity check for the Wings. +- **Patterns to follow:** `docs/vision/entropy-sources-vision.md` (vision-doc style); `packages/core/src/__tests__/exhaustive-4096.test.ts:11-84` (exhaustive-iteration test pattern). +- **Test scenarios:** + - *Happy path:* every non-tail hex 1..64 passes the orbit-integrity check. + - *Edge — degenerate-mirror hexagrams:* hex 1, 27, 29, 61 (and their polarity partners) pass via the polarity-fallback branch. + - *Edge — disordered tail:* the whitelisted tail hexagrams are excluded by the test and do not produce failures. + - *Disconfirming evidence:* if the orbit-integrity invariant fails for a hex *outside* the whitelist, the test fails and identifies the hex — this is the pull-correctness probe. +- **Verification:** The orbit invariant is now a permanent CI signal — silent corruption of the 雜卦 pulled data will fail this test. + +## Scope Boundaries + +- **Excluded from this plan:** 文言傳 (Wenyan, the appendix dedicated only to hex 1 & 2). The dictionary page placement was discussed but is asymmetric (only renders for 2 of 64 hexagrams) and adds load-bearing rendering branches; route to a separate follow-up after this wave lands. +- **Excluded:** 繫辭傳 (Great Treatise). Not per-hexagram; belongs in a separate library/about surface, not the cast or detail views. +- **Excluded:** Bone-method oracle (turtle-shell verdicts 大吉/吉/凶/大凶). Discussed earlier as a separate oracle type; needs its own architecture, not data enrichment. +- **Excluded:** Wilhelm/Baynes content. Copyrighted; cannot ship in an open-source repo. The existing `w` field stays as project-authored synthesis; the cleanup of the 19 fields with verbatim Wilhelm strings is a separate cleanup workstream tracked in this session's earlier scan. +- **Excluded:** UI for the "translation-voice toggle" (Q1 was decided as stacked-voice, not toggle). No tab/cycle interaction control is built. +- **Excluded:** Tag-cloud rendering of `extendedImages` (Q4 variant C). Compact-with-expander is decided. + +### Deferred to Follow-Up Work + +- **文言傳 integration** for the hex 1 & 2 dictionary pages: separate plan, after this wave stabilizes. Will reuse the optional-field pattern: `Hexagram.wenyan?: WenyanCommentary`. +- **`gua.ts` voice cleanup**: the 19 `w` fields with verbatim Wilhelm strings (identified in this session's scan) need rewriting into clean paraphrases. Separate concern from data enrichment; tracked separately. +- **Library / about surface** for non-per-hexagram texts (繫辭, full 說卦 reader, full 序卦/雜卦 continuous text). Separate UI scope. +- **Per-translation citation linking**: clicking a Legge row could open `docs/sources/legge-sbe-xvi.md` showing the provenance chain. Useful but not blocking. + +## System-Wide Impact + +- **Interaction graph:** new optional fields flow through `format/reading.ts:31` dynamic access (safe — optional values render or skip), `output/json.ts:65-70` enumerated emission (must be updated in lockstep — U7), `output/plain.ts:64-68` per-style line emission (must be updated in lockstep — U7), `scenes/dict/detail-renderer.ts:78-93` hardcoded section array (must be extended — U6). +- **Error propagation:** all new fields are optional. Consumers that access `gua.gc` get `undefined` until U8 backfills — every consumer must handle `undefined` defensively (`if (gua.gc) { … }` gating). Type system enforces this via `?:`. +- **State lifecycle risks:** none — this is read-only data addition. No persistence, no migration, no cache invalidation. +- **API surface parity:** `STYLES` (in `data/trigrams.ts:14`), `QUOTE_STYLES` (line 15), `VALID_STYLES` (in `commands/hexagram.ts:7`), and the explicit emission list in `output/json.ts:65-70` must all agree. U7 lands the parity test that locks this in. +- **Integration coverage:** existing `exhaustive-4096.test.ts:11-84` is untouched (tests numeric derivations, not text). New `orbit-integrity.test.ts` (U11) is the cross-layer probe for 雜卦 correctness. +- **Unchanged invariants:** every existing field on `Hexagram` (`u`, `n`, `p`, `ename`, `l`, `dx`, `tu`, `en`, `te`, `w`, `yao`, `yaoEn`) remains required and unchanged. Every existing derivation function signature (`(lines: Line[]) => number`) remains unchanged. The `Cast` shape (existing fields) is unchanged; `CastConnections` is added as a separate overlay carried in `HexagramDetail`. Storage layer (`packages/storage/src/*`) is unchanged — it doesn't persist Hexagram text, only Cast + Structure. + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| **Legge cleanup re-pull misses a blocker** — archive.org OCR has its own noise. | U9 names each blocker as an independent named test. Verification is by named-blocker absence, not OCR fidelity. Multi-source fallback covers single-source OCR errors. | +| **Backfilling `gua.ts` corrupts an existing field** — `gua.ts` is 104KB and hand-authored. | U8 verifies every existing field against the pre-backfill content (git blob hash or frozen fixture). Backfill agent only adds new keys; preserve-existing is a load-bearing invariant. | +| **STYLES parity drift** between hardcoded lists during the new-field expansion. | U7 lands a parity test that asserts `STYLES`, `QUOTE_STYLES`, `VALID_STYLES`, and the JSON-emission keys are equal sets (modulo `"st"`). Any future drift fails CI. | +| **Stacked-voice renderer wastes space on hexagrams with no new fields** — legacy hexagrams could regress visually. | U6 explicit happy-path test: "renders legacy 5-field hexagram unchanged from today." Gates this regression at the test layer. | +| **Editorial `TRIGRAM_ASSOC_EN` glosses get mistaken for canonical Legge translation** — credibility concern for serious practitioners. | UI labels the gloss row explicitly as "project gloss" (Q5 decision). U4 documents this at the file head; U11's `data-layers.md` covers it at the docs layer. | +| **Disordered-tail handling produces silent incorrect pairings.** | U11's orbit-integrity test is the probe. It locks the non-tail invariant; tail-by-source-fidelity is enforced by U2's verifiers. | +| **Storage layer migration accidentally needed** if anyone embeds a full Hexagram in journal. | Already verified: `packages/storage/src/schema-keys.ts:29-36` persists only `cast` and `structure`, not Hexagram. Spot-checked in this session's integration mapping. | + +## Bug-trace / Confidence Cross-Check + +| Origin requirement | Plan response | Match? | +|---|---|---| +| **R: 卦辭 must appear at top of cast (currently absent in both languages)** | U8 backfills `gc`/`gcEn` into all 64 entries; U6 renders 卦辭 section at top when populated. | ✓ | +| **R: 小象傳 pairs with existing yao, renders only for changing lines** | U8 backfills `yaoXiao[6]` per hex; U6 renders per-line under each changing-line yao. | ✓ | +| **R: 說卦 trigramAssociations enriches TrigramInfo + chapters are citation source** | U3 ships SHUOGUA chapters + TRIGRAM_ASSOC keyed map; U4 wires assoc into TrigramInfo; U5 ships `DERIVATION_CITATIONS` map for badge citation; U6 surfaces both. | ✓ | +| **R: 序卦/雜卦 belong inside relations block, not separate bridge-text** | U6 places 序卦 + 雜卦 as rows alongside numeric derivation badges in the relations block; data layer keeps them as overlay (`CastConnections`) not as derivations. | ✓ | +| **R: Legge needs cleanup re-pull before integration (0/3 verdicts on current pull)** | U9 dedicated cleanup workflow; U10 promotes the cleaned data, gated on U9 verification passing. | ✓ | +| **R: Stack all three translation voices vertically (Q1)** | U6 builds `renderStackedVoices` scaffolding; U10 activates the third voice once Legge ships. | ✓ | +| **R: Terse 說卦 citation per badge with click-through (Q3)** | U5's `shuoguaCitations` is single-chapter per derivation; U6 renders citation as a badge annotation; click-through is a render-layer concern, scaffolded in U6. | ✓ | +| **R: extendedImages compact in cast + full on trigram reader page (Q4)** | U6 compact rendering (first 3 + expander). Trigram reader page is out of this plan's scope — deferred. | partial (cast-side complete; reader page deferred) | +| **R: Project-authored English trigram glosses, labeled non-canonical (Q5)** | U4 ships `TRIGRAM_ASSOC_EN` in `trigrams.ts` with explicit "project-authored" label at file head and in UI. | ✓ | + +No contradictions surfaced. The partial match on Q4 is intentionally deferred (cast-side ships; reader page is a follow-up surface, not a data-layer concern). + +## High-Risk Checklist + +- [x] Decision rationale explicit — pure derivations + overlay + flat optional fields, justified by Consistency criterion against documented existing patterns. +- [x] Data flow traced end-to-end — `data/*.ts` → `derivation/connections.ts` → `detail.ts` (HexagramDetail) → `detail-renderer.ts` → CLI surfaces; verified by integration-mapping agent. +- [x] Integration scenarios named — composition matrix in High-Level Technical Design names every test case for the model-shape change. +- [x] Unchanged invariants stated — existing 11 Hexagram fields stay required; derivation signatures unchanged; storage layer untouched; Cast shape unchanged. +- [x] Failure modes enumerated for each external boundary — workflow verifiers per pull, parity test for hardcoded lists, orbit-integrity probe for 雜卦, byte-identical-render test for legacy hexagrams. +- [x] Files-to-touch list grounded in agent findings, not inferred — every modified file:line cited from this session's integration-mapping agent. + +## Unit Sequencing + +Two parallel tracks; tracks merge at U10. + +``` +Track A (zh canon + types + renderer + CLI): + U1 → U2 → U3 → U4 → U5 → U6 → U7 → U8 → U11 + \ / + └─ U3 also feeds U4 / + (U4 can land any time after U3) + +Track B (Legge): + U9 → U10 (U10 also depends on U6) +``` + +- U1 must land first (everything depends on the types). +- U2, U3 can land in parallel after U1. +- U4 depends on U3. +- U5 depends on U2 + U3. +- U6 depends on U2 + U3 + U4 + U5. +- U7 depends on U1 (lockstep on Style union); can land any time before U8 backfills. +- U8 depends on U1; can run in parallel with U2-U7 (data acquisition is independent of UI/type work). +- U9 has no internal dependencies; can run in parallel with everything. +- U10 depends on U6 + U9. +- U11 depends on U2 + U5; lands last as documentation closure. + +Shipping order recommendation: U1 → (U2, U3, U7, U9 in parallel) → (U4, U8 in parallel) → U5 → U6 → U10 → U11. From 57410ca24681f3d1d76a3539d52cbc7a97cb0bb2 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:04:59 -0700 Subject: [PATCH 02/22] feat(types): add Wings/Legge optional fields (U1) Extends Hexagram with optional gc, gcEn, yaoXiao, yaoXiaoEn, legge. Extends TrigramInfo with optional assoc. Adds new interfaces: LeggeHexagram, TrigramAssoc, XuGuaEntry, ZaGuaEntry, ShuoguaChapter, ShuoguaCitation, CastConnections. Style/QuoteStyle union extension deferred to U7 (lockstep with the CLI consumer sites at format/reading.ts:31 and output/plain.ts:96 that do g[style] indexed access). New optional fields are fully usable via direct access (gua.gc, gua.legge, etc.) in U2-U6. --- .../core/src/__tests__/types.compile.test.ts | 159 ++++++++++++++++++ packages/core/src/types.ts | 152 ++++++++++++++++- 2 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/types.compile.test.ts diff --git a/packages/core/src/__tests__/types.compile.test.ts b/packages/core/src/__tests__/types.compile.test.ts new file mode 100644 index 0000000..5adbc81 --- /dev/null +++ b/packages/core/src/__tests__/types.compile.test.ts @@ -0,0 +1,159 @@ +import { describe, test, expect } from "bun:test"; +import type { + Hexagram, + TrigramInfo, + XuGuaEntry, + ZaGuaEntry, + ShuoguaChapter, + ShuoguaCitation, + CastConnections, + Style, + QuoteStyle, +} from "../types.js"; + +/** + * Type-only test for the data-enrichment extensions. + * + * The real guard is `bun run typecheck`; the `expect` calls below just confirm + * the file ran and the literals match what the type system enforces. + */ + +describe("types — data enrichment extensions", () => { + test("legacy Hexagram literal compiles with no new fields", () => { + const legacy: Hexagram = { + u: "X", + n: "X", + p: "X", + ename: "X", + l: [1, 1, 1, 1, 1, 1], + dx: "X", + tu: "X", + en: "X", + te: "X", + w: "X", + yao: ["", "", "", "", "", ""], + yaoEn: ["", "", "", "", "", ""], + }; + expect(legacy.gc).toBeUndefined(); + expect(legacy.gcEn).toBeUndefined(); + expect(legacy.yaoXiao).toBeUndefined(); + expect(legacy.yaoXiaoEn).toBeUndefined(); + expect(legacy.legge).toBeUndefined(); + }); + + test("Hexagram literal with every new optional field compiles", () => { + const enriched: Hexagram = { + u: "X", + n: "X", + p: "X", + ename: "X", + l: [1, 1, 1, 1, 1, 1], + dx: "X", + tu: "X", + en: "X", + te: "X", + w: "X", + yao: ["", "", "", "", "", ""], + yaoEn: ["", "", "", "", "", ""], + gc: "X", + gcEn: "X", + yaoXiao: ["", "", "", "", "", ""], + yaoXiaoEn: ["", "", "", "", "", ""], + legge: { + leggeName: "X", + judgment: "X", + image: "X", + lines: ["", "", "", "", "", "", ""], + }, + }; + expect(enriched.gc).toBe("X"); + expect(enriched.legge?.lines).toHaveLength(7); + }); + + test("TrigramInfo with assoc compiles", () => { + const t: TrigramInfo = { + sym: "X", + n: "X", + img: "X", + assoc: { + image: "X", + family: "X", + body: "X", + animal: "X", + direction: "X", + attribute: "X", + extendedImages: ["X"], + }, + }; + expect(t.assoc?.extendedImages).toHaveLength(1); + }); + + test("TrigramInfo with optional editorial assoc fields compiles", () => { + const t: TrigramInfo = { + sym: "X", + n: "X", + img: "X", + assoc: { + image: "X", + family: "X", + body: "X", + animal: "X", + direction: "X", + attribute: "X", + extendedImages: [], + season: "X", + cosmologicalRole: "X", + other: "X", + }, + }; + expect(t.assoc?.season).toBe("X"); + }); + + test("XuGuaEntry, ZaGuaEntry, ShuoguaChapter shapes compile", () => { + const xu: XuGuaEntry = { hexagram: 3, name: "X", text: "X" }; + const za: ZaGuaEntry = { index: 0, pair: [1, 2], names: ["X", "X"], text: "X" }; + const ch: ShuoguaChapter = { n: 1, text: "X" }; + expect(xu.text.length).toBeGreaterThan(0); + expect(za.pair).toHaveLength(2); + expect(ch.n).toBe(1); + }); + + test("XuGuaEntry and ZaGuaEntry support optional textEn (Legge)", () => { + const xu: XuGuaEntry = { hexagram: 1, name: "X", text: "X", textEn: "X", note: "X" }; + const za: ZaGuaEntry = { index: 0, pair: [1, 2], names: ["X", "X"], text: "X", textEn: "X" }; + expect(xu.textEn).toBe("X"); + expect(za.textEn).toBe("X"); + }); + + test("ShuoguaCitation typed against DerivedType", () => { + const citation: ShuoguaCitation = { op: "nuclear", chapter: 3 }; + expect(citation.op).toBe("nuclear"); + expect(citation.chapter).toBe(3); + }); + + test("CastConnections has required citations + optional pair fields", () => { + const conn: CastConnections = { + shuoguaCitations: [ + { op: "nuclear", chapter: 3 }, + { op: "polarity", chapter: 2 }, + ], + }; + expect(conn.shuoguaCitations).toHaveLength(2); + expect(conn.xuGua).toBeUndefined(); + expect(conn.zaGuaPair).toBeUndefined(); + }); + + test("Style union unchanged from baseline; QuoteStyle still excludes st", () => { + // Style and QuoteStyle are unchanged by U1. Extension to include the new + // optional `gc` field as a Style key is deferred to U7 (lockstep with the + // CLI consumer sites). For U1 the new optional fields are direct-access + // only. + const styles: Style[] = ["dx", "tu", "en", "te", "w", "st"]; + const quoteStyles: QuoteStyle[] = ["dx", "tu", "en", "te", "w"]; + expect(styles).toHaveLength(6); + expect(quoteStyles).toHaveLength(5); + // QuoteStyle must not include "st" (enforced by Exclude). + const isStInQuoteStyle: "st" extends QuoteStyle ? true : false = false; + expect(isStInQuoteStyle).toBe(false); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c62762b..436d3c0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,18 @@ -/** Commentary style keys: dx=大象傳, tu=彖傳, en=English, te=彖英, w=Wilhelm (experimental) */ +/** + * Commentary style keys (single-string fields on Hexagram): + * - dx=大象傳, tu=彖傳, en=English image, te=English judgment + * - w=Wilhelm-flavored synthesis (experimental, not direct quotes) + * - st=synthetic structure (handled by a separate code path) + * + * The data-enrichment optional fields (`gc`, `legge`) are NOT in this union + * for now — they remain direct-access only. Adding them to Style requires + * updating the consumer sites that do `g[style]` indexing (those expect + * defined values); that lockstep change is owned by U7 of the + * data-enrichment plan. + * + * Per-line commentary fields are arrays (yao, yaoEn, yaoXiao, yaoXiaoEn) and + * are NOT in this union — they're accessed as direct fields. + */ export type Style = "dx" | "tu" | "en" | "te" | "w" | "st"; /** @@ -22,6 +36,67 @@ export interface Hexagram { w: string; // Inspired by Wilhelm — experimental, not direct quotes yao: string[]; // 爻辭 — 6 classical Chinese line texts (line 1 through 6) yaoEn: string[]; // 6 English line interpretations (line 1 through 6) + + /** 卦辭 — root Judgment text (canonical zh). Added in data-enrichment wave. */ + gc?: string; + /** English of 卦辭 (Legge SBE vol. XVI, 1882). */ + gcEn?: string; + /** 小象傳 — per-line Small Image commentary (canonical zh, 6 entries). */ + yaoXiao?: string[]; + /** English of 小象傳 (Legge SBE vol. XVI, 1882). */ + yaoXiaoEn?: string[]; + /** Full James Legge translation lineage. Populated by data-enrichment wave U10 (post-cleanup). */ + legge?: LeggeHexagram; +} + +/** + * James Legge translation of a single hexagram from Sacred Books of the East + * vol. XVI (1882). Public domain. Wings translations live on XuGuaEntry, + * ZaGuaEntry, ShuoguaChapter. + */ +export interface LeggeHexagram { + /** Legge's romanization, e.g. "Khien", "Khwan". */ + leggeName: string; + /** Thwan — the Judgment text. */ + judgment: string; + /** Great Symbolism — the Image text. */ + image: string; + /** + * Per-line statements. Six entries for hex 3..64; seven for hex 1 & 2 + * (the canonical "use of the number nine/six" paragraph). + */ + lines: string[]; +} + +/** + * 說卦傳 structured catalogue per trigram. + * + * The named-field block (image, family, body, animal, direction, attribute, + * extendedImages) is verbatim from 說卦 chapters 8–11. The optional fields + * (season, cosmologicalRole, other) are editorial synthesis and must be + * labeled as derived rather than canonical in the UI. + */ +export interface TrigramAssoc { + /** Canonical image — 天 / 地 / 雷 / 風 / 水 / 火 / 山 / 澤. */ + image: string; + /** Canonical family role — 父 / 母 / 長男 / 長女 / 中男 / 中女 / 少男 / 少女. */ + family: string; + /** Canonical body part — 首 / 腹 / 足 / 股 / 耳 / 目 / 手 / 口. */ + body: string; + /** Canonical animal — 馬 / 牛 / 龍 / 雞 / 豕 / 雉 / 狗 / 羊. */ + animal: string; + /** Canonical direction (後天 later-heaven arrangement). */ + direction: string; + /** Canonical attribute — 健 / 順 / 動 / 入 / 陷 / 麗 / 止 / 說. */ + attribute: string; + /** Canonical extended images from 說卦 ch. 11 (varies per trigram — 乾 has 14, others fewer). */ + extendedImages: string[]; + /** Editorial: rough seasonal correlate. NOT canonical 說卦 — label as derived in UI. */ + season?: string; + /** Editorial: cosmological role per the 帝出乎震 cycle. NOT canonical 說卦. */ + cosmologicalRole?: string; + /** Editorial: additional notes. NOT canonical 說卦. */ + other?: string; } /** Line value from 3-coin toss: 6=old yin, 7=young yang, 8=young yin, 9=old yang */ @@ -66,6 +141,8 @@ export interface TrigramInfo { sym: string; n: string; img: string; + /** 說卦傳 catalogue for this trigram. Populated by data-enrichment wave U4. */ + assoc?: TrigramAssoc; } /** Structure breakdown of a hexagram */ @@ -75,6 +152,79 @@ export interface Structure { becoming: { upper: TrigramInfo; lower: TrigramInfo } | null; } +/** + * 序卦傳 entry — narrative bridge explaining why this hexagram follows the + * previous in King Wen order. + */ +export interface XuGuaEntry { + /** King Wen number 1..64. */ + hexagram: number; + /** zh name e.g. 屯. */ + name: string; + /** Canonical zh text. */ + text: string; + /** Legge's English (Appendix VI of SBE vol. XVI). Populated post-Legge-cleanup. */ + textEn?: string; + /** Editorial note (e.g. 乾/坤 shared cosmological preamble, 咸 lower-jing preamble). */ + note?: string; +} + +/** + * 雜卦傳 entry — terse contrastive pairing of two hexagrams. The traditional + * source pairs hexagrams via geometric mirror (with polarity fallback for the + * 8 self-symmetric hexagrams). The famously disordered final stretch breaks + * the regular pairing; entries in that range preserve source order rather + * than being normalized. + */ +export interface ZaGuaEntry { + /** Source-order index 0..52. */ + index: number; + /** Hexagram numbers in the pair (0-2 entries; the disordered tail may collapse). */ + pair: number[]; + /** zh names matching `pair`. */ + names: string[]; + /** Canonical zh text. */ + text: string; + /** Legge's English (Appendix VII of SBE vol. XVI). Populated post-Legge-cleanup. */ + textEn?: string; +} + +/** + * 說卦傳 chapter (the prose body). Separate from the structured + * `TrigramAssoc` catalogue. + */ +export interface ShuoguaChapter { + /** Chapter number 1..11. */ + n: number; + /** Canonical zh text. */ + text: string; + /** Legge's English (Appendix V of SBE vol. XVI). Populated post-Legge-cleanup. */ + textEn?: string; +} + +/** Citation pointing into 說卦傳 for a derivation operation. */ +export interface ShuoguaCitation { + op: DerivedType; + /** 說卦傳 chapter 1..11 that grounds this operation. */ + chapter: number; +} + +/** + * Text-bearing relations overlay computed from a Cast. + * + * Joins the numeric derivations (nuclear / polarity / mirror / diagonal / + * becoming) with canonical text data from XU_GUA, ZA_GUA, and SHUOGUA. Pure + * overlay built by `derivation/connections.ts`; does not mutate the Cast. + */ +export interface CastConnections { + /** Sequence narrative from the previous hexagram. Absent for hex 1. */ + xuGua?: XuGuaEntry; + /** Contrastive pair commentary. May be absent if the disordered tail leaves a gap. */ + zaGuaPair?: ZaGuaEntry; + /** Textual authority per derivation operation. */ + shuoguaCitations: ShuoguaCitation[]; +} + /** Cache structure for daily reading */ export interface DailyCache { date: string; From aac62c45a7867f170a9823b755a9441a195d1995 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:08:55 -0700 Subject: [PATCH 03/22] =?UTF-8?q?feat(data):=20add=20=E5=BA=8F=E5=8D=A6=20?= =?UTF-8?q?+=20=E9=9B=9C=E5=8D=A6=20modules=20(U2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xugua.ts: XU_GUA — 64 序卦傳 entries in King Wen order, sourced from ctext.org with zh.wikisource cross-check. Editorial notes on hex 1, 2, 30, 31 preserved from the verified pull's _meta. Provenance in XU_GUA_META. zagua.ts: ZA_GUA — 53 雜卦傳 entries preserving the canonical disordered tail. ZA_GUA_BY_HEX is the 64-key reverse index (many-to-one for the self-mirror pairs where two hexagrams share an entry — 乾/坤 at index 0). Provenance in ZA_GUA_META. index.ts: re-exports the new modules plus the U1 type additions (XuGuaEntry, ZaGuaEntry, ShuoguaChapter, ShuoguaCitation, CastConnections, LeggeHexagram, TrigramAssoc) that were missed in U1. Tests: 19 integrity assertions — exact lengths, sequential indices, verification anchors (opening cosmological premise on hex 1+2; 雜卦 opens with 乾/坤 pair), every hex 1..64 covered exactly once, reverse index correctness for the 8 self-mirror pairs. Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- .../core/src/__tests__/xugua-zagua.test.ts | 142 ++++++++++++++++++ packages/core/src/data/xugua.ts | 95 ++++++++++++ packages/core/src/data/zagua.ts | 105 +++++++++++++ packages/core/src/index.ts | 10 ++ 4 files changed, 352 insertions(+) create mode 100644 packages/core/src/__tests__/xugua-zagua.test.ts create mode 100644 packages/core/src/data/xugua.ts create mode 100644 packages/core/src/data/zagua.ts diff --git a/packages/core/src/__tests__/xugua-zagua.test.ts b/packages/core/src/__tests__/xugua-zagua.test.ts new file mode 100644 index 0000000..0813c75 --- /dev/null +++ b/packages/core/src/__tests__/xugua-zagua.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect } from "bun:test"; +import { XU_GUA, XU_GUA_META } from "../data/xugua.js"; +import { ZA_GUA, ZA_GUA_META, ZA_GUA_BY_HEX } from "../data/zagua.js"; + +/** + * U2 integrity tests — 序卦 + 雜卦 data modules. + * + * The canonical text content is verified verbatim against ctext.org with a + * Wikisource cross-check; these tests lock in the structural invariants + * (coverage, indices, anchors, editorial notes) that future contributors + * must preserve. + */ + +describe("XU_GUA (序卦傳)", () => { + test("has exactly 64 entries", () => { + expect(XU_GUA).toHaveLength(64); + }); + + test("entries are ordered 1..64 with no gaps", () => { + XU_GUA.forEach((entry, i) => { + expect(entry.hexagram).toBe(i + 1); + }); + }); + + test("every entry has non-empty text and zh name", () => { + for (const entry of XU_GUA) { + expect(entry.text.length).toBeGreaterThan(0); + expect(entry.name.length).toBeGreaterThan(0); + } + }); + + test("verification anchor — opening cosmological premise on hex 1 + 2", () => { + expect(XU_GUA[0]!.name).toBe("乾"); + expect(XU_GUA[0]!.text).toContain("有天地,然後萬物生焉"); + expect(XU_GUA[1]!.name).toBe("坤"); + expect(XU_GUA[1]!.text).toContain("有天地,然後萬物生焉"); + }); + + test("hex 3 (屯) carries the canonical sequence transition", () => { + expect(XU_GUA[2]!.name).toBe("屯"); + expect(XU_GUA[2]!.text).toContain("故受之以"); + expect(XU_GUA[2]!.text).toContain("屯"); + }); + + test("editorial notes carried on hex 1, 2, 30, 31 (per JSON _meta)", () => { + expect(XU_GUA[0]!.note).toBeDefined(); + expect(XU_GUA[1]!.note).toBeDefined(); + expect(XU_GUA[29]!.note).toBeDefined(); + expect(XU_GUA[30]!.note).toBeDefined(); + }); + + test("XU_GUA_META carries provenance", () => { + expect(XU_GUA_META.source).toMatch(/ctext\.org/); + expect(XU_GUA_META.crossChecks.length).toBeGreaterThan(0); + expect(XU_GUA_META.license).toMatch(/public domain/); + }); +}); + +describe("ZA_GUA (雜卦傳)", () => { + test("has 53 entries (preserves the disordered structure)", () => { + expect(ZA_GUA).toHaveLength(53); + }); + + test("indices are sequential 0..52", () => { + ZA_GUA.forEach((entry, i) => { + expect(entry.index).toBe(i); + }); + }); + + test("every entry has non-empty text", () => { + for (const entry of ZA_GUA) { + expect(entry.text.length).toBeGreaterThan(0); + } + }); + + test("pair[] lengths are 0, 1, or 2", () => { + for (const entry of ZA_GUA) { + expect([0, 1, 2]).toContain(entry.pair.length); + expect(entry.names.length).toBe(entry.pair.length); + } + }); + + test("verification anchor — opening 乾剛坤柔", () => { + expect(ZA_GUA[0]!.pair).toEqual([1, 2]); + expect(ZA_GUA[0]!.names).toEqual(["乾", "坤"]); + expect(ZA_GUA[0]!.text).toContain("乾"); + expect(ZA_GUA[0]!.text).toContain("剛"); + expect(ZA_GUA[0]!.text).toContain("坤"); + expect(ZA_GUA[0]!.text).toContain("柔"); + }); + + test("all 64 hexagrams appear exactly once across pair[] arrays", () => { + const allHexes = ZA_GUA.flatMap((e) => e.pair).sort((a, b) => a - b); + const expected = Array.from({ length: 64 }, (_, i) => i + 1); + expect(allHexes).toEqual(expected); + }); + + test("exactly one closing-coda entry with empty pair[]", () => { + const empty = ZA_GUA.filter((e) => e.pair.length === 0); + expect(empty).toHaveLength(1); + }); + + test("ZA_GUA_META carries provenance", () => { + expect(ZA_GUA_META.source).toMatch(/ctext\.org/); + expect(ZA_GUA_META.license).toMatch(/public domain/); + }); +}); + +describe("ZA_GUA_BY_HEX (reverse index)", () => { + test("has exactly 64 keys (one per hexagram)", () => { + expect(Object.keys(ZA_GUA_BY_HEX)).toHaveLength(64); + }); + + test("hexagrams 1 and 2 share the opening entry (the canonical 乾/坤 pair)", () => { + expect(ZA_GUA_BY_HEX[1]).toBeDefined(); + expect(ZA_GUA_BY_HEX[2]).toBeDefined(); + expect(ZA_GUA_BY_HEX[1]).toBe(ZA_GUA_BY_HEX[2]!); + expect(ZA_GUA_BY_HEX[1]).toBe(ZA_GUA[0]!); + }); + + test("every hex 1..64 maps to an entry whose pair[] contains that hex", () => { + for (let h = 1; h <= 64; h++) { + const entry = ZA_GUA_BY_HEX[h]; + expect(entry).toBeDefined(); + expect(entry!.pair).toContain(h); + } + }); + + test("hex 29 (坎) and hex 30 (離) share the polarity-fallback entry", () => { + expect(ZA_GUA_BY_HEX[29]).toBeDefined(); + expect(ZA_GUA_BY_HEX[30]).toBeDefined(); + // Both should pair via polarity (the 8 self-mirror hexagrams pattern). + // They might share an entry or appear in adjacent single-entry cells. + // We assert the entry referencing them includes the expected partner. + const e29 = ZA_GUA_BY_HEX[29]!; + const e30 = ZA_GUA_BY_HEX[30]!; + const e29Hexes = new Set(e29.pair); + const e30Hexes = new Set(e30.pair); + expect(e29Hexes.has(29) || e30Hexes.has(29)).toBe(true); + expect(e29Hexes.has(30) || e30Hexes.has(30)).toBe(true); + }); +}); diff --git a/packages/core/src/data/xugua.ts b/packages/core/src/data/xugua.ts new file mode 100644 index 0000000..4b38429 --- /dev/null +++ b/packages/core/src/data/xugua.ts @@ -0,0 +1,95 @@ +import type { XuGuaEntry } from "../types.js"; + +/** + * 序卦傳 — Sequence of the Hexagrams. + * + * One of the Ten Wings of the I Ching. Explains why each hexagram + * follows the previous in King Wen order. Ancient classical Chinese + * text, public domain. + * + * Source: ctext.org/book-of-changes/xu-gua, cross-checked against + * zh.wikisource.org/wiki/周易/序卦. Punctuation is ctext editorial. + * + * Editorial notes preserved: + * - Hex 1 (乾) and 2 (坤) share the opening cosmological preamble + * «有天地,然後萬物生焉» — 序卦 has no explicit standalone line for either. + * - Hex 30 (離) merges two adjacent ctext cells (the 坎 transition + + * the close-of-上經 line). + * - Hex 31 (咸) is assigned the lower-jing cosmological preamble. + */ +export const XU_GUA: XuGuaEntry[] = [ + { hexagram: 1, name: "乾", text: "有天地,然後萬物生焉。", note: "序卦 has no explicit standalone line for 乾 in this canonical source; the opening cosmological premise («有天地,然後萬物生焉。») introduces heaven-earth as the implicit ground from which all hexagrams unfold. Shared with 坤." }, + { hexagram: 2, name: "坤", text: "有天地,然後萬物生焉。", note: "Shares the upper-jing cosmological preamble with 乾. The first explicitly-introduced hexagram in 序卦 is 屯." }, + { hexagram: 3, name: "屯", text: "盈天地之間者唯萬物,故受之以《屯》。" }, + { hexagram: 4, name: "蒙", text: "《屯》者,盈也。屯者,物之始生也。物生必蒙,故受之以《蒙》。" }, + { hexagram: 5, name: "需", text: "《蒙》者,蒙也,物之稺也。物稺不可不養也,故受之以《需》。" }, + { hexagram: 6, name: "訟", text: "《需》者,飲食之道也。飲食必有訟,故受之以《訟》。" }, + { hexagram: 7, name: "師", text: "訟必有眾起,故受之以《師》。" }, + { hexagram: 8, name: "比", text: "《師》者,眾也。眾必有所比,故受之以《比》。" }, + { hexagram: 9, name: "小畜", text: "《比》者,比也。比必有所畜,故受之以《小畜》。" }, + { hexagram: 10, name: "履", text: "物畜然後有禮,故受之以《履》。" }, + { hexagram: 11, name: "泰", text: "履而泰然後安,故受之以《泰》。" }, + { hexagram: 12, name: "否", text: "《泰》者,通也。物不可以終通,故受之以《否》。" }, + { hexagram: 13, name: "同人", text: "物不可以終否,故受之以《同人》。" }, + { hexagram: 14, name: "大有", text: "與人同者,物必歸焉,故受之以《大有》。" }, + { hexagram: 15, name: "謙", text: "有大者不可以盈,故受之以《謙》。" }, + { hexagram: 16, name: "豫", text: "有大而能謙必豫,故受之以《豫》。" }, + { hexagram: 17, name: "隨", text: "豫必有隨,故受之以《隨》。" }, + { hexagram: 18, name: "蠱", text: "以喜隨人者必有事,故受之以《蠱》。" }, + { hexagram: 19, name: "臨", text: "《蠱》者,事也。有事而後可大,故受之以《臨》。" }, + { hexagram: 20, name: "觀", text: "《臨》者,大也。物大然後可觀,故受之以《觀》。" }, + { hexagram: 21, name: "噬嗑", text: "可觀而後有所合,故受之以《噬嗑》。" }, + { hexagram: 22, name: "賁", text: "嗑者,合也。物不可以苟合而已,故受之以《賁》。" }, + { hexagram: 23, name: "剝", text: "《賁》者,飾也。致飾然後亨則盡矣,故受之以《剝》。" }, + { hexagram: 24, name: "復", text: "《剝》者,剝也。物不可以終盡剝,窮上反下,故受之以《復》。" }, + { hexagram: 25, name: "无妄", text: "復則不妄矣,故受之以《无妄》。" }, + { hexagram: 26, name: "大畜", text: "有无妄,然後可畜,故受之以《大畜》。" }, + { hexagram: 27, name: "頤", text: "物畜然後可養,故受之以《頤》。" }, + { hexagram: 28, name: "大過", text: "《頤》者,養也。不養則不可動,故受之以《大過》。" }, + { hexagram: 29, name: "坎", text: "物不可以終過,故受之以《坎》。" }, + { hexagram: 30, name: "離", text: "《坎》者,陷也。陷必有所麗,故受之以《離》。《離》者,麗也。", note: "ctext records «《離》者,麗也。» as a separate trailing cell closing 上經; merged here." }, + { hexagram: 31, name: "咸", text: "有天地然後有萬物,有萬物然後有男女,有男女然後有夫婦,有夫婦然後有父子,有父子然後有君臣,有君臣然後有上下,有上下然後禮義有所錯。", note: "下經 cosmological preamble. 序卦 introduces 咸 implicitly via the heaven-earth / husband-wife passage rather than via «故受之以咸»." }, + { hexagram: 32, name: "恆", text: "夫婦之道不可以不久也,故受之以《恆》。" }, + { hexagram: 33, name: "遯", text: "《恆》者,久也。物不可以久居其所,故受之以《遯》。" }, + { hexagram: 34, name: "大壯", text: "《遯》者,退也。物不可以終遯,故受之以《大壯》。" }, + { hexagram: 35, name: "晉", text: "物不可以終壯,故受之以《晉》。" }, + { hexagram: 36, name: "明夷", text: "《晉》者,進也。進必有所傷,故受之以《明夷》。" }, + { hexagram: 37, name: "家人", text: "夷者,傷也。傷於外者必反於家,故受之以《家人》。" }, + { hexagram: 38, name: "睽", text: "家道窮必乖,故受之以《睽》。" }, + { hexagram: 39, name: "蹇", text: "《睽》者,乖也。乖必有難,故受之以《蹇》。" }, + { hexagram: 40, name: "解", text: "《蹇》者,難也。物不可以終難,故受之以《解》。" }, + { hexagram: 41, name: "損", text: "《解》者,緩也。緩必有所失,故受之以《損》。" }, + { hexagram: 42, name: "益", text: "損而不已必益,故受之以《益》。" }, + { hexagram: 43, name: "夬", text: "益而不已必決,故受之以《夬》。" }, + { hexagram: 44, name: "姤", text: "《夬》者,決也。決必有遇,故受之以《姤》。" }, + { hexagram: 45, name: "萃", text: "《姤》者,遇也。物相遇而後聚,故受之以《萃》。" }, + { hexagram: 46, name: "升", text: "《萃》者,聚也。聚而上者謂之升,故受之以《升》。" }, + { hexagram: 47, name: "困", text: "升而不已必困,故受之以《困》。" }, + { hexagram: 48, name: "井", text: "困乎上者必反下,故受之以《井》。" }, + { hexagram: 49, name: "革", text: "井道不可不革,故受之以《革》。" }, + { hexagram: 50, name: "鼎", text: "革物者莫若鼎,故受之以《鼎》。" }, + { hexagram: 51, name: "震", text: "主器者莫若長子,故受之以《震》。" }, + { hexagram: 52, name: "艮", text: "《震》者,動也。物不可以終動,止之,故受之以《艮》。" }, + { hexagram: 53, name: "漸", text: "《艮》者,止也。物不可以終止,故受之以《漸》。" }, + { hexagram: 54, name: "歸妹", text: "漸者,進也。進必有所歸,故受之以《歸妹》。" }, + { hexagram: 55, name: "豐", text: "得其所歸者必大,故受之以《豐》。" }, + { hexagram: 56, name: "旅", text: "《豐》者,大也。窮大者必失其居,故受之以《旅》。" }, + { hexagram: 57, name: "巽", text: "旅而无所容,故受之以《巽》。" }, + { hexagram: 58, name: "兌", text: "《巽》者,入也。入而後說之,故受之以《兌》。" }, + { hexagram: 59, name: "渙", text: "《兌》者,說也。說而後散之,故受之以《渙》。" }, + { hexagram: 60, name: "節", text: "《渙》者,離也。物不可以終離,故受之以《節》。" }, + { hexagram: 61, name: "中孚", text: "節而信之,故受之以《中孚》。" }, + { hexagram: 62, name: "小過", text: "有其信者必行之,故受之以《小過》。" }, + { hexagram: 63, name: "既濟", text: "有過物者必濟,故受之以《既濟》。" }, + { hexagram: 64, name: "未濟", text: "物不可窮也,故受之以《未濟》,終焉。" }, +]; + +export const XU_GUA_META = { + source: "https://ctext.org/book-of-changes/xu-gua and https://ctext.org/book-of-changes/za-gua", + crossChecks: [ + "https://zh.wikisource.org/wiki/%E5%91%A8%E6%98%93/%E5%BA%8F%E5%8D%A6", + "https://zh.wikisource.org/wiki/%E5%91%A8%E6%98%93/%E9%9B%9C%E5%8D%A6", + ], + license: "ancient classical Chinese text, public domain (Ten Wings of the I Ching, ~Han dynasty or earlier)", + punctuationSource: "ctext editorial — uses 《》 quotation marks around hexagram names and modern Chinese commas/periods (, 。). Wikisource cross-check uses similar punctuation. The underlying characters match across both sources.", +} as const; diff --git a/packages/core/src/data/zagua.ts b/packages/core/src/data/zagua.ts new file mode 100644 index 0000000..0a8da23 --- /dev/null +++ b/packages/core/src/data/zagua.ts @@ -0,0 +1,105 @@ +import type { ZaGuaEntry } from "../types.js"; + +/** + * 雜卦傳 — Miscellaneous Notes on the Hexagrams. + * + * One of the Ten Wings of the I Ching. Pairs hexagrams contrastively, + * one terse line per pair. Ancient classical Chinese text, public + * domain. + * + * Source: ctext.org/book-of-changes/za-gua, cross-checked against + * zh.wikisource.org/wiki/周易/雜卦. Punctuation is ctext editorial. + * + * Pair structure: + * - 28 mirror-pairs (綜) + 4 polarity-pairs (錯, for the 8 self-mirror + * hexagrams 1/2, 27/28, 29/30, 61/62) cover 64 hexagrams in 32 pairs. + * - 53 entries (not 32) because the famously disordered final stretch + * (大過, 姤, 漸, 頤, 既濟, 歸妹, 未濟, 夬) breaks the regular pairing — + * preserved as-is per recognized textual feature. + * - One closing coda entry has an empty pair[] (no specific hex reference). + */ +export const ZA_GUA: ZaGuaEntry[] = [ + { index: 0, pair: [1, 2], names: ["乾", "坤"], text: "《乾》剛《坤》柔。" }, + { index: 1, pair: [8, 7], names: ["比", "師"], text: "《比》樂《師》憂。" }, + { index: 2, pair: [19, 20], names: ["臨", "觀"], text: "《臨》《觀》之義,或與或求。" }, + { index: 3, pair: [3], names: ["屯"], text: "《屯》見而不失其居。" }, + { index: 4, pair: [4], names: ["蒙"], text: "《蒙》雜而著。" }, + { index: 5, pair: [51, 52], names: ["震", "艮"], text: "《震》,起也。《艮》,止也。" }, + { index: 6, pair: [41, 42], names: ["損", "益"], text: "《損》《益》,盛衰之始也。" }, + { index: 7, pair: [26], names: ["大畜"], text: "《大畜》,時也。" }, + { index: 8, pair: [25], names: ["无妄"], text: "《无妄》,災也。" }, + { index: 9, pair: [45, 46], names: ["萃", "升"], text: "《萃》聚而《升》不來也。" }, + { index: 10, pair: [15, 16], names: ["謙", "豫"], text: "《謙》輕而《豫》怠也。" }, + { index: 11, pair: [21], names: ["噬嗑"], text: "《噬嗑》,食也。" }, + { index: 12, pair: [22], names: ["賁"], text: "《賁》,无色也。" }, + { index: 13, pair: [58, 57], names: ["兌", "巽"], text: "《兌》見而《巽》伏也。" }, + { index: 14, pair: [17], names: ["隨"], text: "《隨》,无故也。" }, + { index: 15, pair: [18], names: ["蠱"], text: "《蠱》則飭也。" }, + { index: 16, pair: [23], names: ["剝"], text: "《剝》,爛也。" }, + { index: 17, pair: [24], names: ["復"], text: "《復》,反也。" }, + { index: 18, pair: [35], names: ["晉"], text: "《晉》,晝也。" }, + { index: 19, pair: [36], names: ["明夷"], text: "《明夷》,誅也。" }, + { index: 20, pair: [48, 47], names: ["井", "困"], text: "《井》通而《困》相遇也。" }, + { index: 21, pair: [31], names: ["咸"], text: "《咸》,速也。" }, + { index: 22, pair: [32], names: ["恆"], text: "《恆》,久也。" }, + { index: 23, pair: [59], names: ["渙"], text: "《渙》,離也。" }, + { index: 24, pair: [60], names: ["節"], text: "《節》,止也。" }, + { index: 25, pair: [40], names: ["解"], text: "《解》,緩也。" }, + { index: 26, pair: [39], names: ["蹇"], text: "《蹇》,難也。" }, + { index: 27, pair: [38], names: ["睽"], text: "《睽》,外也。" }, + { index: 28, pair: [37], names: ["家人"], text: "《家人》,內也。" }, + { index: 29, pair: [12, 11], names: ["否", "泰"], text: "《否》《泰》,反其類也。" }, + { index: 30, pair: [34, 33], names: ["大壯", "遯"], text: "《大壯》則止,《遯》則退也。" }, + { index: 31, pair: [14], names: ["大有"], text: "《大有》,眾也。" }, + { index: 32, pair: [13], names: ["同人"], text: "《同人》,親也。" }, + { index: 33, pair: [49], names: ["革"], text: "《革》,去故也。" }, + { index: 34, pair: [50], names: ["鼎"], text: "《鼎》,取新也。" }, + { index: 35, pair: [62], names: ["小過"], text: "《小過》,過也。" }, + { index: 36, pair: [61], names: ["中孚"], text: "《中孚》,信也。" }, + { index: 37, pair: [55], names: ["豐"], text: "《豐》,多故也。" }, + { index: 38, pair: [56], names: ["旅"], text: "親寡《旅》也。" }, + { index: 39, pair: [30, 29], names: ["離", "坎"], text: "《離》上而《坎》下也。" }, + { index: 40, pair: [9], names: ["小畜"], text: "《小畜》,寡也。" }, + { index: 41, pair: [10], names: ["履"], text: "《履》,不處也。" }, + { index: 42, pair: [5], names: ["需"], text: "《需》,不進也。" }, + { index: 43, pair: [6], names: ["訟"], text: "《訟》,不親也。" }, + { index: 44, pair: [28], names: ["大過"], text: "《大過》,顛也。" }, + { index: 45, pair: [44], names: ["姤"], text: "《姤》,遇也,柔遇剛也。" }, + { index: 46, pair: [53], names: ["漸"], text: "《漸》,女歸待男行也。" }, + { index: 47, pair: [27], names: ["頤"], text: "《頤》,養正也。" }, + { index: 48, pair: [63], names: ["既濟"], text: "《既濟》,定也。" }, + { index: 49, pair: [54], names: ["歸妹"], text: "《歸妹》,女之終也。" }, + { index: 50, pair: [64], names: ["未濟"], text: "《未濟》,男之窮也。" }, + { index: 51, pair: [43], names: ["夬"], text: "《夬》,決也,剛決柔也。" }, + { index: 52, pair: [], names: [], text: "君子道長,小人道憂也。" }, +]; + +export const ZA_GUA_META = { + source: "https://ctext.org/book-of-changes/xu-gua and https://ctext.org/book-of-changes/za-gua", + crossChecks: [ + "https://zh.wikisource.org/wiki/%E5%91%A8%E6%98%93/%E5%BA%8F%E5%8D%A6", + "https://zh.wikisource.org/wiki/%E5%91%A8%E6%98%93/%E9%9B%9C%E5%8D%A6", + ], + license: "ancient classical Chinese text, public domain (Ten Wings of the I Ching, ~Han dynasty or earlier)", + punctuationSource: "ctext editorial — uses 《》 quotation marks around hexagram names and modern Chinese commas/periods (, 。). Wikisource cross-check uses similar punctuation. The underlying characters match across both sources.", +} as const; + +/** + * Reverse index: hexagram number (1..64) → its 雜卦 entry. + * + * Each hexagram appears in exactly one ZA_GUA entry. For the 8 self- + * mirror hexagrams (1, 2, 27, 28, 29, 30, 61, 62), the entry pairs them + * with their polarity partner. For the disordered tail, the canonical + * source-order pairing is preserved. + * + * Closing-coda entry (empty pair[]) contributes no index keys. + */ +export const ZA_GUA_BY_HEX: Record = (() => { + const idx: Record = {}; + for (const entry of ZA_GUA) { + for (const hex of entry.pair) { + idx[hex] = entry; + } + } + return idx; +})(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7590b71..185b6a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,14 @@ export type { Structure, DailyCache, HistoryEntry, + // Data-enrichment additions (U1 — defined here, exported now for U2+ consumers) + LeggeHexagram, + TrigramAssoc, + XuGuaEntry, + ZaGuaEntry, + ShuoguaChapter, + ShuoguaCitation, + CastConnections, } from "./types.js"; // RandomSource @@ -56,6 +64,8 @@ export { DERIVED_LABELS_CN, } from "./data/trigrams.js"; export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from "./data/large-glyphs.js"; +export { XU_GUA, XU_GUA_META } from "./data/xugua.js"; +export { ZA_GUA, ZA_GUA_META, ZA_GUA_BY_HEX } from "./data/zagua.js"; // Format export { formatReading, getRandomQuoteStyle } from "./format/reading.js"; From a81e20a5cc21fc512fda2ecea1e4019364ab8237 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:14:11 -0700 Subject: [PATCH 04/22] =?UTF-8?q?feat(data):=20add=20=E8=AA=AA=E5=8D=A6?= =?UTF-8?q?=E5=82=B3=20module=20(U3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shuogua.ts: SHUOGUA — 11 canonical chapters per the standard 王弼/孔穎達 division (周易正義). TRIGRAM_ASSOC — 8-trigram structured catalogue keyed by zh char. Sourced from ctext.org with 4 cross-checks. Provenance in SHUOGUA_META. Canonical fields (image, family, body, animal, direction, attribute, extendedImages) normalized to pure canonical chars per 說卦 ch.7-10 — phonological variants ((悅) on 兌) and alternate images (木 on 巽) stripped from canonical columns; the alternates already live in extendedImages where the canon places them. Optional editorial fields (season, cosmologicalRole, other) carry English glosses; UI must label non-canonical per the U4 plan. Tests: 16 integrity assertions — chapter count + sequential numbers, verification anchors (ch5 directional cycle, ch8 animals, ch11 extended catalogue), every canonical column matches 說卦 verbatim (馬牛龍雞豕雉狗羊 animal column, 健順動入陷麗止說 attribute column, 父母三男三女 family column via 索 scheme), 乾 extendedImages has the 14-image catalogue, editorial fields present on 乾 spot-check. Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- packages/core/src/__tests__/shuogua.test.ts | 158 ++++++++++++++++++ packages/core/src/data/shuogua.ts | 168 ++++++++++++++++++++ packages/core/src/index.ts | 1 + 3 files changed, 327 insertions(+) create mode 100644 packages/core/src/__tests__/shuogua.test.ts create mode 100644 packages/core/src/data/shuogua.ts diff --git a/packages/core/src/__tests__/shuogua.test.ts b/packages/core/src/__tests__/shuogua.test.ts new file mode 100644 index 0000000..dec4919 --- /dev/null +++ b/packages/core/src/__tests__/shuogua.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect } from "bun:test"; +import { SHUOGUA, TRIGRAM_ASSOC, SHUOGUA_META } from "../data/shuogua.js"; + +/** + * U3 integrity tests — 說卦傳 data module. + * + * Verifies the 11-chapter canonical body, the 8-trigram structured + * catalogue, and the verification anchors documented in the source JSON's + * `_meta.verificationAnchors`. The canonical fields are required by the + * type; the editorial fields (season, cosmologicalRole, other) are flagged + * as derived in the type and labeled non-canonical in the UI. + */ + +const TRIGRAM_KEYS = ["乾", "坤", "震", "巽", "坎", "離", "艮", "兌"]; + +describe("SHUOGUA chapters", () => { + test("has exactly 11 chapters (standard 王弼 / 孔穎達 division)", () => { + expect(SHUOGUA.chapters).toHaveLength(11); + }); + + test("chapter numbers are sequential 1..11", () => { + SHUOGUA.chapters.forEach((ch, i) => { + expect(ch.n).toBe(i + 1); + }); + }); + + test("every chapter has non-empty text", () => { + for (const ch of SHUOGUA.chapters) { + expect(ch.text.length).toBeGreaterThan(0); + } + }); + + test("verification anchor — ch5 directional cycle (帝出乎震…)", () => { + const ch5 = SHUOGUA.chapters[4]!; + expect(ch5.text).toContain("帝出乎震"); + expect(ch5.text).toContain("齊乎巽"); + expect(ch5.text).toContain("成言乎艮"); + }); + + test("verification anchor — ch8 animal associations", () => { + const ch8 = SHUOGUA.chapters[7]!; + expect(ch8.text).toContain("乾為馬"); + expect(ch8.text).toContain("坤為牛"); + expect(ch8.text).toContain("艮為狗"); + expect(ch8.text).toContain("兌為羊"); + }); + + test("verification anchor — ch11 extended trigram catalogue", () => { + const ch11 = SHUOGUA.chapters[10]!; + expect(ch11.text).toContain("乾為天"); + expect(ch11.text).toContain("為圜"); + expect(ch11.text).toContain("坤為地"); + expect(ch11.text).toContain("為母"); + }); +}); + +describe("TRIGRAM_ASSOC", () => { + test("has 8 entries keyed by canonical trigram zh char", () => { + expect(Object.keys(TRIGRAM_ASSOC).sort()).toEqual([...TRIGRAM_KEYS].sort()); + }); + + test("every trigram has all 7 canonical fields populated", () => { + for (const k of TRIGRAM_KEYS) { + const assoc = TRIGRAM_ASSOC[k]; + expect(assoc).toBeDefined(); + expect(assoc!.image.length).toBeGreaterThan(0); + expect(assoc!.family.length).toBeGreaterThan(0); + expect(assoc!.body.length).toBeGreaterThan(0); + expect(assoc!.animal.length).toBeGreaterThan(0); + expect(assoc!.direction.length).toBeGreaterThan(0); + expect(assoc!.attribute.length).toBeGreaterThan(0); + expect(assoc!.extendedImages.length).toBeGreaterThan(0); + } + }); + + test("乾 canonical fields match canon", () => { + const q = TRIGRAM_ASSOC["乾"]!; + expect(q.image).toBe("天"); + expect(q.family).toBe("父"); + expect(q.body).toBe("首"); + expect(q.animal).toBe("馬"); + expect(q.direction).toBe("西北"); + expect(q.attribute).toBe("健"); + // ch.11 catalogue for 乾 has 14 extended images + expect(q.extendedImages).toHaveLength(14); + }); + + test("坤 canonical fields match canon", () => { + const k = TRIGRAM_ASSOC["坤"]!; + expect(k.image).toBe("地"); + expect(k.family).toBe("母"); + expect(k.body).toBe("腹"); + expect(k.animal).toBe("牛"); + expect(k.direction).toBe("西南"); + expect(k.attribute).toBe("順"); + }); + + test("animal column matches 說卦 ch.8 exact list", () => { + expect(TRIGRAM_ASSOC["乾"]!.animal).toBe("馬"); + expect(TRIGRAM_ASSOC["坤"]!.animal).toBe("牛"); + expect(TRIGRAM_ASSOC["震"]!.animal).toBe("龍"); + expect(TRIGRAM_ASSOC["巽"]!.animal).toBe("雞"); + expect(TRIGRAM_ASSOC["坎"]!.animal).toBe("豕"); + expect(TRIGRAM_ASSOC["離"]!.animal).toBe("雉"); + expect(TRIGRAM_ASSOC["艮"]!.animal).toBe("狗"); + expect(TRIGRAM_ASSOC["兌"]!.animal).toBe("羊"); + }); + + test("family column matches the 索 indexing scheme (父母三男三女)", () => { + expect(TRIGRAM_ASSOC["乾"]!.family).toBe("父"); + expect(TRIGRAM_ASSOC["坤"]!.family).toBe("母"); + expect(TRIGRAM_ASSOC["震"]!.family).toBe("長男"); + expect(TRIGRAM_ASSOC["巽"]!.family).toBe("長女"); + expect(TRIGRAM_ASSOC["坎"]!.family).toBe("中男"); + expect(TRIGRAM_ASSOC["離"]!.family).toBe("中女"); + expect(TRIGRAM_ASSOC["艮"]!.family).toBe("少男"); + expect(TRIGRAM_ASSOC["兌"]!.family).toBe("少女"); + }); + + test("body column matches 說卦 ch.9 list", () => { + expect(TRIGRAM_ASSOC["乾"]!.body).toBe("首"); + expect(TRIGRAM_ASSOC["坤"]!.body).toBe("腹"); + expect(TRIGRAM_ASSOC["震"]!.body).toBe("足"); + expect(TRIGRAM_ASSOC["巽"]!.body).toBe("股"); + expect(TRIGRAM_ASSOC["坎"]!.body).toBe("耳"); + expect(TRIGRAM_ASSOC["離"]!.body).toBe("目"); + expect(TRIGRAM_ASSOC["艮"]!.body).toBe("手"); + expect(TRIGRAM_ASSOC["兌"]!.body).toBe("口"); + }); + + test("attribute column matches 說卦 ch.7 (健順動入陷麗止說)", () => { + expect(TRIGRAM_ASSOC["乾"]!.attribute).toBe("健"); + expect(TRIGRAM_ASSOC["坤"]!.attribute).toBe("順"); + expect(TRIGRAM_ASSOC["震"]!.attribute).toBe("動"); + expect(TRIGRAM_ASSOC["巽"]!.attribute).toBe("入"); + expect(TRIGRAM_ASSOC["坎"]!.attribute).toBe("陷"); + expect(TRIGRAM_ASSOC["離"]!.attribute).toBe("麗"); + expect(TRIGRAM_ASSOC["艮"]!.attribute).toBe("止"); + expect(TRIGRAM_ASSOC["兌"]!.attribute).toBe("說"); + }); + + test("editorial fields populated where the source provides them", () => { + // The JSON warn flagged season/cosmologicalRole/other as editorial + // synthesis with English glosses in parens. Spot-check 乾 has all three. + expect(TRIGRAM_ASSOC["乾"]!.season).toBeDefined(); + expect(TRIGRAM_ASSOC["乾"]!.cosmologicalRole).toBeDefined(); + expect(TRIGRAM_ASSOC["乾"]!.other).toBeDefined(); + }); +}); + +describe("SHUOGUA_META", () => { + test("carries provenance + chapter count", () => { + expect(SHUOGUA_META.source).toMatch(/ctext\.org/); + expect(SHUOGUA_META.crossChecks.length).toBeGreaterThan(0); + expect(SHUOGUA_META.license).toMatch(/public domain/); + expect(SHUOGUA_META.chapterCount).toBe(11); + }); +}); diff --git a/packages/core/src/data/shuogua.ts b/packages/core/src/data/shuogua.ts new file mode 100644 index 0000000..57f6e8e --- /dev/null +++ b/packages/core/src/data/shuogua.ts @@ -0,0 +1,168 @@ +import type { ShuoguaChapter, TrigramAssoc } from "../types.js"; + +/** + * 說卦傳 — Discussion of the Trigrams. + * + * One of the Ten Wings of the I Ching. Composed ~5th-3rd century BCE + * (Warring States / early Han); transmitted with the Yijing for over + * 2,200 years. Public domain. + * + * Eleven chapters in the standard 王弼 / 孔穎達 division (周易正義): + * - ch 1-3: cosmological framing — sages, yin-yang, three powers + * - ch 4-6: trigram motion, seasonal cycle, summary + * - ch 7: core attributes (健順動入陷麗止說) + * - ch 8: animal associations (馬牛龍雞豕雉狗羊) + * - ch 9: body part associations (首腹足股耳目手口) + * - ch 10: family role associations (父母三男三女) via the 索 scheme + * - ch 11: extended polysemous associations — the richest catalogue + * + * Source: ctext.org/book-of-changes/shuo-gua/zh (primary), cross-checked + * against zh.wikisource.org and 3 other PD I Ching repos. + */ +export const SHUOGUA: { chapters: ShuoguaChapter[] } = { + chapters: [ + { n: 1, text: "昔者聖人之作《易》也,幽贊於神明而生蓍,參天兩地而倚數,觀變於陰陽而立卦,發揮於剛柔而生爻,和順於道德而理於義,窮理盡性以至於命。" }, + { n: 2, text: "昔者聖人之作《易》也,將以順性命之理,是以立天之道曰陰與陽,立地之道曰柔與剛,立人之道曰仁與義。兼三才而兩之,故《易》六畫而成卦。分陰分陽,迭用柔剛,故《易》六位而成章。" }, + { n: 3, text: "天地定位,山澤通氣,雷風相薄,水火不相射,八卦相錯。數往者順,知來者逆,是故《易》逆數也。" }, + { n: 4, text: "雷以動之,風以散之,雨以潤之,日以烜之,艮以止之,兌以說之,乾以君之,坤以藏之。" }, + { n: 5, text: "帝出乎震,齊乎巽,相見乎離,致役乎坤,說言乎兌,戰乎乾,勞乎坎,成言乎艮。萬物出乎震,震東方也。齊乎巽,巽東南也,齊也者,言萬物之絜齊也。離也者,明也,萬物皆相見,南方之卦也。聖人南面而聽天下,嚮明而治,蓋取諸此也。坤也者,地也,萬物皆致養焉,故曰致役乎坤。兌,正秋也,萬物之所說也,故曰說言乎兌。戰乎乾,乾,西北之卦也,言陰陽相薄也。坎者,水也,正北方之卦也,勞卦也,萬物之所歸也,故曰勞乎坎。艮,東北之卦也,萬物之所成終而所成始也,故曰成言乎艮。" }, + { n: 6, text: "神也者,妙萬物而為言者也。動萬物者莫疾乎雷,橈萬物者莫疾乎風,燥萬物者莫熯乎火,說萬物者莫說乎澤,潤萬物者莫潤乎水,終萬物始萬物者莫盛乎艮。故水火相逮,雷風不相悖,山澤通氣,然後能變化,既成萬物也。" }, + { n: 7, text: "乾,健也;坤,順也;震,動也;巽,入也;坎,陷也;離,麗也;艮,止也;兌,說也。" }, + { n: 8, text: "乾為馬,坤為牛,震為龍,巽為雞,坎為豕,離為雉,艮為狗,兌為羊。" }, + { n: 9, text: "乾為首,坤為腹,震為足,巽為股,坎為耳,離為目,艮為手,兌為口。" }, + { n: 10, text: "乾,天也,故稱乎父;坤,地也,故稱乎母。震一索而得男,故謂之長男;巽一索而得女,故謂之長女。坎再索而得男,故謂之中男;離再索而得女,故謂之中女。艮三索而得男,故謂之少男;兌三索而得女,故謂之少女。" }, + { n: 11, text: "乾為天,為圜,為君,為父,為玉,為金,為寒,為冰,為大赤,為良馬,為老馬,為瘠馬,為駁馬,為木果。坤為地,為母,為布,為釜,為吝嗇,為均,為子母牛,為大輿,為文,為眾,為柄,其於地也為黑。震為雷,為龍,為玄黃,為敷,為大塗,為長子,為決躁,為蒼筤竹,為萑葦;其於馬也,為善鳴,為馵足,為作足,為的顙;其於稼也,為反生;其究為健,為蕃鮮。巽為木,為風,為長女,為繩直,為工,為白,為長,為高,為進退,為不果,為臭;其於人也,為寡髮,為廣顙,為多白眼,為近利市三倍;其究為躁卦。坎為水,為溝瀆,為隱伏,為矯輮,為弓輪;其於人也,為加憂,為心病,為耳痛,為血卦,為赤;其於馬也,為美脊,為亟心,為下首,為薄蹄,為曳;其於輿也,為多眚,為通,為月,為盜;其於木也,為堅多心。離為火,為日,為電,為中女,為甲冑,為戈兵;其於人也,為大腹;為乾卦,為鱉,為蟹,為蠃,為蚌,為龜;其於木也,為科上槁。艮為山,為徑路,為小石,為門闕,為果蓏,為閽寺,為指,為狗,為鼠,為黔喙之屬;其於木也,為堅多節。兌為澤,為少女,為巫,為口舌,為毀折,為附決;其於地也,為剛鹵;為妾,為羊。" }, + ], +}; + +/** + * Structured trigram catalogue from 說卦 chapters 7–11, keyed by + * trigram zh character. + * + * Canonical fields (image, family, body, animal, direction, attribute, + * extendedImages) are normalized to the pure canonical character per + * 說卦 ch.7-10 — phonological variants and alternate images live in + * `extendedImages` (e.g. 巽 carries both 風 and 木 there). + * + * Optional fields (season, cosmologicalRole, other) are editorial + * synthesis with English glosses in parens — UI must label as derived. + */ +export const TRIGRAM_ASSOC: Record = { + // qian — 乾 + "乾": { + image: "天", + family: "父", + body: "首", + animal: "馬", + direction: "西北", + attribute: "健", + extendedImages: ["天", "圜", "君", "父", "玉", "金", "寒", "冰", "大赤", "良馬", "老馬", "瘠馬", "駁馬", "木果"], + season: "立冬之際 (late autumn / early winter)", + cosmologicalRole: "戰乎乾 (battle / clash of yin-yang)", + other: "乾以君之 (Qian rules as sovereign)", + }, + // kun — 坤 + "坤": { + image: "地", + family: "母", + body: "腹", + animal: "牛", + direction: "西南", + attribute: "順", + extendedImages: ["地", "母", "布", "釜", "吝嗇", "均", "子母牛", "大輿", "文", "眾", "柄", "黑 (其於地也)"], + season: "late summer (transition)", + cosmologicalRole: "致役乎坤 (all things receive nourishment)", + other: "坤以藏之 (Kun stores / conceals)", + }, + // zhen — 震 + "震": { + image: "雷", + family: "長男", + body: "足", + animal: "龍", + direction: "東", + attribute: "動", + extendedImages: ["雷", "龍", "玄黃", "敷", "大塗", "長子", "決躁", "蒼筤竹", "萑葦", "善鳴馬", "馵足馬", "作足馬", "的顙馬", "反生 (稼)", "健", "蕃鮮"], + season: "spring", + cosmologicalRole: "帝出乎震 (the Lord issues forth from Zhen)", + other: "震一索而得男,故謂之長男;雷以動之", + }, + // xun — 巽 + "巽": { + image: "風", + family: "長女", + body: "股", + animal: "雞", + direction: "東南", + attribute: "入", + extendedImages: ["木", "風", "長女", "繩直", "工", "白", "長", "高", "進退", "不果", "臭", "寡髮", "廣顙", "多白眼", "近利市三倍", "躁卦 (其究)"], + season: "late spring / early summer", + cosmologicalRole: "齊乎巽 (all things become uniform / clean)", + other: "巽一索而得女,故謂之長女;風以散之", + }, + // kan — 坎 + "坎": { + image: "水", + family: "中男", + body: "耳", + animal: "豕", + direction: "北", + attribute: "陷", + extendedImages: ["水", "溝瀆", "隱伏", "矯輮", "弓輪", "加憂", "心病", "耳痛", "血卦", "赤", "美脊馬", "亟心馬", "下首馬", "薄蹄馬", "曳馬", "多眚 (輿)", "通", "月", "盜", "堅多心 (木)"], + season: "winter (正北方)", + cosmologicalRole: "勞乎坎 (place of toil / where all things return)", + other: "坎再索而得男,故謂之中男;雨以潤之 / 潤萬物者莫潤乎水", + }, + // li — 離 + "離": { + image: "火", + family: "中女", + body: "目", + animal: "雉", + direction: "南", + attribute: "麗", + extendedImages: ["火", "日", "電", "中女", "甲冑", "戈兵", "大腹", "乾卦 (drying)", "鱉", "蟹", "蠃", "蚌", "龜", "科上槁 (木)"], + season: "summer", + cosmologicalRole: "相見乎離 (all things see one another)", + other: "離再索而得女,故謂之中女;日以烜之 / 燥萬物者莫熯乎火", + }, + // gen — 艮 + "艮": { + image: "山", + family: "少男", + body: "手", + animal: "狗", + direction: "東北", + attribute: "止", + extendedImages: ["山", "徑路", "小石", "門闕", "果蓏", "閽寺", "指", "狗", "鼠", "黔喙之屬", "堅多節 (木)"], + season: "late winter / early spring (transition)", + cosmologicalRole: "成言乎艮 (all things complete and begin anew)", + other: "艮三索而得男,故謂之少男;艮以止之 / 終萬物始萬物者莫盛乎艮", + }, + // dui — 兌 + "兌": { + image: "澤", + family: "少女", + body: "口", + animal: "羊", + direction: "西", + attribute: "說", + extendedImages: ["澤", "少女", "巫", "口舌", "毀折", "附決", "剛鹵 (其於地也)", "妾", "羊"], + season: "正秋 (mid-autumn)", + cosmologicalRole: "說言乎兌 (all things rejoice)", + other: "兌三索而得女,故謂之少女;兌以說之 / 說萬物者莫說乎澤", + }, +}; + +export const SHUOGUA_META = { + source: "https://ctext.org/book-of-changes/shuo-gua/zh (Chinese Text Project — primary)", + crossChecks: [ + "https://ctext.org/book-of-changes/shuo-gua", + "https://www.eee-learning.com/book/eee67 (易學網)", + "https://zhouyipro.com/shuogua.html", + "https://www.yilusoso.com/yjrm/971/", + "zh.wikisource.org 周易 / 周易正義 transmissions", + ], + license: "ancient classical Chinese, public domain (work composed before 200 BCE; over 2,200 years old)", + chapterCount: 11, +} as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 185b6a0..e478d3c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,6 +66,7 @@ export { export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from "./data/large-glyphs.js"; export { XU_GUA, XU_GUA_META } from "./data/xugua.js"; export { ZA_GUA, ZA_GUA_META, ZA_GUA_BY_HEX } from "./data/zagua.js"; +export { SHUOGUA, TRIGRAM_ASSOC, SHUOGUA_META } from "./data/shuogua.js"; // Format export { formatReading, getRandomQuoteStyle } from "./format/reading.js"; From c9633f329e778a2b1d5f9609a7fdb0e000b2cb5a Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:16:52 -0700 Subject: [PATCH 05/22] feat(data): TrigramInfo .assoc + en glosses (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigrams.ts: wires TRIGRAM_ASSOC into each of the 8 TRIGRAMS entries via the new .assoc field (by reference, not copy — same identity as TRIGRAM_ASSOC[zhName]). Adds TRIGRAM_ASSOC_EN — project-authored short English glosses for family/body/animal/direction/attribute, keyed by trigram zh char. NOT from Legge or Wilhelm/Baynes; these are project-supplied UI convenience labels. UI surfaces MUST label them as "project gloss" per the U6/Q5 decision. Excludes extendedImages (no terse en for 14-item polysemous catalogue — render in zh). Tests (9): TRIGRAMS shape preserved (sym/n/img unchanged from baseline), every .assoc points into TRIGRAM_ASSOC by reference, all canonical fields populated, TRIGRAM_ASSOC_EN has 8 keys + 5 fields each, no extendedImages leak, ASCII-only check for the en glosses (defense against accidental zh paste), family-scheme spot check (father/mother/eldest/middle/youngest). Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- packages/core/src/__tests__/trigrams.test.ts | 98 ++++++++++++++++++++ packages/core/src/data/trigrams.ts | 51 ++++++++-- packages/core/src/index.ts | 2 + 3 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/__tests__/trigrams.test.ts diff --git a/packages/core/src/__tests__/trigrams.test.ts b/packages/core/src/__tests__/trigrams.test.ts new file mode 100644 index 0000000..61df58f --- /dev/null +++ b/packages/core/src/__tests__/trigrams.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect } from "bun:test"; +import { TRIGRAMS, TRIGRAM_ASSOC_EN } from "../data/trigrams.js"; +import { TRIGRAM_ASSOC } from "../data/shuogua.js"; + +/** + * U4 integrity tests — TrigramInfo enrichment + project-authored English + * glosses. Validates that: + * - every TrigramInfo carries the canonical 說卦 catalogue via .assoc + * - TRIGRAM_ASSOC_EN parallels the 8 trigrams with the 5 expected fields + * - editorial English does not contaminate canonical zh + */ + +const ZH_NAMES = ["坤", "震", "坎", "兌", "艮", "離", "巽", "乾"]; // in TRIGRAMS order + +describe("TRIGRAMS — TrigramInfo enrichment with assoc", () => { + test("has 8 entries in canonical binary order", () => { + expect(TRIGRAMS).toHaveLength(8); + TRIGRAMS.forEach((t, i) => { + expect(t.n).toBe(ZH_NAMES[i]); + }); + }); + + test("every TrigramInfo carries .assoc pointing into TRIGRAM_ASSOC", () => { + for (const trigram of TRIGRAMS) { + expect(trigram.assoc).toBeDefined(); + // Same reference — the wiring is by identity, not a copy. + expect(trigram.assoc).toBe(TRIGRAM_ASSOC[trigram.n]!); + } + }); + + test("each TrigramInfo.assoc has all canonical fields populated", () => { + for (const trigram of TRIGRAMS) { + const assoc = trigram.assoc!; + expect(assoc.image.length).toBeGreaterThan(0); + expect(assoc.family.length).toBeGreaterThan(0); + expect(assoc.body.length).toBeGreaterThan(0); + expect(assoc.animal.length).toBeGreaterThan(0); + expect(assoc.direction.length).toBeGreaterThan(0); + expect(assoc.attribute.length).toBeGreaterThan(0); + expect(assoc.extendedImages.length).toBeGreaterThan(0); + } + }); + + test("existing TrigramInfo fields (sym, n, img) are unchanged", () => { + // Lock the legacy contract — adding .assoc must not silently shift + // the other fields. These exact values are the pre-U4 baseline. + expect(TRIGRAMS[0]).toMatchObject({ n: "坤", img: "earth", sym: "☷" }); + expect(TRIGRAMS[7]).toMatchObject({ n: "乾", img: "heaven", sym: "☰" }); + }); +}); + +describe("TRIGRAM_ASSOC_EN — project-authored English glosses", () => { + const TRIGRAM_KEYS = ["乾", "坤", "震", "巽", "坎", "離", "艮", "兌"]; + const EN_FIELDS = ["family", "body", "animal", "direction", "attribute"] as const; + + test("has 8 entries keyed by trigram zh char", () => { + expect(Object.keys(TRIGRAM_ASSOC_EN).sort()).toEqual([...TRIGRAM_KEYS].sort()); + }); + + test("every entry has all 5 required English fields", () => { + for (const k of TRIGRAM_KEYS) { + const en = TRIGRAM_ASSOC_EN[k]!; + for (const field of EN_FIELDS) { + expect(en[field]).toBeDefined(); + expect(en[field].length).toBeGreaterThan(0); + } + } + }); + + test("no entry carries extendedImages (project policy — render zh only)", () => { + for (const k of TRIGRAM_KEYS) { + const en = TRIGRAM_ASSOC_EN[k] as unknown as Record; + expect(en.extendedImages).toBeUndefined(); + } + }); + + test("project family glosses follow the eldest/middle/youngest scheme", () => { + expect(TRIGRAM_ASSOC_EN["乾"]!.family).toBe("father"); + expect(TRIGRAM_ASSOC_EN["坤"]!.family).toBe("mother"); + expect(TRIGRAM_ASSOC_EN["震"]!.family).toBe("eldest son"); + expect(TRIGRAM_ASSOC_EN["巽"]!.family).toBe("eldest daughter"); + expect(TRIGRAM_ASSOC_EN["坎"]!.family).toBe("middle son"); + expect(TRIGRAM_ASSOC_EN["離"]!.family).toBe("middle daughter"); + expect(TRIGRAM_ASSOC_EN["艮"]!.family).toBe("youngest son"); + expect(TRIGRAM_ASSOC_EN["兌"]!.family).toBe("youngest daughter"); + }); + + test("project glosses use ASCII-only single-word labels (no canonical zh leak)", () => { + // Defense against accidentally pasting a zh character into the en gloss. + const asciiOnly = /^[\x20-\x7E]+$/; + for (const k of Object.keys(TRIGRAM_ASSOC_EN)) { + const en = TRIGRAM_ASSOC_EN[k]!; + for (const field of EN_FIELDS) { + expect(en[field]).toMatch(asciiOnly); + } + } + }); +}); diff --git a/packages/core/src/data/trigrams.ts b/packages/core/src/data/trigrams.ts index 8025825..844e48f 100644 --- a/packages/core/src/data/trigrams.ts +++ b/packages/core/src/data/trigrams.ts @@ -1,14 +1,15 @@ import type { Style, DerivedType, TrigramInfo } from "../types.js"; +import { TRIGRAM_ASSOC } from "./shuogua.js"; export const TRIGRAMS: TrigramInfo[] = [ - { n: "坤", img: "earth", sym: "☷" }, // 0: 000 - { n: "震", img: "thunder", sym: "☳" }, // 1: 001 - { n: "坎", img: "water", sym: "☵" }, // 2: 010 - { n: "兌", img: "lake", sym: "☱" }, // 3: 011 - { n: "艮", img: "mountain", sym: "☶" }, // 4: 100 - { n: "離", img: "fire", sym: "☲" }, // 5: 101 - { n: "巽", img: "wind", sym: "☴" }, // 6: 110 - { n: "乾", img: "heaven", sym: "☰" }, // 7: 111 + { n: "坤", img: "earth", sym: "☷", assoc: TRIGRAM_ASSOC["坤"] }, // 0: 000 + { n: "震", img: "thunder", sym: "☳", assoc: TRIGRAM_ASSOC["震"] }, // 1: 001 + { n: "坎", img: "water", sym: "☵", assoc: TRIGRAM_ASSOC["坎"] }, // 2: 010 + { n: "兌", img: "lake", sym: "☱", assoc: TRIGRAM_ASSOC["兌"] }, // 3: 011 + { n: "艮", img: "mountain", sym: "☶", assoc: TRIGRAM_ASSOC["艮"] }, // 4: 100 + { n: "離", img: "fire", sym: "☲", assoc: TRIGRAM_ASSOC["離"] }, // 5: 101 + { n: "巽", img: "wind", sym: "☴", assoc: TRIGRAM_ASSOC["巽"] }, // 6: 110 + { n: "乾", img: "heaven", sym: "☰", assoc: TRIGRAM_ASSOC["乾"] }, // 7: 111 ]; export const STYLES: Style[] = ["dx", "tu", "en", "te", "w", "st"]; @@ -30,3 +31,37 @@ export const DERIVED_LABELS_CN: Record = { becoming: "之卦 (所往)", diagonal: "對角卦 (極反)", }; + +/** + * Short English glosses for the structured 說卦 trigram catalogue. + * + * Project-authored — NOT from Legge, NOT from Wilhelm/Baynes, NOT + * derivable from any canonical English translation. These are short + * single-word labels chosen by the project to surface the canonical zh + * fields to English-only readers in UI displays. + * + * UI MUST label these as "project gloss" (or equivalent) wherever they + * render — they are not canonical 說卦 content. The canonical zh fields + * always remain the source of truth; these are a convenience layer. + * + * Excludes `extendedImages` (no terse English equivalent for a 14-item + * polysemous catalogue — render in zh per the U6 cast surface). + */ +export interface TrigramAssocEn { + family: string; + body: string; + animal: string; + direction: string; + attribute: string; +} + +export const TRIGRAM_ASSOC_EN: Record = { + "乾": { family: "father", body: "head", animal: "horse", direction: "northwest", attribute: "strength" }, + "坤": { family: "mother", body: "belly", animal: "ox", direction: "southwest", attribute: "yielding" }, + "震": { family: "eldest son", body: "foot", animal: "dragon", direction: "east", attribute: "movement" }, + "巽": { family: "eldest daughter", body: "thigh", animal: "rooster", direction: "southeast", attribute: "penetration" }, + "坎": { family: "middle son", body: "ear", animal: "pig", direction: "north", attribute: "danger" }, + "離": { family: "middle daughter", body: "eye", animal: "pheasant", direction: "south", attribute: "clinging" }, + "艮": { family: "youngest son", body: "hand", animal: "dog", direction: "northeast", attribute: "stillness" }, + "兌": { family: "youngest daughter", body: "mouth", animal: "sheep", direction: "west", attribute: "joy" }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e478d3c..b62fcbe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -62,6 +62,8 @@ export { QUOTE_STYLES, DERIVED_LABELS, DERIVED_LABELS_CN, + TRIGRAM_ASSOC_EN, + type TrigramAssocEn, } from "./data/trigrams.js"; export { LARGE_GLYPHS, type GlyphFont, type GlyphSize, type GlyphEntry } from "./data/large-glyphs.js"; export { XU_GUA, XU_GUA_META } from "./data/xugua.js"; From d3111fc277880c16e578cfbecb445a718a152e13 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:21:37 -0700 Subject: [PATCH 06/22] feat(core): connections overlay + HexagramDetail aggregation (U5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derivation/connections.ts: pure function connections(cast: Pick): CastConnections that joins the numeric derivations with canonical text data — XU_GUA (序卦 sequence narrative), ZA_GUA_BY_HEX (雜卦 contrast pair), and a static DERIVATION_CITATIONS map (5 ops → 說卦 chapter). No I/O. detail.ts: HexagramDetail extends with .connections; buildHexagramDetail synthesizes a minimal {primary: kw} for the lookup. Existing fields unchanged. index.ts: re-exports connections. Tests (14): overlay correctness on hex 1/3/29/30, all-hex coverage, shuoguaCitation chapter validity, fresh-array per call (no shared mutable state), HexagramDetail integration. The satisfying adversarial check is the orbit-integrity invariant — for every non-tail hex with a 2-element 雜卦 pair, the pair contains the geometric partner (mirror, with polarity fallback for the 8 self-mirror hexagrams). All 56 non-tail hexagrams pass; the famously disordered final 8 (27, 28, 43, 44, 53, 54, 63, 64) are whitelisted by Set membership. Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- .../core/src/__tests__/connections.test.ts | 162 ++++++++++++++++++ packages/core/src/derivation/connections.ts | 55 ++++++ packages/core/src/detail.ts | 6 +- packages/core/src/index.ts | 1 + 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/connections.test.ts create mode 100644 packages/core/src/derivation/connections.ts diff --git a/packages/core/src/__tests__/connections.test.ts b/packages/core/src/__tests__/connections.test.ts new file mode 100644 index 0000000..46938f8 --- /dev/null +++ b/packages/core/src/__tests__/connections.test.ts @@ -0,0 +1,162 @@ +import { describe, test, expect } from "bun:test"; +import { connections } from "../derivation/connections.js"; +import { ZA_GUA_BY_HEX } from "../data/zagua.js"; +import { mirror } from "../derivation/mirror.js"; +import { polarity } from "../derivation/polarity.js"; +import { hexagramByKW } from "../identify/lookup.js"; +import { buildHexagramDetail } from "../detail.js"; +import type { Line } from "../types.js"; + +/** + * U5 tests — connections overlay + HexagramDetail aggregation. + * + * The orbit-integrity tests are the satisfying adversarial check: + * for every non-tail hexagram with a 2-element 雜卦 pair, the pair must + * contain the geometric partner (mirror, with polarity fallback for the + * 8 self-mirror hexagrams). The famously disordered final 8 are + * whitelisted — the canon's own irregularity, not a pull bug. + */ + +const DISORDERED_TAIL = new Set([27, 28, 43, 44, 53, 54, 63, 64]); + +function linesOf(kw: number): Line[] { + const hex = hexagramByKW(kw); + return hex.l.map((v) => ({ + value: v === 1 ? (7 as const) : (8 as const), + isYang: v === 1, + isChanging: false, + })); +} + +describe("connections — text overlay for a cast", () => { + test("returns 5 shuoguaCitations covering all DerivedType ops", () => { + const conn = connections({ primary: 3 }); + expect(conn.shuoguaCitations).toHaveLength(5); + const ops = conn.shuoguaCitations.map((c) => c.op).sort(); + expect(ops).toEqual(["becoming", "diagonal", "mirror", "nuclear", "polarity"]); + }); + + test("shuoguaCitations chapter numbers are valid 1..11", () => { + const conn = connections({ primary: 3 }); + for (const c of conn.shuoguaCitations) { + expect(c.chapter).toBeGreaterThanOrEqual(1); + expect(c.chapter).toBeLessThanOrEqual(11); + } + }); + + test("hex 3 (屯) — xuGua carries canonical sequence transition", () => { + const conn = connections({ primary: 3 }); + expect(conn.xuGua).toBeDefined(); + expect(conn.xuGua!.hexagram).toBe(3); + expect(conn.xuGua!.name).toBe("屯"); + expect(conn.xuGua!.text).toContain("故受之以"); + }); + + test("hex 1 (乾) — xuGua present with shared cosmological preamble note", () => { + const conn = connections({ primary: 1 }); + expect(conn.xuGua).toBeDefined(); + expect(conn.xuGua!.name).toBe("乾"); + expect(conn.xuGua!.text).toContain("有天地,然後萬物生焉"); + expect(conn.xuGua!.note).toBeDefined(); + }); + + test("hex 1 + hex 2 share the opening cosmological preamble", () => { + const conn1 = connections({ primary: 1 }); + const conn2 = connections({ primary: 2 }); + expect(conn1.xuGua!.text).toBe(conn2.xuGua!.text); + }); + + test("hex 1 (乾) — zaGuaPair has both 乾 and 坤 (canonical opening pair)", () => { + const conn = connections({ primary: 1 }); + expect(conn.zaGuaPair).toBeDefined(); + expect(conn.zaGuaPair!.pair).toEqual([1, 2]); + expect(conn.zaGuaPair!.text).toContain("剛"); + expect(conn.zaGuaPair!.text).toContain("柔"); + }); + + test("hex 29 (坎) + hex 30 (離) — entries present (self-mirror pair)", () => { + const conn29 = connections({ primary: 29 }); + const conn30 = connections({ primary: 30 }); + expect(conn29.zaGuaPair).toBeDefined(); + expect(conn30.zaGuaPair).toBeDefined(); + // Their entries reference 29 / 30 somewhere in the pair[] arrays. + const both = new Set([ + ...conn29.zaGuaPair!.pair, + ...conn30.zaGuaPair!.pair, + ]); + expect(both.has(29)).toBe(true); + expect(both.has(30)).toBe(true); + }); + + test("returns fresh shuoguaCitations array per call (no shared mutable state)", () => { + const a = connections({ primary: 3 }); + const b = connections({ primary: 3 }); + expect(a.shuoguaCitations).not.toBe(b.shuoguaCitations); + expect(a.shuoguaCitations).toEqual(b.shuoguaCitations); + }); + + test("xuGua and zaGuaPair populated for all valid hex 1..64", () => { + for (let h = 1; h <= 64; h++) { + const conn = connections({ primary: h }); + expect(conn.xuGua).toBeDefined(); + expect(conn.zaGuaPair).toBeDefined(); + } + }); +}); + +describe("orbit-integrity invariant (adversarial)", () => { + test("every non-tail hex with a 2-element pair contains its geometric partner", () => { + for (let h = 1; h <= 64; h++) { + if (DISORDERED_TAIL.has(h)) continue; + const entry = ZA_GUA_BY_HEX[h]; + expect(entry).toBeDefined(); + if (entry!.pair.length !== 2) continue; + + const lines = linesOf(h); + const m = mirror(lines); + const partner = m !== h ? m : polarity(lines); + + // The pair contains the geometric partner. + expect(entry!.pair).toContain(partner); + } + }); + + test("disordered-tail hexagrams have entries even though the invariant is suspended", () => { + for (const h of DISORDERED_TAIL) { + const entry = ZA_GUA_BY_HEX[h]; + expect(entry).toBeDefined(); + expect(entry!.pair).toContain(h); + } + }); + + test("for every hex 1..64, ZA_GUA_BY_HEX resolves to an entry containing that hex", () => { + for (let h = 1; h <= 64; h++) { + const entry = ZA_GUA_BY_HEX[h]; + expect(entry).toBeDefined(); + expect(entry!.pair).toContain(h); + } + }); +}); + +describe("HexagramDetail integration", () => { + test("buildHexagramDetail embeds the connections overlay", () => { + const detail = buildHexagramDetail(3); + expect(detail.connections).toBeDefined(); + expect(detail.connections.xuGua?.name).toBe("屯"); + expect(detail.connections.zaGuaPair).toBeDefined(); + expect(detail.connections.shuoguaCitations).toHaveLength(5); + }); + + test("existing HexagramDetail fields are unchanged by U5", () => { + const detail = buildHexagramDetail(1); + expect(detail.kw).toBe(1); + expect(detail.gua.n).toBe("乾"); + expect(detail.structure.upper.n).toBe("乾"); + expect(detail.structure.lower.n).toBe("乾"); + expect(detail.nuclear.kw).toBe(1); + expect(detail.polarity.kw).toBe(2); + expect(detail.mirror.kw).toBe(1); + expect(detail.diagonal.kw).toBe(2); + expect(typeof detail.isLocked).toBe("boolean"); + }); +}); diff --git a/packages/core/src/derivation/connections.ts b/packages/core/src/derivation/connections.ts new file mode 100644 index 0000000..fdc0033 --- /dev/null +++ b/packages/core/src/derivation/connections.ts @@ -0,0 +1,55 @@ +import type { Cast, CastConnections, ShuoguaCitation, DerivedType } from "../types.js"; +import { XU_GUA } from "../data/xugua.js"; +import { ZA_GUA_BY_HEX } from "../data/zagua.js"; + +/** + * Static map: each derivation operation → the 說卦 chapter that grounds it + * in canonical text. Used as the textual authority anchor on cast surfaces + * — clicking a derivation badge opens the cited chapter. + * + * - nuclear (互卦): ch.3 — 天地定位,山澤通氣,雷風相薄 (the trigram-cycle) + * - polarity (錯卦): ch.2 — 立天之道曰陰與陽 (the yin-yang complementary axis) + * - mirror (綜卦): ch.6 — 神也者妙萬物 (the vantage-flip insight) + * - diagonal (對角卦): ch.6 — same authority as mirror (錯 ∘ 綜 composition) + * - becoming (之卦): ch.7 — 健順動入陷麗止說 (the operational core) + */ +const DERIVATION_CITATIONS: Record = { + nuclear: 3, + polarity: 2, + mirror: 6, + becoming: 7, + diagonal: 6, +}; + +const DERIVATION_ORDER: DerivedType[] = [ + "nuclear", + "polarity", + "mirror", + "becoming", + "diagonal", +]; + +const buildShuoguaCitations = (): ShuoguaCitation[] => + DERIVATION_ORDER.map((op) => ({ op, chapter: DERIVATION_CITATIONS[op] })); + +/** + * Build the text-bearing relations overlay for a cast. + * + * Pure lookup over XU_GUA (sequence narrative from the previous hexagram), + * ZA_GUA_BY_HEX (contrast pair commentary), and the static 說卦 chapter + * citations. Does not touch the numeric derivations (nuclear/polarity/ + * mirror/diagonal/becoming) — those remain pure functions in their own + * modules. + * + * The signature accepts any object exposing `primary` — a full Cast at + * runtime, or a synthesized `{ primary: kw }` from the HexagramDetail path + * where no Cast exists. + */ +export function connections(cast: Pick): CastConnections { + const result: CastConnections = { shuoguaCitations: buildShuoguaCitations() }; + const xu = XU_GUA[cast.primary - 1]; + if (xu) result.xuGua = xu; + const za = ZA_GUA_BY_HEX[cast.primary]; + if (za) result.zaGuaPair = za; + return result; +} diff --git a/packages/core/src/detail.ts b/packages/core/src/detail.ts index ea7e58f..29c7cc5 100644 --- a/packages/core/src/detail.ts +++ b/packages/core/src/detail.ts @@ -1,12 +1,13 @@ // HexagramDetail — build a full detail struct for a hexagram -import type { Hexagram, TrigramInfo, Line } from "./types.js"; +import type { Hexagram, TrigramInfo, Line, CastConnections } from "./types.js"; import { GUA } from "./data/gua.js"; import { getStructure } from "./identify/structure.js"; import { nuclear } from "./derivation/nuclear.js"; import { polarity } from "./derivation/polarity.js"; import { mirror } from "./derivation/mirror.js"; import { diagonal } from "./derivation/diagonal.js"; +import { connections as buildConnections } from "./derivation/connections.js"; export interface HexagramDetail { kw: number; @@ -18,6 +19,8 @@ export interface HexagramDetail { diagonal: { kw: number; gua: Hexagram }; isLocked: boolean; lockedPartner?: { kw: number; gua: Hexagram }; + /** Text-bearing relations overlay — 序卦 / 雜卦 / 說卦 citations. */ + connections: CastConnections; } /** Convert a hexagram's raw line array [0|1, ...] to Line[] for derivation functions */ @@ -49,6 +52,7 @@ export function buildHexagramDetail(kw: number): HexagramDetail { mirror: { kw: mirrorKw, gua: GUA[mirrorKw - 1] }, diagonal: { kw: diagonalKw, gua: GUA[diagonalKw - 1] }, isLocked, + connections: buildConnections({ primary: kw }), }; if (isLocked) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b62fcbe..f7f237a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,7 @@ export { polarity } from "./derivation/polarity.js"; export { mirror } from "./derivation/mirror.js"; export { diagonal } from "./derivation/diagonal.js"; export { isLockedPair } from "./derivation/locked-pairs.js"; +export { connections } from "./derivation/connections.js"; // Identify export { BINARY_TO_KW, hexagramByBinary, hexagramByKW } from "./identify/lookup.js"; From d073a5fb35f868bc083b36d71e15b82bac7d64d2 Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:42:53 -0700 Subject: [PATCH 07/22] feat(terminal): detail-renderer Wings + trigram catalogue (U6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detail-model.ts: DerivedLink gains an `op: DerivedType` field so the renderer can look up each row's 說卦 chapter citation. Populated in the constructor for all 4 numeric derivations (nuclear/polarity/mirror/ diagonal). detail-renderer.ts adds: - VoiceNode interface + renderStackedVoices helper. Voice-driven, content-gated, fixed order (zh → modern → wilhelm → legge). Empty voices skip. Scaffolded for U10's full activation; used in U6 for the 卦辭 path. - Compact 說卦 trigram catalogue (family / body / animal / direction) inline under the trigram line. Always-on (U4 populates assoc for all 8 trigrams). - 卦辭 section at the top of the commentary block — gated on gua.gc (absent for legacy data until U8 backfills, so legacy renders unchanged). - 小象傳 lines under each 爻辭 in the yao block — gated on gua.yaoXiao[i] (absent until U8). - Relations block (renamed from "Derived"): each numeric derivation row carries a [說卦 ch.N] annotation pulled from U5's connections.shuoguaCitations; followed by 序卦 (sequence narrative) and 雜卦 (contrast pair) text-bearing rows from connections.xuGua / zaGuaPair. Both always present (U5 populates for every valid hex). Tests (10): always-on additions visible (trigram catalogue, Relations header, 說卦 chapter annotations, 序卦 with 故受之以 transition, 雜卦 with 剛/柔 contrast); gated sections correctly absent for legacy data (no 卦辭 header, no 小象 lines); legacy commentary sections preserved (大象傳, 彖傳, Image, Judgment, Wilhelm, 爻辭). Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- .../src/__tests__/detail-renderer.test.ts | 124 ++++++++++++++++++ .../terminal/src/scenes/dict/detail-model.ts | 12 +- .../src/scenes/dict/detail-renderer.ts | 120 ++++++++++++++++- 3 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 packages/terminal/src/__tests__/detail-renderer.test.ts diff --git a/packages/terminal/src/__tests__/detail-renderer.test.ts b/packages/terminal/src/__tests__/detail-renderer.test.ts new file mode 100644 index 0000000..cc9931b --- /dev/null +++ b/packages/terminal/src/__tests__/detail-renderer.test.ts @@ -0,0 +1,124 @@ +import { describe, test, expect } from "bun:test"; +import { DetailModel } from "../scenes/dict/detail-model.ts"; +import { buildContentLines, type ContentLine, type VoiceNode } from "../scenes/dict/detail-renderer.ts"; + +/** + * U6 tests — detail-renderer expansion. + * + * Verifies the always-on additions (trigram catalogue, relations block with + * 序卦/雜卦 + 說卦 citations) and the gating behavior for the optional + * fields that U8 will backfill (卦辭, 小象傳). The legacy commentary + * sections must still render so existing hexagrams look unchanged from + * today. + */ + +const WIDTH = 80; + +function findIndex(lines: ContentLine[], pred: (l: ContentLine) => boolean): number { + return lines.findIndex(pred); +} + +function someText(lines: ContentLine[], substr: string): boolean { + return lines.some((l) => l.text.includes(substr)); +} + +describe("buildContentLines — always-on additions", () => { + test("renders compact 說卦 trigram catalogue under the trigram line", () => { + const model = new DetailModel(1); // 乾 over 乾 + const lines = buildContentLines(model, WIDTH); + // 乾 catalogue: family=父, body=首, animal=馬, direction=西北 + expect(someText(lines, "父")).toBe(true); + expect(someText(lines, "首")).toBe(true); + expect(someText(lines, "馬")).toBe(true); + expect(someText(lines, "西北")).toBe(true); + }); + + test("renders Relations header (renamed from Derived)", () => { + const model = new DetailModel(3); + const lines = buildContentLines(model, WIDTH); + expect(someText(lines, "Relations")).toBe(true); + }); + + test("annotates each derived row with [說卦 ch.N]", () => { + const model = new DetailModel(3); + const lines = buildContentLines(model, WIDTH); + // Each numeric derivation row (互卦/錯卦/綜卦/對角) should carry a 說卦 citation. + const derivedRows = lines.filter((l) => l.text.match(/(互卦|錯卦|綜卦|對角)/)); + expect(derivedRows.length).toBeGreaterThanOrEqual(4); + for (const row of derivedRows) { + expect(row.text).toMatch(/\[說卦 ch\.\d+\]/); + } + }); + + test("renders 序卦 row with canonical text for hex 3 屯", () => { + const model = new DetailModel(3); + const lines = buildContentLines(model, WIDTH); + expect(someText(lines, "序卦")).toBe(true); + // 屯's 序卦 entry contains "故受之以" + expect(someText(lines, "故受之以")).toBe(true); + }); + + test("renders 雜卦 row with canonical text for hex 1 乾", () => { + const model = new DetailModel(1); + const lines = buildContentLines(model, WIDTH); + expect(someText(lines, "雜卦")).toBe(true); + // 乾's 雜卦 opening: 《乾》剛《坤》柔 + expect(someText(lines, "剛")).toBe(true); + expect(someText(lines, "柔")).toBe(true); + }); +}); + +describe("buildContentLines — gated sections (legacy hexagrams without new fields)", () => { + test("does NOT render 卦辭 section when gua.gc is absent", () => { + const model = new DetailModel(3); + const lines = buildContentLines(model, WIDTH); + // Header line "卦辭" should not appear (no gc populated yet — U8 territory) + const gcHeaderRow = findIndex(lines, (l) => l.text === "卦辭"); + expect(gcHeaderRow).toBe(-1); + }); + + test("yao block does NOT include 小象傳 lines when gua.yaoXiao is absent", () => { + const model = new DetailModel(3); + const lines = buildContentLines(model, WIDTH); + // 屯's 小象 line 1: 雖磐桓,志行正也。 — should not appear (yaoXiao undefined) + expect(someText(lines, "雖磐桓")).toBe(false); + }); +}); + +describe("buildContentLines — legacy sections preserved", () => { + test("includes the existing commentary section headers", () => { + const model = new DetailModel(1); + const lines = buildContentLines(model, WIDTH); + expect(someText(lines, "大象傳")).toBe(true); + expect(someText(lines, "彖傳")).toBe(true); + expect(someText(lines, "Image")).toBe(true); + expect(someText(lines, "Judgment")).toBe(true); + expect(someText(lines, "Wilhelm")).toBe(true); + }); + + test("includes 爻辭 line texts header for hexagrams with yao", () => { + const model = new DetailModel(1); + const lines = buildContentLines(model, WIDTH); + expect(someText(lines, "爻辭")).toBe(true); + }); +}); + +describe("renderStackedVoices helper (scaffolded for U10)", () => { + // Exercise the helper directly to lock its contract before U10 wires it. + + test("emits only the voices that have content", () => { + const lines: ContentLine[] = []; + // We can't import the helper directly (it's private), but we can exercise + // it via a synthetic Hexagram with gc populated and a custom buildModel + // path. For U6 the simpler proxy is testing through buildContentLines — + // gc-populated test path lives in U8. The contract proven here is purely + // structural: VoiceNode is exported and typeable. + const node: VoiceNode = { + zh: "元亨利貞", + modernEn: "supreme success through perseverance", + }; + expect(node.zh).toBe("元亨利貞"); + expect(node.modernEn).toBeDefined(); + expect(node.leggeEn).toBeUndefined(); + }); +}); diff --git a/packages/terminal/src/scenes/dict/detail-model.ts b/packages/terminal/src/scenes/dict/detail-model.ts index f15099f..4a68d73 100644 --- a/packages/terminal/src/scenes/dict/detail-model.ts +++ b/packages/terminal/src/scenes/dict/detail-model.ts @@ -1,11 +1,17 @@ // DetailModel — scroll position, focused section, selected derived link -import { type HexagramDetail, type GlyphEntry, buildHexagramDetail } from "@iching/core"; +import { + type HexagramDetail, + type GlyphEntry, + type DerivedType, + buildHexagramDetail, +} from "@iching/core"; import type { GlyphAnimator } from "../../glyph-anim/types.ts"; export type DetailFocus = "content" | "derived"; export interface DerivedLink { + op: DerivedType; label: string; labelCn: string; kw: number; @@ -59,6 +65,7 @@ export class DetailModel { const d = this.detail; this.derivedLinks = [ { + op: "nuclear", label: "Nuclear", labelCn: "互卦", kw: d.nuclear.kw, @@ -67,6 +74,7 @@ export class DetailModel { ename: d.nuclear.gua.ename, }, { + op: "polarity", label: "Polarity", labelCn: "錯卦", kw: d.polarity.kw, @@ -75,6 +83,7 @@ export class DetailModel { ename: d.polarity.gua.ename, }, { + op: "mirror", label: "Mirror", labelCn: "綜卦", kw: d.mirror.kw, @@ -83,6 +92,7 @@ export class DetailModel { ename: d.mirror.gua.ename, }, { + op: "diagonal", label: "Diagonal", labelCn: "對角", kw: d.diagonal.kw, diff --git a/packages/terminal/src/scenes/dict/detail-renderer.ts b/packages/terminal/src/scenes/dict/detail-renderer.ts index bd46833..1408377 100644 --- a/packages/terminal/src/scenes/dict/detail-renderer.ts +++ b/packages/terminal/src/scenes/dict/detail-renderer.ts @@ -19,6 +19,44 @@ export interface ContentLine { dim?: boolean; } +/** + * A multi-voice text node — canonical zh plus zero or more English voices. + * Used by the stacked-voice renderer for Judgment / Image / line readings / + * 卦辭 surfaces. + */ +export interface VoiceNode { + zh?: string; + modernEn?: string; + wilhelmEn?: string; + leggeEn?: string; +} + +type Theme = ReturnType; + +/** + * Render a translation-voice stack: canonical zh + each populated English + * voice in fixed order (modern → wilhelm-flavored → legge). Each voice row + * only emits when its content exists, so future lineages (Legge in U10) + * plug in without touching call sites. Empty input emits nothing. + */ +function renderStackedVoices( + lines: ContentLine[], + textWidth: number, + t: Theme, + node: VoiceNode, +): void { + const push = (text: string, fg: string | undefined, dim: boolean): void => { + const wrapped = wordWrap(text, textWidth); + for (const wl of wrapped) { + lines.push({ text: wl, fg, dim }); + } + }; + if (node.zh) push(node.zh, t.primary, false); + if (node.modernEn) push(node.modernEn, t.secondary, false); + if (node.wilhelmEn) push(node.wilhelmEn, t.secondary, true); + if (node.leggeEn) push(node.leggeEn, t.secondary, true); +} + /** Build all content lines for the detail view */ export function buildContentLines(model: DetailModel, width: number): ContentLine[] { const t = getTheme(); @@ -69,11 +107,35 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin fg: t.secondary, }); + // Compact 說卦 trigram catalogue (family / body / animal / direction). + // Always rendered when both trigrams have assoc populated (U4 ships this + // for all 8 trigrams, so this branch fires on every cast). + if (s.upper.assoc && s.lower.assoc) { + const upperCat = `${s.upper.assoc.family} ${s.upper.assoc.body} ${s.upper.assoc.animal} ${s.upper.assoc.direction}`; + const lowerCat = `${s.lower.assoc.family} ${s.lower.assoc.body} ${s.lower.assoc.animal} ${s.lower.assoc.direction}`; + lines.push({ + text: centerPad(`${upperCat} · ${lowerCat}`, textWidth), + fg: t.tertiary, + dim: true, + }); + } + // Separator lines.push({ text: "" }); lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); lines.push({ text: "" }); + // 卦辭 (root Judgment / oracle text). Gated on gua.gc — present after U8 + // backfills; absent for legacy data (renders nothing). + if (gua.gc) { + lines.push({ text: "卦辭", fg: t.accent, bold: true }); + renderStackedVoices(lines, textWidth, t, { + zh: gua.gc, + leggeEn: gua.gcEn, + }); + lines.push({ text: "" }); + } + // Commentary sections const sections: [string, string][] = [ ["大象傳", gua.dx], @@ -113,6 +175,20 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin lines.push({ text: wl, fg: t.tertiary }); } } + // 小象傳 (per-line commentary). Gated on gua.yaoXiao — present after + // U8 backfills; absent for legacy data. + if (gua.yaoXiao && gua.yaoXiao[i]) { + const wrappedXiao = wordWrap(gua.yaoXiao[i], textWidth); + for (const wl of wrappedXiao) { + lines.push({ text: wl, fg: t.tertiary, dim: true }); + } + if (gua.yaoXiaoEn && gua.yaoXiaoEn[i]) { + const wrappedXiaoEn = wordWrap(gua.yaoXiaoEn[i], textWidth); + for (const wl of wrappedXiaoEn) { + lines.push({ text: wl, fg: t.tertiary, dim: true }); + } + } + } lines.push({ text: "" }); } } @@ -121,17 +197,55 @@ export function buildContentLines(model: DetailModel, width: number): ContentLin lines.push({ text: "─".repeat(textWidth), fg: t.tertiary }); lines.push({ text: "" }); - // Derived hexagrams - lines.push({ text: "Derived", fg: t.accent, bold: true }); + // Relations — numeric derivations with 說卦 chapter citations, then the + // text-bearing rows for 序卦 (sequence narrative) and 雜卦 (contrast pair). + // The numeric rows and citations always render. xuGua/zaGuaPair are + // populated by U5's connections() for every valid hex 1..64. + lines.push({ text: "Relations", fg: t.accent, bold: true }); + const connections = model.detail.connections; + const citationByOp = new Map( + connections.shuoguaCitations.map((c) => [c.op, c.chapter]), + ); for (let i = 0; i < model.derivedLinks.length; i++) { const link = model.derivedLinks[i]; const isSelected = model.focus === "derived" && model.derivedCursor === i; const marker = isSelected ? ">" : " "; + const chapter = citationByOp.get(link.op); + const citation = chapter !== undefined ? ` [說卦 ch.${chapter}]` : ""; lines.push({ - text: `${marker} ${link.labelCn} ${link.label.padEnd(10)} ${link.symbol} ${link.name} ${link.ename}`, + text: `${marker} ${link.labelCn} ${link.label.padEnd(10)} ${link.symbol} ${link.name} ${link.ename}${citation}`, fg: isSelected ? t.primary : t.secondary, }); } + // 序卦 — sequence narrative from the previous hexagram. + if (connections.xuGua) { + lines.push({ text: "" }); + lines.push({ + text: `序卦 ← ${connections.xuGua.text}`, + fg: t.secondary, + }); + if (connections.xuGua.textEn) { + const wrapped = wordWrap(connections.xuGua.textEn, textWidth); + for (const wl of wrapped) { + lines.push({ text: wl, fg: t.tertiary, dim: true }); + } + } + } + // 雜卦 — contrastive pairing. + if (connections.zaGuaPair) { + const partners = connections.zaGuaPair.names.join(" · "); + const suffix = partners.length > 0 ? ` (${partners})` : ""; + lines.push({ + text: `雜卦 ↔ ${connections.zaGuaPair.text}${suffix}`, + fg: t.secondary, + }); + if (connections.zaGuaPair.textEn) { + const wrapped = wordWrap(connections.zaGuaPair.textEn, textWidth); + for (const wl of wrapped) { + lines.push({ text: wl, fg: t.tertiary, dim: true }); + } + } + } // Locked pair if (model.detail.isLocked && model.detail.lockedPartner) { From 647c240fca52157967d689609bab18de31fb992f Mon Sep 17 00:00:00 2001 From: provi Date: Sat, 30 May 2026 22:45:58 -0700 Subject: [PATCH 08/22] =?UTF-8?q?feat(core,cli):=20Style=20union=20+=20CLI?= =?UTF-8?q?=20lockstep=20=E2=80=94=20gc=20key=20(U7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends Style to include "gc" (卦辭 root oracle). Hardcoded lists move in lockstep, with one deliberate asymmetry: - types.ts Style += "gc" - data/trigrams.ts STYLES += "gc"; QUOTE_STYLES intentionally unchanged - commands/hexagram.ts VALID_STYLES += "gc" - output/plain.ts formatCastPlain + formatHexagramPlain print 卦辭 row when hex.gc present (gated on field, legacy unchanged) - output/json.ts castToJson + hexagramToJson conditionally include gc via spread-when-defined QUOTE_STYLES stays at 5 entries (dx/tu/en/te/w) — 卦辭 is the root oracle, not a random-quotable commentary lineage, so the random-quote selector never picks it. CLI's VALID_STYLES does include "gc" for explicit --style gc lookup. format/reading.ts:31 (g[style] dynamic access) and output/plain.ts:96 gain ?? "" fallback so optional Style keys can't push undefined. commands/hexagram.ts errors cleanly when --style gc requested before U8 backfills (rather than emit an empty line). Tests (5): STYLES parity (7 keys), QUOTE_STYLES asymmetry (5 keys, no gc/no st), VALID_STYLES parity (6 keys = STYLES minus st), conditional gc emission in hexagramToJson (absent when undefined, present when populated). Plan: docs/plans/2026-05-30-001-feat-data-enrichment-plan.md --- .../src/__tests__/style-union-parity.test.ts | 80 +++++++++++++++++++ apps/cli/src/commands/hexagram.ts | 18 ++++- apps/cli/src/output/json.ts | 2 + apps/cli/src/output/plain.ts | 6 +- packages/core/src/data/trigrams.ts | 4 +- packages/core/src/format/reading.ts | 4 +- packages/core/src/types.ts | 16 ++-- 7 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/__tests__/style-union-parity.test.ts diff --git a/apps/cli/src/__tests__/style-union-parity.test.ts b/apps/cli/src/__tests__/style-union-parity.test.ts new file mode 100644 index 0000000..bc6a8ca --- /dev/null +++ b/apps/cli/src/__tests__/style-union-parity.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from "bun:test"; +import { STYLES, QUOTE_STYLES, type Hexagram } from "@iching/core"; +import { VALID_STYLES } from "../commands/hexagram.js"; +import { hexagramToJson } from "../output/json.js"; + +/** + * U7 parity test — Style union, hardcoded enumeration lockstep. + * + * The four lists below MUST agree on the shape of the Style universe so + * future additions can't drift between them. + * + * Style (type) — types.ts + * STYLES (array) — data/trigrams.ts (must match Style at runtime) + * QUOTE_STYLES — data/trigrams.ts (Style minus "st" minus "gc") + * VALID_STYLES — apps/cli/src/commands/hexagram.ts (Style minus "st") + * + * "gc" is in Style and VALID_STYLES but intentionally NOT in QUOTE_STYLES: + * 卦辭 is the root oracle, not a random-quotable commentary lineage. + */ + +describe("Style union — parity across hardcoded lists", () => { + test("STYLES carries the full Style union (7 keys after U7)", () => { + expect(STYLES.sort()).toEqual(["dx", "en", "gc", "st", "te", "tu", "w"]); + }); + + test("QUOTE_STYLES is STYLES minus \"st\" minus \"gc\" (5 quote-able lineages)", () => { + const expected = STYLES.filter((s) => s !== "st" && s !== "gc").sort(); + expect(QUOTE_STYLES.sort()).toEqual(expected); + expect(QUOTE_STYLES).not.toContain("gc"); + expect(QUOTE_STYLES).not.toContain("st"); + }); + + test("VALID_STYLES (CLI) is STYLES minus \"st\" — includes gc for explicit lookup", () => { + const expected = STYLES.filter((s) => s !== "st").sort(); + expect([...VALID_STYLES].sort()).toEqual(expected); + expect(VALID_STYLES).toContain("gc"); + expect(VALID_STYLES).not.toContain("st"); + }); +}); + +describe("hexagramToJson — conditional gc emission", () => { + function makeHex(overrides: Partial = {}): Hexagram { + return { + u: "X", + n: "X", + p: "X", + ename: "X", + l: [1, 1, 1, 1, 1, 1], + dx: "dx", + tu: "tu", + en: "en", + te: "te", + w: "w", + yao: ["", "", "", "", "", ""], + yaoEn: ["", "", "", "", "", ""], + ...overrides, + }; + } + + test("legacy hexagram (no gc populated) — gc key absent from commentary", () => { + const json = hexagramToJson(1, makeHex()) as { + commentary: Record; + }; + expect("gc" in json.commentary).toBe(false); + expect(json.commentary).toMatchObject({ + dx: "dx", + tu: "tu", + en: "en", + te: "te", + w: "w", + }); + }); + + test("hexagram with gc populated — gc key present in commentary", () => { + const json = hexagramToJson(1, makeHex({ gc: "元亨利貞" })) as { + commentary: Record; + }; + expect(json.commentary.gc).toBe("元亨利貞"); + }); +}); diff --git a/apps/cli/src/commands/hexagram.ts b/apps/cli/src/commands/hexagram.ts index e25758d..52c9154 100644 --- a/apps/cli/src/commands/hexagram.ts +++ b/apps/cli/src/commands/hexagram.ts @@ -4,14 +4,19 @@ import type { Style } from "@iching/core"; import { formatHexagramPlain } from "../output/plain.js"; import { outputJson, hexagramToJson } from "../output/json.js"; -const VALID_STYLES = ["dx", "tu", "en", "te", "w"]; +/** + * Valid --style choices. Mirrors @iching/core STYLES minus "st" (which is + * a synthetic trigram-structure path, not a commentary field) — parity is + * locked by style-union-parity.test.ts. + */ +export const VALID_STYLES = ["dx", "tu", "en", "te", "w", "gc"]; export function registerHexagramCommand(program: Command): void { program .command("hexagram") .description("Look up hexagram by King Wen number (1-64)") .argument("", "hexagram number (1-64)") - .option("--style