diff --git a/.specify/feature.json b/.specify/feature.json index 8d967cd..f4afef9 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/006-compile"} +{"feature_directory":"specs/009-relaxation"} diff --git a/CLAUDE.md b/CLAUDE.md index c0a15a8..b13a68f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,4 +32,4 @@ The oracle cases (vignette §9, `O-1…O-4`) are hand-computed and must never be ## Current phase (update this line when it changes) -Stage 0 complete; PR #8 merged to `main`. Register batch 3 ratified (ASSAY-DEC-22…27, closing concept §6.6–6.11) — walkthrough and findings ledger now canonical, G6 a standing invariant. Stage 1 built (issue #10): research note `01-knowledge.md` (DEC-16 floor); SPEC-05 implemented (`src/{seam,deltas,encoding,lint,knowledge}.ts` + `components/{refusalBanner,s1Table}.ts`); all four Stage-1 exits demonstrated on Meridian. Stage 2 built: research note `docs/research/02-compile.md` (DEC-11 gate — decides the **sparse channel representation** from the Stage-0 dense-world numbers + ATP 2-01.3 MCOO doctrine, retiring dense `Channel.cells`); SPEC-06 specified via spec-kit (`specs/006-compile/`) and **implemented** (`src/compile.ts` + `components/channelTrace.ts`; `fixtures/vignette-config.json`; sparse-channel schema regen; 107 tests, typecheck clean; compile demo moment renders in the gallery). All three Stage-2 exits demonstrated on Meridian (K12 contested ⇒ compile refuses `contested_knowledge`, resolve⇒recompile; byte-identical stamp; every channel traces back to named knowledge via `compiled_into` + `source`). The sparse-channel schema change is recorded as a flagged register candidate (concept §6, item 12), built under seam open item 2's delegated authority pending batch ratification. Stage 3 scorer built: research note `docs/research/03-score-plan.md` (DEC-11 gate — **decides the four-stop verdict mapping as signed margin bands, signs-only**: robust iff m_lo>0, marginal iff m_lo=0, tight iff m_lo<0≤m_hi, violated iff m_hi<0 — the unique O-3-satisfying rule with no interior cut; also settles `(cell,time)` route evaluation over a time-expanded graph, closing note 02 §3's forward flag, and reserves ε-dominance for SPEC-08 plan comparison); SPEC-07 specified via spec-kit (`specs/007-score/`) and **implemented** (`src/{interval,materialise,metrics,score}.ts` + `components/s2Matrix.ts`; `fixtures/plans.json` canned handful; no schema change — verdict/score/plan shapes already exist; 139 tests, typecheck clean; the honest-matrix demo moment renders in the gallery). The correctness leg holds on Meridian: oracle O-1 (`[9,13]`), O-2 (robust, margin `[15,19]`), O-3 (four-stop sweep changes only at band edges 9/13) reproduced exactly, and O-4 propagation-honesty (G6) under property-based testing (`fast-check`). SPEC-07 asserts no new register decision (the mapping is decided in the research note under the latitude vignette §9/O-3 delegated to Stage-3). Stage 3 handful built: SPEC-08 specified via spec-kit (`specs/008-handful/`) and **implemented** (`src/{dominance,generate,handful}.ts` + `components/handfulStrip.ts`; new seam types; no schema change) over the same `03-score-plan.md` note (§5 — the DEC-11 gate, deciding **banded ε-non-domination** and Meridian's **four generation axes**). `/plan/handful` = generate (seeded fan-out over approach × suppression × causeway × extraction) → score (reuses the SPEC-07 scorer) → organise (ε-non-domination on the conservative interval order, ε defaults to 0, diversity cap by axis Hamming — never a scalar, DEC-19). Both Stage-3 handful exits demonstrated on Meridian: `/plan/handful` returns **4 genuinely distinct plans** for the base world (each pair mutually non-dominated; the survivors split cleanly along the two trade axes — approach trades C1↔C2, suppression trades C3↔C4), and **same stamp + seed ⇒ identical handful** (G1); 163 tests, typecheck clean; the honest-matrix demo moment now renders the *generated* handful plus a derived `distinct_because` strip (organised, not authored). SPEC-08 asserts no new register decision (the concept §6.2 handful-axes open question is closed by the research note §5.2 under Stage-3 latitude). **Next: Stage 4 SPEC-09 — relaxation / least-worst (`/relax`, `sacrificed` populated, invariant G4), over research note `04-relaxation.md` (DEC-11 gate — to author); then the Stage 4–6 depth fan-out.** +Stage 0 complete; PR #8 merged to `main`. Register batch 3 ratified (ASSAY-DEC-22…27, closing concept §6.6–6.11) — walkthrough and findings ledger now canonical, G6 a standing invariant. Stage 1 built (issue #10): research note `01-knowledge.md` (DEC-16 floor); SPEC-05 implemented (`src/{seam,deltas,encoding,lint,knowledge}.ts` + `components/{refusalBanner,s1Table}.ts`); all four Stage-1 exits demonstrated on Meridian. Stage 2 built: research note `docs/research/02-compile.md` (DEC-11 gate — decides the **sparse channel representation** from the Stage-0 dense-world numbers + ATP 2-01.3 MCOO doctrine, retiring dense `Channel.cells`); SPEC-06 specified via spec-kit (`specs/006-compile/`) and **implemented** (`src/compile.ts` + `components/channelTrace.ts`; `fixtures/vignette-config.json`; sparse-channel schema regen; 107 tests, typecheck clean; compile demo moment renders in the gallery). All three Stage-2 exits demonstrated on Meridian (K12 contested ⇒ compile refuses `contested_knowledge`, resolve⇒recompile; byte-identical stamp; every channel traces back to named knowledge via `compiled_into` + `source`). The sparse-channel schema change is recorded as a flagged register candidate (concept §6, item 12), built under seam open item 2's delegated authority pending batch ratification. Stage 3 scorer built: research note `docs/research/03-score-plan.md` (DEC-11 gate — **decides the four-stop verdict mapping as signed margin bands, signs-only**: robust iff m_lo>0, marginal iff m_lo=0, tight iff m_lo<0≤m_hi, violated iff m_hi<0 — the unique O-3-satisfying rule with no interior cut; also settles `(cell,time)` route evaluation over a time-expanded graph, closing note 02 §3's forward flag, and reserves ε-dominance for SPEC-08 plan comparison); SPEC-07 specified via spec-kit (`specs/007-score/`) and **implemented** (`src/{interval,materialise,metrics,score}.ts` + `components/s2Matrix.ts`; `fixtures/plans.json` canned handful; no schema change — verdict/score/plan shapes already exist; 139 tests, typecheck clean; the honest-matrix demo moment renders in the gallery). The correctness leg holds on Meridian: oracle O-1 (`[9,13]`), O-2 (robust, margin `[15,19]`), O-3 (four-stop sweep changes only at band edges 9/13) reproduced exactly, and O-4 propagation-honesty (G6) under property-based testing (`fast-check`). SPEC-07 asserts no new register decision (the mapping is decided in the research note under the latitude vignette §9/O-3 delegated to Stage-3). Stage 3 handful built: SPEC-08 specified via spec-kit (`specs/008-handful/`) and **implemented** (`src/{dominance,generate,handful}.ts` + `components/handfulStrip.ts`; new seam types; no schema change) over the same `03-score-plan.md` note (§5 — the DEC-11 gate, deciding **banded ε-non-domination** and Meridian's **four generation axes**). `/plan/handful` = generate (seeded fan-out over approach × suppression × causeway × extraction) → score (reuses the SPEC-07 scorer) → organise (ε-non-domination on the conservative interval order, ε defaults to 0, diversity cap by axis Hamming — never a scalar, DEC-19). Both Stage-3 handful exits demonstrated on Meridian: `/plan/handful` returns **4 genuinely distinct plans** for the base world (each pair mutually non-dominated; the survivors split cleanly along the two trade axes — approach trades C1↔C2, suppression trades C3↔C4), and **same stamp + seed ⇒ identical handful** (G1); 163 tests, typecheck clean; the honest-matrix demo moment now renders the *generated* handful plus a derived `distinct_because` strip (organised, not authored). SPEC-08 asserts no new register decision (the concept §6.2 handful-axes open question is closed by the research note §5.2 under Stage-3 latitude). Stage 4 built (thesis B live): research note `docs/research/04-relaxation.md` (DEC-11 gate — **decides the formalism as preemptive lexicographic priority over the `must/should/prefer` tiers with NO weights**: weighted CSP / MAX-SAT / Archimedean goal programming rejected on honesty grounds by DEC-19, lexicographic goal programming chosen; returns the **inclusion-minimal correction-set frontier** not one optimum, ranked least-worst-first with the must-sacrifice kept-but-last; `sacrificed` = exactly the `violated` commitments; a **content-neutral, stated tie-break** for same-tier sacrifices because encoding a civil-harm-vs-force-protection ranking is the value judgement DEC-19 forbids; command-language narratives per MDMP/JP 5-0; the note also corrects course honestly — the SPEC-08 BASE generator routes clear of the banded regions and cannot enter the mined water, so SPEC-09 authors an R3m-responsive candidate set while the **sacrifices stay computed** by the reused scorer); SPEC-09 specified via spec-kit (`specs/009-relaxation/`) and **implemented** (`src/{tiers,relaxCandidates,relax}.ts` + `components/s3Cards.ts`; new seam types; no schema change — `RelaxationReport`/`RelaxationCandidate`/`Plan` already exist; 187 tests, typecheck clean; the "least-worst, never silence" demo moment renders in the gallery). The Stage-4 exit holds on Meridian: `/relax` over R3m returns **three inclusion-minimal least-worst candidates sacrificing C4, C3, C2** (each `sacrificed` non-empty, computed by the scorer — re-scoring each plan violates exactly its `sacrificed` set; the `{C2,C4}`/`{C3,C4}` supersets are inclusion-dropped), ranked least-worst-first (the C2 `must`-sacrifice last but present), the same-tier C3/C4 tie stated in `tie_break`, every card in command language and traced back via `cited_in`/`sacrificed_in` (G3), never empty and never a silent drop (G4). SPEC-09 asserts no new register decision (the formalism is decided *by* DEC-19; the report shape *by* knowledge model §5/§8 and seam §7/§G; latitude is Stage-4-delegated under vignette §6/§9). **Next: the Stage 4–6 depth fan-out — SPEC-10 (scenario robustness, thesis C), SPEC-11/12/13 (analysis: sensitivity / discrimination / staleness), SPEC-14 (gallery/site), all opening on the landed scorer + relax.** diff --git a/docs/assay-delivery-plan.md b/docs/assay-delivery-plan.md index a92a4e3..47b3993 100644 --- a/docs/assay-delivery-plan.md +++ b/docs/assay-delivery-plan.md @@ -63,7 +63,7 @@ Stage column maps to `assay-build-plan.md`. Research note is the DEC-11 prerequi | **SPEC-15** | Spine-complete gate harness — asserts content-addressing, stamp determinism, and invariants G2–G5 end-to-end on Meridian; re-asserts the vignette §9 oracle cases and the O-4 propagation-honesty property (candidate G6) | gate | SPEC-05…13 | — | | **SPEC-16** | Surfaces S1–S4 as config-declared bundles + five narrative scripts + banded-honesty polish pass | 7 | SPEC-14, SPEC-15, services | `07-narratives.md` | -> **Status (2026-07-13, spine through Stage 3):** SPEC-01…04 (Stage 0) and SPEC-05/06/07/08 (Stages 1–3) built and merged/branch-open. The spine `δ` (SPEC-05 → SPEC-06 → SPEC-07 → SPEC-08) is complete: the scorer stands and the honest generator organises a computed handful over it. SPEC-08's *sacrificial-scope* latitude was spent — the canned fallback (`fixtures/plans.json`) is retained for the SPEC-07 suite; the gallery's honest matrix now runs the generated handful. **Next: the depth fan-out `ε` (SPEC-09…13) opens on the landed scorer.** +> **Status (2026-07-13, spine through Stage 3 + first depth slice):** SPEC-01…04 (Stage 0), SPEC-05/06/07/08 (Stages 1–3), and SPEC-09 (Stage 4) built and merged/branch-open. The spine `δ` (SPEC-05 → SPEC-06 → SPEC-07 → SPEC-08) is complete: the scorer stands and the honest generator organises a computed handful over it. SPEC-08's *sacrificial-scope* latitude was spent — the canned fallback (`fixtures/plans.json`) is retained for the SPEC-07 suite; the gallery's honest matrix now runs the generated handful. The depth fan-out `ε` has opened: **SPEC-09 (relaxation / least-worst, thesis B) is built** over research note `04-relaxation.md` — `/relax` returns the inclusion-minimal least-worst frontier (three candidates sacrificing C4/C3/C2 on R3m), ranked by ordinal tier with a stated tie-break, `sacrificed` computed by the reused scorer, never empty and never a silent drop (G4); the "least-worst, never silence" demo moment renders in the gallery. **Next: the rest of the depth fan-out `ε` (SPEC-10 scenario robustness ∥ SPEC-11/12/13 analysis) opens on the landed scorer + relax.** ## 3. Dependency graph & critical path diff --git a/docs/assets/gallery/index.html b/docs/assets/gallery/index.html index 11455b8..b19527c 100644 --- a/docs/assets/gallery/index.html +++ b/docs/assets/gallery/index.html @@ -614,7 +614,56 @@

Score & organise — the honest matri robust -

Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md

+

Relax — least-worst, never silence (SPEC-09)

+

The Stage-4 demo moment, run end-to-end by the actual machinery. Against the R3m world (both approaches mined, the causeway dropped) no plan satisfies C2, C3 and C4 together — by construction (vignette §6). /relax does not pick one compromise and hide the trade: it returns every inclusion-minimal way forward, each naming what it gives up in command language. The sacrifices are computed — a commitment is sacrificed only when the SPEC-07 scorer returns violated for it — not authored. Ranked least-worst first by the commander's ordinal tiers (must / should / prefer, no numeric weight — DEC-19): the two should-sacrifices lead, the must-sacrifice (C2, "the strait opens two days late") is ranked last but present — never dropped, never silent (G4). Plans that give up more{C2,C4}, {C3,C4} — are inclusion-dominated and never shown as separate options. relax stamp 69fd05662fc5b616… · 3 least-worst candidates over R3m, computed not authored · never empty, never a silent drop (G4) · tie-break stated (DEC-19) · each candidate cited_in the report back to named knowledge (G3)

+
+ +

Tie-break — Same-tier sacrifices were ordered by commitment id (C3 vs C4) — a stated, commander-owned placeholder for within-tier priority, not a claim that one sacrifice outranks the other (ASSAY-DEC-19: no numeric weight; ties stated, never silent).

+
+

Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md · relax per docs/research/04-relaxation.md

