From 8a303cc0853b3c7118673d48b4f2ecb4427292e6 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 28 Jul 2026 03:33:34 +0200 Subject: [PATCH] test(opstack B5): prove the bNearCaveSurface gate -- a voxel outside it is bit-identically untouched STAGE B group 5 of 5, and the only one that adds no operator. Stage B is complete: 19 ops, all twelve detail modifiers ported. THE DECISION, restated where it is paid for The gate is a repeated early-out inside each operator (VF_NearCaveSurface), not a scoping container. Reasons live in the code at that function; the short form is that the stack is a flat list ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide its children from the fold, and an op that only exists inside a container cannot become a Phase-3 asset. What that choice buys in composability it pays for in risk: TWELVE places to forget the gate instead of one. Hence a check aimed at exactly that. NEW CHECK 1d Classify each sample by its final SDF (nothing downstream of the room source writes that channel), then compare the full stack against one with the eleven controllable amplitudes zeroed: - outside the gate the two must be BIT-IDENTICAL -- asserted, and the leak count is printed; - inside the gate they must differ often -- printed, and ZERO is an error, because "nothing leaked" is worthless if the answer is "nothing happened anywhere". Same trap as a coverage guard that only fires at zero, one level up: a check can be vacuous as well as a counter. - the outside-gate sample count is printed and warns below 10%, since deep rock is the common case in production and a sample set that never leaves the cave does not exercise the gate. WHAT THIS CHECK CANNOT COVER, stated in the output rather than left implicit: the column operator (STEP 4d) has no amplitude to zero, so it cannot appear in the zeroed stack. Its gate rides on check 1's bit-identity against the original instead. That is sound but invisible, which is why the info line says so. Also corrected in the test header: the modifier count is TWELVE, not the thirteen that had been carried in the notes, and only ELEVEN of them read the per-room param copy -- surface roughness (4b) sits before the shadow declaration and reads strate params. Both numbers matter for C1. Co-Authored-By: Claude Opus 5 --- .../Tests/VoxelForgeOpStackTunnelTest.cpp | 115 ++++++++++++++++-- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index e9386fc..0efb9ed 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -5,8 +5,7 @@ // POURQUOI UN TEST D'UNE PILE INCOMPLÈTE // `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize » // traînait dans les notes ; il y en a douze, et onze seulement lisent la copie de params par salle). -// Tout porter avant de -// pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées — +// Tout porter avant de pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées — // exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite. // // La sortie : **tous les modificateurs de détail sont pilotés par une amplitude**, et @@ -17,14 +16,17 @@ // Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise // dans l'autre sens. // -// L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté sortait -// de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une sonde de couverture -// qui prouve qu'il a réellement bougé quelque chose et (ii) le compte d'ops de la pile qui augmente. +// L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté +// sortait de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une +// sonde de couverture prouvant qu'il a réellement bougé quelque chose, et (ii) le compte d'ops qui monte. // • B1 : rugosité de paroi, STEP 4b. // • B2 : terrasses, lignes de strates, nervures — STEP 4c. // • B3 : surplombs, falaise, festons, arches — STEP 4c. -// • B4 (ce commit) : colonnes (4d), dômes (4g), pincement (4h), biais de sol. La liste des -// amplitudes éteintes est vide : **l'étape B est complète**. +// • B4 : colonnes (4d), dômes (4g), pincement (4h), biais de sol. La liste des amplitudes +// éteintes est vide : les douze modificateurs sont portés. +// • B5 (ce commit) : le GATE lui-même. Aucun opérateur ajouté — la décision (early-out répété par +// opérateur plutôt que conteneur de portée) est écrite dans `VF_NearCaveSurface`, et le +// contrôle 1d la paie : hors gate, la pile complète doit être BIT À BIT celle sans modificateurs. // // CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur l'override d'op PAR SALLE — onze des douze // modificateurs lisent, dans l'original, une copie des params où l'op de la salle la plus proche a @@ -612,6 +614,105 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) } } + //========================================================================= + // 1d. LE GATE `bNearCaveSurface` — UN VOXEL HORS PORTÉE EST-IL VRAIMENT INTACT ? (ÉTAPE B5) + //========================================================================= + // ⚠️ LA DÉCISION DE L'ÉTAPE B5, RAPPELÉE ICI PARCE QUE C'EST CE CONTRÔLE QUI LA PAIE. + // Dans l'original les douze modificateurs vivent dans UN SEUL `if (bNearCaveSurface)`. La pile + // est une LISTE PLATE, donc chaque opérateur re-teste le gate lui-même (`VF_NearCaveSurface`, + // VoxelDensityOpStack.cpp, où le raisonnement complet est écrit). Un opérateur conteneur aurait + // dû replier ses enfants à la place de `ClassifyBox` et les lui aurait cachés. + // + // Le risque que ce choix introduit est précis : **douze occasions d'oublier le gate au lieu + // d'une**. Un oubli ne se verrait pas forcément dans l'équivalence globale (le modificateur + // s'appliquerait aussi dans l'original si son propre range était large), d'où un contrôle dédié. + // + // MÉTHODE : on classe chaque échantillon par son SDF final (aucun opérateur en aval du graphe de + // salles n'écrit ce canal), puis on compare la pile COMPLÈTE à une pile dont les onze amplitudes + // pilotables sont à zéro. **Hors gate, les deux doivent être bit à bit identiques** ; dedans, + // elles doivent différer souvent — sinon le contrôle ne compare rien. + // + // ⚠️ CE QUE CE CONTRÔLE NE PEUT PAS COUVRIR : l'opérateur de COLONNES, qui n'a aucune amplitude + // à éteindre (cf. la note du contrôle 3b). Son gate à lui n'est couvert que par la bit-identité + // globale du contrôle 1 face à l'original — ce qui suffit, mais ne se voit pas ici. + { + FStrateGenerationParams PNoMods = P; + PNoMods.SurfaceRoughness = 0.0f; + PNoMods.TerraceStepHeight = 0.0f; + PNoMods.LayerLineSpacing = 0.0f; + PNoMods.RibbingSpacing = 0.0f; + PNoMods.OverhangStrength = 0.0f; + PNoMods.CliffStrength = 0.0f; + PNoMods.ScallopStrength = 0.0f; + PNoMods.ArchDensity = 0.0f; + PNoMods.DomeDensity = 0.0f; + PNoMods.PinchDensity = 0.0f; + PNoMods.FloorBias = 0.0f; + + FVoxelOpStack NoModStack; + VoxelDensityOps::BuildTunnelNetworkStack(NoModStack, PNoMods, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + NoModStack.PrepareChunk(Ctx); + + TArray NoModVals; + NoModVals.SetNumUninitialized(NumTunnelSamples); + TArray bOutsideGate; + bOutsideGate.SetNumUninitialized(NumTunnelSamples); + + const float DetailThreshold = P.SDFBlendRadius * 3.0f; + + for (int32 i = 0; i < NumTunnelSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + const FVoxelOpSample S = NoModStack.EvalSample(X, Y, Z); + NoModVals[i] = -S.Density; // même négation MC que EvalMC + // Le gate, écrit exactement comme l'original et comme VF_NearCaveSurface. + bOutsideGate[i] = ((S.Sdf < DetailThreshold) && (S.Sdf < FLT_MAX)) ? 0 : 1; + } + + int32 NumOutside = 0, NumLeaked = 0, NumInsideMoved = 0; + for (int32 i = 0; i < NumTunnelSamples; ++i) + { + if (bOutsideGate[i]) + { + ++NumOutside; + if (!BitEqual(NoModVals[i], FullVals[i])) { ++NumLeaked; } + } + else if (!BitEqual(NoModVals[i], FullVals[i])) + { + ++NumInsideMoved; + } + } + + TestEqual(TEXT("no detail modifier touches a voxel outside the bNearCaveSurface gate"), + NumLeaked, 0); + + AddInfo(FString::Printf( + TEXT("Gate check (stage B5): %d of %d samples (%.1f%%) sit OUTSIDE the gate ") + TEXT("(SDF >= SDFBlendRadius*3), and %d of them leaked. Inside the gate, %d samples ") + TEXT("move when the eleven amplitudes are zeroed -- that second number is what says ") + TEXT("the comparison is not vacuous: if it were 0, 'nothing leaked' would only mean ") + TEXT("'nothing happened anywhere'. The column operator (STEP 4d) is NOT covered here, ") + TEXT("having no amplitude to zero; its gate rides on check 1's bit-identity instead."), + NumOutside, NumTunnelSamples, 100.0f * (float)NumOutside / (float)NumTunnelSamples, + NumLeaked, NumInsideMoved)); + + if (NumOutside < NumTunnelSamples / 10) + { + AddWarning(FString::Printf( + TEXT("Only %d of %d samples are outside the gate, so this check barely asked its ") + TEXT("question. Deep rock is the common case in production -- a sampling set that ") + TEXT("almost never leaves the cave is not representative of what the gate skips."), + NumOutside, NumTunnelSamples)); + } + if (NumInsideMoved == 0) + { + AddError(TEXT("Inside the gate, zeroing all eleven modifier amplitudes changed NOTHING. ") + TEXT("The gate check above is therefore vacuous, and so, probably, is a large ") + TEXT("part of check 1b. Suspect the params never reaching the operators.")); + } + } + //========================================================================= // 2. INVARIANCE DE FENÊTRE — le test qui compte le plus sur cet archétype //=========================================================================