feat(opstack): the box fold carries NUMBERS -- amplitude bounds alongside EVoxelOpEffect (DECOMPOSITION 0.2)

Its own commit, nothing else in it, because it changes the contract all thirteen tests rest on.
VoxelForge.OpStack.BoxVerdictFold is extended in the same commit, as required.

THE PROBLEM, restated because the fix only makes sense against it
Direction alone can never recover a FIELDED carve. A noise-threshold carve (TunnelNetwork's worms,
wall roughness) has no spatial bound: it answers CarveOnly on every box of every strate that enables
it, and that answer is TRUE. So it kills AllSolid everywhere and the archetype skips zero tiles. No
refinement of EffectOverBox can fix that.

But the AMPLITUDE is bounded, and for the worm it is trivial: the block only runs below the
threshold, so t = 1 - WormValue/WormThreshold is in [0,1] and NetworkMask is in [0,1], hence the
carve is at most WormStrength. "The rock is solid by more than the sum of every remaining carve" is
therefore provable.

THE CONTRACT
- IVoxelDensityOp gains MaxCarveOverBox / MaxFillOverBox (density units, FLT_MAX = unknown) and
  ForcedMarginOverBox (how far the density is guaranteed from zero, 0 = unknown).
- FVoxelBoxHypotheses gains SolidMargin / AirMargin. A forcing op sets one; each carve subtracts its
  amplitude; the hypothesis dies when the margin is no longer strictly positive.
- VF_ForceHypotheses and VF_FoldEffect take the new values as DEFAULTED parameters.

BACKWARDS COMPATIBILITY IS THE POINT, and it is structural rather than promised: the defaults are
FLT_MAX and 0, so an op that overrides nothing subtracts FLT_MAX from a margin of 0 and kills the
hypothesis exactly as the purely directional fold did. Not one existing verdict moves. The float
arithmetic is deliberately unguarded: 0 - FLT_MAX is -FLT_MAX, -FLT_MAX - FLT_MAX saturates to -inf,
-inf > 0 is false, and no NaN is reachable because both terms share a sign.

BOUNDS DECLARED (each proved from the code, not estimated -- over-estimating costs CPU,
under-estimating is a hole)
- FConstantFieldSource::ForcedMarginOverBox = |Value|. This is the missing FIRST TERM: without a
  source that states how solid the rock is, there is nothing for a bounded carve to be subtracted
  from, and every bound would still kill the hypothesis.
- FWormFieldSource: MaxCarve = WormStrength (the bound that had been written and unused since stage
  A), MaxFill = 0.
- FCaveRoughnessMod: 1.4 * SurfaceRoughness * VOXEL_NOISE_SCALE both ways.
- FLayerLineMod (LayerLineDepth), FRibbingMod (RibbingDepth), FScallopMod (ScallopStrength),
  FCaveOverhangMod (SCALE * Depth * Strength).

WHAT THIS DOES *NOT* DO YET, said plainly rather than implied
TunnelNetwork still proves ZERO tiles. The chain dies at FRoomGraphSource, which answers Both with
unknown amplitude, before any of the bounded ops are reached. Making it answer spatially means
building the SDF cache for the queried box, which only pays once ClassifyTile actually consumes
ClassifyBox -- so it belongs with that work, not here. The one case that changes today is a tunnel
strate with RoomDensity <= 0: the room source returns Identity and the bounded worm + roughness can
now leave AllSolid standing.

Seven ops keep the FLT_MAX default (terrace, cliff, arch, column, dome, pinch, floor bias). Their
amplitudes depend on room-relative data, and bounding them would change no verdict while the room
source is unbounded. Writing bounds nobody can consume is how a bound goes stale unnoticed.

ONE PRE-EXISTING DEBT MADE SHARPER, noted at every site: these bounds are computed from STRATE
params, and a per-room terrain op can write a LARGER amplitude (ApplyTo overwrites even where the
strate had 0). So a bound can be too small -- the dangerous direction. Same root as the too-optimistic
EffectOverBox flagged in C1, same fix, and it MUST land before ClassifyTile consumes ClassifyBox.

TEST: eight new blocks in BoxVerdictFold. The first two are the ones to read -- they assert that the
defaults reproduce the old fold exactly, and that "unknown" is not "zero". Also asserted: carve
amplitudes accumulate; equality loses the tile (the > is strict on purpose, since zero counts as air
at the mesher); a bounded Both no longer kills a margin it cannot cross; and nothing bounded can
resurrect an unprovable box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-07-28 03:53:02 +02:00
1 parent 64d0e11821
commit 353d504169
3 files changed
+304 -17

No files matched your search

