fix: first-green-build follow-ups (diff-layer assertion + Maze float rounding)

The build passed and six tests ran; five green. Details in OPSTACK-PROGRESS.md.

1. DiffLayerContention's failure was the test, not the plugin.
   GetTotalModificationCount() sums STORED ENTRIES, not operations -- a stroke is
   filed under every chunk its AABB overlaps, so 400 radius-6 spheres straddling
   chunk corners store 3200 entries. The assertion now compares the stored count
   against the chunk fan-out ApplyModification itself returned, which also checks
   that the re-mesh list handed to the caller describes what was actually written.
   Everything the test exists for had already passed: 7 readers, 28.7M read
   rounds against 760 writes and 6 Clear()s, no crash, monotonic version.

2. MazeEquivalence: 454/20000 samples differed by at most 1.907e-06 -- exactly
   one ULP at magnitude 16 -- with ZERO crossing the isosurface, i.e. not one
   triangle would move. Leading hypothesis: the original routes noise coords
   through an FVector (double in UE5) and back to float, rounding twice, while
   the op passed floats straight through; under /fp:fast those round differently.
   The op now reproduces the detour on purpose, with a comment against
   "simplifying" it. Unverified -- it predicts 0 differences next run. If drift
   remains, next candidate is FMA contraction across translation units.

