Files
KosinkadinkandAmp 6ab23718a4 fix(local-validate): traverse AUTOGROW templates and DYNAMIC_COMBO branches
Pre-PR-4 the descriptor-driven `image_max_batch` cap fired only on
top-level IMAGE inputs (Flux2 `images`, LTXV i2v `image`).
Providers whose cap rides a nested template — GPT Image V2 (16),
Grok ImageEditV2 (3 / 1 by branch), Grok VideoReference (7) — silently
fell through to the server's defense-in-depth path, defeating the
"no paid round trip" guarantee PR-3 promised.

## Client

* `_collect_local_validate` now recurses via the new
  `_rules_from_input_spec` helper. AUTOGROW specs surface their
  template's rules under a private `__template__` key; DYNAMIC_COMBO
  specs surface a `__branches__` map of `{branch_key: {sub_name: rules}}`.
* `_enforce_local_validate` walks `__template__` against each
  Autogrow slot value (`{"image0": <env>, "image1": <env>, ...}`) and
  `__branches__` against the DynamicCombo runtime dict, dispatching
  on `value[<name>]` to pick the active branch's sub-input rules.
* `image_max_batch` checking lifted into a dedicated
  `_check_image_max_batch` helper so the recursive enforcer keeps a
  single rule-application code path.
* Failure messages now carry a `parent.slot[.sub]` label so the
  surfaced `INPUT_INVALID` says
  `input 'model.images.image0' accepts at most 3 image frames…`
  for nested cases instead of just the parent name.

## Docs

