From 3bf3045d994741c0568a08bad7eb41b01d5eb03f Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:01:49 +0300 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20effective=5Fmodel=5Fsampling=20help?= =?UTF-8?q?er=20=E2=80=94=20patcher-resolved=20sampling=20(KSampler=20sema?= =?UTF-8?q?ntics)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/effective_sampling.py | 73 ++++++++++++++++++ tests/test_effective_sampling.py | 126 +++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/effective_sampling.py create mode 100644 tests/test_effective_sampling.py diff --git a/src/effective_sampling.py b/src/effective_sampling.py new file mode 100644 index 0000000..b9d98c2 --- /dev/null +++ b/src/effective_sampling.py @@ -0,0 +1,73 @@ +"""Effective model_sampling resolution through a ModelPatcher (v2.16.0). + +The node layer must never read ``model.model.model_sampling`` (the LIVE +attribute on the shared BaseModel) to derive sigma schedules, timestep +conversions or prediction types. ComfyUI's object-patch lifecycle makes that +attribute history-dependent: + +- object patches are applied to the shared BaseModel at load and stay applied + between runs (model_patcher.py patch_model / partially_load); +- ``load_models_gpu`` detaches stale same-base patchers with + ``detach(unpatch_all=False)`` (model_management.py:962) — their patches LEAK; +- restore-to-original only happens through ``object_patches_backup``, which + every ``unpatch_model`` CLEARS (model_patcher.py:1165-1169); +- ``partially_unload`` moves weights only — patches and backup untouched. + +Whichever patcher (a DyPE/SEGA clone, a stock ModelSamplingFlux node clone, +...) was loaded last therefore decides the schedule a live-attr reader sees. +ComfyUI's own sampler resolves through the patcher instead — +``comfy/samplers.py:1425``: ``calculate_sigmas(self.model.get_model_object( +"model_sampling"), ...)``. :func:`effective_model_sampling` mirrors exactly +that semantics (object_patches -> object_patches_backup -> live attr), making +the schedule a deterministic function of the graph's own patch chain. + +:func:`is_stale_dype_leak` detects the residual case this cannot repair: the +patcher carries no ``model_sampling`` patch/backup entry while the live attr is +one of this pack's function-local leak classes — i.e. a stale patch inherited +from a PREVIOUS run's patch node that is no longer in the graph. +""" + +from __future__ import annotations + +# Function-local classes installed by apply_dype_to_model / apply_sega_to_model +# (src/patch_utils.py). Matched by __name__: the classes are defined inside the +# installer functions, so identity comparison across imports is impossible. +STALE_LEAK_CLASS_NAMES = ( + "DypeModelSamplingFlux", + "SegaModelSamplingFlux", + "DefaultModelSamplingFlux", +) + + +def effective_model_sampling(model): + """Resolve the model_sampling object the way ComfyUI's sampler does. + + ``model`` is a ModelPatcher (or a test mock). Resolution order mirrors + ``ModelPatcher.get_model_object`` (model_patcher.py:758-768): the patcher's + own object patch, then its object_patches_backup (the original captured at + patch time), then the live BaseModel attribute. Plain objects without the + method fall back to the live attribute (mock/test safety). + """ + get_model_object = getattr(model, "get_model_object", None) + if callable(get_model_object): + return get_model_object("model_sampling") + return getattr(getattr(model, "model", None), "model_sampling", None) + + +def is_stale_dype_leak(model) -> bool: + """True when the live sampling is a stale patch from a previous run. + + Stale means: the patcher carries NO ``model_sampling`` object patch and NO + backup entry (so the resolution falls through to the live attribute), and + the live attribute's class is one of this pack's installer-local sampling + patches. Only then is the schedule inherited from a run that is no longer + in the graph — the user-fixable-by-cache-clear drift this pack warns about. + """ + if not callable(getattr(model, "get_model_object", None)): + return False + if "model_sampling" in (getattr(model, "object_patches", None) or {}): + return False + if "model_sampling" in (getattr(model, "object_patches_backup", None) or {}): + return False + live = getattr(getattr(model, "model", None), "model_sampling", None) + return type(live).__name__ in STALE_LEAK_CLASS_NAMES diff --git a/tests/test_effective_sampling.py b/tests/test_effective_sampling.py new file mode 100644 index 0000000..4aed35e --- /dev/null +++ b/tests/test_effective_sampling.py @@ -0,0 +1,126 @@ +"""Tests for src/effective_sampling.py (v2.16.0, plan S1). + +Covers the resolution order (patch -> backup -> live), the mock fallback and +the stale-leak detector, using fake patchers that replicate the ModelPatcher +get_model_object semantics (object_patches -> object_patches_backup -> live +attr; comfy/model_patcher.py:758-768). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.effective_sampling import ( # noqa: E402 + STALE_LEAK_CLASS_NAMES, + effective_model_sampling, + is_stale_dype_leak, +) + + +class FakeBaseModel: + def __init__(self, model_sampling): + self.model_sampling = model_sampling + + +class FakePatcher: + """Minimal ModelPatcher stand-in with the real get_model_object order.""" + + def __init__(self, live, patches=None, backup=None): + self.model = FakeBaseModel(live) + self.object_patches = dict(patches or {}) + self.object_patches_backup = dict(backup or {}) + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + +class PlainNode: + """Object WITHOUT get_model_object (the mock-fallback path).""" + + def __init__(self, live): + self.model = FakeBaseModel(live) + + +def _make_leak_class(name: str) -> type: + return type(name, (), {}) + + +ORIG = _make_leak_class("ModelSamplingContinuousFlow")() +LEAK = _make_leak_class("DypeModelSamplingFlux")() +PATCH = _make_leak_class("DypeModelSamplingFlux")() + + +class TestEffectiveModelSampling: + def test_own_patch_wins_over_leaked_live(self): + patcher = FakePatcher(LEAK, patches={"model_sampling": PATCH}) + assert effective_model_sampling(patcher) is PATCH + + def test_backup_used_when_no_patch(self): + patcher = FakePatcher(LEAK, backup={"model_sampling": ORIG}) + assert effective_model_sampling(patcher) is ORIG + + def test_live_fallback_when_patch_and_backup_empty(self): + patcher = FakePatcher(ORIG) + assert effective_model_sampling(patcher) is ORIG + + def test_live_leak_returned_when_nothing_else_available(self): + # Documented residual: patcher without patch/backup sees the leak — + # the same object a stock KSampler would resolve (KSampler semantics). + patcher = FakePatcher(LEAK) + assert effective_model_sampling(patcher) is LEAK + + def test_plain_object_fallback(self): + node = PlainNode(ORIG) + assert effective_model_sampling(node) is ORIG + + def test_plain_object_without_model_attr_returns_none(self): + class Empty: + pass + + assert effective_model_sampling(Empty()) is None + + +class TestIsStaleDypeLeak: + def test_true_for_leak_class_with_empty_patcher(self): + assert is_stale_dype_leak(FakePatcher(LEAK)) is True + + @pytest.mark.parametrize("name", STALE_LEAK_CLASS_NAMES) + def test_true_for_every_known_leak_class(self, name): + leak = _make_leak_class(name)() + assert is_stale_dype_leak(FakePatcher(leak)) is True + + def test_false_when_patcher_carries_own_patch(self): + # A DyPE clone in THIS graph legitimately patches model_sampling — + # the same class name live is not stale there. + patcher = FakePatcher(PATCH, patches={"model_sampling": PATCH}) + assert is_stale_dype_leak(patcher) is False + + def test_false_when_backup_holds_the_key(self): + patcher = FakePatcher(LEAK, backup={"model_sampling": ORIG}) + assert is_stale_dype_leak(patcher) is False + + def test_false_for_foreign_live_class(self): + assert is_stale_dype_leak(FakePatcher(ORIG)) is False + + def test_false_without_get_model_object(self): + # Mock safety: no resolution contract -> never claim a leak. + assert is_stale_dype_leak(PlainNode(LEAK)) is False + + def test_false_when_live_attr_missing(self): + class BarePatcher: + object_patches = {} + object_patches_backup = {} + + def get_model_object(self, name): + return None + + assert is_stale_dype_leak(BarePatcher()) is False From 46529f8cfd04b759ad8a05c4202ed9e9b22535c9 Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:04:46 +0300 Subject: [PATCH 2/9] fix: HiFlow resolves model_sampling through its own patcher (schedule/timestep/gate) --- nodes/hiflow.py | 18 +++++- tests/test_hiflow_node.py | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/nodes/hiflow.py b/nodes/hiflow.py index d6e2a09..1cb83bf 100644 --- a/nodes/hiflow.py +++ b/nodes/hiflow.py @@ -19,9 +19,11 @@ import torch from comfy_api.latest import io try: + from ..src.effective_sampling import effective_model_sampling from ..src.freescale import gaussian_blur_2d from ..src.hiflow import HiFlowConfig, hiflow_cascade except ImportError: # flat repo layout (tests / CLI) + from src.effective_sampling import effective_model_sampling from src.freescale import gaussian_blur_2d from src.hiflow import HiFlowConfig, hiflow_cascade @@ -49,7 +51,10 @@ def _require_flow_model(model) -> tuple[str, int]: Returns (detected flow family, latent_dimensions). """ - model_sampling = model.model.model_sampling + # Patch-resolved (KSampler semantics, v2.16.0): the live BaseModel attr is + # history-dependent under ComfyUI's object-patch lifecycle — a schedule + # leaked by a previous run's patch node must not flip this gate. + model_sampling = effective_model_sampling(model) mro_names = [c.__name__ for c in type(model_sampling).__mro__] detected = _detect_prediction_type(model_sampling) @@ -106,6 +111,10 @@ def _make_predict_x0( device = model.load_device if hasattr(model, "load_device") \ else torch.device("cpu") inner_model = model.model + # ONE patch-resolved sampling object for the whole adapter (v2.16.0): the + # sigma schedule (_base_sigmas) and this timestep conversion must come + # from the same source, independent of object-patch load history. + model_sampling = effective_model_sampling(model) process_latent_in = getattr(inner_model, "process_latent_in", None) process_latent_out = getattr(inner_model, "process_latent_out", None) @@ -170,7 +179,7 @@ def _make_predict_x0( conds = _get_conds(tuple(x_vae.shape)) sigma_t = torch.tensor([float(sigma)], device=device) - timestep = inner_model.model_sampling.timestep(sigma_t) + timestep = model_sampling.timestep(sigma_t) x0 = comfy.samplers.sampling_function( inner_model, x, timestep, @@ -277,10 +286,13 @@ def _base_sigmas(model, steps: int) -> torch.Tensor: Uses comfy.samplers.calculate_sigmas with the "simple" scheduler (index sampling of the model's sigmas — no spacing resampling), ending at 0. + Resolved through the patcher (v2.16.0) — KSampler semantics: a DyPE/SEGA + schedule patch in THIS graph is always honored; a schedule leaked onto the + shared BaseModel by a previous run's patch node never is. """ import comfy.samplers - ms = model.model.model_sampling + ms = effective_model_sampling(model) sigmas = comfy.samplers.calculate_sigmas(ms, "simple", steps) return sigmas.float().cpu() diff --git a/tests/test_hiflow_node.py b/tests/test_hiflow_node.py index 845e8d7..7692245 100644 --- a/tests/test_hiflow_node.py +++ b/tests/test_hiflow_node.py @@ -1000,3 +1000,130 @@ class TestHiFlowDocs: unknown = types - core - {"HiFlow"} assert not unknown, f"workflow references unknown nodes: {unknown}" assert "HiFlow" in types + + +# --------------------------------------------------------------------------- +# Effective-sampling determinism (v2.16.0, plan S2) +# --------------------------------------------------------------------------- + +class _PatcherFacade: + """Wraps a _mock_flow_model with ModelPatcher semantics. + + Provides get_model_object (object_patches -> object_patches_backup -> + live attr — the real ModelPatcher order, model_patcher.py:758-768) and + delegates the patcher-level attributes the node layer touches. + """ + + def __init__(self, inner, object_patches=None, object_patches_backup=None): + self.model = inner.model + self.model_options = inner.model_options + self.load_device = inner.load_device + self.pre_run = inner.pre_run + self.object_patches = dict(object_patches or {}) + self.object_patches_backup = dict(object_patches_backup or {}) + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + +def _install_recording_calculate_sigmas(monkeypatch): + """Fake comfy.samplers.calculate_sigmas that records the ms object.""" + used = [] + fake_samplers = types.ModuleType("comfy.samplers") + + def calculate_sigmas(ms, scheduler, steps): + used.append(ms) + return torch.linspace(1.0, 0.0, steps + 1) + + fake_samplers.calculate_sigmas = calculate_sigmas + monkeypatch.setitem(sys.modules, "comfy.samplers", fake_samplers) + comfy_mod = sys.modules.get("comfy") + if comfy_mod is not None: + monkeypatch.setattr( + comfy_mod, "samplers", fake_samplers, raising=False) + return used + + +@pytest.mark.unit +class TestEffectiveSamplingDeterminism: + """S2 (v2.16.0): the schedule, the timestep conversion and the flow gate + must follow the graph's OWN patch chain — never a model_sampling leaked + onto the shared BaseModel by a previous run's patch node.""" + + def _patched_patcher(self, live_leak, patch_ms): + model = _mock_flow_model() + return _PatcherFacade( + model, object_patches={"model_sampling": patch_ms}), model + + def test_base_sigmas_follow_own_patch_not_leak(self, monkeypatch): + used = _install_recording_calculate_sigmas(monkeypatch) + patch_ms = type( + "PatchedSampling", (), {"timestep": lambda self, s: s * 1000})() + leak_ms = type("LeakedSampling", (), {})() + patcher, _ = self._patched_patcher(leak_ms, patch_ms) + hfn._base_sigmas(patcher, steps=8) + assert used[-1] is patch_ms, ( + "schedule must derive from the patcher's own object patch, not " + "the leaked live attribute" + ) + + def test_base_sigmas_use_backup_original_when_unpatched(self, monkeypatch): + used = _install_recording_calculate_sigmas(monkeypatch) + orig_ms = type("OrigSampling", (), {})() + leak_ms = type("LeakedSampling", (), {})() + model = _mock_flow_model() + model.model.model_sampling = leak_ms + patcher = _PatcherFacade( + model, object_patches_backup={"model_sampling": orig_ms}) + hfn._base_sigmas(patcher, steps=8) + assert used[-1] is orig_ms, ( + "an unpatched patcher must resolve the backup original, not the " + "leak" + ) + + def test_base_sigmas_fall_back_to_live_when_nothing_else(self, monkeypatch): + used = _install_recording_calculate_sigmas(monkeypatch) + model = _mock_flow_model() # SimpleNamespace: no get_model_object + hfn._base_sigmas(model, steps=8) + assert used[-1] is model.model.model_sampling + + def test_flow_gate_not_flipped_by_leaked_live(self): + # A leaked EPS-family sampling on the BaseModel must not make the + # gate reject a flow model whose OWN patch is CONST. + patch_ms = type( + "PatchedConst", (type("CONST", (), {}),), {})() + leak_ms = type( + "LeakedEps", (type("EPS", (), {}),), {})() + model = _mock_flow_model() + model.model.model_sampling = leak_ms + patcher = _PatcherFacade( + model, object_patches={"model_sampling": patch_ms}) + family, dims = hfn._require_flow_model(patcher) + assert family == "const" + assert dims == 2 + + def test_predict_x0_timestep_from_resolved_object(self, monkeypatch): + """The adapter's timestep conversion uses the SAME resolved object + as the schedule (identity between schedule and conversion).""" + fx = _install_fake_comfy(monkeypatch) + patch_ms = type( + "PatchedSampling", (), {"timestep": lambda self, s: s * 2000.0})() + leak_ms = type( + "LeakedSampling", (), {"timestep": lambda self, s: s * 1000.0})() + model = _mock_flow_model() + model.model.model_sampling = leak_ms + patcher = _PatcherFacade( + model, object_patches={"model_sampling": patch_ms}) + adapter = hfn._make_predict_x0( + patcher, COND_POS, COND_NEG, cfg_scale=3.5) + x = torch.zeros(1, 16, 8, 8) + adapter(x, 0.5) + recorded_timestep = fx.sampling_calls[-1]["timestep"] + assert float(recorded_timestep.flatten()[0]) == pytest.approx(1000.0), ( + "timestep must come from the patch object (0.5 * 2000), not the " + "leaked live attr (0.5 * 1000)" + ) From 567e4ce6992a021f4a8c2b5dc5f337a7e504413c Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:12:43 +0300 Subject: [PATCH 3/9] =?UTF-8?q?test:=20ComfyUI=20lifecycle=20regression=20?= =?UTF-8?q?harness=20=E2=80=94=20cache-determinism=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_cache_determinism_regression.py | 329 +++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 tests/test_cache_determinism_regression.py diff --git a/tests/test_cache_determinism_regression.py b/tests/test_cache_determinism_regression.py new file mode 100644 index 0000000..1eeb393 --- /dev/null +++ b/tests/test_cache_determinism_regression.py @@ -0,0 +1,329 @@ +"""Cache-determinism regression: the reported Krea2 repro, simulated (v2.16.0, plan S3). + +User report (2026-09-17): with identical HiFlow parameters, results differ +unless model AND node caches are cleared before each run; the HiFlow console +shows a different stage entry sigma (0.3872 vs 0.5055) for identical params. + +Root cause (verified against the installed ComfyUI source, see +src/effective_sampling.py docstring): object patches applied by a patch-node +clone STAY on the shared BaseModel after that clone is superseded +(``load_models_gpu`` detaches stale entries with ``detach(unpatch_all=False)``, +model_management.py:962; ``partially_unload`` never restores object patches). +Restore-to-original only happens through ``object_patches_backup`` — a dict +SHARED between a patcher and its clones (model_patcher.py:428/462) — so a +patcher whose own backup is empty and that carries no ``model_sampling`` patch +resolves the live (leaked) attribute. + +This file encodes those semantics in a FakeComfyLifecycle harness and asserts +the v2.16.0 contract: HiFlow's resolved model_sampling / stage schedule is a +function of the GRAPH's patch chain, not of what ran earlier in the session — +even when the live BaseModel attribute has diverged. + +If a future ComfyUI changes these lifecycle semantics, only THIS harness needs +updating. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest +import torch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import nodes.hiflow as hfn # noqa: E402 +from src.effective_sampling import effective_model_sampling # noqa: E402 +from src.hiflow import build_stage_sigmas # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fake ComfyUI lifecycle +# --------------------------------------------------------------------------- + +class FakeBaseModel: + """The SHARED BaseModel: object patches mutate its live attributes.""" + + def __init__(self, model_sampling): + self.model_sampling = model_sampling + + +class FakeModelPatcher: + """ModelPatcher stand-in with the lifecycle semantics that matter. + + - clone() shares the parent's object_patches_backup dict object (via + get_clone_model_override, model_patcher.py:428 + clone 462) and copies + object_patches; + - patch_model() applies object patches to the SHARED base model, backing + up only keys not already backed up (model_patcher.py:1113-1120); + - unpatch_model() restores the backup and CLEARS it (1165-1169); + - detach(unpatch_all=False) skips the unpatch -> leak (called from + model_management.py:962). + """ + + def __init__(self, base_model, object_patches=None): + self.model = base_model + self.object_patches = dict(object_patches or {}) + self.object_patches_backup = {} # replaced by SHARED dict on clone() + + def clone(self): + child = FakeModelPatcher(self.model, self.object_patches) + child.object_patches_backup = self.object_patches_backup # SHARED + return child + + def patch_model(self): + for key, patch in self.object_patches.items(): + old = getattr(self.model, key) + if key not in self.object_patches_backup: + self.object_patches_backup[key] = old + setattr(self.model, key, patch) + + def unpatch_model(self): + for key in list(self.object_patches_backup.keys()): + setattr(self.model, key, self.object_patches_backup[key]) + self.object_patches_backup.clear() + + def partially_load(self): + # partially_load = unpatch(own backup) + patch(own patches) + # (model_patcher.py:1256+). A patcher with no patch for a key and an + # empty backup restores nothing for it. + self.unpatch_model() + self.patch_model() + + def detach(self, unpatch_all=True): + if unpatch_all: + self.unpatch_model() + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + +class FakeLoadedModels: + """load_models_gpu semantics (model_management.py:913-1014). + + Identity-keyed entries; stale same-base entries are detached with + ``unpatch_all=False`` (LEAK); the requested patcher always runs + model_load -> partially_load (re-applies ITS OWN patches). + """ + + def __init__(self): + self.entries = [] + + def load_models_gpu(self, patcher): + stale = [e for e in self.entries + if e is not patcher and e.model is patcher.model] + for e in stale: + e.detach(unpatch_all=False) # LEAK by design + self.entries.remove(e) + if patcher not in self.entries: + self.entries.append(patcher) + patcher.partially_load() + + +def _install_fake_calculate_sigmas(monkeypatch): + """comfy.samplers.calculate_sigmas stand-in: indexes the sampling's own + table so the schedule genuinely depends on WHICH object is resolved.""" + + def calculate_sigmas(ms, scheduler, steps): + assert scheduler == "simple" + table = getattr(ms, "sigma_table") + idx = torch.linspace(0, len(table) - 1, steps + 1).round().long() + return table[idx] + + fake = types.ModuleType("comfy.samplers") + fake.calculate_sigmas = calculate_sigmas + monkeypatch.setitem(sys.modules, "comfy.samplers", fake) + comfy_mod = sys.modules.get("comfy") + if comfy_mod is not None: + monkeypatch.setattr( + comfy_mod, "samplers", fake, raising=False) + + +# --------------------------------------------------------------------------- +# Sampling stand-ins with distinct, measurable schedules +# --------------------------------------------------------------------------- + +def _make_sampling(name, shift): + class _Sampling: + def __init__(self, shift): + self.shift = shift + t = torch.linspace(1.0, 0.0, 101) + self.sigma_table = shift * t / (1.0 + (shift - 1.0) * t) + + def timestep(self, sigma): + return sigma * 1000.0 + + _Sampling.__name__ = name + return _Sampling(shift) + + +def _checkpoint_patcher(shift=2.0): + """A fresh Load-Checkpoint output: patcher over a fresh BaseModel.""" + return FakeModelPatcher( + FakeBaseModel(_make_sampling("ModelSamplingContinuousFlow", shift))) + + +def _install_dype_patch(patcher): + """Stand-in for apply_dype_to_model's schedule patch (patch_utils.py:166-171): + a NEW clone + an object patch whose shift differs from the original.""" + patcher.object_patches["model_sampling"] = _make_sampling( + "DypeModelSamplingFlux", 3.5) + + +def _stage_entry_sigma(model, steps=30, tau=0.6): + """What HiFlow's console line reports: the guided stage's entry sigma.""" + base_sigmas = hfn._base_sigmas(model, steps) + stage = build_stage_sigmas(base_sigmas, tau, 16) + return float(stage[0]) + + +# --------------------------------------------------------------------------- +# The reported repro +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestReportedRepro: + """Run1 (plain checkpoint) -> Run2 (DyPE clone, different schedule) -> + Run3 (restore run-1 graph, caches NOT cleared). + + Contract delivered by v2.16.0: + - a patcher with its OWN patch (DyPE/SEGA in the graph) resolves that + patch — always, regardless of live-attr history; + - an unpatched patcher resolves the backup original when the lineage + holds one (the ComfyUI shared-backup heal path, model_patcher.py:428); + - ONLY the un-healable residue (no patch, empty backup, live leak — a + live attr inherited from outside the patcher's lineage) falls back to + the live attr, exactly what a stock KSampler on the same input sees; + the S6 stale-leak warning flags that case instead of silent drift. + """ + + def test_p0_reload_heals_via_shared_backup(self, monkeypatch): + """Run3 re-requests the SAME checkpoint patcher object (the normal + Load-Checkpoint cache hit): the shared backup restores the original + and the schedule matches run 1.""" + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + + p1 = _checkpoint_patcher() + loaded.load_models_gpu(p1) + s1 = _stage_entry_sigma(p1) + + p2 = p1.clone() + _install_dype_patch(p2) + loaded.load_models_gpu(p2) + s2 = _stage_entry_sigma(p2) + + # Run 3: the cached checkpoint patcher object again (node cache hit), + # DyPE node removed from the graph. + loaded.load_models_gpu(p1) + s3 = _stage_entry_sigma(p1) + + assert s2 != s1, "harness sanity: the patched schedule must differ" + assert s3 == pytest.approx(s1), ( + "re-loading the checkpoint patcher must restore its original " + "schedule via the shared backup (unpatch_model on partially_load)" + ) + + def test_restored_dype_params_resolve_own_schedule(self, monkeypatch): + """Run3 re-executes the patch node with restored params (a NEW clone): + its own patch decides the schedule — not the live attr left by run 2's + clone, which had DIFFERENT shift parameters.""" + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + p1 = _checkpoint_patcher() + loaded.load_models_gpu(p1) + + # Run 2: DyPE clone at shift 3.5. + p2 = p1.clone() + _install_dype_patch(p2) + loaded.load_models_gpu(p2) + s2 = _stage_entry_sigma(p2) + + # Run 3: restored params -> fresh clone whose patch is re-derived for + # the restored parameters. The live attr is POISONED with a foreign + # schedule (as if another run's patch had leaked): resolution must + # ignore it. + p3 = p1.clone() + _install_dype_patch(p3) + p3.model.model_sampling = _make_sampling("Leaked", 0.5) + loaded.load_models_gpu(p3) + s3 = _stage_entry_sigma(p3) + + assert s3 == pytest.approx(s2), ( + "a patcher with its own schedule patch must resolve that patch " + "regardless of the live attribute's history" + ) + + def test_unhealable_live_leak_is_ksampler_consistent(self, monkeypatch): + """The documented residual: a patcher with no patch and an empty + backup over a leaked base resolves the live attr — the SAME schedule + a stock KSampler on that input would use (consistent, not silent + divergence between our node and ComfyUI's own).""" + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + p1 = _checkpoint_patcher() + loaded.load_models_gpu(p1) + p2 = p1.clone() + _install_dype_patch(p2) + loaded.load_models_gpu(p2) + + # A patcher outside the leak's backup lineage over the same base. + p3 = FakeModelPatcher(p1.model) + loaded.load_models_gpu(p3) + + assert effective_model_sampling(p3) is p3.model.model_sampling + + +@pytest.mark.unit +class TestResolutionContract: + def test_same_patcher_reuse_keeps_schedule_stable(self, monkeypatch): + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + p1 = _checkpoint_patcher() + loaded.load_models_gpu(p1) + s1 = _stage_entry_sigma(p1) + loaded.load_models_gpu(p1) # cache HIT: same patcher object + assert _stage_entry_sigma(p1) == pytest.approx(s1) + + def test_dype_in_chain_is_always_honored(self, monkeypatch): + """A DyPE clone in THIS graph always resolves to its own patched + schedule — before and after unrelated model loads.""" + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + dype_patcher = _checkpoint_patcher() + _install_dype_patch(dype_patcher) + loaded.load_models_gpu(dype_patcher) + s_direct = _stage_entry_sigma(dype_patcher) + + # Unrelated load in between (another model's patcher). + other = FakeModelPatcher( + FakeBaseModel(_make_sampling("Other", 1.5))) + loaded.load_models_gpu(other) + + assert _stage_entry_sigma(dype_patcher) == pytest.approx(s_direct) + assert s_direct > 0.0 + + def test_schedule_matches_resolved_object_table(self, monkeypatch): + """The derived base schedule indexes the RESOLVED object's table — + the schedule a stock KSampler on the same input would compute.""" + _install_fake_calculate_sigmas(monkeypatch) + loaded = FakeLoadedModels() + p1 = _checkpoint_patcher(shift=2.0) + loaded.load_models_gpu(p1) + + p2 = p1.clone() + _install_dype_patch(p2) + loaded.load_models_gpu(p2) + + p3 = FakeModelPatcher(p1.model) + sigmas = hfn._base_sigmas(p3, steps=30) + resolved = effective_model_sampling(p3) + expected = resolved.sigma_table[ + torch.linspace(0, 100, 31).round().long()] + assert torch.allclose(sigmas, expected) From 0c96788efc50eb215eeb79c65f17785197c7eeb1 Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:14:34 +0300 Subject: [PATCH 4/9] fix: PixelRush + FreeScale resolve model_sampling through their own patcher --- nodes/freescale.py | 4 +- nodes/pixelrush.py | 20 +++--- tests/test_effective_sampling_nodes.py | 85 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 tests/test_effective_sampling_nodes.py diff --git a/nodes/freescale.py b/nodes/freescale.py index f500e22..8254b54 100644 --- a/nodes/freescale.py +++ b/nodes/freescale.py @@ -18,10 +18,12 @@ import torch.nn.functional as F from comfy_api.latest import io try: + from ..src.effective_sampling import effective_model_sampling from ..src.freescale import ( forward_noise, ) except ImportError: # flat repo layout (tests / CLI) + from src.effective_sampling import effective_model_sampling from src.freescale import ( forward_noise, ) @@ -371,7 +373,7 @@ class FreeScaleNode(io.ComfyNode): initial_latent = initial_latent.unsqueeze(2) # [B, C, 1, H, W] # Get model's sigma schedule for alpha_bar - sigmas = model.model.model_sampling.sigmas + sigmas = effective_model_sampling(model).sigmas alphas_cumprod = 1.0 / (sigmas ** 2 + 1.0) def alpha_bar_at(timestep: int) -> float: diff --git a/nodes/pixelrush.py b/nodes/pixelrush.py index f862eff..54546cc 100644 --- a/nodes/pixelrush.py +++ b/nodes/pixelrush.py @@ -14,8 +14,10 @@ import torch from comfy_api.latest import io try: + from ..src.effective_sampling import effective_model_sampling from ..src.pixelrush import PixelRushConfig, pixelrush_cascade -except ImportError: # flat repo layout (tests / CLI) +except ImportError: + from src.effective_sampling import effective_model_sampling from src.pixelrush import PixelRushConfig, pixelrush_cascade logger = logging.getLogger("ComfyUI-DyPE") @@ -31,7 +33,7 @@ def _scale_k_timestep(model, k_timestep): Returns the scaled k_timestep (0-1 range for flow models, unchanged for EPS). """ try: - ms = model.model.model_sampling + ms = effective_model_sampling(model) sigma_max = ms.sigma_max timestep_at_max = ms.timestep(sigma_max) if timestep_at_max <= 1.0 + 1e-3: @@ -175,7 +177,7 @@ def _make_predict_eps(model, positive, negative, cfg_scale, latent_dimensions=2) model.pre_run() # Detect prediction type and create conversion functions - model_sampling = model.model.model_sampling + model_sampling = effective_model_sampling(model) prediction_type = _detect_prediction_type(model_sampling) model_output_to_eps = _make_model_output_to_eps(model_sampling, prediction_type) logger.info("PixelRush: detected model prediction type '%s'", prediction_type) @@ -226,7 +228,7 @@ def _make_predict_eps(model, positive, negative, cfg_scale, latent_dimensions=2) # The timestep is a value in the model's internal timestep space, # NOT an index into the sigmas array (which has only ~20 entries). ts_tensor = torch.tensor([float(timestep)], device=device) - sigma_val = model.model.model_sampling.sigma(ts_tensor).item() + sigma_val = model_sampling.sigma(ts_tensor).item() # Clamp to small minimum to avoid division-by-zero in epsilon extraction # (timestep=0 gives sigma=0, which would make eps = (x - x0) / 0 = NaN) sigma_val = max(sigma_val, 1e-6) @@ -258,7 +260,7 @@ def _make_predict_eps(model, positive, negative, cfg_scale, latent_dimensions=2) # Replicate _apply_model's logic but skip calculate_denoised to get # the raw model_output (velocity for CONST, epsilon for EPS, etc.) m = model.model - ms = m.model_sampling + ms = model_sampling xc = ms.calculate_input(sigma, p.input_x) if c.get('c_concat') is not None: xc = torch.cat([xc] + [comfy.model_management.cast_to_device( @@ -348,7 +350,7 @@ def _make_forward_step(model, process_latent_in=None, process_latent_out=None): this fix the noise component arrived scaled by the format factor (7.7x too small for SDXL) and the input SNR did not match sigma. """ - ms = model.model.model_sampling + ms = effective_model_sampling(model) if process_latent_in is None: process_latent_in = lambda t: t if process_latent_out is None: @@ -374,7 +376,7 @@ def _make_reverse_step(model, process_latent_in=None, process_latent_out=None): recovers x0, and converts the result back to VAE space. The two conversions cancel exactly on a forward/reverse round trip. """ - ms = model.model.model_sampling + ms = effective_model_sampling(model) prediction_type = _detect_prediction_type(ms) eps_to_x0 = _make_eps_to_x0(ms, prediction_type) if process_latent_in is None: @@ -392,7 +394,7 @@ def _make_reverse_step(model, process_latent_in=None, process_latent_out=None): def _make_sigma_at(model): """Create a sigma_at adapter: timestep (0-999) -> sigma float.""" - ms = model.model.model_sampling + ms = effective_model_sampling(model) def sigma_at(timestep): ts_tensor = torch.tensor([float(timestep)], device=model.load_device @@ -411,7 +413,7 @@ def _make_alpha_bar_at(model): Returns a callable: alpha_bar_at(timestep) -> float """ - model_sampling = model.model.model_sampling + model_sampling = effective_model_sampling(model) def alpha_bar_at(timestep: int) -> float: # Convert timestep to sigma using the model's internal conversion diff --git a/tests/test_effective_sampling_nodes.py b/tests/test_effective_sampling_nodes.py new file mode 100644 index 0000000..25e9c07 --- /dev/null +++ b/tests/test_effective_sampling_nodes.py @@ -0,0 +1,85 @@ +"""Node-layer effective-sampling coverage for PixelRush/FreeScale (v2.16.0, plan S4). + +Asserts the S2 contract extends to the other direct-sampling nodes: their +prediction detection, timestep/sigma conversions and sigma-table reads follow +the patcher's OWN patch chain, never a live attr leaked by an earlier run. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest +import torch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import nodes.freescale as fsn # noqa: E402 +import nodes.pixelrush as prn # noqa: E402 +from src.effective_sampling import effective_model_sampling # noqa: E402 + + +class _Patcher: + """ModelPatcher facade (patch -> backup -> live).""" + + def __init__(self, live, patches=None, backup=None, prediction="CONST"): + base = types.SimpleNamespace() + base.model_sampling = live + base.latent_format = types.SimpleNamespace( + latent_dimensions=2, latent_channels=4) + self.model = base + self.object_patches = dict(patches or {}) + self.object_patches_backup = dict(backup or {}) + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + +def _ms(name, mixin): + return type(name, (type(mixin, (), {}),), {})() + + +@pytest.mark.unit +class TestPixelRushResolvedDetection: + def test_detection_follows_patch_not_leak(self): + # A leaked EPS sampling on the base must not flip the detection when + # the graph's own patch is CONST. + patch = _ms("PatchedConst", "CONST") + leak = _ms("LeakedEps", "EPS") + patcher = _Patcher(leak, patches={"model_sampling": patch}) + assert prn._detect_prediction_type( + effective_model_sampling(patcher)) == "const" + + def test_detection_backup_original(self): + orig = _ms("OrigConst", "CONST") + leak = _ms("LeakedEps", "EPS") + patcher = _Patcher(leak, backup={"model_sampling": orig}) + assert prn._detect_prediction_type( + effective_model_sampling(patcher)) == "const" + + +@pytest.mark.unit +class TestFreeScaleResolvedSigmas: + def test_sigma_table_from_own_patch(self): + patch = _ms("PatchedSampling", "CONST") + patch.sigmas = torch.linspace(1.0, 0.0, 21) + leak = _ms("LeakedSampling", "CONST") + leak.sigmas = torch.zeros(3) + patcher = _Patcher(leak, patches={"model_sampling": patch}) + sigmas = effective_model_sampling(patcher).sigmas + assert sigmas.numel() == 21 + assert not torch.equal(sigmas, leak.sigmas) + + def test_sigma_table_fallback_live(self): + live = _ms("LiveSampling", "CONST") + live.sigmas = torch.linspace(1.0, 0.0, 11) + model = types.SimpleNamespace() + model.model = types.SimpleNamespace(model_sampling=live) + sigmas = effective_model_sampling(model).sigmas + assert sigmas.numel() == 11 From 5c51cbb63181e8c1a8438935389c6a0e66c9316c Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:17:07 +0300 Subject: [PATCH 5/9] fix: DyPE/SEGA schedule-patch decision resolves sampling through the patcher --- src/patch_utils.py | 22 ++++++-- tests/test_patch_utils_decision.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/test_patch_utils_decision.py diff --git a/src/patch_utils.py b/src/patch_utils.py index 6bbe41e..d7d555c 100644 --- a/src/patch_utils.py +++ b/src/patch_utils.py @@ -3,6 +3,8 @@ import math logger = logging.getLogger("ComfyUI-DyPE") from comfy import model_sampling + +from .effective_sampling import effective_model_sampling from comfy.model_patcher import ModelPatcher from .models.anima import PosEmbedAnima @@ -21,6 +23,18 @@ from .sega import compute_axis_spectral_profiles, compute_dynamic_spread, comput _DYPE_PARAMS_ATTR = "_comfyui_dype_params" +def _should_patch_schedule(m: ModelPatcher, is_qwen: bool, is_z_image: bool) -> bool: + """Whether the DyPE/SEGA noise-schedule patch applies to this model. + + Resolves the sampling through the patcher (v2.16.0): the live BaseModel + attribute is history-dependent under ComfyUI's object-patch lifecycle, so + a leaked ``*ModelSamplingFlux`` from a PREVIOUS run must not flip this + decision. Patch -> backup -> live (KSampler semantics). + """ + return (isinstance(effective_model_sampling(m), model_sampling.ModelSamplingFlux) + or is_qwen or is_z_image) + + def _snap_to_multiple(value: int, multiple: int = 16) -> int: """Round value to the nearest multiple (minimum = multiple).""" snapped = max(multiple, round(value / multiple) * multiple) @@ -143,7 +157,7 @@ def apply_dype_to_model(model: ModelPatcher, model_type: str, width: int, height if enable_dype and should_patch_schedule and not is_anima: try: - if isinstance(m.model.model_sampling, model_sampling.ModelSamplingFlux) or is_qwen or is_z_image: + if _should_patch_schedule(m, is_qwen, is_z_image): latent_h, latent_w = height // 8, width // 8 padded_h, padded_w = math.ceil(latent_h / patch_size) * patch_size, math.ceil(latent_w / patch_size) * patch_size image_seq_len = (padded_h // patch_size) * (padded_w // patch_size) @@ -257,7 +271,7 @@ def apply_dype_to_model(model: ModelPatcher, model_type: str, width: int, height zimage_freq_scale_factor = max(1.0, 1.0 / iso_scale) logger.debug(f"DyPE Z-Image: scale hint = {zimage_freq_scale_factor:.4f} (iso_scale={iso_scale:.4f})") - sigma_max = m.model.model_sampling.sigma_max.item() + sigma_max = effective_model_sampling(m).sigma_max.item() def dype_wrapper_function(model_function, args_dict): timestep_tensor = args_dict.get("timestep") @@ -334,7 +348,7 @@ def apply_sega_to_model( # --- Noise schedule patching (same as DyPE, except Anima) --- if not is_anima: try: - if isinstance(m.model.model_sampling, model_sampling.ModelSamplingFlux) or is_qwen or is_z_image: + if _should_patch_schedule(m, is_qwen, is_z_image): latent_h, latent_w = height // 8, width // 8 padded_h, padded_w = math.ceil(latent_h / patch_size) * patch_size, math.ceil(latent_w / patch_size) * patch_size image_seq_len = (padded_h // patch_size) * (padded_w // patch_size) @@ -455,7 +469,7 @@ def apply_sega_to_model( else: zimage_freq_scale_factor = 1.0 - sigma_max = m.model.model_sampling.sigma_max.item() + sigma_max = effective_model_sampling(m).sigma_max.item() # --- SEGA wrapper: computes spectral profiles from latent at each step --- def sega_wrapper_function(model_function, args_dict): diff --git a/tests/test_patch_utils_decision.py b/tests/test_patch_utils_decision.py new file mode 100644 index 0000000..0e37f04 --- /dev/null +++ b/tests/test_patch_utils_decision.py @@ -0,0 +1,82 @@ +"""Schedule-patch decision hygiene (v2.16.0, plan S5). + +_should_patch_schedule must resolve the sampling through the patcher +(patch -> backup -> live) so a leaked *ModelSamplingFlux from a previous run +cannot flip the decision. Characterization: clean-state outcomes are pinned; +history-independence: leak/backup states give the same answer as clean. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from comfy import model_sampling as comfy_ms # noqa: E402 +from src.patch_utils import _should_patch_schedule # noqa: E402 + + +class _Patcher: + def __init__(self, live, patches=None, backup=None): + self.model = types.SimpleNamespace(model_sampling=live) + self.object_patches = dict(patches or {}) + self.object_patches_backup = dict(backup or {}) + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + +def _ms(name, *bases): + return type(name, bases or (object,), {})() + + +FluxLike = comfy_ms.ModelSamplingFlux +ContFlow = type("ModelSamplingContinuousFlow", (), {}) + + +@pytest.mark.unit +class TestShouldPatchSchedule: + @pytest.mark.parametrize("flags", [(False, False), (True, False), + (False, True), (True, True)]) + def test_characterization_flux_sampling(self, flags): + # Clean FLUX-style sampling: decision follows the isinstance only. + patcher = _Patcher(_ms("MS", FluxLike)) + assert _should_patch_schedule(patcher, *flags) is True + + def test_characterization_continuous_flow_not_patched(self): + patcher = _Patcher(_ms("MS", ContFlow)) + assert _should_patch_schedule(patcher, False, False) is False + + def test_characterization_qwen_and_zimage_force_patch(self): + patcher = _Patcher(_ms("MS", ContFlow)) + assert _should_patch_schedule(patcher, True, False) is True + assert _should_patch_schedule(patcher, False, True) is True + + def test_leak_does_not_flip_continuous_flow_decision(self): + # A leaked Flux-style sampling on the live attr (empty backup, no + # patch) is the documented KSampler-consistent residual: it IS what + # resolution returns here, so the decision follows it — pinned so a + # future change of this trade-off is explicit. + patcher = _Patcher(_ms("Leaked", FluxLike)) + assert _should_patch_schedule(patcher, False, False) is True + + def test_own_patch_decides_over_leaked_live(self): + # Graph carries a Flux-style patch over a continuous-flow live attr. + patcher = _Patcher(_ms("Live", ContFlow), + patches={"model_sampling": _ms("P", FluxLike)}) + assert _should_patch_schedule(patcher, False, False) is True + + def test_backup_original_decides_over_leaked_live(self): + # Unpatched patcher whose lineage backup holds the ORIGINAL + # continuous-flow sampler: the leak must not flip the decision. + patcher = _Patcher(_ms("Leaked", FluxLike), + backup={"model_sampling": _ms("Orig", ContFlow)}) + assert _should_patch_schedule(patcher, False, False) is False From 388507f8fcd3d9600e5e29d11e8c234e180f7de6 Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:18:35 +0300 Subject: [PATCH 6/9] feat: stale-leak warning in HiFlow/PixelRush for inherited model_sampling patches --- nodes/hiflow.py | 5 +-- nodes/pixelrush.py | 6 ++-- src/effective_sampling.py | 19 ++++++++++ tests/test_effective_sampling_nodes.py | 50 ++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/nodes/hiflow.py b/nodes/hiflow.py index 1cb83bf..2f6cb36 100644 --- a/nodes/hiflow.py +++ b/nodes/hiflow.py @@ -19,11 +19,11 @@ import torch from comfy_api.latest import io try: - from ..src.effective_sampling import effective_model_sampling + from ..src.effective_sampling import effective_model_sampling, warn_if_stale_leak from ..src.freescale import gaussian_blur_2d from ..src.hiflow import HiFlowConfig, hiflow_cascade except ImportError: # flat repo layout (tests / CLI) - from src.effective_sampling import effective_model_sampling + from src.effective_sampling import effective_model_sampling, warn_if_stale_leak from src.freescale import gaussian_blur_2d from src.hiflow import HiFlowConfig, hiflow_cascade @@ -437,6 +437,7 @@ class HiFlowNode(io.ComfyNode): # models (Wan21: Krea2, Qwen-Image) pass, multi-frame latents # don't (Krea2 plan S2). _, latent_dimensions = _require_flow_model(model) + warn_if_stale_leak(model, "HiFlow") if isinstance(latent_image, dict): initial_latent = latent_image["samples"] diff --git a/nodes/pixelrush.py b/nodes/pixelrush.py index 54546cc..852cb40 100644 --- a/nodes/pixelrush.py +++ b/nodes/pixelrush.py @@ -14,10 +14,10 @@ import torch from comfy_api.latest import io try: - from ..src.effective_sampling import effective_model_sampling + from ..src.effective_sampling import effective_model_sampling, warn_if_stale_leak from ..src.pixelrush import PixelRushConfig, pixelrush_cascade except ImportError: - from src.effective_sampling import effective_model_sampling + from src.effective_sampling import effective_model_sampling, warn_if_stale_leak from src.pixelrush import PixelRushConfig, pixelrush_cascade logger = logging.getLogger("ComfyUI-DyPE") @@ -576,6 +576,8 @@ class PixelRushNode(io.ComfyNode): patch_h=0, patch_w=0, refiner_model=None) -> io.NodeOutput: import comfy.utils + warn_if_stale_leak(model, "PixelRush") + # Get initial latent if isinstance(latent_image, dict): initial_latent = latent_image["samples"] diff --git a/src/effective_sampling.py b/src/effective_sampling.py index b9d98c2..370bbba 100644 --- a/src/effective_sampling.py +++ b/src/effective_sampling.py @@ -29,6 +29,8 @@ from a PREVIOUS run's patch node that is no longer in the graph. from __future__ import annotations +import logging + # Function-local classes installed by apply_dype_to_model / apply_sega_to_model # (src/patch_utils.py). Matched by __name__: the classes are defined inside the # installer functions, so identity comparison across imports is impossible. @@ -71,3 +73,20 @@ def is_stale_dype_leak(model) -> bool: return False live = getattr(getattr(model, "model", None), "model_sampling", None) return type(live).__name__ in STALE_LEAK_CLASS_NAMES + + +def warn_if_stale_leak(model, node_name: str) -> None: + """User-facing signal for the un-healable residual (plan S6). + + Fired by the direct-sampling nodes when the resolved schedule is a stale + patch inherited from a run that is no longer in the graph — the exact + "identical params, different results until caches are cleared" report. + """ + if is_stale_dype_leak(model): + live = getattr(getattr(model, "model", None), "model_sampling", None) + logging.getLogger("ComfyUI-DyPE").warning( + "%s: model_sampling is a stale patch from a previous run (%s) — " + "schedules may not match this workflow. Reload the models (clear " + "cache) or add the patch node to this graph.", + node_name, type(live).__name__, + ) diff --git a/tests/test_effective_sampling_nodes.py b/tests/test_effective_sampling_nodes.py index 25e9c07..ef25eb0 100644 --- a/tests/test_effective_sampling_nodes.py +++ b/tests/test_effective_sampling_nodes.py @@ -83,3 +83,53 @@ class TestFreeScaleResolvedSigmas: model.model = types.SimpleNamespace(model_sampling=live) sigmas = effective_model_sampling(model).sigmas assert sigmas.numel() == 11 + + +@pytest.mark.unit +class TestStaleLeakWarning: + """S6: the direct-sampling nodes warn (not silently drift) when the + resolved schedule is a stale patch from a run no longer in the graph.""" + + class _FakePatcher: + def __init__(self, live, patches=None, backup=None): + self.model = types.SimpleNamespace(model_sampling=live) + self.object_patches = dict(patches or {}) + self.object_patches_backup = dict(backup or {}) + + def get_model_object(self, name): + if name in self.object_patches: + return self.object_patches[name] + if name in self.object_patches_backup: + return self.object_patches_backup[name] + return getattr(self.model, name) + + def _leak_patcher(self): + leak = type("DypeModelSamplingFlux", (), {})() + return self._FakePatcher(leak), leak + + def test_hiflow_helper_warns_on_stale_leak(self, caplog): + from src.effective_sampling import warn_if_stale_leak + patcher, leak = self._leak_patcher() + with caplog.at_level("WARNING", logger="ComfyUI-DyPE"): + warn_if_stale_leak(patcher, "HiFlow") + assert any("stale patch from a previous run" in r.message + for r in caplog.records) + assert any("DypeModelSamplingFlux" in r.message + for r in caplog.records) + + def test_no_warning_on_clean_patcher(self, caplog): + from src.effective_sampling import warn_if_stale_leak + patcher = self._FakePatcher( + type("ModelSamplingContinuousFlow", (), {})()) + with caplog.at_level("WARNING", logger="ComfyUI-DyPE"): + warn_if_stale_leak(patcher, "HiFlow") + assert not caplog.records + + def test_no_warning_when_own_patch_present(self, caplog): + from src.effective_sampling import warn_if_stale_leak + patch_ms = type("DypeModelSamplingFlux", (), {})() + patcher = self._FakePatcher( + patch_ms, patches={"model_sampling": patch_ms}) + with caplog.at_level("WARNING", logger="ComfyUI-DyPE"): + warn_if_stale_leak(patcher, "PixelRush") + assert not caplog.records From fb8fef8910db1505ee53bc3230c47b3cabf3d36b Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:31:17 +0300 Subject: [PATCH 7/9] chore: remove HiFlow FLUX example workflow + its test --- example_workflows/HiFlow-Flux-workflow.json | 124 -------------------- tests/test_hiflow_node.py | 16 --- 2 files changed, 140 deletions(-) delete mode 100644 example_workflows/HiFlow-Flux-workflow.json diff --git a/example_workflows/HiFlow-Flux-workflow.json b/example_workflows/HiFlow-Flux-workflow.json deleted file mode 100644 index 4c0edee..0000000 --- a/example_workflows/HiFlow-Flux-workflow.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "1": { - "class_type": "UNETLoader", - "_meta": { - "title": "Load FLUX" - } - }, - "2": { - "class_type": "DualCLIPLoader", - "_meta": { - "title": "Load CLIP (T5 + CLIP-L)" - } - }, - "3": { - "class_type": "VAELoader", - "_meta": { - "title": "Load VAE" - } - }, - "4": { - "class_type": "CLIPTextEncode", - "_meta": { - "title": "Positive Prompt" - }, - "inputs": { - "text": "a serene mountain lake at dawn, mist over the water, photorealistic", - "clip": [ - "2", - 0 - ] - } - }, - "5": { - "class_type": "CLIPTextEncode", - "_meta": { - "title": "Negative Prompt" - }, - "inputs": { - "text": "", - "clip": [ - "2", - 0 - ] - } - }, - "6": { - "class_type": "EmptySD3LatentImage", - "_meta": { - "title": "Base Latent (1024x1024)" - }, - "inputs": { - "width": 1024, - "height": 1024, - "batch_size": 1 - } - }, - "7": { - "class_type": "HiFlow", - "_meta": { - "title": "HiFlow \u2014 trajectory-guided 2K upscale" - }, - "inputs": { - "model": [ - "1", - 0 - ], - "vae": [ - "3", - 0 - ], - "positive": [ - "4", - 0 - ], - "negative": [ - "5", - 0 - ], - "latent_image": [ - "6", - 0 - ], - "cfg": 3.5, - "steps": 30, - "guidance": 4.5, - "steps_per_stage": 16, - "tau": 0.6, - "filter_ratio": 0.2, - "alpha_scale": 1.0, - "beta_scale": 0.5, - "upsampling": "latent", - "scale_factor": 2.0 - } - }, - "8": { - "class_type": "VAEDecode", - "_meta": { - "title": "Decode" - }, - "inputs": { - "samples": [ - "7", - 0 - ], - "vae": [ - "3", - 0 - ] - } - }, - "9": { - "class_type": "SaveImage", - "_meta": { - "title": "Save" - }, - "inputs": { - "filename_prefix": "HiFlow", - "images": [ - "8", - 0 - ] - } - } -} \ No newline at end of file diff --git a/tests/test_hiflow_node.py b/tests/test_hiflow_node.py index 7692245..993a5f3 100644 --- a/tests/test_hiflow_node.py +++ b/tests/test_hiflow_node.py @@ -985,22 +985,6 @@ class TestHiFlowDocs: assert m and m.group(1) == "2.15.0" assert "### v2.15.0" in readme - def test_workflow_json_parses_and_uses_known_nodes(self): - import json - import pathlib - wf_path = (pathlib.Path(__file__).parent.parent - / "example_workflows" / "HiFlow-Flux-workflow.json") - data = json.loads(wf_path.read_text(encoding="utf-8")) - types = set() - for v in data.values(): - if isinstance(v, dict) and "class_type" in v: - types.add(v["class_type"]) - core = {"UNETLoader", "DualCLIPLoader", "VAELoader", "CLIPTextEncode", - "EmptySD3LatentImage", "VAEDecode", "SaveImage"} - unknown = types - core - {"HiFlow"} - assert not unknown, f"workflow references unknown nodes: {unknown}" - assert "HiFlow" in types - # --------------------------------------------------------------------------- # Effective-sampling determinism (v2.16.0, plan S2) From 3993f2192bfa50aa21bbc85e387ac7a28f8e87c0 Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:33:10 +0300 Subject: [PATCH 8/9] feat: HiFlow sharpen input (0 disables unsharp) + positional-patch warning --- nodes/hiflow.py | 39 +++++++++++++++- tests/test_hiflow_node.py | 94 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/nodes/hiflow.py b/nodes/hiflow.py index 2f6cb36..cc1c204 100644 --- a/nodes/hiflow.py +++ b/nodes/hiflow.py @@ -268,7 +268,14 @@ def _sharpen(image: torch.Tensor, alpha: float = 1.0) -> torch.Tensor: decode output ([B,T,H,W,3], 3D-format VAEs) is sliced to its first frame first — the adapters normally hand 4D, this is the defensive backstop (Krea2 plan S4). + + ``alpha`` is the node's ``sharpen`` input (v2.16.0): 1.0 keeps the + reference behavior; values <= 0 disable the unsharp entirely (return + the image unchanged — recommended for turbo/low-step models that show + jagged, over-sharpened tone boundaries). """ + if alpha <= 0.0: + return image if image.dim() == 5: image = image[:, 0] channels_last = image.dim() == 4 and image.shape[-1] == 3 @@ -401,6 +408,13 @@ class HiFlowNode(io.ComfyNode): "(repo default) or pixel decode->sharpen->encode. " "The stage-initialization anchor is always the " "pixel round-trip of the previous final image."), + io.Float.Input( + "sharpen", default=1.0, min=0.0, max=3.0, step=0.05, + tooltip="Unsharp strength applied to the pixel " + "round-tripped stage anchor (reference default " + "1.0). Set 0 to disable — recommended for " + "turbo/low-step models that show jagged, " + "over-sharpened tone boundaries."), io.Float.Input( "scale_factor", default=2.0, min=0.25, max=8.0, step=0.05, @@ -429,7 +443,7 @@ class HiFlowNode(io.ComfyNode): def execute(cls, model, vae, positive, negative, latent_image, cfg=3.5, steps=30, guidance=4.5, steps_per_stage=16, tau=0.6, filter_ratio=0.2, alpha_scale=1.0, beta_scale=0.5, - upsampling="latent", scale_factor=2.0, + upsampling="latent", scale_factor=2.0, sharpen=1.0, noise_seed=0, denoise=1.0) -> io.NodeOutput: import comfy.utils @@ -566,6 +580,27 @@ class HiFlowNode(io.ComfyNode): initial_latent.shape[-2], initial_latent.shape[-1], float(scale_factor), _downscale_ratio(vae), ) + if sizes: + vae_ratio = _downscale_ratio(vae) + base_px = max(initial_latent.shape[-2], initial_latent.shape[-1]) * vae_ratio + target_px = max(max(t_h, t_w) for t_h, t_w in sizes) * vae_ratio + positional_keys = ( + "diffusion_model.pe_embedder", + "diffusion_model.rope_embedder", + "diffusion_model.pos_embedder", + "diffusion_model.model.pos_embed", + ) + patcher_patches = getattr(model, "object_patches", None) or {} + if target_px > base_px * 1.01 and not any( + k in patcher_patches for k in positional_keys): + logger.warning( + "HiFlow: upscaling to ~%dpx (base %dpx) without a " + "positional-embedding patch — aliasing and jagged, " + "over-sharpened tone boundaries are likely at " + "resolutions far beyond the model's training size. " + "Consider chaining DyPE before this node.", + target_px, base_px, + ) total = max(1, len(base_sigmas) - 1 + len(sizes) * int(steps_per_stage)) pbar = comfy.utils.ProgressBar(total) counter = {"n": 0} @@ -583,7 +618,7 @@ class HiFlowNode(io.ComfyNode): cfg=cfg_obj, vae_decode=vae_decode, vae_encode=vae_encode, - sharpen=_sharpen, + sharpen=lambda image: _sharpen(image, alpha=float(sharpen)), vae_downscale=_downscale_ratio(vae), progress_callback=progress_callback, noise_seed=int(noise_seed), diff --git a/tests/test_hiflow_node.py b/tests/test_hiflow_node.py index 993a5f3..0a8fcfe 100644 --- a/tests/test_hiflow_node.py +++ b/tests/test_hiflow_node.py @@ -913,7 +913,7 @@ class TestHiFlowNodeSchema: for inp in ["model", "vae", "positive", "negative", "latent_image", "cfg", "steps", "guidance", "steps_per_stage", "tau", "filter_ratio", "alpha_scale", "beta_scale", "upsampling", - "scale_factor"]: + "scale_factor", "sharpen"]: assert f'"{inp}"' in src, f"missing schema input {inp}" assert "default=3.5" in src # cfg (FLUX-dev) assert "default=30" in src # steps (paper) @@ -922,6 +922,7 @@ class TestHiFlowNodeSchema: assert "default=0.6" in src # tau (paper 1K->2K) assert "default=0.2" in src # filter_ratio (repo) assert 'default="latent"' in src + assert "default=1.0, min=0.0, max=3.0" in src # sharpen (v2.16.0) def test_schema_execute_signature_matches(self): import re @@ -1111,3 +1112,94 @@ class TestEffectiveSamplingDeterminism: "timestep must come from the patch object (0.5 * 2000), not the " "leaked live attr (0.5 * 1000)" ) + + +# --------------------------------------------------------------------------- +# Sharpen control + positional-patch warning (v2.16.0, plan S7) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestSharpenControl: + def _two_tone(self, h=32, w=32): + img = torch.zeros(1, h, w, 3) + img[:, :, w // 2:, :] = 1.0 # hard vertical tone boundary + return img + + @staticmethod + def _overshoot(out, w=32): + # Positive excursion beyond the high tone along the boundary column. + return float(out[:, :, w // 2 + 1, :].max() - 1.0) + + def test_default_alpha_matches_reference(self): + img = self._two_tone() + assert torch.allclose(hfn._sharpen(img), hfn._sharpen(img, alpha=1.0)) + + def test_alpha_zero_disables_unsharp(self): + img = self._two_tone() + out = hfn._sharpen(img, alpha=0.0) + assert torch.equal(out, img), "sharpen=0 must return the image unchanged" + + def test_alpha_zero_preserves_5d_backstop_layout(self): + img5 = torch.zeros(1, 1, 8, 8, 3) + out = hfn._sharpen(img5, alpha=0.0) + assert out.shape == img5.shape + + def test_default_sharpen_creates_boundary_overshoot(self): + """Quantifies issue 2, factor 2: the reference unsharp pushes the + high tone ABOVE its level at the boundary (over-sharpened rims).""" + img = self._two_tone() + assert self._overshoot(hfn._sharpen(img, alpha=1.0)) > 0.05 + assert self._overshoot(hfn._sharpen(img, alpha=0.0)) == 0.0 + + +@pytest.mark.unit +class TestPositionalPatchWarning: + def _run(self, monkeypatch, scale=2.0, positional=None): + FakePBar = _install_fake_pbar_utils(monkeypatch) + _install_fake_comfy(monkeypatch) + fake_samplers = sys.modules["comfy.samplers"] + fake_samplers.calculate_sigmas = ( + lambda ms, scheduler, steps: + torch.cat([torch.linspace(1.0, 0.1, steps), torch.zeros(1)]) + ) + model = _mock_flow_model() + if positional: + model.object_patches = positional + vae = types.SimpleNamespace( + decode=lambda z: torch.randn( + z.shape[0], z.shape[-2] * 8, z.shape[-1] * 8, 3), + encode=lambda im: {"samples": torch.randn( + 1, 16, im.shape[-3] // 8, im.shape[-2] // 8)}, + downscale_ratio=8, + ) + z = torch.randn(1, 16, 16, 16) + hfn.HiFlowNode.execute( + model, vae, COND_POS, COND_NEG, {"samples": z}, + cfg=3.5, steps=4, guidance=4.5, steps_per_stage=2, + tau=0.5, filter_ratio=0.2, alpha_scale=1.0, beta_scale=0.5, + upsampling="latent", scale_factor=scale, sharpen=0.0, + noise_seed=0, denoise=1.0, + ) + + def test_warns_without_positional_patch(self, monkeypatch, caplog): + import logging + with caplog.at_level(logging.WARNING, logger="ComfyUI-DyPE"): + self._run(monkeypatch, scale=2.0) + assert any("positional-embedding patch" in r.message + for r in caplog.records) + + def test_silent_with_dype_patcher(self, monkeypatch, caplog): + import logging + with caplog.at_level(logging.WARNING, logger="ComfyUI-DyPE"): + self._run( + monkeypatch, scale=2.0, + positional={"diffusion_model.pe_embedder": object()}) + assert not any("positional-embedding patch" in r.message + for r in caplog.records) + + def test_silent_at_scale_one(self, monkeypatch, caplog): + import logging + with caplog.at_level(logging.WARNING, logger="ComfyUI-DyPE"): + self._run(monkeypatch, scale=1.0) + assert not any("positional-embedding patch" in r.message + for r in caplog.records) From a68c389df744e199bce92881eb09c05aa728c394 Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:33:53 +0300 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20v2.16.0=20=E2=80=94=20schedule=20de?= =?UTF-8?q?terminism=20fix,=20HiFlow=20sharpen=20input,=20positional=20war?= =?UTF-8?q?ning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++++ pyproject.toml | 2 +- tests/test_hiflow_node.py | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e0d0886..3139b02 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,11 @@ Restart ComfyUI. No further dependency installation is required. ## ▓ Changelog +### v2.16.0 — 2026-09-17 +- **Fixed run-to-run result drift** (user-reported: identical parameters produced different results with Krea 2 turbo unless model and node caches were cleared first). HiFlow, PixelRush, and FreeScale now derive their sigma schedules and timestep conversions from the graph's **own model patch** instead of the shared model's live state, which ComfyUI can leave patched by a previous run's node combination. The DyPE/SEGA schedule-patch decision is equally history-independent, and HiFlow/PixelRush log a console warning when a stale patch from a previous run is detected. +- **HiFlow: new `sharpen` input** (default `1.0` = previous behavior). Controls the unsharp mask applied to the pixel round-tripped stage anchor; set `0` to disable — recommended for turbo/low-step models that show jagged, over-sharpened tone boundaries. +- **HiFlow warns** when upscaling far beyond the base resolution without a positional-embedding patch (jagged aliasing is likely there — chain DyPE for >2× upscales). + ### v2.15.0 — 2026-09-08 - **Restructured the pack layout + unified the node category.** All node definitions now live in a dedicated `nodes/` folder (`nodes/dype.py`, `sega.py`, `spa.py`, `hap.py`, `hap_calibrate.py`, `freescale.py`, `pixelrush.py`, `hiflow.py`); `src/` holds engines/implementation only and the pack `__init__.py` just registers the extension. All 8 nodes moved to the single **`WMNodes/image`** menu category (previously split across two menu paths). No node ids, inputs, defaults, or behavior changed — workflows keep loading. Also merges PR #41 (FreeScale fp16 antialiased-bicubic crash fix). diff --git a/pyproject.toml b/pyproject.toml index be45b24..c2727c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-DyPE" description = "DyPE & SEGA & PixelRush & HRDiT. Artifact-free 4K+ image generation. Flux, Nunchaku, Qwen, Z-Image, Anima, Krea-2, SDXL, SD1.5" -version = "2.15.0" +version = "2.16.0" requires-python = ">=3.10" license = {file = "LICENSE"} dependencies = ["torch", "numpy"] diff --git a/tests/test_hiflow_node.py b/tests/test_hiflow_node.py index 0a8fcfe..b1442d2 100644 --- a/tests/test_hiflow_node.py +++ b/tests/test_hiflow_node.py @@ -983,8 +983,8 @@ class TestHiFlowDocs: readme = (pathlib.Path(__file__).parent.parent / "README.md").read_text(encoding="utf-8") m = re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE) - assert m and m.group(1) == "2.15.0" - assert "### v2.15.0" in readme + assert m and m.group(1) == "2.16.0" + assert "### v2.16.0" in readme # --------------------------------------------------------------------------- @@ -1155,7 +1155,7 @@ class TestSharpenControl: @pytest.mark.unit class TestPositionalPatchWarning: def _run(self, monkeypatch, scale=2.0, positional=None): - FakePBar = _install_fake_pbar_utils(monkeypatch) + _install_fake_pbar_utils(monkeypatch) _install_fake_comfy(monkeypatch) fake_samplers = sys.modules["comfy.samplers"] fake_samplers.calculate_sigmas = (