Files
olivv-cs-ComfyUI-FunPack/tests/test_negative_erase.py
T
digital-garbageandClaude Opus 5 2b3a97ec8d 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 <noreply@anthropic.com>
2026-08-23 03:52:45 +03:00

199 lines
8.1 KiB
Python

"""Using the negative prompt at CFG 1 by erasing its direction from the positive.
MiniMax H3 never evaluates the negative branch, so the negative prompt is dead weight. These
tests pin what the replacement actually does — and, more importantly, what it refuses to do:
touch the vision span, change a token that has nothing to do with the negative, or fail
silently when there is nothing to work with.
"""
import sys
from pathlib import Path
import pytest
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import negative_erase as ne # noqa: E402
def _cond(t, meta=None):
return [(t, dict(meta or {}))]
# --- the direction ---------------------------------------------------------------------
def test_direction_is_the_pooled_unit_vector():
t = torch.tensor([[[2.0, 0.0], [4.0, 0.0]]])
d = ne.direction(t)
assert torch.allclose(d, torch.tensor([1.0, 0.0]))
def test_direction_pools_only_the_negatives_text_positions():
"""H3 packs [text | vision | audio] into one sequence. Pooling the vision span into the
'negative prompt direction' would make an image the model was shown part of what gets
removed from the prompt."""
t = torch.tensor([[[1.0, 0.0], [0.0, 9.0]]])
meta = {"minimax_token_tags": torch.tensor([1, 0])}
assert torch.allclose(ne.direction(t, meta), torch.tensor([1.0, 0.0]))
def test_a_zero_negative_has_no_direction():
assert ne.direction(torch.zeros(1, 3, 4)) is None
def test_mismatched_tags_are_ignored_rather_than_trusted():
"""Tags shorter than the conditioning mean the two disagree; selecting on them would pick
the wrong rows. Fall back to pooling everything."""
t = torch.tensor([[[1.0, 0.0], [0.0, 1.0]]])
d = ne.direction(t, {"minimax_token_tags": torch.tensor([1])})
assert d is not None and float(d[1]) > 0
# --- projection ------------------------------------------------------------------------
def test_project_removes_the_component_and_leaves_the_rest():
"""The whole reason projection is the default: a token orthogonal to the negative is not
moved at all, while a token aligned with it loses that alignment."""
u = torch.tensor([1.0, 0.0])
pos = torch.tensor([[[3.0, 4.0], [0.0, 5.0]]])
out = ne.erase(pos, u, 1.0, renorm=False)
assert float(out[0, 0, 0]) == pytest.approx(0.0) # the aligned part is gone
assert float(out[0, 0, 1]) == pytest.approx(4.0) # the rest is untouched
assert torch.allclose(out[0, 1], pos[0, 1]) # orthogonal token: unmoved
def test_strength_scales_the_removal():
u = torch.tensor([1.0, 0.0])
out = ne.erase(torch.tensor([[[2.0, 1.0]]]), u, 0.5, renorm=False)
assert float(out[0, 0, 0]) == pytest.approx(1.0)
def test_above_one_pushes_past_orthogonal_into_the_opposite():
u = torch.tensor([1.0, 0.0])
out = ne.erase(torch.tensor([[[2.0, 1.0]]]), u, 2.0, renorm=False)
assert float(out[0, 0, 0]) == pytest.approx(-2.0)
def test_renorm_keeps_each_tokens_strength():
"""Projection always shrinks. Without this, a big strength reads partly as a quieter
prompt rather than purely as less of the unwanted thing."""
u = torch.tensor([1.0, 0.0])
pos = torch.tensor([[[3.0, 4.0]]])
out = ne.erase(pos, u, 1.0, renorm=True)
assert float(out.norm()) == pytest.approx(5.0, abs=1e-4)
assert float(out[0, 0, 0]) == pytest.approx(0.0, abs=1e-4)
def test_subtract_moves_every_token_by_the_same_relative_amount():
"""The blunt mode, kept for comparison: it moves a token that had nothing to do with the
negative just as much as one that did."""
u = torch.tensor([1.0, 0.0])
out = ne.erase(torch.tensor([[[0.0, 1.0]]]), u, 0.5, mode="subtract", renorm=False)
assert float(out[0, 0, 0]) == pytest.approx(-0.5) # an orthogonal token IS moved
def test_the_vision_span_of_the_positive_is_never_touched():
"""Erasing a text direction out of the reference image's conditioning is not what a
negative PROMPT was asked to do."""
u = torch.tensor([1.0, 0.0])
pos = torch.tensor([[[1.0, 0.0], [1.0, 0.0]]])
meta = {"minimax_token_tags": torch.tensor([1, 0])}
out = ne.erase(pos, u, 1.0, meta=meta, renorm=False)
assert float(out[0, 0, 0]) == pytest.approx(0.0) # text position: erased
assert float(out[0, 1, 0]) == pytest.approx(1.0) # vision position: untouched
def test_dtype_and_device_survive():
u = torch.tensor([1.0, 0.0])
pos = torch.tensor([[[3.0, 4.0]]], dtype=torch.float16)
assert ne.erase(pos, u, 1.0).dtype == torch.float16
# --- the list-level entry point --------------------------------------------------------
def test_apply_returns_a_note_saying_what_it_did():
pos = _cond(torch.tensor([[[3.0, 4.0]]]))
neg = _cond(torch.tensor([[[1.0, 0.0]]]))
out, note = ne.apply(pos, neg, 1.0)
assert "negative_erase" in note and "project" in note
assert float(out[0][0][0, 0, 0]) == pytest.approx(0.0, abs=1e-4)
def test_strength_zero_is_a_true_no_op():
pos = _cond(torch.tensor([[[3.0, 4.0]]]))
out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 0.0)
assert out is pos and note == ""
def test_an_empty_negative_says_so_instead_of_going_quiet():
"""A switch that is on and doing nothing has to say which. Silence reads as 'it worked'."""
pos = _cond(torch.tensor([[[3.0, 4.0]]]))
out, note = ne.apply(pos, [], 1.0)
assert out is pos and "no negative conditioning" in note
def test_a_useless_negative_says_so_too():
pos = _cond(torch.tensor([[[3.0, 4.0]]]))
out, note = ne.apply(pos, _cond(torch.zeros(1, 2, 2)), 1.0)
assert out is pos and "no usable direction" in note
def test_every_scene_entry_is_treated():
"""A multi-scene prompt is a list of conditionings; erasing from only the first would
make the negative apply to scene 1 alone."""
pos = [(torch.tensor([[[3.0, 4.0]]]), {}), (torch.tensor([[[5.0, 12.0]]]), {})]
out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 1.0)
assert "2 conditioning entries" in note
assert all(float(e[0][0, 0, 0]) == pytest.approx(0.0, abs=1e-3) for e in out)
def test_a_dimension_mismatch_leaves_the_conditioning_alone():
pos = _cond(torch.tensor([[[3.0, 4.0, 5.0]]]))
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