clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M7: scenario validation harness

Dami committed 7fc0c8c
Rule-based gates replacing time-fuzz (user direction):
- Transition matrix: every width-compatible variant pair must agree on
  channel floor height within the loop-gap epsilon; centerline entry
  ports pinned to the lattice. Enumerated from the registry - a future
  piece with a bad floor gets named at the exact pair.
- Named regressions: the four real planner bugs from the M5 round
  (island-run self-intersection, WFC-extras strand, canopy cul-de-sac,
  planning-trap entry) pinned by their original seeds with full co-live
  stream invariants. They failed once; they can never fail silently again.
- The 20-seed fuzz remains as smoke; scenarios are the gate.
commit
7fc0c8c868b50fb37e43ac2b53400dc1111cb65c
parent
5930a67e1b1a438734a32edb877dbe8e6eb17fab
author
Dami <dami.etoile@gmail.com>
1 file changed, +187 -0
Atests/scenario_test.ts187++++++++++++++++++++++++++++++
A tests/scenario_test.ts +187 -0
@@ -0,0 +1,187 @@
1+/**2+ * M7 — scenario validation harness (USER RULE: validate against RULES and3+ * NAMED SCENARIOS, not time-based fuzzing).4+ *5+ * Three layers:6+ *7+ * 1. TRANSITION MATRIX — every pair of piece variants that realization8+ * could butt together (width-compatible ports) must agree on channel9+ * floor height within the loop-gap epsilon. Enumerated from the10+ * registry: no time dimension, no sampling — if a future piece lands11+ * with a bad floor, this names the exact pair.12+ *13+ * 2. NAMED REGRESSIONS — every planner/generator bug found so far, pinned14+ * by its original seed. These failed once; they must never fail again:15+ * - island-run-self-intersection (endless-fuzz-2, seg 15): a turny16+ * island run looped onto itself inside one probe.17+ * - wfc-extras-strand (fuzz-1, seg 15): WFC footprint-extra claims18+ * landed after the exit-corridor guard and stranded the successor.19+ * - canopy-cul-de-sac (fuzz-5, seg 13): island canopy claims sealed a20+ * corridor deeper than the flat-continuation check.21+ * - planning-trap-entry (deep-50, seg 8): a trapped entry pose no retry22+ * could fix — the successor-probe gate exists because of this seed.23+ *24+ * 3. SURVIVABILITY SCAFFOLD — the shape M10's physics scenarios plug into:25+ * a scenario = generator work + invariant assertions. Physics kinds26+ * (piece traversal at speed-envelope extremes) arrive with the physics27+ * marble.28+ */29+import { assert } from "@std/assert";30+import { buildRegistry } from "../src/gen/registry.ts";31+import { EndlessWorld, KEEP_SEGMENTS } from "../src/gen/endless/world.ts";32+import type { DecorCatalog } from "../src/gen/decor.ts";33+34+const catalog: DecorCatalog = {35+ packs: [36+ {37+ slug: "nature-kit",38+ ids: JSON.parse(Deno.readTextFileSync(39+ new URL("../assets/nature-kit.manifest.json", import.meta.url),40+ )).pieces.map((p: { id: string }) => p.id),41+ },42+ ],43+};44+45+// ---------------------------------------------------------------------------46+// 1. Transition matrix47+// ---------------------------------------------------------------------------48+49+Deno.test("transition matrix: width-compatible pairs agree on floor height", () => {50+ const registry = buildRegistry();51+ const FLOOR_EPS = 0.06; // the validate loop-gap epsilon52+ const failures: string[] = [];53+ // Group by port width so only realizable transitions are checked.54+ for (const a of registry.variants) {55+ const aOut = a.widthOut ?? "narrow";56+ for (const b of registry.variants) {57+ const bIn = b.widthIn ?? "narrow";58+ if (aOut !== bIn) continue;59+ const gap = Math.abs(a.floorOut - b.floorIn);60+ if (gap > FLOOR_EPS) {61+ failures.push(62+ `${a.id}${a.reversed ? "(rev)" : ""} → ${b.id}${63+ b.reversed ? "(rev)" : ""64+ }: floor ${a.floorOut} → ${b.floorIn} (gap ${gap.toFixed(3)})`,65+ );66+ }67+ }68+ }69+ assert(70+ failures.length === 0,71+ `floor-height mismatches at realizable transitions:\n${72+ failures.slice(0, 12).join("\n")73+ }${failures.length > 12 ? `\n… +${failures.length - 12} more` : ""}`,74+ );75+});76+77+Deno.test("transition matrix: every variant starts and ends on the port lattice", () => {78+ const registry = buildRegistry();79+ const bad: string[] = [];80+ for (const v of registry.variants) {81+ // Entry frame: entry port at (0, ·, −0.5); floors sit floorIn above the82+ // entry base. Centerline must begin exactly there.83+ const first = v.centerline[0];84+ const start = first.kind === "line"85+ ? first.a86+ : first.kind === "cubic"87+ ? first.p[0]88+ : null;89+ if (start) {90+ const dx = Math.abs(start[0] - 0);91+ const dz = Math.abs(start[2] - -0.5);92+ const dy = Math.abs(start[1] - v.floorIn);93+ if (dx > 1e-6 || dz > 1e-6 || dy > 1e-6) {94+ bad.push(95+ `${v.id}${v.reversed ? "(rev)" : ""}: centerline starts at ` +96+ `(${start[0]}, ${start[1]}, ${start[2]}), expected (0, ${v.floorIn}, -0.5)`,97+ );98+ }99+ }100+ // Arc-starting pieces (bends: diameter ON the port face) are checked by101+ // validateRegistry's port-continuity sweep; here we only pin the lattice102+ // for line/cubic starts, which is where new authoring mistakes land.103+ }104+ assert(bad.length === 0, `off-lattice ports:\n${bad.join("\n")}`);105+});106+107+// ---------------------------------------------------------------------------108+// 2. Named regressions — bugs stay fixed109+// ---------------------------------------------------------------------------110+111+interface StreamScenario {112+ name: string;113+ seed: string;114+ /** Generate through this segment index (the original failure point). */115+ through: number;116+ why: string;117+}118+119+const STREAM_REGRESSIONS: StreamScenario[] = [120+ {121+ name: "island-run-self-intersection",122+ seed: "endless-fuzz-2",123+ through: 16,124+ why: "turny island run looped onto itself within one probe (probe now checks pending cells)",125+ },126+ {127+ name: "wfc-extras-strand",128+ seed: "fuzz-1",129+ through: 16,130+ why: "footprint-extra claims landed after the exit guard (realization now runs inside the planning tx)",131+ },132+ {133+ name: "canopy-cul-de-sac",134+ seed: "fuzz-5",135+ through: 14,136+ why: "canopy claims sealed a corridor deeper than the flat check (deep exit check + corridor exemption)",137+ },138+ {139+ name: "planning-trap-entry",140+ seed: "deep-50",141+ through: 10,142+ why: "trapped entry pose unfixable by retries (successor-probe gate exists because of this seed)",143+ },144+];145+146+/** Shared co-live stream invariants (same rules as the endless fuzz). */147+function assertStreamInvariants(seed: string, through: number): void {148+ const w = new EndlessWorld(seed, catalog);149+ const channels = new Map<string, { y: number; seg: number }[]>();150+ for (let i = 0; i <= through; i++) {151+ const seg = w.get(i);152+ for (const c of seg.plan.cells) {153+ const k = `${c.x},${c.z}`;154+ const ys = channels.get(k) ?? [];155+ for (const e of ys) {156+ if (e.seg <= i - KEEP_SEGMENTS) continue;157+ assert(158+ Math.abs(e.y - c.baseY) >= 0.98,159+ `${seed} seg ${i}: channel collision at ${k} (${e.y} vs ${c.baseY})`,160+ );161+ }162+ ys.push({ y: c.baseY, seg: i });163+ channels.set(k, ys);164+ }165+ for (const crossing of seg.plan.crossings) {166+ const isle = seg.islands.find((x) => x.id === crossing.id)!;167+ for (let j = 0; j < crossing.len; j++) {168+ const c = seg.plan.cells[crossing.start + j];169+ assert(170+ isle.placements.some((p) =>171+ p.role === "support" && p.pos[0] === c.x && p.pos[2] === c.z &&172+ p.pos[1] < crossing.topY173+ ),174+ `${seed} seg ${i}: island ${crossing.id} missing mass under run`,175+ );176+ }177+ }178+ }179+}180+181+for (const s of STREAM_REGRESSIONS) {182+ Deno.test(`regression ${s.name} (${s.seed} → seg ${s.through})`, () => {183+ // Generating through the original failure point without throwing IS the184+ // core assertion; the invariants make it strict.185+ assertStreamInvariants(s.seed, s.through);186+ });187+}