diff --git a/docs/noise/client-preview-ROADMAP.md b/docs/noise/client-preview-ROADMAP.md index cf7371c..bb58b3d 100644 --- a/docs/noise/client-preview-ROADMAP.md +++ b/docs/noise/client-preview-ROADMAP.md @@ -335,8 +335,9 @@ Done = ore patches overlaid on land, responding to the frequency/size/richness s the cliff generator (4-tile lattice) and the tile generator (1 tile) read different `vulcanus_elevation`. PR #57's substitution missed it because its fixture came through the same 1-tile channel the port used. Vulcanus recall - is now 1.000/0.973/0.965 with 2.4% wrong orientations; see the ROOT CAUSE - section of `cliffs-NOTES.md` and issue #84 for the remainder. `VoronoiNoise` (layer-1 primitive table below) is + on the shipping path is now 0.9675 with precision 0.9743 and 2.0% wrong + orientations; see the ROOT CAUSE section of `cliffs-NOTES.md` and issue #84 + for the remainder. `VoronoiNoise` (layer-1 primitive table below) is confirmed **unneeded for Nauvis** - it appears nowhere in the cliff tree or any other Nauvis expression traced so far, only on Space-Age planets - so it remains un-ported with no open TODO against it. diff --git a/docs/noise/cliffs-NOTES.md b/docs/noise/cliffs-NOTES.md index 411e0bd..ac5f151 100644 --- a/docs/noise/cliffs-NOTES.md +++ b/docs/noise/cliffs-NOTES.md @@ -5,10 +5,21 @@ > | | recall | precision | wrong orientation | > | --- | --- | --- | --- | > | **Nauvis** | 1.0000 | 1.0000 | **0 / 334** | -> | **Vulcanus** | 1.000 / 0.973 / 0.965 | 0.872 | **37 / 1531 = 2.4%** | +> | **Vulcanus**, as shipped | **0.9675** | **0.9743** | **31 / 1518 = 2.0%** | +> | **Vulcanus**, no lava rejection | 0.9758 | 0.8719 | 37 / 1531 = 2.4% | > -> Vulcanus's precision is measured **without** the lava-collision rejection, which -> the shipping renderer does apply; the remainder is tracked in **issue #84**. +> **Read the shipping row.** The renderer applies `tryToAddCliff`'s lava-collision +> rejection and the second row does not; leaving it off is what produced the +> "precision 0.872 / 187-cell excess" figure #84 opened with. The rejection drops +> 198 cells, **185 of them false positives and 13 true**, so almost the whole +> excess was a rule the measurement was not applying. Measured 2026-08-01 in +> `test/vulcanusCliffEntities.spec.ts`; the no-rejection row is kept because +> `test/cliffOrientationOracle.spec.ts` deliberately scores the larger set. +> +> What is left on Vulcanus: 51 of the game's 1569 missing, 40 of our 1558 +> spurious, 31 matched cells carrying a wrong orientation. **13 of the 51 are a +> TILE question, not a cliff one** - real cliffs whose collision box hits our lava +> at Chebyshev depth 1, our own perimeter, never deeper. Remainder in **#84**. > > **Read `## ROOT CAUSE, 2026-08-01` (further down) before anything else in this > file.** It is the resolution of issue #18: `multisample`'s offsets are in the @@ -690,6 +701,11 @@ behaves the same when the calling program's grid is 4 tiles rather than 1. > | `[0,0]` (worst region) | 29.8% wrong | **2.5%**, recall 1.000 | > | level sweep, ratio | 1.20-1.49 below 120 | **1.00-1.09 at every level** | > +> Both columns are measured **without** the lava-collision rejection, which is the +> right control for isolating this one change but is not the shipping path. On the +> path the renderer runs, "after" is recall 0.9675 / precision 0.9743 / 31 wrong - +> see the banner at the top of this file. +> > `VulcanusElevation` now exposes `cliffElevation` beside `elevation`; both hang > off one stack and share every sub-expression below the multisample, so the cost > is a second memo table. **Do not collapse them back together** - they are diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 681e3c2..9f05166 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -2,9 +2,12 @@ > ## STATUS, 2026-08-01: issue #18 is CLOSED; remainder tracked in #84 > -> Recall **1.000 / 0.973 / 0.965** across the three regions, **37 / 1531 = 2.4%** -> wrong orientations (was 0.806 / 0.938 / 0.853 and 12.5%). Precision 0.872, -> measured **without** the lava-collision rejection the shipping renderer applies. +> **As shipped** (with `tryToAddCliff`'s lava-collision rejection, which is what +> the renderer runs): recall **0.9675**, precision **0.9743**, **31 / 1518 = 2.0%** +> wrong orientations. Without the rejection the same code scores recall 0.9758, +> precision 0.8719, 37 / 1531 = 2.4% - and it is that second row, not a defect, +> that #84 opened against. The rejection drops 198 cells, 185 false positives to +> 13 true. Before the fix: recall 0.806 / 0.938 / 0.853 and 12.5% wrong. > > **Root cause: `multisample`'s offsets are in the calling noise program's GRID > UNITS, not tiles**, so `vulcanus_basalt_lakes_multisample`'s `min` is a 4-tile @@ -14,7 +17,9 @@ > `test/multisampleGrid.spec.ts`. > > **Every accuracy table below this banner is the PRE-FIX state**, kept because the -> reasoning is the useful part. Do not quote one as current. +> reasoning is the useful part. Do not quote one as current. The one exception is +> the final section, `## The lava rejection accounted for the "excess"`, which is +> post-fix and is where the numbers in this banner come from. Factorio 2.1.12 (build 87038, mac-arm64). Ported 2026-07-26. Companion to `cliffs-NOTES.md`, which holds the reverse-engineering of the placement rule @@ -138,8 +143,16 @@ positions - which is part of why `sulfuricAcidPatches` carries a 2.9e-3 bound. geometry (`makeCliffPlacementFromFields`, factored out of `makeCliffPlacement` for this) and the paint loop (`paintCliffCells`). Two differences: -- **No water exclusion.** Vulcanus has no water tile. Lava plays that visual - role but is not water, and the game does not exclude cliffs from it here. +- **No water exclusion, but there IS a lava exclusion.** Vulcanus has no water + tile, so `renderCliffs`' water check has nothing to test. This section used to + read "the game does not exclude cliffs from lava" and **that was wrong** - it + was inferred from the absence of a *water* tile rather than measured. + `tryToAddCliff` tests the orientation's collision box against the tile mask + grid, and `tile_collision_masks.lava()` sets the same `water_tile` bit the + cliff mask excludes, so lava rejects cliffs by exactly the same mechanism water + does. Ported in #71/#73; it is worth 185 false positives across the three + oracle regions (precision 0.8719 -> 0.9743) and is passed as `tileCollides` + from `VULCANUS_CLIFF_BLOCKING_TILES`. - **No disable path.** With no continuity slider there is nothing to zero. `cliff-vulcanus` declares `map_color = {144, 119, 87}` @@ -912,3 +925,74 @@ cliff 78% of the time (93 of 120 at `[1100,2600]`) - they are edge-of-line offsets along a real cliff face. The ones **inside** ore sit 2-5 cells away (4 of 37 at distance 1). They are not misaligned cliff lines; they are cliff faces the game does not have at all. + +## The lava rejection accounted for the "excess", 2026-08-01 (#84 items 1-2) + +Issue #84 opened with two symptoms, and they turned out to be one measurement +error. Both arms compared **our placement, which did not run the lava-collision +rejection, against the game's, which always does.** `tryToAddCliff` drops any +cliff whose collision box touches a lava tile, and on Vulcanus the lava is the +basalt lakes - so the comparison was missing a deletion rule whose effect is +concentrated in exactly the low-elevation range where the residual sat. + +**Item 1, the 187-cell excess.** Turning the rejection on removes 198 cells +across the three regions: **185 false positives against 13 true**. + +| | game | ours | matched | recall | precision | wrong orientation | +| --- | --- | --- | --- | --- | --- | --- | +| no rejection | 1569 | 1756 | 1531 | 0.9758 | 0.8719 | 37 = 2.42% | +| **with rejection** | 1569 | **1558** | 1518 | 0.9675 | **0.9743** | 31 = 2.04% | + +Per region, with it on: `[0,0]` 283/277, precision **exactly 1.000**; +`[1500,1500]` 885/895, precision 0.9564; `[-1200,800]` 401/386, precision 0.9974. +The port goes from over-placing 12% to under-placing 0.7%. + +**Item 2, the surviving regime split.** `test/vulcanusElevationLevels.spec.ts` +had the low-elevation over-placement ratio at 1.085 against 1.018 high, a gap of +0.067 that read as a second-order error in the same `multisample` term. Run both +sides with the rejection and it collapses: + +| `cliff_elevation_0` | ours/game, no rejection | with rejection | +| --- | --- | --- | +| 20 | 1.085 | 0.988 | +| 40 | 1.048 | 1.022 | +| 60 | 1.044 | **1.027** | +| 90 - 130 | 1.008 - 1.018 | 0.991 - 1.000 | +| 140 - 200 | 1.000 - 1.009 | 1.000 - 1.009 | + +Gap 0.067 -> **0.018**, and the low regime now straddles 1.0 instead of sitting +above it. There is no second `multisample` defect to find. + +### What is actually left is a TILE boundary, not a cliff field + +The rejection costs recall, and it costs it in the same regime: 0.951 at level +20 rising to 1.000 at 140 and above. Each real cliff is a negative-space oracle - +the game ran this rejection and kept it, so the game saw no lava in that box - +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. + +**Depth discriminates in one of the two places it was checked, and not the +other.** At default settings it does: `[1500,1500]`'s 170 *correct* rejections +span depth 1 to 9, 45 of them bottomed out deep in lava, against wrong rejections +that are 100% perimeter. At level 20 it does not - there the correct rejections +are 32/32 perimeter too, because the contour has walked down onto the lake edges +and every candidate is near a boundary. So the supported claim is that these +errors are boundary-**sited** in both directions and that at level 20 we call +about half of them right; not that depth alone proves the perimeter is one tile +fat. Narrowing that is a `vulcanusCatalog` question, not a cliff one. + +Note what the tile resolver is already known to get right, so this is not a +retread: its binary lava/not classification is **exact on all 381 oracle +positions**, including 42 sitting directly on a lava boundary +(`vulcanusTiles.spec.ts`). Whatever is off is finer than the sample that pinned +it. + +### Method note + +This is the same trap as #83 wearing different clothes. There, a fixture was +right at the right site but captured through the wrong **channel**. Here, two +sides of a comparison ran different **rule sets** - and in both cases the +mismatch had a plausible mechanism ready to absorb it, so the wrong explanation +was the comfortable one. Before attributing a residual to a mechanism, check +that both sides of the comparison are running the same rules. diff --git a/test/cliffOrientationOracle.spec.ts b/test/cliffOrientationOracle.spec.ts index 95e4383..eb1fde2 100644 --- a/test/cliffOrientationOracle.spec.ts +++ b/test/cliffOrientationOracle.spec.ts @@ -96,43 +96,58 @@ describe("cliff orientation vs the game's own cliff_orientation", () => { }, 120000); /** - * **Vulcanus does NOT agree, and that is issue #18's residual seen up close.** + * **Vulcanus still does not agree everywhere, and this is issue #18's + * residual seen up close.** * * Nauvis passing exactly means the table above is right, so a disagreement * here is a disagreement about the CROSSINGS - the four edges - not about the - * lookup. Measured 2026-07-30, over the cells the port and the game both - * place: + * lookup. Measured 2026-08-01, after the `multisample` grid-units fix (#83), + * over the cells the port and the game both place: * - * | region | matched | wrong orientation | - * | --- | --- | --- | - * | `[0,0]` | 228 | 68 = **29.8%** | - * | `[1500,1500]` | 830 | 67 = 8.1% | - * | `[-1200,800]` | 342 | 40 = 11.7% | - * | total | 1400 | 175 = 12.5% | + * | region | matched | wrong orientation | was, before #83 | + * | --- | --- | --- | --- | + * | `[0,0]` | 283 | 7 = 2.5% | 228 / 68 = **29.8%** | + * | `[1500,1500]` | 861 | 26 = 3.0% | 830 / 67 = 8.1% | + * | `[-1200,800]` | 387 | 4 = 1.0% | 342 / 40 = 11.7% | + * | total | 1531 | **37 = 2.4%** | 1400 / 175 = 12.5% | + * + * Note the comparison set GREW as the error shrank - the port now matches 131 + * more of the game's cliffs - so this is not 175 falling to 37 by comparing + * fewer cells. * * This is a far sharper instrument than the counts in * `vulcanusCliffEntities.spec.ts`: a cell can land in the right place for the - * wrong reason, and 175 of them do. The dominant failure is **exactly two - * edges differing** (125 of 175), which is one of the cell's two crossings - * sitting on a different side - a single corner on the wrong side of a band - * boundary. Errors are spread evenly over the four edges (L:87 R:80 T:87 - * B:89), so it is not a directional off-by-one. + * wrong reason, and 37 of them still do. Before #83 the dominant failure was + * **exactly two edges differing** (125 of 175), which is one of the cell's two + * crossings sitting on a different side - a single corner on the wrong side of + * a band boundary - spread evenly over the four edges (L:87 R:80 T:87 B:89), + * so never a directional off-by-one. + * + * **This arm deliberately runs WITHOUT the lava rejection**, which is not the + * shipping path and is the point. Rejection only ever REMOVES cells, so + * leaving it off compares the larger set (1531 rather than 1518) and cannot + * hide a bad crossing behind a cell that got dropped for an unrelated reason. + * On the shipping path the same measurement is 31 / 1518 = 2.04%; the + * rejection removes 6 wrong ones with the 185 false positives it is there for. * - * Three causes were tested against this metric and none of them explain it: + * Causes tested against this metric before #83, none of which explained it, + * kept because each is a closed door: * - * - **The fields are exonerated a second time.** Re-running PR #57's - * substitution - the game's own corner elevation and cliffiness, at - * `[1500,1500]` - leaves the mismatch at 67/830, identical to the digit, - * while a +3 elevation bias moves it to 122/793. So the substitution is - * live and this metric is sensitive to it; the fields are simply right. - * #57 only scored PLACEMENT, one bit per cell, and could not have seen this. - * - **`fixImpossibleCells` is not it.** Turning it off moves the total from - * 12.5% to 14.3%, and region `[0,0]` from 29.8% to 30.8%. It helps slightly - * and explains almost nothing. - * - **Chunk borders are not it.** `generateCliffs` passes `tryToAddCliff` a + * - **The fields were exonerated at the site they were sampled.** Re-running + * PR #57's substitution - the game's own corner elevation and cliffiness, at + * `[1500,1500]` - left the mismatch at 67/830, identical to the digit, while + * a +3 elevation bias moved it to 122/793. The substitution was live and the + * metric sensitive to it. What that could not see is that the fixture had + * been captured through `calculate_tile_properties`, a DIFFERENT channel + * from the one the cliff generator reads - which is exactly what #83 turned + * out to be. A field can be right at the right site and still be the wrong + * field for the consumer. + * - **`fixImpossibleCells` was not it.** Turning it off moved the total from + * 12.5% to 14.3%, and region `[0,0]` from 29.8% to 30.8%. + * - **Chunk borders were not it.** `generateCliffs` passes `tryToAddCliff` a * `!onChunkBorder` flag, and `fixImpossibleCells` cannot clear a border * edge, so the outer ring of each 8x8 chunk was the obvious suspect. Border - * cells are wrong 13.3% of the time against interior's 11.9% - no + * cells were wrong 13.3% of the time against interior's 11.9% - no * concentration - and the game places cliffs uniformly across all 64 * in-chunk positions (17-36 each), so that flag suppresses nothing. * @@ -163,14 +178,16 @@ describe("cliff orientation vs the game's own cliff_orientation", () => { } } // Non-vacuity: this arm skips cells the port does not place, so without a - // floor a port that placed NOTHING would pass on an empty comparison. 1400 - // is the measured matched count (2026-07-30, placement without the lava - // rejection, which is what is built above). - expect(compared).toBeGreaterThan(1350); - // Measured 175. An upper bound, not an equality, so fixing the rule does not - // require editing this line - but tight enough that a regression fails. + // floor a port that placed NOTHING would pass on an empty comparison. 1531 + // is the measured matched count (2026-08-01, placement without the lava + // rejection, which is what is built above). The floor is raised with the + // bound below for a reason: a change that shrinks BOTH numbers has not + // fixed anything, it has stopped comparing. + expect(compared).toBeGreaterThan(1500); + // Measured 37. An upper bound, not an equality, so improving the rule does + // not require editing this line - but tight enough that a regression fails. // Do NOT raise it to make a change pass: this number going up means the // crossings got worse, which is the whole thing #18 is about. - expect(wrong.length).toBeLessThanOrEqual(175); + expect(wrong.length).toBeLessThanOrEqual(37); }, 120000); }); diff --git a/test/cliffResidual.spec.ts b/test/cliffResidual.spec.ts index 688e963..ebe4e67 100644 --- a/test/cliffResidual.spec.ts +++ b/test/cliffResidual.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { makeCliffElevation, makeCliffFields } from "../src/noise/cliffs/cliffFields"; -import { makeCliffPlacementFromFields, smoothingKnots } from "../src/noise/cliffs/cliffPlacement"; +import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; import { VULCANUS_CLIFF_ELEVATION_0, VULCANUS_CLIFF_ELEVATION_INTERVAL, diff --git a/test/vulcanusCliffEntities.spec.ts b/test/vulcanusCliffEntities.spec.ts index 30130a8..dcd2fc0 100644 --- a/test/vulcanusCliffEntities.spec.ts +++ b/test/vulcanusCliffEntities.spec.ts @@ -110,25 +110,36 @@ describe("Vulcanus cliff placement vs find_entities", () => { // `water_tile` and `tile_collision_masks.lava()` sets it. See // `CLIFF_ORIENTATION_COLLISION_BOX`. // - // | region | game | ours | recall | precision | ratio | before rejection | - // | --- | --- | --- | --- | --- | --- | --- | - // | 0 `[0,0]` | 283 | 317 | 0.784 | 0.700 | 1.120 | 335 / 0.806 / 0.681 / 1.184 | - // | 1 `[1500,1500]` | 885 | 888 | 0.933 | 0.930 | **1.003** | 1065 / 0.938 / 0.779 / 1.203 | - // | 2 `[-1200,800]` | 401 | 371 | 0.853 | 0.922 | 0.925 | 375 / 0.853 / 0.912 / 0.935 | + // **Updated 2026-08-01, after the `multisample` grid-units fix (#83).** + // That fix is measured in `test/multisampleGrid.spec.ts`; these are its + // end-to-end numbers on the path the renderer actually runs. // - // Region 1 is the case this rule was found on and it lands almost exactly: - // 173 false positives dropped against 4 true ones, ratio 1.203 -> 1.003. + // | region | game | ours | recall | precision | ratio | + // | --- | --- | --- | --- | --- | --- | + // | 0 `[0,0]` | 283 | 277 | 0.9788 | **1.0000** | 0.979 | + // | 1 `[1500,1500]` | 885 | 895 | 0.9672 | 0.9564 | 1.011 | + // | 2 `[-1200,800]` | 401 | 386 | 0.9601 | 0.9974 | 0.963 | + // | **total** | **1569** | **1558** | **0.9675** | **0.9743** | 0.993 | // - // **Reported rather than smoothed over: recall gets WORSE in two regions.** - // Region 0 loses 6 true positives (0.806 -> 0.784) and region 1 loses 4. - // Those are cells where the game placed a cliff and our tile resolver puts - // lava inside its box. + // Region 0 now places no cell the game did not - precision is exactly 1. // - // **Those 10 are a one-tile boundary error, and that is measured now - // (2026-07-30), not assumed.** This comment used to carry the guess that - // the resolver "is plausibly worse at a lava boundary"; the guess was - // wrong in its premise and right in its conclusion, so both halves are - // worth stating: + // **The lava rejection is what closes the over-placement, and #84 item 1 + // asked how much.** The answer is nearly all of it. Without it the same + // code places 1756 against the game's 1569 for a precision of 0.8719; the + // rejection drops 198 cells, of which **185 are false positives and 13 are + // true**. Precision 0.8719 -> 0.9743 and the port goes from over-placing + // 12% to under-placing 0.7%. So the "187-cell excess" recorded in #84 was + // 185 cells of a rule the measurement was not applying, not a defect. + // + // **Reported rather than smoothed over: the rejection costs recall.** + // Those 13 lost true positives are cells where the game placed a cliff and + // our tile resolver puts lava inside its box. + // + // **They are a one-tile boundary error, and that is measured (2026-07-30, + // re-measured 2026-08-01), not assumed.** This comment used to carry the + // guess that the resolver "is plausibly worse at a lava boundary"; the + // guess was wrong in its premise and right in its conclusion, so both + // halves are worth stating: // // - The resolver is NOT worse at a lava boundary. Its binary lava/not // classification - the only thing `tryToAddCliff` reads - is EXACT on @@ -139,23 +150,17 @@ describe("Vulcanus cliff placement vs find_entities", () => { // - It is nonetheless off by about one tile SOMEWHERE, because each real // cliff the game placed is itself a negative-space oracle: the game ran // this same rejection and kept the cliff, so the game sees no lava in - // that box. Over the 1400 real cliffs we place across the three regions, - // 10 boxes contradict that - 0.71% - and in **all 10** the offending + // that box. Over the 1531 real cliffs we place across the three regions, + // 13 boxes contradict that - 0.85% - and in **all 13** the offending // tile sits at Chebyshev depth 1 inside our lava, i.e. on our own - // perimeter. Not one is deep water. + // perimeter. Not one is deep lava. // // Depth 1 is the common case for any lava tile, so that alone would not - // discriminate; region 1's 173 correct rejections are the control and they - // are spread right across the range (65 at depth 1, 52 deeper than 6). The - // rule's real work is untouched by the boundary. - // - // **This also rules the resolver out as a cause of the residual below**, - // which was the reason to measure it first. An under-calling resolver - // would leave false positives sitting next to lava; they do not. Of region - // 0's 95 surviving false positives only 4.2% come within 2 tiles of any - // lava, against 7.2% of its matched true positives - the wrong way round - - // and in regions 1 and 2 the bulk (42/62 and 20/29) are more than 8 tiles - // from the nearest lava tile. Whatever is left is not a lava question. + // discriminate; region 1's 170 correct rejections are the control and they + // are spread right across the range (62 at depth 1, 61 deeper than 6, 45 + // of those bottomed out at depth 9). Wrong rejections are 100% perimeter + // against a control that is 36% perimeter, so the rule's real work is + // untouched by the boundary. // // A control run pins that the rejection is not just deleting cells at the // background lava rate: sampling the same lava field 10,000 tiles away @@ -163,15 +168,20 @@ describe("Vulcanus cliff placement vs find_entities", () => { // indiscriminate, ratio collapsing to 0.65 / 0.70. The real arm rejects // almost only false positives. // - // **Still not Nauvis-grade, and the remaining gap is no longer one thing.** + // **Still not Nauvis-grade, but it is now one thing, not several.** // `test/cliffPlacement.spec.ts` measures Nauvis at 1.0000 recall AND - // precision. Region 1 is now within 0.3% on count; regions 0 and 2 barely - // moved and are +12% and -7.5%. The residual is not one-directional - region - // 2 UNDER-places - which is why the ratio is guarded on both sides below. - expect(recall).toBeGreaterThan(0.77); - expect(precision).toBeGreaterThan(0.68); - expect(predicted.size / actual.size).toBeLessThan(1.15); - expect(predicted.size / actual.size).toBeGreaterThan(0.9); + // precision. Here 51 of the game's 1569 are missing and 40 of our 1558 are + // spurious. 13 of the 51 are the perimeter error above - a TILE question, + // not a cliff one. The residual is not one-directional (regions 0 and 2 + // UNDER-place, region 1 over-places), which is why the ratio is guarded on + // both sides below. + // + // Guards sit just outside the measured values in the direction that would + // signal a regression, and open in the direction of improvement. + expect(recall).toBeGreaterThan(0.95); + expect(precision).toBeGreaterThan(0.94); + expect(predicted.size / actual.size).toBeLessThan(1.05); + expect(predicted.size / actual.size).toBeGreaterThan(0.95); }, 120000); } }); diff --git a/test/vulcanusElevationLevels.spec.ts b/test/vulcanusElevationLevels.spec.ts index 4ba8bb9..05a8cb1 100644 --- a/test/vulcanusElevationLevels.spec.ts +++ b/test/vulcanusElevationLevels.spec.ts @@ -6,6 +6,8 @@ import { makeCliffinessBasic, makeVulcanusCliffFields, } from "../src/noise/cliffs/vulcanusCliffFields"; +import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; +import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; import { withCtxDefaults } from "../src/noise/eval/ctx"; const key = (x: number, y: number): string => `${String(x)},${String(y)}`; @@ -18,8 +20,21 @@ const fields = { cliffiness: makeCliffinessBasic(fx.seed, 4), }; +/** + * The lava rejection, the same predicate `renderVulcanusCliffs` passes. Off by + * default here: this file's job is to invert the elevation FIELD, and the + * rejection brings the tile resolver - a different subsystem - into the answer. + * The last test turns it on deliberately, to attribute a residual to it. + */ +const tileAt = makeVulcanusTileResolver({ seed0: fx.seed, startingPositions: [{ x: 0, y: 0 }] }); +const lavaCollides = (x: number, y: number): boolean => + VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); + /** Per level: how many cells the game placed, how many we place, and the overlap. */ -const atLevel = (index: number): { level: number; game: number; ours: number; both: number } => { +const atLevel = ( + index: number, + reject = false, +): { level: number; game: number; ours: number; both: number } => { const c = fx.cases[index]; const r = fx.region; const game = new Set(); @@ -29,11 +44,13 @@ const atLevel = (index: number): { level: number; game: number; ours: number; bo elevation0: c.elevation0, interval: c.effective?.cliff_elevation_interval ?? 1000000, smoothing: 0, + tileCollides: reject ? lavaCollides : undefined, }).placedCells(r.x0, r.y0, r.x1, r.y1); let both = 0; const ours = new Set(); for (const p of cells) { const k = key(Math.round((p.x - 2) / 4), Math.round((p.y - 2.5) / 4)); + if (ours.has(k)) continue; ours.add(k); if (game.has(k)) both++; } @@ -127,14 +144,77 @@ describe("Vulcanus elevation, inverted through a cliff_elevation_0 sweep", () => // below the edge before. Asserting a single band across BOTH regimes is the // inversion of the old test, which asserted a gap between them. for (const v of [...high, ...low]) expect(v).toBeLessThanOrEqual(1.09); - // The regime split has NOT vanished entirely, and that is worth recording - // rather than rounding away: the worst low-level ratio is 1.085 against the - // worst high-level 1.018, a gap of 0.067 where it used to be 1.20 vs 1.04 - // (0.16). So it shrank ~2.4x but a small residual of the SAME SHAPE - excess - // placement concentrated in the basalt-lakes elevation range - survives. It - // is the remaining lead for the Vulcanus cliff follow-up. + // A small split does survive here - worst low ratio 1.085 against worst + // high 1.018, a gap of 0.067 where it used to be 0.16 - and #84 item 2 + // recorded it as a suspected second-order error in the same `multisample` + // term. **It is not. See the next test**, which attributes it. const gap = Math.max(...low) - Math.max(...high); expect(gap).toBeGreaterThan(0); expect(gap).toBeLessThan(0.1); }); + + /** + * **The surviving split is a MEASUREMENT artefact, not a second-order error + * in `multisample`** (measured 2026-08-01, closing #84 item 2). + * + * Everything above compares our placement, which does not run the lava + * rejection, against the game's, which always does. `tryToAddCliff` drops any + * cliff whose collision box touches a lava tile - and on Vulcanus the lava is + * the basalt lakes, i.e. exactly the low-elevation range where the excess sat. + * So the arm reading "we over-place below 120" was really reading "we do not + * delete what the game deletes, and there is more to delete down there." + * + * Running both sides with the rejection collapses it: + * + * | `cliff_elevation_0` | ours/game, no rejection | with rejection | + * | --- | --- | --- | + * | 20 | 1.085 | 0.988 | + * | 40 | 1.048 | 1.022 | + * | 60 | 1.044 | **1.027** | + * | 90 - 130 | 1.008 - 1.018 | 0.991 - 1.000 | + * | 140 - 200 | 1.000 - 1.009 | 1.000 - 1.009 | + * + * Worst-low 1.027 against worst-high 1.009: the gap goes 0.067 -> 0.018, and + * the low regime now straddles 1.0 rather than sitting above it. + * + * **What remains at low levels is a boundary error in the TILES, not the + * elevation.** The rejection also costs recall, and it costs it in the same + * regime: 0.951 at level 20 rising to 1.000 at 140 and above. Every one of + * those losses is a real cliff whose box hits our lava at Chebyshev depth 1 - + * our own perimeter - never deeper: 32/32 at level 20, 52/52 across the sweep, + * 13/13 at default settings. A sub-tile disagreement about where lava stops. + * + * **Stated carefully, because depth only discriminates in one of the two + * places it was checked.** At default settings it does: region `[1500,1500]`'s + * 170 CORRECT rejections span depth 1 to 9 with 45 bottomed out deep in lava, + * against wrong rejections that are 100% perimeter. At level 20 it does not - + * there the correct rejections are 32/32 perimeter as well, because the + * contour has walked down onto the lake edges and every candidate is near a + * boundary. So the honest claim is that the low-level errors are + * boundary-SITED in both directions, and that at level 20 we get about half of + * them right; not that depth alone proves the perimeter is one tile fat. + */ + it("attributes the split to the lava rejection, not to the elevation field", () => { + const withRejection = fx.cases.map((_, i) => atLevel(i, true)); + // Non-vacuity: the rejection must actually remove cells, or "the split went + // away" and "the predicate never fired" are the same observation. + const removed = rows.reduce((n, r, i) => n + (r.ours - withRejection[i].ours), 0); + expect(removed).toBeGreaterThan(100); + + const ratios = (rs: typeof rows, pick: (level: number) => boolean): number[] => + rs.filter((r) => pick(r.level)).map((r) => r.ours / r.game); + const low = Math.max(...ratios(withRejection, (l) => l <= 110)); + const high = Math.max(...ratios(withRejection, (l) => l >= 120)); + // Measured 1.0266 and 1.0085. Both bounds are upper, so the port may improve + // without editing them. + expect(low).toBeLessThanOrEqual(1.03); + expect(high).toBeLessThanOrEqual(1.01); + // The gap is what #84 item 2 was about: 0.067 without the rejection, 0.018 + // with it. Guarded as an upper bound only - it may shrink to zero or invert. + expect(low - high).toBeLessThan(0.03); + + // And the low regime no longer sits entirely ABOVE the game, which is the + // part that read as over-placement: level 20 goes 1.085 -> 0.988. + expect(withRejection[0].ours / withRejection[0].game).toBeLessThan(1); + }, 120000); });