merge: v2.16.0 — patcher-resolved model_sampling (HiFlow/PixelRush/FreeScale/DyPE/SEGA), stale-leak + positional warnings, HiFlow sharpen input

This commit is contained in:
WildAi
2026-09-25 14:43:52 +03:00
13 changed files with 1078 additions and 162 deletions
+5
View File
@@ -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).
-124
View File
@@ -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
]
}
}
}
+3 -1
View File
@@ -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:
+53 -5
View File
@@ -19,9 +19,11 @@ import torch
from comfy_api.latest import io
try:
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, warn_if_stale_leak
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,
@@ -259,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
@@ -277,10 +293,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()
@@ -389,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,
@@ -417,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
@@ -425,6 +451,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"]
@@ -553,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}
@@ -570,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),
+13 -9
View File
@@ -14,8 +14,10 @@ import torch
from comfy_api.latest import io
try:
from ..src.effective_sampling import effective_model_sampling, warn_if_stale_leak
from ..src.pixelrush import PixelRushConfig, pixelrush_cascade
except ImportError: # flat repo layout (tests / CLI)
except ImportError:
from src.effective_sampling import effective_model_sampling, warn_if_stale_leak
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
@@ -574,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"]
+1 -1
View File
@@ -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"]
+92
View File
@@ -0,0 +1,92 @@
"""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
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.
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
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__,
)
+18 -4
View File
@@ -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):
+329
View File
@@ -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)
+126
View File
@@ -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
+135
View File
@@ -0,0 +1,135 @@
"""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
@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
+221 -18
View File
@@ -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
@@ -982,21 +983,223 @@ 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
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)
# ---------------------------------------------------------------------------
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)"
)
# ---------------------------------------------------------------------------
# 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):
_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)
+82
View File
@@ -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