test(opstack): three tile scans sampled <1 lattice period; fix the CLASS this time

The "a sampler must cover at least one period" bug was found in the tunnel test
(+/-32 vs RoomSpacing 80) and again in the shaft test (+/-48 vs ShaftSpacing 55).
Both were fixed. The class was not: three box-verdict scans still ran the
original RandRange(-6,6)*Extent at Step 1 / Cells 8, i.e. +/-48 voxels.

  Island  +/-48 vs IslandSpacing 95 = 0.51 periods  (worse than either fix)
  Slab    +/-48 vs ColumnSpacing 60 = 0.80 periods
  Maze    +/-48 vs CellSize      40 = 1.20 periods

Verified the fixture does not override any of those spacings, so the header
defaults are what these tests really ran against.

This matters now specifically: 7dbdf51 changed the island and maze verdicts and
eaa44bf changed the slab column verdict, and these are the tests that guard
them. Widening before the build is what makes that build's green mean anything.

The rule the fixed tests already encoded, now explicit: Extent = 8, so
SpanCells = the lattice spacing gives exactly 8 periods -- which is where the
shaft test's 55 came from. Island 95, Slab 60, Maze 40, each printing the ratio
computed LIVE from the params struct so a future narrowing cannot be silent.

No assertion, tolerance, AddError, brute-force loop, seed, Step, Cells or tile
count was touched: this changes what the measurement looks at, never what it
demands. Proved counts will move (expected); NumUnsound must stay 0.

Test-only -- git diff lists three files under Private/Tests/. Not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:04:55 +02:00
parent c993e6e877
commit f737488d88
5 changed files with 174 additions and 10 deletions
@@ -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(<the spacing param>, 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.
+46
View File
@@ -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.
@@ -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)
@@ -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)
@@ -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)
{