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>
199 lines
8.1 KiB
Python
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
|