## Summary
Collapses the experimental three-mode EPS switch
(`current`/`identity`/`true_v`) into a single, mathematically-correct
v-prediction-space path used by **all** variance-preserving
parameterizations — EPS, v-pred, and x0.
The sampler hook delivers `cond`/`uncond` as `x - x0` for every VP
parameterization, so NRS recovers the true velocity `v = (cond -
A)/factor` (with `A = x·σ²/(σ²+1)`, `factor = σ/√(σ²+1)`), runs its
geometry in v-space, and inverts exactly on return. This replaces the
prior EPS-only affine that operated on the wrong input space.
## Version: 1.1.0
MINOR, not a patch: this adds **X0 (sample) prediction support** — a new
backward-compatible capability — and **changes the default parameters**
(2/5/0.75 → 2/4/0.5). The node interface is unchanged (`eps_mode` never
shipped in 1.0.0), so it is **not** breaking/MAJOR. `pyproject.toml` and
`NRS/nodes_NRS.py __version__` are bumped to `1.1.0` in this PR; the
registry publish is triggered by the merge/release process, not by this
branch.
## Changes
- **`NRS/nodes_NRS.py`**: two-branch conversion — FLOW/CONST operated
natively (identity); all VP types (EPS/V/X0, UNKNOWN→VP) share one
true-v-space transform. Implements X0 (removes the
`NotImplementedError`). Removes `eps_mode` from every method and the
`nrs()` call. New defaults **skew 2 / stretch 4 / squash 0.5**.
- **`scripts/negative_rejection_steering_script.py`** (Forge/A1111):
removes the `eps_mode` test radio, process args, XYZ axis, and
extra-param; reverts `patch()` to 4 args; defaults 2/4/0.5.
- **`tests/test_pred_type.py`**: rewritten for the unified path — FLOW
is the sole native branch; EPS/V/X0/UNKNOWN all run the algebra.
- **`README.md`**: rewrites the V-Space step (now covers EPS/v-pred/x0
identically; flow-matching stays native) and updates the default to
2/4/0.5.
- **`CHANGELOG.md`**: `[1.1.0]` entry.
- **`pyproject.toml` / `NRS/nodes_NRS.py`**: version bumped 1.0.0 →
1.1.0.
## Rationale
Empirical A/B on an EPS checkpoint showed the true-v path is weakly
dominant: equal in normal ranges, burn-resistant at the failure edge,
and unlocks an on-style high-detail regime via squash (squash becomes a
detail/steering knob because the offset `A` is unscaled).
## Validation
- 39 passed / 9 skipped locally (torch tests skip; CI runs them).
Branch-coverage gate passed on push.
- ⚠️ **v-pred and x0 in v-space are math-validated but not yet
image-validated** — recommend a spot-check on a v-pred checkpoint before
merge/release.
279 lines
9.5 KiB
Python
279 lines
9.5 KiB
Python
"""Tests for pack-aware per-stream routing in NRS.nodes_NRS.
|
|
|
|
These tests need real torch (tensor math), but tests/conftest.py installs a
|
|
MagicMock in sys.modules["torch"] for the whole session so other test modules
|
|
can import without the heavy dependency. We swap the real torch module in for
|
|
the duration of this module only, then restore the mock so the rest of the
|
|
suite is unaffected.
|
|
"""
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
_saved_torch = None
|
|
_saved_nodes_nrs = None
|
|
torch = None
|
|
nrs_module = None
|
|
|
|
|
|
def setup_module(module):
|
|
# NOTE: we deliberately avoid importlib.reload() here. reload() mutates
|
|
# the *existing* NRS.nodes_NRS module dict in place, and other test
|
|
# modules (e.g. test_pred_type.py) import PredictionType/NRS at
|
|
# collection time and keep those references for the whole session. Their
|
|
# methods' __globals__ point at that same dict, so an in-place reload
|
|
# would silently swap PredictionType out from under them (new class
|
|
# object, same name -> broken identity-based Enum equality). Instead we
|
|
# unregister the module from sys.modules and import it fresh: this
|
|
# creates an independent module object, leaving the original (still
|
|
# cached in other modules' namespaces) untouched. We restore the exact
|
|
# original module object on teardown.
|
|
global _saved_torch, _saved_nodes_nrs, torch, nrs_module
|
|
_saved_torch = sys.modules.get("torch")
|
|
sys.modules.pop("torch", None)
|
|
try:
|
|
import torch as real_torch
|
|
except ImportError:
|
|
pytest.skip("real torch unavailable", allow_module_level=True)
|
|
torch = real_torch
|
|
|
|
_saved_nodes_nrs = sys.modules.get("NRS.nodes_NRS")
|
|
sys.modules.pop("NRS.nodes_NRS", None)
|
|
|
|
import NRS.nodes_NRS as m
|
|
|
|
nrs_module = m
|
|
|
|
|
|
def teardown_module(module):
|
|
if _saved_torch is not None:
|
|
sys.modules["torch"] = _saved_torch
|
|
else:
|
|
sys.modules.pop("torch", None)
|
|
|
|
if _saved_nodes_nrs is not None:
|
|
sys.modules["NRS.nodes_NRS"] = _saved_nodes_nrs
|
|
else:
|
|
sys.modules.pop("NRS.nodes_NRS", None)
|
|
|
|
|
|
class _StubModelSampling:
|
|
"""Minimal stand-in that makes _get_pred_type fall back to EPS quickly."""
|
|
|
|
|
|
class _StubInnerModel:
|
|
def __init__(self, latent_shapes=None):
|
|
self.model_sampling = _StubModelSampling()
|
|
if latent_shapes is not None:
|
|
self.latent_shapes = latent_shapes
|
|
|
|
|
|
class _StubModel:
|
|
"""Stub for the outer ComfyUI ModelPatcher passed to NRS.patch()."""
|
|
|
|
def __init__(self, latent_shapes=None):
|
|
self.model = _StubInnerModel(latent_shapes)
|
|
self._captured_fn = None
|
|
|
|
def clone(self):
|
|
return self
|
|
|
|
def set_model_sampler_cfg_function(self, fn, flag):
|
|
self._captured_fn = fn
|
|
|
|
|
|
def _make_args(model, cond, uncond, x_orig, sigma):
|
|
return {
|
|
"model": model.model, # args["model"] is the inner model carrying latent_shapes
|
|
"cond": cond,
|
|
"uncond": uncond,
|
|
"input": x_orig,
|
|
"sigma": sigma,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 1: round-trip pack/unpack correctness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_roundtrip_unpack_repack_two_streams():
|
|
video = torch.randn(1, 4, 3, 2)
|
|
audio = torch.randn(1, 6, 5)
|
|
shapes = [video.shape, audio.shape]
|
|
|
|
packed = nrs_module._pack_latents([video, audio])
|
|
assert packed.shape == (1, 1, video.numel() + audio.numel())
|
|
|
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
|
assert len(unpacked) == 2
|
|
assert torch.allclose(unpacked[0], video)
|
|
assert torch.allclose(unpacked[1], audio)
|
|
|
|
repacked = nrs_module._pack_latents(unpacked)
|
|
assert torch.allclose(repacked, packed)
|
|
|
|
|
|
def test_roundtrip_single_stream():
|
|
x = torch.randn(1, 4, 8, 8)
|
|
shapes = [x.shape]
|
|
|
|
packed = nrs_module._pack_latents([x])
|
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
|
assert len(unpacked) == 1
|
|
assert torch.allclose(unpacked[0], x)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 2: fallback path (no latent_shapes) is a byte-for-byte regression no-op
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_nrs(model, cond, uncond, x_orig, sigma, skew=2.0, stretch=5.0, squash=0.75):
|
|
node = nrs_module.NRS()
|
|
(patched_model,) = node.patch(model, skew, stretch, squash)
|
|
fn = patched_model._captured_fn
|
|
args = _make_args(model, cond, uncond, x_orig, sigma)
|
|
return fn(args)
|
|
|
|
|
|
def test_fallback_no_latent_shapes_matches_single_stream_shape():
|
|
model = _StubModel(latent_shapes=None)
|
|
cond = torch.randn(2, 4, 8, 8)
|
|
uncond = torch.randn(2, 4, 8, 8)
|
|
x_orig = torch.randn(2, 4, 8, 8)
|
|
sigma = torch.rand(2) + 0.1
|
|
|
|
result = _run_nrs(model, cond, uncond, x_orig, sigma)
|
|
assert result.shape == x_orig.shape
|
|
|
|
# Regression check: manually compute the single-stream result the same
|
|
# way the pre-split code path did, and confirm equality.
|
|
node = nrs_module.NRS()
|
|
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
|
|
assert torch.allclose(result, expected)
|
|
|
|
|
|
def test_single_stream_latent_shapes_also_matches():
|
|
"""A model.latent_shapes list of length 1 must take the same code path."""
|
|
cond = torch.randn(1, 4, 5, 5)
|
|
uncond = torch.randn(1, 4, 5, 5)
|
|
x_orig = torch.randn(1, 4, 5, 5)
|
|
sigma = torch.rand(1) + 0.1
|
|
|
|
model = _StubModel(latent_shapes=[cond.shape])
|
|
result = _run_nrs(model, cond, uncond, x_orig, sigma)
|
|
|
|
node = nrs_module.NRS()
|
|
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
|
|
assert torch.allclose(result, expected)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3: degeneracy tripwire
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tripwire_fires_on_flat_pack_without_latent_shapes(caplog):
|
|
model = _StubModel(latent_shapes=None)
|
|
cond = torch.randn(1, 1, 100)
|
|
uncond = torch.randn(1, 1, 100)
|
|
x_orig = torch.randn(1, 1, 100)
|
|
sigma = torch.rand(1) + 0.1
|
|
|
|
with caplog.at_level("WARNING"):
|
|
_run_nrs(model, cond, uncond, x_orig, sigma)
|
|
|
|
assert any("singleton reduction axis" in rec.message for rec in caplog.records)
|
|
|
|
|
|
def test_tripwire_does_not_fire_for_normal_single_stream(caplog):
|
|
model = _StubModel(latent_shapes=None)
|
|
cond = torch.randn(1, 4, 8, 8)
|
|
uncond = torch.randn(1, 4, 8, 8)
|
|
x_orig = torch.randn(1, 4, 8, 8)
|
|
sigma = torch.rand(1) + 0.1
|
|
|
|
with caplog.at_level("WARNING"):
|
|
_run_nrs(model, cond, uncond, x_orig, sigma)
|
|
|
|
assert not any("singleton reduction axis" in rec.message for rec in caplog.records)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 4: per-stream reduced shapes after unpack (H3-like video + audio)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_per_stream_reduced_shapes_after_unpack():
|
|
video = torch.randn(1, 24, 4, 3, 2)
|
|
audio = torch.randn(1, 32, 2, 5)
|
|
shapes = [video.shape, audio.shape]
|
|
|
|
packed = nrs_module._pack_latents([video, audio])
|
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
|
|
|
video_u, audio_u = unpacked
|
|
assert video_u.shape == video.shape
|
|
assert audio_u.shape == audio.shape
|
|
|
|
# Channels sit at dim 1 for both streams.
|
|
assert video_u.shape[1] == 24
|
|
assert audio_u.shape[1] == 32
|
|
|
|
video_reduced = video_u.sum(dim=1, keepdim=True)
|
|
audio_reduced = audio_u.sum(dim=1, keepdim=True)
|
|
assert video_reduced.shape == (1, 1, 4, 3, 2)
|
|
assert audio_reduced.shape == (1, 1, 2, 5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: split restores non-degenerate rejection (proves Skew is alive)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_split_restores_nondegenerate_rejection():
|
|
"""On a real multi-channel stream, uncond's rejection on cond must not
|
|
collapse to ~0 -- this is the geometry that was silently dead on the flat
|
|
[B,1,N] pack before the unpack/repack fix.
|
|
"""
|
|
torch.manual_seed(0)
|
|
cond = torch.randn(1, 8, 4, 4)
|
|
# Make uncond non-parallel to cond so the rejection component is nonzero.
|
|
uncond = torch.randn(1, 8, 4, 4)
|
|
|
|
def _dot(a, b):
|
|
return (a * b).sum(dim=1, keepdim=True)
|
|
|
|
eps = torch.finfo(cond.dtype).eps
|
|
c_dot_c = _dot(cond, cond) + eps
|
|
u_dot_c = _dot(uncond, cond)
|
|
u_on_c = (u_dot_c / c_dot_c) * cond
|
|
u_rej_c = uncond - u_on_c
|
|
|
|
assert u_rej_c.abs().max().item() > 1e-4
|
|
|
|
|
|
def test_flat_pack_rejection_is_degenerate_without_split():
|
|
"""Sanity check for the bug this PR fixes: reducing over the flat pack's
|
|
singleton dim=1 axis collapses the rejection to exactly zero (up to
|
|
floating point noise from the eps regularization term).
|
|
"""
|
|
# float64 keeps the residual from the eps regularizer near the true
|
|
# machine epsilon instead of float32 accumulation noise, so the
|
|
# collapse-to-zero identity is exact enough to assert tightly.
|
|
packed_cond = torch.randn(1, 1, 100, dtype=torch.float64)
|
|
packed_uncond = torch.randn(1, 1, 100, dtype=torch.float64)
|
|
|
|
def _dot(a, b):
|
|
return (a * b).sum(dim=1, keepdim=True)
|
|
|
|
eps = torch.finfo(packed_cond.dtype).eps
|
|
c_dot_c = _dot(packed_cond, packed_cond) + eps
|
|
u_dot_c = _dot(packed_uncond, packed_cond)
|
|
u_on_c = (u_dot_c / c_dot_c) * packed_cond
|
|
u_rej_c = packed_uncond - u_on_c
|
|
|
|
assert u_rej_c.abs().max().item() < 1e-8
|