Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1409,7 +1409,7 @@ measured rather than assumed:
exactly 14,406, because the shape of the sweep does not depend on the host.

**Tier 2's shelf life is now a FREEZE rather than a deadline.** It compared Rust
against TypeScript, and #227 deletes the TypeScript, so all 942 folds are
against TypeScript, and #227 deletes the TypeScript, so all 1,168 folds are
committed to `test/fixtures/tier2-checksums.json` and each spec asserts BOTH
arms against the frozen value instead of against each other. When the TypeScript
arm goes, the wasm arm keeps running against a number captured while the two
Expand All @@ -1432,6 +1432,60 @@ the table is for. **Read a moved number, do not adjust it.**
Record with `FMW_FREEZE_TIER2=1`, then run the specs normally - a record run
compares nothing and so proves nothing.

**Tier 3 now carries the same freeze, for the same reason (#227).** The render
parity specs got their TypeScript arm by calling `runRenderRequest` with the
engine argument left off, so after the deletion both arms would be the SAME
code - a comparison that passes while grading nothing. `test/tier3Frozen.ts`
freezes each render to a checksum in
`test/fixtures/tier3-render-checksums.json`; `nauvis:render` holds 73 rows.
Record with `FMW_FREEZE_TIER3=1`.

**The plumbing is shared and the tables are not.** `test/frozenTable.ts` holds
the machinery, and both `tier2Frozen.ts` and `tier3Frozen.ts` are thin wrappers
over `makeFrozenTable`, so the guards live in one place rather than two. Tier 3
keeps its own FILE because a row means a different thing - one rendered image,
not a field folded over a grid - and because `tier2Coverage.spec.ts` anchors
tier 2's rows to the module's own `checksum_*` exports, which render rows do
not have.

**The tier-3 fold runs in JavaScript**, not Rust. Both arms already hand back
RGBA bytes, so folding there keeps them symmetric and adds no export - which
means freezing tier 3 rebuilds no `engine.wasm` and cannot go stale against the
committed binary. The fold takes the byte LENGTH first, so a truncated buffer
cannot collide with a shorter render that shares a prefix.

**Each tier-3 spec asserts its own COVERAGE**, the way the three tier-2 planet
specs do. `expectRecordedRows` guards only a RECORD run - it feeds
`flushRecording`, which returns immediately unless the environment variable is
set - so without a second guard nothing checks that the rows are consulted on a
normal run, and a deleted `freeze` call site would leave its row in the table
un-consulted while every gate stayed green. `frozenTable.ts` tracks the distinct
rows each run looks up, and the spec asserts BOTH that count and the table's.
The two fail on opposite mistakes: the table count catches a re-record that
wrote a different surface, the consulted count catches a call site that stopped
asking. A literal compared only against the file would move with neither.

Three planted breaks were RUN rather than predicted:

- a corrupted row reddens the wasm arm by name
(`wasm 11549297961623709281 != frozen 16045690984833335023`)
- a deleted row fails "no frozen checksum" rather than skipping quietly
- **a deleted `freeze` call site leaves all 37 other tests GREEN**, and is
caught only by the coverage guard, at "expected 72 to be 73"

The row-count guard also fired for real - a first record run declared 73 and
recorded 60, and `flushRecording` DROPPED the section rather than committing a
short table. The 13 missing rows were the four overlay lever loops, which share
an identical body.

**One test is deliberately NOT frozen**: `refuses the engine for a spawn list
longer than the ABI cap`. Both its arms are the TypeScript renderer, which is
its whole claim, so a frozen row would capture a picture the engine can never
reproduce. It belongs to the `> 8` spawn carve-out, and the #227 deletion
removes both together. The spawn census on #227 is why that is safe: the most
starting points any exchange string in the repo carries is two, against a cap
of eight.

**Parity sweeps must use NON-binary origins and steps**, or they agree by
construction. `test/wasmNauvisParity.spec.ts` freezes 2,365 of 2,420 positions
off the f32 grid, with two tier-3-shaped windows asserted at 0 as the control.
Expand Down
3 changes: 2 additions & 1 deletion test/fixtures/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@
"starting-area-diff.txt": "a captured map exchange string that no test reads. The README says it pins the mid-block starting_area f32 offset, but the same 2026-08-18 grep found only README.md and three design docs, no spec. Its own payload stamps 2.1.9.3, so it can be given a real entry the moment a test starts reading it. Issue #240 flags it and seed-123456789.txt as either unasserted ground truth or dead.",
"wasm-request.v2.json": "not Factorio ground truth, so it has no game version to record - it pins OUR OWN WASM request encoding at ABI v2 (#225, replacing the v1 entry from #223), whose layout tables live in crates/fmw-wasm/src/abi.rs. It is read by a spec, unlike most entries here: test/wasmFulgoraRenderParity.spec.ts asserts the TypeScript writer produces exactly these bytes for both a Fulgora and a Vulcanus request, so neither side of the boundary can move a field without the other going red. The bytes were checked by test/fixtures/verify-wasm-request.py, a third implementation written from the layout table, rather than by re-running the writer - which would only have proved the writer agrees with itself.",
"verify-wasm-request.py": "a tool, not ground truth. It is the independent decoder that verified wasm-request.v2.json, committed beside it so a future ABI version can be re-verified the same way rather than by regenerating the bytes from the encoder under test. It deliberately does not reproduce the trig VALUES - those are V8's Math.sin after an f32 narrowing, and a second libm is exactly the disagreement #270 measured - so it checks the trig block structurally: unit norm per pair, one legitimate duplicate, and each bearing's angle recovered with atan2 and checked against the offset the game's Lua gives it. Seven planted breaks were confirmed caught, including two bearing swaps that the unit-norm check alone was measured MISSING. The cliff cell query box (#225, ABI v2's Vulcanus block growing 248 -> 280 bytes with no version bump) added FIVE more, all confirmed caught by its per-edge value check: a transposed x0/x1, four identical edges, a block shifted by one f64, a mismatched y0, and a declared length still saying 248. Its two structural checks - four distinct edges, not inverted on either axis - constrain the FIXTURE rather than catching a break, and the file says so rather than claiming credit for the five.",
"tier2-checksums.json": "our own tier-2 folds, not Factorio ground truth. 942 order-sensitive u64 checksums (fulgora 76x2, vulcanus 74x5, nauvis 84x5) recorded 2026-08-27 by the three wasm parity specs under FMW_FREEZE_TIER2=1, each captured only after the Rust and TypeScript arms were compared and agreed. It exists because #227 deletes the TypeScript, and tier 2 is the only grading that runs the port inside wasm32-unknown-unknown - cargo test links the host libm, so the #270 class is invisible to tier 1. Regenerate with the command in test/tier2Frozen.ts; a moved number is a finding, not a value to adjust."
"tier2-checksums.json": "our own tier-2 folds, not Factorio ground truth. 1,168 order-sensitive u64 checksums (fulgora 76x2, vulcanus 74x5, nauvis 84x6, plus 142 across the five primitives: sections) recorded by the wasm parity specs under FMW_FREEZE_TIER2=1, each captured only after the Rust and TypeScript arms were compared and agreed. It exists because #227 deletes the TypeScript, and tier 2 is the only grading that runs the port inside wasm32-unknown-unknown - cargo test links the host libm, so the #270 class is invisible to tier 1. Regenerate with the command in test/tier2Frozen.ts; a moved number is a finding, not a value to adjust.",
"tier3-render-checksums.json": "our own tier-3 render folds, not Factorio ground truth. 73 u64 FNV-1a checksums over rendered RGBA buffers, all under nauvis:render, recorded 2026-08-29 by test/wasmNauvisRenderParity.spec.ts under FMW_FREEZE_TIER3=1, each captured only after the Rust and TypeScript arms were compared and agreed. It exists because #227 deletes the TypeScript, and the render specs got their reference arm by calling runRenderRequest with the engine argument left off - after the deletion both arms would be the same code, so the comparison would pass while grading nothing. The fold runs in JavaScript over bytes both arms already return, so freezing this rebuilds no engine.wasm. Regenerate with the command in test/tier3Frozen.ts; a moved number is a finding, not a value to adjust."
}
}
77 changes: 77 additions & 0 deletions test/fixtures/tier3-render-checksums.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"nauvis:render": {
"all six, far | all": "0xa20d3bd22615320e",
"all six, far | resources": "0x233dc5950c87e4ee",
"cliffControls.continuity | lever": "0xcb688576d19eaec2",
"cliffControls.frequency | lever": "0xc3f92237f5cf22b5",
"cliffSettings.cliffElevation0 | lever": "0x2a53c2d5d33e12d6",
"cliffSettings.cliffElevationInterval | lever": "0xd7b1232e87011a86",
"cliffs base | cliffs": "0x58bf7c68cd5d62bc",
"cliffs richness 0 | cliffs": "0xa047594e27b96661",
"climate auxBias | terrain": "0xc520433eb15510d3",
"climate auxFrequency | terrain": "0xdac04895d7dd5410",
"climate base | terrain": "0xe88da401014f0bc4",
"climate moistureBias | terrain": "0x6cd287a690f83fbd",
"climate moistureFrequency | terrain": "0xb2efe3f5a88dbc26",
"climate segmentationMultiplier | terrain": "0x0a83c4096198974f",
"climate startingAreaMoistureFrequency alone | terrain": "0xe88da401014f0bc4",
"climate startingAreaMoistureSize | terrain": "0x36e0ab32ce5b4829",
"climate startingAreaMoistureSize+startingAreaMoistureFrequency | terrain": "0x98639039fd819c80",
"coarse at spawn | rocks": "0xdc2a9d583465bcaf",
"coarse at spawn | terrain": "0x1200f3eb03dc5884",
"coarse at spawn | trees": "0x4ff16ceefbe15e25",
"coarse, far field | rocks": "0x267a993c4b702bb1",
"coarse, far field | terrain": "0x36c766c310b3ab64",
"coarse, far field | trees": "0xf380ac5a6ed25a00",
"enemies base | enemies": "0xb4af851f064f3d6f",
"enemyControls.frequency | lever": "0xac009b4b6fd28f7e",
"enemyControls.size | lever": "0xf90752231a916b14",
"far north-east | enemies": "0x65bc252d2a60a1dc",
"far | cliffs": "0x157a5f5be6bd3e9d",
"fine at spawn | resources": "0xf3471077eb75d374",
"fine | cliffs": "0x67ba794e6b0f3b49",
"fine, far | enemies": "0x05963e1ebb000313",
"north-east | all": "0xf59cc0824bc17196",
"north-east | resources": "0x2b12c67dc87337ce",
"resources base | resources": "0x7f29bce32feb86b5",
"resources copper-ore levers | resources": "0x0d25dae652e32b7a",
"resources iron-ore levers | resources": "0xc6e34461ffe626d3",
"rockControls.frequency | lever": "0x1a36a2fb24fe4467",
"rockControls.size | lever": "0xccef29139bf6d146",
"rocks base | rocks": "0x2cb1d817b2b604d4",
"routes very far | all": "0xd35416a48907be1c",
"routes very far | cliffs": "0x58bf7c68cd5d62bc",
"routes very far | enemies": "0xb4af851f064f3d6f",
"routes very far | resources": "0xaa97cb23d994d34f",
"routes very far | rocks": "0x3bcbbf3bb4c2151b",
"routes very far | terrain": "0xa047594e27b96661",
"routes very far | trees": "0xba8131f1518f67a1",
"spawn moved | terrain": "0x5d9c3c6ed25d45ec",
"spawn two points | terrain": "0x44f3674eb7ff3c3e",
"tall | cliffs": "0xda88c9ed971b42fd",
"tall | enemies": "0x23006e905d6076da",
"tall, coarse | rocks": "0x4bfd520666b659b7",
"tall, coarse | terrain": "0x4052b3042748e53b",
"tall, coarse | trees": "0x60b323fbe78920cf",
"tall, far | resources": "0x0e29aab0138a270d",
"temperatureBias | lever": "0x0c97ad1f0e3d3dd9",
"temperatureFrequency | lever": "0x77208f99cd333ade",
"treeControls.frequency | lever": "0xbd486343234dafdf",
"treeControls.size | lever": "0xc4b2f6ca1b7e780c",
"trees base | trees": "0x69d33b5c55d6d7a5",
"very far | all": "0xd35416a48907be1c",
"very far | cliffs": "0x58bf7c68cd5d62bc",
"very far, coarse | enemies": "0xb4af851f064f3d6f",
"waterLevel | lever": "0xb0027d246c2c82ba",
"wide at spawn | all": "0xec30157cedf818ca",
"wide at spawn | resources": "0x7f29bce32feb86b5",
"wide at spawn | rocks": "0x2cb1d817b2b604d4",
"wide at spawn | trees": "0x69d33b5c55d6d7a5",
"wide | cliffs": "0x58b621d56b68617f",
"wide | enemies": "0xbf9a192bda5499f3",
"wide, far | resources": "0x003c244bc7e2efae",
"wide, offset | rocks": "0x9d67c26e606466f2",
"wide, offset | terrain": "0x4babfc28a8b1e8c2",
"wide, offset | trees": "0x98d9f0592e35f2af"
}
}
158 changes: 158 additions & 0 deletions test/frozenTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

