Skip to content

Commit 394dfbc

Browse files
wormeymanclaude
andauthored
test(cliffs): the orientation residual IS part of the over-placement (#84) (#103)
The wrong orientations and the surplus cells have been tracked as two defects. They are one. `placedCells` builds one edge register per chunk, so `v[cy][cx]` is cell `cx`'s left edge and cell `cx-1`'s right edge - the same array slot, not two equal values. A spurious crossing therefore cannot sit inside one cell: it corrupts the orientation of the real cell on one side and manufactures a cliff the game never placed on the other. Over the three oracle regions, rejections off: 37 wrong orientations, **0** whose disputed-edge neighbour the game places, 34 distinct phantom neighbours, and **34 of 34** are cells the port emits - i.e. surplus. On the shipping path this owns 12 of the 25 surplus cells, and at [0,0] it owns every surplus cell there is (2 of 2). That does not change the design of the blocked grid-4 cliff-elevation capture, it changes what it is worth: it was being weighed against "1.6% of cells carry a wrong orientation", which reads like a rounding-error chase. Ruled out on the way, each with a discriminating control: - No gate in `crossesCliff` is marginal on the disputed edges, scored against every crossing edge of every matched cell as control. - The asymmetric smoothing knot lattice (in-chunk knots at 0, 4 and 7, `hi` clamped to CHUNK_CORNERS-1) is not a misreading. Knots every 4 corners globally is 8x worse - 312 wrong against 37. The disassembly reading now has a measurement behind it. The new spec was checked for vacuity by pointing the neighbour lookup at the wrong axis: 4 of its 5 tests fail. Item of #84; the issue stays open. Claude-Session: https://claude.ai/code/session_01FpXiK2MPqPFVtm5CMPNXR2 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 21e8ccc commit 394dfbc

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

docs/noise/vulcanus-cliffs-NOTES.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,3 +1757,69 @@ of the 4-grid program, e.g. a mod that publishes
17571757
`vulcanus_basalt_lakes_multisample` at grid 4 into a readable tile property.
17581758
Until that exists, "the fields are exonerated" cannot be said of the channel that
17591759
matters - and #93's exoneration rested on a substitution in the tile channel.
1760+
1761+
## The residual and the OVER-PLACEMENT are one defect (2026-08-03, #84)
1762+
1763+
Everything above treats two numbers as separate problems: the wrong orientations
1764+
(33 on the shipping path) and the surplus cells (25). They are the same defect,
1765+
and the reason is structural rather than statistical.
1766+
1767+
`placedCells` builds **one edge register per chunk**. `v[cy][cx]` is cell `cx`'s
1768+
left edge and cell `cx-1`'s right edge - not two equal values, the same array
1769+
slot. So a spurious crossing can never sit inside one cell. It corrupts the
1770+
orientation of the real cell on one side and manufactures a cliff the game never
1771+
placed on the other.
1772+
1773+
Measured over all three oracle regions, rejections off so the geometry is not
1774+
masked:
1775+
1776+
| | |
1777+
| --- | --- |
1778+
| matched cells | 1531 |
1779+
| wrong orientations | 37 |
1780+
| of those whose disputed-edge neighbour the GAME places | **0** |
1781+
| distinct phantom neighbours | 34 |
1782+
| of those the PORT places, i.e. that are surplus cells | **34 of 34** |
1783+
1784+
Not one of the 37 has a neighbour the game agrees about, and not one phantom
1785+
fails to be surplus. On the shipping path:
1786+
1787+
| region | matched | wrong | surplus | surplus that ARE phantoms |
1788+
| --- | --- | --- | --- | --- |
1789+
| `[0,0]` | 281 | 5 | 2 | **2 of 2** |
1790+
| `[1500,1500]` | 858 | 25 | 22 | 10 of 22 |
1791+
| `[-1200,800]` | 386 | 3 | 1 | 0 of 1 |
1792+
1793+
**At `[0,0]` the spurious crossings are the whole of the over-placement.** And
1794+
the reason 33 wrong cells do not imply 33 surplus is that the lava and ore
1795+
rejections already remove 19 of the phantoms - the rejection hides the phantom
1796+
while leaving the neighbouring cell's orientation wrong, which is exactly how the
1797+
two counts drifted apart and came to be read as unrelated mechanisms.
1798+
1799+
What this changes is the **value** of the blocked oracle capture above, not its
1800+
design. It was being weighed against "33 wrong orientations, ~1.6% of cells",
1801+
which reads like a rounding-error chase; it is also worth 12 of the 25 surplus
1802+
cells, and all of `[0,0]`'s. `test/cliffPhantomNeighbour.spec.ts`.
1803+
1804+
### Ruled out on the way, each with a discriminating control
1805+
1806+
- **No gate in `crossesCliff` is marginal on the disputed edges.** Measured
1807+
against every crossing edge of every matched cell as control: cliffiness gap
1808+
above the `> 0.5` threshold (disputed min 6.8e-3 vs control 1.0e-3), the
1809+
`a < 0 || b < 0` early-out (disputed min elevation 15.6, nowhere near zero) and
1810+
`boundary < e0` (disputed min 2.73 above `e0`). The disputed edges are not
1811+
sitting on any cliff edge of the rule. This corroborates the band-margin result
1812+
above by a different route.
1813+
- **The asymmetric smoothing knot lattice is not a misreading.** The port puts
1814+
in-chunk knots at corner indices **0, 4 and 7** because `hi` is clamped to
1815+
`CHUNK_CORNERS - 1`, which is odd enough to look like an off-by-one. Scoring
1816+
the natural alternative - knots every 4 corners globally, i.e. 0, 4, 8 - it is
1817+
**8x worse**: 312 wrong orientations against 37, matched collapsing 1531 -> 1173
1818+
and missing 38 -> 396. Raw (`s = 0`) is worse still at 677. The disassembly
1819+
reading now has a measurement behind it, not just a careful read.
1820+
- **`cliffiness_basic` stays exonerated, and the exoneration is sound.** It
1821+
contains no `multisample` (the channel audit, #87, found `multisample` in
1822+
exactly one expression in all of factorio-data), so unlike elevation it has no
1823+
grid-4 variant to be captured in the wrong channel. Its gate test scores the
1824+
binary the consumer reads over 24,960 edges with hit counts and both outcomes
1825+
asserted - 0 flips.

test/cliffPhantomNeighbour.spec.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json";
4+
import {
5+
CLIFF_CODE_TO_ORIENTATION,
6+
CLIFF_GRID_SIZE,
7+
CLIFF_ORIENTATION_NAMES,
8+
} from "../src/noise/cliffs/cliffCatalog";
9+
import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement";
10+
import {
11+
VULCANUS_CLIFF_ELEVATION_0,
12+
VULCANUS_CLIFF_ELEVATION_INTERVAL,
13+
VULCANUS_CLIFF_SMOOTHING,
14+
makeVulcanusCliffFields,
15+
} from "../src/noise/cliffs/vulcanusCliffFields";
16+
import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection";
17+
import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs";
18+
import { buildResources } from "../src/noise/preview/renderVulcanusResources";
19+
import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog";
20+
import { withCtxDefaults } from "../src/noise/eval/ctx";
21+
22+
const key = (x: number, y: number): string => `${String(x)},${String(y)}`;
23+
24+
/** code = (enc(L)<<6)|(enc(R)<<4)|(enc(T)<<2)|enc(B); enc: 0->0, +1->1, -1->3. */
25+
const edgesOf = (code: number): number[] => [
26+
(code >> 6) & 3,
27+
(code >> 4) & 3,
28+
(code >> 2) & 3,
29+
code & 3,
30+
];
31+
32+
/**
33+
* Per edge index (L, R, T, B): the world offset to the cell that SHARES it.
34+
*
35+
* `placedCells` builds one edge register per chunk - `v[cy][cx]` is cell `cx`'s
36+
* left edge and cell `cx-1`'s right edge, the same array slot - so two adjacent
37+
* cells do not merely agree about the edge between them, they read the identical
38+
* value. That is what makes the test below a test and not a coincidence hunt.
39+
*/
40+
const ACROSS: readonly (readonly [number, number])[] = [
41+
[-CLIFF_GRID_SIZE, 0],
42+
[CLIFF_GRID_SIZE, 0],
43+
[0, -CLIFF_GRID_SIZE],
44+
[0, CLIFF_GRID_SIZE],
45+
];
46+
47+
const codeForOrientation = new Map<number, number>();
48+
for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION))
49+
codeForOrientation.set(id, Number(c));
50+
const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i]));
51+
52+
/** The game's orientation name -> the cell code that produces it (a bijection). */
53+
const gameCodeOf = (orientation: string): number | undefined => {
54+
const id = nameToId.get(orientation);
55+
return id === undefined ? undefined : codeForOrientation.get(id);
56+
};
57+
58+
interface Ent {
59+
x: number;
60+
y: number;
61+
name: string;
62+
orientation: string;
63+
}
64+
interface Case {
65+
region: { x0: number; y0: number; x1: number; y1: number };
66+
cliffs: Ent[];
67+
}
68+
69+
const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] };
70+
const ctx = withCtxDefaults(INPUT);
71+
const fields = makeVulcanusCliffFields(ctx);
72+
const tileAt = makeVulcanusTileResolver(INPUT);
73+
const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls);
74+
75+
const place = (
76+
r: Case["region"],
77+
withRejections: boolean,
78+
): { x: number; y: number; code: number }[] =>
79+
makeCliffPlacementFromFields(fields, {
80+
elevation0: VULCANUS_CLIFF_ELEVATION_0,
81+
interval: VULCANUS_CLIFF_ELEVATION_INTERVAL,
82+
smoothing: VULCANUS_CLIFF_SMOOTHING,
83+
tileCollides: withRejections
84+
? (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name)
85+
: undefined,
86+
cellRejects: withRejections ? oreRejects : undefined,
87+
}).placedCells(r.x0, r.y0, r.x1, r.y1);
88+
89+
interface Scored {
90+
matched: number;
91+
/** Cells the game also places, where our orientation differs. */
92+
wrong: number;
93+
/** Cells we place that the game does not. */
94+
surplus: string[];
95+
/** Distinct neighbours across a disputed edge. */
96+
phantoms: Set<string>;
97+
/** Disputed edges whose neighbour the GAME places. Expected: none. */
98+
phantomPlacedByGame: number;
99+
}
100+
101+
const score = (c: Case, withRejections: boolean): Scored => {
102+
const ours = new Map(place(c.region, withRejections).map((p) => [key(p.x, p.y), p.code]));
103+
const game = new Map<string, string>();
104+
for (const e of c.cliffs) if (e.name === "cliff-vulcanus") game.set(key(e.x, e.y), e.orientation);
105+
106+
const phantoms = new Set<string>();
107+
let matched = 0;
108+
let wrong = 0;
109+
let phantomPlacedByGame = 0;
110+
for (const [k, ourCode] of ours) {
111+
const want = game.get(k);
112+
if (want === undefined) continue;
113+
matched++;
114+
const gameCode = gameCodeOf(want);
115+
if (gameCode === undefined || gameCode === ourCode) continue;
116+
wrong++;
117+
const a = edgesOf(ourCode);
118+
const b = edgesOf(gameCode);
119+
const [xs, ys] = k.split(",");
120+
for (let i = 0; i < 4; i++) {
121+
if (a[i] === b[i]) continue;
122+
const nk = key(Number(xs) + ACROSS[i][0], Number(ys) + ACROSS[i][1]);
123+
phantoms.add(nk);
124+
if (game.has(nk)) phantomPlacedByGame++;
125+
}
126+
}
127+
return {
128+
matched,
129+
wrong,
130+
surplus: [...ours.keys()].filter((k) => !game.has(k)),
131+
phantoms,
132+
phantomPlacedByGame,
133+
};
134+
};
135+
136+
/**
137+
* **The orientation residual and the over-placement are ONE defect** (issue #84).
138+
*
139+
* `cliffOrientationResidual.spec.ts` pins the residual's shape - every wrong cell
140+
* differs from the game in exactly one edge, and always by finding a crossing the
141+
* game does not - and `cliffOrientationMargin.spec.ts` rules out a boundary tie.
142+
* Both treat the wrong orientations as their own defect, separate from the
143+
* surplus cells counted in `cliffOreExclusion.spec.ts`. **They are not separate.**
144+
*
145+
* A cell's four edges are shared with its four neighbours - literally the same
146+
* slot in the chunk's edge register, see `ACROSS` above - so a spurious crossing
147+
* is never confined to one cell. It corrupts the orientation of the real cell on
148+
* one side AND, on the other, manufactures a whole cliff the game never placed.
149+
* Measured over all three oracle regions, without the rejections so the geometry
150+
* is not masked:
151+
*
152+
* | | |
153+
* | --- | --- |
154+
* | matched cells | 1531 |
155+
* | wrong orientations | 37 |
156+
* | of those whose disputed-edge neighbour the GAME places | **0** |
157+
* | distinct phantom neighbours | 34 |
158+
* | of those the PORT places (i.e. that are surplus cells) | **34 of 34** |
159+
*
160+
* Not one of the 37 has a neighbour the game agrees about, and not one phantom
161+
* fails to be a surplus cell. So the residual is not a cosmetic orientation
162+
* mismatch to be chased after the placement is right - it IS part of the
163+
* placement error, and one root cause retires both.
164+
*
165+
* **Why this reframes the hunt.** The open lead is the grid-4 cliff-elevation
166+
* channel, which has no per-corner oracle (see `cliffOrientationMargin.spec.ts`).
167+
* The value of capturing it was previously scored against 33 wrong orientations -
168+
* about 1.6% of cells, easy to read as a rounding-error chase. It is worth more
169+
* than that: on the shipping path it also owns 12 of the 25 surplus cells, and at
170+
* `[0,0]` it owns **every** surplus cell there is.
171+
*/
172+
describe("the wrong orientations and the surplus cells are the same defect", () => {
173+
const bare = (entities.cases as unknown as Case[]).map((c) => score(c, false));
174+
175+
it("compares a real population, not a handful", () => {
176+
// Non-vacuity. If the residual is ever fixed these two lines are what will
177+
// fail, and the correct response is to delete this file's premise, not to
178+
// relax them - every assertion below is vacuous at `wrong === 0`.
179+
expect(bare.reduce((n, s) => n + s.matched, 0)).toBeGreaterThan(1500);
180+
expect(bare.reduce((n, s) => n + s.wrong, 0)).toBeGreaterThan(0);
181+
}, 120000);
182+
183+
it("never has the game placing the neighbour across the disputed edge", () => {
184+
for (const s of bare) expect(s.phantomPlacedByGame).toBe(0);
185+
}, 120000);
186+
187+
/**
188+
* The other half, and the one that makes it a shared defect rather than a
189+
* shared symptom: every phantom is a cell the port really does emit. A
190+
* disputed edge that produced no cliff on either side would be a discrepancy
191+
* with no cost.
192+
*/
193+
it("makes every phantom neighbour a surplus cell of our own", () => {
194+
let phantoms = 0;
195+
for (const [i, s] of bare.entries()) {
196+
const surplus = new Set(s.surplus);
197+
for (const p of s.phantoms) {
198+
phantoms++;
199+
expect({ region: i, cell: p, surplus: surplus.has(p) }).toEqual({
200+
region: i,
201+
cell: p,
202+
surplus: true,
203+
});
204+
}
205+
}
206+
// Measured 34 distinct phantoms behind 37 wrong cells - a few are shared,
207+
// where one spurious crossing sits between two cells the game both places.
208+
expect(phantoms).toBe(34);
209+
}, 120000);
210+
});
211+
212+
/**
213+
* **What that costs on the path the renderer actually runs.**
214+
*
215+
* The bare arm above is the right control for the geometry - the lava and ore
216+
* rejections drop cells for reasons unrelated to the crossings, and they drop
217+
* phantoms and honest cliffs alike. But the arm that matters for accuracy is the
218+
* one `renderVulcanusCliffs` runs, and the split there is worth pinning because
219+
* it is not obvious from the bare numbers:
220+
*
221+
* | region | matched | wrong | surplus | phantoms | surplus that ARE phantoms |
222+
* | --- | --- | --- | --- | --- | --- |
223+
* | `[0,0]` | 281 | 5 | 2 | 5 | **2 of 2** |
224+
* | `[1500,1500]` | 858 | 25 | 22 | 23 | 10 of 22 |
225+
* | `[-1200,800]` | 386 | 3 | 1 | 3 | 0 of 1 |
226+
*
227+
* Two things follow. **At `[0,0]` the spurious crossings are the whole of the
228+
* over-placement** - fix them and that region is exact. And the reason 33 wrong
229+
* cells do not imply 33 surplus is that the rejections already remove 19 of the
230+
* phantoms; the rejection hides the phantom while leaving the neighbouring cell's
231+
* orientation wrong, which is why the two counts drifted apart and were read as
232+
* unrelated in the first place.
233+
*/
234+
describe("the same defect, on the shipping path", () => {
235+
const shipped = (entities.cases as unknown as Case[]).map((c) => score(c, true));
236+
237+
it("owns every surplus cell at [0,0]", () => {
238+
const s = shipped[0];
239+
expect(s.surplus.length).toBeGreaterThan(0);
240+
for (const k of s.surplus) expect(s.phantoms.has(k)).toBe(true);
241+
}, 120000);
242+
243+
it("owns a substantial minority of the surplus overall", () => {
244+
const surplus = shipped.reduce((n, s) => n + s.surplus.length, 0);
245+
const explained = shipped.reduce(
246+
(n, s) => n + s.surplus.filter((k) => s.phantoms.has(k)).length,
247+
0,
248+
);
249+
// Measured 12 of 25. Bounds rather than equalities: a fix should move both
250+
// down, and this file should not have to be edited to let it.
251+
expect(surplus).toBeLessThanOrEqual(25);
252+
expect(explained).toBeGreaterThanOrEqual(Math.min(12, surplus));
253+
}, 120000);
254+
});

0 commit comments

Comments
 (0)