diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 53865a9b..ebbe5a10 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -1985,3 +1985,81 @@ neighbourhood of a rejected cell. Note this is a HYPOTHESIS from one chunk plus the coverage statistics; it has not been tested, and the obvious control is whether disputed edges are adjacent to rejected cells at a rate above the base rate of all crossing edges. + +## The rejections act on the CROSSING, not on the entity (2026-08-03, #84) + +#107 handed over a hypothesis - "disputed edges sit adjacent to rejected cells +above the base rate of all crossing edges" - and named the control. Run, it is +not an enrichment over a base rate. It is a dichotomy, and it refutes the stage +at which the port applies both Vulcanus cliff rejections. + +`test/vulcanusCliffRejectionStage.spec.ts`, over the fine sweep's 41 levels at +`[1500,1500]`: + +| | | +| --- | --- | +| edges the port has and the game does not | **1235** | +| ... sitting against a cell the game did not emit | **1233** | +| edges both sides agree on (in region) | **36103** | +| ... sitting against a cell the game did not emit | **0** | + +Zero, not "fewer". A cell's edge register is the same array slot as its +neighbour's (#103), so this says the game's absences take their crossings with +them, every time. + +### The refutation is structural, not a score + +The post-filter reading makes a falsifiable prediction, and it does not need any +model to score well. When cell `N` is rejected, a pure post-filter leaves the +shared crossing in `C`'s code, so `C` is emitted still carrying it. Counted with +the port's own rejection predicate, that should happen **1,662 times** across the +41 levels. The game does it **0 times**. + +The vacuity arm is the same counter reading the port's own post-filter output +instead of the game's: it fires on all 1,662. So the zero is a property of the +game's cliffs, not a dead branch. (A first attempt at a vacuity arm compared +against the *next* level's game output and also returned 0 - not a broken +control but a stronger result, since the invariant holds independently at all 41 +levels.) + +Note what this does and does not settle. `tryToAddCliff` really does ignore +`wouldCollide`'s return value - the disassembly reading behind #71/#73 was not +misread. What is refuted is that the OBSERVABLE output behaves like a +post-filter. Whether `wouldCollide` removes the crossings or the game never +computed them there is not decidable from entity dumps, and the port now models +the first because it is the one that can be written. + +### The fix, and what it is worth + +`rejectAtCrossingStage` in `cliffPlacement.ts` zeroes a rejected cell's four edge +registers after the repair sweep, before any code is read. Scored two ways: + +| | matched | wrong | surplus | missing | +| --- | --- | --- | --- | --- | +| collapsed rule, post-filter | 18130 | 1235 | 1366 | 85 | +| collapsed rule, crossing stage | **18654** | **693** | **1200** | 103 | + +and at the SHIPPING settings (smoothing 1, the real 120-tile interval, +`cliffiness_basic` rather than a constant) across the three entity regions: +wrong orientations **33 -> 21**, precision 0.9839 -> 0.9858, and the matched set +**identical** at 1525 - this removes wrong edges and costs no recall at all. + +It is not free: 18 more of the game's cells go missing under the collapsed rule, +because an edge taken off a survivor can leave its code non-placing. Reported +rather than buried. + +Nauvis is unaffected in both directions - it already matched 1.0000 recall and +precision, and enabling the flag there reproduces the same 282 and 52 cells with +0 wrong orientations. So this is not a regression risk on the other planet, but +neither is it corroboration from it: Nauvis has no cells that could move. + +### What is left + +The predicate, not the stage. 693 wrong orientations and 1200 surplus cells +survive under the collapsed rule, and the remaining absences still follow the +same "the game emitted nothing there" shape - so the lava-box + ore predicate is +catching most of what the game suppresses but not all of it. Chasing that is a +question about WHICH cells the game refuses, and the sweep cannot answer it +where the game emits nothing at any level: those corners get no bracket, so the +field there is still unmeasured. A wider sweep (well outside `[700,900]`) is the +measurement that would close that gap. diff --git a/src/noise/cliffs/cliffPlacement.ts b/src/noise/cliffs/cliffPlacement.ts index 08bba0f3..e575e8bc 100644 --- a/src/noise/cliffs/cliffPlacement.ts +++ b/src/noise/cliffs/cliffPlacement.ts @@ -100,10 +100,13 @@ export interface CliffBands { * orientation, takes that orientation's `collision_bounding_box`, and calls * `wouldCollide` (`0x101625468`) against the tile mask grid; on a hit the * cliff is simply **not added**. `generateCliffs` ignores the return value - * entirely - there is no retry, no alternative orientation, and no effect on - * the neighbouring cells - so this is a pure post-filter on the emit loop, - * which is why it can run per-cell in any order and leaves tiling - * byte-identical. + * entirely - there is no retry and no alternative orientation. + * + * That reading of the code is unchanged, but the conclusion drawn from it - + * "and therefore no effect on the neighbouring cells" - **is refuted by the + * game's output**, so see `rejectAtCrossingStage` below before assuming this + * is a post-filter. The observable behaviour is that a rejected cell's + * crossings go with it and its neighbour's orientation changes. * * Which tiles collide is planet-specific but the rule is not: a tile collides * when its `CollisionMask` shares a layer with the cliff's. The cliff mask @@ -132,10 +135,26 @@ export interface CliffBands { * directly, so a filter applied further out would score a different thing than * it renders. * - * Like `tileCollides` this is a pure post-filter on the emit loop - it cannot - * affect a neighbouring cell, so it leaves worker tiling byte-identical. + * Like `tileCollides`, whether this acts as a post-filter or on the crossing + * itself is `rejectAtCrossingStage`'s decision, and the measurement says the + * crossing. Either way it stays chunk-local - the zeroing runs over the whole + * chunk, including cells outside the query box - so worker tiling remains + * byte-identical. */ readonly cellRejects?: (code: number, x: number, y: number) => boolean; + /** + * EXPERIMENTAL (#84): apply `tileCollides` / `cellRejects` by zeroing the + * rejected cell's four edge registers after the repair sweep, instead of + * filtering the emitted cell. A neighbour sharing one of those edges therefore + * loses it too, and its orientation changes. + * + * The post-filter reading came from `tryToAddCliff` ignoring `wouldCollide`'s + * return value, and it is REFUTED as a description of the observable output: + * see `test/vulcanusCliffRejectionStage.spec.ts`. Under a post-filter, a + * surviving cell keeps an edge whose neighbour was rejected; the game shows + * that 0 times where the model predicts 1,662. + */ + readonly rejectAtCrossingStage?: boolean; } /** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */ @@ -487,6 +506,35 @@ export function makeCliffPlacementFromFields( fixImpossibleCellsSweep(v, hEdges, n, n); + if (bands.rejectAtCrossingStage === true) { + // Collect first, then clear: a cell's rejection is decided from + // the code the repair left, not from a code a previous cell's + // clearing has already eaten into. + const kill: number[] = []; + for (let cy = 0; cy < n; cy++) { + for (let cx = 0; cx < n; cx++) { + const code = cellCode( + v[cy * (n + 1) + cx], + v[cy * (n + 1) + cx + 1], + hEdges[cy * n + cx], + hEdges[(cy + 1) * n + cx], + ); + if (!isCliffPlaced(code)) continue; + const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; + const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; + if (rejected(code, x, y) || cellRejects?.(code, x, y) === true) kill.push(cx, cy); + } + } + for (let i = 0; i < kill.length; i += 2) { + const cx = kill[i]; + const cy = kill[i + 1]; + v[cy * (n + 1) + cx] = 0; + v[cy * (n + 1) + cx + 1] = 0; + hEdges[cy * n + cx] = 0; + hEdges[(cy + 1) * n + cx] = 0; + } + } + for (let cy = 0; cy < n; cy++) { for (let cx = 0; cx < n; cx++) { const code = cellCode( @@ -502,8 +550,10 @@ export function makeCliffPlacementFromFields( // expensive half (it resolves tiles), and a chunk always // overhangs the query box. if (x < x0 || x >= x1 || y < y0 || y >= y1) continue; - if (rejected(code, x, y)) continue; - if (cellRejects?.(code, x, y) === true) continue; + if (bands.rejectAtCrossingStage !== true) { + if (rejected(code, x, y)) continue; + if (cellRejects?.(code, x, y) === true) continue; + } result.push({ x, y, code }); } } diff --git a/src/noise/preview/renderVulcanusCliffs.ts b/src/noise/preview/renderVulcanusCliffs.ts index dd52b9c7..b3b06368 100644 --- a/src/noise/preview/renderVulcanusCliffs.ts +++ b/src/noise/preview/renderVulcanusCliffs.ts @@ -109,6 +109,13 @@ export function renderVulcanusCliffs(base: ImageData, opts: RenderVulcanusCliffs smoothing: VULCANUS_CLIFF_SMOOTHING, tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls), + // Both rejections act on the CROSSING, not on the emitted entity (#84). + // A rejected cell's four edges go with it, so a surviving neighbour loses + // the shared one and changes orientation. See + // `test/vulcanusCliffRejectionStage.spec.ts`: the post-filter reading + // predicts 1,662 cases of a survivor keeping such an edge and the game + // shows 0. Worth 33 -> 21 wrong orientations at no cost in recall. + rejectAtCrossingStage: true, }); const box = opts.cellQueryBox ?? { diff --git a/test/vulcanusCliffRejectionStage.spec.ts b/test/vulcanusCliffRejectionStage.spec.ts new file mode 100644 index 00000000..d9b979b4 --- /dev/null +++ b/test/vulcanusCliffRejectionStage.spec.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "vite-plus/test"; + +import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; +import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; +import { + CLIFF_CELL_CENTER_X, + CLIFF_CELL_CENTER_Y, + CLIFF_CODE_TO_ORIENTATION, + CLIFF_GRID_SIZE, + CLIFF_ORIENTATION_NAMES, + cliffCollisionTileBox, +} from "../src/noise/cliffs/cliffCatalog"; +import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; +import { + VULCANUS_CLIFF_ELEVATION_0, + VULCANUS_CLIFF_ELEVATION_INTERVAL, + VULCANUS_CLIFF_SMOOTHING, + makeVulcanusCliffFields, +} from "../src/noise/cliffs/vulcanusCliffFields"; +import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; +import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; +import { buildResources } from "../src/noise/preview/renderVulcanusResources"; +import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; +import { withCtxDefaults } from "../src/noise/eval/ctx"; + +/** + * **WHEN the cliff rejections act** (#84) - and the refutation of "they are pure + * post-filters on the emitted entity". + * + * That reading came from the disassembly and is a fair description of the code: + * `EntityMapGenerationTask::tryToAddCliff` calls `wouldCollide` and, on a hit, + * simply does not add the entity; `generateCliffs` ignores the return value, so + * there is no retry and no write-back. The port therefore modelled both + * rejections - Vulcanus's lava collision (#71/#73) and its ORE -> CLIFF + * suppression (#99/#100) - as filters over the emit loop. + * + * **As a description of the observable output that is refuted here, by a control + * that needs no new fixture and does not depend on any model scoring well.** + * + * A cell's edge register is the SAME array slot as its neighbour's (#103). So a + * *post-filter* makes a specific prediction: when cell `N` is rejected, its + * surviving neighbour `C` still holds the shared crossing, and `C` is emitted + * with that edge in its orientation code. Counted over the fine sweep's 41 + * levels, the port's own rejection predicate says that happens **1,662 times**. + * The game does it **0 times**. Whatever suppresses these cells takes their + * crossings with it. + * + * The complementary count says the same thing from the other side: of the 1,235 + * edges the port has and the game does not, **1,233** sit against a cell the game + * did not emit - while of the 36,103 in-region edges the two sides agree on, + * **0** do. That is not an enrichment over a base rate, it is a dichotomy. + * + * **What this does NOT establish.** It fixes the STAGE, not the PREDICATE. It + * says nothing about whether `wouldCollide` itself is what removes the crossings + * or whether the game simply never computed them there - the two are + * indistinguishable from entity output, and the residual below (693 wrong + * orientations still, down from 1,235) says the predicate is still incomplete. + * "The crossing is absent" is what is measured; "the rejection removed it" is the + * model the port implements for it. + */ + +const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; +const ctx = withCtxDefaults(INPUT); +const fields = makeVulcanusCliffFields(ctx); +const tileAt = makeVulcanusTileResolver(INPUT); +const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); +const G = CLIFF_GRID_SIZE; + +const codeForOrientation = new Map(); +for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) + codeForOrientation.set(id, Number(c)); +const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); +const gameCodeOf = (o: string): number | undefined => { + const id = nameToId.get(o); + return id === undefined ? undefined : codeForOrientation.get(id); +}; +const bitsOf = (c: number): number[] => [(c >> 6) & 3, (c >> 4) & 3, (c >> 2) & 3, c & 3]; + +/** Neighbour cell-index delta sharing edge `i`, in the code's `L, R, T, B` order. */ +const NB: readonly (readonly [number, number])[] = [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], +]; + +const lavaRejects = (code: number, x: number, y: number): boolean => { + const b = cliffCollisionTileBox(code, x, y); + if (b === undefined) return false; + for (let tx = b.left; tx <= b.right; tx++) + for (let ty = b.top; ty <= b.bottom; ty++) + if (VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(tx, ty).name)) return true; + return false; +}; +const anyRejects = (code: number, x: number, y: number): boolean => + lavaRejects(code, x, y) || oreRejects(code, x, y); + +const inRegion = (x: number, y: number): boolean => + x >= sweep.region.x0 && x < sweep.region.x1 && y >= sweep.region.y0 && y < sweep.region.y1; + +/** + * The collapsed rule of `vulcanusCliffBands.spec.ts`: smoothing off, one band, + * gate held open at a constant - so `crossesCliff` is a 1-bit comparator and + * nothing sits between the field and the placement. `stage` selects which model + * of the rejection runs; `"none"` leaves both rejections off entirely, which is + * what the shadow control needs in order to ask what the port WOULD have placed. + */ +const place = (level: number, stage: "post" | "crossing" | "none"): Map => + new Map( + makeCliffPlacementFromFields( + { cliffElevation: fields.cliffElevation, cliffiness: (): number => 1 }, + { + elevation0: level, + interval: 1000000, + smoothing: 0, + tileCollides: + stage === "none" + ? undefined + : (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + cellRejects: stage === "none" ? undefined : oreRejects, + rejectAtCrossingStage: stage === "crossing", + }, + ) + .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) + .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), + ); + +const gameAt = (index: number): Map => { + const m = new Map(); + for (const e of sweep.cases[index].cliffs) { + if (e.name !== "cliff-vulcanus" || !inRegion(e.x, e.y)) continue; + const code = gameCodeOf(e.orientation); + if (code !== undefined) m.set(`${String(e.x)},${String(e.y)}`, code); + } + return m; +}; + +describe("the stage at which the Vulcanus cliff rejections act", () => { + /** + * **The refutation.** Every case where the port places `C`, `C` survives the + * rejection, and the neighbour `N` across one of `C`'s crossings is rejected. + * A post-filter leaves that crossing in place, so the game should emit `C` + * carrying it while `N` is gone. + * + * `selfCheck` is the vacuity arm, and it is the sharpest form of one available: + * the IDENTICAL counter run against the port's own post-filter output, which + * has the property by construction. It fires on all 1,662 there and on 0 + * against the game. So the zero is a fact about the game's output, not a dead + * branch - the two runs differ only in which cliff set is being read. + * + * (An earlier vacuity arm compared against the NEXT level's game output and + * also returned 0. That is not a broken control, it is a stronger result: the + * invariant holds independently at all 41 levels, so mis-registering them + * cannot break it.) + */ + it("the post-filter model predicts 1,662 survivor-keeps-edge cases and the game shows 0", () => { + let predicted = 0; + let observed = 0; + let selfCheck = 0; + + for (let idx = 0; idx < sweep.cases.length; idx++) { + const ours = place(sweep.cases[idx].level, "none"); + const game = gameAt(idx); + const post = place(sweep.cases[idx].level, "post"); + + const rejects = new Map(); + for (const [k, code] of ours) { + const [xs, ys] = k.split(","); + rejects.set(k, anyRejects(code, Number(xs), Number(ys))); + } + + for (const [k, ourCode] of ours) { + if (rejects.get(k) === true) continue; // C must itself survive + const [xs, ys] = k.split(","); + const ci = (Number(xs) - CLIFF_CELL_CENTER_X) / G; + const cj = (Number(ys) - CLIFF_CELL_CENTER_Y) / G; + const mine = bitsOf(ourCode); + for (let i = 0; i < 4; i++) { + if (mine[i] === 0) continue; + const nx = (ci + NB[i][0]) * G + CLIFF_CELL_CENTER_X; + const ny = (cj + NB[i][1]) * G + CLIFF_CELL_CENTER_Y; + if (!inRegion(nx, ny)) continue; + const nk = `${String(nx)},${String(ny)}`; + if (rejects.get(nk) !== true) continue; // N must be rejected + predicted++; + const theirs = game.get(k); + if (theirs !== undefined && bitsOf(theirs)[i] !== 0 && !game.has(nk)) observed++; + const self = post.get(k); + if (self !== undefined && bitsOf(self)[i] !== 0 && !post.has(nk)) selfCheck++; + } + } + } + + expect(predicted).toBe(1662); + expect(observed).toBe(0); + // The same counter, reading the port's post-filter output instead of the + // game's, fires on every one of them. `observed` is measuring the game, not + // a dead branch. + expect(selfCheck).toBe(predicted); + }, 300000); + + /** + * The dichotomy, from the other direction: the port's extra edges sit against + * cells the game did not emit, and the edges both sides agree on never do. + * Run under the SHIPPING post-filter model, because that is the population + * #107 characterised as "the game's code is the port's minus edges". + */ + it("every extra edge sits against a cell the game dropped, and no agreed edge does", () => { + let dropped = 0; + let droppedAgainstAbsent = 0; + let agreed = 0; + let agreedAgainstAbsent = 0; + + for (let idx = 0; idx < sweep.cases.length; idx++) { + const ours = place(sweep.cases[idx].level, "post"); + const game = gameAt(idx); + for (const [k, ourCode] of ours) { + const theirCode = game.get(k); + if (theirCode === undefined) continue; + const mine = bitsOf(ourCode); + const theirs = bitsOf(theirCode); + const [xs, ys] = k.split(","); + const ci = (Number(xs) - CLIFF_CELL_CENTER_X) / G; + const cj = (Number(ys) - CLIFF_CELL_CENTER_Y) / G; + for (let i = 0; i < 4; i++) { + const nx = (ci + NB[i][0]) * G + CLIFF_CELL_CENTER_X; + const ny = (cj + NB[i][1]) * G + CLIFF_CELL_CENTER_Y; + if (!inRegion(nx, ny)) continue; + const absent = !game.has(`${String(nx)},${String(ny)}`); + if (mine[i] !== 0 && theirs[i] === 0) { + dropped++; + if (absent) droppedAgainstAbsent++; + } else if (mine[i] !== 0 && theirs[i] !== 0) { + agreed++; + if (absent) agreedAgainstAbsent++; + } + } + } + } + + expect(dropped).toBe(1235); + expect(droppedAgainstAbsent).toBe(1233); + expect(agreed).toBe(36103); + expect(agreedAgainstAbsent).toBe(0); + }, 300000); + + /** + * Moving the rejection to the crossing stage - zeroing a rejected cell's four + * edge registers after the repair sweep - is the minimal model of that. Under + * the collapsed rule it removes 44% of the wrong orientations and 12% of the + * over-placement. It is not free: 18 more of the game's cells go missing, + * because an edge taken off a survivor can leave its code non-placing. The + * trade is reported here rather than buried. + */ + it("scores the two stages under the collapsed rule", () => { + const score = (stage: "post" | "crossing"): Record => { + let matched = 0; + let wrong = 0; + let surplus = 0; + let missing = 0; + for (let idx = 0; idx < sweep.cases.length; idx++) { + const ours = place(sweep.cases[idx].level, stage); + const game = gameAt(idx); + for (const [k, code] of ours) { + const t = game.get(k); + if (t === undefined) surplus++; + else if (t === code) matched++; + else wrong++; + } + for (const k of game.keys()) if (!ours.has(k)) missing++; + } + return { matched, wrong, surplus, missing }; + }; + + const post = score("post"); + const crossing = score("crossing"); + + expect(post).toEqual({ matched: 18130, wrong: 1235, surplus: 1366, missing: 85 }); + expect(crossing).toEqual({ matched: 18654, wrong: 693, surplus: 1200, missing: 103 }); + // Every headline moves the right way, and the one that does not is named. + expect(crossing.wrong).toBeLessThan(post.wrong); + expect(crossing.surplus).toBeLessThan(post.surplus); + expect(crossing.matched).toBeGreaterThan(post.matched); + expect(crossing.missing).toBeGreaterThan(post.missing); + }, 600000); + + /** + * And it holds at the SHIPPING settings - smoothing 1, the real 120-tile band + * interval, `cliffiness_basic` rather than a constant - which is the + * configuration the renderer runs and a different one from the collapsed rule + * above. Wrong orientations 33 -> 21 across the three entity regions, with the + * matched set IDENTICAL: this costs no recall at all, it only removes edges + * that were wrong. + */ + it("holds at the shipping settings, at no cost in recall", () => { + const score = (stage: boolean): Record => { + let matched = 0; + let actual = 0; + let predicted = 0; + let wrongOrientation = 0; + for (const c of entities.cases) { + const placement = makeCliffPlacementFromFields(fields, { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + cellRejects: oreRejects, + rejectAtCrossingStage: stage, + }); + const r = c.region; + const ours = new Map( + placement + .placedCells(r.x0, r.y0, r.x1, r.y1) + .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), + ); + const real = c.cliffs.filter((p) => p.name === "cliff-vulcanus"); + predicted += ours.size; + actual += real.length; + for (const p of real) { + const code = ours.get(`${String(p.x)},${String(p.y)}`); + if (code === undefined) continue; + matched++; + const id = nameToId.get(p.orientation); + if (id !== undefined && CLIFF_CODE_TO_ORIENTATION[code] !== id) wrongOrientation++; + } + } + return { matched, actual, predicted, wrongOrientation }; + }; + + const post = score(false); + const crossing = score(true); + + expect(post).toEqual({ matched: 1525, actual: 1569, predicted: 1550, wrongOrientation: 33 }); + expect(crossing).toEqual({ + matched: 1525, + actual: 1569, + predicted: 1547, + wrongOrientation: 21, + }); + // The matched SET is what "no cost in recall" means, not just its size. + expect(crossing.matched).toBe(post.matched); + expect(crossing.wrongOrientation).toBeLessThan(post.wrongOrientation); + }, 300000); +});