/**
* The table machinery behind the frozen checksum tiers, in one place.
*
* Extracted when tier 3 needed the same guards tier 2 already had. Both tiers
* freeze a `(section, label, field) -> u64` table, both record through the code
* that asserts it, and both need the partial-run protection that
* `flushRecording` provides. Two copies of that would have been two places for
* a guard to rot, and the guards here are the load-bearing part - each one was
* added after a specific way of writing a wrong table.
*
* What differs between tiers is only the file, the environment variable and
* what a row means. Those are the arguments; everything else is shared.
*
* `section` is the grouping key - a planet for tier 2, a render surface for
* tier 3. It is deliberately not called "planet" here, because tier 3 groups by
* something else and a name that lies is worse than a general one.
*/
export interface FrozenTable {
/** True when the tier's environment variable asks for a record run. */
readonly RECORDING: boolean;
frozen(this: void, section: string, label: string, field: string): bigint | undefined;
record(this: void, section: string, label: string, field: string, value: bigint): void;
frozenNames(this: void): [string, string[]][];
frozenCount(this: void, section: string): number;
/**
* Distinct rows this run has actually looked up, per section.
*
* `frozenCount` reads the committed FILE and `expectRecordedRows` only guards
* a record run, so neither notices a `freeze` call site that was deleted: its
* row sits in the table un-consulted and every gate stays green. That gap
* matters most after #227, when the table is the only thing grading.
*/
consultedCount(this: void, section: string): number;
expectFrozen(
this: void,
section: string,
label: string,
name: string,
wasm: bigint,
reference?: bigint,
): void;
expectRecordedRows(this: void, section: string, rows: number): void;
flushRecording(this: void): void;
}

