From 56aa6f53b04a2ce3999ac579ff1c82f910d2dff3 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 09:38:04 -0700 Subject: [PATCH] feat(inspect): resolve guider-based samplers Under INSPECTION_PROFILE the resolver discovers samplers from the semantics table and follows SamplerCustomAdvanced through its guider link (Reroutes allowed) to a listed guider: - CFGGuider: positive and negative, so the pair resolves. - BasicGuider: positive only. The state is "absent" with the reason "Guider has no negative input", the existing missing-negative path. - Any other guider, including DualCFGGuider (D17), is unsupported-node and names that guider. Guider hops draw visits, scan and depth from the existing budgets. A SamplerCustomAdvanced upstream in latent ancestry is an earlier stage, so the result stays multistage-ambiguous. Positive and negative provenance record guider_id. FaceDetailer (detailer-stage) is not a candidate yet: deciding it needs output ancestry. DEFAULT_PROFILE never loads the table and is unchanged. Vector changes (inspection profile only, default vectors unchanged): - f07b: no-sampler -> resolved (CFGGuider). - f17: no-sampler -> unsupported-node (FluxGuidance, Task 2.3). - f01b: no-sampler -> unsupported-node (EmptyFlux2LatentImage latent, Task 2.5). - f01: no-sampler -> ambiguous (24 SamplerCustomAdvanced branches). The no-sampler tests (resolver and inspect route) now use a sampler class the inspector does not know, because f17 has a supported one. --- TESTING.md | 9 +- tests/fixtures/inspection_parity/vectors.json | 24 +-- tests/test_api_prompt_inspector.py | 9 +- tests/test_prompt_pair_resolver.py | 204 +++++++++++++++++- utils/prompt_pair_resolver.py | 137 +++++++++--- 5 files changed, 341 insertions(+), 42 deletions(-) diff --git a/TESTING.md b/TESTING.md index 7f72bb3..1372d92 100644 --- a/TESTING.md +++ b/TESTING.md @@ -227,7 +227,14 @@ workflow graph and its facts become limited). The serialized response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt graph, then all raw records atomically. Retry does not raise these limits. Ingestion (scan, live monitor, admin import) and queue-time run counting keep the default -profile, including its 500-node gate. +profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node +semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting +levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider` +link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider` +(positive only; the negative is `absent`, "Guider has no negative input"). Any +other guider, such as `DualCFGGuider`, is `unsupported-node`. If the table cannot +be loaded, graph inspection is `unresolved` ("Inspection node semantics are +unavailable") and ingestion is unaffected, because it never imports the table. `tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL] [--summary TXT] [--code TREE]` measures a media folder read-only against the #240 diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json index 02ff874..1f86062 100644 --- a/tests/fixtures/inspection_parity/vectors.json +++ b/tests/fixtures/inspection_parity/vectors.json @@ -6,9 +6,9 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "unresolved", - "reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)", - "reason_code": "no-sampler", + "state": "ambiguous", + "reason": "Multiple sampler branches", + "reason_code": null, "selection": null, "positive": null, "negative": null, @@ -49,8 +49,8 @@ "expect": { "metadata_status": "available", "state": "unresolved", - "reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)", - "reason_code": "no-sampler", + "reason": "Unsupported latent ancestry", + "reason_code": "unsupported-node", "selection": null, "positive": null, "negative": null, @@ -510,12 +510,12 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "unresolved", - "reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)", - "reason_code": "no-sampler", + "state": "resolved", + "reason": "", + "reason_code": null, "selection": null, - "positive": null, - "negative": null, + "positive": "a red fox in snow, detailed fur", + "negative": "blurry, lowres", "candidate_count": 0, "facts": { "seed": { @@ -889,8 +889,8 @@ "expect": { "metadata_status": "available", "state": "unresolved", - "reason": "No supported sampler pair found (saw SamplerCustomAdvanced)", - "reason_code": "no-sampler", + "reason": "Unsupported node semantics or output slot", + "reason_code": "unsupported-node", "selection": null, "positive": null, "negative": null, diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index 052de9a..40846da 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -773,8 +773,11 @@ class TestPromptInspector(ImageAPITestCase): self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed") async def test_inspect_route_reports_no_sampler_instead_of_absent(self): - # f17: a BasicGuider-only prompt graph is a record, never "not recorded". - self.parity_fixture("f17") + # f17 with a sampler class the inspector does not know: a prompt graph + # is a record, never "not recorded". + built = parity.build("f17") + built["prompt"]["13"]["class_type"] = "FutureSampler" + parity.write_png(self.output_dir / "f17.png", built) response, result = await self.inspect("f17.png") self.assertEqual(response.status, 200) self.assertEqual( @@ -785,7 +788,7 @@ class TestPromptInspector(ImageAPITestCase): self.assertEqual(result["resolution"]["reason_code"], "no-sampler") self.assertEqual( result["resolution"]["reason"], - "No supported sampler pair found (saw SamplerCustomAdvanced)", + "No supported sampler pair found (saw FutureSampler)", ) async def test_inspect_route_exposes_reason_code_and_blocking_node(self): diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index 90a5f20..fc466ab 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -912,12 +912,15 @@ class TestResolverProfiles(unittest.TestCase): self.assertEqual(result["state"], "resolved") def test_prompt_graph_without_supported_sampler_is_unresolved_not_absent(self): - metadata = {"prompt": parity.build("f17")["prompt"]} + # f17 with its sampler renamed to a class the inspector does not know. + source = parity.build("f17")["prompt"] + source["13"]["class_type"] = "FutureSampler" + metadata = {"prompt": source} result = resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE) self.assertEqual( (result["state"], result["reason_code"]), ("unresolved", "no-sampler") ) - self.assertIn("SamplerCustomAdvanced", result["reason"]) + self.assertIn("FutureSampler", result["reason"]) self.assertIsNone(result["positive"]) default = resolve_prompt_pair(metadata) self.assertEqual(default["state"], "absent") @@ -1049,6 +1052,203 @@ class TestResolverProfiles(unittest.TestCase): self.assertEqual(result["reason"], "Resolution visit limit reached") +def _guided(guider: dict = None) -> dict: + """``f07b`` (SamplerCustomAdvanced + CFGGuider), optionally another guider.""" + source = parity.build("f07b")["prompt"] + if guider is not None: + source["21"] = guider + return source + + +def _basic_guided() -> dict: + """``f17`` with its BasicGuider reading the CLIPTextEncode directly.""" + source = parity.build("f17")["prompt"] + source["22"]["inputs"]["conditioning"] = ["6", 0] + del source["26"] + return source + + +def _inspect(source: dict, **options) -> dict: + return resolve_prompt_pair( + {"prompt": source}, profile=resolver.INSPECTION_PROFILE, **options + ) + + +class TestGuiderSamplers(unittest.TestCase): + """SamplerCustomAdvanced follows its guider through the semantics table.""" + + def test_sampler_custom_advanced_with_cfg_guider_resolves(self): + result = _inspect(_guided()) + self.assertEqual(result["state"], "resolved") + self.assertIsNone(result["reason_code"]) + self.assertEqual(result["sampler_id"], "3") + self.assertEqual(result["positive"]["text"], parity.POS) + self.assertEqual(result["negative"]["text"], parity.NEG) + provenance = result["positive"]["provenance"] + self.assertEqual( + (provenance["sampler_id"], provenance["guider_id"]), ("3", "21") + ) + self.assertEqual(provenance["node_id"], "6") + self.assertEqual(provenance["path"], [{"node_id": "6", "output_slot": 0}]) + self.assertEqual(result["negative"]["provenance"]["node_id"], "7") + # The saver anchor reaches the same sampler. + anchored = _inspect(_guided(), output_node_id="9") + self.assertEqual(anchored, result) + + def test_basic_guider_negative_is_absent_by_design(self): + from utils.prompt_inspector import inspect_prompt_evidence + + result = _inspect(_basic_guided()) + self.assertEqual(result["state"], "absent") + self.assertEqual(result["reason"], "Guider has no negative input") + self.assertIsNone(result["reason_code"]) + self.assertEqual(result["positive"]["text"], parity.POS) + self.assertIsNone(result["negative"]) + evidence = inspect_prompt_evidence({"prompt": _basic_guided()}) + self.assertEqual(evidence["prompts"]["positive"]["status"], "resolved") + self.assertEqual(evidence["prompts"]["positive"]["text"], parity.POS) + self.assertEqual(evidence["prompts"]["negative"]["status"], "absent") + self.assertEqual( + evidence["resolution"]["reason"], "Guider has no negative input" + ) + + def test_basic_guider_empty_positive_is_not_absent(self): + source = _basic_guided() + source["6"]["inputs"]["text"] = " " + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason"], "Positive prompt is empty") + + def test_dual_cfg_guider_is_unsupported_node(self): + dual = { + "class_type": "DualCFGGuider", + "inputs": { + "model": ["4", 0], + "cond1": ["6", 0], + "cond2": ["6", 0], + "negative": ["7", 0], + "cfg_conds": 8.0, + "cfg_cond2_negative": 8.0, + }, + } + result = _inspect(_guided(dual)) + self.assertEqual( + (result["state"], result["reason_code"], result["node"]["class_type"]), + ("unresolved", "unsupported-node", "DualCFGGuider"), + ) + self.assertEqual(result["node"], {"id": "21", "class_type": "DualCFGGuider"}) + self.assertEqual(result["sampler_id"], "3") + self.assertIsNone(result["positive"]) + + def test_unsupported_node_names_first_blocking_node(self): + source = _guided() + source["30"] = { + "class_type": "FutureConditioner", + "inputs": {"conditioning": ["6", 0]}, + } + source["31"] = { + "class_type": "OtherConditioner", + "inputs": {"conditioning": ["7", 0]}, + } + source["21"]["inputs"]["positive"] = ["30", 0] + source["21"]["inputs"]["negative"] = ["31", 0] + result = _inspect(source) + self.assertEqual(result["reason_code"], "unsupported-node") + self.assertEqual( + result["node"], {"id": "30", "class_type": "FutureConditioner"} + ) + # With the positive fixed, the negative's node is the first blocker. + source["21"]["inputs"]["positive"] = ["6", 0] + result = _inspect(source) + self.assertEqual(result["node"], {"id": "31", "class_type": "OtherConditioner"}) + self.assertEqual(result["positive"]["text"], parity.POS) + + def test_default_profile_ignores_semantics(self): + for source in (_guided(), _basic_guided()): + result = resolve_prompt_pair({"prompt": source}) + self.assertIn(result["state"], ("unresolved", "absent")) + self.assertEqual(result["reason"], "No supported sampler pair found") + self.assertIsNone(result["reason_code"]) + self.assertIsNone(result["positive"]) + # Without semantics the inspection bounds alone do not follow guiders. + plain = replace(resolver.INSPECTION_PROFILE, semantics=False) + result = resolve_prompt_pair({"prompt": _guided()}, profile=plain) + self.assertEqual(result["reason_code"], "no-sampler") + + def test_guider_reached_through_a_reroute(self): + source = _guided() + source["40"] = {"class_type": "Reroute", "inputs": {"value": ["21", 0]}} + source["3"]["inputs"]["guider"] = ["40", 0] + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(result["positive"]["provenance"]["guider_id"], "21") + + def test_malformed_guider_links_and_slots_never_guess(self): + cases = { + "missing": None, + "literal": "CFGGuider", + "dangling": ["99", 0], + "slot": ["21", 1], + } + for label, value in cases.items(): + with self.subTest(label=label): + source = _guided() + if value is None: + del source["3"]["inputs"]["guider"] + else: + source["3"]["inputs"]["guider"] = value + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertIsNone(result["positive"]) + expected = "unsupported-node" if label == "slot" else "malformed" + self.assertEqual(result["reason_code"], expected) + source = _guided() + source["21"]["inputs"]["model"] = ["21", 0] + source["40"] = {"class_type": "Reroute", "inputs": {"value": ["40", 0]}} + source["3"]["inputs"]["guider"] = ["40", 0] + self.assertEqual(_inspect(source)["reason"], "Cyclic guider ancestry") + source["40"]["inputs"] = {"value": ["21", 0], "input": ["21", 0]} + result = _inspect(source) + self.assertEqual(result["reason"], "Reroute input is not uniquely identifiable") + self.assertEqual(result["reason_code"], "malformed") + + def test_guider_hops_draw_from_the_work_budgets(self): + self.assertEqual(_inspect(_guided(), max_visits=2)["reason_code"], "work-limit") + self.assertEqual(_inspect(_guided())["state"], "resolved") + source = _guided() + previous = "21" + for index in range(70): + source[f"r{index}"] = { + "class_type": "Reroute", + "inputs": {"value": [previous, 0]}, + } + previous = f"r{index}" + source["3"]["inputs"]["guider"] = [previous, 0] + self.assertEqual(_inspect(source)["reason_code"], "depth-limit") + + def test_custom_advanced_upstream_of_a_sampler_is_multistage(self): + source = _guided() + source["41"] = { + "class_type": "KSampler", + "inputs": { + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["3", 0], + }, + } + source["8"]["inputs"]["samples"] = ["41", 0] + result = _inspect(source, output_node_id="9") + self.assertEqual(result["sampler_id"], "41") + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason"], "Multistage sampler ancestry is ambiguous") + unanchored = _inspect(source) + self.assertEqual(unanchored["state"], "ambiguous") + self.assertEqual( + sorted(item["sampler_id"] for item in unanchored["candidates"]), + ["3", "41"], + ) + + REPO = Path(__file__).resolve().parents[1] SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable" diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index 564760d..cabb6bb 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -132,6 +132,17 @@ INSPECTION_PROFILE = ResolverProfile( # About four passes over the 2 MiB role-record budget. max_scanned_units=8_000_000, ) +# Without the semantics table every sampler reads its pair directly. +_DIRECT_SAMPLER = { + "strategy": "ksampler", + "conditioning": {"positive": "positive", "negative": "negative"}, + "latent": "latent_image", +} +# Strategies whose nodes are prompt candidates in this slice. A detailer stage +# re-samples an upstream image; it is judged together with its upstream chain, +# which needs output ancestry (later slice), so it is never a candidate alone. +_CANDIDATE_STRATEGIES = frozenset({"ksampler", "custom-advanced"}) +_GUIDER_ABSENT_REASON = "Guider has no negative input" _SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative") _NO_SAMPLER_NAMES = 3 _CLASS_NAME_LENGTH = 64 @@ -239,6 +250,7 @@ class _GraphResolver: *, budget: Optional[_Budget] = None, profile: ResolverProfile = DEFAULT_PROFILE, + semantics: Optional[Any] = None, ) -> None: # ``max_visits`` is the per-candidate allowance; ``profile`` supplies only # the path selection and whole-resolution bounds. ``resolve_text_input`` @@ -268,6 +280,18 @@ class _GraphResolver: self.active: set = set() self.metadata = metadata or {} self.text_sources: dict = {} + # ``semantics`` (inspection) names the samplers and guiders it follows. + self.semantics = semantics + if semantics is None: + self.sampler_classes = self.stage_classes = _SAMPLERS + else: + self.sampler_classes = frozenset( + name + for name, entry in semantics.samplers.items() + if entry["strategy"] in _CANDIDATE_STRATEGIES + ) + # Any listed sampler upstream in latent ancestry is an earlier stage. + self.stage_classes = frozenset(semantics.samplers) def node(self, node_id: str) -> Tuple[str, dict]: if self.legacy: @@ -511,22 +535,73 @@ class _GraphResolver: ) return record - def side(self, value: Any, sampler_id: str) -> dict: - text, path, storage_text = self.text(value, "conditioning") + def side( + self, + value: Any, + sampler_id: str, + guider_id: Optional[str] = None, + depth: int = 0, + ) -> dict: + text, path, storage_text = self.text(value, "conditioning", depth) origin = path[0] - return { - "text": text, - "storage_text": storage_text, - "provenance": { - "format": "comfyui", - "sampler_id": sampler_id, - **origin, - "path": path, - "text_source": self.text_sources.get( - (origin["node_id"], origin["output_slot"]), "graph-inferred" - ), - }, + provenance = { + "format": "comfyui", + "sampler_id": sampler_id, + **origin, + "path": path, + "text_source": self.text_sources.get( + (origin["node_id"], origin["output_slot"]), "graph-inferred" + ), } + if guider_id is not None: + # The conditioning path starts below the guider the sampler reads. + provenance["guider_id"] = guider_id + return {"text": text, "storage_text": storage_text, "provenance": provenance} + + def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]: + """Follow a sampler's ``guider`` link (through Reroutes) to a listed guider. + + Returns the guider's ID and inputs, its conditioning input names, the + reason used when it has no negative input, and the depth reached. Each + hop draws a visit; an unlisted guider class is ``unsupported-node``. + """ + seen: set = set() + depth = 0 + while True: + if depth >= self.max_depth: + raise _Unresolved("Resolution depth limit reached", "depth-limit") + if not _link(value): + raise _Unresolved("Expected a guider link", "malformed") + node_id, slot = str(value[0]), value[1] + if node_id in seen: + raise _Unresolved("Cyclic guider ancestry", "malformed") + seen.add(node_id) + self.budget.spend() + name, inputs = self.node(node_id) + self.scan(len(inputs)) + depth += 1 + if name == "Reroute" and slot == 0: + keys = [key for key in ("value", "input", "in") if key in inputs] + if len(keys) != 1: + raise _Unresolved( + "Reroute input is not uniquely identifiable", "malformed" + ) + value = inputs[keys[0]] + continue + entry = self.semantics.guiders.get(name) + if entry is None or slot != 0: + raise self.unsupported("Unsupported guider", node_id) + conditioning = { + "positive": entry["positive"], + "negative": entry.get("negative"), + } + reason = entry.get("absent_reason", _GUIDER_ABSENT_REASON) + return node_id, inputs, conditioning, reason, depth + + def sampler_entry(self, name: str) -> Mapping: + if self.semantics is None: + return _DIRECT_SAMPLER + return self.semantics.samplers[name] def pair(self, sampler_id: str) -> dict: positive = negative = None @@ -539,22 +614,35 @@ class _GraphResolver: reason_code="work-limit", ) try: - _, inputs = self.node(sampler_id) + name, inputs = self.node(sampler_id) + entry = self.sampler_entry(name) # A second sampler in the latent ancestry denotes a multistage run; # associating the final image with one prompt pair would be a guess. - if "latent_image" in inputs: - self.latent_source(inputs["latent_image"], set(), 0) - positive = self.side(inputs.get("positive"), sampler_id) + if entry["latent"] in inputs: + self.latent_source(inputs[entry["latent"]], set(), 0) + guider_id, depth = None, 0 + conditioning = entry["conditioning"] + absent_reason = "Negative input is absent" + if entry["strategy"] == "custom-advanced": + guider_id, inputs, conditioning, guider_reason, depth = self.guider( + inputs.get(conditioning["guider"]) + ) + if conditioning["negative"] is None: + absent_reason = guider_reason + positive = self.side( + inputs.get(conditioning["positive"]), sampler_id, guider_id, depth + ) if not positive["text"].strip(): raise _Unresolved("Positive prompt is empty") - if "negative" not in inputs: + negative_name = conditioning["negative"] + if negative_name is None or negative_name not in inputs: return _result( "absent", positive, sampler_id=sampler_id, - reason="Negative input is absent", + reason=absent_reason, ) - negative = self.side(inputs["negative"], sampler_id) + negative = self.side(inputs[negative_name], sampler_id, guider_id, depth) return _result("resolved", positive, negative, sampler_id) except _Unresolved as error: return _result( @@ -581,7 +669,7 @@ class _GraphResolver: self.budget.spend() seen.add(node_id) name, inputs = self.node(node_id) - if name in _SAMPLERS: + if name in self.stage_classes: # ``multistage-differs`` is reserved for when stage texts are compared. raise _Unresolved("Multistage sampler ancestry is ambiguous") if name in ("EmptyLatentImage", "EmptySD3LatentImage") and slot == 0: @@ -603,7 +691,7 @@ class _GraphResolver: self.budget.spend() seen.add(node_id) name, inputs = self.node(node_id) - if name in _SAMPLERS and slot == 0: + if name in self.sampler_classes and slot == 0: return node_id key = { "SaveImage": "images", @@ -811,6 +899,7 @@ def _resolve( metadata if "prompt" in metadata else None, budget=_Budget(profile.total_visits), profile=profile, + semantics=semantics, ) if output_node_id is not None: try: @@ -825,7 +914,7 @@ def _resolve( for node_id, node in normalized.items() if isinstance(node, dict) and isinstance(node.get("class_type"), str) - and node["class_type"] in _SAMPLERS + and node["class_type"] in resolver.sampler_classes ] if not sampler_ids: return _no_sampler(normalized, profile)