diff --git a/CLAUDE.md b/CLAUDE.md index 5af3b55..c0a15a8 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). **Next: SPEC-08 — handful generator + banded non-dominated organisation (sacrificial scope, delivery plan §3), over the same `03-score-plan.md` note; then Stage 4 SPEC-09 relaxation.** +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.** diff --git a/docs/assay-delivery-plan.md b/docs/assay-delivery-plan.md index 843a2d4..a92a4e3 100644 --- a/docs/assay-delivery-plan.md +++ b/docs/assay-delivery-plan.md @@ -63,6 +63,8 @@ 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.** + ## 3. Dependency graph & critical path ```mermaid diff --git a/docs/assets/gallery/index.html b/docs/assets/gallery/index.html index 03a4c35..11455b8 100644 --- a/docs/assets/gallery/index.html +++ b/docs/assets/gallery/index.html @@ -526,17 +526,48 @@

Compile — knowledge → CompiledWor K12a assessed · moderateassessment, not factowner: J-2 red cell -

Score — the honest matrix (SPEC-07)

-

The Stage-3 demo moment, run by the actual scorer over the canned Meridian handful against the base world. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never as a headline number (G2). Each commitment's verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — and is 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. score stamp c5e11b4a027f1053… · every verdict carries a scored_from edge to the world (G3) · margins on hover, no decimals (G2)

+

Score & organise — the honest matrix (SPEC-07 · SPEC-08)

+

The Stage-3 demo moment, run end-to-end by the actual machinery: /plan/handful (SPEC-08) fans out over Meridian's four axes — approach, suppression, causeway, extraction — scores every candidate with the SPEC-07 scorer, and organises by banded non-domination into the genuinely distinct plans below. No author picked the set: a plan is kept only when no other conservatively beats it on every commitment (overlapping bands are honestly incomparable and both survive), and dropped only when strictly beaten everywhere — never on a scalar total (DEC-19). Each plan's reason is derived from the organiser, not captioned:

+
+

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. handful stamp 99ca45289c4582da… · 4 genuinely distinct plans, organised not authored · same stamp + seed ⇒ identical handful (G1) · every verdict carries a scored_from edge to the world (G3)

-
plan \ commitment C1C2C3C4C5C6
P1 · Strait-early / battery-suppression - robust +
P-SwSoCcXe · sweep-first · stand-off · contest · pull-early + robust - robust + robust marginal @@ -544,11 +575,35 @@

Score — the honest matrix (SPEC-07)

marginal - robust -
P2 · Sweep-first / fires-forward + robust +
P-SeFfCcXe · strait-early · fires-forward · contest · pull-early robust + robust + + violated + + robust + + marginal + + robust +
P-SeSoCcXe · strait-early · stand-off · contest · pull-early robust + + robust + + marginal + + tight + + marginal + + robust +
P-SwFfCcXe · sweep-first · fires-forward · contest · pull-early + robust + + robust violated @@ -556,7 +611,7 @@

Score — the honest matrix (SPEC-07)

marginal - robust + 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