* No descriptor or matrix changes — the server already published the
  `local_validate.image_max_batch` rules on the nested templates in
  PR-3 (eat(descriptor): declare image_max_batch local_validate caps,
  comfy-rnp-server#15). The wire shape is unchanged; this PR just
  makes the client honour what the server already declared.

## Verification

* Extended `notes/run_image_max_batch.py` with two new fixtures
  (`_autogrow_descriptor` and `_dyncombo_with_autogrow_descriptor`)
  and 9 new sub-cases covering: AUTOGROW slot over-cap fires,
  AUTOGROW None-slot tolerance, AUTOGROW all-under-cap passes,
  DYNAMIC_COMBO branch-A over-cap fires, DYNAMIC_COMBO branch-A
  under-cap passes, DYNAMIC_COMBO branch-B (cap=1) fires the
  single-frame copy, DYNAMIC_COMBO unknown branch is a defensive
  no-op, plus collected-rules shape assertions.
* End-to-end check against live registered descriptors confirmed all
  four nested-cap providers fire client-side: GrokImageEditNodeV2_RNP
  (`model.images.image0` cap=3 and cap=1 branches),
  GrokVideoReferenceNode_RNP (`model.reference_images.reference0`
  cap=7), OpenAIGPTImageNodeV2_RNP (`model.images.image0` cap=16).

## Lockstep

Client-only fix. Server already publishes the rules; no paired
server PR required. The companion paper trail is comfy-rnp-server#15
(declarative caps) and the parent code-review note from the merged
PR-2/PR-3 wave that flagged this gap as the blocking follow-up.

Amp-Thread-ID: T-019e29b4-d606-7278-8584-52910661c981
Co-authored-by: Amp <amp@ampcode.com>
2026-05-15 02:37:41 -07:00

442 lines
16 KiB
Python

"""Smoke test for the descriptor-driven ``local_validate.image_max_batch``
client-side fail-fast.
Exercises the proxy_node's ``_collect_local_validate`` /
``_enforce_local_validate`` pair against a fake descriptor that mirrors
the Flux2 wire shape. Asserts that:
1. ``_collect_local_validate`` extracts ``{"images": {"image_max_batch": 2}}``
from the descriptor's IMAGE-input options dict.
2. Encoding a 3-frame tensor for that input raises ``RnpProtocolError``
with ``code=INPUT_INVALID`` and ``user_facing=True`` *before* any
serialization / HTTP attempt.
3. A 1-frame tensor passes through cleanly.
4. A descriptor without ``local_validate`` is a no-op.
5. ``cap=1`` surfaces the single-frame copy.
6. A ``local_validate`` block missing the ``image_max_batch`` key is a no-op.
Run with any python that has ``torch`` available:
python notes/run_image_max_batch.py
The test loads the helper functions out of ``proxy_node.py`` via AST
extraction so it doesn't pay the cost of importing ComfyUI's
``comfy_api`` / ``comfy_api_nodes`` packages (which the helper
functions don't touch). Standalone — no test runner / fixtures needed.
"""
from __future__ import annotations
import ast
import asyncio
import importlib.util
import os
import sys
import types
import torch
# ---------------------------------------------------------------------------
# Locate the worktree under test (this file's parent's parent).
# ---------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_CLIENT_DIR = os.path.dirname(_HERE)
def _load_protocol() -> types.ModuleType:
"""Load ``protocol.py`` standalone (no relative imports)."""
path = os.path.join(_CLIENT_DIR, "protocol.py")
spec = importlib.util.spec_from_file_location("rnp_smoke_protocol", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
def _build_serialization_stub(protocol: types.ModuleType) -> types.ModuleType:
"""Minimal ``serialization`` shim covering exactly the surface
``_encode_one`` / ``_enforce_local_validate`` / ``_encode_inputs`` use:
* ``_is_torch_tensor(value)`` — duck-type check for torch tensors.
* ``is_audio_input(value)`` — always False (no audio in this test).
* ``encode_image_tensor(tensor, *, accepts_batch)`` — fake envelope.
* ``encode_mask_tensor(tensor)`` — fake envelope.
* ``is_envelope(value)`` — False so ``maybe_externalize`` is skipped.
"""
mod = types.ModuleType("rnp_smoke_serialization")
def _is_torch_tensor(value): # noqa: ANN001
try:
import torch as _torch
except ImportError:
return False
return isinstance(value, _torch.Tensor)
def is_audio_input(value): # noqa: ANN001
return isinstance(value, dict) and {"waveform", "sample_rate"} <= set(value.keys())
def encode_image_tensor(tensor, *, accepts_batch=False): # noqa: ANN001, ARG001
return {"type": "image", "encoding": "png_base64", "frames": int(tensor.shape[0])}
def encode_mask_tensor(tensor): # noqa: ANN001, ARG001
return {"type": "mask", "encoding": "png_base64"}
def encode_audio_input(value): # noqa: ANN001, ARG001
return {"type": "audio", "encoding": "mp3_base64"}
def is_envelope(value): # noqa: ANN001
return False # smoke test never externalizes
mod._is_torch_tensor = _is_torch_tensor
mod.is_audio_input = is_audio_input
mod.encode_image_tensor = encode_image_tensor
mod.encode_mask_tensor = encode_mask_tensor
mod.encode_audio_input = encode_audio_input
mod.is_envelope = is_envelope
async def maybe_externalize(*args, **kwargs): # pragma: no cover — unused
return args[0] if args else None
mod.maybe_externalize = maybe_externalize
return mod
def _build_proxy_node_stub(
protocol: types.ModuleType, serialization: types.ModuleType,
) -> types.ModuleType:
"""Extract the four helper functions under test from ``proxy_node.py``
and exec them in a minimal namespace so we don't drag the rest of
the module's heavy ComfyUI imports into the test process.
"""
mod = types.ModuleType("rnp_smoke_proxy_node")
mod.__dict__.update({
"Any": object,
"log": types.SimpleNamespace(warning=lambda *a, **kw: None),
"serialization": serialization,
"RnpProtocolError": protocol.RnpProtocolError,
"ErrorCode": protocol.ErrorCode,
})
src_path = os.path.join(_CLIENT_DIR, "proxy_node.py")
with open(src_path, "r", encoding="utf-8") as fh:
src = fh.read()
tree = ast.parse(src)
wanted = {
"_collect_local_validate",
"_rules_from_input_spec",
"_enforce_local_validate",
"_check_image_max_batch",
"_encode_one",
"_encode_inputs",
}
nodes = [
n for n in tree.body
if isinstance(n, (ast.AsyncFunctionDef, ast.FunctionDef)) and n.name in wanted
]
snippet = ast.Module(body=nodes, type_ignores=[])
code = compile(snippet, src_path, "exec")
exec(code, mod.__dict__)
missing = wanted - set(mod.__dict__)
if missing:
raise RuntimeError(f"proxy_node helpers missing from extract: {missing}")
return mod
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
def _flux2_like_descriptor(cap: int) -> dict:
"""Mirrors the Flux2 ``images`` input descriptor surface."""
return {
"name": "FakeFlux2_RNP",
"input": {
"required": {
"prompt": ["STRING", {"multiline": True, "default": ""}],
},
"optional": {
"images": ["IMAGE", {
"tooltip": "Up to N images to be used as references.",
"local_validate": {"image_max_batch": cap},
}],
},
"hidden": {},
},
}
def _no_validate_descriptor() -> dict:
return {
"name": "FakeNoValidate_RNP",
"input": {
"required": {},
"optional": {
"images": ["IMAGE", {"tooltip": "no cap"}],
},
"hidden": {},
},
}
def _autogrow_descriptor(cap: int, prefix: str = "reference") -> dict:
"""Mirrors GrokVideoReferenceNode_RNP's top-level Autogrow input
after server descriptor unwraps the nested DynamicCombo branch
(kept simpler here for direct AUTOGROW coverage)."""
return {
"name": "FakeAutogrow_RNP",
"input": {
"required": {
"reference_images": ["AUTOGROW", {
"template": ["IMAGE", {
"local_validate": {"image_max_batch": cap},
}],
"prefix": prefix,
"min": 1,
"max": cap,
}],
},
"optional": {},
"hidden": {},
},
}
def _dyncombo_with_autogrow_descriptor() -> dict:
"""Mirrors GrokImageEditNodeV2_RNP's ``model`` DynamicCombo with
two branches each carrying an Autogrow ``images`` template — one
capped at 3, the other at 1."""
def branch(key: str, cap: int) -> dict:
return {
"key": key,
"inputs": [
["images", "AUTOGROW", {
"template": ["IMAGE", {
"local_validate": {"image_max_batch": cap},
}],
"prefix": "image",
"min": 1,
"max": cap,
}],
["resolution", "COMBO", {"options": ["1K", "2K"]}],
],
}
return {
"name": "FakeDynComboAutogrow_RNP",
"input": {
"required": {
"model": ["DYNAMIC_COMBO", {
"options": [
branch("grok-imagine-image-quality", 3),
branch("grok-imagine-image-pro", 1),
],
}],
},
"optional": {},
"hidden": {},
},
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
async def main() -> int:
protocol = _load_protocol()
serialization = _build_serialization_stub(protocol)
proxy_node = _build_proxy_node_stub(protocol, serialization)
# ---- 1. _collect_local_validate extracts the rule
desc = _flux2_like_descriptor(cap=2)
rules = proxy_node._collect_local_validate(desc)
assert rules == {"images": {"image_max_batch": 2}}, rules
print("ok: _collect_local_validate extracted", rules)
# ---- 2. over-cap tensor raises INPUT_INVALID before encoding
over_cap = torch.zeros((3, 64, 64, 3), dtype=torch.float32)
raised = False
try:
await proxy_node._encode_inputs(
{"images": over_cap},
input_serialization={"images": ["png_base64", "png_base64_batch"]},
local_validate=rules,
node_id="FakeFlux2_RNP",
)
except protocol.RnpProtocolError as e:
raised = True
assert e.code == protocol.ErrorCode.INPUT_INVALID, e.code
assert e.user_facing is True, e.user_facing
msg = str(e)
assert "FakeFlux2_RNP" in msg, msg
assert "3" in msg and "2" in msg, msg
print("ok: over-cap raised INPUT_INVALID:", msg)
assert raised, "over-cap tensor did not raise"
# ---- 3. at-cap tensor passes through (no exception)
at_cap = torch.zeros((2, 64, 64, 3), dtype=torch.float32)
out = await proxy_node._encode_inputs(
{"images": at_cap},
input_serialization={"images": ["png_base64", "png_base64_batch"]},
local_validate=rules,
node_id="FakeFlux2_RNP",
)
assert "images" in out
print("ok: at-cap (B=2) encoded without error")
# ---- 4. cap=1 / multi-frame raises with the single-frame copy
desc1 = _flux2_like_descriptor(cap=1)
rules1 = proxy_node._collect_local_validate(desc1)
raised = False
try:
await proxy_node._encode_inputs(
{"images": over_cap},
local_validate=rules1,
node_id="FakeI2V_RNP",
)
except protocol.RnpProtocolError as e:
raised = True
assert e.code == protocol.ErrorCode.INPUT_INVALID
assert "single image" in str(e), str(e)
print("ok: cap=1 single-frame copy fired:", str(e))
assert raised
# ---- 5. descriptor without local_validate is a no-op
desc_n = _no_validate_descriptor()
rules_n = proxy_node._collect_local_validate(desc_n)
assert rules_n == {}, rules_n
out = await proxy_node._encode_inputs(
{"images": over_cap},
input_serialization={"images": ["png_base64", "png_base64_batch"]},
local_validate=rules_n,
node_id="FakeNoValidate_RNP",
)
assert "images" in out
print("ok: missing local_validate is a no-op")
# ---- 6. local_validate block without image_max_batch sub-key is a no-op
out = await proxy_node._encode_inputs(
{"images": over_cap},
input_serialization={"images": ["png_base64", "png_base64_batch"]},
local_validate={"images": {"some_other_rule": 7}},
node_id="FakeOther_RNP",
)
assert "images" in out
print("ok: missing image_max_batch sub-key is a no-op")
# ---- 7. AUTOGROW: each slot value enforced against the template cap
desc_ag = _autogrow_descriptor(cap=2)
rules_ag = proxy_node._collect_local_validate(desc_ag)
assert "reference_images" in rules_ag, rules_ag
assert rules_ag["reference_images"].get("__template__") == {"image_max_batch": 2}, rules_ag
print("ok: AUTOGROW collected __template__ rules:", rules_ag)
one_frame = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
raised = False
try:
# Slot 1 is over-cap (B=3 > cap=2) → should fire on that slot
await proxy_node._encode_inputs(
{"reference_images": {"reference0": one_frame, "reference1": over_cap}},
local_validate=rules_ag,
node_id="FakeAutogrow_RNP",
)
except protocol.RnpProtocolError as e:
raised = True
assert e.code == protocol.ErrorCode.INPUT_INVALID
msg = str(e)
# Slot path label should mention reference_images.<slot_key>
assert "reference_images" in msg, msg
assert "reference1" in msg, msg
print("ok: AUTOGROW slot over-cap fired:", msg)
assert raised, "AUTOGROW slot over-cap did not raise"
# All-under-cap autogrow passes (no exception)
out = await proxy_node._encode_inputs(
{"reference_images": {"reference0": one_frame, "reference1": one_frame, "reference2": None}},
local_validate=rules_ag,
node_id="FakeAutogrow_RNP",
)
assert "reference_images" in out
print("ok: AUTOGROW all-under-cap (None slots tolerated) passed")
# ---- 8. DYNAMIC_COMBO with nested AUTOGROW: branch-aware enforcement
desc_dc = _dyncombo_with_autogrow_descriptor()
rules_dc = proxy_node._collect_local_validate(desc_dc)
assert "model" in rules_dc, rules_dc
branches = rules_dc["model"].get("__branches__")
assert isinstance(branches, dict), rules_dc
assert "grok-imagine-image-quality" in branches
assert "grok-imagine-image-pro" in branches
assert branches["grok-imagine-image-quality"]["images"]["__template__"] == {"image_max_batch": 3}
assert branches["grok-imagine-image-pro"]["images"]["__template__"] == {"image_max_batch": 1}
print("ok: DYNAMIC_COMBO collected branch-keyed templates")
# Branch A (cap=3): slot with B=4 should raise
over_4 = torch.zeros((4, 64, 64, 3), dtype=torch.float32)
raised = False
try:
await proxy_node._encode_inputs(
{"model": {
"model": "grok-imagine-image-quality",
"resolution": "1K",
"images": {"image0": over_4},
}},
local_validate=rules_dc,
node_id="FakeDynComboAutogrow_RNP",
)
except protocol.RnpProtocolError as e:
raised = True
assert e.code == protocol.ErrorCode.INPUT_INVALID
msg = str(e)
assert "image0" in msg and "3" in msg and "4" in msg, msg
print("ok: DYNAMIC_COMBO branch-A (cap=3) fired on B=4 slot:", msg)
assert raised
# Branch A (cap=3): all slots within cap → passes
out = await proxy_node._encode_inputs(
{"model": {
"model": "grok-imagine-image-quality",
"resolution": "2K",
"images": {"image0": one_frame, "image1": one_frame, "image2": one_frame},
}},
local_validate=rules_dc,
node_id="FakeDynComboAutogrow_RNP",
)
assert "model" in out
print("ok: DYNAMIC_COMBO branch-A all-under-cap passed")
# Branch B (cap=1): a B=3 slot fires the single-frame copy
raised = False
try:
await proxy_node._encode_inputs(
{"model": {
"model": "grok-imagine-image-pro",
"resolution": "2K",
"images": {"image0": over_cap},
}},
local_validate=rules_dc,
node_id="FakeDynComboAutogrow_RNP",
)
except protocol.RnpProtocolError as e:
raised = True
assert e.code == protocol.ErrorCode.INPUT_INVALID
assert "single image" in str(e), str(e)
print("ok: DYNAMIC_COMBO branch-B (cap=1) fired single-frame copy:", str(e))
assert raised
# Unknown branch key → no enforcement (defensive)
out = await proxy_node._encode_inputs(
{"model": {
"model": "unknown-branch-key",
"images": {"image0": over_cap},
}},
local_validate=rules_dc,
node_id="FakeDynComboAutogrow_RNP",
)
assert "model" in out
print("ok: DYNAMIC_COMBO unknown branch key is a defensive no-op")
print("PASS: image_max_batch local_validate smoke test (incl. nested traversal)")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))