type Table = Record<string, Record<string, string>>;

/**
* u64 as a zero-padded hex string. JSON has no integer wide enough to hold one
* of these exactly - `2n ** 64n` is far past `Number.MAX_SAFE_INTEGER`, so a
* JSON number would silently round and the table would freeze the wrong value.
*/
function toHex(v: bigint): string {
return `0x${v.toString(16).padStart(16, "0")}`;
}

export function makeFrozenTable(opts: { tablePath: string; envVar: string }): FrozenTable {
const TABLE_PATH = opts.tablePath;
const RECORDING = process.env[opts.envVar] === "1";

function load(): Table {
if (!existsSync(TABLE_PATH)) return {};
return JSON.parse(readFileSync(TABLE_PATH, "utf8")) as Table;
}

const table: Table = RECORDING ? {} : load();
const recorded: Table = {};
const expected = new Map<string, number>();
const consulted = new Map<string, Set<string>>();
let dirty = false;

function frozen(section: string, label: string, field: string): bigint | undefined {
const hex = table[section]?.[`${label} | ${field}`];
return hex === undefined ? undefined : BigInt(hex);
}

function record(section: string, label: string, field: string, value: bigint): void {
(recorded[section] ??= {})[`${label} | ${field}`] = toHex(value);
dirty = true;
}

return {
RECORDING,
frozen,
record,

frozenNames(): [string, string[]][] {
return Object.entries(table).map(([section, rows]) => [section, Object.keys(rows)]);
},

frozenCount(section: string): number {
return Object.keys(table[section] ?? {}).length;
},

consultedCount(section: string): number {
return consulted.get(section)?.size ?? 0;
},

expectFrozen(section, label, name, wasm, reference): void {
const where = `${label}: ${name}`;
if (RECORDING) {
if (reference !== undefined && wasm !== reference) {
throw new Error(`${where}: refusing to record, arms disagree - ${wasm} vs ${reference}`);
}
record(section, label, name, wasm);
return;
}
// Counted BEFORE the lookup, so a row that is consulted and disagrees
// still counts as covered - this tracks reach, not success.
let seen = consulted.get(section);
if (seen === undefined) {
seen = new Set<string>();
consulted.set(section, seen);
}
seen.add(`${label} | ${name}`);
const want = frozen(section, label, name);
if (want === undefined) throw new Error(`${where}: no frozen checksum`);
if (wasm !== want) throw new Error(`${where}: wasm ${wasm} != frozen ${want}`);
if (reference !== undefined && reference !== want) {
throw new Error(`${where}: reference ${reference} != frozen ${want}`);
}
},

expectRecordedRows(section: string, rows: number): void {
const already = expected.get(section);
if (already !== undefined) {
throw new Error(
`${section}: row total declared twice (${already} then ${rows}) - declare it once, ` +
`at module scope, as the whole section's total`,
);
}
expected.set(section, rows);
},

flushRecording(): void {
if (!RECORDING || !dirty) return;
for (const [section, rows] of Object.entries(recorded)) {
const want = expected.get(section);
if (want === undefined || Object.keys(rows).length !== want) delete recorded[section];
}
if (Object.keys(recorded).length === 0) return;
const merged: Table = load();
for (const [section, rows] of Object.entries(recorded)) merged[section] = rows;
const ordered: Table = {};
for (const section of Object.keys(merged).sort()) {
const rows = merged[section] as Record<string, string>;
const sorted: Record<string, string> = {};
for (const k of Object.keys(rows).sort()) sorted[k] = rows[k] as string;
ordered[section] = sorted;
}
mkdirSync(dirname(TABLE_PATH), { recursive: true });
writeFileSync(TABLE_PATH, `${JSON.stringify(ordered, null, 2)}\n`);
},
};
}
Loading