diff --git a/CODEX-TASK-005-tile-scan-sampler-extent.md b/CODEX-TASK-005-tile-scan-sampler-extent.md new file mode 100644 index 0000000..c455f0f --- /dev/null +++ b/CODEX-TASK-005-tile-scan-sampler-extent.md @@ -0,0 +1,99 @@ +# Codex task 005 — three box-verdict tile scans sample less than one lattice period + +**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental` +**Status:** specified, not started +**Kind:** test coverage. **Zero risk to the game — no non-test file may change.** + +--- + +## Why this exists + +This project has already found and fixed this exact defect **twice**: + +> *"A sampler must cover at least one period of what it samples. The tunnel test drew tile XY from +> ±32 voxels with `RoomSpacing 80` — it measured the spine hub and called it the world. The shaft +> test had the identical bug (±48 against `ShaftSpacing 55`)."* — `OPSTACK-HANDOFF.md` + +Both were fixed to `SpanCells = 55` ⇒ **±440 voxels**, and both now print their own extent in units +of the pattern's period so it cannot silently regress. + +**Three box-verdict tile scans were never fixed**, because the fix was applied where the bug was +noticed rather than to the class. All three still use the original `Rng.RandRange(-6, 6) * Extent` +with `Step = 1, Cells = 8` ⇒ `Extent = 8` ⇒ **half-extent ±48 voxels**: + +| test | half-extent | lattice period (default, **unchanged by the fixture**) | coverage | +|---|---|---|---| +| `VoxelForgeOpStackIslandTest.cpp` ~244 | ±48 | `IslandSpacing` **95** | **0.51 periods** ⛔ worse than either bug already fixed | +| `VoxelForgeOpStackSlabTest.cpp` ~289 | ±48 | `ColumnSpacing` **60** | **0.80 periods** ⛔ | +| `VoxelForgeOpStackMazeTest.cpp` ~276 | ±48 | `CellSize` **40** | 1.20 periods ⚠️ marginal | + +Verified: none of `EnableIslandFeatures` / the slab tuning / the maze setup overrides the spacing, so +the header defaults are what these tests actually run against. + +**Why it matters right now, specifically.** Two commits just changed the box verdicts these very +tests are supposed to guard — `7dbdf51` (the `ExtraReach` bound, which touches **islands**, maze and +shafts) and `eaa44bf` (the radius envelope, which touches **slab columns** and shafts). The tests +that would catch a mistake in those changes currently sample about half a lattice cell. + +## The rule the two fixed tests already encode + +`Extent = Step * Cells = 8` voxels, so `SpanVoxels = SpanCells * 8`. To get **8 periods** of +half-extent you set: + +> **`SpanCells` = the lattice spacing** (`55` for `ShaftSpacing 55` — that is where the shaft test's +> `55` comes from, and it is not a coincidence). + +Apply the same: + +| test | `SpanCells` | resulting half-extent | periods | +|---|---|---|---| +| Island | `95` | ±760 | 8.0 | +| Slab | `60` | ±480 | 8.0 | +| Maze | `40` | ±320 | 8.0 | + +## What to build + +For each of the three tests, mirror **exactly** what `VoxelForgeOpStackShaftTest.cpp` (~212) does: + +1. Hoist `const int32 SpanCells` and `const int32 SpanVoxels = SpanCells * 8;` **outside the tile + loop** — the report needs them and `Extent` is loop-local. (That scoping slip has already happened + once in this file family; the shaft test's comment records it.) +2. Draw `Rng.RandRange(-SpanCells, SpanCells) * Extent` for X and Y. **Leave the Z draw exactly as + it is** — it is clamped to the strate slot and is not part of this defect. +3. Extend the existing report line to print `SpanVoxels`, the live ratio + `(float)SpanVoxels / FMath::Max(, 1.0f)`, and the spacing itself — so the + extent is stated in units of the pattern's own period and a future narrowing is visible. + +## ⚠️ Invariants + +1. **This is NOT "widen until it passes."** The comment already in the tunnel test says it best and + the same reasoning applies here: *every proved tile is still brute-forced voxel by voxel below, so + a wider sampler that produced a FALSE verdict fails exactly as before. We are changing what the + measurement **looks at**, not what it **demands**.* Do not touch the brute-force loop, its + tolerance, or any `AddError`. +2. **Do not adjust an assertion to accommodate a moved number.** Widening will change the proved / + Mixed counts — that is the point. If an existing assertion would now fail, **report it and stop**; + do not retune it. ("Don't assert a number you want to improve" is a written lesson here.) +3. **No file outside `Source/VoxelForge/Private/Tests/` may change.** `git diff --stat` must list + only those three test files. +4. Do not change `Step`, `Cells`, the tile count (`60`), or the RNG seeds — a changed seed makes the + before/after incomparable, and comparability is the whole point of touching this now. +5. Comments are French + English; match the surrounding file. + +## Acceptance + +- Three test files changed, nothing else. +- Each of the three now prints its extent **and** that extent in periods of its own spacing param. +- After the build, each ratio line reads **≥ 8 periods**. +- The proved counts will move. **That is expected.** What must NOT move: `violations` / `NumUnsound` + stays **0** in all three. If it becomes non-zero, the wider sampler has found a genuine hole that + the narrow one was hiding — which would be this task paying for itself immediately, and must be + reported loudly rather than tuned away. + +## Notes for the reviewer (Claude) + +- Confirm `SpanCells`/`SpanVoxels` are outside the tile loop in all three. +- Confirm the Z draw is untouched. +- Confirm the ratio is computed **live** from the params struct, not hardcoded — a hardcoded "8.0 + periods" in a format string would be a success message that asserts coverage while measuring + nothing, which is a named failure mode in this project. diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index aac5fd5..b769cdc 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -3697,3 +3697,49 @@ the same file** — `VF_PerlinAbsBound` for the first, `FIslandBlobSource` for t ⇒ When adding a bound, the question is not "what is the max parameter" but **"what is the supremum of the thing `Eval` can actually produce, and where in this file has that already been worked out?"** + +## 2026-08-16 (g) — the sampler bug was fixed twice and never as a CLASS. Three more instances. + +`CODEX-TASK-005`. Test-only; no non-test file changed. + +The handoff records: *"A sampler must cover at least one period of what it samples"* — found in the +tunnel test (±32 vs `RoomSpacing 80`) and again in the shaft test (±48 vs `ShaftSpacing 55`). Both +were widened to ±440 and taught to print their extent in periods. **The fix was applied where the +bug was noticed, never to the class.** Three box-verdict tile scans still ran the original +`RandRange(-6, 6) * Extent` with `Step 1, Cells 8` ⇒ **±48 voxels**: + +| test | half-extent | period (default — verified NOT overridden by the fixture) | coverage | +|---|---|---|---| +| Island | ±48 | `IslandSpacing` **95** | **0.51 periods** — worse than either bug already fixed | +| Slab | ±48 | `ColumnSpacing` **60** | **0.80 periods** | +| Maze | ±48 | `CellSize` **40** | 1.20 periods (marginal) | + +**Why this mattered *today* rather than in general:** the two commits immediately before it changed +the very verdicts these tests guard — `7dbdf51` touches **islands** and maze, `eaa44bf` touches +**slab columns**. The tests that would catch a mistake in those fixes were sampling half a lattice +cell. Widening them before the build is what makes that build's green mean something. + +### The rule the two fixed tests already encoded, now written down + +`Extent = Step · Cells = 8`, so `SpanVoxels = SpanCells · 8`. For **8 periods** of half-extent: +**`SpanCells` = the lattice spacing.** That is where the shaft test's `55` comes from — it equals +`ShaftSpacing`, and it is not a coincidence. Applied: Island `95`, Slab `60`, Maze `40`, each giving +exactly 8.0 periods, each printing the ratio **computed live from the params struct** so a future +narrowing is visible instead of silent. + +### What was deliberately NOT done + +- **No assertion, tolerance, `AddError` or brute-force loop touched.** Every proved tile is still + verified voxel by voxel; a wider sampler producing a false verdict fails exactly as before. This + changes what the measurement *looks at*, never what it *demands*. +- **No seed, `Step`, `Cells` or tile count changed** — a moved seed makes the before/after + incomparable, which is the only reason to do this now rather than later. +- The ratio is never a hardcoded "8.0" in a format string. That would be a success message asserting + coverage while measuring nothing — a named failure mode here. + +### What to read after the build + +The proved counts **will move**; that is the point, not a regression. **`NumUnsound` / `violations` +must stay 0 in all three.** If one goes non-zero, the wider sampler has found a real hole the narrow +one was hiding — in which case this task paid for itself on its first run, and the number must be +reported rather than tuned away. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp index 5a8b21d..b920eb9 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp @@ -235,14 +235,18 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) { int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0; FRandomStream Rng(24680); + // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on + // ne peut pas citer dans le rapport est une étendue que personne ne surveille. + const int32 SpanCells = 95; + const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8 for (int32 t = 0; t < 60; ++t) { const int32 Step = 1, Cells = 8; const int32 Extent = Step * Cells; const FIntVector Origin( - Rng.RandRange(-6, 6) * Extent, - Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); const int32 GridDim = Cells + 1; @@ -289,10 +293,12 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0); AddInfo(FString::Printf( - TEXT("Box verdicts over 60 FloatingIslands tiles: %d proved AllSolid, %d proved AllAir, ") + TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ") + TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ") TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ") TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-") TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."), + SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing, NumProvedSolid, NumProvedAir, NumMixed)); if (NumProvedAir == 0) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index 7cee486..cbdea9a 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -267,14 +267,18 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) { int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; FRandomStream Rng(24680); + // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on + // ne peut pas citer dans le rapport est une étendue que personne ne surveille. + const int32 SpanCells = 40; + const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8 for (int32 t = 0; t < 60; ++t) { const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable const int32 Extent = Step * Cells; const FIntVector Origin( - Rng.RandRange(-6, 6) * Extent, - Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise @@ -319,9 +323,11 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) NumUnsound, 0); AddInfo(FString::Printf( - TEXT("Box verdicts over 60 Maze tiles: %d proved uniform, %d Mixed. Today's ClassifyTile ") + TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ") + TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ") TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ") TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."), + SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize, NumProved, NumMixed)); if (NumProved == 0) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp index 5358ece..74c0607 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp @@ -280,14 +280,18 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) { int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; FRandomStream Rng(24680 + SlotIndex); + // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on + // ne peut pas citer dans le rapport est une étendue que personne ne surveille. + const int32 SpanCells = 60; + const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8 for (int32 t = 0; t < NumSlabTiles; ++t) { const int32 Step = 1, Cells = 8; const int32 Extent = Step * Cells; const FIntVector Origin( - Rng.RandRange(-6, 6) * Extent, - Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise @@ -335,10 +339,13 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) NumUnsound, 0); AddInfo(FString::Printf( - TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ") + TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ") + TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ") TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ") TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."), - SlotName, NumSlabTiles, NumProved, NumMixed)); + SlotName, NumSlabTiles, SpanVoxels, + (float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing, + NumProved, NumMixed)); if (NumProved == 0) {