diff --git a/docs/research/04-relaxation.md b/docs/research/04-relaxation.md new file mode 100644 index 0000000..d408cdc --- /dev/null +++ b/docs/research/04-relaxation.md @@ -0,0 +1,59 @@ +# Research note 04 — Relaxation, least-worst + +Stage 4 · per ASSAY-DEC-11 · 2026-07-13 · bounded to hours, one page preferred +Prompts (build plan §Stage 4): soft-constraint formalisms — weighted CSP, partial weighted MAX-SAT, goal programming, lexicographic methods; pick and justify one for v1 scale. Read how staff practice currently handles unsatisfiable guidance (commander's dialogue, assumptions log) so the relaxation *report language* matches how such news is actually delivered. Output feeds SPEC-09 (`/relax`, `sacrificed` populated, invariant G4). + +## 1. The register has already excluded three of the four families + +The soft-constraint literature offers four ways to handle an unsatisfiable constraint set. Three of them are ruled out before we read a line of it, by a decision already on the register: + +- **Weighted CSP** (Schiex et al., 1995) — each soft constraint carries a numeric cost; minimise total cost of violated constraints. +- **Partial weighted MAX-SAT** (Li & Manyà, 2009) — hard clauses must hold; each soft clause has a numeric weight; maximise satisfied-soft-weight. +- **Goal programming, Archimedean form** (Charnes & Cooper, 1961) — numeric deviational weights summed into one objective. + +All three attach a **numeric weight to each commitment** and collapse the trade-off into one scalar to minimise. **ASSAY-DEC-19 forbids exactly this**: "No numeric commitment weights anywhere… numeric sacrifice weights would be false precision exactly where SMEs will look hardest (the least-worst argument)." A weight of 7 on C3 and 5 on C4 is an invented ratio the commander never issued; it would let the machine *decide* that two civil-harm units equal three exposure units and hand back a single "optimal" sacrifice, hiding the trade it claims to have weighed. That is the false-precision machine ASSAY refuses (seam §G, G2). So weighted CSP, MAX-SAT, and Archimedean goal programming are out — not on scale grounds, on honesty grounds. This is ASSAY-FIND-2 re-derived at the build face: we already found (findings §3) that the least-worst *mechanism* — sacrifice reporting, banded space, human choice — does not need the weights, and REMIT's priority-weighted penalties are not load-bearing for it. Note 04 spends that finding. + +## 2. What survives: lexicographic priority, and the frontier instead of the optimum + +The fourth family is **preemptive (lexicographic) goal programming** (Ignizio, 1976): goals sit in ordinal **priority levels**; a lower-priority goal is never traded against a higher one, and — critically — there are **no weights across or within levels**. This is a term-for-term match to DEC-19's `must / should / prefer` tiers: the tiers *are* the priority levels, and "no numeric weight slot exists" (knowledge model §5) is precisely lexicography's refusal to commensurate levels. So the formalism is chosen by the register, not invented here. + +But classical lexicographic goal programming still returns **one optimum**, and within a level it silently falls back to weights or arbitrary order to break ties. Two adaptations, both forced by ASSAY's invariants, turn it into `/relax`: + +1. **Return the frontier, not the optimum (G4).** The seam contract's G4 is "least-worst, *never silence*": the report must *surface* the minimal trade-offs and let the commander weigh them (JP 5-0 keeps COA comparison a commander's judgement, not an arithmetic total — note 03 §5). So `/relax` does not pick the single lexicographically-best sacrifice; it enumerates the **inclusion-minimal correction sets** — the minimal sets of commitments whose sacrifice restores feasibility — and returns one candidate per set. This is the **minimal-correction-set (MCS)** view of infeasibility from the MUS/MaxSAT literature (Liffiton & Sakallah, 2008; Marques-Silva & Planes, 2011): an MCS is a minimal set whose removal makes the remainder satisfiable, and the MCSes are exactly "the minimal honest ways to give something up." Enumerating them *is* refusing to pre-decide. +2. **Ties are surfaced, never optimised away (DEC-19).** When two candidates sit at the same tier cost — here two `should`-sacrifices — lexicography has run out of ordinal information. The register's instruction is explicit: "within-tier ties in relaxation are broken explicitly and the tie-break is stated in the relaxation report — never silently." So a same-tier ordering is a **stated `tie_break`**, not a hidden numeric nudge (§4). + +At v1 scale — six commitments, a seeded fan-out of candidate plans — we do not need a SAT solver or a WCSP engine. We **generate → score → read sacrifices off the verdicts → keep the inclusion-minimal sets → rank lexicographically by tier** (§3). The literature names and justifies the structure; the implementation is a re-scoring loop over the SPEC-07 scorer, which is DEC-10 (the scorer is the linchpin; every depth stage is a loop over it) and keeps `/relax` from re-implementing any planning maths. + +## 3. The mechanism, decided + +`/relax {world, commitments, seed}` over an infeasible world (Meridian's R3m: both approaches mined, causeway dropped): + +1. **Generate** the candidate set — a small, seeded fan-out of plans **whose routes engage the infeasible world's contested regions**. This is where the honest reading bites: the SPEC-08 axis generator (`src/generate.ts`) deliberately routes *clear* of the banded regions so BASE reaches stay clean (note 03 §2, generator comments) — scored against R3m it barely moves, because the mined `halcyon_strait` / `voss_chain` threat and the dropped causeway never touch its cells. So a generator that cannot enter the mined water cannot surface the mining conflict. SPEC-09 therefore authors a **R3m-responsive candidate set** (`src/relaxCandidates.ts`) — a handful of plans that *do* route BROOM/ANVIL through the mined approaches and time the sweep against C2 — exactly the geometry the vignette §6 narrates (parallel vs sequential sweep; fires-forward vs stand-off). This is route-geometry latitude within the vignette's constraints (§8, as SPEC-07's `fixtures/plans.json` was authored), **not** an authored verdict: the geometry is the argument's staging, the *sacrifices are computed* (step 3). +2. **Score** each candidate against the excursioned world with the SPEC-07 scorer, REUSED (`ScoreService`) — DEC-10, no new planning maths, and it keeps `/relax` scenario-blind (the world is already R3m — the excursion is compile-time, note 02 §2). The scorer, not the candidate author, decides which commitments each plan gives up. +3. **Read the sacrifice set** off the verdicts. **A commitment is `sacrificed` by a plan iff its verdict is `violated`** — the strongest, unambiguous claim (the *best* realisation already fails; margin `m_hi < 0`, note 03 §3). `tight` and `marginal` are **not** sacrifices: they are risks the commander weighs, not commitments given up — calling a `tight` commitment "sacrificed" would be dishonestly pessimistic, the mirror of the false-precision error. So `sacrificed` is grounded in the four-stop scale the scorer already computes, not a second judgement. +4. **Keep the inclusion-minimal candidates.** Candidate A is discarded iff some candidate B sacrifices a **strict subset** of A's sacrifices (B gives up strictly less and is otherwise feasible) — that is a *silent-waste* sacrifice, forbidden. Candidates whose sacrifice sets are different singletons (or otherwise incomparable by inclusion) are **both kept** — neither wastes, and the trade between them is the commander's. This is set-inclusion domination, the coarse cousin of note 03 §5's banded ε-non-domination; here the criteria are **sacrifice sets with tier structure**, so no bands are compared and no ε exists. +5. **Rank lexicographically by tier**, least-worst first: order by (musts sacrificed, then shoulds, then prefers), ascending — a candidate that gives up only `should`s ranks above one that gives up a `must` ("sacrifices from the bottom tier up", knowledge model §5). The must-sacrifice is **still returned** (G4 — never silence); it is ranked last, not dropped. +6. **State the tie-break** (§4) for any same-tier ordering, and emit one `RelaxationCandidate {plan, sacrificed (non-empty), narrative}` per surviving set, plus the report-level `tie_break` prose. `cited_in` edges connect the verdicts each candidate rests on to the report (knowledge model §10), so every card opens a complete backward trace to named knowledge (G3). + +On Meridian R3m this yields the Stage-4 exit (vignette §6): the C2/C3/C4 conflict splits the frontier into three inclusion-minimal candidates, distinguished by which of the mutually-unsatisfiable trio they give up — **sacrifice C4** (parallel/fast sweep — ANVIL inside the mined strait, `threat_exposure` over the band-hour ceiling), **sacrifice C3** (fires forward — clears the berths but fires into the harbourfront district), **sacrifice C2** (sequential safe sweep — the strait opens at D+9, "two days late", `strait_open_step` past the ceiling) — while candidates that give up *more* (a plan both slow-and-exposed sacrifices {C2,C4}; one both forward-firing-and-exposed sacrifices {C3,C4}) are inclusion-dominated and dropped, never shown as separate options. Three candidates survive, each `sacrificed` non-empty, none an empty set, none a silent drop (G4). (C5, "causeway intact," is a `prefer` the vignette's R3m demolishes; in the compiled world its channel override coincides on the same region as the base engineering estimate and the innermost-wins tie keeps the base value, so C5 scores satisfied rather than `violated` — a compile-overlay precedence question that belongs to a compile-semantics slice, not to `/relax`; `/relax` reports the sacrifices the scorer computes and invents none.) + +## 4. The tie-break is content-neutral by construction — and that is the honest choice + +C3 (no fires into the populated district) and C4 (amphibious group not exposed) are **both `should`**: the commander set them at equal tier deliberately. When their two candidates tie on tier cost, the report must order them, and DEC-19 says the ordering must be *stated*. The temptation is to encode a doctrinal preference — surface civil-harm before force-protection, say. **That temptation is the exact error DEC-19 exists to prevent.** Encoding "civil harm outranks own-force exposure" (or the reverse) into the relaxation code is a value judgement the commander did not issue, smuggled in as if it were mechanism — a non-numeric cousin of the forbidden weight. So the tie-break is deliberately **content-neutral**: same-tier candidates are ordered by a stable, stated convention (commitment logical-id, ascending — the order the commander declared them in), and the report says so in plain language, explicitly flagging it as a *placeholder for commander-issued within-tier priority*, not a judgement that one `should` outranks the other. The tie-break's honesty is that it visibly declines to make the commander's call. (The vignette §6 prose lists the three as "C4, C3, C2" in narrative/physical order; that prose is illustrative, not a normative report order — the normative content is *which three, at which tiers, with the same-tier order stated*. Matching the prose order would require encoding the forbidden C4-before-C3 value judgement, so we do not.) + +## 5. Report language matches how the news is actually delivered + +The build plan asks that the report *language* match staff practice. In MDMP (FM 5-0 / FM 6-0) and joint planning (JP 5-0), unsatisfiable guidance is never silently dropped: it surfaces in the **mission-analysis brief** as a **request for commander's guidance / a recommended change to guidance**, or is logged as an **assumption** pending decision. Constraints are *restated in command terms* with their operational consequence, not as solver residuals. `/relax`'s cards mirror this exactly: each `narrative` is **command language naming the operational consequence** — "opens the strait D+9 — two days late," "fires into the harbourfront where the craft berth among the fishing fleet," "the amphibious group crosses the FAC arc unsuppressed" — never "C2 margin −8 steps" or "objective 2 deviation 0.31." The banded `margin` the scorer computes rides underneath for the trace drawer (G2: margins on demand, never a headline number); the card the commander reads is the argument, in their own idiom. This is thesis B: infeasibility becomes an **argument the commander weighs**, not an error the machine hides or a scalar it optimises. + +## What we will do differently + +1. **Adopt preemptive lexicographic priority over the `must/should/prefer` tiers, with NO weights** — the register (DEC-19) chose the formalism; weighted CSP / MAX-SAT / Archimedean goal programming are rejected on honesty grounds, not scale. +2. **Return the inclusion-minimal correction-set frontier, not a single optimum** (G4): enumerate the minimal honest ways to restore feasibility (the MCS view), rank them lexicographically by tier least-worst-first, and **return the must-sacrifice too** — ranked last, never dropped, never silent. +3. **Define `sacrificed` as exactly the `violated` commitments** — grounded in the four-stop scale the scorer already computes; `tight`/`marginal` are risks, not sacrifices. +4. **Author an R3m-responsive candidate set, reuse the SPEC-07 scorer** — `/relax` is a generate-and-re-score loop with no new planning maths (DEC-10). The candidate *geometry* is authored (the SPEC-08 BASE generator routes clear of the banded regions and cannot enter the mined water, so it cannot surface the mining conflict — route latitude, vignette §8), but the *sacrifices are computed* by the reused scorer, not authored; `/relax` stays scenario-blind over the compile-time R3m excursion. +5. **State a content-neutral tie-break** for same-tier sacrifices (commitment-id order), explicitly a placeholder for commander priority — because encoding a civil-harm-vs-force-protection ordering is the value judgement DEC-19 forbids, in non-numeric clothing. +6. **Write every card's `narrative` in command language** naming the operational consequence (MDMP / JP 5-0 mission-analysis idiom); the banded `margin` stays underneath for the trace drawer (G2). + +This note asserts **no new register decision**: the formalism is decided *by* DEC-19, the report shape *by* the knowledge model §5/§8 and seam §7/§G (G4), and the tie-break rule *by* DEC-19's own "state it explicitly" instruction. Any latitude spent (the singleton tie-break convention) is within Stage-4 delegated authority under the vignette §6 / §9 exit and is recorded here, not asserted in a peer document. + +Sources: T. Schiex, H. Fargier & G. Verfaillie, "Valued Constraint Satisfaction Problems," *IJCAI* 1995 — weighted/valued CSP; C. M. Li & F. Manyà, "MaxSAT," *Handbook of Satisfiability* (2009) — partial weighted MAX-SAT; A. Charnes & W. W. Cooper, *Management Models and Industrial Applications of Linear Programming* (1961) and J. P. Ignizio, *Goal Programming and Extensions* (1976) — Archimedean vs preemptive (lexicographic) goal programming; M. H. Liffiton & K. A. Sakallah, "Algorithms for Computing Minimal Unsatisfiable Subsets of Constraints," *J. Automated Reasoning* 40(1), 2008 — MUS/MCS duality and enumeration; J. Marques-Silva & J. Planes, on MaxSAT/MCS (2011); JP 5-0 *Joint Planning* (2020) and FM 5-0 / FM 6-0 (MDMP) — mission analysis, request for commander's guidance, assumptions log; ASSAY register DEC-10/14/19/20, knowledge model §5 (Commitment tiers)/§8 (RelaxationReport)/§10 (edges), seam contract §7 (Relax)/§G (G4/G2), vignette §6 (C1–C6, the R3m conflict)/§9 (Stage-4 exit), findings §3 (ASSAY-FIND-2), research notes 02-compile.md §2 and 03-score-plan.md §3/§5. diff --git a/scripts/build-gallery.ts b/scripts/build-gallery.ts index 7d88d67..2ba9c24 100644 --- a/scripts/build-gallery.ts +++ b/scripts/build-gallery.ts @@ -18,12 +18,14 @@ import { channelTrace } from '../src/components/channelTrace.js'; import { refusalBanner } from '../src/components/refusalBanner.js'; import { s2Matrix, type S2Cell } from '../src/components/s2Matrix.js'; import { handfulStrip, type HandfulStripRow } from '../src/components/handfulStrip.js'; +import { s3Cards, type S3Card } from '../src/components/s3Cards.js'; import { checkEncoding } from '../src/encoding.js'; import { confidenceLint } from '../src/lint.js'; import { KnowledgeService } from '../src/knowledge.js'; import { CompileService } from '../src/compile.js'; import { ScoreService } from '../src/score.js'; import { HandfulService } from '../src/handful.js'; +import { RelaxService } from '../src/relax.js'; import { isRefusal } from '../src/seam.js'; import type { Commitment, @@ -152,6 +154,38 @@ const scoreStampLine = handfulStamp ? `handful stamp ${handfulStamp.slice(0, 16)}… · ${matrixRows.length} genuinely distinct plans, organised not authored · same stamp + seed ⇒ identical handful (G1) · every verdict carries a scored_from edge to the world (G3)` : ''; +// SPEC-09 — the Stage-4 demo moment, "least-worst, never silence", run end-to-end +// by the actual machinery: compile the R3m world (both approaches mined), then +// `/relax` the commitment set against it. No plan satisfies C2–C4 together, so the +// service returns the three inclusion-minimal least-worst candidates — sacrificing +// C4, C3, C2 — each stated in command language, the must-sacrifice ranked last but +// present, the same-tier tie-break stated. No author picked the sacrifices: the +// reused SPEC-07 scorer computed each candidate's `violated` set. +const commitmentById = new Map(commitments.map((c) => [c.logical_id, c])); +const r3m = await compiler.compile({ + knowledge: [...BASE, 'K12a'].map(refFor), + config: vignetteConfig, + scenario: 'R3m', + engine_version: '0.1.0', +}); +const relaxSvc = new RelaxService({ store: svc.store, trace: svc.trace, scorer, commitments }); +let s3 = ''; +let relaxStampLine = ''; +if (!isRefusal(r3m)) { + const rr = await relaxSvc.relax({ world: r3m.world, commitments: commitments.map((c) => refFor(c.logical_id)), seed: 1, engine_version: '0.1.0' }); + if (!isRefusal(rr)) { + const cards: S3Card[] = rr.report.candidates.map((candidate) => ({ + candidate, + sacrificed: candidate.sacrificed.map((id) => { + const c = commitmentById.get(id)!; + return { logical_id: id, tier: c.tier, statement: c.statement }; + }), + })); + s3 = s3Cards(cards, rr.report.tie_break); + relaxStampLine = `relax stamp ${rr.stamp.slice(0, 16)}… · ${rr.report.candidates.length} least-worst candidates over R3m, computed not authored · never empty, never a silent drop (G4) · tie-break stated (DEC-19) · each candidate cited_in the report back to named knowledge (G3)`; + } +} + const html = ` @@ -179,7 +213,10 @@ ${rows}
${strip}

