diff --git a/scratch/.gitignore b/scratch/.gitignore new file mode 100644 index 0000000..d864542 --- /dev/null +++ b/scratch/.gitignore @@ -0,0 +1 @@ +scratch/*.png diff --git a/scratch/build-viz.ts b/scratch/build-viz.ts new file mode 100644 index 0000000..60154e2 --- /dev/null +++ b/scratch/build-viz.ts @@ -0,0 +1,407 @@ +/** + * SCRATCH — builds the SANDPIPER relevance visual from the REAL engine model + * (scratch/relevance.ts → computeModel). Every value on the page is computed by + * the shipped services and pre-rendered; the page adds no numbers of its own. + * + * npx tsx scratch/build-viz.ts + * → scratch/relevance-viz.html (standalone, openable in a browser) + * → scratch/relevance-viz.body.html (content-only, for publishing as an Artifact) + */ +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { computeModel } from './relevance.js'; + +const { viz } = await computeModel(); +const DATA = JSON.stringify(viz); + +const STYLE = ` +:root{ + --bg:#eef1f5; --panel:#ffffff; --panel-2:#f6f8fb; --ink:#151a21; --muted:#5c6773; + --hair:#d5dce4; --hair-2:#e6ebf1; --accent:#0f8a9b; + --decisive:#c07d00; --read:#2f6f9e; --masked:#7a45d0; --inert:#9aa5b1; + --robust:#2f9e6f; --marginal:#3f7fc0; --tight:#c0871a; --violated:#c0455b; + --sea:#c0455b; --mine:#7c2d3a; + --shadow:0 1px 2px rgba(20,30,45,.06),0 8px 24px rgba(20,30,45,.06); + --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace; + --sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; +} +@media (prefers-color-scheme:dark){:root{ + --bg:#0c1016; --panel:#141a22; --panel-2:#0f151c; --ink:#e7edf3; --muted:#8b97a6; + --hair:#232d38; --hair-2:#1b2029; --accent:#35c2d2; + --decisive:#ecaa2b; --read:#5c9bd6; --masked:#a97ff0; --inert:#48535f; + --robust:#3bb488; --marginal:#5b9bd5; --tight:#d7a53a; --violated:#d75f72; + --sea:#d75f72; --mine:#9c3b4a; + --shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.35); +}} +:root[data-theme="light"]{ + --bg:#eef1f5; --panel:#ffffff; --panel-2:#f6f8fb; --ink:#151a21; --muted:#5c6773; + --hair:#d5dce4; --hair-2:#e6ebf1; --accent:#0f8a9b; + --decisive:#c07d00; --read:#2f6f9e; --masked:#7a45d0; --inert:#9aa5b1; + --robust:#2f9e6f; --marginal:#3f7fc0; --tight:#c0871a; --violated:#c0455b; + --sea:#c0455b; --mine:#7c2d3a; +} +:root[data-theme="dark"]{ + --bg:#0c1016; --panel:#141a22; --panel-2:#0f151c; --ink:#e7edf3; --muted:#8b97a6; + --hair:#232d38; --hair-2:#1b2029; --accent:#35c2d2; + --decisive:#ecaa2b; --read:#5c9bd6; --masked:#a97ff0; --inert:#48535f; + --robust:#3bb488; --marginal:#5b9bd5; --tight:#d7a53a; --violated:#d75f72; + --sea:#d75f72; --mine:#9c3b4a; +} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.5; + -webkit-font-smoothing:antialiased;font-size:14px} +.wrap{max-width:1140px;margin:0 auto;padding:32px 20px 56px} +.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin:0 0 10px} +h1{font-size:clamp(22px,3.2vw,30px);line-height:1.12;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance;font-weight:650} +.lede{color:var(--muted);max-width:66ch;margin:0} +.lede b{color:var(--ink);font-weight:600} + +.controls{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;margin:26px 0 6px; + padding:16px;background:var(--panel);border:1px solid var(--hair);border-radius:12px;box-shadow:var(--shadow)} +.seg-label{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin:0 0 8px} +.seg{display:inline-flex;background:var(--panel-2);border:1px solid var(--hair);border-radius:9px;padding:3px;gap:2px} +.seg button{appearance:none;border:0;background:transparent;color:var(--muted);font-family:var(--mono); + font-size:12px;letter-spacing:.02em;padding:7px 13px;border-radius:6px;cursor:pointer;white-space:nowrap;transition:all .12s} +.seg button:hover{color:var(--ink)} +.seg button[aria-pressed="true"]{background:var(--accent);color:#fff} +:root[data-theme="dark"] .seg button[aria-pressed="true"]{color:#07121a} +.seg button:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.narrative{flex:1 1 280px;min-width:240px} +.narrative .name{font-weight:650;font-size:15px;margin:0 0 2px} +.narrative .desc{color:var(--muted);margin:0;font-size:13.5px} + +.grid{display:grid;grid-template-columns:minmax(0,0.92fr) minmax(0,1.08fr);gap:18px;margin-top:18px} +@media (max-width:860px){.grid{grid-template-columns:1fr}} +.panel{background:var(--panel);border:1px solid var(--hair);border-radius:12px;box-shadow:var(--shadow);overflow:hidden} +.panel > h2{font-size:12px;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted); + margin:0;padding:14px 16px 0} +.panel .sub{color:var(--muted);font-size:12.5px;padding:2px 16px 0;margin:0} + +svg.map{display:block;width:100%;height:auto;padding:12px 14px 4px} +.map text{font-family:var(--mono);fill:var(--muted)} +.rlabel{font-size:1.15px;letter-spacing:.02px;text-transform:uppercase} +.maplegend{display:flex;flex-wrap:wrap;gap:12px 16px;padding:6px 16px 16px;font-size:11.5px;color:var(--muted)} +.maplegend span{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);letter-spacing:.02em} +.sw{width:11px;height:11px;border-radius:3px;flex:none;border:1px solid rgba(128,128,128,.35)} + +table.mx{border-collapse:collapse;width:100%;font-size:13px} +table.mx th,table.mx td{padding:7px 9px;text-align:left;border-bottom:1px solid var(--hair-2)} +table.mx thead th{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted); + font-weight:500;cursor:pointer;user-select:none;white-space:nowrap} +table.mx thead th.coa[aria-selected="true"]{color:var(--accent)} +.mxwrap{overflow-x:auto;padding:6px 8px 8px} +.kid{font-family:var(--mono);font-weight:600;font-size:12.5px} +.kmeta{color:var(--muted);font-size:11.5px;font-family:var(--mono)} +.chan{display:inline-block;font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;text-transform:uppercase; + padding:1.5px 5px;border-radius:4px;border:1px solid var(--hair);color:var(--muted);vertical-align:1px} +.chan.read{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,transparent)} +tr.coa-sel td{background:color-mix(in srgb,var(--accent) 6%,transparent)} +td.cell{text-align:center} +.chip{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;letter-spacing:.02em; + padding:3px 8px;border-radius:999px;border:1px solid;white-space:nowrap} +.chip .dot{width:7px;height:7px;border-radius:50%;flex:none} +.chip.decisive{color:var(--decisive);border-color:color-mix(in srgb,var(--decisive) 45%,transparent);background:color-mix(in srgb,var(--decisive) 12%,transparent)} +.chip.decisive .dot{background:var(--decisive)} +.chip.read{color:var(--read);border-color:color-mix(in srgb,var(--read) 40%,transparent)} +.chip.read .dot{background:var(--read)} +.chip.masked{color:var(--masked);border-color:color-mix(in srgb,var(--masked) 45%,transparent)} +.chip.masked .dot{background:var(--masked);box-shadow:inset 0 0 0 1.5px var(--panel)} +.chip.inert{color:var(--inert);border-color:var(--hair)} +.chip.inert .dot{background:var(--inert)} +tr.divider td{padding:9px 9px 4px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);border:0} +tr.rrow{cursor:pointer} +tr.rrow.hot td:first-child{box-shadow:inset 3px 0 0 var(--accent)} + +.verdicts{margin-top:18px} +.vtable{border-collapse:collapse;width:100%;font-size:12.5px} +.vtable th,.vtable td{padding:6px 8px;border-bottom:1px solid var(--hair-2);text-align:center} +.vtable th:first-child,.vtable td:first-child{text-align:left;font-family:var(--mono);font-size:12px} +.vtable thead th{font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500} +.vpill{display:inline-block;font-family:var(--mono);font-size:10.5px;letter-spacing:.03em;padding:2px 7px;border-radius:5px;color:#fff;min-width:64px} +:root[data-theme="dark"] .vpill{color:#0a1218} +.vpill.robust{background:var(--robust)} .vpill.marginal{background:var(--marginal)} +.vpill.tight{background:var(--tight)} .vpill.violated{background:var(--violated)} +.worst td{border-top:2px solid var(--hair)} + +.callout{margin-top:20px;padding:16px 18px;border:1px solid var(--hair);border-left:3px solid var(--accent); + border-radius:10px;background:var(--panel)} +.callout h3{margin:0 0 8px;font-size:12px;font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.callout ul{margin:0;padding-left:18px} +.callout li{margin:5px 0} +.callout .k{font-family:var(--mono);font-weight:600} +.callout em{font-style:normal;color:var(--muted)} +.tag{font-family:var(--mono);font-size:10.5px;padding:1px 6px;border-radius:4px;border:1px solid currentColor} +.tag.decisive{color:var(--decisive)} .tag.masked{color:var(--masked)} + +.foot{margin-top:30px;padding-top:16px;border-top:1px solid var(--hair);color:var(--muted);font-size:12px;max-width:80ch} +.foot code{font-family:var(--mono);font-size:11.5px} +@media (prefers-reduced-motion:reduce){*{transition:none!important}} +`; + +const BODY = ` +
+

Operation Sandpiper · exploratory demonstrator

+

Which intelligence actually decides the plan?

+

Ten intelligence reports describe a coastline — mined channels, a SAM arc, tides, a town. + Three courses of action are drawn over it. The engine decides, reproducibly, which reports each plan + depends on — and that answer shifts with what the enemy does. Pick an enemy course of action and watch it move.

+ +
+
+

Enemy course of action

+
+
+
+

Situation

+

+

+
+
+ +
+
+

The world & the courses of action

+

+ +
+
+ +
+

Relevance matrix — intelligence × course of action

+

Click a plan to trace its routes on the map.

+
+
+
+
+ +
+ +
+

Plan verdicts under the selected enemy course of action

+

Four honest stops, never a score. The matrix above explains what each verdict turns on.

+
+
+ +

Every value shown — verdicts, footprints, decisiveness — is computed by the real ASSAY engine + (compile → score → materialise, engine ) and pre-rendered; the page adds no numbers of its own. + Operation Sandpiper is an exploratory vignette, not Meridian — a scratch demonstrator built to show the shape, not a canonical fixture. + Relevance is computed two ways that agree: read-set (what the scorer reads) and removal (what changes a verdict when withheld).

+
+`; + +const SCRIPT = ` + +`; + +const content = STYLE.replace(/^/, '') + BODY + SCRIPT; + +const standalone = ` + +Operation Sandpiper — relevance${content}`; + +const here = fileURLToPath(new URL('.', import.meta.url)); +writeFileSync(here + 'relevance-viz.html', standalone); +writeFileSync(here + 'relevance-viz.body.html', content); +console.log('wrote scratch/relevance-viz.html and scratch/relevance-viz.body.html'); +console.log(`model: ${viz.intel.length} intel, ${viz.plans.length} COAs, ${viz.scenarioKeys.length} scenarios, ${viz.shifts.length} shift(s), ${viz.masks.length} mask(s)`); diff --git a/scratch/check-viz.ts b/scratch/check-viz.ts new file mode 100644 index 0000000..22dbf90 --- /dev/null +++ b/scratch/check-viz.ts @@ -0,0 +1,49 @@ +/** SCRATCH — functional smoke test of the built page via happy-dom. */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Window } from 'happy-dom'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const html = readFileSync(here + 'relevance-viz.html', 'utf8'); + +const scriptText = html.match(/ diff --git a/scratch/relevance-viz.html b/scratch/relevance-viz.html new file mode 100644 index 0000000..9b612a9 --- /dev/null +++ b/scratch/relevance-viz.html @@ -0,0 +1,377 @@ + + +Operation Sandpiper — relevance +
+

Operation Sandpiper · exploratory demonstrator

+

Which intelligence actually decides the plan?

+

Ten intelligence reports describe a coastline — mined channels, a SAM arc, tides, a town. + Three courses of action are drawn over it. The engine decides, reproducibly, which reports each plan + depends on — and that answer shifts with what the enemy does. Pick an enemy course of action and watch it move.

+ +
+
+

Enemy course of action

+
+
+
+

Situation

+

+

+
+
+ +
+
+

The world & the courses of action

+

+ +
+
+ +
+

Relevance matrix — intelligence × course of action

+

Click a plan to trace its routes on the map.

+
+
+
+
+ +
+ +
+

Plan verdicts under the selected enemy course of action

+

Four honest stops, never a score. The matrix above explains what each verdict turns on.

+
+
+ +

Every value shown — verdicts, footprints, decisiveness — is computed by the real ASSAY engine + (compile → score → materialise, engine ) and pre-rendered; the page adds no numbers of its own. + Operation Sandpiper is an exploratory vignette, not Meridian — a scratch demonstrator built to show the shape, not a canonical fixture. + Relevance is computed two ways that agree: read-set (what the scorer reads) and removal (what changes a verdict when withheld).

+
+ + + \ No newline at end of file diff --git a/scratch/relevance.ts b/scratch/relevance.ts new file mode 100644 index 0000000..9f89739 --- /dev/null +++ b/scratch/relevance.ts @@ -0,0 +1,619 @@ +/** + * EXPLORATORY PROTOTYPE — not a spec, not register-blessed, not on the DEC-11 + * research-first path. Committed only so the exploration survives the session; + * promotion to a proper second vignette (research note → register candidate → + * spec-kit) is a separate, deliberate decision. + * + * Purpose: prove one demonstration shape against the REAL ASSAY engine — + * + * (1) a fresh military world ("Operation SANDPIPER") whose intelligence is + * authored to describe the world, BLIND to any course of action; + * (2) three hand-drawn COAs laid over that world afterwards; + * (3) the machine deciding, PREDICTABLY (== reproducibly), which piece of + * intelligence each COA actually depends on — computed two ways that must + * agree, and checked against a hand-derived relevance oracle. + * + * It drives the shipped services unchanged (compile/score/materialise/metrics); + * the only thing added is a thin read-set projection over the same sparse + * channels the scorer reads — no new engine (DEC-10 in spirit). + * + * Run: npx tsx scratch/relevance.ts + */ +import { KnowledgeService } from '../src/knowledge.js'; +import { CompileService } from '../src/compile.js'; +import { ScoreService } from '../src/score.js'; +import { ENGINE_VERSION } from '../src/engine.js'; +import { channelAt } from '../src/materialise.js'; +import { isRefusal } from '../src/seam.js'; +import type { Ref } from '../src/store.js'; +import type { + Band, + ChannelKind, + Commitment, + CommitmentVerdict, + CompiledWorld, + KnowledgeObject, + Plan, + RegionOverride, + VignetteConfig, +} from '../src/generated/types.js'; + +// ─────────────────────────────────────────────────────────────────────────── +// 1. THE WORLD — grid, channels, region geometry, subject routing. +// (A fresh namespace: VC-SANDPIPER. Nothing here references a COA.) +// ─────────────────────────────────────────────────────────────────────────── + +const config: VignetteConfig = { + logical_id: 'VC-SANDPIPER', + version: 1, + grid: { cols: 40, rows: 40, cell_km: 2, timestep_hours: 6, horizon_steps: 48 }, + channels: [ + { kind: 'mobility', default: { lo: 1, hi: 1, unit: 'transit factor' } }, + { kind: 'threat', default: { lo: 0, hi: 0, unit: 'threat index' } }, + { kind: 'storm', default: { lo: 0, hi: 0, unit: 'm surge' } }, + { kind: 'tide', default: { lo: 0, hi: 0, unit: 'm surge' } }, + { kind: 'civil_density', default: { lo: 0, hi: 0, unit: 'persons' } }, + { kind: 'sensor', default: { lo: 0, hi: 0, unit: 'coverage index' } }, + ], + regions: [ + { name: 'open_water', x0: 0, y0: 30, x1: 39, y1: 39 }, + { name: 'north_channel', x0: 6, y0: 10, x1: 16, y1: 16 }, + { name: 'south_channel', x0: 6, y0: 22, x1: 16, y1: 28 }, + { name: 'cobalt_strait', x0: 18, y0: 12, x1: 26, y1: 20 }, + { name: 'raptor_arc', x0: 26, y0: 6, x1: 34, y1: 16 }, + { name: 'fac_waters', x0: 22, y0: 18, x1: 30, y1: 24 }, + { name: 'civil_town', x0: 18, y0: 24, x1: 26, y1: 30 }, + { name: 'causeway', x0: 11, y0: 15, x1: 17, y1: 21 }, + { name: 'objective', x0: 22, y0: 22, x1: 30, y1: 30 }, + { name: 'battery_sensor', x0: 30, y0: 2, x1: 36, y1: 8 }, + { name: 'north_shoal', x0: 2, y0: 2, x1: 8, y1: 8 }, + ], + subject_map: [ + { subject: 'mobility.north_channel', channel: 'mobility', region: 'north_channel' }, + { subject: 'mobility.cobalt_strait', channel: 'mobility', region: 'cobalt_strait' }, + { subject: 'mobility.causeway', channel: 'mobility', region: 'causeway' }, + { subject: 'mobility.north_shoal', channel: 'mobility', region: 'north_shoal' }, + { subject: 'threat.raptor_arc', channel: 'threat', region: 'raptor_arc' }, + { subject: 'threat.fac_waters', channel: 'threat', region: 'fac_waters' }, + { subject: 'weather.south_channel', channel: 'storm', region: 'south_channel' }, + { subject: 'civil_density.town', channel: 'civil_density', region: 'civil_town' }, + { subject: 'sensor.battery', channel: 'sensor', region: 'battery_sensor' }, + { subject: 'tide.open_water', channel: 'tide', region: 'open_water' }, + ], +}; + +// ─────────────────────────────────────────────────────────────────────────── +// 2. THE INTELLIGENCE — authored to describe the world, blind to any COA. +// Each entry says something true about a place. Some places routes will +// cross; some they never will. The author does not know which. +// ─────────────────────────────────────────────────────────────────────────── + +const prov = ( + source_class: 'observed' | 'reported' | 'assessed' | 'assumption', + confidence: 'low' | 'moderate' | 'high', + owner: string, + single_source = false, +) => ({ source_class, confidence, owner, single_source, collected_at: 0 }); + +const knowledge: KnowledgeObject[] = [ + // ---- READ channels (mobility, threat): CAN be load-bearing ---- + { + logical_id: 'S1', version: 1, jipoe_step: 'step2_describe_effects', + question: 'What transit factor does the mined North Channel impose?', + subject: 'mobility.north_channel', encoding_class: 'banded_soft_cost', + answer: { lo: 0.3, hi: 0.6, unit: 'transit factor' }, + provenance: prov('assessed', 'moderate', 'J-2 MIW cell'), + criticality: 'critical', status: 'answered', + }, + { + logical_id: 'S2', version: 1, jipoe_step: 'step3_evaluate_adversary', + question: 'What is the SAM threat index over the Raptor arc?', + subject: 'threat.raptor_arc', encoding_class: 'banded_soft_cost', + answer: { lo: 1, hi: 2, unit: 'threat index' }, + provenance: prov('assessed', 'moderate', 'J-2 air'), + criticality: 'important', status: 'answered', + }, + { + logical_id: 'S3', version: 1, jipoe_step: 'step3_evaluate_adversary', + question: 'What is the fast-attack-craft threat index in the FAC waters?', + subject: 'threat.fac_waters', encoding_class: 'banded_soft_cost', + answer: { lo: 2, hi: 4, unit: 'threat index' }, + provenance: prov('assessed', 'low', 'J-2 maritime'), + criticality: 'important', status: 'answered', + }, + { + logical_id: 'S4', version: 1, jipoe_step: 'step1_define_oe', + question: 'What residual transit factor do obstacles leave in Cobalt Strait?', + subject: 'mobility.cobalt_strait', encoding_class: 'banded_soft_cost', + answer: { lo: 0.6, hi: 0.9, unit: 'transit factor' }, + provenance: prov('assessed', 'moderate', 'J-2 MIW cell'), + criticality: 'important', status: 'answered', + }, + { + logical_id: 'S5', version: 1, jipoe_step: 'step2_describe_effects', + question: 'Is the Saddle causeway trafficable (transit factor)?', + subject: 'mobility.causeway', encoding_class: 'banded_soft_cost', + answer: { lo: 0.8, hi: 1.0, unit: 'transit factor' }, + provenance: prov('assessed', 'moderate', 'J-2 imagery'), + criticality: 'important', status: 'answered', + }, + { + logical_id: 'S6', version: 1, jipoe_step: 'step2_describe_effects', + question: 'What transit factor do uncharted mines leave on the North Shoal?', + subject: 'mobility.north_shoal', encoding_class: 'banded_soft_cost', + answer: { lo: 0.2, hi: 0.5, unit: 'transit factor' }, + provenance: prov('assessed', 'low', 'J-2 MIW cell'), + criticality: 'routine', status: 'answered', + }, + // ---- UNREAD channels (storm/tide/civil/sensor): describe the world, + // cannot touch any of these commitments (structurally inert) ---- + { + logical_id: 'S7', version: 1, jipoe_step: 'step2_describe_effects', + question: 'What storm surge is forecast in the South Channel D+0..D+4?', + subject: 'weather.south_channel', encoding_class: 'banded_soft_cost', + answer: { lo: 1.0, hi: 1.6, unit: 'm surge' }, + provenance: prov('reported', 'high', 'METOC'), + criticality: 'important', validity: { valid_from: 0, valid_until: 24 }, status: 'answered', + }, + { + logical_id: 'S8', version: 1, jipoe_step: 'step1_define_oe', + question: 'What is the civil population of Cobalt Town?', + subject: 'civil_density.town', encoding_class: 'banded_soft_cost', + answer: { lo: 18000, hi: 32000, unit: 'persons' }, + provenance: prov('reported', 'moderate', 'J-2 civil affairs'), + criticality: 'important', status: 'answered', + }, + { + logical_id: 'S9', version: 1, jipoe_step: 'step3_evaluate_adversary', + question: 'What coverage does the coastal battery radar provide?', + subject: 'sensor.battery', encoding_class: 'banded_soft_cost', + answer: { lo: 0.4, hi: 0.8, unit: 'coverage index' }, + provenance: prov('assessed', 'moderate', 'J-2 ELINT'), + criticality: 'routine', status: 'answered', + }, + { + logical_id: 'S10', version: 1, jipoe_step: 'step1_define_oe', + question: 'What is the tidal range across the open water staging box?', + subject: 'tide.open_water', encoding_class: 'banded_soft_cost', + answer: { lo: 0.2, hi: 0.5, unit: 'm surge' }, + provenance: prov('observed', 'high', 'CTF hydrographic'), + criticality: 'routine', status: 'answered', + }, +]; + +const ALL_IDS = knowledge.map((k) => k.logical_id); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. THE COMMANDER'S INTENT — commitments (metric names fixed by the shipped +// registry; force-element ids are the registry's fixed responsibles). +// ─────────────────────────────────────────────────────────────────────────── + +const commitments: Commitment[] = [ + { logical_id: 'C1', version: 1, statement: 'Relief column reaches the objective by D+6 (step 24).', tier: 'must', metric: 'port_open_step', comparator: 'at_most', threshold: 24, unit: 'step', owner: 'J-3' }, + { logical_id: 'C2', version: 1, statement: 'Cobalt Strait swept open by step 20.', tier: 'must', metric: 'strait_open_step', comparator: 'at_most', threshold: 20, unit: 'step', owner: 'J-3' }, + { logical_id: 'C3', version: 1, statement: 'Assault group not over-exposed to threat.', tier: 'should', metric: 'threat_exposure', comparator: 'at_most', threshold: 60, unit: 'threat index-hours', owner: 'J-3', scope: 'FE-ANVIL' }, + { logical_id: 'C4', version: 1, statement: 'No fires into the Cobalt Town district.', tier: 'should', metric: 'civil_harm_exposure', comparator: 'at_most', threshold: 0, unit: 'legs', owner: 'J-3', scope: 'civil_town' }, + { logical_id: 'C5', version: 1, statement: 'Saddle causeway taken intact.', tier: 'prefer', metric: 'causeway_intact', comparator: 'at_least', threshold: 1, unit: 'intact', owner: 'J-3' }, + { logical_id: 'C6', version: 1, statement: 'Recon detachment extracted by step 40.', tier: 'prefer', metric: 'extraction_step', comparator: 'at_most', threshold: 40, unit: 'step', owner: 'J-3' }, +]; + +// ─────────────────────────────────────────────────────────────────────────── +// 4. THREE COAs — hand-drawn over the world AFTER the intel was authored. +// NORTH runs the mined channel; SOUTH avoids it; CENTER cuts the strait. +// ─────────────────────────────────────────────────────────────────────────── + +type Leg = { x: number; y: number; enter_step: number; exit_step: number }; +const leg = (x: number, y: number, enter_step: number, exit_step: number): Leg => ({ x, y, enter_step, exit_step }); + +const planNorth: Plan = { + logical_id: 'P-NORTH', version: 1, name: 'North-channel push', seed: 1, generator: 'hand-drawn', + elements: [ + { force_element: 'FE-BROOM', route: [leg(10, 35, 0, 4), leg(11, 13, 4, 8), leg(22, 16, 8, 12)] }, + { force_element: 'FE-PACKHORSE', route: [leg(10, 35, 0, 4), leg(11, 13, 4, 9), leg(14, 18, 9, 13), leg(26, 25, 13, 18)] }, + { force_element: 'FE-ANVIL', route: [leg(10, 35, 0, 6), leg(28, 12, 6, 10), leg(26, 20, 10, 12)] }, + { force_element: 'FE-FALCON', route: [leg(10, 35, 0, 4), leg(30, 10, 4, 8)] }, + { force_element: 'FE-KINGFISHER', route: [leg(33, 5, 0, 4), leg(10, 35, 4, 36)] }, + ], +}; + +const planSouth: Plan = { + logical_id: 'P-SOUTH', version: 1, name: 'South-channel sweep', seed: 1, generator: 'hand-drawn', + elements: [ + { force_element: 'FE-BROOM', route: [leg(10, 35, 0, 4), leg(11, 25, 4, 8), leg(22, 16, 8, 12)] }, + { force_element: 'FE-PACKHORSE', route: [leg(10, 35, 0, 4), leg(11, 25, 4, 10), leg(14, 18, 10, 14), leg(26, 25, 14, 19)] }, + { force_element: 'FE-ANVIL', route: [leg(10, 35, 0, 6), leg(26, 20, 6, 8), leg(12, 25, 8, 10)] }, // fac waters, then south channel + { force_element: 'FE-FALCON', route: [leg(10, 35, 0, 4), leg(22, 27, 4, 8)] }, // through the town + { force_element: 'FE-KINGFISHER', route: [leg(33, 5, 0, 4), leg(10, 35, 4, 36)] }, + ], +}; + +const planCenter: Plan = { + logical_id: 'P-CENTER', version: 1, name: 'Centre strait cut', seed: 1, generator: 'hand-drawn', + elements: [ + { force_element: 'FE-BROOM', route: [leg(10, 35, 0, 5), leg(22, 16, 5, 9)] }, + { force_element: 'FE-PACKHORSE', route: [leg(10, 35, 0, 5), leg(22, 16, 5, 9), leg(14, 18, 9, 12), leg(26, 25, 12, 17)] }, + { force_element: 'FE-ANVIL', route: [leg(10, 35, 0, 6), leg(26, 20, 6, 10)] }, + { force_element: 'FE-FALCON', route: [leg(10, 35, 0, 4), leg(30, 10, 4, 8)] }, + { force_element: 'FE-KINGFISHER', route: [leg(33, 5, 0, 4), leg(10, 35, 4, 36)] }, + ], +}; + +const PLANS = [planNorth, planSouth, planCenter]; + +// Hand-derived relevance oracle — which intel each COA reads, reasoned from +// geometry alone (independent of the implementation). Checked below. +const ORACLE_FOOTPRINT: Record = { + 'P-NORTH': ['S1', 'S2', 'S3', 'S4', 'S5'], + 'P-SOUTH': ['S3', 'S4', 'S5'], + 'P-CENTER': ['S3', 'S4', 'S5'], +}; + +// ─────────────────────────────────────────────────────────────────────────── +// 4b. ADVERSARY COURSES OF ACTION — hypotheticals that re-shape the world. +// Each is a ScenarioCOA whose `excursion` overlays the base world (and, by +// SPEC-20 layered precedence, BEATS our base assessment where it speaks). +// Authored to describe what the ENEMY might do — still blind to our COAs. +// ─────────────────────────────────────────────────────────────────────────── + +interface Scenario { logical_id: string; version: number; name: string; narrative: string; excursion: { channel: ChannelKind; region: string; override: Band }[] } + +const SCENARIOS: Scenario[] = [ + { + logical_id: 'R-SEA', version: 1, name: 'Littoral fight', + narrative: 'The enemy surges fast-attack craft into the southern approaches.', + excursion: [{ channel: 'threat', region: 'south_channel', override: { lo: 4, hi: 6, unit: 'threat index' } }], + }, + { + logical_id: 'R-STRAITMINE', version: 1, name: 'Strait denial', + narrative: 'The enemy heavily mines Cobalt Strait to deny the direct cut.', + excursion: [{ channel: 'mobility', region: 'cobalt_strait', override: { lo: 0.1, hi: 0.3, unit: 'transit factor' } }], + }, +]; +const SCENARIO_KEYS = ['BASE', ...SCENARIOS.map((s) => s.logical_id)]; + +// ─────────────────────────────────────────────────────────────────────────── +// Engine wiring helpers (all real services). +// ─────────────────────────────────────────────────────────────────────────── + +async function buildStore(ids: string[]): Promise { + const ks = new KnowledgeService({ actor: 'J-2', role: 'analyst' }); + for (const id of ids) { + const ko = knowledge.find((k) => k.logical_id === id)!; + const r = await ks.create(ko); + if (isRefusal(r)) throw new Error(`unexpected refusal creating ${id}: ${r.explanation}`); + } + // Plans and adversary scenarios live in the same store (compile resolves the + // scenario by logical id; the scorer resolves plans by ref). + for (const p of PLANS) await ks.store.put(p as unknown as Record); + for (const s of SCENARIOS) await ks.store.put(s as unknown as Record); + return ks; +} + +/** scenarioKey === 'BASE' compiles the un-excursioned world. */ +async function compileWorld(ks: KnowledgeService, ids: string[], scenarioKey: string): Promise<{ ref: Ref; world: CompiledWorld; stamp: string }> { + const compile = new CompileService({ knowledge: ks }); + const req: Parameters[0] = { + knowledge: ids.map((id) => ({ logical_id: id, content_hash: '' })), + config, + engine_version: ENGINE_VERSION, + }; + if (scenarioKey !== 'BASE') req.scenario = scenarioKey; + const res = await compile.compile(req); + if (isRefusal(res)) throw new Error(`compile refused (${scenarioKey}): ${res.explanation}`); + const world = ks.store.get(res.world.content_hash) as CompiledWorld; + return { ref: res.world, world, stamp: res.stamp }; +} + +async function scoreAll(ks: KnowledgeService, worldRef: Ref, scenarioKey: string): Promise>> { + const scorer = new ScoreService({ store: ks.store, trace: ks.trace, config, commitments }); + const out: Record> = {}; + for (const p of PLANS) { + const planRef = ks.store.versions(p.logical_id).at(-1)!; + const res = await scorer.score({ plan: planRef, world: worldRef, scenario: scenarioKey, engine_version: ENGINE_VERSION }); + if (isRefusal(res)) throw new Error(`score refused for ${p.logical_id} (${scenarioKey}): ${res.explanation}`); + out[p.logical_id] = Object.fromEntries(res.verdicts.map((v: CommitmentVerdict) => [v.commitment, v.verdict])); + } + return out; +} + +/** One full run: build store from `ids`, compile under `scenarioKey`, score all. */ +async function run(ids: string[], scenarioKey: string): Promise<{ world: CompiledWorld; verdicts: Record>; stamp: string }> { + const ks = await buildStore(ids); + const w = await compileWorld(ks, ids, scenarioKey); + const verdicts = await scoreAll(ks, w.ref, scenarioKey); + return { world: w.world, verdicts, stamp: w.stamp }; +} + +// ── The read-set projection: which source the scorer actually reads at each +// metric read-point. Mirrors channelAt EXACTLY (incl. SPEC-20 excursion +// precedence) and cross-checks the winning value against channelAt. + +type MetricRead = { kind: 'reach' | 'exposure' | 'fires' | 'state'; element?: string; channel?: ChannelKind; region?: string }; +const METRIC_READS: Record = { + strait_open_step: { kind: 'reach', element: 'FE-BROOM', channel: 'mobility' }, + port_open_step: { kind: 'reach', element: 'FE-PACKHORSE', channel: 'mobility' }, + extraction_step: { kind: 'reach', element: 'FE-KINGFISHER', channel: 'mobility' }, + threat_exposure: { kind: 'exposure', element: 'FE-ANVIL', channel: 'threat' }, + civil_harm_exposure: { kind: 'fires', element: 'FE-FALCON' }, + causeway_intact: { kind: 'state', channel: 'mobility', region: 'causeway' }, +}; + +function winnerAt(world: CompiledWorld, kind: ChannelKind, x: number, y: number, t: number): string | undefined { + const channel = world.channels.find((c) => c.kind === kind)!; + const contains = (o: RegionOverride) => { + const g = config.regions.find((r) => r.name === o.region)!; + return x >= g.x0 && x <= g.x1 && y >= g.y0 && y <= g.y1; + }; + const activeAt = (o: RegionOverride) => (o.from_step === undefined || t >= o.from_step) && (o.until_step === undefined || t <= o.until_step); + const active = (channel.regions ?? []).filter((o) => activeAt(o) && contains(o)); + if (active.length === 0) return undefined; // default → no knowledge read here + // SPEC-20: an excursion-layer override (source names the world's scenario) wins. + const excursion = active.filter((o) => world.scenario !== undefined && o.source === world.scenario); + const candidates = excursion.length > 0 ? excursion : active; + const area = (name: string) => { const g = config.regions.find((r) => r.name === name)!; return (g.x1 - g.x0 + 1) * (g.y1 - g.y0 + 1); }; + candidates.sort((a, b) => (b.from_step ?? 0) - (a.from_step ?? 0) || area(a.region) - area(b.region)); + const winner = candidates[0]!; + const viaEngine = channelAt(world, config, kind, x, y, t); + if (viaEngine.lo !== winner.value.lo || viaEngine.hi !== winner.value.hi) { + throw new Error(`read-set/channelAt disagree at ${kind}(${x},${y},${t})`); + } + return winner.source; +} + +function readsFor(plan: Plan, commitment: Commitment, world: CompiledWorld): Set { + const m = METRIC_READS[commitment.metric]!; + const sources = new Set(); + if (m.kind === 'fires') return sources; // reads no assessed channel — pure route geometry + if (m.kind === 'state') { + const g = config.regions.find((r) => r.name === m.region)!; + const cx = Math.floor((g.x0 + g.x1) / 2), cy = Math.floor((g.y0 + g.y1) / 2); + const s = winnerAt(world, m.channel!, cx, cy, config.grid.horizon_steps); + if (s) sources.add(s); + return sources; + } + const ep = plan.elements.find((e) => e.force_element === m.element); + for (const l of ep?.route ?? []) { + const s = winnerAt(world, m.channel!, l.x, l.y, l.enter_step); + if (s) sources.add(s); + } + return sources; +} + +function footprint(plan: Plan, world: CompiledWorld): Set { + const all = new Set(); + for (const c of commitments) for (const s of readsFor(plan, c, world)) all.add(s); + return all; +} + +const isOurIntel = (source: string) => source.startsWith('S'); +const READ_CHANNELS: ChannelKind[] = ['mobility', 'threat']; +const ORDER: Record = { violated: 0, tight: 1, marginal: 2, robust: 3 }; +export type Relevance = 'decisive' | 'read' | 'masked' | 'inert'; + +// ─────────────────────────────────────────────────────────────────────────── +// The model — every number here is produced by the real engine. computeModel +// runs the whole pipeline (per scenario, and once per single-K removal) and +// returns both the raw structures (for the console proofs) and a +// JSON-serialisable `viz` model (for the visual, scratch/build-viz.ts). +// ─────────────────────────────────────────────────────────────────────────── + +export interface VizModel { + engine: string; + grid: { cols: number; rows: number }; + scenarioKeys: string[]; + scenarios: { key: string; name: string; narrative: string; excursion: { channel: string; region: string; band: [number, number]; unit: string }[] }[]; + regions: { name: string; x0: number; y0: number; x1: number; y1: number; channel: string | null; source: string | null; read: boolean }[]; + intel: { id: string; question: string; channel: string; region: string; band: [number, number]; unit: string; read: boolean; source_class: string; confidence: string }[]; + plans: { id: string; name: string; routes: { element: string; legs: { x: number; y: number; enter: number; exit: number }[] }[] }[]; + commitments: { id: string; statement: string; tier: string; metric: string }[]; + verdicts: Record>>; // sk → plan → C → verdict + worst: Record>; // plan → C → worst-case verdict + relevance: Record>>; // sk → plan → S → label + shifts: { intel: string; plan: string; decisiveUnder: string[]; inertUnder: string[] }[]; + masks: { intel: string; plan: string; scenario: string }[]; +} + +export async function computeModel(): Promise<{ + baseline: Record>>; + fp: Record>>; + decisive: Record>>; + viz: VizModel; +}> { + const baseline: Record>> = {}; + for (const sk of SCENARIO_KEYS) baseline[sk] = await run(ALL_IDS, sk); + + // Structural footprint (our intel only) and behavioural decisiveness, per scenario. + const fp: Record>> = {}; + const decisive: Record>> = {}; + for (const sk of SCENARIO_KEYS) { + fp[sk] = {}; + for (const p of PLANS) fp[sk][p.logical_id] = new Set([...footprint(p, baseline[sk]!.world)].filter(isOurIntel)); + decisive[sk] = {}; + for (const id of ALL_IDS) { + const sub = await run(ALL_IDS.filter((x) => x !== id), sk); + const moves = new Set(); + for (const p of PLANS) for (const c of commitments) { + if (baseline[sk]!.verdicts[p.logical_id]![c.logical_id] !== sub.verdicts[p.logical_id]![c.logical_id]) moves.add(p.logical_id); + } + decisive[sk][id] = moves; + } + } + + // Per-region channel/source (from the BASE compiled world). + const regionInfo: Record = {}; + for (const ch of baseline['BASE']!.world.channels) for (const o of ch.regions ?? []) { + regionInfo[o.region] = { channel: ch.kind, ...(o.source ? { source: o.source } : {}) }; + } + const subjectRoute = (subject: string) => config.subject_map.find((e) => e.subject === subject)!; + + // Relevance label per (scenario, plan, intel). + const relevance: VizModel['relevance'] = {}; + for (const sk of SCENARIO_KEYS) { + relevance[sk] = {}; + for (const p of PLANS) { + relevance[sk][p.logical_id] = {}; + for (const id of ALL_IDS) { + let label: Relevance; + if (decisive[sk][id]!.has(p.logical_id)) label = 'decisive'; + else if (fp[sk][p.logical_id]!.has(id)) label = 'read'; + else if (sk !== 'BASE' && fp['BASE']![p.logical_id]!.has(id)) label = 'masked'; + else label = 'inert'; + relevance[sk][p.logical_id]![id] = label; + } + } + } + + const shifts: VizModel['shifts'] = []; + for (const id of ALL_IDS) for (const p of PLANS) { + const where = SCENARIO_KEYS.filter((sk) => decisive[sk][id]!.has(p.logical_id)); + if (where.length > 0 && where.length < SCENARIO_KEYS.length) { + shifts.push({ intel: id, plan: p.logical_id, decisiveUnder: where, inertUnder: SCENARIO_KEYS.filter((sk) => !where.includes(sk)) }); + } + } + const masks: VizModel['masks'] = []; + for (const sk of SCENARIO_KEYS) for (const p of PLANS) for (const id of ALL_IDS) { + if (relevance[sk][p.logical_id]![id] === 'masked') masks.push({ intel: id, plan: p.logical_id, scenario: sk }); + } + + const worst: VizModel['worst'] = {}; + for (const p of PLANS) { + worst[p.logical_id] = {}; + for (const c of commitments) { + worst[p.logical_id]![c.logical_id] = SCENARIO_KEYS + .map((sk) => baseline[sk]!.verdicts[p.logical_id]![c.logical_id]!) + .reduce((a, b) => (ORDER[a]! <= ORDER[b]! ? a : b)); + } + } + + const viz: VizModel = { + engine: ENGINE_VERSION, + grid: { cols: config.grid.cols, rows: config.grid.rows }, + scenarioKeys: SCENARIO_KEYS, + scenarios: [ + { key: 'BASE', name: 'Baseline', narrative: 'Our current assessment of the world — no adversary hypothesis applied.', excursion: [] }, + ...SCENARIOS.map((s) => ({ key: s.logical_id, name: s.name, narrative: s.narrative, excursion: s.excursion.map((e) => ({ channel: e.channel, region: e.region, band: [e.override.lo, e.override.hi] as [number, number], unit: e.override.unit })) })), + ], + regions: config.regions.map((g) => { + const info = regionInfo[g.name]; + return { name: g.name, x0: g.x0, y0: g.y0, x1: g.x1, y1: g.y1, channel: info?.channel ?? null, source: info?.source ?? null, read: info ? READ_CHANNELS.includes(info.channel as ChannelKind) : false }; + }), + intel: knowledge.map((k) => { + const r = subjectRoute(k.subject); + return { id: k.logical_id, question: k.question, channel: r.channel, region: r.region, band: [k.answer!.lo, k.answer!.hi] as [number, number], unit: k.answer!.unit, read: READ_CHANNELS.includes(r.channel), source_class: k.provenance!.source_class, confidence: k.provenance!.confidence }; + }), + plans: PLANS.map((p) => ({ id: p.logical_id, name: p.name, routes: p.elements.map((e) => ({ element: e.force_element, legs: (e.route ?? []).map((l) => ({ x: l.x, y: l.y, enter: l.enter_step, exit: l.exit_step })) })) })), + commitments: commitments.map((c) => ({ id: c.logical_id, statement: c.statement, tier: c.tier, metric: c.metric })), + verdicts: Object.fromEntries(SCENARIO_KEYS.map((sk) => [sk, baseline[sk]!.verdicts])), + worst, + relevance, + shifts, + masks, + }; + + return { baseline, fp, decisive, viz }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Console report (the proofs). Runs only when executed directly. +// ─────────────────────────────────────────────────────────────────────────── + +const pad = (s: string, n: number) => (s + ' '.repeat(n)).slice(0, n); +const line = (n = 82) => console.log('─'.repeat(n)); + +async function main() { + console.log('\nOPERATION SANDPIPER — reproducible, scenario-dependent intelligence→COA relevance\n'); + console.log(`engine ${ENGINE_VERSION}; grid ${config.grid.cols}×${config.grid.rows}, horizon ${config.grid.horizon_steps} steps`); + console.log(`scenarios: ${SCENARIO_KEYS.join(', ')}`); + + const { baseline, fp, decisive, viz } = await computeModel(); + + line(); + console.log('COMPILED WORLD (BASE) — every K describes a place; only mobility & threat are read\n'); + for (const ch of baseline['BASE']!.world.channels) { + if (!(ch.regions && ch.regions.length)) continue; + for (const o of ch.regions) { + console.log(` ${pad(o.source ?? '?', 5)} ${pad(ch.kind, 14)} ${pad(o.region, 15)} ${pad(`[${o.value.lo}, ${o.value.hi}] ${o.value.unit}`, 26)} ${READ_CHANNELS.includes(ch.kind) ? 'READ' : 'UNREAD → cannot touch a COA'}`); + } + } + + line(); + console.log('VERDICTS PER SCENARIO + worst-case (COA × commitment)\n'); + console.log(' ' + pad('', 20) + commitments.map((c) => pad(c.logical_id, 9)).join('')); + for (const p of PLANS) { + for (const sk of SCENARIO_KEYS) { + console.log(' ' + pad(`${p.logical_id} @ ${sk}`, 20) + commitments.map((c) => pad(viz.verdicts[sk]![p.logical_id]![c.logical_id]!, 9)).join('')); + } + console.log(' ' + pad(`${p.logical_id} worst-case`, 20) + commitments.map((c) => pad(viz.worst[p.logical_id]![c.logical_id]!, 9)).join('')); + console.log(); + } + + line(); + console.log('RELEVANCE SHIFTS WITH THE ENEMY COA — same intel, different decisiveness\n'); + for (const s of viz.shifts) { + console.log(` ${pad(s.intel, 4)} is DECISIVE for ${pad(s.plan, 9)} under {${s.decisiveUnder.join(', ')}} but INERT under {${s.inertUnder.join(', ')}}`); + } + for (const m of viz.masks) { + console.log(` ${pad(m.intel, 4)} is READ for ${pad(m.plan, 9)} under {BASE} but MASKED by the enemy's own action under {${m.scenario}}`); + } + + // ── Proofs ── + line(); + console.log('PREDICTABLE == REPRODUCIBLE — proofs\n'); + const checks: [string, boolean][] = []; + + // P1: same inputs → identical stamp + verdicts, per scenario, on re-run. + let reruns = true; + for (const sk of SCENARIO_KEYS) { + const again = await run(ALL_IDS, sk); + if (again.stamp !== baseline[sk]!.stamp || JSON.stringify(again.verdicts) !== JSON.stringify(baseline[sk]!.verdicts)) reruns = false; + } + checks.push(['same inputs → identical stamp + verdicts, every scenario', reruns]); + + // P2: shuffled knowledge input order → identical stamp, per scenario. + let shuffleOk = true; + for (const sk of SCENARIO_KEYS) { + const rev = await run([...ALL_IDS].reverse(), sk); + if (rev.stamp !== baseline[sk]!.stamp) shuffleOk = false; + } + checks.push(['shuffled input order → identical stamp, every scenario', shuffleOk]); + + // P3: read-set INERT ⇒ removal-invariant, per scenario (the two methods agree). + let agree = true; const viol: string[] = []; + for (const sk of SCENARIO_KEYS) for (const id of ALL_IDS) for (const p of PLANS) { + const isRead = fp[sk][p.logical_id]!.has(id); + if (!isRead && decisive[sk][id]!.has(p.logical_id)) { agree = false; viol.push(`${id}/${p.logical_id}@${sk}`); } + } + if (viol.length) console.log(' violations:', viol.join(', ')); + checks.push(['read-set INERT ⇒ removal-invariant, every scenario', agree]); + + // P4: BASE footprint == hand-derived oracle. + let oracleOk = true; + for (const p of PLANS) { + const got = [...fp['BASE']![p.logical_id]!].sort().join(','); + const want = [...ORACLE_FOOTPRINT[p.logical_id]!].sort().join(','); + if (got !== want) { oracleOk = false; console.log(` oracle mismatch ${p.logical_id}: got {${got}} want {${want}}`); } + } + checks.push(['BASE footprint == hand-derived relevance oracle', oracleOk]); + + // P5: relevance genuinely SHIFTED — at least one (K, plan) decisive under some + // scenario and inert under another (the whole point of the tune). + let shifted = false; + for (const id of ALL_IDS) for (const p of PLANS) { + const d = SCENARIO_KEYS.filter((sk) => decisive[sk][id]!.has(p.logical_id)).length; + if (d > 0 && d < SCENARIO_KEYS.length) shifted = true; + } + checks.push(['at least one piece of intel changes decisiveness across scenarios', shifted]); + + console.log(); + for (const [name, ok] of checks) console.log(` [${ok ? 'PASS' : 'FAIL'}] ${name}`); + line(); + const allOk = checks.every(([, ok]) => ok); + console.log(allOk ? '\nAll proofs pass — relevance is a reproducible function of (world, COA, enemy COA).\n' : '\nSOME PROOFS FAILED — see above.\n'); + process.exitCode = allOk ? 0 : 1; +} + +// Run the proofs only when invoked directly (build-viz.ts imports computeModel). +import { fileURLToPath } from 'node:url'; +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { console.error(e); process.exitCode = 1; }); +}