Skip to content

feat(cliffs): port the tile-collision rejection (#18) - #73

Merged
wormeyman merged 2 commits into
mainfrom
feat/cliff-collision-rejection
Jul 30, 2026
Merged

feat(cliffs): port the tile-collision rejection (#18)#73
wormeyman merged 2 commits into
mainfrom
feat/cliff-collision-rejection

Conversation

@wormeyman

Copy link
Copy Markdown
Collaborator

Supersedes #72, which GitHub auto-closed when its base branch (feat/cliff-orientation-tables, now merged as #71) was deleted — a closed PR cannot be retargeted or reopened, so this is the same commits rebased onto main.

Ports the rejection #71's tables exist for. EntityMapGenerationTask::tryToAddCliff tests the cell's orientation collision box against the tile mask grid and drops the entity on a hit; generateCliffs ignores the return value - no retry, no alternative orientation, no effect on neighbouring cells - so this is a pure post-filter on the emit loop. That is what makes per-cell evaluation in any order faithful, and tiled output stays byte-identical (tiledEquality.spec.ts passes).

Result

Vulcanus, against oracle-vulcanus-cliff-entities.seed123456:

region ours recall precision ratio was
[0,0] 317 0.784 0.700 1.120 335 / 0.806 / 0.681 / 1.184
[1500,1500] 888 0.933 0.930 1.003 1065 / 0.938 / 0.779 / 1.203
[-1200,800] 371 0.853 0.922 0.925 375 / 0.853 / 0.912 / 0.935

Region 1 - the case the rule was found on - drops 173 false positives against 4 true ones and lands within 0.3% on count. Regions 0 and 2 barely move, so this is not the whole of #18.

Recall gets worse in two regions, and that is reported rather than smoothed. 6 true positives lost at [0,0] and 4 at [1500,1500] are cells where the game placed a cliff and our tile resolver puts lava in its box. The resolver is ~98.2% accurate overall and is plausibly worse at a lava boundary, but that is untested - it is the first thing to check before suspecting the collision geometry.

Nauvis

Kept on its cheap paint-time water-colour skip rather than resolving tiles. That is only sound while no Nauvis cliff's collision box touches water, which was a remembered one-off measurement. It is now a standing check: test/cliffPlacement.spec.ts runs the real rejection and asserts the placed list is unchanged, with three non-vacuity guards - the predicate ran, it found no water under any cliff, and the same resolver does find water elsewhere in the region. If it ever fails, NAUVIS_CLIFF_BLOCKING_TILES is what to pass the placement.

Cost

Two arms, same machine, back to back:

figure without with delta
terrain (the control arm) 3948 3947 -0.0%
cliffs marginal 1500 1917 +28%
ratio all/terrain, whole 1.944 2.040 +4.9%
ratio all/terrain, TILED 2.455 2.569 +4.6%

Terrain landing within 1 ms across the arms is what makes this readable - the bench's own header records a 4.8% baseline drift between runs.

It puts the whole-image "under 2x terrain" gate back over the line, 1.944 -> 2.040. Recorded rather than buried, as the same gate was when the Vulcanus V3 overlays crossed it. The tiled figure - the geometry the app actually renders - was already over at 2.455 and is now 2.569. The untaken cheap lever is a "could lava possibly win here" pre-gate in front of the full 19-tile argmax.

Plumbing

renderVulcanusCliffs now takes the composite's sharedStack like renderVulcanusRocks does, and makeVulcanusCliffFields grows the same optional-shared-stack seam as makeVulcanusRockFields. Without it the tile resolver would rebuild the whole field DAG - memoXY is single-entry, so separate copies share nothing.

Also corrects that file's header, which asserted the opposite of what the game does ("Lava plays that visual role but is not a water tile, and the game does not exclude cliffs from it here") - wrong in both halves.

pnpm run verify green on the rebased branch: 143 test files, 1268 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_012cd6B2MKoJF5W2T175BDoe

wormeyman and others added 2 commits July 30, 2026 13:53
`EntityMapGenerationTask::tryToAddCliff` tests the cell's orientation
collision box against the tile mask grid and drops the entity on a hit.
`generateCliffs` ignores the return value - no retry, no alternative
orientation, no effect on neighbouring cells - so this is a pure
post-filter on the emit loop, which is why it runs per-cell in any order
and leaves tiled output byte-identical.

`CliffBands.tileCollides` is the seam, planet-agnostic: a tile blocks a
cliff when its CollisionMask shares a layer with the cliff's. The cliff
mask holds `water_tile`, so that is lava on Vulcanus
(`tile_collision_masks.lava()` sets it) and water on Nauvis.

Vulcanus, against `oracle-vulcanus-cliff-entities.seed123456`:

| region | ours | recall | precision | ratio | was |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 317 | 0.784 | 0.700 | 1.120 | 335 / 0.806 / 0.681 / 1.184 |
| [1500,1500] | 888 | 0.933 | 0.930 | 1.003 | 1065 / 0.938 / 0.779 / 1.203 |
| [-1200,800] | 371 | 0.853 | 0.922 | 0.925 | 375 / 0.853 / 0.912 / 0.935 |

Region 1 - the case the rule was found on - drops 173 false positives
against 4 true ones and lands within 0.3% on count. Regions 0 and 2 barely
move, so this is not the whole of #18.

Recall gets WORSE in two regions and that is reported rather than
smoothed: 6 true positives lost at [0,0] and 4 at [1500,1500] are cells
where the game placed a cliff and our tile resolver puts lava in its box.
The resolver is ~98.2% accurate overall and is plausibly worse at a lava
boundary, but that is untested and is the first thing to check before
suspecting the collision geometry.

Nauvis keeps its cheap paint-time water-colour skip rather than resolving
tiles, which is only sound while no Nauvis cliff's box touches water.
That was a remembered one-off measurement; `test/cliffPlacement.spec.ts`
now runs the real rejection and asserts the placed list is unchanged,
with three non-vacuity checks - the predicate ran, it found no water under
any cliff, and the same resolver does find water elsewhere in the region.

`renderVulcanusCliffs` now takes the composite's `sharedStack` like
`renderVulcanusRocks` does, and `makeVulcanusCliffFields` grows the same
optional-shared-stack seam as `makeVulcanusRockFields`; without it the
tile resolver would rebuild the whole field DAG, since `memoXY` is
single-entry and separate copies share nothing.

Also corrects the header of `renderVulcanusCliffs`, which asserted the
opposite of what the game does ("Lava plays that visual role but is not a
water tile, and the game does not exclude cliffs from it here") - wrong in
both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cd6B2MKoJF5W2T175BDoe
Two arms of the vulcanus block, same machine, back to back, with and
without `tileCollides`:

| figure                    | without | with  | delta |
| ------------------------- | ------- | ----- | ----- |
| terrain (the control arm) |    3948 |  3947 | -0.0% |
| cliffs marginal           |    1500 |  1917 | +28%  |
| ratio all/terrain, whole  |   1.944 | 2.040 | +4.9% |
| ratio all/terrain, TILED  |   2.455 | 2.569 | +4.6% |

Terrain landing within 1 ms across the arms is what makes the number
readable - this file's own header records a 4.8% baseline drift between
runs, which would otherwise swamp a change this size.

It puts the whole-image "under 2x terrain" gate back over the line,
1.944 -> 2.040. Recorded rather than buried, as the same gate was when the
Vulcanus V3 overlays crossed it. The tiled figure - the geometry the app
actually renders - was already over at 2.455 and is now 2.569.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cd6B2MKoJF5W2T175BDoe
@wormeyman
wormeyman merged commit ff4787b into main Jul 30, 2026
2 checks passed
@wormeyman
wormeyman deleted the feat/cliff-collision-rejection branch July 30, 2026 21:00
wormeyman added a commit that referenced this pull request Jul 30, 2026
The lava collision rejection (#73) costs 10 real cliffs, and the guess
recorded alongside it was that the Vulcanus tile resolver - ~98.2% on the
19-way tile name - is worse at a lava boundary. Measured now, on the two
committed fixtures, with no Factorio run needed. The guess was wrong in
its premise and right in its conclusion.

The binary lava/not classification, which is the only thing
`tryToAddCliff` reads, is EXACT: 381 oracle positions, 49 lava and 332
not, zero mismatches in either direction. All 7 name errors are
non-lava/non-lava confusions inside one biome family, and the 42
positions sitting directly on a lava boundary are 42/42 correct on the
full name.

It is still off by about a tile, and every real cliff proves it: the game
ran this same rejection and kept the cliff, so it saw no lava in that
box. 10 of the 1400 real cliffs we place contradict us (0.71%), and in
all 10 the offending tile sits at Chebyshev depth 1 inside our own lava.
Depth 1 alone does not discriminate, so the control is region
[1500,1500]'s 173 correct rejections, spread across the whole range (65
at depth 1, 52 deeper than 6).

That rules the resolver out as a cause of the remaining over-placement,
which is why it was worth measuring before reading more of
generateCliffs. An under-calling resolver would leave false positives
next to lava; they are not. Only 4.2% of region [0,0]'s 95 surviving
false positives come within 2 tiles of lava against 7.2% of its matched
true positives - the wrong way round - and elsewhere the bulk (42/62,
20/29) are more than 8 tiles from any lava.

Adds a zero-mismatch pin on the lava classification, with its sensitivity
measured rather than assumed: planted scale factors on lava's probability
show 1.02 and 1.2 pass while 2, 5 and 20 fail, so it guards against a
broken range expression and not against a sub-tile shift. Says so at the
assertion.

No production code changes.


Claude-Session: https://claude.ai/code/session_013eczw9uoWDfN6Wc6kA7UBY

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 2, 2026
#84) (#86)

#84 opened with two symptoms and they were one measurement error: every arm
compared OUR placement, which did not run tryToAddCliff's lava-collision
rejection, against the GAME's, which always does. On Vulcanus the lava is the
basalt lakes, so the missing deletion rule's effect lands exactly in the
low-elevation range where the residual sat.

Item 1 - the 187-cell excess. Turning the rejection on removes 198 cells across
the three oracle regions: 185 false positives against 13 true.

|                | game | ours | matched | recall | precision | wrong orient |
| -------------- | ---- | ---- | ------- | ------ | --------- | ------------ |
| no rejection   | 1569 | 1756 | 1531    | 0.9758 | 0.8719    | 37 = 2.42%   |
| with rejection | 1569 | 1558 | 1518    | 0.9675 | 0.9743    | 31 = 2.04%   |

Region [0,0] reaches precision exactly 1.000. The port goes from over-placing
12% to under-placing 0.7%.

Item 2 - the surviving regime split. The low/high over-placement gap of 0.067
read as a second-order error in the same multisample term. Running both sides
with the rejection collapses it to 0.018, and the low regime straddles 1.0
instead of sitting above it (level 20: 1.085 -> 0.988). There is no second
multisample defect to find.

What is actually left is a TILE boundary, not a cliff field. The rejection costs
recall in the same regime (0.951 at level 20 -> 1.000 at 140+), and every
contradiction sits at Chebyshev depth 1 in our lava - our own perimeter, never
deeper: 32/32 at level 20, 52/52 across the sweep, 13/13 at default settings.
Stated carefully in the notes, because depth discriminates at default settings
(region 1's 170 correct rejections span depth 1-9, 45 deep in lava) and does NOT
at level 20, where the correct rejections are 32/32 perimeter too.

- vulcanusCliffEntities.spec.ts: new table; guards tightened from
  recall>0.77/precision>0.68/ratio<1.15 to 0.95/0.94/1.05.
- cliffOrientationOracle.spec.ts: bound 175 -> 37, floor 1350 -> 1500 so a
  change that shrinks both has not fixed anything, it has stopped comparing.
  Records why this arm deliberately omits the rejection.
- vulcanusElevationLevels.spec.ts: new arm attributing the split, with a
  non-vacuity guard on cells actually removed (verified by planting a no-op
  rejection: it fails with "expected 0 to be greater than 100").
- Docs: banners carry the shipping-path row; corrected the Vulcanus rendering
  note that claimed the game does not exclude cliffs from lava - inferred from
  the absence of a water TILE rather than measured, and wrong since #71/#73.
- Cleared a pre-existing unused-import warning in cliffResidual.spec.ts.


Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
) (#108)

#107 handed over one untested hypothesis with its control named: "disputed
edges sit adjacent to rejected cells above the base rate of all crossing
edges". 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.

Of the 1235 edges the port has and the game does not, **1233** sit against a
cell the game did not emit. Of the 36,103 in-region edges both sides agree
on, **0** do. A cell's edge register is the same array slot as its
neighbour's (#103), so the game's absences take their crossings with them.

**The refutation is structural, not a score.** A pure post-filter makes a
falsifiable prediction: when cell N is rejected, its surviving neighbour C
still holds the shared crossing and is emitted carrying it. Counted with the
port's own rejection predicate over the fine sweep's 41 levels, that should
happen **1,662 times**. The game does it **0 times**. The vacuity arm is the
identical counter reading the port's own post-filter output, where it fires
on all 1,662 - so the zero is a property of the game's cliffs, not a dead
branch.

`tryToAddCliff` really does ignore `wouldCollide`'s return value; the #71/#73
disassembly reading was not misread. What is refuted is the conclusion drawn
from it - "and therefore no effect on the neighbouring cells". Both doc
comments that asserted it are corrected rather than left standing.

`rejectAtCrossingStage` zeroes a rejected cell's four edge registers after
the repair sweep, before any code is read. Collapsed rule: wrong orientations
**1235 -> 693**, surplus 1366 -> 1200, matched 18130 -> 18654. At the
SHIPPING settings across the three entity regions: wrong orientations
**33 -> 21**, precision 0.9839 -> 0.9858, and the matched set IDENTICAL at
1525 - it removes wrong edges and costs no recall.

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 byte-identical either way (it already matched
1.0000 both directions), so this is not a regression risk there and not
corroboration from there either.

Tiling stays byte-identical - the zeroing runs over the whole chunk,
including cells outside the query box - and `tiledEquality.spec.ts`'s
Vulcanus cliff seam and chunk-boundary cases verify it rather than the
comment asserting it.

What is left is the PREDICATE, not the stage: 693 wrong and 1200 surplus
survive, and the sweep cannot measure the field where the game emits nothing
at any level.

Item of #84; the issue stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExK3ASs5i2KMm9NwWqnZuj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant