Files
olivv-cs-ComfyUI-FunPack/tests/test_distilled_flow_ancestral.py
T
digital-garbage e966b0ab19 fix(sampler): Distilled Flow's s_noise is rectified-flow ancestral now
It stepped the full distance to sigma_next and then added
sqrt(sigma^2 - sigma_next^2) of noise on top. That is the VP variance
formula, and adding noise without shortening the step leaves the latent
noisier at sigma_next than the schedule defines — so s_noise never behaved
like euler_ancestral on H3 or LTXAV at any value.

Lands short (sigma_down) and renoises with flow matching's alpha rescaling:
the arithmetic _sample_const_rf_full already carried, and comfy's
sample_euler_ancestral_RF. At s_noise=1.0 it IS euler_ancestral.

s_noise now means eta (fraction of the step taken stochastically); range
raised to 1.0 so euler_ancestral is expressible. A value carried over from
before does NOT mean what it did — the tooltip says so. Non-CONST models
keep the eps path unchanged. Audio still never gets ancestral noise.
2026-08-25 17:53:45 +03:00

127 lines
5.4 KiB
Python

"""Distilled Flow's ancestral noise is rectified-flow correct on H3 and LTXAV.
`s_noise` used to take the FULL deterministic step to sigma_next and then add
sqrt(sigma^2 - sigma_next^2) of noise on top. Both halves are wrong for a flow model: that
variance formula is the VP/eps one, and adding noise without shortening the step leaves the
latent noisier at sigma_next than the schedule says it should be. So `s_noise` never behaved
like `euler_ancestral` at any value, and the sampler could not reproduce the result the stock
ancestral sampler gives on H3.
The correct step lands SHORT (sigma_down) and renoises with flow matching's alpha rescaling —
the same arithmetic `_sample_const_rf_full` already used, and the same as comfy's
`sample_euler_ancestral_RF`.
"""
import inspect
import sys
import types
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import samplers
def rf_step(x, denoised, sigma, sigma_next, eta, noise):
"""The formulation now in the sampler, transcribed for arithmetic checks."""
downstep_ratio = 1 + (sigma_next / sigma - 1) * eta
sigma_down = sigma_next * downstep_ratio
alpha_ip1, alpha_down = 1 - sigma_next, 1 - sigma_down
ratio = sigma_down / sigma
x_anc = ratio * x + (1 - ratio) * denoised
renoise = (sigma_next ** 2 - sigma_down ** 2 * alpha_ip1 ** 2 / alpha_down ** 2) ** 0.5
return (alpha_ip1 / alpha_down) * x_anc + noise * renoise
def test_the_sampler_uses_the_rf_arithmetic():
src = inspect.getsource(samplers.sample_funpack_distilled_flow)
for token in ("downstep_ratio = 1 + (sigma_next / sigma - 1) * s_noise",
"sigma_down = sigma_next * downstep_ratio",
"alpha_ip1 = 1 - sigma_next",
"renoise = (sigma_next ** 2"):
assert token in src
def test_it_matches_the_hybrid_samplers_existing_rf_step():
"""The RF step already existed for CONST models — this is the same arithmetic, not a
second opinion about it."""
hybrid = inspect.getsource(samplers._sample_const_rf_full)
flow = inspect.getsource(samplers.sample_funpack_distilled_flow)
for token in ("downstep_ratio = 1 + (sigma_next / sigma - 1)",
"sigma_down = sigma_next * downstep_ratio",
"alpha_ip1 = 1 - sigma_next"):
assert token in hybrid and token in flow
def test_eta_zero_is_the_deterministic_step():
"""No stochasticity means the ancestral branch must not run at all."""
x, den, s, sn = 3.0, 1.0, 0.8, 0.5
assert rf_step(x, den, s, sn, 0.0, 999.0) == pytest.approx(sn / s * x + (1 - sn / s) * den)
def test_eta_one_converts_the_whole_step():
"""At eta=1 sigma_down collapses to sigma_next^2/sigma — comfy's euler_ancestral_RF."""
s, sn = 0.8, 0.5
downstep_ratio = 1 + (sn / s - 1) * 1.0
assert sn * downstep_ratio == pytest.approx(sn * sn / s)
def test_more_eta_lands_shorter():
"""The point of the fix: noise REPLACES deterministic progress rather than adding to it."""
s, sn = 0.8, 0.5
downs = [sn * (1 + (sn / s - 1) * e) for e in (0.0, 0.25, 0.5, 1.0)]
assert downs == sorted(downs, reverse=True)
assert downs[0] == pytest.approx(sn) # eta=0 lands exactly on sigma_next
def test_the_old_formula_overshot_the_schedule():
"""Why it never matched: the old path kept the full step AND added noise, so the latent
carried more variance at sigma_next than the schedule defines."""
s, sn, eta = 0.8, 0.5, 0.15
old_surplus = eta * (s ** 2 - sn ** 2) ** 0.5
assert old_surplus > 0 # added on top of an already-complete step
new_down = sn * (1 + (sn / s - 1) * eta)
assert new_down < sn # the new one gives ground back first
def test_audio_never_gets_ancestral_noise():
src = inspect.getsource(samplers.sample_funpack_distilled_flow)
assert "x = _video_only(x_anc, x_det, video_mask)" in src
def test_the_eps_path_is_untouched_for_non_flow_models():
"""LTXV and anything else that is not CONST keeps the behaviour it was tuned with."""
src = inspect.getsource(samplers.sample_funpack_distilled_flow)
assert "elif s_noise > 0.0 and float(sigma_next) > 0:" in src
assert "sigma_up = math.sqrt(max(0.0, float(sigma.item()) ** 2" in src
def test_const_detection_never_raises():
assert samplers._rf_ancestral(object()) is False
assert samplers._rf_ancestral(None) is False
def test_const_detection_finds_a_flow_model(monkeypatch):
"""The suite stubs comfy.model_sampling, which has no CONST — so install one. That the
stub lacks it is itself covered: the detector must return False, not raise."""
import comfy.model_sampling as msmod
class CONST: pass
monkeypatch.setattr(msmod, "CONST", CONST, raising=False)
class Flow(CONST): pass
model = types.SimpleNamespace(
inner_model=types.SimpleNamespace(
inner_model=types.SimpleNamespace(model_sampling=Flow())))
assert samplers._rf_ancestral(model) is True
other = types.SimpleNamespace(
inner_model=types.SimpleNamespace(
inner_model=types.SimpleNamespace(model_sampling=object())))
assert samplers._rf_ancestral(other) is False
def test_the_range_reaches_one_so_euler_ancestral_is_expressible():
w = samplers.FunPackDistilledFlowSampler.INPUT_TYPES()["required"]["s_noise"][1]
assert w["max"] == 1.0
assert w["default"] == 0.0
assert "BEHAVIOUR CHANGED" in w["tooltip"]