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.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 9424cd2e35
commit 56aa6f53b0
5 changed files with 341 additions and 42 deletions
+8 -1
View File
@@ -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
+12 -12
View File
@@ -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,
+6 -3
View File
@@ -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):
+202 -2
View File
@@ -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"
+113 -24
View File
@@ -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)