204 lines
6.5 KiB
Python
204 lines
6.5 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 the shared managed Anima model-modifier graph boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
from tools.anima_attention_coupling_conditioning import BuiltAnimaConditioning
|
|
from tools.anima_attention_coupling_workflow import (
|
|
AnimaAttentionCouplingWorkflowBuilder,
|
|
AnimaModelModifierWorkflow,
|
|
AnimaModelObserverWorkflow,
|
|
ModifiedAnimaModel,
|
|
ObservedAnimaModel,
|
|
)
|
|
from tools.anima_latent_source_workflow import EmptyAnimaLatentSourceWorkflow
|
|
from tools.anima_workflow_graph import AnimaWorkflowGraph, NodeReference
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Case:
|
|
"""Provide the exact values consumed by the shared workflow builder."""
|
|
|
|
case_id: str = "modifier-boundary"
|
|
cfg: float = 1.0
|
|
feather: int = 0
|
|
|
|
|
|
class _NegPipModifier:
|
|
"""Build one public modifier over explicit loader references."""
|
|
|
|
def add(
|
|
self,
|
|
graph: AnimaWorkflowGraph,
|
|
*,
|
|
model: NodeReference,
|
|
clip: NodeReference,
|
|
) -> ModifiedAnimaModel:
|
|
"""Return both outputs of the public NegPiP node."""
|
|
|
|
node = graph.add("CLIPNegPip", model=model, clip=clip)
|
|
return ModifiedAnimaModel([node, 0], [node, 1])
|
|
|
|
|
|
class _Conditioning:
|
|
"""Record explicit MODEL and CLIP links without reading a loader node."""
|
|
|
|
def add(
|
|
self,
|
|
graph: AnimaWorkflowGraph,
|
|
*,
|
|
model: NodeReference,
|
|
clip: NodeReference,
|
|
) -> BuiltAnimaConditioning:
|
|
"""Encode two placeholders and preserve the supplied MODEL link."""
|
|
|
|
positive = graph.add("CLIPTextEncode", clip=clip, text="positive")
|
|
negative = graph.add("CLIPTextEncode", clip=clip, text="negative")
|
|
return BuiltAnimaConditioning(model, [positive, 0], [negative, 0])
|
|
|
|
|
|
class _Observer:
|
|
"""Publish one model snapshot without owning modification policy."""
|
|
|
|
def add(
|
|
self,
|
|
graph: AnimaWorkflowGraph,
|
|
*,
|
|
model: NodeReference,
|
|
) -> ObservedAnimaModel:
|
|
"""Return the pass-through MODEL link and evidence node identity."""
|
|
|
|
node = graph.add(
|
|
"SimpleSyrupBenchmark.SnapshotModelModifier",
|
|
model=model,
|
|
run_id="run:case",
|
|
)
|
|
return ObservedAnimaModel([node, 0], node)
|
|
|
|
|
|
def test_shared_builder_routes_modifier_outputs_without_loader_reach_in() -> None:
|
|
"""Keep model modification separate from conditioning graph ownership."""
|
|
|
|
built = AnimaAttentionCouplingWorkflowBuilder(
|
|
public_node_id="SimpleSyrup.KSamplerAttentionCoupling",
|
|
artifact_phase="p9.7",
|
|
conditioning=_Conditioning(),
|
|
model_modifier_workflow=_NegPipModifier(),
|
|
model_observer_workflow=_Observer(),
|
|
steps=1,
|
|
latent_source=EmptyAnimaLatentSourceWorkflow(64, 64),
|
|
).build(
|
|
_Case(),
|
|
run_id="run",
|
|
mask_names=("left.png", "right.png"),
|
|
)
|
|
|
|
modifier_id = _only_node_id(built.prompt, "CLIPNegPip")
|
|
observer_id = _only_node_id(
|
|
built.prompt,
|
|
"SimpleSyrupBenchmark.SnapshotModelModifier",
|
|
)
|
|
modifier = built.prompt[modifier_id]
|
|
loader_id = _only_node_id(built.prompt, "SimpleSyrup.SimpleLoadAnima")
|
|
modifier_inputs = modifier["inputs"]
|
|
assert isinstance(modifier_inputs, dict)
|
|
assert modifier_inputs == {
|
|
"model": [loader_id, 0],
|
|
"clip": [loader_id, 1],
|
|
}
|
|
for node in built.prompt.values():
|
|
if node["class_type"] == "CLIPTextEncode":
|
|
node_inputs = node["inputs"]
|
|
assert isinstance(node_inputs, dict)
|
|
assert node_inputs["clip"] == [modifier_id, 1]
|
|
observer_inputs = built.prompt[observer_id]["inputs"]
|
|
assert isinstance(observer_inputs, dict)
|
|
assert observer_inputs["model"] == [modifier_id, 0]
|
|
assert built.modifier_snapshot_node_id == observer_id
|
|
instrument_id = _only_node_id(
|
|
built.prompt,
|
|
"SimpleSyrupBenchmark.InstrumentModel",
|
|
)
|
|
instrument_inputs = built.prompt[instrument_id]["inputs"]
|
|
assert isinstance(instrument_inputs, dict)
|
|
assert instrument_inputs["model"] == [observer_id, 0]
|
|
|
|
|
|
def test_shared_builder_rejects_invalid_modifier_result_before_conditioning() -> None:
|
|
"""Require the typed boundary instead of interpreting arbitrary graph links."""
|
|
|
|
class InvalidModifier:
|
|
"""Return a deliberately untyped graph value."""
|
|
|
|
def add(self, *args: object, **kwargs: object) -> object:
|
|
"""Return no MODEL/CLIP contract."""
|
|
|
|
del args, kwargs
|
|
return object()
|
|
|
|
with pytest.raises(TypeError, match="modifier workflow returned an invalid"):
|
|
AnimaAttentionCouplingWorkflowBuilder(
|
|
public_node_id="SimpleSyrup.KSamplerAttentionCoupling",
|
|
artifact_phase="p9.7",
|
|
conditioning=_Conditioning(),
|
|
model_modifier_workflow=cast(
|
|
AnimaModelModifierWorkflow,
|
|
InvalidModifier(),
|
|
),
|
|
steps=1,
|
|
).build(
|
|
_Case(),
|
|
run_id="run",
|
|
mask_names=("left.png", "right.png"),
|
|
)
|
|
|
|
|
|
def test_shared_builder_rejects_invalid_observer_result_before_conditioning() -> None:
|
|
"""Require evidence collaborators to return the explicit pass-through value."""
|
|
|
|
class InvalidObserver:
|
|
"""Return a deliberately untyped observer value."""
|
|
|
|
def add(self, *args: object, **kwargs: object) -> object:
|
|
"""Return no observed-MODEL contract."""
|
|
|
|
del args, kwargs
|
|
return object()
|
|
|
|
with pytest.raises(TypeError, match="observer workflow returned an invalid"):
|
|
AnimaAttentionCouplingWorkflowBuilder(
|
|
public_node_id="SimpleSyrup.KSamplerAttentionCoupling",
|
|
artifact_phase="p9.7",
|
|
conditioning=_Conditioning(),
|
|
model_observer_workflow=cast(
|
|
AnimaModelObserverWorkflow,
|
|
InvalidObserver(),
|
|
),
|
|
steps=1,
|
|
).build(
|
|
_Case(),
|
|
run_id="run",
|
|
mask_names=("left.png", "right.png"),
|
|
)
|
|
|
|
|
|
def _only_node_id(
|
|
prompt: dict[str, dict[str, object]],
|
|
class_type: str,
|
|
) -> str:
|
|
"""Return the unique node ID for one graph class."""
|
|
|
|
matches = tuple(
|
|
node_id for node_id, node in prompt.items() if node["class_type"] == class_type
|
|
)
|
|
assert len(matches) == 1
|
|
return matches[0]
|