* feat(resolver): trace multi-stage chains through pass-through nodes The inspection resolver (server and local Inspect) now compares a sampler with the earlier sampler its latent continues through image-space pass-through nodes: decode, image filter, model upscale, scale and encode, listed in a new validated `lineage` section of the semantics table. Equal prompts resolve (hires-fix, refiner); different prompts are multistage-differs. A later stage whose positive adds conditioning to the shared base prompt through an additive composite (ConditioningCombine or Concat) keeps the base pair, the text legacy linked (three-stage Flux). Image-edit batches stay ambiguous and run-time text stays untraceable. Ingestion keeps the default profile (D1). A card no longer shows a seed when the inspector names a chain stage whose seed differs from the parser's pick. * feat(resolver): leave out run-time PromptManager parts, labelled Maintainer option A: when a PromptManager prepend_text or append_text is fed directly by a node whose output is decided at run time (LoraManager's TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt resolves to the node's own text, as legacy showed. The side's source is runtime-parts-omitted and the resolved reason says that text added at run time, such as trigger words, is not included; the prompt block shows that line (D-NEW-R1). The toggle's output is never computed. Literal and traced parts are combined as before; an indirect run-time part, a run-time main text or an unknown feeder keep their outcome. The browser port changes identically. Ingestion is unchanged. * fix(resolver): keep run-time and added-conditioning labels honest A text that left out a run-time PromptManager part is now labelled when any node on its traced path omitted it, so the label survives encoders, concatenations and chained PromptManagers. Comparisons include what a text leaves out, so a partial prompt never equals a complete one: a branch with trigger words and one without stay ambiguous, and such stages are multistage-differs. Only classes listed as run time are left out. The added-conditioning floor labels its positive (D126, "Conditioning added at a later stage is not included."); the run-time line is D125. A card hides its seed only when the stage the inspector names reads a different seed than the parser; an agreeing seed is kept, as on main. The browser port moves its lineage, latent and part helpers into local-resolver-stages.mjs, keeping parity.
294 lines
14 KiB
Python
294 lines
14 KiB
Python
"""Card facts from header records: the shared legacy parser, bounded and exact."""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from utils.card_seed import ( # noqa: E402
|
|
MAX_RECORD_BYTES,
|
|
card_seed,
|
|
header_params,
|
|
parsed_positive,
|
|
)
|
|
from utils.generation_params import ( # noqa: E402
|
|
GenerationParamsError,
|
|
extract_from_records,
|
|
extract_generation_params,
|
|
)
|
|
from utils.inspection_response import ( # noqa: E402
|
|
MAX_PROJECTION_DEPTH,
|
|
project_graph,
|
|
)
|
|
|
|
try:
|
|
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
|
except ImportError: # discovered with tests/ as the top-level directory
|
|
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
|
|
|
|
|
def graph(*samplers, extra=None):
|
|
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
|
|
for index, sampler in enumerate(samplers):
|
|
nodes[str(10 + index)] = sampler
|
|
nodes.update(extra or {})
|
|
return {"prompt": json.dumps(nodes)}
|
|
|
|
|
|
def seeded(seed, **settings):
|
|
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
|
|
|
|
|
|
def nested_workflow(depth=900):
|
|
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
|
|
nested = "[" * depth + "1" + "]" * depth
|
|
return (
|
|
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
|
|
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
|
|
)
|
|
|
|
|
|
def info_seed(info):
|
|
return card_seed(header_params(info))
|
|
|
|
|
|
class CardSeed(unittest.TestCase):
|
|
def test_card_seed_single_sampler(self):
|
|
info = graph(seeded(42001))
|
|
self.assertEqual(info_seed(info), "42001")
|
|
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
|
|
|
|
def test_card_seed_two_different_seeds_follows_shared_parser(self):
|
|
# Ruling: no separate heuristic; the card shows what the legacy parser
|
|
# (and so the inspector) shows for several samplers.
|
|
info = graph(seeded(111), seeded(222))
|
|
expected = extract_from_records(info["prompt"], None)["seed"]
|
|
self.assertIn(expected, (111, 222))
|
|
self.assertEqual(info_seed(info), str(expected))
|
|
|
|
def test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed(self):
|
|
# The inspector shows the named final stage's seed (D19, pass-through
|
|
# chains); the parser shows another stage's. Never two seeds: none.
|
|
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
|
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
|
extra["21"] = {
|
|
"class_type": "FaceDetailer",
|
|
"inputs": {
|
|
"seed": 333,
|
|
"image": ["20", 0],
|
|
"model": ["4", 0],
|
|
"positive": ["6", 0],
|
|
"negative": ["7", 0],
|
|
},
|
|
}
|
|
info = graph(seeded(111), extra=extra)
|
|
self.assertEqual(extract_from_records(info["prompt"], None)["seed"], 111)
|
|
self.assertIsNone(info_seed(info))
|
|
# The same chain on one seed keeps it; so does a refiner over a decode.
|
|
extra["21"]["inputs"]["seed"] = 111
|
|
self.assertEqual(info_seed(graph(seeded(111), extra=extra)), "111")
|
|
refiner = seeded(222, latent_image=["22", 0])
|
|
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
|
del extra["21"]
|
|
self.assertIsNone(info_seed(graph(seeded(111), refiner, extra=extra)))
|
|
refiner["inputs"]["seed"] = 111
|
|
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
|
|
|
def test_a_seed_that_agrees_with_the_named_stage_is_kept(self):
|
|
# Review I3: a saved sampler (seed 7) beside a preview-only sampler
|
|
# (seed 5): the parser and the inspector both show 7, as on main.
|
|
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
|
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
|
extra["21"] = {"class_type": "SaveImage", "inputs": {"images": ["20", 0]}}
|
|
extra["30"] = {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0]}}
|
|
extra["31"] = {"class_type": "PreviewImage", "inputs": {"images": ["30", 0]}}
|
|
info = graph(seeded(7), seeded(5), extra=extra)
|
|
self.assertEqual(info_seed(info), "7")
|
|
|
|
def test_a_named_ambiguous_chain_keeps_the_parser_seed(self):
|
|
# Review M2: multistage-differs names a sampler, but its facts merge,
|
|
# and the inspector fills the parser's seed: the card keeps it.
|
|
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
|
extra["8"] = encoder("another scene")
|
|
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
|
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
|
refiner = ksampler(("8", 0), ("7", 0), seed=222, latent_image=["22", 0])
|
|
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
|
|
|
def test_card_seed_linked_input_follows_shared_parser(self):
|
|
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
|
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
|
self.assertEqual(info_seed(info), "555")
|
|
dangling = graph(seeded(["99", 0]))
|
|
self.assertEqual(
|
|
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
|
|
)
|
|
self.assertIsNone(info_seed(dangling))
|
|
|
|
def test_card_seed_advanced_sampler_noise_seed(self):
|
|
advanced = seeded(None)
|
|
advanced["class_type"] = "KSamplerAdvanced"
|
|
del advanced["inputs"]["seed"]
|
|
advanced["inputs"]["noise_seed"] = 77
|
|
self.assertEqual(info_seed(graph(advanced)), "77")
|
|
|
|
def test_card_seed_oversized_record_is_none(self):
|
|
padding = "x" * (MAX_RECORD_BYTES + 1)
|
|
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
|
|
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
|
|
self.assertIsNone(header_params(big_prompt))
|
|
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
|
|
self.assertIsNone(header_params(big_workflow))
|
|
# Bytes, not characters: two-byte text over the budget is oversized.
|
|
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
|
|
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
|
|
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
|
|
self.assertEqual(info_seed(graph(seeded(1))), "1")
|
|
|
|
def test_records_past_the_inspection_projection_bound_give_nothing(self):
|
|
# The inspector hands the browser parser only graphs its projection
|
|
# parsed; a deeper graph is "limited" there, so the card skips it too.
|
|
self.assertEqual(
|
|
project_graph(nested_workflow(), "workflow")["status"], "limited"
|
|
)
|
|
self.assertIsNone(header_params({"workflow": nested_workflow()}))
|
|
# A limited workflow beside a parsed prompt leaves the prompt's facts.
|
|
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
|
|
self.assertEqual(card_seed(both), "1")
|
|
|
|
def test_depth_bound_is_the_inspectors(self):
|
|
def deep(levels):
|
|
nested = "[" * levels + "1" + "]" * levels
|
|
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
|
|
text = graph(seeded(3))["prompt"]
|
|
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
|
|
|
|
# root object, node object, inputs object: three levels before "v".
|
|
inside = MAX_PROJECTION_DEPTH - 3
|
|
self.assertEqual(info_seed(deep(inside)), "3")
|
|
self.assertIsNone(header_params(deep(inside + 1)))
|
|
|
|
def test_strict_projection_rules_apply(self):
|
|
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
|
|
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
|
|
self.assertIsNone(header_params({"prompt": duplicate}))
|
|
|
|
def test_record_size_is_utf8_bytes_at_both_bounds(self):
|
|
frame = '{"a":"%s"}'
|
|
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
|
|
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
|
|
self.assertIsNotNone(header_params({"prompt": ascii_at}))
|
|
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
|
|
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
|
|
self.assertIsNotNone(header_params({"prompt": four_byte}))
|
|
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
|
|
self.assertIsNone(header_params({"prompt": padded}))
|
|
|
|
def test_placeholder_and_non_integer_seeds_are_none(self):
|
|
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
|
|
with self.subTest(seed=value):
|
|
self.assertIsNone(info_seed(graph(seeded(value))))
|
|
self.assertIsNone(card_seed({"seed": "Unknown"}))
|
|
self.assertIsNone(card_seed({"seed": 7.0}))
|
|
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
|
|
|
|
def test_digit_string_seed_is_what_the_inspector_reads(self):
|
|
# The projection carries big integers as text and keeps text as text;
|
|
# the inspector shows a digit string as the seed, and so does the card.
|
|
self.assertEqual(info_seed(graph(seeded("123"))), "123")
|
|
|
|
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
|
|
# JSON.parse would round it; the projection carries the record's digits.
|
|
case = CASES["seed-beyond-double-precision"]
|
|
rounded = extract_from_records(case["prompt"], None)["seed"]
|
|
self.assertEqual(rounded, float(4974052037430000001))
|
|
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
|
|
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
|
|
|
|
def test_seed_is_always_a_decimal_string(self):
|
|
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
|
|
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
|
|
with self.subTest(seed=value):
|
|
self.assertEqual(card_seed({"seed": value}), str(value))
|
|
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
|
|
|
|
def test_digit_string_seed_is_bounded(self):
|
|
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
|
|
# must not reach int() (Python's int max-str-digits raises ValueError).
|
|
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
|
|
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
|
|
self.assertIsNone(card_seed({"seed": "1" * 21}))
|
|
self.assertIsNone(card_seed({"seed": "9" * 5000}))
|
|
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
|
|
|
|
def test_seed_conversion_error_gives_none(self):
|
|
class Huge(int):
|
|
def __str__(self):
|
|
raise ValueError("Exceeds the limit for integer string conversion")
|
|
|
|
self.assertIsNone(card_seed({"seed": Huge(5)}))
|
|
|
|
def test_workflow_only_record(self):
|
|
case = next(
|
|
case
|
|
for case in CASES.values()
|
|
if case["prompt"] is None
|
|
and case["workflow"] is not None
|
|
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
|
|
)
|
|
expected = extract_from_records(None, case["workflow"])["seed"]
|
|
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
|
|
|
|
def test_records_the_parser_cannot_use_give_nothing(self):
|
|
self.assertIsNone(header_params({}))
|
|
self.assertIsNone(header_params(None))
|
|
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
|
|
self.assertIsNone(header_params({"prompt": b"{}"}))
|
|
self.assertIsNone(card_seed(None))
|
|
self.assertIsNone(parsed_positive(None))
|
|
# The browser parser throws here; the card shows nothing.
|
|
thrown = CASES["null-workflow-node"]
|
|
self.assertIsNone(header_params(thrown))
|
|
unparsable = header_params(CASES["unparsable-records"])
|
|
self.assertIsNone(card_seed(unparsable))
|
|
self.assertIsNone(parsed_positive(unparsable))
|
|
|
|
def test_every_shared_case_reads_the_projected_graphs(self):
|
|
withheld = []
|
|
for name, case in CASES.items():
|
|
with self.subTest(case=name):
|
|
info = {k: v for k, v in case.items() if v is not None}
|
|
params = header_params(info)
|
|
graphs = {key: project_graph(text, key) for key, text in info.items()}
|
|
values = {
|
|
key: graph["value"]
|
|
for key, graph in graphs.items()
|
|
if graph["status"] == "parsed"
|
|
}
|
|
if not values:
|
|
self.assertIsNone(params)
|
|
continue
|
|
try:
|
|
expected = extract_generation_params(values)
|
|
except GenerationParamsError:
|
|
self.assertIsNone(params)
|
|
continue
|
|
if name == "shared-prompt-pipeline":
|
|
# The inspector names the detailer (seed 333); the parser
|
|
# reads the base sampler (111): the card shows none.
|
|
self.assertEqual(expected["seed"], 111)
|
|
expected = {**expected, "seed": None}
|
|
withheld.append(name)
|
|
self.assertEqual(params, expected)
|
|
# Prompt text is the same as from a plain parse of the records.
|
|
if len(values) == len(info):
|
|
raw = extract_from_records(case["prompt"], case["workflow"])
|
|
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
|
self.assertEqual(withheld, ["shared-prompt-pipeline"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|