336 lines
12 KiB
Python
336 lines
12 KiB
Python
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
|
# Copyright (C) 2026 Artificial Sweetener and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Verify capability execution routing, order independence and early rejection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from itertools import permutations, product
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import torch
|
|
from comfy.model_sampling import CONST
|
|
|
|
from simple_syrup.domain.conditioning_batch import ConditioningBatch
|
|
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
|
|
from simple_syrup.domain.sampler_options import (
|
|
AttentionCouplingOptions,
|
|
ContextualDiffusionOptions,
|
|
SamplerCapability,
|
|
SamplerOptions,
|
|
TilingOptions,
|
|
)
|
|
from simple_syrup.nodes_v3.contextual_diffusion_options import (
|
|
ContextualDiffusionOptionsV3,
|
|
)
|
|
from simple_syrup.nodes_v3.tiling_options import TilingOptionsV3
|
|
from simple_syrup.runtime import sampling_schedulers
|
|
from simple_syrup.services import sampler_options_sampling_service as routing
|
|
|
|
|
|
@pytest.fixture
|
|
def routed_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict[str, Any]]]:
|
|
"""Record sampling delegates while exercising the real configuration compiler."""
|
|
calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def boundary(label: str) -> type:
|
|
"""Represent one expensive application sampling boundary."""
|
|
|
|
class Recorder:
|
|
"""Keep neural execution outside configuration-routing unit tests."""
|
|
|
|
def sample(self, **kwargs: Any) -> Any:
|
|
"""Retain complete routed values and the declared result shape."""
|
|
calls.append((label, kwargs))
|
|
result = kwargs["latent_image"]
|
|
if label.startswith("context"):
|
|
return SimpleNamespace(latent=result, contexts=())
|
|
return result
|
|
|
|
return Recorder
|
|
|
|
for attribute, label in (
|
|
("KSamplerSamplingService", "full"),
|
|
("AttentionCouplingSamplingService", "attention"),
|
|
("TiledDiffusionSamplingService", "tiled"),
|
|
("TiledAttentionCouplingSamplingService", "tiled_attention"),
|
|
("ContextualDiffusionSamplingService", "context"),
|
|
("ContextualAttentionCouplingSamplingService", "context_attention"),
|
|
):
|
|
monkeypatch.setattr(routing, attribute, boundary(label))
|
|
monkeypatch.setattr(
|
|
sampling_schedulers,
|
|
"calculate_sigmas",
|
|
lambda **kwargs: torch.tensor([0.5, 0.25, 0.0]),
|
|
)
|
|
return calls
|
|
|
|
|
|
def arguments() -> routing.SamplingArguments:
|
|
"""Use real flow-target validation with a small dynamic host MODEL boundary."""
|
|
flow = CONST()
|
|
flow.noise_scale = 1.0
|
|
return {
|
|
"model": SimpleNamespace(get_model_object=lambda name: flow),
|
|
"seed": 17,
|
|
"steps": 4,
|
|
"cfg": 3.0,
|
|
"sampler_name": "euler",
|
|
"scheduler": "simple",
|
|
"positive": [],
|
|
"negative": None,
|
|
"latent_image": {"samples": torch.zeros((1, 4, 64, 96))},
|
|
"denoise": 0.5,
|
|
}
|
|
|
|
|
|
def capabilities() -> tuple[SamplerCapability, ...]:
|
|
"""Keep local geometry independent from the reduced whole-scene context."""
|
|
return (
|
|
TilingOptions(width=64, height=48, overlap=8, batch_size=2),
|
|
ContextualDiffusionOptions(
|
|
context_size=32, overlap=8, global_weight=0.8, global_steps=3
|
|
),
|
|
NoiseInversionOptions(),
|
|
AttentionCouplingOptions(0.7, 2),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("enabled", tuple(product((False, True), repeat=4)))
|
|
@pytest.mark.parametrize("connected_segs", [None, object()])
|
|
@pytest.mark.parametrize("connected_masks", [None, object()])
|
|
def test_every_capability_combination_uses_one_sampling_authority(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
enabled: tuple[bool, bool, bool, bool],
|
|
connected_segs: object | None,
|
|
connected_masks: object | None,
|
|
) -> None:
|
|
"""Compose all 16 combinations without dropping configuration or negatives."""
|
|
options = SamplerOptions()
|
|
for capability, active in zip(capabilities(), enabled, strict=True):
|
|
if active:
|
|
options = options.with_capability(capability)
|
|
values = arguments()
|
|
if enabled[3] and connected_masks is not None:
|
|
values["positive"] = ConditioningBatch(([], []))
|
|
output = routing.SamplerOptionsSamplingService().sample(
|
|
**values, options=options, segs=connected_segs, region_masks=connected_masks
|
|
)
|
|
assert output is values["latent_image"]
|
|
assert len(routed_calls) == 1
|
|
tile, context, inversion, attention = enabled
|
|
attention = attention and connected_masks is not None
|
|
expected = (
|
|
("context_attention" if attention else "context")
|
|
if context
|
|
else ("tiled_attention" if attention else "tiled")
|
|
if tile
|
|
else "attention"
|
|
if attention
|
|
else "full"
|
|
)
|
|
label, forwarded = routed_calls[0]
|
|
assert label == expected
|
|
if tile or context:
|
|
assert forwarded["segs"] is connected_segs
|
|
else:
|
|
assert "segs" not in forwarded
|
|
assert forwarded["negative"] is None
|
|
assert forwarded["noise_inversion"] == (
|
|
NoiseInversionOptions() if inversion else None
|
|
)
|
|
assert forwarded["model"] is values["model"]
|
|
if context:
|
|
assert forwarded["latent_context_size"] == 32
|
|
assert forwarded["tiling"].width == 32
|
|
assert forwarded["tiling"].height == 32
|
|
elif tile:
|
|
assert forwarded["latent_tile_width"] == 64
|
|
assert forwarded["latent_tile_height"] == 48
|
|
if attention:
|
|
assert forwarded["region_masks"] is connected_masks
|
|
assert forwarded["regional_prompt_weight"] == 0.7
|
|
assert forwarded["region_mask_feather"] == 2
|
|
else:
|
|
assert "region_masks" not in forwarded
|
|
|
|
|
|
def test_all_connection_orders_compile_identical_execution(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
) -> None:
|
|
"""Require all 24 capability orders to reach the same service with exact values."""
|
|
values = arguments()
|
|
values["positive"] = ConditioningBatch(([], []))
|
|
masks = torch.ones((1, 64, 96))
|
|
for order in permutations(capabilities()):
|
|
options = SamplerOptions()
|
|
for capability in order:
|
|
options = options.with_capability(capability)
|
|
output = routing.SamplerOptionsSamplingService().sample(
|
|
**values, options=options, region_masks=masks
|
|
)
|
|
assert output is values["latent_image"]
|
|
assert routed_calls[-1] == routed_calls[0]
|
|
assert len(routed_calls) == 24
|
|
|
|
|
|
@pytest.mark.parametrize("options", [object(), {}, "MODEL"])
|
|
def test_foreign_options_fail_before_execution(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
options: Any,
|
|
) -> None:
|
|
"""Reject arbitrary socket payloads rather than interpreting them as controls."""
|
|
with pytest.raises(TypeError, match="options nodes"):
|
|
routing.SamplerOptionsSamplingService().sample(**arguments(), options=options)
|
|
assert not routed_calls
|
|
|
|
|
|
def test_invalid_inversion_target_fails_before_attention_preparation(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Reject a full-noise flow endpoint before any preparation or inference."""
|
|
monkeypatch.setattr(
|
|
sampling_schedulers,
|
|
"calculate_sigmas",
|
|
lambda **kwargs: torch.tensor([1.0, 0.5, 0.0]),
|
|
)
|
|
options = SamplerOptions()
|
|
for capability in capabilities():
|
|
options = options.with_capability(capability)
|
|
with pytest.raises(ValueError, match="full-noise endpoint"):
|
|
routing.SamplerOptionsSamplingService().sample(**arguments(), options=options)
|
|
assert not routed_calls
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"options",
|
|
[
|
|
SamplerOptions(tiling=TilingOptions()),
|
|
SamplerOptions(contextual_diffusion=ContextualDiffusionOptions()),
|
|
],
|
|
)
|
|
def test_unipc_conflicts_fail_before_sampling(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
options: SamplerOptions,
|
|
) -> None:
|
|
"""Do not silently bypass tiling for an incompatible sampling method."""
|
|
values = arguments()
|
|
values["sampler_name"] = "uni_pc"
|
|
with pytest.raises(ValueError, match="UniPC"):
|
|
routing.SamplerOptionsSamplingService().sample(**values, options=options)
|
|
assert not routed_calls
|
|
|
|
|
|
@pytest.mark.parametrize("cd_first", [False, True])
|
|
@pytest.mark.parametrize("attention", [False, True])
|
|
def test_cd_owns_all_local_controls_and_ignores_tiling_in_either_order(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
caplog: pytest.LogCaptureFixture,
|
|
cd_first: bool,
|
|
attention: bool,
|
|
) -> None:
|
|
"""Prove conflicting tiling cannot leak into CD routing, including its preflight."""
|
|
cd_segs = object()
|
|
(cd,) = ContextualDiffusionOptionsV3.execute(
|
|
diffusion_mode="mixture_of_diffusers",
|
|
latent_context_size=48,
|
|
latent_context_overlap=12,
|
|
latent_context_batch_size=2,
|
|
differential_diffusion=True,
|
|
)
|
|
base = cd
|
|
if attention:
|
|
base = base.with_capability(AttentionCouplingOptions())
|
|
values = arguments()
|
|
masks = torch.ones((1, 64, 96)) if attention else None
|
|
if attention:
|
|
values["positive"] = ConditioningBatch(([], []))
|
|
routing.SamplerOptionsSamplingService().sample(
|
|
**values, options=base, segs=cd_segs, region_masks=masks
|
|
)
|
|
reference = routed_calls[-1]
|
|
assert not caplog.records
|
|
|
|
def tiling_node(options: SamplerOptions | None = None) -> SamplerOptions:
|
|
"""Build a conflicting local configuration at the actual node boundary."""
|
|
(result,) = TilingOptionsV3.execute(
|
|
options=options,
|
|
diffusion_mode="multidiffusion",
|
|
latent_tile_width=128,
|
|
latent_tile_height=64,
|
|
latent_tile_overlap=32,
|
|
latent_tile_batch_size=4,
|
|
differential_diffusion=False,
|
|
)
|
|
return result
|
|
|
|
if cd_first:
|
|
combined = tiling_node(base)
|
|
else:
|
|
tiles = tiling_node()
|
|
assert base.contextual_diffusion is not None
|
|
combined = tiles.with_capability(base.contextual_diffusion)
|
|
if attention:
|
|
assert base.attention_coupling is not None
|
|
combined = combined.with_capability(base.attention_coupling)
|
|
routing.SamplerOptionsSamplingService().sample(
|
|
**values, options=combined, segs=cd_segs, region_masks=masks
|
|
)
|
|
assert routed_calls[-1] == reference
|
|
forwarded = reference[1]
|
|
assert forwarded["tiling"] == TilingOptions(
|
|
diffusion_mode="mixture_of_diffusers",
|
|
width=48,
|
|
height=48,
|
|
overlap=12,
|
|
batch_size=2,
|
|
differential_diffusion=True,
|
|
)
|
|
assert forwarded["segs"] is cd_segs
|
|
assert forwarded["latent_context_overlap"] == 12
|
|
assert forwarded["latent_context_batch_size"] == 2
|
|
assert len(caplog.records) == 1
|
|
assert caplog.records[0].levelname == "WARNING"
|
|
assert caplog.records[0].getMessage() == (
|
|
"Contextual Diffusion takes precedence; Tiling Options ignored."
|
|
)
|
|
assert caplog.records[0].__dict__["node_id"] == "SimpleSyrup.KSampler"
|
|
# Bypassing CD leaves the original tiling capability intact.
|
|
assert combined.tiling is not None
|
|
routing.SamplerOptionsSamplingService().sample(
|
|
**values, options=SamplerOptions(tiling=combined.tiling), segs=cd_segs
|
|
)
|
|
assert routed_calls[-1][0] == "tiled"
|
|
assert routed_calls[-1][1]["latent_tile_width"] == 128
|
|
assert routed_calls[-1][1]["segs"] is cd_segs
|
|
assert len(caplog.records) == 1
|
|
|
|
|
|
def test_inversion_preflight_uses_cd_geometry_not_ignored_tiles(
|
|
routed_calls: list[tuple[str, dict[str, Any]]],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Use the effective CD view when validating geometry-dependent inversion sigmas."""
|
|
views: list[sampling_schedulers.SchedulerView] = []
|
|
|
|
def sigmas(**kwargs: Any) -> torch.Tensor:
|
|
"""Observe the external scheduler boundary without running model inference."""
|
|
views.append(kwargs["view"])
|
|
return torch.tensor([0.5, 0.25, 0.0])
|
|
|
|
monkeypatch.setattr(sampling_schedulers, "calculate_sigmas", sigmas)
|
|
routing.SamplerOptionsSamplingService().sample(
|
|
**arguments(),
|
|
options=SamplerOptions(
|
|
tiling=TilingOptions(width=128, height=64),
|
|
contextual_diffusion=ContextualDiffusionOptions(context_size=48, overlap=8),
|
|
noise_inversion=NoiseInversionOptions(),
|
|
),
|
|
)
|
|
assert views == [sampling_schedulers.SchedulerView(48, 48)]
|
|
assert len(routed_calls) == 1
|