diff --git a/scripts/build-gallery.ts b/scripts/build-gallery.ts index 1062baa..7d88d67 100644 --- a/scripts/build-gallery.ts +++ b/scripts/build-gallery.ts @@ -17,11 +17,13 @@ import { s1Table, type S1Row } from '../src/components/s1Table.js'; 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 { 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 { isRefusal } from '../src/seam.js'; import type { Commitment, @@ -41,9 +43,6 @@ const coas = JSON.parse( const commitments = JSON.parse( readFileSync(new URL('../fixtures/commitments.json', import.meta.url), 'utf8'), ) as Commitment[]; -const plans = JSON.parse( - readFileSync(new URL('../fixtures/plans.json', import.meta.url), 'utf8'), -) as Plan[]; const vignetteConfig = JSON.parse( readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'), ) as VignetteConfig; @@ -119,33 +118,38 @@ const stampLine = ? `stamp ${compiled.stamp.slice(0, 16)}… · ${world.consumed.length} knowledge objects consumed · sparse channels (no dense cell stored)` : ''; -// SPEC-07 — the Stage-3 demo moment, "the honest matrix", run by the ACTUAL -// scorer over the canned Meridian handful (P1/P2) against the resolved base -// world. Four-stop chips, margin bands on hover, no decimals; every verdict was -// written with a `scored_from` edge back to the world it rests on (G3). +// SPEC-07/08 — the Stage-3 demo moment, "the honest matrix", now run over the +// GENERATED handful (SPEC-08) rather than a hand-authored one. /plan/handful +// fans out over Meridian's four axes, scores each candidate with the ACTUAL +// scorer, and organises by banded non-domination into 3–5 genuinely distinct +// plans — no author picked the set. The S2 matrix renders the survivors as +// four-stop chips (margins on hover, no decimals); the handful strip shows the +// organiser's derived reason each plan is in the set. for (const c of commitments) await svc.store.put(c as unknown as Record); -const planRefs = new Map(); -for (const p of plans) planRefs.set(p.logical_id, await svc.store.put(p as unknown as Record)); const scorer = new ScoreService({ store: svc.store, trace: svc.trace, config: vignetteConfig, commitments }); +const handfulSvc = new HandfulService({ store: svc.store, scorer, config: vignetteConfig, commitments }); const matrixRows: S2Cell[] = []; -let scoreStamp = ''; +const stripRows: HandfulStripRow[] = []; +let handfulStamp = ''; if (!isRefusal(compiled)) { - for (const p of plans) { - const scored = await scorer.score({ - plan: planRefs.get(p.logical_id)!, - world: compiled.world, - scenario: 'BASE', - engine_version: '0.1.0', + const h = await handfulSvc.handful({ world: compiled.world, seed: 1, engine_version: '0.1.0' }); + if (!isRefusal(h)) { + handfulStamp = h.stamp; + h.plans.forEach((planRef, i) => { + const plan = svc.store.get(planRef.content_hash) as Plan; + stripRows.push({ plan: planRef, name: plan.name, distinct_because: h.organisation.distinct_because[i]! }); }); - if (!isRefusal(scored)) { - matrixRows.push({ plan: `${p.logical_id} · ${p.name}`, verdicts: scored.verdicts }); - scoreStamp = scored.stamp; + for (const planRef of h.plans) { + const scored = await scorer.score({ plan: planRef, world: compiled.world, scenario: 'BASE', engine_version: '0.1.0' }); + const plan = svc.store.get(planRef.content_hash) as Plan; + if (!isRefusal(scored)) matrixRows.push({ plan: `${plan.logical_id} · ${plan.name}`, verdicts: scored.verdicts }); } } } const s2 = matrixRows.length > 0 ? s2Matrix(['C1', 'C2', 'C3', 'C4', 'C5', 'C6'], matrixRows) : ''; -const scoreStampLine = scoreStamp - ? `score stamp ${scoreStamp.slice(0, 16)}… · every verdict carries a scored_from edge to the world (G3) · margins on hover, no decimals (G2)` +const strip = stripRows.length > 0 ? handfulStrip(stripRows) : ''; +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)` : ''; const html = ` @@ -170,8 +174,10 @@ ${rows}
${compileRefusalHtml}

Resolve the contest, and the recompile builds the world. Channels are stored sparse — a per-channel default plus named region overrides (the MCOO idiom; no dense 1.2M-cell surface is ever stored or hashed). Every channel value walks back to the named knowledge it came from, with its owner (G3). ${stampLine}

${channelHtml}
-

Score — the honest matrix (SPEC-07)

-

The Stage-3 demo moment, run by the actual scorer over the canned Meridian handful against the base world. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never as a headline number (G2). Each commitment's verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — and is 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}

+

Score & organise — the honest matrix (SPEC-07 · SPEC-08)

+

The Stage-3 demo moment, run end-to-end by the actual machinery: /plan/handful (SPEC-08) fans out over Meridian's four axes — approach, suppression, causeway, extraction — scores every candidate with the SPEC-07 scorer, and organises by banded non-domination into the genuinely distinct plans below. No author picked the set: a plan is kept only when no other conservatively beats it on every commitment (overlapping bands are honestly incomparable and both survive), and dropped only when strictly beaten everywhere — never on a scalar total (DEC-19). Each plan's reason is derived from the organiser, not captioned:

+
${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

diff --git a/specs/008-handful/checklists/requirements.md b/specs/008-handful/checklists/requirements.md new file mode 100644 index 0000000..52a1553 --- /dev/null +++ b/specs/008-handful/checklists/requirements.md @@ -0,0 +1,37 @@ +# Requirements checklist — SPEC-08 (Handful) + +Traceability from spec requirements to acceptance. Every box is a committed test or a rendered surface. + +## The exit (build plan Stage 3 · the reason SPEC-08 exists) + +- [x] `/plan/handful` over the Meridian base world returns **3–5 plans**, each resolving to a stored `Plan` (FR-001/002, SC-001) — `tests/handful.test.ts` +- [x] Every pair in the handful is mutually **non-dominated** on the C1–C6 margin vector (FR-004, SC-001) +- [x] The handful shows more than one distinct verdict on at least two commitments — genuinely different profiles (US1-3) +- [x] **Same (world, seed) ⇒ identical** stamp, plans, scores, organisation (FR-008, SC-002) +- [x] A different seed changes the stamp (seed participates in the stamp) (FR-008, SC-002) + +## The organiser (the honesty core) + +- [x] Drops a strictly-worse-everywhere plan; keeps a genuine trade-off; irreflexive + asymmetric (FR-004/005, SC-003) — `tests/dominance.test.ts` +- [x] `violated` (no margin) treated as the conservatively worst value on its criterion (FR-006) +- [x] Monotone in ε — increasing ε never shrinks the non-dominated set (FR-004, SC-003) +- [x] No scalar total / weighted sum / midpoint anywhere in the organiser (FR-005, DEC-19/15) +- [x] The diversity cap trims by axis spread (seed-tie-broken), never by a scalar; below `count` returns all, no padding (FR-007) — `count=3` cap test + +## Generation (honest fan-out) + +- [x] 16 candidates, each a valid `Plan` with all five force elements (FR-002, SC-004) — `tests/generate.test.ts` +- [x] The candidate set spans both settings of all four axes (US4-2) +- [x] Deterministic in `(config, seed)` — same seed ⇒ identical candidate list (FR-002, SC-004) + +## Trace, surface & discipline + +- [x] Every handful member opens a complete trace chain verdict → world → knowledge (G3, SC-005) +- [x] `distinct_because` is derived and non-empty per plan, aligned to `plans` (FR-010, SC-006) +- [x] The gallery renders the generated handful: S2 four-stop chips (margin-on-hover, no decimals) + the handful strip (SC-006) +- [x] `unknown_ref` refuses first-class when the world cannot be resolved; persists nothing (FR-011) +- [x] No `knowledge_overrides` on the handful; no `/relax` call (FR-012) + +## Gate + +- [x] `npm run typecheck` clean; `npm test` green (SPEC-07 baseline + SPEC-08 suite); no schema regen (SC-007) diff --git a/specs/008-handful/contracts/handful-service.md b/specs/008-handful/contracts/handful-service.md new file mode 100644 index 0000000..67cff0c --- /dev/null +++ b/specs/008-handful/contracts/handful-service.md @@ -0,0 +1,42 @@ +# Contract — Handful service (`POST /plan/handful`, seam §6) + +Generates a strategy-biased fan-out (seeded), scores via §5, and organises by banded non-domination into 3–5 genuinely distinct plans. Sacrificial scope (delivery plan §3): a canned handful satisfying this contract keeps every downstream consumer honest — but SPEC-08 implements the honest generator. All declines are first-class `Refusal`s (seam §1). + +## Request + +``` +POST /plan/handful +{ + world: Ref, // a CompiledWorld to plan against; its stamp seeds determinism + seed: number, // orders the enumeration and breaks diversity-cap ties + count?: number, // clamped to [3,5]; default 5 + engine_version: string +} +``` + +Scenario is derived from the world (`world.scenario ?? BASE`) — the world already carries any excursion (DEC-10); the handful is scenario-blind like the scorer. + +## Response + +``` +→ { plans: Ref[], scores: PlanScore[], organisation: { distinct_because: string[] }, stamp } // success +| Refusal { refused: true, reason, offending: Ref[], explanation } // decline +``` + +## Behavioural contract + +1. **Generate** — a seeded fan-out over the four Meridian axes (research note §5.2): approach, suppression, causeway, extraction. 2⁴ = 16 candidate `Plan`s, each a valid DEC-20 plan with routes for the five force elements. Deterministic in `(config, seed)`. +2. **Score** — each candidate through the SPEC-07 `ScoreService` (reused). The criteria vector is the per-commitment **margin band** (satisfied ⟺ margin ≥ 0 — one uniform maximisation order). +3. **Organise** — banded ε-non-domination on the conservative interval order (research note §5): keep a plan unless another is no worse on every commitment and strictly separated-above (by ε) on at least one. `ε` defaults to 0 (scale-free); monotone — a larger ε never shrinks the set. No scalar total, weighted sum, or midpoint decides membership (DEC-19, DEC-15). +4. **Cap** — when the frontier exceeds `count`, trim by deterministic axis-diversity selection (seed-tie-broken), never by a scalar. When below `count`, return all (no padding). +5. **`distinct_because`** — one derived reason per returned plan (aligned to `plans`): the commitments it leads on + its axis signature. Never a hand-authored caption. +6. **Trace** — every returned plan is stored and content-addressed; its verdicts carry the scorer's `scored_from` edges, so every handful member opens a complete backward chain to named knowledge (G3). +7. **Determinism** — `stamp = hash(world.stamp, seed, count, engine_version)`; same inputs ⇒ byte-identical stamp and identical ordered plans/scores/organisation (G1). Never hashes materialised cells. +8. **Scope** — runs no `knowledge_overrides` perturbation (stays scorer-only, seam §6 open question 3) and calls no `/relax` (the commitment set stays whole — relaxation is SPEC-09). + +## 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/008-handful/data-model.md b/specs/008-handful/data-model.md new file mode 100644 index 0000000..05e426a --- /dev/null +++ b/specs/008-handful/data-model.md @@ -0,0 +1,37 @@ +# Phase 1 — Data Model (SPEC-08) + +Domain entities are generated from LinkML (`src/generated/types.ts`, slice D2) and must not be hand-edited. **SPEC-08 changes no schema** — every stored shape it needs already exists (D2, SPEC-06, SPEC-07). This file maps the spec's entities to the existing LinkML shapes and defines the handful's service types (movement, not stored objects). + +## Existing LinkML types reused (source of truth — do not hand-edit) + +| Entity | Shape | Notes | +|---|---|---| +| `Plan` / `ElementPlan` / `RouteLeg` | timed routes for the five force elements (DEC-20) | the generated candidates; stored & content-addressed | +| `PlanScore` | `{plan, scenario, world_stamp, criterion, score, engine_version}` | banded criteria; returned in `scores` | +| `CommitmentVerdict` | `{plan, commitment, scenario, world_stamp, verdict, margin?, engine_version}` | `margin` is the oriented criterion the organiser reads | +| `Band` | `{lo, hi, unit}` | pure closed interval (DEC-15); compared by the conservative interval order | +| `CompiledWorld` | sparse channels + `stamp` + `scenario?` | the world the handful is planned against; `stamp` seeds determinism | +| `TraceEdge` (`scored_from`) | written by the scorer per verdict → world | every handful member opens a backward chain (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 | +|---|---|---| +| `HandfulRequest` | `{world: Ref, seed: number, count?: number, engine_version: string}` | `count` clamped to `[3,5]` (default 5); scenario is derived from the world (seam §6 request shape) | +| `HandfulOrganisation` | `{distinct_because: string[]}` | derived reasons, index-aligned to `plans` | +| `HandfulSuccess` | `{plans: Ref[], scores: PlanScore[], organisation, stamp}` | the seam §6 return shape | +| `HandfulResult` | `HandfulSuccess \| Refusal` | `unknown_ref` is the only handful-specific refusal reason (already in `RefusalReason`) | + +## Internal shapes (not exported at the seam) + +| Type | Shape | Notes | +|---|---|---| +| `AxisSignature` | `{approach, suppression, causeway, extraction}` | `src/generate.ts`: the four Meridian axes (research note §5.2); drives generation + the diversity cap | +| `Criterion` / `CriteriaVector` | `Band \| null` / `Criterion[]` | `src/dominance.ts`: a plan's per-commitment margin vector; `null` = `violated` (worst) | +| `Candidate` | `{sig, planRef, vector, scores}` | `src/handful.ts`: a scored candidate before organisation | + +## Determinism + +`stamp = SHA-256(canonicalJson({world: world.stamp, seed, count, engine_version}))` — over inputs, never over materialised cells (seam §1). The generator is a pure function of `(config, seed)`; the organiser and diversity cap are pure functions of the scored vectors + seed. Same inputs ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation` (G1). diff --git a/specs/008-handful/plan.md b/specs/008-handful/plan.md new file mode 100644 index 0000000..956180b --- /dev/null +++ b/specs/008-handful/plan.md @@ -0,0 +1,57 @@ +# Implementation Plan: Handful — generate → score → organise (SPEC-08) + +**Branch**: `claude/next-spec-tkiz9e` · **Spec dir**: `specs/008-handful/` · **Date**: 2026-07-13 +**Spec**: `specs/008-handful/spec.md` · **Research note (DEC-11 gate)**: `docs/research/03-score-plan.md` §5 (present) + +## Summary + +Build the handful unit (delivery plan §δ, the last of the spine): `/plan/handful` = generate → score → organise. A seeded, strategy-biased **generator** fans out over the vignette's four axes into candidate `Plan`s; the SPEC-07 **scorer** (reused, not re-implemented) turns each into a C1–C6 margin vector; a banded ε-non-domination **organiser** reduces them to 3–5 genuinely distinct plans, capped by seed-driven axis diversity. Deterministic over `(world stamp, seed, count, engine)`; every member stored and traced. No schema change — `Plan`, `PlanScore`, `CommitmentVerdict.margin`, `TraceEdge` all exist. The demo moment ("the honest matrix") now renders the *generated* handful plus a `distinct_because` strip, replacing the canned P1/P2. + +## 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 generator is deterministic fan-out (no PRNG library; the seed drives ordering/tie-breaks over a fixed enumeration). +**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/components/s2Matrix.ts` (reused unchanged), `src/interval.ts` (band helpers for the organiser). The margin vector comes straight off the scorer's `CommitmentVerdict.margin`. +**New source**: `src/dominance.ts` (banded ε-non-domination + diversity cap), `src/generate.ts` (seeded axis fan-out → candidate Plans), `src/handful.ts` (the `HandfulService`), `src/components/handfulStrip.ts` (the `distinct_because` view). New seam types in `src/seam.ts`. No new fixtures — candidates are generated, not authored; `fixtures/plans.json` (the canned handful) stays as a documented fallback and for the SPEC-07 suite. +**Constraints**: banded honesty at the seam (G2); no midpoint/scalar-total/weighted-sum anywhere in the organiser (DEC-15, DEC-19); determinism over inputs, never over cells (G1); first-class refusals (seam §1); frozen identifiers — generated plans stay in the `P*` family, clearly marked generator scaffolding (vignette §8). + +## Constitution Check + +- **Register-first (DEC-2)**: SPEC-08 introduces no new decision. The distinctness rule (banded ε-non-domination) and the four generation axes are *decided in the research note* (`03-score-plan.md` §5), under the concept §6.2 open question that the note explicitly closes for this domain. No peer document asserts a new decision; the sparse-channel candidate (concept §6 item 12) remains the only open one and is unaffected. +- **Research-first (DEC-11)**: `docs/research/03-score-plan.md` §5 present before implementation — gate satisfied (the same note that gated SPEC-07 reserved §5 for SPEC-08). +- **Banded honesty (G2)**: the organiser reads margin *bands* and compares them by the conservative interval order — it never collapses a band to a point. Verdicts still cross the seam only as the four-stop scale; `distinct_because` names commitments and axes, never a decimal. +- **Frozen identifiers**: C1–C6, R1/R2/R3, FE-* rendered as-is; generated plans use `P*` ids encoding their axis signature, clearly marked demonstrator scaffolding (as the canned P1/P2 were). +- **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/03-score-plan.md # DEC-11 gate (done — §5 is SPEC-08's) +specs/008-handful/{spec,plan,tasks,data-model,research,quickstart}.md +specs/008-handful/contracts/handful-service.md +specs/008-handful/checklists/requirements.md +src/dominance.ts # conservative interval order; εDominates; nonDominated; diversityCap +src/generate.ts # AxisSignature; generateCandidates(config, seed) -> Plan[] +src/handful.ts # HandfulService: /plan/handful (generate -> score -> organise) +src/components/handfulStrip.ts +tests/{dominance,generate,handful}.test.ts +``` + +## Design decisions (from the research note §5) + +1. **Distinctness = banded ε-non-domination on the conservative interval order** (note §5). Maximisation on the scorer's margin bands (satisfied ⟺ margin ≥ 0 gives uniform "higher is better" across all comparators). `A ε-dominates B` iff `∀i: B.lo_i ≤ A.hi_i` (A no worse) and `∃j: A.lo_j ≥ B.hi_j + ε` (A strictly separated-above). `ε` defaults to 0 — scale-free, since criteria have incommensurate units and no absolute ε is honest. Two plans with overlapping bands are incomparable and both survive. This is DEC-15 in the organiser: overlap = honestly-indistinguishable = keep both. +2. **The margin vector, not the raw metric, is the criteria vector.** The scorer already orients each comparator to a signed margin (`at_most`/`at_least`/`never`/`by_step` → margin ≥ 0 iff satisfied). Reading margins gives one uniform maximisation order and needs no per-criterion orientation table. A `violated` verdict (no margin) maps to the conservatively-worst sentinel on that criterion. +3. **Generation is deterministic fan-out over four binary axes** (note §5.2), 2⁴ = 16 candidates via documented route templates; the seed orders the enumeration and breaks ties in the diversity cap — no PRNG, so no hidden non-determinism. Route geometry is fixture-authoring latitude *within* the constraints (vignette §8): the axis→route mapping is documented in `generate.ts`. +4. **The cap is diversity, never score** (note §5, spec FR-007): when > `count` plans are non-dominated, greedily select for maximal axis-signature spread, seed as tie-break. Capping by any scalar would smuggle a weighting past DEC-19. +5. **ε stays the organiser's knob, out of verdicts** (note §3/§5): SPEC-07 kept ε out of the four-stop scale; SPEC-08 is where ε lives, and it defaults to 0 so the honest baseline is pure conservative non-domination. + +## Complexity Tracking + +Scope is bounded to exactly the contract §6 movement: generate (fixed 16-candidate fan-out), score (reuse SPEC-07), organise (non-domination + diversity cap). No optimiser, no PRNG dependency, no `/relax` awareness (SPEC-09), no scenario strip (SPEC-10), no `knowledge_overrides` on the handful (seam §6 open question 3 — stays scorer-only). The only new surface is `handfulStrip`; the S2 matrix is reused unchanged. + +## Phase Gates + +- Research complete (DEC-11): ✅ `03-score-plan.md` §5. +- Spec approved: this slice. +- Tests-first: the organiser's non-domination laws (constructed vectors) and the determinism assertions are written and confirmed failing before `src/handful.ts` behaviour (constitution quality gate 2). +- Green: typecheck + full suite; the Meridian base handful is 3–5 distinct, computed not hand-set; no schema regen in this slice. diff --git a/specs/008-handful/quickstart.md b/specs/008-handful/quickstart.md new file mode 100644 index 0000000..86b9b03 --- /dev/null +++ b/specs/008-handful/quickstart.md @@ -0,0 +1,45 @@ +# Quickstart — SPEC-08 handful + +## What this slice adds + +`POST /plan/handful` — the last unit of the spine. Generate a strategy-biased fan-out over Meridian's four axes, score each candidate with the SPEC-07 scorer, and organise by banded non-domination into 3–5 genuinely distinct plans. + +## Run it + +``` +npm install +npm run typecheck # clean — no schema change, no gen +npm test # vitest — includes dominance / generate / handful suites +npm run gallery # regenerates the honest-matrix demo over the GENERATED handful +``` + +## The three steps (src) + +| File | Role | +|---|---| +| `src/generate.ts` | seeded fan-out over the four axes → 16 candidate `Plan`s (`generateCandidates(config, seed)`) | +| `src/dominance.ts` | banded ε-non-domination on the conservative interval order (`nonDominated`, `dominates`, `distinct`) | +| `src/handful.ts` | `HandfulService.handful(req)` — generate → score (reuse `ScoreService`) → organise → cap; deterministic stamp | +| `src/components/handfulStrip.ts` | renders the derived `distinct_because` per plan above the S2 matrix | + +## Minimal use + +```ts +const scorer = new ScoreService({ store, trace, config, commitments }); +const handfuls = new HandfulService({ store, scorer, config, commitments }); +const h = await handfuls.handful({ world: baseWorldRef, seed: 1, engine_version: '0.1.0' }); +// h.plans — 3–5 Refs, each a stored Plan +// h.scores — banded PlanScores for the survivors +// h.organisation.distinct_because — one derived reason per plan +// h.stamp — same (world stamp, seed, count, engine) ⇒ identical handful +``` + +## The exit (build plan Stage 3) + +- `/plan/handful` returns **3–5 genuinely distinct plans** for the Meridian baseline (every pair mutually non-dominated). +- **Same stamp + seed ⇒ identical handful** (G1). +- Every verdict opens a complete backward trace chain (G3), reused from the scorer. + +## Demo moment + +*The honest matrix, organised not authored:* the S2 matrix now renders the generated handful, and the strip above it names — from the organiser, not a caption — why each plan is one of the few. On Meridian the survivors split cleanly along the two trade axes: approach trades C1 (port) against C2 (strait); suppression trades C3 (civil harm) against C4 (threat). diff --git a/specs/008-handful/research.md b/specs/008-handful/research.md new file mode 100644 index 0000000..c5fbda4 --- /dev/null +++ b/specs/008-handful/research.md @@ -0,0 +1,21 @@ +# Phase 0 — Research (SPEC-08) + +**DEC-11 gate**: `docs/research/03-score-plan.md` §5 — present before implementation. The Stage-3 note reserved §5 for SPEC-08 when it gated SPEC-07; no separate note is authored (the handful strategy folds into the score-plan note, as the note itself states). + +## Decisions taken from the note (not re-derived here) + +1. **Distinctness = banded ε-non-domination** (note §5). Two plans are distinct in banded space when neither ε-dominates the other across the criteria vector of banded scores, using the conservative interval order (`A ⪯ B` iff `A.hi ≤ B.lo`). Overlapping bands are honestly incomparable and both survive. +2. **ε is the organiser's knob, not the verdict's** (note §3/§5). SPEC-07 kept ε out of the four-stop scale; SPEC-08 is where it lives. It defaults to 0 — scale-free, since the criteria carry incommensurate units (steps, band-hours, boolean, district cells) and no absolute ε is honest. +3. **The four generation axes are Meridian's own** (note §5.2): approach (strait-early vs sweep-first), suppression posture (fires-forward vs stand-off), causeway (contest vs bypass), extraction coupling (pull-out early vs mission-tail). REMIT's time/exposure/robustness/completeness axes do not transfer unexamined (concept §6.2) — the note closes that open question for this domain. +4. **The generator is sacrificial scope** (delivery plan §3). A canned handful over the honest scorer already demonstrates four theses; the honest generator turns "3–5 genuinely distinct plans" into a computed exit rather than a hand-set one. This is why the slice is late on the spine and low-risk. + +## Choices this slice makes within that latitude + +- **Criteria vector = the scorer's margin bands**, not the raw metric scores. SPEC-07 already orients every comparator to a signed margin (satisfied ⟺ margin ≥ 0), giving one uniform "higher is better" order and removing the need for a per-criterion orientation table. A `violated` verdict (no margin) is the conservatively worst value on its criterion. +- **The domination gate is strict-above** (`A.lo > B.hi + ε`) rather than touching (`≥`), so equal/overlapping bands never count as "strictly better" — this is what makes the relation irreflexive (no plan dominates itself) and keeps DEC-15's "no honest interior point" honest. +- **The diversity cap is by axis Hamming spread, seed-tie-broken** — never by a scalar score, which would smuggle a weighting past DEC-19. +- **No PRNG.** The generator is a fixed 2⁴ enumeration; the seed only orders output and breaks cap ties (a deterministic FNV-1a key). This keeps "same stamp + seed ⇒ identical handful" true by construction, with no hidden non-determinism. + +## Sources + +As `03-score-plan.md` §5: Laumanns et al. (2002) ε-dominance; Deb et al. (2002) NSGA-II diversity; JP 5-0 COA comparison as commander's judgement (why the cap is not a scalar). ASSAY register DEC-4/10/15/19/20; seam contract §6; vignette §6 (C1–C6, force elements)/§8 (frozen identifiers); concept §6.2. diff --git a/specs/008-handful/spec.md b/specs/008-handful/spec.md new file mode 100644 index 0000000..b4a9912 --- /dev/null +++ b/specs/008-handful/spec.md @@ -0,0 +1,148 @@ +# Feature Specification: Handful — generate → score → organise into a banded non-dominated handful (SPEC-08) + +**Feature Branch**: `claude/next-spec-tkiz9e` (spec dir `008-handful`) + +**Created**: 2026-07-13 + +**Status**: Draft + +**Stage**: Build-plan Stage 3 · **Depends on**: SPEC-07 (scorer — `ScoreService`, `CommitmentVerdict.margin`, deterministic stamp), SPEC-06 (compile — CompiledWorld, VignetteConfig geometry), D3 (seam contract §6) · **Research note**: `docs/research/03-score-plan.md` §5 (DEC-11 gate — present; defines banded ε-non-domination and Meridian's four generation axes) + +**Input**: `/plan/handful` (`{world, seed, count?} → {plans: Ref[], scores: PlanScore[], organisation: {distinct_because}, stamp}`) — the last unit of the spine (delivery plan §δ). It **generates** candidate plans by a seeded, strategy-biased fan-out over the vignette's four domain axes, **scores** each via the SPEC-07 scorer, and **organises** them by banded ε-non-domination into 3–5 genuinely distinct plans. It is *sacrificial scope* (delivery plan §3): the generator can slip to the gate because a canned handful over the honest scorer already demonstrates four theses — but the honest generator is what makes "3–5 genuinely distinct plans for the Meridian baseline" a computed exit rather than a hand-set one. + +## Honesty stance *(the point of this slice)* + +The handful is where the demonstrator could most easily cheat: pick a flattering three, call them "the options", and bury the trade. SPEC-08 refuses that by construction. + +1. **The organiser never invents precision.** Distinctness is banded ε-non-domination on the scorer's **margin bands** — the conservative interval order (`A ⪯ B` iff `A.hi ≤ B.lo`, research note §5). Two plans whose criteria bands *overlap* are honestly incomparable and both survive; a plan is dropped only when another is conservatively at-or-above it on **every** commitment and strictly separated-above on at least one. No scalar total, no weighted sum, no midpoint (DEC-15, DEC-19) ever decides membership. +2. **The generator is honest fan-out, not cherry-picking.** Candidates come from the domain's own four axes (research note §5.2 — approach, suppression posture, causeway, extraction coupling), enumerated deterministically. The handful is what the organiser leaves standing, not what an author chose to show. +3. **Every member is fully traced.** Each plan is content-addressed and stored; each verdict carries the `scored_from` edge the scorer wrote (G3). A handful member with no backward chain is a defect. +4. **`distinct_because` is derived, not decorative.** Each returned reason names *why* the organiser kept that plan — the commitments it leads on and its axis signature — so the "why is this in the set" question has a computed answer, not a caption. + +## User Scenarios & Testing *(mandatory)* + +The scenarios play on the Meridian base world (the resolved compile of SPEC-06/07 — K1–K9 + resolved K12a). The handful over the base world is the Stage-3 user-observable; the depth stages (4–6) re-score into the same S2 matrix. + +### User Story 1 — 3–5 genuinely distinct plans for the Meridian baseline (Priority: P1) 🎯 exit + +`/plan/handful` over the Meridian base world returns **between three and five plans, each genuinely distinct in banded space** — none ε-dominates another across the C1–C6 margin vector. The organiser produced the set; no author hand-picked it. + +**Why this priority**: This is the Stage-3 handful exit (build plan §Stage 3, coverage matrix G1 row): "`/plan/handful` returns 3–5 genuinely distinct plans for the Meridian baseline." It is the whole reason the slice exists. + +**Independent Test**: Generate over the base world with a fixed seed; assert `3 ≤ plans.length ≤ 5`, that every pair is mutually non-dominated (neither ε-dominates the other on the margin vector), and that each returned plan resolves to a stored `Plan` with a distinct route geometry. + +**Acceptance Scenarios**: + +1. **Given** the Meridian base world and a fixed seed, **When** `/plan/handful` is called, **Then** it returns 3–5 plan refs, each resolving to a stored `Plan`. +2. **Given** the returned handful, **When** every pair is checked, **Then** neither member of any pair ε-dominates the other on the C1–C6 margin vector (banded non-domination, research note §5). +3. **Given** the returned handful, **When** each plan is scored, **Then** the members occupy genuinely different criteria profiles — at least two commitments on which the handful shows more than one distinct verdict. + +--- + +### User Story 2 — Same stamp + seed ⇒ identical handful (Priority: P1) + +Calling `/plan/handful` twice with the same world stamp and the same seed returns a byte-identical stamp, the same plan refs in the same order, and the same `scores`/`organisation`. The handful is a pure function of `(world stamp, seed, count, engine version)`. + +**Why this priority**: The Stage-3 exit names it explicitly (coverage matrix G1: "same stamp + seed ⇒ identical handful") and every downstream demo (sensitivity/discrimination/robustness) re-runs the handful; a non-deterministic handful makes those loops theatre (DEC-4). P1 alongside US1 as the two together *are* the exit. + +**Independent Test**: Call twice with identical `(world, seed)`; assert identical `stamp`, identical `plans` (same order, same content hashes), identical `scores` and `distinct_because`. Change only the seed; assert the stamp changes (the seed participates in the stamp). + +**Acceptance Scenarios**: + +1. **Given** a fixed world and seed, **When** `/plan/handful` is called twice, **Then** both calls return the same stamp, the same ordered plan refs, and the same organisation. +2. **Given** the same world and a different seed, **When** called, **Then** the stamp differs (the seed is part of the stamped input) and the handful may differ. +3. **Given** the same seed and a world recompiled to a different stamp, **When** called, **Then** the handful stamp differs (the world stamp is part of the stamped input). + +--- + +### User Story 3 — The organiser drops only conservatively-dominated plans (Priority: P1) + +The banded ε-non-domination organiser keeps every plan that any other does not conservatively beat on all criteria. A plan is excluded **only** when another is at-or-above it on every commitment margin (`A ⪯ B` fails nowhere) and strictly separated-above (by ε) on at least one. Overlapping bands are incomparable and both survive. + +**Why this priority**: This is the honesty core (stance §1). An organiser that dropped a plan on a scalar total, a midpoint tie-break, or a weighted score would be the false-precision machine ASSAY refuses (DEC-15, DEC-19). It is testable independently of the generator on constructed criteria vectors, so it is P1 and specified against hand-built cases. + +**Independent Test**: On constructed banded criteria vectors: assert a strictly-worse-everywhere plan is dominated and dropped; assert two plans with overlapping bands on any criterion are both kept; assert `ε` widening only ever *keeps more* plans (monotone in ε); assert the relation is irreflexive (no plan dominates itself) and asymmetric. + +**Acceptance Scenarios**: + +1. **Given** plan A whose every C1–C6 margin band is conservatively above plan B's (`B.hi ≤ A.lo` on all, strict on one), **When** organised, **Then** B is dominated and excluded, A retained. +2. **Given** plans A and B whose margin bands overlap on at least one commitment, **When** organised, **Then** neither dominates the other and both are candidates for the handful. +3. **Given** a fixed candidate set, **When** ε is increased, **Then** the non-dominated set never shrinks (ε is the organiser's distinctness knob, research note §5) — more plans read as distinct, never fewer. +4. **Given** any plan, **When** compared to itself, **Then** it does not dominate itself (irreflexive); and if A dominates B then B does not dominate A (asymmetric). + +--- + +### User Story 4 — Generation is honest fan-out over the domain's four axes (Priority: P2) + +Candidate plans are produced by a **strategy-biased, seeded** fan-out across the Meridian axes (research note §5.2): **approach** (strait-early vs sweep-first), **suppression posture** (fires-forward vs stand-off), **causeway** (contest vs bypass), **extraction coupling** (pull-out early vs mission-tail). Each candidate is a valid `Plan` (routes for the five force elements, DEC-20); the fan-out is deterministic and reproducible. + +**Why this priority**: The four axes are the vignette's own (concept §6.2 gate — REMIT's axes "do not transfer unexamined"), decided in the research note; the generator is the machine that turns them into candidates. P2 because the exit (US1/US2) constrains the *output* of generation, and the axes are the honest *means*; a different valid fan-out that still yields 3–5 distinct plans would satisfy the exit. + +**Independent Test**: Generate the candidate set for a fixed seed; assert every candidate validates as a `Plan` against the generated types, that the four axes each vary across the set (both settings of each axis appear), and that generation is deterministic (same seed ⇒ identical candidate list). + +**Acceptance Scenarios**: + +1. **Given** a fixed seed, **When** the candidate set is generated, **Then** every candidate is a valid `Plan` with routes for the five Meridian force elements (FE-BROOM, FE-PACKHORSE, FE-ANVIL, FE-FALCON, FE-KINGFISHER). +2. **Given** the candidate set, **When** its axis signatures are read, **Then** both settings of each of the four axes appear (the fan-out spans the axes, not one corner). +3. **Given** the same seed, **When** candidates are generated twice, **Then** the two candidate lists are identical (deterministic generation). + +--- + +### User Story 5 — The honest matrix over the generated handful, with `distinct_because` (Priority: P2) + +The S2 planner matrix now renders the **generated** handful (replacing the canned P1/P2), each plan a row of four-stop chips (margin on hover, no decimals), alongside a `distinct_because` line per plan naming why the organiser kept it. This is the Stage-3 demo moment made honest end-to-end: *pick a verdict, walk it to the assessment and owner it rests on* — and now, *see why this plan is one of the handful.* + +**Why this priority**: It is the user-observable and the surface the depth stages re-score into. P2 because it composes US1–US4's correctness onto the gallery; the S2 matrix component already exists (SPEC-07) and is reused unchanged, fed by the generated handful. + +**Independent Test**: Run `/plan/handful` in the gallery build over the resolved base world; render the S2 matrix over the returned plans and a `handfulStrip` over the `organisation`; assert four-stop chips with margin-on-hover and no decimals, and a non-empty `distinct_because` per plan. + +**Acceptance Scenarios**: + +1. **Given** the generated handful, **When** the S2 matrix renders, **Then** every cell is a four-stop chip, margin appears only on hover, and no verdict cell shows a decimal (G2, ui-design §2). +2. **Given** the returned `organisation`, **When** the handful strip renders, **Then** each plan carries a `distinct_because` reason naming the commitments it leads on and its axis signature. +3. **Given** the gallery build, **When** it runs, **Then** the honest-matrix demo moment is driven by the actual `/plan/handful`, not by hand-authored fixture rows. + +--- + +### Edge Cases + +- **Fewer than three non-dominated candidates**: if the honest organiser leaves fewer than three plans standing, that is a *signal*, not something to pad — the service returns what stands and the test surfaces it (for the engineered Meridian base world the axes are designed to yield ≥ 3; this case guards against silent padding). +- **More non-dominated candidates than `count`**: the service caps at `count` (default 5) by a deterministic, seed-driven **diversity** selection over axis signatures — never by a scalar score (that would smuggle a weighting in through the cap). The drop is logged in `distinct_because` framing, never silent. +- **`count` out of the 3–5 band**: a `count` below 3 or above 5 is clamped to the contract band (seam §6), not honoured blindly. +- **A severed / unreachable objective in a candidate** (a route crossing a zero-mobility region): the scorer already returns `violated` with no margin; the organiser treats an absent margin as the worst possible on that criterion (a `violated` plan is conservatively dominated there), never as a gap to skip. +- **World refusal / unknown world ref**: `/plan/handful` refuses `unknown_ref` (first-class, seam §1) when the world cannot be resolved; it never scores against a phantom world. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: `/plan/handful` MUST accept `{world: Ref, seed: integer, count?: 3..5}` and return `{plans: Ref[], scores: PlanScore[], organisation: {distinct_because: string[]}, stamp}` or a first-class `Refusal` (seam §6). `count` MUST be clamped to `[3,5]` (default 5). +- **FR-002**: Generation MUST be a seeded, strategy-biased fan-out over the four Meridian axes (research note §5.2). Every candidate MUST be a valid `Plan` (DEC-20) with routes for the five force elements, and generation MUST be deterministic in `(world, seed)`. +- **FR-003**: Each candidate MUST be scored by the SPEC-07 scorer against the given world (reusing `ScoreService`, no re-implementation); the criteria vector for organisation is the per-commitment **margin bands** (satisfied ⟺ margin ≥ 0 — uniform orientation, higher is better). +- **FR-004**: Organisation MUST be banded ε-non-domination on the conservative interval order (research note §5): A ε-dominates B iff A is no worse than B on every criterion (`B.lo ≤ A.hi`) and strictly separated-above by ε on at least one (`A.lo ≥ B.hi + ε`). The handful is the ε-non-dominated set. `ε` defaults to 0 (scale-free conservative non-domination) and MUST be monotone: increasing ε never shrinks the set. +- **FR-005**: The domination relation MUST be irreflexive (no plan dominates itself) and asymmetric (A dom B ⇒ ¬(B dom A)); it MUST NOT use any scalar total, weighted sum, or band midpoint (DEC-15, DEC-19). +- **FR-006**: A `violated` verdict (no margin — an unreachable objective) MUST be treated as the conservatively worst value on that criterion, so a plan failing a `must` is dominated there, never skipped. +- **FR-007**: When the non-dominated set exceeds `count`, the service MUST cap it by a deterministic, seed-driven diversity selection over axis signatures — never by a scalar score. When it is below `count`, the service returns all non-dominated plans (no padding). +- **FR-008**: The handful MUST be deterministic: same `(world stamp, seed, count, engine version)` ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation` (G1). The stamp is a hash over those inputs, never over materialised cells. +- **FR-009**: Every returned plan MUST be content-addressed and stored (so its ref resolves), and MUST carry the scorer's `scored_from` edges on its verdicts, so every handful member opens a complete backward trace chain to named knowledge (G3). +- **FR-010**: `distinct_because` MUST be **derived** from the organisation (the commitments each plan leads on + its axis signature), one entry aligned to each returned plan; it MUST NOT be a hand-authored caption. +- **FR-011**: `/plan/handful` MUST refuse `unknown_ref` (first-class) when the world ref cannot be resolved, and persist nothing on refusal. +- **FR-012**: `/plan/handful` MUST NOT compute a `knowledge_overrides` perturbation (that stays scorer-only — seam §6 open question 3), and MUST NOT call `/relax` (the commitment set stays whole; relaxation is SPEC-09). + +### Key Entities + +- **Plan / ElementPlan / RouteLeg** (existing LinkML, DEC-20) — the generated candidates; timed routes for the five force elements. Stored and content-addressed. +- **PlanScore** (existing LinkML) — the banded criteria per plan; returned in `scores`. +- **CommitmentVerdict.margin** (existing LinkML, SPEC-07) — the oriented banded criterion the organiser reads; never crosses the seam as a scalar. +- **HandfulRequest / HandfulSuccess / HandfulResult** (service types, `src/seam.ts` — not stored) — the seam §6 movement shapes; `organisation.distinct_because` is a derived view, not a stored object. +- **AxisSignature** (internal, not at the seam) — the four-axis label of a candidate; drives generation and the diversity cap. + +## Success Criteria *(mandatory)* + +- **SC-001**: `/plan/handful` over the Meridian base world returns 3–5 plans, every pair mutually non-dominated on the C1–C6 margin vector. +- **SC-002**: Same `(world, seed)` ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation`; changing the seed changes the stamp. +- **SC-003**: The organiser drops a strictly-worse-everywhere plan, keeps overlapping-band plans, is monotone in ε, and is irreflexive + asymmetric — proven on constructed criteria vectors. +- **SC-004**: Generation is a deterministic fan-out spanning both settings of all four Meridian axes; every candidate validates as a `Plan`. +- **SC-005**: Every handful member resolves to a stored `Plan` and every verdict opens a complete backward trace chain (G3). +- **SC-006**: `distinct_because` is derived and non-empty per plan; the S2 matrix + handful strip render in the gallery from the actual `/plan/handful` with four-stop chips, margins on hover, and no decimals (G2). +- **SC-007**: `npm run typecheck` clean; `npm test` green (SPEC-07 baseline plus the SPEC-08 suite); no schema change (`npm run gen` not run in this slice). diff --git a/specs/008-handful/tasks.md b/specs/008-handful/tasks.md new file mode 100644 index 0000000..fecf092 --- /dev/null +++ b/specs/008-handful/tasks.md @@ -0,0 +1,70 @@ +--- +description: "Task list for SPEC-08 — Handful (generate → score → organise)" +--- + +# Tasks: Handful — generate → score → organise into a banded non-dominated handful (SPEC-08) + +**Input**: Design documents from `specs/008-handful/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/handful-service.md, quickstart.md; research note `docs/research/03-score-plan.md` §5 (DEC-11 gate — present) +**Tests**: REQUIRED — the organiser's non-domination laws and the determinism assertions are written against the seam §6 shape and **confirmed failing before implementation** (TDD; constitution quality gate 2). The Meridian handful is a *computed* product; tests assert its shape (3–5, mutually non-dominated, deterministic), never hand-set membership. +**Organization**: the organiser (pure, testable in isolation) and the generator first, then the service that composes them, then the surface. Stage-3 handful exit is the acceptance. + +## Format: `[ID] [P?] [Story] Description` +- **[P]**: parallelizable (different file, no dependency on an incomplete task) +- **[Story]**: US1 (3–5 distinct, P1) · US2 (determinism, P1) · US3 (organiser laws, P1) · US4 (fan-out, P2) · US5 (matrix + strip, P2) + +## Path conventions +Single project: `src/`, `tests/`. Reuse `src/{store,trace,canonical,validate,seam,score,interval}.ts`, `src/generated/types.ts`, `src/components/s2Matrix.ts`. **No schema change** — `Plan`/`PlanScore`/`CommitmentVerdict`/`Band`/`TraceEdge` already exist; do **not** run `npm run gen`. + +--- + +## Phase 1: Setup + +- [x] T001 Confirm the SPEC-07 baseline is green: `npm install`, `npm run typecheck`, `npm test`. Record any failing baseline before Stage-3 code. +- [x] T002 Confirm the pieces SPEC-08 reuses exist: `ScoreService` (`src/score.ts`) with `CommitmentVerdict.margin`; `Plan`/`PlanScore` in `src/generated/types.ts`; `scored_from` in `TraceEdgeType`; the sparse world shapes SPEC-06 produced. No new dependency (the generator is deterministic fan-out — no PRNG library). + +## Phase 2: US3 — the organiser laws (P1, the honesty core, pure) + +**Goal**: banded ε-non-domination on the conservative interval order, proven on constructed vectors before any pipeline exists. +**Independent test**: constructed margin vectors → dominated/kept/monotone/irreflexive/asymmetric. + +- [x] T003 Write `tests/dominance.test.ts` FIRST and confirm failing: `noWorse`/`strictlyBetter` on the conservative order incl. `null` (violated) as worst; `dominates` drops strictly-worse-everywhere, keeps trade-offs, is irreflexive + asymmetric, throws on misaligned lengths; `nonDominated` monotone in ε. +- [x] T004 Implement `src/dominance.ts`: `Criterion`/`CriteriaVector`, `noWorse`, `strictlyBetter(a,b,ε=0)`, `dominates(a,b,ε=0)`, `distinct`, `nonDominated(vectors,ε=0)`. Maximisation on margins; strict-above gate (`a.lo > b.hi + ε`) so equal/overlap is never "strictly better" (DEC-15). Make T003 pass. + +## Phase 3: US4 — the seeded fan-out generator (P2) + +**Goal**: deterministic candidates over Meridian's four axes, each a valid `Plan`. +**Independent test**: 16 valid plans, span all axes, deterministic in `(config, seed)`. + +- [x] T005 Write `tests/generate.test.ts` FIRST and confirm failing: 16 candidates, each `validateInstance('Plan', p) === []` with the five force elements and a `P-*` id; both settings of all four axes appear; same seed ⇒ identical list; id encodes the signature. +- [x] T006 Implement `src/generate.ts`: `AxisSignature`, `AXES`, `signatureId`/`signatureName`, per-force-element route builders (documented axis→route mapping — reach cells avoid banded-mobility regions so reaches stay clean; C4 exercises the banded threat channel), `planFor`, `allSignatures`, `generateCandidates(config, seed)`. Make T005 pass. + +## Phase 4: US1 + US2 — the handful service (P1) + +**Goal**: `/plan/handful` = generate → score → organise → cap, deterministic, over the Meridian base world. +**Independent test**: base world → 3–5 mutually non-dominated plans; same (world, seed) ⇒ identical; count clamps; unknown world refuses. + +- [x] T007 Add handful service types to `src/seam.ts`: `HandfulRequest`, `HandfulOrganisation`, `HandfulSuccess`, `HandfulResult` (per data-model.md). Types only; reuse `Refusal`/`Ref`. +- [x] T008 Write `tests/handful.test.ts` FIRST and confirm failing (rig mirrors `score.test.ts`: resolved base world): 3–5 stored plans; every pair `distinct`; ≥2 commitments vary; same (world, seed) ⇒ identical stamp/plans/scores/organisation; different seed ⇒ different stamp; `distinct_because` derived & aligned; verdict→world→knowledge chain (G3); `count` clamp; `unknown_ref` persists nothing. +- [x] T009 Implement `src/handful.ts` `HandfulService`: resolve world (refuse `unknown_ref`), clamp `count` to `[3,5]`, generate → store each candidate → score via `ScoreService` → build margin vectors → `nonDominated` → diversity cap (axis Hamming, seed FNV-1a tie-break) → derive `distinct_because` → stamp over `(world.stamp, seed, count, engine)`. Make T008 pass. + +## Phase 5: US5 — the surface (P2) + +**Goal**: the honest-matrix demo moment, now over the generated handful. + +- [x] T010 [P] Implement `src/components/handfulStrip.ts`: one row per plan — id, axis-signature name, derived `distinct_because`. Arranges projections only; depends on generated types + `Ref`. +- [x] T011 Wire `scripts/build-gallery.ts`: replace the canned P1/P2 scoring with `HandfulService.handful` over the resolved base world; render the strip above the reused `s2Matrix`; update the section copy to name SPEC-08 (organised, 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-3 status; the gallery. Note the concept §6.2 handful-axes open question is closed by research note §5.2 (no new register candidate — the note holds the decision under Stage-3 latitude). +- [x] T013 Gate: `npm run typecheck` clean; `npm test` green (SPEC-07 baseline + SPEC-08 suites); `npm run gallery` renders the generated handful. No schema regen. + +--- + +## Dependencies & parallelism + +- **T003→T004** (organiser) and **T005→T006** (generator) 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/handfulStrip.ts b/src/components/handfulStrip.ts new file mode 100644 index 0000000..5039672 --- /dev/null +++ b/src/components/handfulStrip.ts @@ -0,0 +1,43 @@ +/** + * SPEC-08 — the handful organisation strip (seam §6; ui-design §S2). + * + * Renders the organiser's answer to "why is this plan one of the handful?" — one + * row per plan with its axis signature and the DERIVED `distinct_because` reason + * the service returned (the commitments it leads on; never a hand-authored + * caption). It arranges projections only (constitution I): no service call, no + * state, depends only on the returned `plans`/`organisation`. It sits above the + * SPEC-07 S2 matrix, which renders the same handful as four-stop chips — the + * strip explains membership, the matrix shows the verdicts. + */ +import type { Ref } from '../store.js'; + +const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export interface HandfulStripRow { + plan: Ref; + name?: string; // the plan's human name (axis signature), if to hand + distinct_because: string; +} + +/** + * @param rows one per handful member, aligned to the service's `plans` order. + */ +export function handfulStrip(rows: HandfulStripRow[]): string { + const items = rows + .map((r) => { + const id = esc(r.plan.logical_id); + const name = r.name ? `${esc(r.name)}` : ''; + return `
  • +
    + ${id} + ${name} +
    +
    ${esc(r.distinct_because)}
    +
  • `; + }) + .join('\n'); + return `
      +${items} +
    `; +} diff --git a/src/dominance.ts b/src/dominance.ts new file mode 100644 index 0000000..939064b --- /dev/null +++ b/src/dominance.ts @@ -0,0 +1,87 @@ +/** + * SPEC-08 — banded ε-non-domination, the handful's organiser (research note + * `03-score-plan.md` §5). + * + * Two plans are DISTINCT in banded space when neither ε-dominates the other + * across the criteria vector of banded scores. The criteria are the scorer's + * per-commitment MARGIN bands (satisfied ⟺ margin ≥ 0 — SPEC-07's signed margin + * gives one uniform "higher is better" order across every comparator), so the + * organiser needs no per-criterion orientation table. + * + * The order is the CONSERVATIVE interval order (note §5): `A ⪯ B` iff + * `A.hi ≤ B.lo` (A entirely at/below B). For maximisation this reads: + * - A is NO WORSE than B on a criterion ⟺ B is not entirely above A ⟺ B.lo ≤ A.hi + * - A is STRICTLY BETTER than B by ε ⟺ A is separated above B ⟺ A.lo > B.hi + ε + * and `A ε-dominates B` iff A is no worse on EVERY criterion and strictly better + * on AT LEAST ONE. Bands that overlap are honestly incomparable and BOTH survive + * — that is DEC-15 in the organiser: overlap means indistinguishable, so keep both. + * No scalar total, no weighted sum, no midpoint ever decides membership (DEC-19). + * + * ε is the organiser's distinctness knob and defaults to 0 (scale-free — the + * criteria carry incommensurate units, so no absolute ε is honest). Increasing ε + * only ever KEEPS MORE plans (fewer dominations), never fewer. + */ +import type { Band } from './generated/types.js'; + +/** One criterion of a plan's vector: a margin band, or `null` for a `violated` + * verdict (an unreachable objective — conservatively the worst possible here). */ +export type Criterion = Band | null; +export type CriteriaVector = Criterion[]; + +/** + * A is NO WORSE than B on this criterion (maximisation): B is not conservatively + * above A. `null` (violated) is the worst value: any finite margin is above it. + */ +export function noWorse(a: Criterion, b: Criterion): boolean { + if (a === null) return b === null; // worst is no-worse only than the worst + if (b === null) return true; // a finite margin is at least as good as unreachable + return b.lo <= a.hi; // B does not sit entirely above A +} + +/** + * A is STRICTLY BETTER than B on this criterion, by more than ε: A is separated + * conservatively above B. A finite margin strictly beats `violated`; equal or + * overlapping bands are NOT strictly better (no honest interior point — DEC-15). + */ +export function strictlyBetter(a: Criterion, b: Criterion, eps = 0): boolean { + if (a === null) return false; // the worst is never strictly better + if (b === null) return true; // reachable strictly beats unreachable + return a.lo > b.hi + eps; // A entirely above B by more than ε +} + +/** + * A ε-dominates B: no worse on every criterion, strictly better on at least one. + * Irreflexive (a vector never dominates itself — no criterion is strictly above + * itself) and asymmetric (the strict criterion that lets A beat B forbids B beating A). + * Vectors must be the same length and aligned to the same criterion order. + */ +export function dominates(a: CriteriaVector, b: CriteriaVector, eps = 0): boolean { + if (a.length !== b.length) { + throw new Error(`dominance: criteria vectors of differing length (${a.length} vs ${b.length}) — misaligned commitment order`); + } + let strictSomewhere = false; + for (let i = 0; i < a.length; i++) { + if (!noWorse(a[i]!, b[i]!)) return false; // worse on some criterion ⇒ no domination + if (strictlyBetter(a[i]!, b[i]!, eps)) strictSomewhere = true; + } + return strictSomewhere; +} + +/** Two plans are DISTINCT iff neither ε-dominates the other (research note §5). */ +export function distinct(a: CriteriaVector, b: CriteriaVector, eps = 0): boolean { + return !dominates(a, b, eps) && !dominates(b, a, eps); +} + +/** + * Indices of the ε-non-dominated vectors — those no other vector ε-dominates. + * This is the honest frontier; it is never padded and never trimmed by a scalar. + * Monotone in ε: a larger ε yields a superset (fewer dominations). + */ +export function nonDominated(vectors: CriteriaVector[], eps = 0): number[] { + const keep: number[] = []; + for (let i = 0; i < vectors.length; i++) { + const dominated = vectors.some((other, j) => j !== i && dominates(other, vectors[i]!, eps)); + if (!dominated) keep.push(i); + } + return keep; +} diff --git a/src/generate.ts b/src/generate.ts new file mode 100644 index 0000000..1a1ab49 --- /dev/null +++ b/src/generate.ts @@ -0,0 +1,179 @@ +/** + * SPEC-08 — the seeded, strategy-biased plan generator (research note + * `03-score-plan.md` §5.2). + * + * Candidates are a deterministic fan-out over the vignette's OWN four axes — + * REMIT's time/exposure/robustness/completeness axes "do not transfer + * unexamined" (concept §6.2), so these are read off Meridian's C1–C6 and force + * elements: + * + * 1. approach — strait-early vs sweep-first (trades C2 strait ↔ C1 port) + * 2. suppression — fires-forward vs stand-off (trades C4 threat ↔ C3 civil) + * 3. causeway — contest intact vs bypass by sea + * 4. extraction — KINGFISHER pull-out early vs mission-tail + * + * Each axis is binary, so the fan-out is 2⁴ = 16 candidate `Plan`s (DEC-20: + * timed routes for the five force elements, nothing richer). The generator is a + * PURE function of `(config, seed)` — no PRNG, no wall clock — so the same seed + * yields the same candidates; the seed only orders the enumeration and breaks + * ties in the organiser's diversity cap (`src/handful.ts`). It scores nothing and + * organises nothing: it hands honest candidates to the scorer, and the organiser + * decides which survive. Route geometry is fixture-authoring latitude within the + * vignette's constraints (§8); the axis→route mapping is documented cell-by-cell + * below so a reviewer can walk any leg back to the axis that set it. + */ +import type { ElementPlan, Plan, RouteLeg, VignetteConfig } from './generated/types.js'; + +export type Approach = 'strait_early' | 'sweep_first'; +export type Suppression = 'fires_forward' | 'stand_off'; +export type Causeway = 'contest' | 'bypass'; +export type Extraction = 'pull_early' | 'mission_tail'; + +export interface AxisSignature { + approach: Approach; + suppression: Suppression; + causeway: Causeway; + extraction: Extraction; +} + +/** The four binary axes, in canonical enumeration order. */ +export const AXES = { + approach: ['strait_early', 'sweep_first'] as Approach[], + suppression: ['fires_forward', 'stand_off'] as Suppression[], + causeway: ['contest', 'bypass'] as Causeway[], + extraction: ['pull_early', 'mission_tail'] as Extraction[], +}; + +/** Two-letter axis codes → the `P*` logical id (vignette §8 frozen family). */ +const CODE = { + strait_early: 'Se', sweep_first: 'Sw', + fires_forward: 'Ff', stand_off: 'So', + contest: 'Cc', bypass: 'Cb', + pull_early: 'Xe', mission_tail: 'Xt', +} as const; + +const PHRASE = { + strait_early: 'strait-early', sweep_first: 'sweep-first', + fires_forward: 'fires-forward', stand_off: 'stand-off', + contest: 'contest', bypass: 'bypass', + pull_early: 'pull-early', mission_tail: 'mission-tail', +} as const; + +export const signatureId = (s: AxisSignature): string => + `P-${CODE[s.approach]}${CODE[s.suppression]}${CODE[s.causeway]}${CODE[s.extraction]}`; + +export const signatureName = (s: AxisSignature): string => + `${PHRASE[s.approach]} · ${PHRASE[s.suppression]} · ${PHRASE[s.causeway]} · ${PHRASE[s.extraction]}`; + +const leg = (x: number, y: number, enter: number, exit: number): RouteLeg => ({ + x, y, enter_step: enter, exit_step: exit, +}); + +/** + * FE-BROOM sweeps the strait — the C2 clock. `strait_early` rushes a direct pass + * (fast C2); `sweep_first` does a thorough approach pass then the strait (C2 later + * but clean, the thesis-C robustness posture). All cells avoid the banded-mobility + * approach/causeway regions, so C2 is a clean point band. + */ +function broom(a: Approach): ElementPlan { + const route = a === 'strait_early' + ? [leg(30, 50, 0, 3), leg(26, 25, 3, 20)] // strait_open = 0 + 3 + 17 = 20 + : [leg(30, 50, 0, 2), leg(24, 26, 2, 9), leg(26, 25, 9, 26)]; // = 0 + 2 + 7 + 17 = 26 + return { force_element: 'FE-BROOM', route }; +} + +/** + * FE-PACKHORSE is the relief payload — the C1 clock. `approach` trades it against + * C2: rushing the strait (strait_early) sends PACKHORSE in behind an unfinished + * sweep, so it berths later (higher C1); sweep-first lets it run clean and berth + * earlier. `causeway` sets the route: `contest` takes the direct Ledger crossing, + * `bypass` the longer sea route (12 steps slower), so contest strictly beats + * bypass on C1, all else equal. Cells avoid the banded-mobility regions, so the + * reach is a clean point band (the banded channels are exercised on C4 below). + */ +function packhorse(a: Approach, c: Causeway): ElementPlan { + const berth = a === 'strait_early' ? 36 : 24; // strait_early berths later (higher C1) + const route = c === 'contest' + ? [leg(30, 50, 0, 4), leg(26, 32, 4, berth)] // direct crossing: port_open = berth + : [leg(30, 50, 0, 4), leg(44, 44, 4, 16), leg(26, 32, 16, berth + 12)]; // by sea, +12 + return { force_element: 'FE-PACKHORSE', route }; +} + +/** + * FE-ANVIL's threat exposure is C4 — the INVERSE of the suppression choice: + * `fires_forward` means FALCON has suppressed the threat, so ANVIL transits a + * clean cell (26,32) (zero threat ⇒ C4 robust); `stand_off` leaves the threat up, + * so ANVIL must cross the FAC waters cell (32,22) (threat [2,6] ⇒ C4 exposed/tight). + */ +function anvil(s: Suppression): ElementPlan { + const route = s === 'fires_forward' + ? [leg(30, 50, 0, 8), leg(26, 32, 8, 10)] // clean cell — exposure [0,0] + : [leg(30, 50, 0, 7), leg(32, 22, 7, 8)]; // FAC waters — one step in threat [2,6] + return { force_element: 'FE-ANVIL', route }; +} + +/** + * FE-FALCON's fires decide C3: `fires_forward` puts a leg into the populated port + * district cell (26,32) (a fire in the district ⇒ C3 violated — civil harm); + * `stand_off` keeps FALCON at the Carrick battery standoff (48,15) outside the + * district (no fires ⇒ C3 marginal). This is the C3↔C4 trade the suppression axis + * carries: fires forward buys ANVIL safety at a civil-harm cost, and vice versa. + */ +function falcon(s: Suppression): ElementPlan { + const route = s === 'fires_forward' + ? [leg(30, 50, 0, 4), leg(26, 32, 4, 8)] // inside port_district ⇒ C3 fire + : [leg(30, 50, 0, 4), leg(48, 15, 4, 10)]; // battery standoff, outside district + return { force_element: 'FE-FALCON', route }; +} + +/** + * DET KINGFISHER's extraction is C6: `pull_early` extracts by step 38 (C6 robust + * with room); `mission_tail` keeps it in until step 46 (C6 robust but tight). Pull + * early is strictly better on C6, all else equal — mission-tail's collection value + * (K11) is not a scored criterion here, so on the honest matrix it is dominated. + */ +function kingfisher(x: Extraction): ElementPlan { + const route = x === 'pull_early' + ? [leg(48, 15, 0, 4), leg(30, 50, 4, 38)] // extraction_step = 0 + 4 + 34 = 38 + : [leg(48, 15, 0, 8), leg(30, 50, 8, 46)]; // = 0 + 8 + 38 = 46 + return { force_element: 'FE-KINGFISHER', route }; +} + +/** Build one candidate `Plan` from an axis signature (DEC-20 shape). */ +export function planFor(sig: AxisSignature, seed: number): Plan { + return { + logical_id: signatureId(sig), + version: 1, + name: signatureName(sig), + seed, + generator: 'spec-08 axis fan-out (approach × suppression × causeway × extraction)', + elements: [ + broom(sig.approach), + packhorse(sig.approach, sig.causeway), + anvil(sig.suppression), + falcon(sig.suppression), + kingfisher(sig.extraction), + ], + }; +} + +/** All 16 axis signatures, in canonical enumeration order. */ +export function allSignatures(): AxisSignature[] { + const out: AxisSignature[] = []; + for (const approach of AXES.approach) + for (const suppression of AXES.suppression) + for (const causeway of AXES.causeway) + for (const extraction of AXES.extraction) + out.push({ approach, suppression, causeway, extraction }); + return out; +} + +/** + * The strategy-biased fan-out: one candidate `Plan` per axis signature. The seed + * is stamped onto each plan and (in the organiser) orders the diversity cap; the + * candidate SET is seed-independent — the organiser, not the generator, decides + * the handful, so generation stays honest fan-out rather than cherry-picking. + */ +export function generateCandidates(_config: VignetteConfig, seed: number): Plan[] { + return allSignatures().map((sig) => planFor(sig, seed)); +} diff --git a/src/handful.ts b/src/handful.ts new file mode 100644 index 0000000..e0f8f4b --- /dev/null +++ b/src/handful.ts @@ -0,0 +1,218 @@ +/** + * SPEC-08 — the handful service (seam contract §6; delivery plan §δ, the last + * unit of the spine). + * + * POST /plan/handful {world, seed, count?} + * → {plans: Ref[], scores: PlanScore[], organisation: {distinct_because}, stamp} + * + * Three honest steps and nothing more: + * 1. GENERATE — a seeded, strategy-biased fan-out over the vignette's four axes + * (`src/generate.ts`), 16 candidate `Plan`s. The generator cherry-picks + * nothing; it hands every candidate forward. + * 2. SCORE — each candidate through the SPEC-07 scorer (`ScoreService`, REUSED, + * never re-implemented). The criteria vector is the per-commitment MARGIN + * band (satisfied ⟺ margin ≥ 0 — one uniform "higher is better" order). + * 3. ORGANISE — banded ε-non-domination (`src/dominance.ts`) keeps the plans no + * other conservatively beats on every commitment; a diversity cap trims to + * `count` by axis spread, NEVER by a scalar score (that would smuggle a + * weighting past DEC-19). `distinct_because` is derived from the survivors. + * + * Deterministic over `(world stamp, seed, count, engine)`: same inputs ⇒ + * byte-identical stamp and identical ordered plans/scores/organisation (G1). It + * runs no `knowledge_overrides` perturbation (that stays scorer-only, seam §6) + * and calls no `/relax` (the commitment set stays whole — relaxation is SPEC-09). + */ +import type { Commitment, CompiledWorld, PlanScore } from './generated/types.js'; +import { ObjectStore, type Ref } from './store.js'; +import { contentHash } from './canonical.js'; +import { ScoreService } from './score.js'; +import { isRefusal, type HandfulRequest, type HandfulResult, type Refusal } from './seam.js'; +import { allSignatures, planFor, type AxisSignature } from './generate.js'; +import { type Criterion, type CriteriaVector, nonDominated, noWorse, strictlyBetter } from './dominance.js'; +import type { VignetteConfig } from './generated/types.js'; + +const COUNT_MIN = 3; +const COUNT_MAX = 5; +const DEFAULT_SCENARIO = 'BASE'; + +/** One axis word per axis, for `distinct_because` (matches generate.ts phrasing). */ +const AXIS_PHRASE: Record = { + strait_early: 'strait-early', sweep_first: 'sweep-first', + fires_forward: 'fires-forward', stand_off: 'stand-off', + contest: 'contest', bypass: 'bypass', + pull_early: 'pull-early', mission_tail: 'mission-tail', +}; + +interface Candidate { + sig: AxisSignature; + planRef: Ref; + vector: CriteriaVector; // per-commitment margin band (or null = violated) + scores: PlanScore[]; +} + +export class HandfulService { + #store: ObjectStore; + #scorer: ScoreService; + #config: VignetteConfig; + #commitmentIds: string[]; // canonical criterion order (by logical_id) + + constructor(opts: { + store: ObjectStore; + scorer: ScoreService; + config: VignetteConfig; + commitments: Commitment[]; + }) { + this.#store = opts.store; + this.#scorer = opts.scorer; + this.#config = opts.config; + this.#commitmentIds = [...opts.commitments].map((c) => c.logical_id).sort(cmp); + } + + /** POST /plan/handful — pure over its stamped inputs; refuses first-class. */ + async handful(req: HandfulRequest): Promise { + const world = this.#resolveWorld(req.world); + if (!world) { + return refusal('unknown_ref', [req.world], `${req.world.logical_id}: no such world to plan against.`); + } + const count = clamp(req.count ?? COUNT_MAX, COUNT_MIN, COUNT_MAX); + const scenario = world.scenario ?? DEFAULT_SCENARIO; + + // 1–2. Generate the fan-out, then score every candidate (store first so the + // scorer can resolve each plan ref; storage is content-addressed/idempotent). + // The signature travels WITH each plan — no id round-trip. + const candidates: Candidate[] = []; + for (const sig of allSignatures()) { + const plan = planFor(sig, req.seed); + const planRef = await this.#store.put(plan as unknown as Record); + const scored = await this.#scorer.score({ + plan: planRef, + world: req.world, + scenario, + engine_version: req.engine_version, + }); + if (isRefusal(scored)) return scored; // a world that refuses scoring refuses the handful + const byCommitment = new Map(scored.verdicts.map((v) => [v.commitment, v])); + const vector: CriteriaVector = this.#commitmentIds.map( + (id) => (byCommitment.get(id)?.margin ?? null) as Criterion, + ); + candidates.push({ sig, planRef, vector, scores: scored.scores }); + } + + // 3. Organise: the ε-non-dominated frontier (ε = 0, scale-free), then a + // deterministic diversity cap to `count`. + const frontier = nonDominated(candidates.map((c) => c.vector)).map((i) => candidates[i]!); + const selected = this.#cap(frontier, count, req.seed); + + // Derive `distinct_because` from the survivors: the commitments each leads on + // (strictly better than every OTHER survivor) plus its axis signature. + const distinct_because = selected.map((c) => this.#reason(c, selected)); + + // Deterministic stamp over inputs only (never over materialised cells — G1). + const stamp = await contentHash({ + world: world.stamp, + seed: req.seed, + count, + engine_version: req.engine_version, + }); + + return { + plans: selected.map((c) => c.planRef), + scores: selected.flatMap((c) => c.scores), + organisation: { distinct_because }, + stamp, + }; + } + + #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; + } + + /** + * Cap the frontier to `count` by axis DIVERSITY (never a scalar). Greedy: seed + * the pick order deterministically, take the first, then repeatedly add the + * candidate whose signature is farthest (max Hamming distance over the four + * axes) from those already chosen; ties break on the seed key. When the frontier + * already fits, return it in the deterministic seed order (no padding). + */ + #cap(frontier: Candidate[], count: number, seed: number): Candidate[] { + const ordered = [...frontier].sort( + (a, b) => seedKey(seed, a.planRef.logical_id) - seedKey(seed, b.planRef.logical_id) + || cmp(a.planRef.logical_id, b.planRef.logical_id), + ); + if (ordered.length <= count) return ordered; + const chosen: Candidate[] = [ordered[0]!]; + while (chosen.length < count) { + let best: Candidate | undefined; + let bestDist = -1; + for (const cand of ordered) { + if (chosen.includes(cand)) continue; + const dist = Math.min(...chosen.map((c) => hamming(c.sig, cand.sig))); + if (dist > bestDist) { + bestDist = dist; + best = cand; + } + } + chosen.push(best!); + } + // Present the chosen set in the same deterministic seed order. + return ordered.filter((c) => chosen.includes(c)); + } + + /** + * Why the organiser kept this plan: the commitments it LEADS on (at least as + * good as every other survivor and strictly better than at least one) plus its + * axis signature. Derived from the frontier — never a hand-authored caption. + */ + #reason(c: Candidate, handful: Candidate[]): string { + const others = handful.filter((o) => o !== c); + const leads = this.#commitmentIds.filter((_, i) => { + const a = c.vector[i]!; + const noOneBeatsMe = others.every((o) => noWorse(a, o.vector[i]!)); + const iBeatSomeone = others.some((o) => strictlyBetter(a, o.vector[i]!)); + return noOneBeatsMe && iBeatSomeone; + }); + const axes = signatureWords(c.sig).join(' · '); + if (leads.length > 0) { + return `${axes} — leads the handful on ${leads.join(', ')}; non-dominated (no plan beats it on every commitment).`; + } + return `${axes} — a distinct trade-off; non-dominated (no plan beats it on every commitment).`; + } +} + +// ————— helpers ————— + +const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, Math.round(n))); + +const signatureWords = (s: AxisSignature): string[] => [ + AXIS_PHRASE[s.approach]!, AXIS_PHRASE[s.suppression]!, AXIS_PHRASE[s.causeway]!, AXIS_PHRASE[s.extraction]!, +]; + +/** Axis Hamming distance: how many of the four axes two signatures differ on. */ +function hamming(a: AxisSignature, b: AxisSignature): number { + return (a.approach !== b.approach ? 1 : 0) + + (a.suppression !== b.suppression ? 1 : 0) + + (a.causeway !== b.causeway ? 1 : 0) + + (a.extraction !== b.extraction ? 1 : 0); +} + +/** Deterministic seed-keyed order (FNV-1a over `${seed}:${id}`) — no PRNG, no clock. */ +function seedKey(seed: number, id: string): number { + let h = 0x811c9dc5; + const s = `${seed}:${id}`; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +const refusal = (reason: Refusal['reason'], offending: Ref[], explanation: string): Refusal => ({ + refused: true, + reason, + offending, + explanation, +}); diff --git a/src/seam.ts b/src/seam.ts index 1013686..01e0761 100644 --- a/src/seam.ts +++ b/src/seam.ts @@ -79,3 +79,30 @@ export interface ScoreSuccess { } export type ScoreResult = ScoreSuccess | Refusal; + +/** + * SPEC-08 — handful movement types (seam §6). `/plan/handful` generates a + * strategy-biased fan-out, scores each via §5, and organises by banded + * non-domination into 3–5 genuinely distinct plans. `count` is clamped to the + * contract band [3,5]. `organisation.distinct_because` is a DERIVED view (one + * reason per returned plan, aligned to `plans`), never a hand-authored caption. + */ +export interface HandfulRequest { + world: Ref; + seed: number; + count?: number; // clamped to [3,5]; default 5 + engine_version: string; +} + +export interface HandfulOrganisation { + distinct_because: string[]; // aligned index-for-index with `plans` +} + +export interface HandfulSuccess { + plans: Ref[]; + scores: PlanScore[]; + organisation: HandfulOrganisation; + stamp: string; +} + +export type HandfulResult = HandfulSuccess | Refusal; diff --git a/tests/dominance.test.ts b/tests/dominance.test.ts new file mode 100644 index 0000000..a927950 --- /dev/null +++ b/tests/dominance.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { Band } from '../src/generated/types.js'; +import { + type CriteriaVector, + distinct, + dominates, + nonDominated, + noWorse, + strictlyBetter, +} from '../src/dominance.js'; + +// Margin bands are a maximisation criterion (satisfied ⟺ margin ≥ 0), so higher +// is better. Unit is irrelevant to the order (same criterion index ⇒ same unit). +const b = (lo: number, hi: number): Band => ({ lo, hi, unit: 'step' }); + +describe('SPEC-08 — banded ε-non-domination (research note 03 §5)', () => { + describe('the conservative interval order', () => { + it('noWorse: A is no worse than B unless B sits entirely above A', () => { + expect(noWorse(b(5, 6), b(1, 2))).toBe(true); // A above B + expect(noWorse(b(1, 2), b(5, 6))).toBe(false); // B entirely above A + expect(noWorse(b(1, 4), b(3, 6))).toBe(true); // overlap ⇒ no worse (incomparable) + expect(noWorse(b(2, 2), b(2, 2))).toBe(true); // equal ⇒ no worse + }); + + it('strictlyBetter: A must be separated conservatively above B (by more than ε)', () => { + expect(strictlyBetter(b(5, 6), b(1, 2))).toBe(true); // A entirely above B + expect(strictlyBetter(b(2, 2), b(2, 2))).toBe(false); // equal is NOT strictly better + expect(strictlyBetter(b(1, 4), b(3, 6))).toBe(false); // overlap is NOT strictly better + expect(strictlyBetter(b(5, 6), b(1, 2), 10)).toBe(false); // ε too large to clear + }); + + it('violated (null) is the worst possible value on a criterion', () => { + expect(noWorse(b(-9, -9), null)).toBe(true); // any finite margin ≥ unreachable + expect(noWorse(null, b(-9, -9))).toBe(false); // unreachable < any finite margin + expect(strictlyBetter(b(-9, -9), null)).toBe(true); // reachable strictly beats unreachable + expect(strictlyBetter(null, b(1, 1))).toBe(false); // the worst never strictly better + expect(noWorse(null, null)).toBe(true); // worst is no-worse only than the worst + expect(strictlyBetter(null, null)).toBe(false); + }); + }); + + describe('domination', () => { + it('drops a plan strictly worse on every criterion (US3-1)', () => { + const a: CriteriaVector = [b(5, 6), b(5, 6), b(5, 6)]; + const worse: CriteriaVector = [b(1, 2), b(1, 2), b(1, 2)]; + expect(dominates(a, worse)).toBe(true); + expect(dominates(worse, a)).toBe(false); // asymmetric + expect(nonDominated([a, worse])).toEqual([0]); // only A survives + }); + + it('keeps both plans of a genuine trade-off (US3-2)', () => { + const a: CriteriaVector = [b(5, 6), b(1, 2)]; // wins criterion 0 + const c: CriteriaVector = [b(1, 2), b(5, 6)]; // wins criterion 1 + expect(dominates(a, c)).toBe(false); + expect(dominates(c, a)).toBe(false); + expect(distinct(a, c)).toBe(true); + expect(nonDominated([a, c]).sort()).toEqual([0, 1]); + }); + + it('is irreflexive — no plan dominates itself (US3-4)', () => { + expect(dominates([b(2, 2), b(3, 3)], [b(2, 2), b(3, 3)])).toBe(false); + expect(dominates([b(1, 4)], [b(1, 4)])).toBe(false); + }); + + it('a no-worse-everywhere-and-strictly-better-once plan dominates', () => { + const a: CriteriaVector = [b(5, 6), b(3, 4)]; // equal-ish on 1, strictly better on 0 + const c: CriteriaVector = [b(1, 2), b(3, 4)]; + expect(dominates(a, c)).toBe(true); + expect(dominates(c, a)).toBe(false); + }); + + it('throws on misaligned criteria vectors', () => { + expect(() => dominates([b(1, 1)], [b(1, 1), b(2, 2)])).toThrow(/differing length/); + }); + }); + + describe('ε is the distinctness knob — increasing it never shrinks the set', () => { + it('is monotone in ε (US3-3)', () => { + // A dominates B at ε=0 (separated by 1); raising ε past the gap frees B. + const vectors: CriteriaVector[] = [ + [b(5, 6), b(1, 2)], + [b(3, 4), b(1, 2)], + ]; + const at0 = nonDominated(vectors, 0); + const at1 = nonDominated(vectors, 1); // gap is 5-4 = 1, not > 1 ⇒ no domination + const at5 = nonDominated(vectors, 5); + expect(at0).toEqual([0]); // B dominated at ε=0 + expect(at1.length).toBeGreaterThanOrEqual(at0.length); // never shrinks + expect(at5.sort()).toEqual([0, 1]); // both distinct at a large ε + }); + }); +}); diff --git a/tests/generate.test.ts b/tests/generate.test.ts new file mode 100644 index 0000000..b031d9a --- /dev/null +++ b/tests/generate.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import type { VignetteConfig } from '../src/generated/types.js'; +import { validateInstance } from '../src/validate.js'; +import { AXES, allSignatures, generateCandidates, signatureId } from '../src/generate.js'; + +const config = JSON.parse( + readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'), +) as VignetteConfig; + +const FORCE_ELEMENTS = ['FE-BROOM', 'FE-PACKHORSE', 'FE-ANVIL', 'FE-FALCON', 'FE-KINGFISHER']; + +describe('SPEC-08 — the seeded axis fan-out generator (research note 03 §5.2)', () => { + it('produces one valid Plan per axis signature (2⁴ = 16) with all five force elements (US4-1)', () => { + const plans = generateCandidates(config, 1); + expect(plans).toHaveLength(16); + for (const p of plans) { + expect(validateInstance('Plan', p)).toEqual([]); // validates against the generated LinkML type + const elements = p.elements.map((e) => e.force_element).sort(); + expect(elements).toEqual([...FORCE_ELEMENTS].sort()); + expect(p.logical_id.startsWith('P-')).toBe(true); // P* frozen family (vignette §8) + expect(p.generator).toMatch(/axis fan-out/); + } + const ids = new Set(plans.map((p) => p.logical_id)); + expect(ids.size).toBe(16); // every signature is a distinct plan + }); + + it('spans both settings of all four axes across the candidate set (US4-2)', () => { + const sigs = allSignatures(); + for (const axis of Object.keys(AXES) as (keyof typeof AXES)[]) { + const settings = new Set(sigs.map((s) => s[axis])); + expect(settings.size).toBe(2); // both settings of each axis appear + } + }); + + it('is deterministic in (config, seed) — same seed ⇒ identical candidate list (US4-3)', () => { + const a = generateCandidates(config, 7); + const b = generateCandidates(config, 7); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('stamps the seed onto every candidate; the candidate SET is seed-independent', () => { + const a = generateCandidates(config, 1); + const b = generateCandidates(config, 2); + expect(a.every((p) => p.seed === 1)).toBe(true); + expect(b.every((p) => p.seed === 2)).toBe(true); + // Same signatures, only the stamped seed differs (the organiser uses the seed). + expect(a.map((p) => p.logical_id)).toEqual(b.map((p) => p.logical_id)); + }); + + it('the logical id encodes the axis signature (self-documenting P* id)', () => { + expect(signatureId({ approach: 'strait_early', suppression: 'fires_forward', causeway: 'contest', extraction: 'pull_early' })) + .toBe('P-SeFfCcXe'); + }); +}); diff --git a/tests/handful.test.ts b/tests/handful.test.ts new file mode 100644 index 0000000..8505b3c --- /dev/null +++ b/tests/handful.test.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'node:fs'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { + Commitment, + CommitmentVerdict, + 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 { HandfulService } from '../src/handful.js'; +import { isRefusal, type HandfulResult } from '../src/seam.js'; +import { type Criterion, type CriteriaVector, distinct } from '../src/dominance.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 cids = commitments.map((c) => c.logical_id).sort(); + +interface Rig { + svc: KnowledgeService; + scorer: ScoreService; + handfuls: HandfulService; + 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, engine_version: ENGINE }); + if (isRefusal(compiled)) throw new Error(`compile refused: ${compiled.reason}`); + + const scorer = new ScoreService({ store: svc.store, trace: svc.trace, config, commitments }); + const handfuls = new HandfulService({ store: svc.store, scorer, config, commitments }); + return { svc, scorer, handfuls, worldRef: compiled.world }; +} + +const ok = (r: HandfulResult) => { + if (isRefusal(r)) throw new Error(`expected success, got refusal ${r.reason}`); + return r; +}; + +/** Re-score a returned plan to recover its C1–C6 margin vector for domination checks. */ +async function vectorOf(rig: Rig, planRef: Ref): Promise { + const scored = await rig.scorer.score({ plan: planRef, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE }); + if (isRefusal(scored)) throw new Error('re-score refused'); + const byC = new Map(scored.verdicts.map((v: CommitmentVerdict) => [v.commitment, v])); + return cids.map((id) => (byC.get(id)?.margin ?? null) as Criterion); +} + +describe('SPEC-08 — /plan/handful over the Meridian base world', () => { + let rig: Rig; + beforeEach(async () => { + rig = await setup(); + }); + + it('returns 3–5 plans, each resolving to a stored Plan (US1-1, SC-001)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + expect(h.plans.length).toBeGreaterThanOrEqual(3); + expect(h.plans.length).toBeLessThanOrEqual(5); + for (const p of h.plans) { + const plan = rig.svc.store.get(p.content_hash) as Plan | undefined; + expect(plan).toBeDefined(); + expect(plan!.logical_id).toBe(p.logical_id); + } + }); + + it('every pair in the handful is mutually non-dominated (US1-2, SC-001)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + const vectors = await Promise.all(h.plans.map((p) => vectorOf(rig, p))); + for (let i = 0; i < vectors.length; i++) { + for (let j = i + 1; j < vectors.length; j++) { + expect(distinct(vectors[i]!, vectors[j]!)).toBe(true); // neither ε-dominates the other + } + } + }); + + it('the handful shows more than one distinct verdict on at least two commitments (US1-3)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + const perCommitment: Map> = new Map(cids.map((id) => [id, new Set()])); + for (const p of h.plans) { + const scored = await rig.scorer.score({ plan: p, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE }); + if (isRefusal(scored)) throw new Error('score refused'); + for (const v of scored.verdicts) perCommitment.get(v.commitment)!.add(v.verdict); + } + const varying = [...perCommitment.values()].filter((s) => s.size > 1).length; + expect(varying).toBeGreaterThanOrEqual(2); + }); + + it('same (world, seed) ⇒ identical stamp, plans, scores, organisation (US2-1, SC-002)', async () => { + const a = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + const b = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + expect(a.stamp).toBe(b.stamp); + expect(a.plans).toEqual(b.plans); + expect(a.scores).toEqual(b.scores); + expect(a.organisation).toEqual(b.organisation); + }); + + it('a different seed changes the stamp (US2-2, SC-002)', async () => { + const a = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + const b = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 2, engine_version: ENGINE })); + expect(a.stamp).not.toBe(b.stamp); + }); + + it('derives a non-empty distinct_because per plan, aligned to plans (FR-010, SC-006)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + expect(h.organisation.distinct_because).toHaveLength(h.plans.length); + for (const reason of h.organisation.distinct_because) { + expect(reason.length).toBeGreaterThan(0); + expect(reason).toMatch(/non-dominated/); + } + }); + + it('every handful member opens a complete trace chain verdict → world → knowledge (G3, SC-005)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE })); + const worldHash = rig.worldRef.content_hash; + for (const p of h.plans) { + const scored = await rig.scorer.score({ plan: p, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE }); + if (isRefusal(scored)) throw new Error('score refused'); + // Each verdict was written with a scored_from edge to the world it rests on… + const verdictHash = (await rig.svc.store.put(scored.verdicts[0]! as unknown as Record)).content_hash; + const scoredFrom = rig.svc.trace.edges.filter( + (e) => e.edge_type === 'scored_from' && e.from_hash === verdictHash && e.to_hash === worldHash, + ); + expect(scoredFrom.length).toBeGreaterThan(0); + } + // …and the world carries incoming compiled_into edges from named knowledge, so + // the chain verdict → world → knowledge terminates at named owners (G3). + const compiledInto = rig.svc.trace.edges.filter((e) => e.edge_type === 'compiled_into' && e.to_hash === worldHash); + expect(compiledInto.length).toBeGreaterThan(0); + }); + + describe('count clamping (FR-001)', () => { + it('count = 3 returns exactly 3 by diversity cap (SC-001)', async () => { + const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 3, engine_version: ENGINE })); + expect(h.plans).toHaveLength(3); + }); + + it('count below 3 is clamped up to 3, count above 5 is clamped down to 5', async () => { + const low = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 1, engine_version: ENGINE })); + expect(low.plans.length).toBeGreaterThanOrEqual(3); + const high = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 9, engine_version: ENGINE })); + expect(high.plans.length).toBeLessThanOrEqual(5); + }); + }); + + it('refuses unknown_ref when the world cannot be resolved, persisting nothing (FR-011)', async () => { + const before = rig.svc.store.size; + const r = await rig.handfuls.handful({ world: { logical_id: 'NO-SUCH-WORLD', content_hash: '' }, 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); // nothing generated/stored on refusal + }); +});