diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index d05cde5..2ea374c 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -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. --- + +## 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. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp index 0b7b6b3..948351f 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp @@ -128,6 +128,17 @@ bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters) } // ── 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) { const TArray 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)); 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("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index b240896..bcce241 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -240,10 +240,24 @@ namespace void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { 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 - // tuiles lointaines perdent les octaves sous-cellule. - InOut.Sdf += VoxelNoise::FBM(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency, + + // ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ — ne pas « simplifier ». + // L'original écrit `FractalNoise3D(FVector(WorldX * 0.12f, ...), Eff(3))`, et + // 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) * VOXEL_NOISE_SCALE * Strength; }