* 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.
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"generation_params": [
|
||||
"case:basic",
|
||||
"case:shared-prompt-pipeline",
|
||||
"tests/fixtures/inspection_parity/f02.png",
|
||||
"tests/fixtures/inspection_parity/f03.png",
|
||||
"tests/fixtures/inspection_parity/f06c.png",
|
||||
"tests/fixtures/inspection_parity/f06e.png",
|
||||
"tests/fixtures/inspection_parity/f07a.png",
|
||||
"tests/fixtures/inspection_parity/f07b.png",
|
||||
"tests/fixtures/inspection_parity/f08.png",
|
||||
"tests/fixtures/inspection_parity/f09.png",
|
||||
"tests/fixtures/inspection_parity/f10.png",
|
||||
"tests/fixtures/inspection_parity/f11.png",
|
||||
"tests/fixtures/inspection_parity/f15.png",
|
||||
"tests/fixtures/inspection_parity/f16.png",
|
||||
"tests/fixtures/inspection_parity/f23.png"
|
||||
],
|
||||
"inspection_parity": [
|
||||
"f02",
|
||||
"f03",
|
||||
"f06c",
|
||||
"f06e",
|
||||
"f07a",
|
||||
"f07b",
|
||||
"f08",
|
||||
"f09",
|
||||
"f10",
|
||||
"f11",
|
||||
"f14",
|
||||
"f15",
|
||||
"f16",
|
||||
"f23"
|
||||
],
|
||||
"local_resolver": [
|
||||
"anchor-first-stage-no-latent",
|
||||
"anchor-same-links",
|
||||
"anchor-same-links-unanchored",
|
||||
"built-chars",
|
||||
"class-type-missing",
|
||||
"class-type-number",
|
||||
"combine-equal",
|
||||
"controlnet-slot1",
|
||||
"custom-cfg",
|
||||
"duplicate-key",
|
||||
"encode",
|
||||
"int-id",
|
||||
"latent-encode",
|
||||
"overwritten-bad-workflow",
|
||||
"pair-execution",
|
||||
"pair-execution-bad-json",
|
||||
"pair-execution-mismatch",
|
||||
"pair-execution-negative-changed",
|
||||
"pair-execution-other-node",
|
||||
"pair-execution-truncated",
|
||||
"pair-execution-two-records",
|
||||
"pair-execution-version-float",
|
||||
"pair-execution-version-true",
|
||||
"pair-graph",
|
||||
"pass-through-unknown-role-inputs",
|
||||
"passthrough",
|
||||
"prompt-and-parameters",
|
||||
"prompt-manager",
|
||||
"select-detailer-chain-same",
|
||||
"select-detailer-no-sampler-upstream",
|
||||
"select-detailer-output-walk-exhausted",
|
||||
"select-detailer-same-prompt",
|
||||
"select-edge-hires-fix-denoised-slot",
|
||||
"select-edge-hires-fix-lineage-cycle",
|
||||
"select-edge-hires-fix-two-refiners",
|
||||
"select-flux-concat",
|
||||
"select-flux-reroute-input",
|
||||
"select-flux-three-stage",
|
||||
"select-hires-fix",
|
||||
"select-hires-fix-both-saved",
|
||||
"select-hires-fix-reroute",
|
||||
"select-hires-fix-unlisted",
|
||||
"select-multistage-latent",
|
||||
"select-preview-dropped",
|
||||
"select-preview-through-bad-node",
|
||||
"select-runtime-flux-other-full-prompt",
|
||||
"select-runtime-flux-trigger",
|
||||
"select-runtime-search-list-append",
|
||||
"select-runtime-through-chained",
|
||||
"select-runtime-through-concat",
|
||||
"select-runtime-through-text-encode",
|
||||
"select-runtime-traced-prepend",
|
||||
"select-runtime-trigger-append-single",
|
||||
"select-runtime-trigger-pair",
|
||||
"select-runtime-trigger-prepend",
|
||||
"select-three-identical-one-advanced",
|
||||
"select-two-identical",
|
||||
"select-unconsumed-stage-kept",
|
||||
"several-samplers",
|
||||
"smz-false",
|
||||
"text-at-limit",
|
||||
"unknown-encoder",
|
||||
"workflow-type-dict",
|
||||
"workflow-type-in-subgraph",
|
||||
"workflow-type-list",
|
||||
"workflow-type-without-id",
|
||||
"zeroed"
|
||||
],
|
||||
"unknown_nodes": [
|
||||
"unknown-role-inputs"
|
||||
]
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@ const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '.
|
||||
const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
|
||||
|
||||
/** The local evidence for one set of record texts. */
|
||||
const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) });
|
||||
const resolved = (records, fileName = null) => ({ evidence: resolveLocalEvidence(records, semantics, fileName) });
|
||||
|
||||
/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */
|
||||
function localPayload(read, file, classification) {
|
||||
@@ -59,14 +59,14 @@ const output = {};
|
||||
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
|
||||
const { name } = entry;
|
||||
if (entry.mode === 'resolve') {
|
||||
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
|
||||
if (entry.records !== undefined) { output[name] = resolved(entry.records, entry.fileName ?? null); continue; }
|
||||
const blob = await fs.openAsBlob(entry.path);
|
||||
const { classification, result } = await readLocalFile(blob, { pngMetadata });
|
||||
const read = classification === null ? null : toInspectionRead(classification, result).read;
|
||||
if (classification === null) { output[name] = { unreadable: true }; continue; }
|
||||
// The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records.
|
||||
const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification);
|
||||
const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence;
|
||||
const direct = read === null ? unreadableEvidence() : resolved(read.records, path.basename(entry.path)).evidence;
|
||||
if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`);
|
||||
output[name] = read === null ? { unreadable: true, ...local } : local;
|
||||
continue;
|
||||
|
||||
@@ -155,7 +155,9 @@ test('committed server answers equal the local evidence', () => {
|
||||
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
||||
let compared = 0;
|
||||
for (const {name, records, evidence} of vectors) {
|
||||
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
|
||||
// A case may name the inspected file (`__file_name`, #381 saver anchoring); it is no record.
|
||||
const {__file_name: fileName = null, ...roles} = records;
|
||||
assert.deepEqual(canonical(resolver.resolveLocalEvidence(roles, semantics, fileName)), canonical(evidence), name);
|
||||
compared++;
|
||||
}
|
||||
assert.ok(compared > 280, `compared ${compared}`);
|
||||
|
||||
@@ -26,6 +26,8 @@ VECTORS_PATH = (
|
||||
/ "local-inspect"
|
||||
/ "resolver-vectors.json"
|
||||
)
|
||||
# A case may name the inspected file (#381 saver anchoring); it is no record.
|
||||
FILE_NAME = "__file_name"
|
||||
# Committed record sets stay small; the large bound cases are built at test time.
|
||||
MAX_VECTOR_CHARS = 20_000
|
||||
COMMITTED_FUZZ = (0, 60)
|
||||
@@ -906,6 +908,60 @@ def golden_cases():
|
||||
},
|
||||
),
|
||||
)
|
||||
# #381: unknown nodes between the prompt and the sampler pass through.
|
||||
try:
|
||||
from tests import unknown_node_graphs
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import unknown_node_graphs
|
||||
for name, graph in unknown_node_graphs.shapes().items():
|
||||
add(f"pass-through-{name}", graph)
|
||||
for name, records in {
|
||||
**unknown_node_graphs.recorded_shapes(),
|
||||
**unknown_node_graphs.review_shapes(),
|
||||
}.items():
|
||||
cases[f"pass-through-{name}"] = records
|
||||
try:
|
||||
from tests import saver_anchor_graphs as anchor
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import saver_anchor_graphs as anchor
|
||||
for name, graph, file_name in [
|
||||
("branch-3", anchor.branch_bench(), "s45_branch_3_00002_.png"),
|
||||
("branch-unanchored", anchor.branch_bench(), None),
|
||||
("branch-no-match", anchor.branch_bench(), "photo_00001_.png"),
|
||||
("branch-linked", anchor.linked_prefix_bench(), "s45_branch_2_00007_.png"),
|
||||
(
|
||||
"branch-unknown-saver",
|
||||
anchor.branch_bench(saver="VendorSaveImage"),
|
||||
"s45_branch_4_00001.webp",
|
||||
),
|
||||
("shared-prefix", anchor.shared_prefix(), "IMG_GEN_00371_.png"),
|
||||
("same-links", anchor.two_stage_same_conditioning(), "final_00003_.png"),
|
||||
("same-links-unanchored", anchor.two_stage_same_conditioning(), None),
|
||||
(
|
||||
"other-links",
|
||||
anchor.two_stage_same_conditioning(["91", 0]),
|
||||
"final_00003_.png",
|
||||
),
|
||||
("hidden-earlier", anchor.hidden_earlier_prompt(), "out_00001_.png"),
|
||||
(
|
||||
"hidden-override",
|
||||
anchor.hidden_earlier_prompt(override=True),
|
||||
"out_00001_.png",
|
||||
),
|
||||
] + [
|
||||
(f"first-stage-{kind}", anchor.first_stage_variant(kind), "final_00003_.png")
|
||||
for kind in (
|
||||
"no-latent",
|
||||
"literal-latent",
|
||||
"missing-latent",
|
||||
"cyclic-latent",
|
||||
"stage-shaped",
|
||||
"detailer",
|
||||
)
|
||||
]:
|
||||
cases[f"anchor-{name}"] = {"prompt": _dump(graph)}
|
||||
if file_name is not None:
|
||||
cases[f"anchor-{name}"][FILE_NAME] = file_name
|
||||
cases["parameters-only"] = {
|
||||
"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler"
|
||||
}
|
||||
@@ -1059,7 +1115,8 @@ def boundary_cases():
|
||||
["7", 0],
|
||||
{
|
||||
"\U0001f600"
|
||||
* 70: {"class_type": long_name, "inputs": {"text": "x"}},
|
||||
# No text and no links: still named, not passed through (#381).
|
||||
* 70: {"class_type": long_name, "inputs": {"seed": 1}},
|
||||
"7": _encode("b"),
|
||||
},
|
||||
)
|
||||
@@ -1387,7 +1444,10 @@ def committed_vectors():
|
||||
"name": name,
|
||||
"records": records,
|
||||
"evidence": inspect_prompt_evidence(
|
||||
records, None, profile=INSPECTION_PROFILE
|
||||
{key: value for key, value in records.items() if key != FILE_NAME},
|
||||
None,
|
||||
profile=INSPECTION_PROFILE,
|
||||
file_name=records.get(FILE_NAME),
|
||||
),
|
||||
}
|
||||
for name, records in cases.items()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Synthetic graphs for saver anchoring and shared-conditioning stages (#381).
|
||||
|
||||
``branch_bench`` reproduces the maintainer's ``gpu_unlock_bench`` run: four
|
||||
KSampler branches with different prompts, each saved by its own SaveImage whose
|
||||
literal prefix names the branch (``bench/run/s45_branch_N``). ``shared_prefix``
|
||||
is the dataset run's shape: every branch's saver uses one prefix, so the file
|
||||
name cannot name the branch. ``two_stage_same_conditioning`` reproduces the
|
||||
ANIMA run: a hires stage whose latent passes an unlisted node and reads the
|
||||
same positive and negative links as the first stage. Texts are neutral.
|
||||
"""
|
||||
|
||||
BRANCH_TEXTS = (
|
||||
"a knit beanie on a wooden table",
|
||||
"an old fishing boat at dawn",
|
||||
"a red vintage car in the rain",
|
||||
"a cozy library with tall shelves",
|
||||
)
|
||||
NEGATIVE = "blurry, lowres"
|
||||
|
||||
|
||||
def _encode(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
|
||||
|
||||
|
||||
def _ksampler(positive, negative, latent, denoise=1.0):
|
||||
return {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 1,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": denoise,
|
||||
"model": ["1", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"latent_image": latent,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _base():
|
||||
return {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
|
||||
"3": _encode(NEGATIVE),
|
||||
}
|
||||
|
||||
|
||||
def branch_bench(prefixes=None, saver="SaveImage"):
|
||||
"""Four branches; ``prefixes`` replaces each saver's ``filename_prefix``."""
|
||||
prefixes = prefixes or [
|
||||
f"bench/run/s45_branch_{index}" for index in range(1, len(BRANCH_TEXTS) + 1)
|
||||
]
|
||||
graph = _base()
|
||||
for index, text in enumerate(BRANCH_TEXTS, start=1):
|
||||
base = index * 10
|
||||
graph[str(base)] = _encode(text)
|
||||
graph[str(base + 1)] = _ksampler([str(base), 0], ["3", 0], ["2", 0])
|
||||
graph[str(base + 2)] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph[str(base + 3)] = {
|
||||
"class_type": saver,
|
||||
"inputs": {
|
||||
"filename_prefix": prefixes[index - 1],
|
||||
"images": [str(base + 2), 0],
|
||||
},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def linked_prefix_bench():
|
||||
"""The branch run with each prefix coming from a PrimitiveString."""
|
||||
graph = branch_bench(prefixes=[["90", 0], ["91", 0], ["92", 0], ["93", 0]])
|
||||
for index in range(4):
|
||||
graph[str(90 + index)] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": f"bench/run/s45_branch_{index + 1}"},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def shared_prefix():
|
||||
"""Every branch saved with one prefix: the file name names no branch."""
|
||||
return branch_bench(prefixes=["dataset/IMG_GEN"] * len(BRANCH_TEXTS))
|
||||
|
||||
|
||||
def two_stage_same_conditioning(second_positive=None):
|
||||
"""Base and hires stage on the same links; the hires latent passes an
|
||||
unlisted node (``VendorGpuCleaner``), so the stages' lineage is unknown."""
|
||||
graph = _base()
|
||||
graph["90"] = _encode(BRANCH_TEXTS[0])
|
||||
graph["63"] = _ksampler(["90", 0], ["3", 0], ["2", 0])
|
||||
graph["30"] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["63", 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph["98"] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "draft", "images": ["30", 0]},
|
||||
}
|
||||
graph["110"] = {"class_type": "VendorGpuCleaner", "inputs": {"anything": ["63", 0]}}
|
||||
graph["108"] = {
|
||||
"class_type": "LatentUpscaleBy",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"scale_by": 1.2,
|
||||
"samples": ["110", 0],
|
||||
},
|
||||
}
|
||||
graph["114"] = _ksampler(second_positive or ["90", 0], ["3", 0], ["108", 0], 0.5)
|
||||
graph["113"] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["114", 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph["115"] = {
|
||||
"class_type": "VendorGpuCleaner",
|
||||
"inputs": {"anything": ["113", 0]},
|
||||
}
|
||||
graph["107"] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "final", "images": ["115", 0]},
|
||||
}
|
||||
if second_positive is not None:
|
||||
graph["91"] = _encode(BRANCH_TEXTS[1])
|
||||
return graph
|
||||
|
||||
|
||||
def hidden_earlier_prompt(override=False):
|
||||
"""Review shape: the skipped same-links stage continues a different prompt.
|
||||
|
||||
10 (cat) -> 11 (dog) -> unlisted LatentBlend -> 13 (dog, same links as 11)
|
||||
-> VAEDecode -> SaveImage ``out``. With ``override``, 11 is a
|
||||
``KSampler (Efficient)`` with a linked ``script``.
|
||||
"""
|
||||
graph = _base()
|
||||
graph["5"] = _encode("cat X")
|
||||
graph["6"] = _encode("dog Y")
|
||||
graph["10"] = _ksampler(["5", 0], ["3", 0], ["2", 0])
|
||||
graph["11"] = _ksampler(["6", 0], ["3", 0], ["10", 0])
|
||||
if override:
|
||||
graph["11"] = _ksampler(["6", 0], ["3", 0], ["2", 0])
|
||||
graph["11"]["class_type"] = "KSampler (Efficient)"
|
||||
graph["11"]["inputs"]["script"] = ["40", 0]
|
||||
graph["40"] = {"class_type": "XY Plot", "inputs": {"grid_spacing": 0}}
|
||||
graph["12"] = {
|
||||
"class_type": "LatentBlend",
|
||||
"inputs": {"samples1": ["11", 0], "samples2": ["2", 0], "blend_factor": 0.5},
|
||||
}
|
||||
graph["13"] = _ksampler(["6", 0], ["3", 0], ["12", 0], 0.5)
|
||||
graph["14"] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["13", 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph["15"] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "out", "images": ["14", 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def deep_prefixes(chain):
|
||||
"""Review shape: two branches whose savers both trace the prefix ``dup``.
|
||||
|
||||
Saver A's prefix runs through ``chain`` DisplayText nodes; saver B's
|
||||
through one more, so a short visit allowance can finish only one trace.
|
||||
"""
|
||||
graph = branch_bench(prefixes=[["p0", 0], ["q", 0], "x_1", "x_2"])
|
||||
graph["pz"] = {"class_type": "StringConstant", "inputs": {"string": "dup"}}
|
||||
previous = "pz"
|
||||
for index in range(chain - 1, -1, -1):
|
||||
graph[f"p{index}"] = {
|
||||
"class_type": "DisplayText",
|
||||
"inputs": {"text": [previous, 0]},
|
||||
}
|
||||
previous = f"p{index}"
|
||||
graph["q"] = {"class_type": "DisplayText", "inputs": {"text": ["p0", 0]}}
|
||||
return graph
|
||||
|
||||
|
||||
def first_stage_variant(kind):
|
||||
"""``two_stage_same_conditioning`` whose first stage (63) is not plain.
|
||||
|
||||
``no-latent`` drops its latent input (still plain); ``literal-latent``,
|
||||
``missing-latent``, ``cyclic-latent``, ``stage-shaped`` and ``detailer``
|
||||
each make it something other than a plain first stage, so the later
|
||||
stage stays unattributed.
|
||||
"""
|
||||
graph = two_stage_same_conditioning()
|
||||
inputs = graph["63"]["inputs"]
|
||||
if kind == "no-latent":
|
||||
del inputs["latent_image"]
|
||||
elif kind == "literal-latent":
|
||||
inputs["latent_image"] = "latent"
|
||||
elif kind == "missing-latent":
|
||||
inputs["latent_image"] = ["999", 0]
|
||||
elif kind == "cyclic-latent":
|
||||
graph["70"] = {"class_type": "VendorLatentOp", "inputs": {"samples": ["63", 0]}}
|
||||
inputs["latent_image"] = ["70", 0]
|
||||
elif kind == "stage-shaped":
|
||||
graph["70"] = {
|
||||
"class_type": "VendorSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"positive": ["90", 0],
|
||||
"latent_image": ["2", 0],
|
||||
},
|
||||
}
|
||||
inputs["latent_image"] = ["70", 0]
|
||||
elif kind == "detailer":
|
||||
graph["63"]["class_type"] = "FaceDetailer"
|
||||
inputs["image"] = ["30", 0]
|
||||
return graph
|
||||
@@ -190,7 +190,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
|
||||
}
|
||||
else:
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
# No text and no links: nothing passes through it (#381).
|
||||
source["1"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
|
||||
png(
|
||||
path,
|
||||
prompt=source,
|
||||
@@ -215,7 +216,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
result["prompts"]["negative"], self.side("", "embedded-parameters")
|
||||
)
|
||||
source = graph()
|
||||
source["2"]["class_type"] = "UnknownEncoder"
|
||||
source["2"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
|
||||
png(path, prompt=source)
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
|
||||
|
||||
@@ -59,6 +59,7 @@ def _minimal():
|
||||
{"class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels"}
|
||||
],
|
||||
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
|
||||
"pass_through": {"skip_inputs": ["clip"]},
|
||||
"facts": {
|
||||
"configuration_outputs": {},
|
||||
"model_outputs": {},
|
||||
@@ -690,6 +691,18 @@ class ValidationTest(_TableCase):
|
||||
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
|
||||
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
|
||||
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
|
||||
(lambda d: d.pop("pass_through"), "category"),
|
||||
(lambda d: d["pass_through"].update(extra=["x"]), "pass_through"),
|
||||
(lambda d: d["pass_through"].update(skip_inputs="clip"), "input name"),
|
||||
(lambda d: d["pass_through"].update(skip_inputs=[]), "input name"),
|
||||
(
|
||||
lambda d: d["pass_through"].update(skip_inputs=["clip", "clip"]),
|
||||
"repeated",
|
||||
),
|
||||
(
|
||||
lambda d: d["pass_through"].update(skip_inputs=["conditioning"]),
|
||||
"carries text",
|
||||
),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Legacy parity gate for the inspection resolver (#381, the PR #170 floor).
|
||||
|
||||
The legacy parser (``utils.generation_params``, the port of the browser's
|
||||
``extractGenerationParams``) is the floor: a graph whose positive prompt it
|
||||
found, and that the inspection resolver resolved to the same text, must keep
|
||||
resolving to that text. ``legacy-agreement.json`` records those graphs per
|
||||
corpus: the generation-params vectors, the inspection parity fixtures, the
|
||||
local resolver golden and selection cases, and the unknown-node shapes of
|
||||
#381. A graph may join the list, never leave it; regenerate with::
|
||||
|
||||
python3 -m tests.test_legacy_parity_gate --write
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.generation_params import (
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
from tests import local_resolver_cases as cases
|
||||
from tests import local_resolver_selection_cases as selection
|
||||
from tests import unknown_node_graphs as unknown
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import inspection_parity as parity
|
||||
import local_resolver_cases as cases
|
||||
import local_resolver_selection_cases as selection
|
||||
import unknown_node_graphs as unknown
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
AGREEMENT_PATH = HERE / "fixtures" / "local-inspect" / "legacy-agreement.json"
|
||||
VECTORS_PATH = HERE / "fixtures" / "generation_params" / "vectors.json"
|
||||
NOT_FOUND = "No prompt found"
|
||||
|
||||
|
||||
def _parsed(value):
|
||||
return parse_js_json(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def corpora() -> dict:
|
||||
"""Corpus name to ``{case name: (prompt graph, workflow or None[, file])}``."""
|
||||
vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))
|
||||
named = {**cases.golden_cases(), **selection.selection_cases()}
|
||||
return {
|
||||
"generation_params": {
|
||||
item["name"]: (_parsed(item["prompt"]), _parsed(item.get("workflow")))
|
||||
for item in vectors
|
||||
},
|
||||
"inspection_parity": {
|
||||
name: (parity.build(name)["prompt"], parity.build(name)["workflow"])
|
||||
for name in parity.COMMITTED
|
||||
},
|
||||
"local_resolver": {
|
||||
name: (
|
||||
_parsed(records["prompt"]),
|
||||
_parsed(records.get("workflow")),
|
||||
records.get(cases.FILE_NAME),
|
||||
)
|
||||
for name, records in named.items()
|
||||
if "prompt" in records
|
||||
},
|
||||
"unknown_nodes": {
|
||||
name: (graph, None) for name, graph in unknown.shapes().items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def legacy_positive(graph, workflow):
|
||||
"""The positive prompt the legacy parser found, or ``None``."""
|
||||
try:
|
||||
found = extract_generation_params({"prompt": graph, "workflow": workflow})
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
text = found["positivePrompt"]
|
||||
return None if text == NOT_FOUND else text
|
||||
|
||||
|
||||
def inspected(graph, workflow=None, file_name=None) -> dict:
|
||||
"""The inspection resolver's answer, anchored by ``file_name`` when given."""
|
||||
metadata = {"prompt": graph}
|
||||
if workflow is not None:
|
||||
metadata["workflow"] = workflow
|
||||
return resolver.resolve_prompt_pair(
|
||||
metadata, profile=resolver.INSPECTION_PROFILE, file_name=file_name
|
||||
)
|
||||
|
||||
|
||||
# Graphs where the legacy parser's positive is wrong and the inspector's
|
||||
# differs on purpose: (corpus, case) -> (the inspector's positive, why).
|
||||
LEGACY_WRONG = {
|
||||
("local_resolver", "anchor-branch-3"): (
|
||||
"a red vintage car in the rain",
|
||||
"legacy shows the first sampler's prompt for every branch; the file "
|
||||
"name s45_branch_3_00002_ names branch 3's saver (gpu_unlock_bench)",
|
||||
),
|
||||
("local_resolver", "anchor-branch-linked"): (
|
||||
"an old fishing boat at dawn",
|
||||
"legacy shows branch 1; the traced prefix names branch 2's saver",
|
||||
),
|
||||
("local_resolver", "anchor-branch-unknown-saver"): (
|
||||
"a cozy library with tall shelves",
|
||||
"legacy shows branch 1; the file name names branch 4's saver",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def agreeing(graphs: dict) -> list:
|
||||
"""Names whose inspection positive equals the legacy parser's, sorted."""
|
||||
names = []
|
||||
for name, case in graphs.items():
|
||||
graph, workflow = case[0], case[1]
|
||||
if not isinstance(graph, dict):
|
||||
continue
|
||||
expected = legacy_positive(graph, workflow)
|
||||
if expected is None:
|
||||
continue
|
||||
result = inspected(*case)
|
||||
positive = result["positive"]
|
||||
if (
|
||||
result["state"] == "resolved"
|
||||
and isinstance(positive, dict)
|
||||
and positive["text"] == expected
|
||||
):
|
||||
names.append(name)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
class LegacyParityGateTest(unittest.TestCase):
|
||||
def test_every_legacy_difference_is_stated(self):
|
||||
"""A graph may read differently from legacy only with a stated reason."""
|
||||
all_graphs = corpora()
|
||||
for (corpus, name), (expected, why) in LEGACY_WRONG.items():
|
||||
with self.subTest(case=name, why=why):
|
||||
case = all_graphs[corpus][name]
|
||||
legacy = legacy_positive(case[0], case[1])
|
||||
self.assertIsNotNone(legacy)
|
||||
result = inspected(*case)
|
||||
positive = result["positive"]
|
||||
text = positive["text"] if isinstance(positive, dict) else None
|
||||
if result["state"] != "resolved":
|
||||
text = None
|
||||
self.assertEqual(text, expected)
|
||||
self.assertNotEqual(text, legacy)
|
||||
|
||||
def test_no_graph_leaves_the_legacy_agreement(self):
|
||||
recorded = json.loads(AGREEMENT_PATH.read_text(encoding="utf-8"))
|
||||
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
|
||||
self.assertEqual(set(recorded), set(current))
|
||||
for corpus, names in recorded.items():
|
||||
with self.subTest(corpus=corpus):
|
||||
lost = sorted(set(names) - set(current[corpus]))
|
||||
self.assertEqual(lost, [], "these graphs no longer match legacy")
|
||||
|
||||
def test_every_unknown_node_shape_legacy_reads_still_agrees(self):
|
||||
graphs = corpora()["unknown_nodes"]
|
||||
legacy = {name for name, pair in graphs.items() if legacy_positive(*pair)}
|
||||
self.assertLessEqual(legacy, set(agreeing(graphs)))
|
||||
|
||||
def test_the_reported_graph_reads_better_than_legacy(self):
|
||||
# Legacy found no positive here (cf0cef3 showed the positive text as
|
||||
# the negative); the floor is met, and the inspector now reads it.
|
||||
graph = unknown.any_switch_graph()
|
||||
self.assertIsNone(legacy_positive(graph, None))
|
||||
result = resolver.resolve_prompt_pair(
|
||||
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], unknown.POSITIVE)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--write", action="store_true", help="record the agreement")
|
||||
if parser.parse_args().write:
|
||||
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
|
||||
text = json.dumps(current, indent=1, sort_keys=True, ensure_ascii=False)
|
||||
AGREEMENT_PATH.write_text(text + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1118,8 +1118,17 @@ class LocalProjectionParityTests(unittest.TestCase):
|
||||
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
|
||||
|
||||
|
||||
def _server_evidence(records):
|
||||
return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
|
||||
def _server_evidence(records, file_name=None):
|
||||
return inspect_prompt_evidence(
|
||||
records, None, profile=INSPECTION_PROFILE, file_name=file_name
|
||||
)
|
||||
|
||||
|
||||
FILE_NAME = resolver_cases.FILE_NAME
|
||||
|
||||
|
||||
def _records(case):
|
||||
return {key: value for key, value in case.items() if key != FILE_NAME}
|
||||
|
||||
|
||||
class LocalResolverParityTests(unittest.TestCase):
|
||||
@@ -1143,7 +1152,12 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
observed = _run_oracle(
|
||||
self,
|
||||
[
|
||||
{"name": name, "mode": "resolve", "records": records}
|
||||
{
|
||||
"name": name,
|
||||
"mode": "resolve",
|
||||
"records": _records(records),
|
||||
"fileName": records.get(FILE_NAME),
|
||||
}
|
||||
for name, records in cases.items()
|
||||
],
|
||||
)
|
||||
@@ -1151,7 +1165,7 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
for name, records in cases.items():
|
||||
with self.subTest(case=name):
|
||||
local = observed[name]
|
||||
server = _server_evidence(records)
|
||||
server = _server_evidence(_records(records), records.get(FILE_NAME))
|
||||
answers[name] = server
|
||||
self.assertEqual(
|
||||
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||
@@ -1190,7 +1204,7 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
# The single-chain subset (Task 8); the rest is Task 9's test below.
|
||||
if not resolver_cases.single_chain(records):
|
||||
continue
|
||||
server = _server_evidence(records)
|
||||
server = _server_evidence(records, Path(path).name)
|
||||
self.assertEqual(
|
||||
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||
)
|
||||
@@ -1237,7 +1251,8 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
local, expect = observed[name], vector["expect"]
|
||||
try:
|
||||
server = _server_evidence(
|
||||
read_inspection_file(files[name]).metadata
|
||||
read_inspection_file(files[name]).metadata,
|
||||
Path(files[name]).name,
|
||||
)
|
||||
except MetadataReadError:
|
||||
server = unreadable_evidence()
|
||||
|
||||
@@ -1019,7 +1019,8 @@ class TestResolverProfiles(unittest.TestCase):
|
||||
|
||||
def test_unsupported_node_names_the_blocking_node(self):
|
||||
source = graph()
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
# No text and no links: nothing passes through it (#381).
|
||||
source["1"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
|
||||
result = resolve_prompt_pair(source, profile=resolver.INSPECTION_PROFILE)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "1", "class_type": "UnknownEncoder"})
|
||||
@@ -1161,14 +1162,9 @@ class TestGuiderSamplers(unittest.TestCase):
|
||||
|
||||
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]},
|
||||
}
|
||||
# No links: nothing passes through them (#381).
|
||||
source["30"] = {"class_type": "FutureConditioner", "inputs": {"seed": 1}}
|
||||
source["31"] = {"class_type": "OtherConditioner", "inputs": {"seed": 2}}
|
||||
source["21"]["inputs"]["positive"] = ["30", 0]
|
||||
source["21"]["inputs"]["negative"] = ["31", 0]
|
||||
result = _inspect(source)
|
||||
@@ -1548,7 +1544,8 @@ class TestConditioningSemantics(unittest.TestCase):
|
||||
def test_diverging_branch_that_cannot_be_traced_is_never_guessed(self):
|
||||
extra = {
|
||||
"10": _encoded(parity.POS),
|
||||
"11": {"class_type": "FutureEncoder", "inputs": {"text": WOLF}},
|
||||
# No text and no links: nothing passes through it (#381).
|
||||
"11": {"class_type": "FutureEncoder", "inputs": {"strength": 1.0}},
|
||||
"12": _image_exists(),
|
||||
}
|
||||
source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
|
||||
@@ -2048,12 +2045,17 @@ class TestEncoderAndStringSemantics(unittest.TestCase):
|
||||
result = _inspect(_with_positive(node))
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_unverified_text_nodes_stay_unsupported_and_named(self):
|
||||
# TextBoxMira was verified in its source in Task 2.7b and is listed.
|
||||
def test_unknown_text_nodes_pass_their_text_through(self):
|
||||
# #381: an unlisted node's ``text`` input is its text (the legacy rule);
|
||||
# without one, and with nothing linked, it is still named.
|
||||
for name in ("LoRAfromText", "RegexReplace", "XYZPrompt"):
|
||||
with self.subTest(node=name):
|
||||
node = {"class_type": name, "inputs": {"text": parity.POS}}
|
||||
result = _inspect(_string_positive(node))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], parity.POS)
|
||||
node = {"class_type": name, "inputs": {"pattern": parity.POS}}
|
||||
result = _inspect(_string_positive(node))
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "10", "class_type": name})
|
||||
|
||||
@@ -2313,7 +2315,8 @@ class TestDetailerStages(unittest.TestCase):
|
||||
|
||||
def test_unresolved_stage_blocks_the_chain(self):
|
||||
source = _detailed(same_prompt=True)
|
||||
source["30"] = {"class_type": "FutureConditioner", "inputs": {"c": ["6", 0]}}
|
||||
# No links: nothing passes through it (#381).
|
||||
source["30"] = {"class_type": "FutureConditioner", "inputs": {"c": 1.0}}
|
||||
source["3"]["inputs"]["positive"] = ["30", 0]
|
||||
source["50"]["inputs"]["positive"] = ["10", 0]
|
||||
result = _inspect(source)
|
||||
@@ -2662,11 +2665,13 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
},
|
||||
{
|
||||
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
# Its own encoder (#381: the same links would be one prompt).
|
||||
"73": parity._enc(parity.POS),
|
||||
"70": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"positive": ["73", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["72", 0],
|
||||
},
|
||||
@@ -2969,7 +2974,8 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
source["60"]["class_type"] = "UltimateSDUpscale"
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
|
||||
# Reached only through an unlisted image node, it stays unattributed.
|
||||
# Reached only through an unlisted image node, it stays unattributed:
|
||||
# a detailer is never a plain first stage, even on the same links (#381).
|
||||
source["61"] = {"class_type": "MysteryFilter", "inputs": {"image": ["60", 0]}}
|
||||
source["5"]["inputs"]["pixels"] = ["61", 0]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Unknown nodes between the prompt and the sampler pass through (#381).
|
||||
|
||||
The inspection resolver no longer needs a node class on an allow-list to read
|
||||
through it. An output the semantics table does not describe passes through: a
|
||||
``text`` input is the text, a ``positive``/``negative`` input routes by output
|
||||
slot, a ``conditioning`` input is followed alone, otherwise every linked input
|
||||
except core type inputs (``pass_through.skip_inputs``) is followed. One text,
|
||||
or several equal texts, resolve; PromptManager nodes are authoritative; several
|
||||
different texts are one candidate each. Ingestion (the default profile) is
|
||||
unchanged. Every graph here is synthetic, with neutral texts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from tests import unknown_node_graphs as graphs
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import unknown_node_graphs as graphs
|
||||
|
||||
POSITIVE, OTHER, NEGATIVE = graphs.POSITIVE, graphs.OTHER, graphs.NEGATIVE
|
||||
any_switch_graph = graphs.any_switch_graph
|
||||
_encode, _pair, _with = graphs.encode, graphs.pair, graphs.with_nodes
|
||||
|
||||
|
||||
def _inspect(graph, **options):
|
||||
return resolve_prompt_pair(
|
||||
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
|
||||
)
|
||||
|
||||
|
||||
def _texts(result):
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
class TestTheReportedGraph(unittest.TestCase):
|
||||
def test_prompt_manager_pair_through_any_switch_resolves(self):
|
||||
result = _inspect(any_switch_graph())
|
||||
self.assertEqual(result["state"], "resolved", result["reason"])
|
||||
self.assertEqual(_texts(result), (POSITIVE, ""))
|
||||
self.assertEqual(result["sampler_id"], "17")
|
||||
self.assertIsNone(result["reason_code"])
|
||||
path = [step["node_id"] for step in result["positive"]["provenance"]["path"]]
|
||||
self.assertEqual(path, ["15", "10"])
|
||||
|
||||
def test_the_inspector_shows_the_prompt(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": json.dumps(any_switch_graph())})
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["text"], POSITIVE)
|
||||
|
||||
def test_ingestion_is_unchanged(self):
|
||||
result = resolve_prompt_pair({"prompt": any_switch_graph()})
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
# Its first failure is the custom latent node, as before this change.
|
||||
self.assertEqual(result["reason"], "Unsupported latent ancestry")
|
||||
|
||||
def test_the_inspection_version_moves(self):
|
||||
self.assertEqual(resolver.INSPECTION_RESOLVER_VERSION, 2)
|
||||
self.assertEqual(resolver.RESOLVER_VERSION, 1)
|
||||
|
||||
|
||||
class TestSourcesThroughUnknownNodes(unittest.TestCase):
|
||||
def test_several_inputs_with_one_text_resolve(self):
|
||||
graph = any_switch_graph(any_01=["10", 0], any_02=["10", 0])
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
|
||||
|
||||
def test_equal_texts_from_different_nodes_resolve(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
|
||||
**{"20": _encode(OTHER), "21": _encode(OTHER)},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], OTHER)
|
||||
|
||||
def test_different_texts_are_candidates_not_a_refusal(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
|
||||
**{"20": _encode(POSITIVE), "21": _encode(OTHER)},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], resolver.SEVERAL_SOURCES_REASON)
|
||||
self.assertEqual(
|
||||
[_texts(item) for item in result["candidates"]],
|
||||
[(POSITIVE, ""), (OTHER, "")],
|
||||
)
|
||||
for item in result["candidates"]:
|
||||
self.assertEqual(item["state"], "resolved")
|
||||
self.assertEqual(item["sampler_id"], "17")
|
||||
|
||||
def test_prompt_manager_is_authoritative(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
|
||||
**{"20": _encode(OTHER)},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (POSITIVE, ""))
|
||||
|
||||
def test_prompt_manager_wins_over_an_unreadable_branch(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
|
||||
},
|
||||
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
|
||||
|
||||
def test_an_unreadable_branch_blocks_without_prompt_manager(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["22", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
|
||||
},
|
||||
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
|
||||
"22": _encode(OTHER),
|
||||
},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
|
||||
def test_chained_unknown_nodes(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "Router Node (vendor)",
|
||||
"inputs": {"in": ["10", 0]},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
|
||||
|
||||
def test_no_text_names_the_unknown_node(self):
|
||||
graph = any_switch_graph(any_01=["7", 0])
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "15", "class_type": "Any Switch (rgthree)"}
|
||||
)
|
||||
|
||||
def test_another_sampler_is_never_entered(self):
|
||||
graph = any_switch_graph(any_01=["17", 0], any_02=["10", 0])
|
||||
graph["30"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {**graph["17"]["inputs"], "positive": ["31", 0]},
|
||||
}
|
||||
graph["31"] = _encode(OTHER)
|
||||
graph["15"]["inputs"]["any_01"] = ["30", 0]
|
||||
result = _inspect(graph)
|
||||
found = {item["sampler_id"]: _texts(item) for item in result["candidates"]}
|
||||
self.assertEqual(found["17"], (POSITIVE, ""))
|
||||
|
||||
|
||||
class TestLegacyShapedRules(unittest.TestCase):
|
||||
def test_a_text_input_is_the_text(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorTextEncode",
|
||||
"inputs": {"text": OTHER, "clip": ["9", 1]},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
|
||||
|
||||
def test_role_inputs_route_by_output_slot(self):
|
||||
graph = any_switch_graph()
|
||||
graph["20"] = {
|
||||
"class_type": "VendorControlApply",
|
||||
"inputs": {
|
||||
"positive": ["21", 0],
|
||||
"negative": ["22", 0],
|
||||
"control_net": ["8", 0],
|
||||
"image": ["8", 0],
|
||||
},
|
||||
}
|
||||
graph["21"] = _encode(POSITIVE)
|
||||
graph["22"] = _encode(NEGATIVE)
|
||||
graph["17"]["inputs"]["positive"] = ["20", 0]
|
||||
graph["17"]["inputs"]["negative"] = ["20", 1]
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, NEGATIVE))
|
||||
|
||||
def test_a_conditioning_input_is_followed_alone(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorStyleApply",
|
||||
"inputs": {"conditioning": ["10", 0], "style": ["21", 0]},
|
||||
},
|
||||
"21": _encode(OTHER),
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
|
||||
|
||||
def test_core_type_inputs_are_skipped(self):
|
||||
# The vendor encoder's ``clip`` reaches a loader with its own ``text``:
|
||||
# that text is a lora tag, never the prompt.
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorPromptEncode",
|
||||
"inputs": {"prompt": ["21", 0], "clip": ["22", 1]},
|
||||
},
|
||||
"21": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
|
||||
"22": {
|
||||
"class_type": "VendorLoraLoader",
|
||||
"inputs": {"text": "<lora:x:1>", "clip": ["7", 0]},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], OTHER)
|
||||
|
||||
|
||||
class TestBounds(unittest.TestCase):
|
||||
def test_a_long_unknown_chain_reaches_the_depth_limit(self):
|
||||
graph = any_switch_graph(any_01=["100", 0])
|
||||
for index in range(100, 100 + resolver.INSPECTION_PROFILE.max_depth + 2):
|
||||
graph[str(index)] = {
|
||||
"class_type": "Router Node (vendor)",
|
||||
"inputs": {"in": [str(index + 1), 0]},
|
||||
}
|
||||
graph[str(index + 1)] = _pair()
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "depth-limit")
|
||||
|
||||
def test_many_different_sources_hit_the_candidate_cap(self):
|
||||
inputs = {}
|
||||
graph = any_switch_graph()
|
||||
for index in range(resolver._MAX_BRANCH_CANDIDATES + 1):
|
||||
graph[str(200 + index)] = _encode(f"{OTHER} {index}")
|
||||
inputs[f"any_{index:02}"] = [str(200 + index), 0]
|
||||
graph["15"]["inputs"] = inputs
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "work-limit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestGeneratedAndTransformedText(unittest.TestCase):
|
||||
def test_a_seeded_text_node_is_generated_at_run_time(self):
|
||||
result = _inspect(graphs.generated_text_graph())
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "30", "class_type": "VendorTextGenerate"}
|
||||
)
|
||||
|
||||
def test_the_recorded_text_is_authoritative(self):
|
||||
# The record is what ran: a generated or transformed text is never
|
||||
# checked against the generator's or transform's inputs.
|
||||
for name, records in graphs.recorded_shapes().items():
|
||||
with self.subTest(shape=name):
|
||||
metadata = {
|
||||
"prompt": json.loads(records["prompt"]),
|
||||
"comfyassets_prompt_pairs": records["comfyassets_prompt_pairs"],
|
||||
}
|
||||
result = resolve_prompt_pair(
|
||||
metadata, profile=resolver.INSPECTION_PROFILE
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved", result["reason"])
|
||||
self.assertEqual(_texts(result), (graphs.GENERATED, ""))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"], "execution"
|
||||
)
|
||||
|
||||
def test_a_seeded_conditioning_node_still_passes_through(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorConditioningNoise",
|
||||
"inputs": {"conditioning": ["10", 0], "seed": 3},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
|
||||
|
||||
|
||||
class TestReviewFindings(unittest.TestCase):
|
||||
"""Shapes from the independent review of the first #381 commit."""
|
||||
|
||||
def _recorded(self, text_source: dict) -> dict:
|
||||
graph = any_switch_graph()
|
||||
graph["10"]["inputs"]["text"] = ["30", 0]
|
||||
graph.update(text_source)
|
||||
metadata = {
|
||||
"prompt": graph,
|
||||
"comfyassets_prompt_pairs": graphs.execution("10", graphs.GENERATED),
|
||||
}
|
||||
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE)
|
||||
|
||||
def test_a_failing_walk_never_overrides_the_recorded_text(self):
|
||||
sources = {
|
||||
"missing": {"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}},
|
||||
"composite": {
|
||||
"30": {"class_type": "XLLM", "inputs": {"src": ["31", 0]}},
|
||||
"31": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {
|
||||
"conditioning_1": ["32", 0],
|
||||
"conditioning_2": ["33", 0],
|
||||
},
|
||||
},
|
||||
"32": _encode(POSITIVE),
|
||||
"33": _encode(OTHER),
|
||||
},
|
||||
"several": {
|
||||
"30": {
|
||||
"class_type": "XLLM",
|
||||
"inputs": {"a": ["31", 0], "b": ["32", 0]},
|
||||
},
|
||||
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
|
||||
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
|
||||
},
|
||||
"list-text": {"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}},
|
||||
}
|
||||
for name, source in sources.items():
|
||||
with self.subTest(source=name):
|
||||
result = self._recorded(source)
|
||||
self.assertEqual(result["state"], "resolved", result["reason"])
|
||||
self.assertEqual(_texts(result)[0], graphs.GENERATED)
|
||||
|
||||
def test_the_seed_rule_reads_only_string_outputs(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
|
||||
"21": {
|
||||
"class_type": "XSeedEncode",
|
||||
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
|
||||
|
||||
def test_an_unlinked_role_input_adds_nothing(self):
|
||||
graph = any_switch_graph()
|
||||
graph["20"] = {
|
||||
"class_type": "XApply",
|
||||
"inputs": {"positive": ["21", 0], "negative_opt": ""},
|
||||
}
|
||||
graph["21"] = _encode(POSITIVE)
|
||||
graph["17"]["inputs"]["positive"] = ["20", 0]
|
||||
graph["17"]["inputs"]["negative"] = ["20", 1]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "20", "class_type": "XApply"})
|
||||
|
||||
def test_one_text_offered_twice_is_one_candidate(self):
|
||||
graph = _with(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
|
||||
**{
|
||||
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
|
||||
"21": _encode(POSITIVE),
|
||||
"22": _encode(OTHER),
|
||||
"23": _encode(POSITIVE),
|
||||
},
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
[_texts(item)[0] for item in result["candidates"]], [POSITIVE, OTHER]
|
||||
)
|
||||
@@ -72,16 +72,24 @@ class TestHiresFixChain(unittest.TestCase):
|
||||
def test_an_unlisted_image_node_breaks_the_lineage(self):
|
||||
# The walk still finds the earlier sampler, but not through listed
|
||||
# pass-through nodes: the stages are never compared.
|
||||
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
|
||||
graph = shapes.hires_fix(
|
||||
second_positive=shapes.POSITIVE, filter_class="MysteryImageEffect"
|
||||
)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
later = next(
|
||||
item for item in result["candidates"] if item["sampler_id"] == "20"
|
||||
)
|
||||
self.assertEqual(later["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
# Stages on the very same conditioning links are one prompt (#381).
|
||||
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
|
||||
def test_a_second_earlier_sampler_in_the_ancestry_stays_unattributed(self):
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
|
||||
# Its own encoder: the same links would be one prompt (#381).
|
||||
graph["33"] = shapes.encode(shapes.POSITIVE)
|
||||
graph["30"] = shapes.ksampler(["33", 0], ["3", 0], ["4", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
|
||||
graph["32"] = {
|
||||
"class_type": "ImageBlend",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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"])
|
||||
@@ -864,7 +864,11 @@ class TestInspectionResolverVersion(InspectionTestCase):
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(review_capture, "INSPECTION_RESOLVER_VERSION", 2),
|
||||
patch.object(
|
||||
review_capture,
|
||||
"INSPECTION_RESOLVER_VERSION",
|
||||
review_capture.INSPECTION_RESOLVER_VERSION + 1,
|
||||
),
|
||||
patch.object(review_capture, "resolve_inspection", side_effect=newer),
|
||||
):
|
||||
self.assertNotEqual(_version(), version)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Synthetic graphs with unknown nodes between the prompt and the sampler (#381).
|
||||
|
||||
``any_switch_graph`` reproduces the shape of the maintainer's krea2 run
|
||||
(PromptManagerPair -> Any Switch (rgthree) -> KSampler, a LoraManager loader
|
||||
with its own lora ``text``, custom latent, filename and saver nodes) with
|
||||
neutral texts. ``shapes()`` names one graph per pass-through rule; the resolver
|
||||
tests and the browser-port parity vectors share them. No user file is involved.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
POSITIVE = "a lighthouse on a cliff, golden hour"
|
||||
OTHER = "a red bicycle in the rain"
|
||||
NEGATIVE = "blurry, lowres"
|
||||
|
||||
|
||||
def encode(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["7", 0]}}
|
||||
|
||||
|
||||
def pair(text=POSITIVE, negative=""):
|
||||
return {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"negative_text": negative,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"clip": ["9", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def any_switch_graph(**switch_inputs):
|
||||
"""The shape of #381: PromptManagerPair -> Any Switch (rgthree) -> KSampler.
|
||||
|
||||
Classes and links reproduce the maintainer's krea2 run (a LoraManager
|
||||
loader with its own lora ``text``, a project-path filename node, a custom
|
||||
latent and saver); the texts are neutral.
|
||||
"""
|
||||
inputs = switch_inputs or {"any_02": ["10", 0]}
|
||||
return {
|
||||
"3": {"class_type": "SeedHistory", "inputs": {"seed": 1538595059}},
|
||||
"5": {
|
||||
"class_type": "KikoProjectFilePath",
|
||||
"inputs": {"project_name": "demo", "filename": "final"},
|
||||
},
|
||||
"6": {"class_type": "UNETLoader", "inputs": {"unet_name": "model.safetensors"}},
|
||||
"7": {"class_type": "CLIPLoader", "inputs": {"clip_name": "clip.safetensors"}},
|
||||
"8": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
|
||||
"9": {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {
|
||||
"text": "<lora:style-demo:1>",
|
||||
"loras": [{"name": "style-demo", "strength": 1, "active": True}],
|
||||
"model": ["6", 0],
|
||||
"clip": ["7", 0],
|
||||
},
|
||||
},
|
||||
"10": pair(),
|
||||
"15": {"class_type": "Any Switch (rgthree)", "inputs": inputs},
|
||||
"16": {
|
||||
"class_type": "EmptyLatentBatch",
|
||||
"inputs": {"width": 1024, "height": 1536, "batch_size": 1},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": ["3", 0],
|
||||
"steps": 8,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "er_sde",
|
||||
"scheduler": "beta",
|
||||
"denoise": 1.0,
|
||||
"model": ["9", 0],
|
||||
"positive": ["15", 0],
|
||||
"negative": ["10", 1],
|
||||
"latent_image": ["16", 0],
|
||||
},
|
||||
},
|
||||
"18": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["17", 0], "vae": ["8", 0]},
|
||||
},
|
||||
"19": {
|
||||
"class_type": "KikoSaveImage",
|
||||
"inputs": {"filename_prefix": ["5", 0], "images": ["18", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def with_nodes(graph, **nodes):
|
||||
return {**graph, **nodes}
|
||||
|
||||
|
||||
GENERATED = "a generated caption of a lighthouse"
|
||||
|
||||
|
||||
def generated_text_graph():
|
||||
"""PromptManagerPair whose ``text`` is a language model's output.
|
||||
|
||||
The generator (``sampling_mode.seed``, ``prompt``, ``system_prompt``)
|
||||
writes new text at run time: its inputs are not the prompt.
|
||||
"""
|
||||
graph = any_switch_graph()
|
||||
graph["10"]["inputs"]["text"] = ["30", 0]
|
||||
graph["30"] = {
|
||||
"class_type": "VendorTextGenerate",
|
||||
"inputs": {
|
||||
"prompt": ["31", 0],
|
||||
"system_prompt": ["32", 0],
|
||||
"sampling_mode.seed": 0,
|
||||
"clip": ["7", 0],
|
||||
},
|
||||
}
|
||||
graph["31"] = {"class_type": "PrimitiveStringMultiline", "inputs": {"value": OTHER}}
|
||||
graph["32"] = {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "You"}}
|
||||
return graph
|
||||
|
||||
|
||||
def transformed_text_graph():
|
||||
"""PromptManagerPair whose ``text`` passes an unknown string transform."""
|
||||
graph = any_switch_graph()
|
||||
graph["10"]["inputs"]["text"] = ["30", 0]
|
||||
graph["30"] = {
|
||||
"class_type": "VendorRegexReplace",
|
||||
"inputs": {"text": OTHER, "pattern": "rain", "replace": "snow"},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def execution(node_id, text):
|
||||
"""A ``comfyassets_prompt_pairs`` record of what the pair node ran."""
|
||||
record = {
|
||||
"text": text,
|
||||
"negative_text": "",
|
||||
"positive_text": text,
|
||||
"negative_effective_text": "",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
}
|
||||
return json.dumps({"version": 1, "nodes": {node_id: {"records": [record]}}})
|
||||
|
||||
|
||||
def recorded_shapes():
|
||||
"""Record sets whose PromptManager node recorded the text it ran."""
|
||||
return {
|
||||
"generated-recorded": {
|
||||
"prompt": json.dumps(generated_text_graph()),
|
||||
"comfyassets_prompt_pairs": execution("10", GENERATED),
|
||||
},
|
||||
"transformed-recorded": {
|
||||
"prompt": json.dumps(transformed_text_graph()),
|
||||
"comfyassets_prompt_pairs": execution("10", GENERATED),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sampler_inputs(graph, positive, negative):
|
||||
graph["17"]["inputs"]["positive"] = positive
|
||||
graph["17"]["inputs"]["negative"] = negative
|
||||
return graph
|
||||
|
||||
|
||||
def shapes():
|
||||
"""One named graph per rule (resolved, candidates, PromptManager, refusals)."""
|
||||
vendor_encode = {
|
||||
"class_type": "VendorPromptEncode",
|
||||
"inputs": {"prompt": ["21", 0], "clip": ["22", 1]},
|
||||
}
|
||||
control = {
|
||||
"class_type": "VendorControlApply",
|
||||
"inputs": {
|
||||
"positive": ["21", 0],
|
||||
"negative": ["22", 0],
|
||||
"control_net": ["8", 0],
|
||||
"image": ["8", 0],
|
||||
},
|
||||
}
|
||||
return {
|
||||
"any-switch-pair": any_switch_graph(),
|
||||
"any-switch-same-pair": any_switch_graph(any_01=["10", 0], any_02=["10", 0]),
|
||||
"any-switch-equal-texts": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
|
||||
**{"20": encode(OTHER), "21": encode(OTHER)},
|
||||
),
|
||||
"any-switch-candidates": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
|
||||
**{"20": encode(POSITIVE), "21": encode(OTHER)},
|
||||
),
|
||||
"any-switch-prompt-manager-wins": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
|
||||
**{"20": encode(OTHER)},
|
||||
),
|
||||
"any-switch-runtime-blocks": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["22", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
|
||||
},
|
||||
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
|
||||
"22": encode(OTHER),
|
||||
},
|
||||
),
|
||||
"any-switch-no-text": any_switch_graph(any_01=["7", 0]),
|
||||
"unknown-chain": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "Router Node (vendor)",
|
||||
"inputs": {"in": ["10", 0]},
|
||||
}
|
||||
},
|
||||
),
|
||||
"unknown-text-input": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorTextEncode",
|
||||
"inputs": {"text": OTHER, "clip": ["9", 1]},
|
||||
}
|
||||
},
|
||||
),
|
||||
"unknown-role-inputs": _sampler_inputs(
|
||||
with_nodes(
|
||||
any_switch_graph(),
|
||||
**{"20": control, "21": encode(POSITIVE), "22": encode(NEGATIVE)},
|
||||
),
|
||||
["20", 0],
|
||||
["20", 1],
|
||||
),
|
||||
"unknown-conditioning-input": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "VendorStyleApply",
|
||||
"inputs": {"conditioning": ["10", 0], "style": ["21", 0]},
|
||||
},
|
||||
"21": encode(OTHER),
|
||||
},
|
||||
),
|
||||
"generated-text-runtime": generated_text_graph(),
|
||||
"transformed-text": transformed_text_graph(),
|
||||
"unknown-skips-clip": with_nodes(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": vendor_encode,
|
||||
"21": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
|
||||
"22": {
|
||||
"class_type": "VendorLoraLoader",
|
||||
"inputs": {"text": "<lora:x:1>", "clip": ["7", 0]},
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def review_shapes():
|
||||
"""Record sets for the review findings on the first #381 commit."""
|
||||
|
||||
def recorded(source):
|
||||
graph = any_switch_graph()
|
||||
graph["10"]["inputs"]["text"] = ["30", 0]
|
||||
graph.update(source)
|
||||
return {
|
||||
"prompt": json.dumps(graph),
|
||||
"comfyassets_prompt_pairs": execution("10", GENERATED),
|
||||
}
|
||||
|
||||
seeded = with_nodes(
|
||||
any_switch_graph(any_01=["20", 0]),
|
||||
**{
|
||||
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
|
||||
"21": {
|
||||
"class_type": "XSeedEncode",
|
||||
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
|
||||
},
|
||||
},
|
||||
)
|
||||
unlinked = any_switch_graph()
|
||||
unlinked["20"] = {
|
||||
"class_type": "XApply",
|
||||
"inputs": {"positive": ["21", 0], "negative_opt": ""},
|
||||
}
|
||||
unlinked["21"] = encode(POSITIVE)
|
||||
unlinked["17"]["inputs"]["positive"] = ["20", 0]
|
||||
unlinked["17"]["inputs"]["negative"] = ["20", 1]
|
||||
twice = with_nodes(
|
||||
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
|
||||
**{
|
||||
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
|
||||
"21": encode(POSITIVE),
|
||||
"22": encode(OTHER),
|
||||
"23": encode(POSITIVE),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"recorded-missing": recorded(
|
||||
{"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}}
|
||||
),
|
||||
"recorded-several": recorded(
|
||||
{
|
||||
"30": {
|
||||
"class_type": "XLLM",
|
||||
"inputs": {"a": ["31", 0], "b": ["32", 0]},
|
||||
},
|
||||
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
|
||||
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
|
||||
}
|
||||
),
|
||||
"recorded-list-text": recorded(
|
||||
{"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}}
|
||||
),
|
||||
"seeded-encoder-behind-noise": {"prompt": json.dumps(seeded)},
|
||||
"unlinked-role-input": {"prompt": json.dumps(unlinked)},
|
||||
"one-text-twice": {"prompt": json.dumps(twice)},
|
||||
}
|
||||
Reference in New Issue
Block a user