From 2b3a97ec8da3a3ab9e58739b7242dc387cb0e431 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:52:45 +0300 Subject: [PATCH] fix(studio): cap the negative_erase renorm gain A token pointing along the negative is left with rounding error after projection; restoring its norm amplified that noise ~1000x into the conditioning. Cap the gain and refuse a non-finite result. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 ++++++ negative_erase.py | 20 ++++++++++++++++-- tests/test_negative_erase.py | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccde9bb..a6096083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] ### Fixed +- **negative_erase could put an amplified-noise vector into the conditioning.** "Keep prompt + strength" restored each token's norm after the erase — but a token pointing almost exactly + along the negative is left with nothing but rounding error, and restoring its norm scaled + that residue by thousands. The gain is now capped: past it the token stays quiet, because + it really was mostly the thing you asked to remove. A non-finite result is refused outright + rather than passed on, since the sampler captures conditioning for the refinement key and + would have banked the bad vector. - **Bypass now works as an A/B switch.** Wire two alternatives at one input — MiniMax H3's ref-to-video and first-last-to-video both feeding the sampler's latent — and bypass the one you are not using. A bypassed slot no longer claims the input, no longer counts as a diff --git a/negative_erase.py b/negative_erase.py index 225c5ee9..bd42ea22 100644 --- a/negative_erase.py +++ b/negative_erase.py @@ -22,6 +22,10 @@ Pure tensor math, no ComfyUI imports, so it is testable without a model. MODES = ("project", "subtract") +# How far a token may be scaled back up after the erase. See the note in `erase`: without a +# cap, a token that WAS the unwanted concept comes back as amplified rounding error. +RENORM_MAX_GAIN = 2.0 + def _torch(): import torch @@ -107,8 +111,20 @@ def erase(pos_tensor, unit, strength, meta=None, mode="project", renorm=True): rows = rows - float(strength) * before * u if renorm: - after = rows.norm(dim=-1, keepdim=True).clamp_min(1e-6) - rows = rows * (before / after) + # The gain is CAPPED, and that cap is the whole safety of this operation. A token + # pointing almost exactly along the negative is left with nothing but rounding error + # after projection; scaling that back to its original norm multiplies noise by + # thousands and puts a garbage vector at full prompt strength into the conditioning. + # Past the cap the honest answer is that the token really was mostly the thing you + # asked to remove, so it stays quiet instead of being refilled with residue. + after = rows.norm(dim=-1, keepdim=True).clamp_min(1e-12) + rows = rows * (before / after).clamp(max=RENORM_MAX_GAIN) + + # Never hand a non-finite conditioning onward. It does not fail here — it fails deep in + # the model, and worse, a run that captures conditioning for the refinement key would + # bank the bad vector and keep steering toward it long after this setting was turned off. + if not bool(torch.isfinite(rows).all()): + return pos_tensor if idx is None: work = rows diff --git a/tests/test_negative_erase.py b/tests/test_negative_erase.py index 2735d7cb..c5c68fcc 100644 --- a/tests/test_negative_erase.py +++ b/tests/test_negative_erase.py @@ -155,3 +155,44 @@ def test_a_dimension_mismatch_leaves_the_conditioning_alone(): out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 1.0) assert "no conditioning entry could be modified" in note assert out is pos + + +# --- the renorm gain cap --------------------------------------------------------------- +# Restoring a token's norm after projection is fine until the projection removed almost all +# of it. Then what is left is rounding error, and "restore the original norm" means putting +# amplified noise into the conditioning at full prompt strength — which the sampler will +# happily bank into the refinement key and keep steering toward afterwards. + + +def test_a_token_that_WAS_the_negative_goes_quiet_instead_of_exploding(): + u = torch.tensor([1.0, 0.0]) + pos = torch.tensor([[[5.0, 0.001]]]) # almost exactly the negative's direction + out = ne.erase(pos, u, 1.0, renorm=True) + assert float(out.norm()) < 0.01 # not 5.0 of amplified residue + assert float(out[0, 0, 1]) == pytest.approx(0.002, abs=1e-4) # capped at 2x, no more + + +def test_an_ordinary_token_still_gets_its_norm_back(): + """The cap must not break the normal case it exists to serve.""" + u = torch.tensor([1.0, 0.0]) + out = ne.erase(torch.tensor([[[3.0, 4.0]]]), u, 1.0, renorm=True) + assert float(out.norm()) == pytest.approx(5.0, abs=1e-4) + + +def test_the_gain_never_exceeds_the_cap(): + u = torch.tensor([1.0, 0.0]) + for orth in (0.001, 0.01, 0.1, 1.0, 4.0): + pos = torch.tensor([[[5.0, orth]]]) + before = float(pos.norm()) + after = float(ne.erase(pos, u, 1.0, renorm=True).norm()) + residue = float(torch.tensor([0.0, orth]).norm()) + assert after <= residue * ne.RENORM_MAX_GAIN + 1e-4 + assert after <= before + 1e-4 + + +def test_a_non_finite_result_is_refused_rather_than_passed_on(): + """It would not fail here — it fails deep in the model, and a run that captures + conditioning for the refinement key would bank the bad vector first.""" + u = torch.tensor([float("nan"), 0.0]) + pos = torch.tensor([[[3.0, 4.0]]]) + assert ne.erase(pos, u, 1.0) is pos