@@ -323,6 +323,109 @@ bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters)
}
}
//=========================================================================
// LE PLIAGE NUMÉRIQUE — `OPSTACK-DECOMPOSITION §0.2`
//=========================================================================
// La direction seule ne récupère jamais un carve FIELDÉ : il peut creuser partout, donc il rend
// `CarveOnly` partout, et c'est VRAI. Ce que la direction ignore, c'est qu'il ne peut creuser que
// de `WormStrength` au plus. Le pliage porte donc deux nombres : une MARGE posée par l'opérateur
// forçant, et une AMPLITUDE retirée par chaque carve.
//
// ⚠️ LE PREMIER CONTRÔLE CI-DESSOUS EST LE PLUS IMPORTANT : il vérifie que la rétro-compatibilité
// est réelle. Les cinq blocs au-dessus n'ont pas changé d'une ligne et doivent rester verts —
// ils appellent les mêmes fonctions sans marge ni amplitude, donc avec les défauts
// (`Margin = 0`, `MaxCarve = FLT_MAX`), qui reproduisent le comportement purement directionnel.
// 1. Les défauts REPRODUISENT l'ancien pliage — c'est ce qui rend le changement sûr.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // marge par défaut = 0
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // amplitude par défaut = FLT_MAX
TestEqual(TEXT("with default margin and amplitude, a carve still kills AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 2. Une amplitude INCONNUE tue même une grosse marge. « Je ne sais pas » n'est pas « zéro ».
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1000.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // FLT_MAX
TestEqual(TEXT("an unbounded carve kills AllSolid however solid the rock is"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 3. LE CAS QUI JUSTIFIE TOUT : roc à 1.0, ver à 0.6 ⇒ il reste 0.4 de marge, la boîte est
// prouvablement pleine. C'est exactement `FConstantFieldSource` + `FWormFieldSource`.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); // BaseDensity
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.6f, 0.0f); // WormStrength
TestEqual(TEXT("rock solid by more than the worm can carve stays provably AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
// …et les carves S'ACCUMULENT : un second à 0.5 fait passer la marge sous zéro.
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.5f, 0.0f);
TestEqual(TEXT("carve amplitudes accumulate until the margin runs out"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 4. ÉGALITÉ ⇒ ON PERD LA TUILE, délibérément. Une marge de 1.0 contre un carve de 1.0 peut
// atteindre exactement zéro, et zéro est du côté AIR pour le mesher. Le test `> 0` est
// STRICT, et il doit le rester : se tromper ici ferait un trou, pas une tuile en trop.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 1.0f, 0.0f);
TestEqual(TEXT("a carve exactly equal to the margin loses the tile (strict >, on purpose)"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 5. Le miroir côté AIR : une strate d'îles flottantes, surtout vide, avec un remplissage borné.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::FillOnly, 0.0f, 0.35f);
TestEqual(TEXT("air deeper than the fill can reach stays provably AllAir"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllAir);
}
// 6. `Both` BORNÉ : la rugosité de paroi peut aller dans les deux sens, mais pas loin. Sur du
// roc forcé, l'hypothèse AIR est déjà morte (le forçage l'a tuée) ; ce qui compte est que
// l'hypothèse SOLIDE survive à un `Both` d'amplitude connue — impossible avant ce changement,
// où `Both` tuait tout inconditionnellement.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::Both, 0.3f, 0.3f);
TestEqual(TEXT("a bounded Both no longer kills a margin it cannot cross"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
// 7. LA PROPRIÉTÉ DE SÛRETÉ TIENT TOUJOURS : rien de borné ne ressuscite quoi que ce soit.
// Le pliage numérique ne fait que retarder la mort d'une hypothèse, jamais l'annuler.
{
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::Mixed, 1000.0f); // marge ignorée sur Mixed
VF_FoldEffect(H, E, 0.0f, 0.0f); // amplitudes NULLES
TestEqual(TEXT("a zero-amplitude op cannot resurrect an unprovable box either"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
}
// 8. Une marge NULLE avec une amplitude NULLE : le carve ne retire rien, mais `0 > 0` est faux,
// donc l'hypothèse meurt quand même. C'est voulu — une marge inconnue reste inconnue, et un
// opérateur qui ne fait rien devrait rendre `Identity`, pas `CarveOnly` d'amplitude 0.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
TestEqual(TEXT("zero margin dies even to a zero carve -- unknown is not zero"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
return true;
}
@@ -134,6 +134,16 @@ namespace
return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant
}
/** ⚠️ LA MOITIÉ MANQUANTE DU PLIAGE NUMÉRIQUE (`OPSTACK-DECOMPOSITION §0.2`).
* `MaxCarveOverBox` dit ce qu'un opérateur peut RETIRER ; ceci dit ce qu'il y avait à
* retirer. Un champ constant est le seul opérateur du plugin qui connaisse cette marge
* EXACTEMENT : la densité vaut `Value` partout, donc la marge est `|Value|`. Sans elle la
* soustraction n'a pas de premier terme et tout carve borné tue quand même l'hypothèse. */
float ForcedMarginOverBox(const FBox&, const FVoxelOpContext&) const override
{
return FMath::Abs(Value);
}
private:
float Value;
};
@@ -2305,7 +2315,17 @@ namespace
return (P.SurfaceRoughness > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
}
/** La borne d'amplitude, en unités de densité. Pas encore consommée par le pliage. */
/**
* ✅ Borne consommée par le pliage numérique. `RoughNoise` et `FineNoise` respectent tous
* deux le contrat `[-1, 1]` de fBM/Ridged/Cellular, mis à l'échelle par `VOXEL_NOISE_SCALE`,
* et `TotalRough = Rough·S + Fine·S·0.4` ⇒ `|TotalRough| ≤ 1.4 · S · SCALE`. Le fade est
* dans `[0,1]`. Le clamp anti-remplissage ne fait que RÉDUIRE côté fill ; on ne s'appuie pas
* dessus (il ne s'applique que dans l'air certain), donc la borne fill reste la même.
*/
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); }
float MaxFillOverBox (const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); }
/** La borne d'amplitude, en unités de densité. */
float MaxAmplitude() const
{
return (P.SurfaceRoughness > 0.0f)
@@ -2476,6 +2496,17 @@ namespace
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
/** `LineValue = max(sin,0)³ ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ retrait ≤ `LayerLineDepth`.
* ⚠️ Borne calculée sur les params de la STRATE : un op `LayerLines` par salle peut écrire
* une profondeur PLUS GRANDE, ce qui rendrait cette borne fausse dans le sens dangereux.
* C'est la même dette que la note d'`EffectOverBox` juste au-dessus, et elle doit être
* réglée par le même correctif — AVANT que `ClassifyTile` ne consomme `ClassifyBox`. */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
}
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
@@ -2532,6 +2563,14 @@ namespace
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
/** `RibValue = max(sin,0)² ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ ajout ≤ `RibbingDepth`.
* Même réserve « params de strate » que `FLayerLineMod::MaxCarveOverBox`. */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.RibbingSpacing > 0.0f) ? FMath::Max(P.RibbingDepth, 0.0f) : 0.0f;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
@@ -2594,6 +2633,15 @@ namespace
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
/** fBM ∈ [-1,1] × `VOXEL_NOISE_SCALE`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ ajout
* ≤ `SCALE · Depth · Strength`. Même réserve « params de strate ». */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return VOXEL_NOISE_SCALE * FMath::Max(P.OverhangDepth, 0.0f)
* FMath::Max(P.OverhangStrength, 0.0f);
}
private:
FStrateGenerationParams P;
uint32 SeedU;
@@ -2725,6 +2773,14 @@ namespace
return (P.ScallopStrength > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
/** `Cellular3D ∈ [-1,1]`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ retrait ≤
* `ScallopStrength`. Même réserve « params de strate ». */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return FMath::Max(P.ScallopStrength, 0.0f);
}
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
private:
FStrateGenerationParams P;
uint32 SeedU;
@@ -3223,9 +3279,28 @@ namespace
: EVoxelOpEffect::Identity;
}
/** L'amplitude max de carve, en unités de densité. Pas encore consommée par le pliage —
* posée ici pour que la borne de `§0.2` ait déjà un domicile quand les intervalles
* arriveront. / The bound §0.2 needs, given a home before it has a consumer. */
/**
* ✅ **LA BORNE DE `§0.2`, MAINTENANT CONSOMMÉE.** Elle a passé plusieurs entrées de journal
* écrite mais inutilisée, faute d'un pliage capable de porter un nombre ; ce pliage existe.
*
* La preuve tient en une ligne : `t = 1 − WormValue/WormThreshold ∈ [0,1]` (le bloc ne
* s'exécute que sous le seuil) et `NetworkMask ∈ [0,1]` par construction, donc
* `t · WormStrength · NetworkMask ≤ WormStrength`. C'est une borne PROUVÉE, pas prudente —
* la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait
* un trou.
*/
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return MaxCarveAmplitude();
}
/** Le ver ne REMPLIT jamais : `InOut.Density -= …` avec un terme positif. */
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return 0.0f;
}
/** L'amplitude max de carve, en unités de densité. */
float MaxCarveAmplitude() const
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;