The same handful, as the S2 planner matrix. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never a headline number (G2). Each verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — validated by the vignette §9 oracle cases (O-1–O-3 exact, O-4 containment). Pick a verdict, walk it to the assessment and owner it rests on: every cell was written with a scored_from edge back to the world, whose channels trace to named knowledge above. ${scoreStampLine}

${s2}
-

Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md

+

Relax — least-worst, never silence (SPEC-09)

+

The Stage-4 demo moment, run end-to-end by the actual machinery. Against the R3m world (both approaches mined, the causeway dropped) no plan satisfies C2, C3 and C4 together — by construction (vignette §6). /relax does not pick one compromise and hide the trade: it returns every inclusion-minimal way forward, each naming what it gives up in command language. The sacrifices are computed — a commitment is sacrificed only when the SPEC-07 scorer returns violated for it — not authored. Ranked least-worst first by the commander's ordinal tiers (must / should / prefer, no numeric weight — DEC-19): the two should-sacrifices lead, the must-sacrifice (C2, "the strait opens two days late") is ranked last but present — never dropped, never silent (G4). Plans that give up more{C2,C4}, {C3,C4} — are inclusion-dominated and never shown as separate options. ${relaxStampLine}

+
${s3}
+

Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md · relax per docs/research/04-relaxation.md