Also recorded, because it is the perf half of the whole refactor: the Maze op
stack proved 23 of 60 tiles uniform. ClassifyTile proves ZERO for any cave
archetype today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 02:40:51 +02:00
parent 62e3d5a933
commit 826a8c99dc
3 changed files with 87 additions and 5 deletions
+52
View File
@@ -256,3 +256,55 @@ hole. The **new** seal op keeps a 1-voxel safety margin before it forces. The ol
uniform) → only then wire the stack into `GetDensityAt` behind a per-strate opt-in. uniform) → only then wire the stack into `GetDensityAt` behind a per-strate opt-in.
--- ---
## 2026-07-27 — FIRST GREEN BUILD. Six tests ran. Five passed.
**The plugin compiles and the tests execute.** Results
(`VoxelM/Saved/Automation/Automation2026.07.27-02.36.57.csv`):
| Test | Result | What it means |
|---|---|---|
| `ClassifyTileSoundness` | ✅ | 600 tiles: 471 Mixed, 76 AllSolid, 53 AllAir. 24 brute-forced against the exact mesher lattice, **zero holes**. T1.d's soundness is machine-checked for the first time. |
| `DensityPurity` | ✅ | 10k points, shuffled order, multi-threaded, with and without carves — **bit-identical throughout**. No cache-key bug of the AUDIT C2 family survives in the density path. |
| `LiveEditInvalidation` | ✅ | 64/64 probes moved after a live edit. The C2 fix works. |
| `BoxVerdictFold` | ✅ | The op-stack fold's logic, case by case. |
| `MazeEquivalence` | ✅ (with warning) | see below |
| `DiffLayerContention` | ❌ | **my test was wrong, not the plugin** — see below |
### The two numbers that mattered
**454 of 20000 Maze samples differ, largest |delta| 1.907e-06, and ZERO land on the opposite side
of the isosurface.** 1.907e-06 is exactly one ULP at a float of magnitude 16 — i.e. the ports are
*geometrically identical*: not one triangle would move. §2.6 accepts this. Leading hypothesis for
the residue, now fixed and awaiting a re-run: the original routes the noise coordinates through an
`FVector` (double in UE5) before casting back to float, so it rounds float→double→float, while the
op passed floats straight through. Under `/fp:fast` those round in different places. The op now
reproduces the detour deliberately, with a comment saying not to "simplify" it. **If the next run
still shows drift, the next candidate is FMA contraction differing across translation units.**
**23 of 60 Maze tiles proved uniform.** Today's `ClassifyTile` proves **zero** for Maze — every cave
archetype falls through to `"pas prouvable en v1"`. That is ~38% of tiles becoming skippable for an
archetype that has never skipped one, and it is the first hard evidence for the perf half of the
whole refactor.
### The failure was mine
`Expected 'every carve was recorded' to be 400, but it was 3200.` `GetTotalModificationCount()` sums
**stored entries**, not operations — a stroke is filed under every chunk its AABB overlaps, and 400
radius-6 spheres straddling chunk corners store 8 entries each. The concurrency the test actually
exists to check all passed: **7 reader threads, 28.7 million read rounds against 760 writes and 6
`Clear()`s, no crash, monotonic version, clean state afterwards.** Assertion rewritten to compare
the stored count against the fan-out `ApplyModification` itself reported, which is a stronger check.
Worth noting for AUDIT C6: that getter is the right metric for the diff-layer scaling wall (stored
entries are what grow without bound) and the wrong name for it.
**UNVERIFIED:** the three fixes in this entry (the diff-layer assertion, the `FVector` rounding
detour, and the `FVoxelOpStack` move-only/dllexport fix that made the build pass) have not been
re-run.
**Next single action:** rebuild, re-run, and check whether `MazeEquivalence` now reports 0 differing
samples. Then wire the stack into `GetDensityAt` behind a per-strate opt-in — Phase 1 step 3, which
was deliberately held back until the build went green. It now has.
---
@@ -128,6 +128,17 @@ bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
} }
// ── Phase 1 : écritures pures. L'état final doit être exact. ── // ── Phase 1 : écritures pures. L'état final doit être exact. ──
// ⚠️ `GetTotalModificationCount()` ne compte PAS les opérations : il somme les entrées
// STOCKÉES, et un coup de pinceau est rangé dans CHAQUE chunk que son AABB recouvre. 400
// sphères de rayon 6 posées à cheval sur des coins de chunk donnent 3200 entrées, pas 400.
// (Le compteur d'opérations est le membre privé `ModificationCount`, non exposé.)
// C'est d'ailleurs la métrique qui compte pour AUDIT C6 : ce sont les ENTRÉES stockées qui
// grossissent sans borne, pas le nombre de coups de pioche. Le nom du getter induit en erreur.
//
// GetTotalModificationCount() does NOT count operations: it sums STORED entries, and a stroke
// is filed under EVERY chunk its AABB overlaps. It is also the metric that matters for AUDIT C6
// — stored entries are what grow without bound. The getter's name misleads.
int32 ExpectedEntries = 0;
for (int32 i = 0; i < NumWrites; ++i) for (int32 i = 0; i < NumWrites; ++i)
{ {
const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i)); const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i));
@@ -138,9 +149,14 @@ bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i)); TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i));
break; break;
} }
ExpectedEntries += Touched.Num();
} }
TestEqual(TEXT("every carve was recorded"), Diff->GetTotalModificationCount(), NumWrites); // Vérifie que le fan-out RÉELLEMENT stocké correspond à ce qu'ApplyModification a rapporté —
// un désaccord voudrait dire que la liste de chunks rendue à l'appelant (celle qui décide quoi
// re-mailler) ne décrit pas ce qui a été écrit. C'est un bien meilleur test que « == 400 ».
TestEqual(TEXT("stored diff entries match the chunk fan-out ApplyModification reported"),
Diff->GetTotalModificationCount(), ExpectedEntries);
TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods()); TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods());
TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0); TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0);
@@ -240,10 +240,24 @@ namespace
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{ {
if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; } if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; }
// VoxelNoise::FBM est exactement ce que FractalNoise3D appelle (VoxelGenerator.cpp) —
// le wrapper ne fait que transtyper. T2.b : les octaves passent par Eff() pour que les // ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ — ne pas « simplifier ».
// tuiles lointaines perdent les octaves sous-cellule. // L'original écrit `FractalNoise3D(FVector(WorldX * 0.12f, ...), Eff(3))`, et
InOut.Sdf += VoxelNoise::FBM(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency, // FractalNoise3D fait `VoxelNoise::FBM((float)Position.X, ...)`. FVector étant en
// DOUBLE (UE5), le produit flottant y transite par un double avant d'être re-arrondi
// en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast
// les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF,
// qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire
// l'arrondi. HYPOTHÈSE NON ENCORE VÉRIFIÉE : elle prédit que MazeEquivalence passe de
// 454 écarts à 0. Si le prochain run montre encore des écarts, c'est que la divergence
// vient d'ailleurs (candidat suivant : contraction FMA entre unités de compilation).
//
// THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. The original goes
// float -> double (FVector is double in UE5) -> float; going straight through floats
// skips a rounding step, and under /fp:fast the two paths round in different places.
// Reproducing the detour reproduces the rounding.
const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency);
InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f) VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f)
* VOXEL_NOISE_SCALE * Strength; * VOXEL_NOISE_SCALE * Strength;
} }