fix(sampling): prepare captured noise before native validation

This commit is contained in:
Artificial Sweetener
2026-10-11 17:17:24 -04:00
parent a073d587bc
commit 889962d354
5 changed files with 353 additions and 47 deletions
+3 -3
View File
@@ -30,8 +30,8 @@ from .simple_syrup.runtime.attention_region_prompt_handler import ( # noqa: E40
from .simple_syrup.runtime.comfy_safetensors_dtypes import ( # noqa: E402
register_comfy_safetensors_dtypes,
)
from .simple_syrup.runtime.noise_reuse_prompt_handler import ( # noqa: E402
register_noise_reuse_prompt_handler,
from .simple_syrup.runtime.noise_reuse_validation import ( # noqa: E402
register_noise_reuse_validation,
)
WEB_DIRECTORY = "./web/dist"
@@ -61,7 +61,7 @@ register_quant_cache_routes()
register_external_llm_routes()
register_mask_batch_preview_routes()
register_attention_region_prompt_handler()
register_noise_reuse_prompt_handler()
register_noise_reuse_validation()
__all__ = [
"WEB_DIRECTORY",
@@ -1,40 +0,0 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Register capture coordination before Comfy validates or executes source sampling."""
from __future__ import annotations
from importlib import import_module
from typing import Any
from ..shared.logging import get_logger
from .noise_reuse_graph import rewrite_noise_reuse_graph
LOGGER = get_logger(__name__)
_MARKER = "_simple_syrup_noise_reuse_prompt_handler"
def register_noise_reuse_prompt_handler() -> None:
"""Register once when the native PromptServer becomes available."""
try:
instance = getattr(import_module("server").PromptServer, "instance", None)
except (ImportError, AttributeError):
return
if instance is not None and not getattr(instance, _MARKER, False):
instance.add_on_prompt_handler(_rewrite_prompt)
setattr(instance, _MARKER, True)
def prepare_cube_prompt(prompt: dict[str, dict[str, object]]) -> None:
"""Reuse the authoritative capture planner on the final lowered Cube graph."""
rewrite_noise_reuse_graph(prompt, import_module("nodes").NODE_CLASS_MAPPINGS)
def _rewrite_prompt(data: Any) -> Any:
"""Use normal error-producing nodes for invalid source provenance."""
if isinstance(data, dict) and isinstance(data.get("prompt"), dict):
rewrite_noise_reuse_graph(
data["prompt"], import_module("nodes").NODE_CLASS_MAPPINGS
)
return data
@@ -0,0 +1,87 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prepare capture at Comfy's native validation boundary for every queue caller."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from functools import wraps
from importlib import import_module
from typing import Any, Protocol, cast
from ..shared.logging import get_logger
from .noise_reuse_graph import rewrite_noise_reuse_graph
LOGGER = get_logger(__name__)
_MARKER = "_simple_syrup_noise_reuse_validation"
NativePromptValidator = Callable[
[str, dict[str, Any], list[str] | None],
Awaitable[tuple[bool, object, object, object]],
]
class NativeValidationModule(Protocol):
"""Describe the host's replaceable async prompt validation boundary."""
validate_prompt: NativePromptValidator
def prepare_noise_reuse_prompt(prompt: dict[str, Any]) -> None:
"""Install cache-keyed capture dependencies before source sampling executes."""
rewrite_noise_reuse_graph(prompt, import_module("nodes").NODE_CLASS_MAPPINGS)
class NoiseReuseValidationAdapter:
"""Own capture preparation while preserving the host validator's contract."""
def __init__(self, validator: NativePromptValidator) -> None:
"""Retain the current validator, including other installed extensions."""
self._validator = validator
def wrap(self) -> NativePromptValidator:
"""Preserve the host's async function identity and introspection metadata."""
@wraps(self._validator)
async def validate_prompt(
prompt_id: str,
prompt: dict[str, Any],
partial_execution_list: list[str] | None,
) -> tuple[bool, object, object, object]:
"""Delegate through this adapter without changing the native signature."""
return await self(prompt_id, prompt, partial_execution_list)
setattr(validate_prompt, _MARKER, True)
return validate_prompt
async def __call__(
self,
prompt_id: str,
prompt: dict[str, Any],
partial_execution_list: list[str] | None,
) -> tuple[bool, object, object, object]:
"""Prepare once before native validation and propagate its exact result."""
try:
prepare_noise_reuse_prompt(prompt)
except Exception:
LOGGER.exception(
"Noise reuse capture preparation failed before Comfy validation",
extra={"prompt_id": prompt_id, "operation": "prepare_capture"},
)
raise
return await self._validator(prompt_id, prompt, partial_execution_list)
def register_noise_reuse_validation() -> None:
"""Install once on the shared boundary used by HTTP and direct graph queues."""
try:
execution = cast(NativeValidationModule, import_module("execution"))
except ModuleNotFoundError as error:
if error.name != "execution":
raise
return
validator = execution.validate_prompt
if not getattr(validator, _MARKER, False):
execution.validate_prompt = NoiseReuseValidationAdapter(validator).wrap()
LOGGER.info("Installed noise reuse capture preparation before Comfy validation")
@@ -1,16 +1,16 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prepare captured-noise dependencies for direct Cube queue execution."""
"""Expose capture preparation through the existing public Cube capability."""
from __future__ import annotations
from ..runtime.noise_reuse_prompt_handler import prepare_cube_prompt
from ..runtime.noise_reuse_validation import prepare_noise_reuse_prompt
class NoiseReuseGraphPreparationService:
"""Share the capture graph authority with queues outside Comfy's prompt hook."""
"""Delegate public host requests to the authoritative capture planner."""
def prepare(self, prompt: dict[str, dict[str, object]]) -> None:
"""Install cache-keyed source capture before any sampler executes."""
prepare_cube_prompt(prompt)
prepare_noise_reuse_prompt(prompt)
@@ -0,0 +1,259 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prove capture preparation precedes native validation for every queue caller."""
from __future__ import annotations
import asyncio
import inspect
import sys
from copy import deepcopy
from types import ModuleType
from typing import Any
import pytest
from simple_syrup.runtime.noise_reuse_validation import (
NativePromptValidator,
prepare_noise_reuse_prompt,
register_noise_reuse_validation,
)
class ValidationModule(ModuleType):
"""Describe the isolated host module's replaceable validation function."""
validate_prompt: NativePromptValidator
async def validate(
execution: ValidationModule,
prompt_id: str,
prompt: dict[str, Any],
partial_targets: list[str] | None,
) -> tuple[bool, object, object, object]:
"""Await the external validator whether it returns a coroutine or awaitable."""
return await execution.validate_prompt(prompt_id, prompt, partial_targets)
def capture_graph() -> dict[str, Any]:
"""Describe direct source lineage and a live receiving capture dependency."""
return {
"source": {
"class_type": "KSampler",
"inputs": {
"model": ["loader", 0],
"latent_image": ["empty", 0],
"sampler_name": "euler_ancestral",
},
},
"reuse": {
"class_type": "SimpleSyrup.NoiseReuseOptions",
"inputs": {"enabled": True},
},
"receive": {
"class_type": "SimpleSyrup.KSampler",
"inputs": {
"model": ["loader", 0],
"latent_image": ["source", 0],
"options": ["reuse", 0],
"steps": 12,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"denoise": 0.15,
},
},
}
@pytest.fixture
def validation_host(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]]:
"""Isolate only Comfy's external validation and registration boundaries."""
calls: list[tuple[str, dict[str, Any], list[str] | None]] = []
async def validate_prompt(
prompt_id: str,
prompt: dict[str, Any],
partial_execution_list: list[str] | None,
) -> tuple[bool, object, object, object]:
"""Record the graph exactly as the native validator receives it."""
calls.append((prompt_id, deepcopy(prompt), partial_execution_list))
return True, None, ["receive"], {}
execution = ValidationModule("execution")
execution.validate_prompt = validate_prompt
nodes = ModuleType("nodes")
nodes.NODE_CLASS_MAPPINGS = {} # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "execution", execution)
monkeypatch.setitem(sys.modules, "nodes", nodes)
return execution, calls
@pytest.mark.parametrize("partial_targets", [None, ["receive"]])
def test_direct_validation_prepares_capture_without_http_handler(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
partial_targets: list[str] | None,
) -> None:
"""Cover both full Cube queueing and Comfy partial-output validation."""
execution, calls = validation_host
register_noise_reuse_validation()
prompt = capture_graph()
result = asyncio.run(validate(execution, "run", prompt, partial_targets))
assert result == (True, None, ["receive"], {})
assert len(calls) == 1
prompt_id, validated, targets = calls[0]
assert prompt_id == "run" and targets is partial_targets
assert validated == prompt
assert validated["source"]["inputs"]["model"] == ["source.trajectory_capture", 0]
assert validated["receive"]["inputs"]["latent_image"] == [
"source.sampling_provenance",
0,
]
assert validated["receive.noise_capture_request"]["inputs"]["denoise"] == 0.15
def test_registration_and_history_requeue_do_not_duplicate_capture(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
) -> None:
"""Preserve graph identity across registration, pre-preparation and history."""
execution, calls = validation_host
register_noise_reuse_validation()
validator = execution.validate_prompt
register_noise_reuse_validation()
assert execution.validate_prompt is validator
prompt = capture_graph()
prepare_noise_reuse_prompt(prompt)
prepared = deepcopy(prompt)
for prompt_id in ("first", "requeue"):
asyncio.run(validate(execution, prompt_id, prompt, None))
assert prompt == prepared and len(calls) == 2
assert all(validated == prepared for _, validated, _ in calls)
def test_registration_preserves_async_signature_and_wrapped_validator(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
) -> None:
"""Keep native function inspection and extension chaining compatible."""
execution, _ = validation_host
native = execution.validate_prompt
register_noise_reuse_validation()
wrapped = execution.validate_prompt
assert inspect.iscoroutinefunction(wrapped)
assert inspect.signature(wrapped) == inspect.signature(native)
assert inspect.unwrap(wrapped) is native
def test_changed_receiving_controls_remain_source_cache_dependencies(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
) -> None:
"""Keep fresh submissions sensitive to downstream denoise and live steps."""
execution, calls = validation_host
register_noise_reuse_validation()
for denoise in (0.15, 0.2):
prompt = capture_graph()
prompt["receive"]["inputs"].update(denoise=denoise, steps=["live_steps", 0])
asyncio.run(validate(execution, "run", prompt, None))
requests = [
graph["receive.noise_capture_request"]["inputs"] for _, graph, _ in calls
]
assert [request["denoise"] for request in requests] == [0.15, 0.2]
assert all(request["steps"] == ["live_steps", 0] for request in requests)
def test_unrelated_graph_and_validation_rejection_are_preserved(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
) -> None:
"""Leave ordinary workflows and native validation error payloads intact."""
execution, _ = validation_host
prompt = {"output": {"class_type": "SaveImage", "inputs": {}}}
before = deepcopy(prompt)
rejection: tuple[bool, object, object, object] = (
False,
{"type": "native_error"},
[],
{"output": {"errors": []}},
)
async def reject(
prompt_id: str, prompt: dict[str, Any], partial_execution_list: list[str] | None
) -> tuple[bool, object, object, object]:
"""Return the host's validation rejection without extension translation."""
return rejection
execution.validate_prompt = reject
register_noise_reuse_validation()
assert asyncio.run(validate(execution, "run", prompt, None)) is rejection
assert prompt == before
def test_native_validation_exception_propagates(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
) -> None:
"""Keep unexpected host failures visible to the queue that called it."""
execution, _ = validation_host
async def fail(
prompt_id: str, prompt: dict[str, Any], partial_execution_list: list[str] | None
) -> tuple[bool, object, object, object]:
"""Represent a failed external native validator."""
raise RuntimeError("native validation failed")
execution.validate_prompt = fail
register_noise_reuse_validation()
with pytest.raises(RuntimeError, match="native validation failed"):
asyncio.run(validate(execution, "run", capture_graph(), None))
def test_preparation_failure_stops_before_validation(
validation_host: tuple[
ValidationModule, list[tuple[str, dict[str, Any], list[str] | None]]
],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Reject an unexpected planner failure rather than silently skipping capture."""
execution, calls = validation_host
def fail(prompt: dict[str, Any]) -> None:
"""Represent failure at the graph preparation boundary."""
raise RuntimeError("capture planning failed")
monkeypatch.setattr(
"simple_syrup.runtime.noise_reuse_validation.prepare_noise_reuse_prompt", fail
)
register_noise_reuse_validation()
with pytest.raises(RuntimeError, match="capture planning failed"):
asyncio.run(validate(execution, "run", capture_graph(), None))
assert calls == []
assert "capture planning failed" in caplog.text
def test_registration_without_comfy_runtime_is_safe(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Allow tooling imports when Comfy's execution module is unavailable."""
def unavailable(name: str) -> ModuleType:
"""Represent an absent external host installation."""
raise ModuleNotFoundError("No module named execution", name="execution")
monkeypatch.setattr(
"simple_syrup.runtime.noise_reuse_validation.import_module", unavailable
)
register_noise_reuse_validation()