diff --git a/specs/009-relaxation/checklists/requirements.md b/specs/009-relaxation/checklists/requirements.md new file mode 100644 index 0000000..cdd9835 --- /dev/null +++ b/specs/009-relaxation/checklists/requirements.md @@ -0,0 +1,45 @@ +# Requirements checklist — SPEC-09 Relaxation + +Traceability of the spec's requirements to the invariants and the research note. Checked as the slice lands. + +## G4 — least-worst, never silence (the invariant this slice upholds) + +- [x] Report is **never empty** on an infeasible set (FR-005, SC-002) +- [x] Every candidate's `sacrificed` is **non-empty** (FR-009, SC-001) +- [x] No constraint dropped without a card naming it (FR-005) — a strict-superset sacrifice is excluded *because a lesser one is shown* (FR-004, SC-002) +- [x] Same-tier ordering is **stated** in `tie_break`, never silent (FR-007, SC-003) +- [x] A `must`-sacrifice is **returned, ranked last**, never suppressed (FR-006, SC-003) + +## DEC-19 — ordinal tiers, no weights, stated tie-break + +- [x] Ranking uses tier lexicography only — **no numeric weight / scalar total / midpoint** (FR-008) +- [x] Tie-break is **content-neutral** (id order), flagged as a commander-priority placeholder, not a value ranking (FR-007, note §4) + +## G2 — banded honesty at the seam + +- [x] Sacrifice set read from the four-stop `verdict`, not a scalar (FR-003) +- [x] Card face carries **no decimal / no verdict-internal token**; `margin` stays for the trace drawer (FR-009, SC-007) + +## G3 — complete trace chains + +- [x] `cited_in` edges written from each candidate's verdicts/scores to the report (FR-010, SC-005) +- [x] `sacrificed_in` edges written from each sacrificed commitment to its candidate (FR-010) + +## G1 — determinism + +- [x] Same `(world, commitments, seed, engine)` ⇒ byte-identical stamp and identical ordered report (FR-011, SC-006) +- [x] Stamp is over inputs, never over materialised cells (FR-011) + +## DEC-10 / DEC-11 — reuse & research-first + +- [x] Scorer reused, not re-implemented (FR-003); sacrifices computed, not authored (SC-004) +- [x] `docs/research/04-relaxation.md` present before implementation (gate) + +## Refusals + +- [x] `unknown_ref` on an unresolvable world, persists nothing (FR-012) +- [x] A scorer refusal on a candidate returned verbatim (FR-012) + +## Gate + +- [x] `npm run typecheck` clean; `npm test` green (SPEC-01…08 baseline + SPEC-09); no `npm run gen` (SC-008) diff --git a/specs/009-relaxation/contracts/relax-service.md b/specs/009-relaxation/contracts/relax-service.md new file mode 100644 index 0000000..8e25af0 --- /dev/null +++ b/specs/009-relaxation/contracts/relax-service.md @@ -0,0 +1,43 @@ +# Contract — Relax service (`POST /relax`, seam §7) + +Called when a commitment set is unsatisfiable against a world (or to ask "what would we give up if it were?"). Returns a `RelaxationReport` whose candidates each name their `sacrificed` commitments — never an empty set, never a silent constraint drop, tie-breaks stated (G4). All declines are first-class `Refusal`s (seam §1). + +## Request + +``` +POST /relax +{ + world: Ref, // a CompiledWorld carrying its excursion (e.g. the R3m world) + commitments: Ref[], // the commander's set (C1–C6); tiers drive the ranking + seed: number, // orders candidate enumeration and same-tier ties + engine_version: string +} +``` + +Scenario is derived from the world (`world.scenario ?? BASE`) — the world already carries any excursion (DEC-10); `/relax` is scenario-blind like the scorer. + +## Response + +``` +→ { report: RelaxationReport, stamp, feasible?: { plan: Ref } } // success +| Refusal { refused: true, reason, offending: Ref[], explanation } // decline +``` + +## Behavioural contract + +1. **Generate** — a seeded, R3m-responsive candidate set (`src/relaxCandidates.ts`): plans whose routes engage the mined approaches / the timing that trades C2, each a valid DEC-20 plan with routes for the five force elements. Deterministic in `(seed)`. +2. **Score** — each candidate through the SPEC-07 `ScoreService` (reused). The **sacrifice set** of a candidate is exactly the commitments whose verdict is `violated`; `tight`/`marginal` are risks, not sacrifices. +3. **Feasibility** — if a candidate sacrifices nothing (empty set), report it as `feasible` (first-class) rather than a `RelaxationCandidate` with empty `sacrificed` (G4 requires non-empty). The commitment set was satisfiable. +4. **Minimality** — keep the inclusion-minimal sacrifice sets: drop a candidate whose sacrifice set is a strict superset of another's; keep incomparable sets (different singletons) both; collapse duplicate sets to one representative. No scalar total, weighted sum, or midpoint decides membership (DEC-19, DEC-15). +5. **Rank** — least-worst first by lexicographic tier cost `(musts, shoulds, prefers)` ascending. A must-sacrifice is still returned, ranked last (G4 — never dropped). Same-tier ties ordered by commitment-id, stated in `tie_break`. +6. **Emit** — one `RelaxationCandidate {plan, sacrificed (non-empty), narrative}` per surviving set; `narrative` in command language naming the operational consequence (no decimal, no verdict-internal token — G2). +7. **Trace** — write `cited_in` edges from each candidate's verdicts/scores to the `RelaxationReport`, and `sacrificed_in` from each sacrificed `Commitment` to its candidate, so every card opens a complete backward chain to named knowledge (G3, knowledge model §10). +8. **Determinism** — `stamp = hash(world.stamp, sorted commitment ids, seed, engine_version)`; same inputs ⇒ byte-identical stamp and identical ordered report (G1). Never hashes materialised cells. +9. **Scope** — writes no delta (relax is a read-of-world, like scoring), and does not perform the commander's `/select` act (seam §11 — a later slice). + +## Refusals (all first-class, persist nothing) + +| reason | when | +|---|---| +| `unknown_ref` | `world` names no live object | +| _(scorer refusal)_ | if scoring a candidate refuses (e.g. `stamp_mismatch`), that refusal is returned verbatim | diff --git a/specs/009-relaxation/data-model.md b/specs/009-relaxation/data-model.md new file mode 100644 index 0000000..e27bc02 --- /dev/null +++ b/specs/009-relaxation/data-model.md @@ -0,0 +1,36 @@ +# Phase 1 — Data Model (SPEC-09) + +Domain entities are generated from LinkML (`src/generated/types.ts`, slice D2) and must not be hand-edited. **SPEC-09 changes no schema** — every stored shape it needs already exists (D2). This file maps the spec's entities to the existing LinkML shapes and defines the relaxation service's movement types. + +## Existing LinkML types reused (source of truth — do not hand-edit) + +| Entity | Shape | Notes | +|---|---|---| +| `RelaxationReport` | `{logical_id, version, world_stamp, scenario, candidates[], tie_break?}` (extends StoredObject) | returned as `report`; stored & content-addressed | +| `RelaxationCandidate` | `{plan, sacrificed: LogicalId[] (non-empty), narrative}` | one per surviving inclusion-minimal sacrifice set | +| `Commitment` | `{..., tier: must\|should\|prefer, ...}` (DEC-19) | tiers drive the lexicographic ranking; **no weight slot** | +| `Plan` / `ElementPlan` / `RouteLeg` | timed routes for the five force elements (DEC-20) | the authored candidate geometry; stored & content-addressed | +| `CommitmentVerdict` | `{plan, commitment, scenario, world_stamp, verdict, margin?, engine_version}` | `verdict` (`violated`) is read for the sacrifice set; `margin` for the trace drawer only | +| `TraceEdge` (`cited_in`, `sacrificed_in`) | written by the relax service | candidates trace back to knowledge (G3) | + +No enum or class edit is required; `npm run gen` is **not** run in this slice. + +## Service types added (`src/seam.ts` — movement, not stored objects) + +| Type | Shape | Notes | +|---|---|---| +| `RelaxRequest` | `{world: Ref, commitments: Ref[], seed: number, engine_version: string}` | seam §7 request shape; scenario derived from the world (DEC-10) | +| `RelaxSuccess` | `{report: RelaxationReport, stamp: string, feasible?: {plan: Ref}}` | `feasible` set only when a candidate needs no sacrifice (FR-013 — never a candidate with empty `sacrificed`) | +| `RelaxResult` | `RelaxSuccess \| Refusal` | `unknown_ref` is the only relax-specific refusal reason (already in `RefusalReason`); scorer refusals pass through | + +## Internal shapes (not exported at the seam) + +| Type | Shape | Notes | +|---|---|---| +| `SacrificeSet` | `string[]` (sorted commitment ids) | `src/tiers.ts`: a candidate's `violated` commitments | +| `TierCost` | `[musts, shoulds, prefers]` counts | `src/tiers.ts`: the lexicographic key (ascending = least-worst first) | +| `RelaxCandidate` (internal) | `{plan, planRef, sacrificed, narrative, verdicts}` | `src/relax.ts`: a scored candidate before organisation | + +## Determinism + +`stamp = SHA-256(canonicalJson({world: world.stamp, commitments: sorted ids, seed, engine_version}))` — over inputs, never over materialised cells (seam §1). The candidate set is a pure function of `(seed)`; scoring is the deterministic SPEC-07 scorer; inclusion-minimality and tier ranking are pure functions of the scored sacrifice sets, with the content-neutral id tie-break making the order total. Same inputs ⇒ byte-identical stamp and identical ordered report (G1). diff --git a/specs/009-relaxation/plan.md b/specs/009-relaxation/plan.md new file mode 100644 index 0000000..f441886 --- /dev/null +++ b/specs/009-relaxation/plan.md @@ -0,0 +1,59 @@ +# Implementation Plan: Relaxation — least-worst under infeasibility (SPEC-09) + +**Branch**: `claude/next-spec-12qusu` · **Spec dir**: `specs/009-relaxation/` · **Date**: 2026-07-13 +**Spec**: `specs/009-relaxation/spec.md` · **Research note (DEC-11 gate)**: `docs/research/04-relaxation.md` (present) + +## Summary + +Build the least-worst unit (delivery plan §ε, Stage 4): `POST /relax {world, commitments, seed} → {report, stamp}`. On an infeasible world (Meridian R3m), a small **R3m-responsive candidate set** (authored geometry that routes into the mined water) is **scored** by the SPEC-07 scorer (reused, not re-implemented); each candidate's **sacrifice set** is read off its `violated` verdicts; the **inclusion-minimal** correction sets survive; they are **ranked lexicographically by commitment tier** (least-worst first) with same-tier orderings stated in `tie_break`; and one `RelaxationCandidate` is emitted per surviving set, each `sacrificed` non-empty (G4), each `narrative` in command language, each traced to knowledge via `cited_in`/`sacrificed_in`. No schema change — `RelaxationReport`, `RelaxationCandidate`, `Plan`, `TraceEdge` all exist (D2). The demo moment ("least-worst, never silence") renders the S3 cards from the actual `/relax` over R3m. + +## Technical Context + +**Language**: TypeScript (ESM, `type: module`), Node ≥ 22, in-browser-capable (no Node-only APIs in `src/`). +**Testing**: vitest (existing). No new dependency — the candidate set is a fixed authored fan-out; the seed orders enumeration/ties (no PRNG library). +**Reuse (do not re-implement)**: `src/{store,trace,canonical,validate,seam}.ts`, `src/score.ts` (`ScoreService` — the scorer is CALLED, never re-built), `src/generated/types.ts`, `src/metrics.ts`/`src/materialise.ts` (via the scorer). The four-stop `verdict` the sacrifice set reads comes straight off the scorer's `CommitmentVerdict`. +**New source**: `src/relaxCandidates.ts` (authored R3m-responsive candidate `Plan`s + per-candidate command-language narrative), `src/tiers.ts` (tier-lexicographic order + inclusion-minimality over sacrifice sets — pure, testable in isolation), `src/relax.ts` (the `RelaxService`), `src/components/s3Cards.ts` (the least-worst cards). New seam types in `src/seam.ts`. No new fixtures file — candidates are code (documented route geometry, as `src/generate.ts` is), the commitments/coas fixtures already exist. +**Constraints**: G4 (never empty, never a silent drop, tie-break stated); banded honesty at the seam (G2 — no decimal/margin on the card face); no numeric weight / scalar total / midpoint anywhere in ranking (DEC-19, DEC-15); determinism over inputs, never over cells (G1); first-class refusals (seam §1); frozen identifiers — candidate plans stay in the `P*`/`RX-*` family, clearly marked relaxation scaffolding (vignette §8). + +## Constitution Check + +- **Register-first (DEC-2)**: SPEC-09 introduces **no new decision**. The formalism (lexicographic priority, no weights) is decided *by* DEC-19; the report shape *by* knowledge model §5/§8 and seam §7/§G (G4); the tie-break rule *by* DEC-19's own "state it explicitly" instruction. The research note asserts no register candidate; any latitude (the singleton tie-break convention; the authored candidate geometry) is within Stage-4 delegated authority under the vignette §6/§9 exit and is recorded in the note, not asserted in a peer document. +- **Research-first (DEC-11)**: `docs/research/04-relaxation.md` present before implementation — gate satisfied. The note also corrects course honestly: the SPEC-08 BASE generator routes clear of the banded regions and cannot surface the mining conflict, so SPEC-09 authors an R3m-responsive candidate set (route latitude, §8) while the *sacrifices remain computed* by the reused scorer. +- **Banded honesty (G2)**: the sacrifice set is read from the four-stop `verdict`, not from a scalar; the card face names commitments and consequences, never a decimal; the banded `margin` stays for the trace drawer only. +- **Least-worst, never silence (G4)**: the report is never empty and never drops a constraint without a card naming it; tie-breaks are stated. This is the invariant the slice exists to uphold. +- **Frozen identifiers**: C1–C6, R3m, FE-* rendered as-is; candidate plans use `RX-*` ids encoding their posture, clearly marked demonstrator scaffolding. +- **Batch propagation**: the current-phase line (CLAUDE.md), the build/delivery peers, and the gallery sweep in the same change. + +## Project Structure (this slice) + +``` +docs/research/04-relaxation.md # DEC-11 gate (done) +specs/009-relaxation/{spec,plan,tasks,data-model,research,quickstart}.md +specs/009-relaxation/contracts/relax-service.md +specs/009-relaxation/checklists/requirements.md +src/tiers.ts # tierCost; lexicographic compare; inclusionMinimal over sacrifice sets +src/relaxCandidates.ts # authored R3m-responsive candidate Plans + command-language narratives +src/relax.ts # RelaxService: /relax (generate -> score -> sacrifice -> minimal -> rank) +src/components/s3Cards.ts +tests/{tiers,relax}.test.ts +``` + +## Design decisions (from the research note) + +1. **Formalism = preemptive lexicographic priority over ordinal tiers, NO weights** (note §1–2). Weighted CSP / MAX-SAT / Archimedean goal programming rejected on honesty grounds (DEC-19). The tiers ARE the priority levels. +2. **Return the inclusion-minimal correction-set frontier, not one optimum** (note §2; G4). A candidate is dropped iff another's sacrifice set is a strict subset; different singletons both survive; duplicate sets collapse. The must-sacrifice is ranked last, never dropped. +3. **`sacrificed` = exactly the `violated` commitments** (note §3). Grounded in the four-stop scale the scorer computes; `tight`/`marginal` are risks, not sacrifices. +4. **Reuse the SPEC-07 scorer; author the R3m-responsive candidates** (note §3–4). The scorer decides the sacrifices; the candidate geometry (routes into the mined water) is authored latitude (vignette §8), as SPEC-07's `plans.json` was. +5. **Content-neutral, stated tie-break** (note §4). Same-tier candidates ordered by commitment-id, stated in `tie_break` as a placeholder for commander priority — never a coded value ranking (the C3-vs-C4 civil-harm/force-protection judgement is the commander's). +6. **Command-language narratives** (note §5). Each card names the operational consequence (MDMP / JP 5-0 idiom); the banded `margin` stays underneath for the trace drawer (G2). + +## Complexity Tracking + +Scope is bounded to exactly the contract §7 movement: generate (fixed authored candidate set), score (reuse SPEC-07), read sacrifices, keep inclusion-minimal, rank lexicographically, emit report + `cited_in` edges. No SAT/WCSP solver (v1 scale — note §2), no numeric weights, no `/select` (seam §11 — the commander's act is a later slice), no scenario strip (SPEC-10). The only new surface is `s3Cards`. + +## Phase Gates + +- Research complete (DEC-11): ✅ `04-relaxation.md`. +- Spec approved: this slice. +- Tests-first: the tier-lexicographic order and inclusion-minimality laws (constructed sacrifice sets) and the R3m determinism assertions are written and confirmed failing before `src/relax.ts` behaviour (constitution quality gate 2). +- Green: typecheck + full suite; the R3m relax returns three candidates (C4/C3/C2), computed not hand-set; no schema regen in this slice. diff --git a/specs/009-relaxation/quickstart.md b/specs/009-relaxation/quickstart.md new file mode 100644 index 0000000..739f6d9 --- /dev/null +++ b/specs/009-relaxation/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart — SPEC-09 Relaxation (`/relax`) + +## What it does + +`/relax` turns an infeasible commitment set into an **argument**, not an error. Against the Meridian R3m world (both approaches mined, causeway dropped) no plan satisfies C2, C3 and C4 together. `/relax` returns the three least-worst ways forward — each naming what it gives up, in command language. + +## Run it (in a test / script) + +```ts +import { CompileService } from './src/compile.js'; +import { ScoreService } from './src/score.js'; +import { RelaxService } from './src/relax.js'; +import { isRelaxRefusal /* isRefusal */ } from './src/seam.js'; + +// 1. Compile the R3m world (SPEC-06): base knowledge under the R3m excursion. +const compiled = await compiler.compile({ knowledge: baseRefs, config, scenario: 'R3m', engine_version: '0.1.0' }); + +// 2. Relax the commitment set against it. +const scorer = new ScoreService({ store, trace, config, commitments }); +const relax = new RelaxService({ store, trace, scorer, commitments }); +const res = await relax.relax({ + world: compiled.world, + commitments: ['C1','C2','C3','C4','C5','C6'].map((id) => ({ logical_id: id, content_hash: '' })), + seed: 1, + engine_version: '0.1.0', +}); +if (isRefusal(res)) throw new Error(res.reason); + +// 3. Read the least-worst candidates. +for (const c of res.report.candidates) { + console.log(c.sacrificed.join(','), '—', c.narrative); +} +console.log('tie-break:', res.report.tie_break); +``` + +## Expect (Stage-4 exit) + +``` +C3 — fires forward: clears the FAC berths but fires into the populated harbourfront district +C4 — parallel sweep: the amphibious group crosses the mined strait unsuppressed +C2 — sequential sweep: the strait opens at D+9 — two days late +tie-break: C3 and C4 are both `should`; ordered by commitment id (a stated, commander-owned placeholder — not a claim that one outranks the other) +``` + +Three candidates, each `sacrificed` non-empty; the two `should`-sacrifices (C3, C4) rank above the `must`-sacrifice (C2); nothing is dropped silently (G4). Plans that give up *more* — `{C2,C4}`, `{C3,C4}` — are inclusion-dominated and never shown as separate options. + +## Gallery + +`npm run gallery` renders the S3 least-worst cards from this exact call. `npm run typecheck && npm test` gate the slice; no schema regen. diff --git a/specs/009-relaxation/research.md b/specs/009-relaxation/research.md new file mode 100644 index 0000000..cc77263 --- /dev/null +++ b/specs/009-relaxation/research.md @@ -0,0 +1,16 @@ +# Phase 0 — Research (SPEC-09) + +The DEC-11 research gate for this slice is **`docs/research/04-relaxation.md`** (Stage 4). It is the authoritative note; this file is a pointer and a decision digest so the spec-kit bundle is self-contained. + +## Decisions carried into the plan (all from `04-relaxation.md`) + +1. **Formalism — preemptive lexicographic priority over the `must/should/prefer` tiers, no weights** (note §1–2). The register (DEC-19) chose it: weighted CSP (Schiex et al., 1995), partial weighted MAX-SAT (Li & Manyà, 2009), and Archimedean goal programming (Charnes & Cooper, 1961) all attach numeric weights and are rejected on honesty grounds, not scale. Lexicographic (preemptive) goal programming (Ignizio, 1976) maps term-for-term to the ordinal tiers. +2. **Frontier, not optimum** (note §2; G4). Return the inclusion-minimal correction sets — the minimal-correction-set view of infeasibility (Liffiton & Sakallah, 2008; Marques-Silva & Planes, 2011). At v1 scale this is generate-and-re-score, not a SAT solver. +3. **`sacrificed` = the `violated` commitments** (note §3), read off the four-stop scale the SPEC-07 scorer already computes; `tight`/`marginal` are risks, not sacrifices. +4. **Reuse the scorer; author the R3m-responsive candidates** (note §3–4). The SPEC-08 BASE generator routes clear of the banded regions and cannot enter the mined water, so it cannot surface the mining conflict; SPEC-09 authors candidate geometry that does (vignette §8 latitude), and the scorer — not the author — computes the sacrifices (DEC-10). +5. **Content-neutral, stated tie-break** (note §4). Same-tier orderings by commitment-id, stated in `tie_break` as a placeholder for commander priority — because encoding a civil-harm-vs-force-protection ranking is the value judgement DEC-19 forbids. +6. **Command-language narratives** (note §5) matching MDMP / JP 5-0 mission-analysis idiom; the banded `margin` stays for the trace drawer (G2). + +## Register position + +No new register decision (note "What we will do differently", final paragraph). The formalism, report shape, and tie-break rule are all *consequences* of existing decisions (DEC-10/14/19/20) and invariants (G2/G4). The concept §6 open items are unaffected (the sparse-channel candidate, item 12, remains the only open one). diff --git a/specs/009-relaxation/spec.md b/specs/009-relaxation/spec.md new file mode 100644 index 0000000..7e1bb3e --- /dev/null +++ b/specs/009-relaxation/spec.md @@ -0,0 +1,151 @@ +# Feature Specification: Relaxation — least-worst under an infeasible commitment set (SPEC-09) + +**Feature Branch**: `claude/next-spec-12qusu` (spec dir `009-relaxation`) + +**Created**: 2026-07-13 + +**Status**: Draft + +**Stage**: Build-plan Stage 4 (least-worst, thesis B live) · **Depends on**: SPEC-07 (scorer — `ScoreService`, `CommitmentVerdict.verdict/margin`, deterministic stamp), SPEC-06 (compile — CompiledWorld with a COA excursion, the R3m world), D3 (seam contract §7) · **Research note**: `docs/research/04-relaxation.md` (DEC-11 gate — present; decides the formalism, the mechanism, `sacrificed` grounding, and the tie-break) + +**Input**: `/relax` (`{world, commitments, seed} → {report: RelaxationReport, stamp}`). Called when a commitment set is unsatisfiable against a world (Meridian's R3m: both approaches mined, causeway dropped). It **generates** candidate plans that engage the mined water, **scores** each via the SPEC-07 scorer, reads each plan's **sacrifice set** off the `violated` verdicts, keeps the **inclusion-minimal** correction sets, ranks them lexicographically by commitment tier (least-worst first), and returns one `RelaxationCandidate` per surviving set — each `sacrificed` non-empty, each `narrative` in command language, same-tier orderings stated in `tie_break`. It never returns an empty candidate list and never silently drops a constraint (invariant G4). + +## Honesty stance *(the point of this slice)* + +Infeasibility is the moment a planner is most tempted to lie — to quietly drop the constraint that will not fit, or to hand back one "optimal" compromise as if the machine had weighed the trade the commander is paid to weigh. SPEC-09 refuses both by construction. + +1. **Never silence (G4).** An unsatisfiable set returns a `RelaxationReport` whose candidates each *name* what they give up (`sacrificed`, non-empty). No constraint is dropped without a card saying so; the report is never empty. +2. **Never a weight (DEC-19).** Candidates are ranked by the commander's ordinal tiers (`must / should / prefer`) — never by a numeric sacrifice weight. Weighted CSP / MAX-SAT / Archimedean goal programming are rejected on honesty grounds (research note §1): a weight of 7-on-C3, 5-on-C4 is a ratio the commander never issued. +3. **Surface the frontier, don't pick the optimum.** `/relax` returns *every* inclusion-minimal way to restore feasibility (the minimal-correction-set view, research note §2), ranked least-worst-first — the must-sacrifice included, ranked last, never dropped. The commander weighs; the machine enumerates (JP 5-0: comparison is a commander's judgement). +4. **`sacrificed` is computed, not authored.** A commitment is sacrificed by a plan iff the reused SPEC-07 scorer returns `violated` for it — the strongest, unambiguous claim (the *best* realisation already fails). `tight`/`marginal` are risks, not sacrifices. The candidate route geometry is authored (it must enter the mined water — vignette §8 latitude), but which commitments each plan gives up is the scorer's verdict, not the author's. +5. **The tie-break is content-neutral and stated (DEC-19).** Two same-tier sacrifices (C3 and C4 are both `should`) are ordered by a stable, stated convention (commitment id), explicitly a *placeholder for commander-issued within-tier priority* — because encoding "civil harm outranks own-force exposure" (or the reverse) into code is the value judgement DEC-19 exists to prevent, in non-numeric clothing. + +## User Scenarios & Testing *(mandatory)* + +The scenarios play against the Meridian **R3m** world — the compile of the base knowledge (K1–K9 + resolved K12a) under the R3m excursion (both approaches mined, causeway dropped). Under R3m no plan satisfies C2, C3 and C4 together (vignette §6, by construction — thesis B). + +### User Story 1 — three least-worst candidates, sacrificing C4, C3, C2 (Priority: P1) 🎯 exit + +`/relax` over the R3m world returns a `RelaxationReport` with **three inclusion-minimal candidates**, distinguished by which of the mutually-unsatisfiable trio they give up: one sacrifices **C4** (parallel/fast sweep — the amphibious group crosses the mined strait unsuppressed), one sacrifices **C3** (fires forward — clears the berths but fires into the populated district), one sacrifices **C2** (sequential safe sweep — the strait opens two days late). Each `sacrificed` is non-empty; the report is not empty. + +**Why this priority**: This is the Stage-4 exit verbatim (build plan §Stage 4; coverage matrix row B): "the R3 mining branch yields three candidates sacrificing C4, C3, C2 respectively; the cards state each sacrifice in command language; no silent constraint drops anywhere." It is the whole reason the slice exists. + +**Independent Test**: Compile the R3m world; call `/relax` with C1–C6 and a fixed seed; assert the report has exactly three candidates, that the multiset of distinguishing sacrifices across them is `{C4}, {C3}, {C2}`, that every candidate's `sacrificed` is non-empty, and that each candidate resolves to a stored `Plan` whose verdict on its sacrificed commitment is `violated`. + +**Acceptance Scenarios**: + +1. **Given** the R3m world and the commitment set C1–C6, **When** `/relax` is called, **Then** it returns a non-empty `RelaxationReport` (G4). +2. **Given** the returned report, **When** the candidates' sacrifice sets are read, **Then** they are the three inclusion-minimal sets `{C4}`, `{C3}`, `{C2}` — one candidate each. +3. **Given** any candidate, **When** its plan is re-scored against R3m, **Then** exactly the commitments in its `sacrificed` list are `violated`, and every commitment *not* sacrificed is satisfied (`robust`/`marginal`). + +--- + +### User Story 2 — no silent drops: minimality and never-empty (Priority: P1) + +A candidate that gives up *more* than another (a strict superset of sacrifices) is **inclusion-dominated and excluded** — but the excluded plan's extra sacrifice is never a *silent* drop, because the surviving candidate that gives up less is in the report. And the report is **never empty**: even when every plan sacrifices something, the minimal sacrifices are returned as the least-worst options. + +**Why this priority**: G4's two halves — "never empty, never a silent constraint drop" — are the invariant this slice exists to uphold (seam §G). A plan sacrificing `{C2,C4}` must not appear as a distinct option when `{C2}` and `{C4}` already do (that would present a strictly-worse trade as a choice); equally, the machinery must never return `{}` and imply feasibility where there is none. + +**Independent Test**: Among the scored candidates include plans sacrificing `{C3,C4}` and `{C2,C4}`; assert neither appears in the report (each is a strict superset of a surviving singleton). Assert the report's candidate count equals the number of inclusion-minimal sacrifice sets, and is ≥ 1. + +**Acceptance Scenarios**: + +1. **Given** a candidate sacrificing `{C2,C4}` and candidates sacrificing `{C2}` and `{C4}`, **When** organised, **Then** the `{C2,C4}` candidate is excluded (inclusion-dominated) and both singletons survive. +2. **Given** any infeasible commitment set with at least one restorable feasibility, **When** `/relax` is called, **Then** the report has at least one candidate (never empty). +3. **Given** two candidates with the *same* sacrifice set, **When** organised, **Then** only one representative appears (no duplicate cards for the same trade). + +--- + +### User Story 3 — least-worst ordering by tier, with a stated tie-break (Priority: P1) + +The report ranks candidates **least-worst first** by the commander's ordinal tiers: a candidate that gives up only `should`s ranks above one that gives up a `must`. The must-sacrifice (C2) is **still returned**, ranked last. When two candidates tie on tier cost — C3 and C4 are both `should` — their order is set by a **stated** `tie_break`, never silently. + +**Why this priority**: This is DEC-19 made observable ("within-tier ties in relaxation are broken explicitly and the tie-break is stated") and the lexicographic-priority formalism the research note chose (§2). Ranking a must-sacrifice last but *keeping it* is the difference between least-worst and hiding-the-bad-news. + +**Independent Test**: Assert the C2-sacrifice candidate (a `must`) appears after both `should`-sacrifice candidates in the report order; assert the report carries a non-empty `tie_break` string that names the same-tier pair (C3/C4) and the stated convention used to order them. + +**Acceptance Scenarios**: + +1. **Given** candidates sacrificing `{C3}` (should), `{C4}` (should) and `{C2}` (must), **When** ranked, **Then** the two `should`-sacrifices precede the `must`-sacrifice in the report order. +2. **Given** the two `should`-sacrifices tie on tier cost, **When** ranked, **Then** `tie_break` states how they were ordered (commitment-id convention), flagged as a placeholder for commander priority — not a claim that one `should` outranks the other. +3. **Given** a commitment set whose minimal sacrifices are all the same tier, **When** ranked, **Then** `tie_break` is still present and stated (never a silent ordering). + +--- + +### User Story 4 — every card states its sacrifice in command language, fully traced (Priority: P2) + +Each `RelaxationCandidate` carries a `narrative` in **command language** naming the operational consequence — "the strait opens at D+9, two days late," "fires into the harbourfront where the craft berth among the fishing fleet," "the amphibious group crosses the mined strait unsuppressed" — never "C2 margin −6 steps." The banded `margin` the scorer computed rides underneath for the trace drawer (G2: on demand, never a headline). Every candidate opens a complete backward trace to named knowledge (G3). + +**Why this priority**: The report *language* is the point of thesis B — infeasibility becomes an argument the commander reads in their own idiom (MDMP / JP 5-0 mission-analysis, research note §5). P2 because US1–US3 fix the *content* (which sacrifices, minimal, ordered); US4 fixes the *voice* and the trace, and renders the S3 least-worst cards. + +**Independent Test**: Assert each candidate's `narrative` is non-empty command-language prose that mentions no decimal and no metric-internal token (`margin`, `m_lo`); assert the report writes `cited_in` edges from each candidate's verdicts to the report so a candidate opens a backward chain to knowledge (G3); render the S3 cards and assert one card per candidate naming its sacrifice. + +**Acceptance Scenarios**: + +1. **Given** a candidate, **When** its card renders, **Then** the sacrifice is stated in command language and no decimal or verdict-internal scalar appears on the card face (G2). +2. **Given** the report, **When** the `cited_in` edges are walked, **Then** each candidate reaches the verdicts it rests on, which reach the world, which reaches named knowledge (G3). +3. **Given** the gallery build, **When** it runs, **Then** the S3 least-worst cards are driven by the actual `/relax` over the R3m world, not by hand-authored fixture rows. + +--- + +### User Story 5 — determinism (Priority: P2) + +`/relax` is a pure function of `(world stamp, commitment set, seed, engine version)`: the same inputs return a byte-identical stamp and an identical ordered report. + +**Why this priority**: G1 across the demonstrator; every downstream act (the commander's `/select`) cites a specific report, so a non-deterministic report makes the citation meaningless. P2 because US1–US3 are the exit and US5 is the cross-cutting invariant they must not violate. + +**Independent Test**: Call `/relax` twice with identical inputs; assert identical `stamp` and identical ordered candidates (same plans, same `sacrificed`, same `narrative`, same `tie_break`). Change the world stamp (a different excursion) and assert the stamp changes. + +**Acceptance Scenarios**: + +1. **Given** a fixed `(world, commitments, seed)`, **When** `/relax` is called twice, **Then** both return the same stamp and the same ordered report. +2. **Given** a different world stamp, **When** called, **Then** the report stamp differs (the world stamp is part of the stamped input). + +--- + +### Edge Cases + +- **The commitment set is already satisfiable**: some candidate sacrifices nothing. `/relax` is defined for the infeasible case (seam §7); when a fully-feasible candidate exists its sacrifice set is empty, which is *not* a valid `RelaxationCandidate` (G4 requires `sacrificed` non-empty). The service returns the feasible plan as a distinct, first-class success signal (`feasible: true` with the satisfying plan), never a candidate with an empty `sacrificed` — feasibility is honestly *not* a relaxation. +- **No candidate restores feasibility** (every plan violates a `must` that cannot be given up): the report still returns the inclusion-minimal sacrifice sets (they will include the `must`) — G4 forbids an empty report, so the least-bad must-sacrifice is surfaced, ranked last, never suppressed. +- **A candidate with a severed route** (a leg crossing a zero-mobility region): the scorer returns `violated` with no margin; that commitment is counted as sacrificed (an unreachable objective is honestly given up), never skipped. +- **World refusal / unknown world ref**: `/relax` refuses `unknown_ref` (first-class, seam §1) when the world cannot be resolved; it never relaxes against a phantom world. A scorer refusal on a candidate (e.g. `stamp_mismatch`) is returned verbatim. +- **Ties across more than two same-tier sacrifices**: `tie_break` states the ordering convention once for all same-tier groups; the convention is stable and content-neutral (id order), so the ordering is reproducible and never a hidden judgement. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: `/relax` MUST accept `{world: Ref, commitments: Ref[], seed: integer}` and return `{report: RelaxationReport, stamp}` on success or a first-class `Refusal` (seam §7). The `world` is a CompiledWorld carrying its excursion (scenario-blind, DEC-10). +- **FR-002**: The service MUST generate candidate plans whose routes engage the infeasible world's contested regions (the mined approaches / the timing that trades C2), each a valid `Plan` (DEC-20) with routes for the five Meridian force elements. Generation MUST be deterministic in `(seed)`. +- **FR-003**: Each candidate MUST be scored by the SPEC-07 scorer against the given world (reusing `ScoreService`, no re-implementation). A commitment is `sacrificed` by a candidate iff its scorer verdict is `violated`; `tight` and `marginal` are NOT sacrifices. +- **FR-004**: The service MUST keep the **inclusion-minimal** correction sets: a candidate is excluded iff another candidate's sacrifice set is a strict subset of its own. Candidates whose sacrifice sets are incomparable by inclusion (e.g. different singletons) MUST both survive. Duplicate sacrifice sets collapse to one representative. +- **FR-005**: The report MUST be **never empty** when the set is infeasible (G4): at least one inclusion-minimal candidate is returned. No constraint may be dropped without a candidate naming it in `sacrificed`. +- **FR-006**: Candidates MUST be ranked **least-worst first** by lexicographic tier cost — order by (musts sacrificed, then shoulds, then prefers), ascending. A candidate sacrificing a `must` MUST still be returned (ranked last), never dropped. +- **FR-007**: When two or more candidates tie on tier cost, their order MUST be set by a **stated, content-neutral convention** (commitment-id order) recorded in the report's `tie_break` prose (DEC-19). `tie_break` MUST be present whenever any same-tier ordering occurred and MUST NOT encode a value ranking between same-tier commitments; it is a placeholder for commander-issued within-tier priority. +- **FR-008**: Ranking and organisation MUST NOT use any numeric commitment weight, weighted sum, scalar total, or band midpoint (DEC-19, DEC-15). +- **FR-009**: Each returned `RelaxationCandidate` MUST have a non-empty `sacrificed` (G4) and a `narrative` in command language naming the operational consequence — no decimal, no verdict-internal token on the card face (G2). The banded `margin` stays available for the trace drawer only. +- **FR-010**: The service MUST write `cited_in` edges from each candidate's verdicts (and the scores they rest on) to the `RelaxationReport`, so every candidate opens a complete backward trace to named knowledge (G3, knowledge model §10 — the `cited_in` and `sacrificed_in` edges). +- **FR-011**: `/relax` MUST be deterministic: same `(world stamp, commitment set, seed, engine version)` ⇒ byte-identical stamp and identical ordered report (G1). The stamp is a hash over those inputs, never over materialised cells. +- **FR-012**: `/relax` MUST refuse `unknown_ref` (first-class) when the world ref cannot be resolved and persist nothing on refusal; a scorer refusal on a candidate MUST be returned verbatim. +- **FR-013**: When a fully feasible candidate exists (empty sacrifice set), the service MUST report feasibility as a first-class signal rather than emit a `RelaxationCandidate` with an empty `sacrificed` (G4 — `sacrificed` is non-empty by definition). + +### Key Entities + +- **RelaxationReport** (existing LinkML, G4) — `{world_stamp, scenario, candidates: RelaxationCandidate[], tie_break}`. Returned as `report`; stored and content-addressed. +- **RelaxationCandidate** (existing LinkML) — `{plan, sacrificed: LogicalId[] (non-empty), narrative}`. One per surviving inclusion-minimal sacrifice set. +- **Commitment** (existing LinkML, DEC-19) — `{tier: must|should|prefer, ...}`; the tiers drive the lexicographic ranking; no numeric weight slot exists. +- **Plan / ElementPlan / RouteLeg** (existing LinkML, DEC-20) — the authored candidate geometry (routes engaging the mined water); stored and content-addressed. +- **CommitmentVerdict.verdict** (existing LinkML, SPEC-07) — the four-stop scale the sacrifice set is read off (`violated` ⇒ sacrificed). +- **RelaxRequest / RelaxSuccess / RelaxResult** (service types, `src/seam.ts` — not stored) — the seam §7 movement shapes. +- **TraceEdge** (`cited_in`, `sacrificed_in`) — written by the relax service so candidates trace back to knowledge (G3). + +## Success Criteria *(mandatory)* + +- **SC-001**: `/relax` over the Meridian R3m world returns exactly three candidates whose distinguishing sacrifices are `{C4}`, `{C3}`, `{C2}`; each `sacrificed` is non-empty and the report is non-empty (G4). *(Stage-4 exit.)* +- **SC-002**: A candidate sacrificing a strict superset of another's sacrifices is excluded (inclusion-minimal); duplicate sacrifice sets collapse to one; the report is never empty. +- **SC-003**: Candidates are ranked least-worst first by tier (the C2 `must`-sacrifice ranks last but is returned); `tie_break` is present and states the content-neutral ordering of the C3/C4 same-tier pair. +- **SC-004**: For each candidate, re-scoring its plan against R3m marks exactly its `sacrificed` commitments `violated` and every other commitment satisfied — the sacrifices are computed by the scorer, not authored. +- **SC-005**: Every candidate opens a complete backward trace chain (candidate → verdicts → world → named knowledge) via `cited_in`/`scored_from` edges (G3). +- **SC-006**: Same `(world, commitments, seed)` ⇒ byte-identical stamp and identical ordered report; a different world stamp changes the report stamp (G1). +- **SC-007**: The S3 least-worst cards render in the gallery from the actual `/relax` over R3m — one card per candidate, its sacrifice in command language, no decimals on the card face (G2). +- **SC-008**: `npm run typecheck` clean; `npm test` green (SPEC-01…08 baseline plus the SPEC-09 suite); no schema change (`npm run gen` not run — `RelaxationReport`/`RelaxationCandidate`/`Plan` already exist). diff --git a/specs/009-relaxation/tasks.md b/specs/009-relaxation/tasks.md new file mode 100644 index 0000000..e980b55 --- /dev/null +++ b/specs/009-relaxation/tasks.md @@ -0,0 +1,70 @@ +--- +description: "Task list for SPEC-09 — Relaxation (least-worst under infeasibility)" +--- + +# Tasks: Relaxation — least-worst under an infeasible commitment set (SPEC-09) + +**Input**: Design documents from `specs/009-relaxation/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/relax-service.md, quickstart.md; research note `docs/research/04-relaxation.md` (DEC-11 gate — present) +**Tests**: REQUIRED — the tier-lexicographic order and inclusion-minimality laws (constructed sacrifice sets) and the R3m relax assertions are written against the seam §7 shape and **confirmed failing before implementation** (TDD; constitution quality gate 2). The R3m candidates are a *computed* product; tests assert the sacrifice sets (`{C4}`,`{C3}`,`{C2}`), never hand-set membership. +**Organization**: the tier/minimality algebra (pure, testable in isolation) and the authored candidate set first, then the service that composes them with the reused scorer, then the surface. + +## Format: `[ID] [P?] [Story] Description` +- **[P]**: parallelizable (different file, no dependency on an incomplete task) +- **[Story]**: US1 (three C4/C3/C2, P1) · US2 (minimality/never-empty, P1) · US3 (tier ranking + tie-break, P1) · US4 (cards + trace, P2) · US5 (determinism, P2) + +## Path conventions +Single project: `src/`, `tests/`. Reuse `src/{store,trace,canonical,validate,seam,score,metrics,materialise}.ts`, `src/generated/types.ts`. **No schema change** — `RelaxationReport`/`RelaxationCandidate`/`Plan`/`TraceEdge` already exist; do **not** run `npm run gen`. + +--- + +## Phase 1: Setup + +- [x] T001 Confirm the SPEC-08 baseline is green: `npm install`, `npm run typecheck`, `npm test`. Record any failing baseline before Stage-4 code. +- [x] T002 Confirm the pieces SPEC-09 reuses exist: `ScoreService` with `CommitmentVerdict.verdict` (four-stop); `Commitment.tier`; `RelaxationReport`/`RelaxationCandidate` in `src/generated/types.ts`; `cited_in`/`sacrificed_in` in `TraceEdgeType`; the R3m COA in `fixtures/coas.json` and the compile scenario overlay (SPEC-06). No new dependency. + +## Phase 2: US2 + US3 — the tier / minimality algebra (P1, the honesty core, pure) + +**Goal**: lexicographic tier order + inclusion-minimality over sacrifice sets, proven on constructed sets before any pipeline exists. +**Independent test**: constructed sacrifice sets → minimal kept / superset dropped / duplicate collapsed / tier order least-worst-first / tie-break stable. + +- [x] T003 Write `tests/tiers.test.ts` FIRST and confirm failing: `tierCost(set, tierOf)` counts `(musts, shoulds, prefers)`; `compareTierCost` orders least-worst first (fewer musts wins, then shoulds, then prefers); `inclusionMinimal(sets)` drops strict supersets, keeps incomparable singletons, collapses duplicates; the id tie-break is stable and total; **no numeric weight anywhere** (DEC-19). +- [x] T004 Implement `src/tiers.ts`: `SacrificeSet`, `TierCost`, `tierCost`, `compareTierCost`, `inclusionMinimal`, and the content-neutral `tieBreakText` helper. Pure functions over commitment ids + a `tierOf` map. Make T003 pass. + +## Phase 3: US1 — the R3m-responsive candidate set (P1) + +**Goal**: authored candidate `Plan`s that engage the mined water so the scorer computes the C4/C3/C2 sacrifices. +**Independent test**: each candidate a valid `Plan` with the five force elements; the set spans the parallel / fires-forward / sequential postures plus the two dominated supersets; deterministic in seed. + +- [x] T005 Write `tests/relax.test.ts` (candidate-set portion) FIRST and confirm failing: `relaxCandidates(seed)` returns valid `Plan`s (each `validateInstance('Plan', p) === []`, five FEs, `RX-*` id); the postures documented cell-by-cell; deterministic. +- [x] T006 Implement `src/relaxCandidates.ts`: per-force-element route builders (documented axis→route mapping — BROOM fast/slow trades C2; ANVIL clean/mined-strait trades C4; FALCON standoff/fires trades C3; PACKHORSE/KINGFISHER constant), the candidate `Plan`s (parallel, fires, sequential, parallel+fires, seq+exposed), and a command-language `narrative` template per posture. Make the T005 portion pass. + +## Phase 4: US1 + US2 + US3 + US5 — the relax service (P1/P2) + +**Goal**: `/relax` = generate → score → sacrifice → minimal → rank, deterministic, over the R3m world. +**Independent test**: R3m → three candidates `{C4}`/`{C3}`/`{C2}`; supersets excluded; must-sacrifice last; tie_break present; same inputs ⇒ identical; unknown world refuses. + +- [x] T007 Add relax service types to `src/seam.ts`: `RelaxRequest`, `RelaxSuccess` (with optional `feasible`), `RelaxResult` (per data-model.md). Types only; reuse `Refusal`/`Ref`. +- [x] T008 Extend `tests/relax.test.ts` FIRST and confirm failing (rig mirrors `handful.test.ts`: compile the R3m world): report non-empty (G4); exactly three candidates with sacrifice sets `{C4}`/`{C3}`/`{C2}`; each `sacrificed` non-empty; re-score confirms exactly those violated (SC-004); `{C2,C4}`/`{C3,C4}` excluded (SC-002); must-sacrifice (C2) ranked last; `tie_break` present naming C3/C4; `cited_in` edges present (G3); same `(world, commitments, seed)` ⇒ identical stamp/report; `unknown_ref` persists nothing. +- [x] T009 Implement `src/relax.ts` `RelaxService`: resolve world (refuse `unknown_ref`), generate candidates → store each → score via `ScoreService` → read `violated` sacrifice sets → surface `feasible` on an empty set → `inclusionMinimal` → rank by `compareTierCost` then id tie-break → build `RelaxationCandidate[]` with narratives → assemble/validate/store `RelaxationReport` → write `cited_in`/`sacrificed_in` edges → stamp over `(world.stamp, sorted ids, seed, engine)`. Make T008 pass. + +## Phase 5: US4 — the surface (P2) + +**Goal**: the least-worst demo moment ("least-worst, never silence"). + +- [x] T010 [P] Implement `src/components/s3Cards.ts`: one card per candidate — sacrifice (commitment ids + statements) stated in command language, the narrative, and the `tie_break` note below the set. Arranges projections only (constitution I); no decimal / no verdict-internal token on the card face (G2). Depends on generated types + `Ref`. +- [x] T011 Wire `scripts/build-gallery.ts`: compile the R3m world, call `RelaxService.relax` over C1–C6, render the S3 cards; add the "least-worst, never silence" section naming SPEC-09 (report over R3m, computed not authored). Regenerate `docs/assets/gallery/index.html`. + +## Phase 6: Batch propagation & gate + +- [x] T012 Sweep the canonical peers in the same change: CLAUDE.md current-phase line; `assay-build-plan.md` / `assay-delivery-plan.md` Stage-4 status; the gallery. Note the research note asserts no new register candidate (the formalism is decided by DEC-19; latitude is Stage-4-delegated under vignette §6/§9). +- [x] T013 Gate: `npm run typecheck` clean; `npm test` green (SPEC-01…08 baseline + SPEC-09 suites); `npm run gallery` renders the S3 cards from the actual `/relax`. No schema regen. + +--- + +## Dependencies & parallelism + +- **T003→T004** (tier/minimality) and **T005→T006** (candidates) are independent tracks — run in parallel. +- **T007** (types) unblocks **T008→T009** (service), which depends on T004 + T006. +- **T010** is parallel to the service; **T011** needs T009 + T010. +- **T012/T013** are the closing sweep and gate. diff --git a/src/components/s3Cards.ts b/src/components/s3Cards.ts new file mode 100644 index 0000000..852345f --- /dev/null +++ b/src/components/s3Cards.ts @@ -0,0 +1,78 @@ +/** + * SPEC-09 — the S3 least-worst cards (seam §7; ui-design §S3). + * + * Renders a `RelaxationReport` as one card per candidate: what it gives up + * (`sacrificed` — commitment ids, tiers, and the commander's own statements) and + * the `narrative` in command language, ranked least-worst first, with the report's + * stated `tie_break` note below the set. It arranges projections only + * (constitution I): no service call, no state, depends only on the returned report + * (and the commitment statements handed in for legibility). The card face carries + * NO decimal and NO verdict-internal token (`margin`, `m_lo`) — the sacrifice is an + * argument in command language, and the banded margin stays in the trace drawer (G2). + */ +import type { RelaxationCandidate } from '../generated/types.js'; + +const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** One sacrificed commitment, resolved for legibility (statement + tier). */ +export interface SacrificedCommitment { + logical_id: string; + tier: string; // must | should | prefer + statement: string; +} + +export interface S3Card { + candidate: RelaxationCandidate; + /** The sacrificed commitments, aligned to `candidate.sacrificed` (resolved). */ + sacrificed: SacrificedCommitment[]; +} + +const TIER_TINT: Record = { + must: '#B23A48', // giving up a must is the gravest + should: '#C46A1E', + prefer: '#5B6B77', +}; + +function chip(c: SacrificedCommitment): string { + const tint = TIER_TINT[c.tier] ?? '#5B6B77'; + return ` + ${esc(c.logical_id)} + ${esc(c.tier)} + `; +} + +/** + * @param cards one per candidate, in the report's ranked (least-worst-first) order. + * @param tie_break the report's stated tie-break prose, if any (rendered below the set). + */ +export function s3Cards(cards: S3Card[], tie_break?: string): string { + const items = cards + .map((card, i) => { + const chips = card.sacrificed.map(chip).join('\n '); + const statements = card.sacrificed + .map((c) => `
  • gives up: ${esc(c.statement)} (${esc(c.logical_id)})
  • `) + .join('\n '); + return `
  • +
    + least-worst #${i + 1} + ${esc(card.candidate.plan)} + ${chips} +
    +
    ${esc(card.candidate.narrative)}
    + +
  • `; + }) + .join('\n'); + const tie = tie_break + ? `

    Tie-break — ${esc(tie_break)}

    ` + : ''; + return `
    + + ${tie} +
    `; +} diff --git a/src/relax.ts b/src/relax.ts new file mode 100644 index 0000000..a832385 --- /dev/null +++ b/src/relax.ts @@ -0,0 +1,224 @@ +/** + * SPEC-09 — the relaxation service (seam contract §7; delivery plan §ε, Stage 4). + * + * POST /relax {world, commitments, seed} + * → {report: RelaxationReport, stamp, feasible?} + * + * Least-worst, never silence (G4). On an infeasible world (Meridian R3m), it turns + * unsatisfiability into an ARGUMENT the commander weighs, in five honest steps: + * + * 1. GENERATE — the R3m-responsive candidate set (`src/relaxCandidates.ts`): + * authored plans that ENTER the mined water (the SPEC-08 BASE generator cannot + * — research note §3). Geometry authored; sacrifices computed. + * 2. SCORE — each candidate through the SPEC-07 scorer (`ScoreService`, REUSED, + * never re-implemented). A commitment is `sacrificed` iff its verdict is + * `violated` — the strongest claim; `tight`/`marginal` are risks, not sacrifices. + * 3. MINIMALITY — keep the inclusion-minimal correction sets (`src/tiers.ts`): a + * strict-superset sacrifice is silent waste and is dropped; different singletons + * both survive; the report is never empty (G4). + * 4. RANK — least-worst first by ordinal tier cost (musts, then shoulds, then + * prefers), NO weights (DEC-19); the must-sacrifice ranks last, never dropped. + * Same-tier ties ordered by a stated, content-neutral convention (`tie_break`). + * 5. TRACE — `cited_in` from each candidate's verdicts/scores to the report and + * `sacrificed_in` from each sacrificed commitment, so every card opens a + * complete backward chain to named knowledge (G3). + * + * Deterministic over `(world stamp, sorted commitment ids, seed, engine)` (G1); + * scenario-blind over the compile-time excursion (DEC-10); refuses first-class. + */ +import type { + Commitment, + CommitmentVerdict, + CompiledWorld, + PlanScore, + RelaxationCandidate, + RelaxationReport, + TraceEdge, +} from './generated/types.js'; +import { ObjectStore, type Ref } from './store.js'; +import { TraceStore } from './trace.js'; +import { contentHash } from './canonical.js'; +import { validateInstance } from './validate.js'; +import { ScoreService } from './score.js'; +import { isRefusal, type RelaxRequest, type RelaxResult, type Refusal } from './seam.js'; +import { relaxCandidates, narrativeFor } from './relaxCandidates.js'; +import { + canonicalSet, + compareTierCost, + inclusionMinimal, + tierCost, + tieBreakText, + type SacrificeSet, + type TierOf, +} from './tiers.js'; + +const DEFAULT_SCENARIO = 'BASE'; + +/** A scored candidate before organisation. */ +interface Scored { + planRef: Ref; + sacrificed: SacrificeSet; // canonical (sorted, de-duped) `violated` commitment ids + verdicts: CommitmentVerdict[]; + scores: PlanScore[]; +} + +export class RelaxService { + #store: ObjectStore; + #trace: TraceStore; + #scorer: ScoreService; + #tierOf: TierOf; + #commitmentsById: Map; + #writtenBy: string; + + constructor(opts: { + store: ObjectStore; + trace: TraceStore; + scorer: ScoreService; + /** The commander's commitments (C1–C6) — tiers drive the ranking (DEC-19). */ + commitments: Commitment[]; + writtenBy?: string; + }) { + this.#store = opts.store; + this.#trace = opts.trace; + this.#scorer = opts.scorer; + this.#commitmentsById = new Map(opts.commitments.map((c) => [c.logical_id, c])); + this.#tierOf = (id) => { + const c = this.#commitmentsById.get(id); + if (!c) throw new Error(`relax: no tier for commitment '${id}' (commitment set mismatch)`); + return c.tier; + }; + this.#writtenBy = opts.writtenBy ?? 'relax-service'; + } + + /** POST /relax — pure over its stamped inputs; refuses first-class, persists nothing on refusal. */ + async relax(req: RelaxRequest): Promise { + const world = this.#resolveWorld(req.world); + if (!world) { + return refusal('unknown_ref', [req.world], `${req.world.logical_id}: no such world to relax against.`); + } + const scenario = world.scenario ?? DEFAULT_SCENARIO; + + // 1–2. Generate the R3m-responsive candidates, then score each. The scorer — + // not this author — decides the sacrifices (the `violated` set). + const scored: Scored[] = []; + let feasible: { plan: Ref } | undefined; + for (const candidate of relaxCandidates(req.seed)) { + const planRef = await this.#store.put(candidate as unknown as Record); + const result = await this.#scorer.score({ + plan: planRef, + world: req.world, + scenario, + engine_version: req.engine_version, + }); + if (isRefusal(result)) return result; // a candidate that refuses scoring refuses the relax + const sacrificed = canonicalSet( + result.verdicts.filter((v) => v.verdict === 'violated').map((v) => v.commitment), + ); + if (sacrificed.length === 0) { + // A fully-feasible plan: report feasibility first-class, never as a + // RelaxationCandidate with empty `sacrificed` (G4 — FR-013). It is excluded + // from the candidate frontier below (there is nothing to give up). + feasible ??= { plan: planRef }; + continue; + } + scored.push({ planRef, sacrificed, verdicts: result.verdicts, scores: result.scores }); + } + + // 3. Inclusion-minimal correction sets, then map each surviving set back to a + // representative scored candidate (first candidate carrying that exact set). + const minimalSets = inclusionMinimal(scored.map((s) => s.sacrificed)); + const survivors = minimalSets.map((set) => { + const rep = scored.find((s) => sameSet(s.sacrificed, set)); + if (!rep) throw new Error(`relax: minimal set ${set.join('+')} lost its candidate (internal)`); + return rep; + }); + if (survivors.length === 0) { + // Only reachable if every candidate was feasible — impossible for the authored + // set (each posture sacrifices ≥1). Fail loud rather than emit an empty report. + throw new Error('relax: no non-empty sacrifice sets — candidate set produced no relaxation (fixture defect).'); + } + + // 4. Rank least-worst first by tier cost, then the stated content-neutral id + // tie-break for same-tier groups (DEC-19). A total, reproducible order. + const ranked = [...survivors].sort( + (a, b) => + compareTierCost(tierCost(a.sacrificed, this.#tierOf), tierCost(b.sacrificed, this.#tierOf)) || + cmp(a.sacrificed.join('+'), b.sacrificed.join('+')), + ); + const tie_break = tieBreakText(ranked.map((s) => s.sacrificed), this.#tierOf); + + // 5. Assemble, validate, store the report, then write the trace edges (G3). + const candidates: RelaxationCandidate[] = ranked.map((s) => ({ + plan: s.planRef.logical_id, + sacrificed: s.sacrificed, + narrative: narrativeFor(s.planRef.logical_id), + })); + const report: RelaxationReport = { + logical_id: `RR-${scenario}`, + version: 1, + world_stamp: world.stamp, + scenario, + candidates, + ...(tie_break ? { tie_break } : {}), + }; + const errors = validateInstance('RelaxationReport', report); + if (errors.length > 0) throw new Error(`relax produced an invalid RelaxationReport: ${errors.join('; ')}`); + + const stamp = await contentHash({ + world: world.stamp, + commitments: [...this.#commitmentsById.keys()].sort(cmp), + seed: req.seed, + engine_version: req.engine_version, + }); + const reportRef = await this.#store.put(report as unknown as Record); + + // cited_in: each surviving candidate's verdicts + scores → the report. + // sacrificed_in: each sacrificed Commitment → the report (the candidate lives in it). + for (const s of ranked) { + for (const v of s.verdicts) { + const vhash = (await this.#store.put(v as unknown as Record)).content_hash; + this.#trace.add(this.#edge(vhash, reportRef.content_hash, 'cited_in', stamp)); + } + for (const sc of s.scores) { + const shash = (await this.#store.put(sc as unknown as Record)).content_hash; + this.#trace.add(this.#edge(shash, reportRef.content_hash, 'cited_in', stamp)); + } + for (const cid of s.sacrificed) { + const chash = this.#commitmentHash(cid); + if (chash) this.#trace.add(this.#edge(chash, reportRef.content_hash, 'sacrificed_in', stamp)); + } + } + + return { report, stamp, ...(feasible ? { feasible } : {}) }; + } + + #resolveWorld(ref: Ref): CompiledWorld | undefined { + if (ref.content_hash) return this.#store.get(ref.content_hash) as CompiledWorld | undefined; + const versions = this.#store.versions(ref.logical_id); + const hash = versions.length > 0 ? versions[versions.length - 1]!.content_hash : undefined; + return hash ? (this.#store.get(hash) as CompiledWorld) : undefined; + } + + /** The stored content hash of a commitment id, if it is in the store (for sacrificed_in). */ + #commitmentHash(id: string): string | undefined { + const versions = this.#store.versions(id); + return versions.length > 0 ? versions[versions.length - 1]!.content_hash : undefined; + } + + #edge(from: string, to: string, type: TraceEdge['edge_type'], stamp: string): TraceEdge { + return { from_hash: from, to_hash: to, edge_type: type, stamp, written_by: this.#writtenBy }; + } +} + +// ————— helpers ————— + +const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +const sameSet = (a: SacrificeSet, b: SacrificeSet): boolean => + a.length === b.length && a.every((x, i) => x === b[i]); + +const refusal = (reason: Refusal['reason'], offending: Ref[], explanation: string): Refusal => ({ + refused: true, + reason, + offending, + explanation, +}); diff --git a/src/relaxCandidates.ts b/src/relaxCandidates.ts new file mode 100644 index 0000000..7226852 --- /dev/null +++ b/src/relaxCandidates.ts @@ -0,0 +1,132 @@ +/** + * SPEC-09 — the R3m-responsive relaxation candidate set (research note + * `04-relaxation.md` §3–4). + * + * `/relax` needs candidates that actually ENTER the mined water — otherwise the + * mining conflict never surfaces. The SPEC-08 BASE generator (`src/generate.ts`) + * deliberately routes clear of the banded regions so BASE reaches stay clean, so + * scored against R3m it barely moves. So SPEC-09 authors a small set of plans + * whose routes DO engage the R3m excursion (mined `halcyon_strait`) and time the + * sweep against C2. This is route-geometry latitude within the vignette's + * constraints (§8) — the candidate *geometry* is authored, but the *sacrifices are + * computed* by the reused SPEC-07 scorer, never by this author (DEC-10). + * + * The postures are read off the vignette §6 conflict (C2/C3/C4 unsatisfiable under + * R3m). Each axis is mapped cell-by-cell so a reviewer can walk any leg to the + * posture that set it, exactly as `generate.ts` documents its own: + * + * - BROOM (C2, strait_open_step) — FAST parallel pass (reach 22 ≤ 28, C2 met) vs + * SLOW sequential sweep (reach 34 > 28, C2 late). Mobility is 1 on both routes, + * so C2 is the plan's own timing, not the channel — the honest reading of + * "sweeping sequentially and safely opens the strait at D+9" (vignette §6). + * - ANVIL (C4, threat_exposure) — CLEAN cell (threat 0 ⇒ exposure [0,0]) vs + * five steps in the mined `halcyon_strait` (R3m threat [0.5,0.9], dwell 30 h ⇒ + * exposure [15,27] > the 12 band-hour ceiling ⇒ C4 violated). + * - FALCON (C3, civil_harm_exposure) — STAND-OFF at the Carrick battery (no fires + * in the district ⇒ C3 marginal) vs FIRES into the populated port district + * (one leg inside ⇒ count 1 > 0 ⇒ C3 violated). + * - PACKHORSE (C1) and KINGFISHER (C6) are CONSTANT across candidates and kept + * comfortably satisfied — they are not the R3m trade, so they never distinguish. + * + * The five candidates are the three minimal postures (parallel/fires/sequential) + * plus two that give up MORE (parallel+fires, sequential+exposed) so the service's + * inclusion-minimality is exercised on real, scored supersets — not just asserted. + */ +import type { ElementPlan, Plan, RouteLeg } from './generated/types.js'; + +const leg = (x: number, y: number, enter: number, exit: number): RouteLeg => ({ + x, y, enter_step: enter, exit_step: exit, +}); +const el = (fe: string, route: RouteLeg[]): ElementPlan => ({ force_element: fe, route }); + +// FE-BROOM — the C2 clock (strait_open_step). Mobility 1 on both, so reach = timing. +const broomFast = el('FE-BROOM', [leg(30, 50, 0, 3), leg(26, 25, 3, 22)]); // strait_open = 22 ≤ 28 +const broomSlow = el('FE-BROOM', [leg(30, 50, 0, 4), leg(24, 26, 4, 16), leg(30, 25, 16, 34)]); // = 34 > 28 + +// FE-ANVIL — C4 (threat_exposure). Clean cell vs five steps in the mined strait. +const anvilClean = el('FE-ANVIL', [leg(30, 50, 0, 8), leg(26, 32, 8, 10)]); // threat 0 ⇒ exposure [0,0] +const anvilExposed = el('FE-ANVIL', [leg(30, 50, 0, 8), leg(26, 25, 8, 13)]); // mined strait, dwell 30h ⇒ [15,27] + +// FE-FALCON — C3 (civil_harm_exposure). Battery stand-off vs a fire in the district. +const falconStandoff = el('FE-FALCON', [leg(30, 50, 0, 4), leg(48, 15, 4, 10)]); // no district leg ⇒ C3 marginal +const falconFires = el('FE-FALCON', [leg(30, 50, 0, 4), leg(26, 32, 4, 8)]); // inside port_district ⇒ C3 violated + +// FE-PACKHORSE (C1) / FE-KINGFISHER (C6) — constant, comfortably satisfied. +const packFast = el('FE-PACKHORSE', [leg(30, 50, 0, 4), leg(26, 32, 4, 24)]); // port_open = 24 ≤ 40 +const kingPull = el('FE-KINGFISHER', [leg(48, 15, 0, 4), leg(30, 50, 4, 38)]); // extraction = 38 ≤ 48 + +/** One posture: an id, its command-language narrative, and its five element plans. */ +interface Posture { + id: string; + name: string; + narrative: string; + elements: ElementPlan[]; +} + +/** + * The postures, in a fixed authored order. The `narrative` is command language + * (the argument the commander reads); the *sacrifice* each implies is computed by + * the scorer, not stated here. Narratives name the operational consequence in the + * vignette §6 idiom, never a margin or a decimal (G2). + */ +const POSTURES: Posture[] = [ + { + id: 'RX-parallel', + name: 'parallel sweep', + narrative: + 'Parallel sweep — both approaches cleared at once, fastest to open the strait, ' + + 'but the amphibious group crosses the mined strait before the battery and FACs are suppressed.', + elements: [broomFast, anvilExposed, falconStandoff, packFast, kingPull], + }, + { + id: 'RX-fires', + name: 'fires forward', + narrative: + 'Fires forward — suppress the FAC berths so the group crosses clean and on time, ' + + 'but the fires fall into the populated harbourfront where the craft berth among the fishing fleet.', + elements: [broomFast, anvilClean, falconFires, packFast, kingPull], + }, + { + id: 'RX-sequential', + name: 'sequential sweep', + narrative: + 'Sequential sweep — clear one approach then the other, keeping the group clear and the ' + + 'harbourfront quiet, but the strait opens at D+9, two days late.', + elements: [broomSlow, anvilClean, falconStandoff, packFast, kingPull], + }, + { + id: 'RX-parallel-fires', + name: 'parallel sweep with fires forward', + narrative: + 'Parallel sweep with fires forward — fast and clean-crossing, but it both fires into the ' + + 'district and, on the parallel pass, still exposes the group.', + elements: [broomFast, anvilExposed, falconFires, packFast, kingPull], + }, + { + id: 'RX-sequential-exposed', + name: 'sequential sweep, group exposed', + narrative: + 'Sequential sweep with the group pressed forward — the strait still opens two days late ' + + 'and the amphibious group is exposed in the mined water.', + elements: [broomSlow, anvilExposed, falconStandoff, packFast, kingPull], + }, +]; + +const plan = (p: Posture, seed: number): Plan => ({ + logical_id: p.id, + version: 1, + name: p.name, + seed, + generator: 'spec-09 relaxation candidate set (R3m-responsive postures)', + elements: p.elements, +}); + +/** The authored candidate plans for the R3m conflict, in fixed enumeration order. */ +export function relaxCandidates(seed: number): Plan[] { + return POSTURES.map((p) => plan(p, seed)); +} + +/** The command-language narrative for a candidate plan (by logical_id). */ +export function narrativeFor(planId: string): string { + return POSTURES.find((p) => p.id === planId)?.narrative ?? ''; +} diff --git a/src/seam.ts b/src/seam.ts index 01e0761..db6f8ce 100644 --- a/src/seam.ts +++ b/src/seam.ts @@ -8,7 +8,7 @@ * rather than in the generated schema. */ import type { Ref } from './store.js'; -import type { Band, CommitmentVerdict, PlanScore } from './generated/types.js'; +import type { Band, CommitmentVerdict, PlanScore, RelaxationReport } from './generated/types.js'; export type RefusalReason = | 'contested_knowledge' @@ -106,3 +106,26 @@ export interface HandfulSuccess { } export type HandfulResult = HandfulSuccess | Refusal; + +/** + * SPEC-09 — relaxation movement types (seam §7). `/relax` is called on an + * infeasible commitment set; it returns a `RelaxationReport` whose candidates each + * name their `sacrificed` commitments (non-empty, G4), ranked least-worst first. + * `feasible` is set ONLY when a candidate needs no sacrifice at all — feasibility + * is reported first-class rather than as a candidate with an empty `sacrificed` + * (G4 requires `sacrificed` non-empty). Scenario is derived from the world (DEC-10). + */ +export interface RelaxRequest { + world: Ref; + commitments: Ref[]; + seed: number; + engine_version: string; +} + +export interface RelaxSuccess { + report: RelaxationReport; + stamp: string; + feasible?: { plan: Ref }; // present only when the set turned out to be satisfiable +} + +export type RelaxResult = RelaxSuccess | Refusal; diff --git a/src/tiers.ts b/src/tiers.ts new file mode 100644 index 0000000..27ef903 --- /dev/null +++ b/src/tiers.ts @@ -0,0 +1,109 @@ +/** + * SPEC-09 — the tier / minimality algebra (research note `04-relaxation.md` §2, §4). + * + * Relaxation ranks candidates by the commander's ORDINAL tiers (`must / should / + * prefer`, DEC-19) — never by a numeric weight. Two adaptations of preemptive + * lexicographic goal programming, both forced by the invariants: + * + * - **Inclusion-minimal frontier, not one optimum** (G4). A candidate's sacrifice + * set is dropped only when another's is a strict SUBSET (that candidate gives up + * strictly less) — a strict-superset sacrifice is silent waste, forbidden. + * Incomparable sets (different singletons) both survive; the commander weighs. + * - **Least-worst-first tier order.** Order by (musts, shoulds, prefers) sacrificed, + * ascending: giving up only `should`s beats giving up a `must`. The must-sacrifice + * is ranked LAST, never dropped (G4 — never silence). + * + * Where lexicography runs out — two same-tier sacrifices — the order is a STATED, + * content-neutral tie-break (commitment id), never a hidden numeric nudge (DEC-19). + * No numeric commitment weight, scalar total, or midpoint appears anywhere here. + */ +import type { CommitmentTier } from './generated/types.js'; + +/** A candidate's sacrificed commitments, as sorted logical ids (`violated` set). */ +export type SacrificeSet = string[]; + +/** The lexicographic key: how many of each tier a set sacrifices. Lower is better. */ +export type TierCost = { must: number; should: number; prefer: number }; + +/** Map from commitment logical_id → its ordinal tier. */ +export type TierOf = (commitmentId: string) => CommitmentTier; + +const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Canonicalise a sacrifice set: de-duplicated, id-sorted (the stable representation). */ +export function canonicalSet(ids: Iterable): SacrificeSet { + return [...new Set(ids)].sort(cmp); +} + +/** Count the tiers a sacrifice set gives up. No weights — counts only (DEC-19). */ +export function tierCost(set: SacrificeSet, tierOf: TierOf): TierCost { + const cost: TierCost = { must: 0, should: 0, prefer: 0 }; + for (const id of set) cost[tierOf(id)] += 1; + return cost; +} + +/** + * Least-worst-first order over tier costs: fewer musts wins, then fewer shoulds, + * then fewer prefers. Returns <0 if `a` is less-bad than `b`. Purely ordinal — + * the tiers are compared in priority order, never summed into a scalar. + */ +export function compareTierCost(a: TierCost, b: TierCost): number { + return a.must - b.must || a.should - b.should || a.prefer - b.prefer; +} + +const isStrictSubset = (a: SacrificeSet, b: SacrificeSet): boolean => { + if (a.length >= b.length) return false; + const bs = new Set(b); + return a.every((x) => bs.has(x)); +}; + +const equalSet = (a: SacrificeSet, b: SacrificeSet): boolean => + a.length === b.length && a.every((x, i) => x === b[i]); + +/** + * The inclusion-minimal correction-set frontier (research note §2, G4). Keep a set + * unless another is a strict SUBSET of it (that one wastes less); collapse + * duplicates to one representative (first occurrence). Incomparable sets — different + * singletons, overlapping-but-neither-subset — all survive: the trade is the + * commander's, not the machine's. Never returns fewer than the truly minimal sets. + */ +export function inclusionMinimal(sets: SacrificeSet[]): SacrificeSet[] { + const canon = sets.map((s) => canonicalSet(s)); + const kept: SacrificeSet[] = []; + for (let i = 0; i < canon.length; i++) { + const s = canon[i]!; + // dropped if some OTHER set is a strict subset of s (gives up strictly less)… + const dominated = canon.some((o, j) => j !== i && isStrictSubset(o, s)); + // …or if an earlier identical set already represents this trade (de-dup). + const duplicateEarlier = canon.slice(0, i).some((o) => equalSet(o, s)); + if (!dominated && !duplicateEarlier) kept.push(s); + } + return kept; +} + +/** + * The stated tie-break prose (DEC-19). When same-tier sacrifices were ordered, say + * so — content-neutrally, by commitment id, explicitly a placeholder for + * commander-issued within-tier priority. It NEVER claims one same-tier commitment + * outranks another (that value judgement is the commander's — research note §4). + * Returns undefined when no same-tier ordering occurred (nothing to state). + */ +export function tieBreakText(sets: SacrificeSet[], tierOf: TierOf): string | undefined { + // Group the surviving sets by tier cost; any group with >1 member was id-ordered. + const seen = new Map(); + for (const s of sets) { + const c = tierCost(s, tierOf); + const key = `${c.must},${c.should},${c.prefer}`; + (seen.get(key) ?? seen.set(key, []).get(key)!).push(s); + } + const tiedGroups = [...seen.values()].filter((g) => g.length > 1); + if (tiedGroups.length === 0) return undefined; + const pairs = tiedGroups + .map((g) => g.map((s) => s.join('+')).join(' vs ')) + .join('; '); + return ( + `Same-tier sacrifices were ordered by commitment id (${pairs}) — a stated, ` + + `commander-owned placeholder for within-tier priority, not a claim that one ` + + `sacrifice outranks the other (ASSAY-DEC-19: no numeric weight; ties stated, never silent).` + ); +} diff --git a/tests/relax.test.ts b/tests/relax.test.ts new file mode 100644 index 0000000..75f367b --- /dev/null +++ b/tests/relax.test.ts @@ -0,0 +1,186 @@ +import { readFileSync } from 'node:fs'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { + Commitment, + KnowledgeObject, + Plan, + ScenarioCOA, + VignetteConfig, +} from '../src/generated/types.js'; +import { KnowledgeService } from '../src/knowledge.js'; +import { CompileService } from '../src/compile.js'; +import { ScoreService } from '../src/score.js'; +import { RelaxService } from '../src/relax.js'; +import { relaxCandidates } from '../src/relaxCandidates.js'; +import { validateInstance } from '../src/validate.js'; +import { isRefusal, type RelaxResult } from '../src/seam.js'; +import type { Ref } from '../src/store.js'; + +const load = (name: string): T[] => + JSON.parse(readFileSync(new URL(`../fixtures/${name}.json`, import.meta.url), 'utf8')) as T[]; + +const knowledge = load('knowledge'); +const coas = load('coas'); +const commitments = load('commitments'); +const config = JSON.parse( + readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'), +) as VignetteConfig; + +const byId = new Map(knowledge.map((k) => [k.logical_id, k])); +const K = (id: string): KnowledgeObject => structuredClone(byId.get(id)!); +const answered = (id: string): KnowledgeObject => ({ ...K(id), status: 'answered' }); +const ref = (id: string): Ref => ({ logical_id: id, content_hash: '' }); +const BASE = ['K1', 'K2', 'K3', 'K4', 'K6', 'K7', 'K8', 'K9']; +const ENGINE = '0.1.0'; +const cRefs = commitments.map((c) => ref(c.logical_id)); + +interface Rig { + svc: KnowledgeService; + scorer: ScoreService; + relax: RelaxService; + worldRef: Ref; +} + +async function setup(): Promise { + const svc = new KnowledgeService(); + for (const id of BASE) await svc.create(answered(id)); + await svc.create(K('K12a')); + await svc.create(K('K12b')); + svc.contest('K12a', 'K12b'); + svc.resolve('K12a', 'defector debrief corroborated'); + for (const coa of coas) await svc.store.put(coa as unknown as Record); + for (const c of commitments) await svc.store.put(c as unknown as Record); + + const compiler = new CompileService({ knowledge: svc }); + const compiled = await compiler.compile({ + knowledge: [...BASE, 'K12a'].map(ref), + config, + scenario: 'R3m', + engine_version: ENGINE, + }); + if (isRefusal(compiled)) throw new Error(`R3m compile refused: ${compiled.reason}`); + + const scorer = new ScoreService({ store: svc.store, trace: svc.trace, config, commitments }); + const relax = new RelaxService({ store: svc.store, trace: svc.trace, scorer, commitments }); + return { svc, scorer, relax, worldRef: compiled.world }; +} + +const ok = (r: RelaxResult) => { + if (isRefusal(r)) throw new Error(`expected success, got refusal ${r.reason}`); + return r; +}; + +const setOf = (c: { sacrificed: string[] }): string => [...c.sacrificed].sort().join('+'); + +describe('SPEC-09 — the candidate set (US1)', () => { + it('produces valid Plans with the five force elements, deterministic in seed', () => { + const a = relaxCandidates(1); + const b = relaxCandidates(1); + expect(a).toEqual(b); // deterministic + const FES = ['FE-BROOM', 'FE-PACKHORSE', 'FE-ANVIL', 'FE-FALCON', 'FE-KINGFISHER'].sort(); + for (const p of a) { + expect(validateInstance('Plan', p)).toEqual([]); + expect(p.logical_id).toMatch(/^RX-/); + expect(p.elements.map((e) => e.force_element).sort()).toEqual(FES); + } + }); +}); + +describe('SPEC-09 — /relax over the Meridian R3m world', () => { + let rig: Rig; + beforeEach(async () => { + rig = await setup(); + }); + + it('returns a non-empty report (G4, SC-001)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + expect(r.report.candidates.length).toBeGreaterThan(0); + expect(r.report.world_stamp).toBeTruthy(); + expect(r.report.scenario).toBe('R3m'); + }); + + it('yields three candidates sacrificing {C4}, {C3}, {C2} (US1-2, SC-001) 🎯 exit', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + expect(r.report.candidates).toHaveLength(3); + const sets = r.report.candidates.map(setOf).sort(); + expect(sets).toEqual(['C2', 'C3', 'C4']); + }); + + it('each candidate has non-empty sacrificed, computed by the scorer (US1-3, SC-004)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + for (const cand of r.report.candidates) { + expect(cand.sacrificed.length).toBeGreaterThan(0); // G4 + // Re-score the plan: exactly its sacrificed commitments are `violated`. + const planRef = rig.svc.store.versions(cand.plan).at(-1)!; + const scored = await rig.scorer.score({ plan: planRef, world: rig.worldRef, scenario: 'R3m', engine_version: ENGINE }); + if (isRefusal(scored)) throw new Error('re-score refused'); + const violated = scored.verdicts.filter((v) => v.verdict === 'violated').map((v) => v.commitment).sort(); + expect(violated).toEqual([...cand.sacrificed].sort()); + } + }); + + it('excludes strict-superset sacrifices — {C2,C4} and {C3,C4} never appear (US2, SC-002)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + const sets = r.report.candidates.map(setOf); + expect(sets).not.toContain('C2+C4'); + expect(sets).not.toContain('C3+C4'); + }); + + it('ranks least-worst first: the C2 must-sacrifice is last, but present (US3, SC-003)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + const order = r.report.candidates.map(setOf); + expect(order).toContain('C2'); // the must-sacrifice is returned (never dropped) + expect(order.indexOf('C2')).toBe(order.length - 1); // …ranked last (least-worst first) + // The two should-sacrifices (C3, C4) precede it. + expect(order.indexOf('C3')).toBeLessThan(order.indexOf('C2')); + expect(order.indexOf('C4')).toBeLessThan(order.indexOf('C2')); + }); + + it('states the same-tier tie-break (C3/C4), never silently (US3, SC-003)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + expect(r.report.tie_break).toBeTruthy(); + expect(r.report.tie_break).toMatch(/commitment id/i); + expect(r.report.tie_break).toMatch(/DEC-19/); + }); + + it('every candidate narrative is command language — no decimal, no verdict token (US4, G2)', async () => { + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + for (const cand of r.report.candidates) { + expect(cand.narrative.length).toBeGreaterThan(0); + expect(cand.narrative).not.toMatch(/\d+\.\d+/); // no decimals + expect(cand.narrative).not.toMatch(/margin|m_lo|m_hi/i); // no verdict-internal tokens + } + }); + + it('writes cited_in and sacrificed_in edges to the report (US4, G3, SC-005)', async () => { + const before = rig.svc.trace.edges.length; + const r = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + const reportHash = (await rig.svc.store.put(r.report as unknown as Record)).content_hash; + const cited = rig.svc.trace.edges.filter((e) => e.edge_type === 'cited_in' && e.to_hash === reportHash); + const sacrificedIn = rig.svc.trace.edges.filter((e) => e.edge_type === 'sacrificed_in' && e.to_hash === reportHash); + expect(cited.length).toBeGreaterThan(0); + expect(sacrificedIn.length).toBeGreaterThan(0); + expect(rig.svc.trace.edges.length).toBeGreaterThan(before); + }); + + it('is deterministic: same (world, commitments, seed) ⇒ identical stamp & report (US5, SC-006)', async () => { + const a = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + const b = ok(await rig.relax.relax({ world: rig.worldRef, commitments: cRefs, seed: 1, engine_version: ENGINE })); + expect(a.stamp).toBe(b.stamp); + expect(a.report.candidates).toEqual(b.report.candidates); + expect(a.report.tie_break).toEqual(b.report.tie_break); + }); + + it('refuses unknown_ref for an unresolvable world, persisting nothing (FR-012)', async () => { + const before = rig.svc.store.size; + const r = await rig.relax.relax({ + world: { logical_id: 'NO-SUCH-WORLD', content_hash: '' }, + commitments: cRefs, + seed: 1, + engine_version: ENGINE, + }); + expect(isRefusal(r)).toBe(true); + if (isRefusal(r)) expect(r.reason).toBe('unknown_ref'); + expect(rig.svc.store.size).toBe(before); + }); +}); diff --git a/tests/tiers.test.ts b/tests/tiers.test.ts new file mode 100644 index 0000000..2eb8894 --- /dev/null +++ b/tests/tiers.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { CommitmentTier } from '../src/generated/types.js'; +import { + canonicalSet, + compareTierCost, + inclusionMinimal, + tierCost, + tieBreakText, +} from '../src/tiers.js'; + +// Meridian tiers (DEC-19): C1/C2 must, C3/C4 should, C5/C6 prefer. +const TIER: Record = { + C1: 'must', C2: 'must', C3: 'should', C4: 'should', C5: 'prefer', C6: 'prefer', +}; +const tierOf = (id: string): CommitmentTier => TIER[id]!; + +describe('SPEC-09 — tier / minimality algebra (US2, US3)', () => { + describe('canonicalSet', () => { + it('sorts and de-duplicates', () => { + expect(canonicalSet(['C4', 'C2', 'C4'])).toEqual(['C2', 'C4']); + }); + }); + + describe('tierCost + compareTierCost — least-worst first, no weights', () => { + it('counts tiers without summing them into a scalar', () => { + expect(tierCost(['C2', 'C3'], tierOf)).toEqual({ must: 1, should: 1, prefer: 0 }); + }); + + it('a should-sacrifice is less-bad than a must-sacrifice', () => { + const should = tierCost(['C4'], tierOf); // 0 must, 1 should + const must = tierCost(['C2'], tierOf); // 1 must, 0 should + expect(compareTierCost(should, must)).toBeLessThan(0); // should ranks first (least-worst) + }); + + it('fewer musts always wins, regardless of how many lower-tier sacrifices', () => { + const oneMust = tierCost(['C2'], tierOf); // 1 must + const manyShoulds = tierCost(['C3', 'C4'], tierOf); // 0 must, 2 should + // Lexicographic: 0 musts beats 1 must even with more shoulds — no weighted sum. + expect(compareTierCost(manyShoulds, oneMust)).toBeLessThan(0); + }); + + it('same tier profile compares equal (a genuine tie)', () => { + expect(compareTierCost(tierCost(['C3'], tierOf), tierCost(['C4'], tierOf))).toBe(0); + }); + }); + + describe('inclusionMinimal — drop strict supersets, keep incomparable, collapse dupes', () => { + it('drops a strict superset, keeps the singletons', () => { + const kept = inclusionMinimal([['C4'], ['C3'], ['C2'], ['C2', 'C4'], ['C3', 'C4']]); + const asKeys = kept.map((s) => s.join('+')).sort(); + expect(asKeys).toEqual(['C2', 'C3', 'C4']); // the two supersets dropped + }); + + it('keeps incomparable sets (different singletons) both', () => { + const kept = inclusionMinimal([['C3'], ['C4']]); + expect(kept).toHaveLength(2); + }); + + it('collapses duplicate sacrifice sets to one representative', () => { + const kept = inclusionMinimal([['C4'], ['C4'], ['C2']]); + expect(kept.map((s) => s.join('+')).sort()).toEqual(['C2', 'C4']); + }); + + it('keeps two overlapping sets when neither is a subset of the other', () => { + const kept = inclusionMinimal([['C2', 'C3'], ['C3', 'C4']]); + expect(kept).toHaveLength(2); // share C3 but neither contains the other + }); + + it('never empties a non-empty input (G4 — never silence)', () => { + expect(inclusionMinimal([['C2']]).length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('tieBreakText — stated, content-neutral, only when a same-tier tie occurred', () => { + it('is present when two same-tier sacrifices are ordered', () => { + const text = tieBreakText([['C3'], ['C4'], ['C2']], tierOf); + expect(text).toBeTruthy(); + expect(text).toMatch(/commitment id/i); + expect(text).toMatch(/DEC-19/); + }); + + it('names no value ranking between the same-tier commitments', () => { + const text = tieBreakText([['C3'], ['C4']], tierOf)!; + expect(text).toMatch(/not a claim that one/i); // explicitly declines to rank them + }); + + it('is absent when no same-tier ordering occurred', () => { + // Distinct tier profiles → no tie to break. + expect(tieBreakText([['C4'], ['C2']], tierOf)).toBeUndefined(); + }); + }); +});