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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user