diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index f5e88da..2c4d400 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -411,3 +411,43 @@ opt-in. Phase 1's question is fully answered: Maze decomposes cleanly, the stack window-invariant, and it proves 23/60 tiles uniform where `ClassifyTile` proves zero. --- + +## 2026-07-27 — /fp:fast hypothesis DEAD. Third wrong guess. Switching to instrumentation. + +**Jahni built with `FPSemantics = FPSemanticsMode.Precise` and got a BYTE-IDENTICAL result:** same +454 samples, same `1.90734863e-06`, same `(-23, 55, -660)`. A different float model producing +identical output is not "the same rounding error twice" — it is proof that **rounding is not the +cause at all.** The residue is a real, deterministic LOGIC difference somewhere in a transcription I +have read three times and believe to be identical. + +**Track record on this one discrepancy, recorded because the pattern matters more than the bug:** + +| # | Hypothesis | Killed by | +|---|---|---| +| 1 | `FVector` float→double→float round-trip | re-run returned the identical result | +| 2 | "check the roughness window / carve blend / octave count" | the bisect: residue survives to `corridors + carve ONLY` | +| 3 | `/fp:fast` cross-TU reassociation | `/fp:precise` build returned the identical result | + +Three hypotheses, all plausible, all reasoned from *what could explain it* rather than from +measurement. Each cost a build cycle. **The lesson is not "be smarter", it is "instrument earlier":** +the bisect (measurement) produced more information in one run than two hypotheses did in three. + +**Corrected in the docs:** `AUDIT-2026-07.md §C9` and `OPSTACK-PLAN §2.6` both assert the `/fp:fast` +story as the explanation for the residue. **That specific claim is now falsified and must be walked +back** — see the next entry. (The *separate* C9 finding, that UBT's FP default differs by toolchain +and the MP model assumes bit-reproducible terrain, still stands on its own: it was read out of +`VCToolChain.cs` / `ClangToolChain.cs`, not inferred from this test.) + +**What changed in code:** `FVoxelOpStack::EvalSample` now exposes the full `FVoxelOpSample`, and +`MazeEquivalence` dumps the worst point in raw hex — both densities, the stack's internal SDF, and +the carve factor reconstructed from each side. That last one localises the divergence: identical +recovered carve with differing density ⇒ the fault is after the conversion; differing carve ⇒ it is +in the SDF (lattice edges or `VoxelSDF::Capsule`) or in `SmoothStep01`. + +**UNVERIFIED:** the instrumentation itself. + +**Next single action:** re-run `MazeEquivalence` and read the WORST-POINT DUMP. Phase 1 step 3 +(wiring the stack into `GetDensityAt`) is deliberately **paused** until this is understood — it is a +small unexplained numeric difference, and those do not get smaller when you build on them. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index a1dd6c8..9f0f266 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -181,6 +181,56 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; } } + //========================================================================= + // INSTRUMENTATION — pas une hypothèse de plus. + //========================================================================= + // Trois hypothèses ont déjà échoué sur ces 454 échantillons : (1) l'aller-retour FVector + // float→double, (2) « vérifie la fenêtre de rugosité / le blend », (3) /fp:fast. La troisième + // est morte quand un build en **/fp:precise** a rendu EXACTEMENT le même résultat — même + // compte, même delta, même coordonnée. Un modèle flottant différent qui produit une sortie + // identique au bit près, ce n'est pas « la même erreur d'arrondi » : c'est la preuve que + // l'arrondi n'y est pour rien. + // + // Donc on arrête de raisonner et on IMPRIME. Au pire point : les bits bruts des deux densités, + // le SDF interne de la pile, et le Carve implicite reconstruit depuis chaque densité. Le canal + // SDF tranche la question qui compte — l'écart naît-il AVANT la conversion (donc dans les + // capsules / le treillis) ou APRÈS (dans l'arithmétique du carve) ? + // + // Three hypotheses have already died on these 454 samples, the last when an /fp:precise build + // returned a byte-identical result — a different float model producing identical output is + // proof that rounding is not the cause. So: print, don't reason. The SDF channel settles the + // question that matters — is the divergence born before the carve (lattice/capsule) or after? + if (NumDiff > 0 && WorstIdx >= 0) + { + const float X = (float)Points[WorstIdx].X, Y = (float)Points[WorstIdx].Y, Z = (float)Points[WorstIdx].Z; + const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); + const FVoxelOpSample S = Stack.EvalSample(X, Y, Z); + const float New = -S.Density; + + // Carve reconstruit : MC = -Base + Carve·Base·2 ⇒ Carve = (MC + Base) / (2·Base). + // Si les deux Carve sont identiques mais les densités non, l'écart est APRÈS le carve. + // Si les Carve diffèrent, il est dans le SDF ou dans le smoothstep. + const float Base = MazeParams.BaseDensity; + const float CarveOld = (Base > 0.0f) ? (Old + Base) / (2.0f * Base) : 0.0f; + const float CarveNew = (Base > 0.0f) ? (New + Base) / (2.0f * Base) : 0.0f; + + auto Bits = [](float V) { return *reinterpret_cast(&V); }; + + AddInfo(FString::Printf( + TEXT("WORST-POINT DUMP at (%.0f, %.0f, %.0f) — raw bits, so a 1-ULP story is checkable ") + TEXT("rather than assertable:\n") + TEXT(" GetMazeDensity = %.9g [0x%08X]\n") + TEXT(" stack EvalMC = %.9g [0x%08X]\n") + TEXT(" stack SDF = %.9g [0x%08X] (BaseDensity %.9g, carve blend 2.0)\n") + TEXT(" carve recovered : old %.9g vs new %.9g\n") + TEXT(" READ IT LIKE THIS: identical recovered carve + differing density ⇒ the divergence ") + TEXT("is AFTER the conversion, in the carve arithmetic. Differing carve ⇒ it is in the SDF ") + TEXT("(lattice edges or VoxelSDF::Capsule) or in SmoothStep01. Either way it is a LOGIC ") + TEXT("difference, because the /fp:precise run reproduced this byte for byte."), + X, Y, Z, + Old, Bits(Old), New, Bits(New), S.Sdf, Bits(S.Sdf), Base, CarveOld, CarveNew)); + } + if (NumDiff == 0) { AddInfo(FString::Printf( diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 574747a..dfd2b61 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -83,10 +83,19 @@ public: * Returns INTERNAL-convention density (positive = solid). The caller negates once for MC. */ float EvalInternal(float WorldX, float WorldY, float WorldZ) const + { + return EvalSample(WorldX, WorldY, WorldZ).Density; + } + + /** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand une comparaison + * avec l'ancien chemin diverge, c'est le canal SDF qui dit si l'écart naît avant ou après + * la conversion. / The full state after the stack — the SDF channel is what says whether a + * divergence is born before or after the carve. */ + FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const { FVoxelOpSample S; for (const TUniquePtr& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); } - return S.Density; + return S; } /** Le même, négaté pour le mesher (négatif = solide). */