The NaN guard was on one of two doors. A FUNPACK_SETTINGS socket is typed by a string tag, so a dict reaches the load node without necessarily having passed through the settings node -- a raw API prompt does exactly that. Through that door, until_sigma = NaN made ALG a permanent no-op: sigma > nan is False at every step, so the anchor was never loosened, and the node reported "1 modifier applied: alg". Nothing about the run looked wrong, which is the only reason this is worth a commit of its own. Values are now checked against the declaration at the point they are consumed. The producer still checks too; that is not redundant, it is where a person editing JSON gets told immediately. Also recorded ALG's standing assumption rather than leaving it implicit: the anchor is latent frame 0. True of everything it can currently be wired into, untrue in general, and the denoise_mask already carries what would be needed to do it properly. Left undone because nothing yet pins elsewhere, and a derivation with no consumer cannot be checked against reality. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Modifiers: chosen, filtered, installed, and never accumulating.
|
|
|
|
Run against real ComfyUI ModelPatcher objects. No weights are needed: a patcher
|
|
around an empty nn.Module carries the same wrapper machinery a real one does.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _needs_comfy(comfyui):
|
|
"""Everything here touches comfy."""
|
|
|
|
|
|
@pytest.fixture
|
|
def patcher():
|
|
import torch
|
|
from comfy.model_patcher import ModelPatcher
|
|
|
|
class Stub(torch.nn.Module):
|
|
pass
|
|
|
|
return ModelPatcher(Stub(), load_device=torch.device("cpu"),
|
|
offload_device=torch.device("cpu"))
|
|
|
|
|
|
def _spec(module_id, install, requires=(), settings=None, stage="sampling"):
|
|
from core.contract import ModuleSpec
|
|
return ModuleSpec(id=module_id, title=module_id, mount="", stage=stage,
|
|
requires=list(requires), settings=settings or {},
|
|
provides={"modifier": install})
|
|
|
|
|
|
@pytest.fixture
|
|
def registry(monkeypatch):
|
|
"""A registry holding only what a test puts in it."""
|
|
from core import registry as registry_mod
|
|
|
|
class Fake:
|
|
def __init__(self):
|
|
self.specs = {}
|
|
self.failed = []
|
|
|
|
fake = Fake()
|
|
monkeypatch.setattr(registry_mod, "current", lambda rescan=False: fake)
|
|
return fake
|
|
|
|
|
|
# --- installing ------------------------------------------------------------
|
|
|
|
def test_a_modifier_installs_on_the_model_and_is_reported(registry, patcher):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
target.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, lambda e, *a, **k: e(*a, **k))
|
|
return "on"
|
|
|
|
registry.specs["m"] = _spec("m", install)
|
|
out = FunPackLoadModifiers.execute(patcher)
|
|
model, status = out.result
|
|
|
|
assert "m: on" in status
|
|
assert any("funpack.m" in keys for keys in model.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {}))
|
|
|
|
|
|
def test_installing_never_touches_the_model_everyone_else_holds(registry, patcher):
|
|
"""The fault this makes impossible: v4 registered hooks on shared blocks and
|
|
removed them only on a scene change, so every run stacked another set and
|
|
only a restart cleared them. Installing on a clone means the original is
|
|
untouched, so there is nothing to accumulate and nothing to clean up."""
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
target.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, lambda e, *a, **k: e(*a, **k))
|
|
return "on"
|
|
|
|
registry.specs["m"] = _spec("m", install)
|
|
|
|
before = len(patcher.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {}))
|
|
for _ in range(50):
|
|
FunPackLoadModifiers.execute(patcher)
|
|
|
|
after = patcher.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {})
|
|
assert len(after) == before == 0, (
|
|
f"fifty runs left {len(after)} wrapper(s) on the shared model")
|
|
|
|
|
|
def test_a_modifier_that_says_it_is_off_installs_nothing(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
return None # the module's own decision, not core's
|
|
|
|
registry.specs["m"] = _spec("m", install)
|
|
_model, status = FunPackLoadModifiers.execute(patcher).result
|
|
assert "0 modifier(s) applied" in status
|
|
|
|
|
|
def test_one_broken_modifier_does_not_stop_the_others(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def explodes(target, values, key):
|
|
raise RuntimeError("boom")
|
|
|
|
def works(target, values, key):
|
|
return "on"
|
|
|
|
registry.specs["a_broken"] = _spec("a_broken", explodes)
|
|
registry.specs["b_works"] = _spec("b_works", works)
|
|
|
|
_model, status = FunPackLoadModifiers.execute(patcher).result
|
|
assert "b_works: on" in status
|
|
assert "failed to install" in status and "boom" in status
|
|
|
|
|
|
def test_modifiers_install_in_declared_order(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
from core.contract import ModuleSpec
|
|
|
|
seen = []
|
|
|
|
def make(name):
|
|
def install(target, values, key):
|
|
seen.append(name)
|
|
return "on"
|
|
return install
|
|
|
|
# "first" declares that it runs before "second", so filesystem/alphabetical
|
|
# order must not decide this.
|
|
registry.specs["zzz_first"] = ModuleSpec(
|
|
id="zzz_first", title="", mount="", before=["aaa_second"],
|
|
provides={"modifier": make("zzz_first")})
|
|
registry.specs["aaa_second"] = ModuleSpec(
|
|
id="aaa_second", title="", mount="", provides={"modifier": make("aaa_second")})
|
|
|
|
FunPackLoadModifiers.execute(patcher)
|
|
assert seen == ["zzz_first", "aaa_second"]
|
|
|
|
|
|
# --- filtering by what the model actually is -------------------------------
|
|
|
|
def test_a_modifier_needing_an_absent_trait_is_absent_and_says_why(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
raise AssertionError("an incompatible modifier was installed")
|
|
|
|
registry.specs["needs_audio"] = _spec("needs_audio", install, requires=["audio_stream"])
|
|
_model, status = FunPackLoadModifiers.execute(patcher).result
|
|
|
|
assert "needs_audio: needs audio_stream" in status
|
|
assert "0 modifier(s) applied" in status
|
|
|
|
|
|
def test_a_modifier_declaring_no_traits_runs_on_anything(registry, patcher):
|
|
"""Narrowing, not opting in -- the property that lets a modifier reach models
|
|
FunPack has never heard of."""
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
registry.specs["plain"] = _spec("plain", lambda t, v, key: "on")
|
|
_model, status = FunPackLoadModifiers.execute(patcher).result
|
|
assert "plain: on" in status
|
|
|
|
|
|
# --- the settings payload --------------------------------------------------
|
|
|
|
def test_values_reach_the_modifier_that_declared_them(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
got = {}
|
|
|
|
def install(target, values, key):
|
|
got.update(values)
|
|
return "on"
|
|
|
|
registry.specs["m"] = _spec("m", install, settings={
|
|
"strength": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "label": "S"},
|
|
})
|
|
FunPackLoadModifiers.execute(patcher, settings={"m": {"strength": 0.8}})
|
|
assert got == {"strength": 0.8}
|
|
|
|
|
|
def test_a_modifier_with_no_values_gets_its_declared_defaults(registry, patcher):
|
|
"""A headless run must match a run with the panel open, because both read the
|
|
same declaration."""
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
got = {}
|
|
registry.specs["m"] = _spec("m", lambda t, v, key: got.update(v) or "on", settings={
|
|
"strength": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "label": "S"},
|
|
})
|
|
FunPackLoadModifiers.execute(patcher)
|
|
assert got == {"strength": 0.5}
|
|
|
|
|
|
def test_running_a_model_through_the_loader_twice_does_not_double_the_modifier(registry, patcher):
|
|
"""The first version protected the ORIGINAL model and not its own output.
|
|
`add_wrapper_with_key` appends, and a clone carries the list, so chaining two
|
|
of these -- two passes, a flattened subgraph -- installed the same wrapper
|
|
twice and ran it twice per step at double strength, reporting "1 modifier
|
|
applied" both times."""
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
target.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, lambda e, *a, **k: e(*a, **k))
|
|
return "on"
|
|
|
|
registry.specs["alg"] = _spec("alg", install)
|
|
|
|
once = FunPackLoadModifiers.execute(patcher).result[0]
|
|
twice = FunPackLoadModifiers.execute(once).result[0]
|
|
|
|
installed = twice.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {}).get("funpack.alg", [])
|
|
assert len(installed) == 1, f"the modifier is installed {len(installed)} times"
|
|
|
|
|
|
def test_chaining_says_that_it_replaced_the_earlier_pass(registry, patcher):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
def install(target, values, key):
|
|
target.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, lambda e, *a, **k: e(*a, **k))
|
|
return "on"
|
|
|
|
registry.specs["alg"] = _spec("alg", install)
|
|
once = FunPackLoadModifiers.execute(patcher).result[0]
|
|
_model, status = FunPackLoadModifiers.execute(once).result
|
|
assert "replaced 1 modifier" in status
|
|
|
|
|
|
def test_stripping_leaves_wrappers_this_pack_did_not_install(registry, patcher):
|
|
"""Namespaced keys exist so cleanup is surgical. Removing someone else's
|
|
wrapper would be a worse fault than the one being fixed."""
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, "someone_else",
|
|
lambda e, *a, **k: e(*a, **k))
|
|
registry.specs["m"] = _spec("m", lambda t, v, key: "on")
|
|
|
|
out = FunPackLoadModifiers.execute(patcher).result[0]
|
|
assert "someone_else" in out.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {})
|
|
|
|
|
|
def test_a_settings_payload_of_the_wrong_shape_is_refused_clearly(registry, patcher):
|
|
"""The socket type is a string match, so anything can arrive on it. An
|
|
AttributeError traceback is not the error reporting the rest of this uses."""
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
registry.specs["m"] = _spec("m", lambda t, v, key: "on")
|
|
|
|
with pytest.raises(RuntimeError, match="keyed by module id"):
|
|
FunPackLoadModifiers.execute(patcher, settings=["not", "a", "dict"])
|
|
|
|
|
|
def test_values_are_checked_where_they_are_used_not_only_where_they_were_made(registry, patcher):
|
|
"""The settings socket is typed by a string tag, so a dict can reach this
|
|
node without passing through FunPackModifierSettings. Validating only at the
|
|
producer guards one of two doors, and NaN walked through the other: sigma >
|
|
nan is False at every step, so the modifier became a permanent no-op while
|
|
reporting that it had been applied."""
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
|
|
registry.specs["alg"] = _spec("alg", lambda t, v, key: "on", settings={
|
|
"until_sigma": {"type": "float", "default": 0.6, "min": 0.0, "max": 1.0, "label": "T"},
|
|
})
|
|
|
|
with pytest.raises(RuntimeError, match="finite"):
|
|
FunPackLoadModifiers.execute(patcher, settings={"alg": {"until_sigma": float("nan")}})
|
|
|
|
|
|
def test_an_out_of_range_value_is_refused_at_this_node_too(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
registry.specs["alg"] = _spec("alg", lambda t, v, key: "on", settings={
|
|
"strength": {"type": "float", "default": 4.0, "min": 1.0, "max": 16.0, "label": "S"},
|
|
})
|
|
with pytest.raises(RuntimeError, match="above its maximum"):
|
|
FunPackLoadModifiers.execute(patcher, settings={"alg": {"strength": 99.0}})
|
|
|
|
|
|
def test_a_setting_no_module_declares_is_refused_at_this_node_too(registry, patcher):
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
registry.specs["alg"] = _spec("alg", lambda t, v, key: "on", settings={
|
|
"strength": {"type": "float", "default": 4.0, "min": 1.0, "max": 16.0, "label": "S"},
|
|
})
|
|
with pytest.raises(RuntimeError, match="no setting named"):
|
|
FunPackLoadModifiers.execute(patcher, settings={"alg": {"strenght": 4.0}})
|