Files
ComfyAssets-ComfyUI_PromptM…/tests/test_resolver_saver_anchor.py
T
Vito a0fc5171e3 fix(inspector): pass text through unknown nodes instead of refusing them (#381) (#383)
* fix(inspector): pass text through unknown nodes instead of refusing them (#381)

The inspector refused any node class missing from the semantics table, so a
PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the
inspector does not support". The inspection resolver (Python and browser) now
reads through unknown nodes: a text input is the text, positive/negative route
by output slot, a conditioning input is followed alone, otherwise every linked
input except core type inputs (pass_through.skip_inputs). PromptManager nodes
are authoritative; several different texts become one candidate each
("Several prompts reach the sampler"). A seeded text node with no conditioning
input is generated at run time, and a recorded PromptManager execution is not
checked against text that passed through an unknown node.

INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile.
A legacy parity gate records every committed graph whose inspection positive
matches the legacy parser's; no graph may leave it.

* fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381)

Review of the pass-through: a walk through an unknown node never overrides a
PromptManagerPair's execution record (any outcome but work-limit is
unverifiable while the record is checked); the run-time seed rule reads only
string outputs; a node with positive/negative inputs follows only the role of
the requested slot, nothing when it is unlinked; one text offered twice is one
candidate.

* fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381)

An inspected file's name now anchors the inspection on the one saver whose
filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_];
the samplers in that saver's image ancestry are the candidates. No match,
several matches or an unreadable walk anchor nothing, so the file stays
"several possible prompts". In a latent walk, an earlier sampler reading the
same conditioning links as the later one is the same prompt, not an
unattributed stage. Python and the browser resolver behave the same; the
legacy parity gate states each graph that differs from legacy on purpose.

* fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381)

Review of the saver anchor: a same-links earlier stage is skipped only when it
is not a detailer, sets no prompt override and continues no other sampler, so
an earlier prompt or override behind it is never hidden. A work limit while
tracing a saver prefix ends the match scan without an anchor instead of
counting that saver as no match.

* test(inspector): cover every plain-first-stage outcome on both resolvers (#381)

The frontend coverage gate found local-resolver-stages.mjs branches at 92.44%
(< 93). Each way an earlier stage stops being a plain first stage (no latent,
literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a
resolver test and an anchor-first-stage-* parity vector.
2026-10-27 16:56:37 +00:00

152 lines
6.2 KiB
Python

"""Saver anchoring by file name and shared-conditioning stages (#381).
An inspected file's name anchors the walk on the one saver whose
``filename_prefix`` (literal or traced) makes ``<prefix>_<digits>[_]``; the
samplers in that saver's image ancestry are the candidates. When no saver or
more than one matches, nothing is guessed: the file stays "several possible
prompts". A later stage reading the same positive and negative links as the
earlier stage has the same prompt whatever its latent ancestry. Shapes come
from the maintainer's runs (``tests/saver_anchor_graphs.py``).
"""
import json
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
try:
from tests import saver_anchor_graphs as shapes
except ImportError: # discovered with ``-s tests``
import saver_anchor_graphs as shapes
def _inspect(graph, file_name=None):
return resolver.resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, file_name=file_name
)
def _positive(result):
side = result["positive"]
return side["text"] if isinstance(side, dict) else None
class TestSaverAnchor(unittest.TestCase):
def test_the_file_name_picks_its_branch(self):
for index, text in enumerate(shapes.BRANCH_TEXTS, start=1):
for name in (
f"s45_branch_{index}_00002_.png",
f"s45_branch_{index}_00002.webp",
):
with self.subTest(file=name):
result = _inspect(shapes.branch_bench(), name)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_positive(result), text)
self.assertEqual(result["sampler_id"], str(index * 10 + 1))
def test_unanchored_branches_stay_several(self):
self.assertEqual(_inspect(shapes.branch_bench())["state"], "ambiguous")
def test_a_traced_prefix_anchors(self):
result = _inspect(shapes.linked_prefix_bench(), "s45_branch_3_00007_.png")
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[2])
def test_an_unknown_saver_class_anchors_too(self):
result = _inspect(
shapes.branch_bench(saver="VendorSaveImage"), "s45_branch_2_00001_.png"
)
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[1])
def test_no_guessing_when_the_name_matches_none_or_several(self):
cases = {
"no saver matches": (shapes.branch_bench(), "photo_00001_.png"),
"not a counter": (shapes.branch_bench(), "s45_branch_3_final.png"),
"prefix of another branch": (
shapes.branch_bench(),
"s45_branch_00001_.png",
),
"shared prefix": (shapes.shared_prefix(), "IMG_GEN_00371_.png"),
}
for label, (graph, name) in cases.items():
with self.subTest(case=label):
result = _inspect(graph, name)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), len(shapes.BRANCH_TEXTS))
def test_the_inspector_passes_the_file_name(self):
records = {"prompt": json.dumps(shapes.branch_bench())}
evidence = inspect_prompt_evidence(records, file_name="s45_branch_4_00009_.png")
self.assertEqual(
evidence["prompts"]["positive"]["text"], shapes.BRANCH_TEXTS[3]
)
self.assertEqual(
inspect_prompt_evidence(records)["resolution"]["state"], "ambiguous"
)
def test_ingestion_ignores_the_file_name(self):
result = resolver.resolve_prompt_pair(
{"prompt": shapes.branch_bench()}, file_name="s45_branch_1_00001_.png"
)
self.assertEqual(result["state"], "ambiguous")
class TestSharedConditioningStages(unittest.TestCase):
def test_a_later_stage_on_the_same_links_resolves(self):
for name in (None, "final_00003_.png"):
with self.subTest(file=name):
result = _inspect(shapes.two_stage_same_conditioning(), name)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[0])
def test_different_links_stay_unattributed(self):
graph = shapes.two_stage_same_conditioning(second_positive=["91", 0])
result = _inspect(graph, "final_00003_.png")
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
if __name__ == "__main__":
unittest.main()
class TestReviewFindings(unittest.TestCase):
"""Shapes from the independent review of the saver-anchor commit."""
def test_a_skipped_stage_never_hides_its_own_earlier_prompt(self):
result = _inspect(shapes.hidden_earlier_prompt(), "out_00001_.png")
self.assertNotEqual(result["state"], "resolved")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_a_skipped_stage_never_hides_its_override(self):
result = _inspect(shapes.hidden_earlier_prompt(override=True), "out_00001_.png")
self.assertNotEqual(result["state"], "resolved")
def test_an_unfinished_prefix_scan_anchors_nothing(self):
graph = shapes.deep_prefixes(9)
for visits in range(10, 30):
with self.subTest(visits=visits):
result = resolver.resolve_prompt_pair(
{"prompt": graph},
profile=resolver.INSPECTION_PROFILE,
max_visits=visits,
file_name="dup_00001_.png",
)
self.assertNotEqual(result["state"], "resolved")
class TestPlainFirstStage(unittest.TestCase):
def test_only_a_plain_first_stage_is_skipped(self):
expected = {
"no-latent": "resolved",
"literal-latent": "unresolved",
"missing-latent": "unresolved",
"cyclic-latent": "unresolved",
"stage-shaped": "unresolved",
"detailer": "unresolved",
}
for kind, state in expected.items():
with self.subTest(kind=kind):
result = _inspect(shapes.first_stage_variant(kind), "final_00003_.png")
self.assertEqual(result["state"], state, result["reason"])