diff --git a/TESTING.md b/TESTING.md
index 7f72bb36..d249e9d6 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -227,7 +227,55 @@ workflow graph and its facts become limited). The serialized
response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt
graph, then all raw records atomically. Retry does not raise these limits. Ingestion
(scan, live monitor, admin import) and queue-time run counting keep the default
-profile, including its 500-node gate.
+profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node
+semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting
+levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
+link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
+(positive only; the negative is `absent`, "Guider has no negative input"). Any
+other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
+traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
+ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
+WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
+with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
+branch only; with a linked boolean both branches are traced, equal texts resolve and
+different texts make the sampler `ambiguous` with one candidate per choice (at most
+32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
+part has the same text, otherwise `unresolved` with `composite` naming the node.
+Results that depend on a forced choice are never memoized. A `FaceDetailer`
+(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
+ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
+different texts are `ambiguous` with `multistage-differs` and one candidate per
+stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
+whose output only feeds detailers (or previews) is not a candidate of its own.
+A sampler's latent ancestry is walked through every link input of non-sampler
+nodes within the same visit and depth budgets: with no earlier sampler it is a
+single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
+EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
+WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
+`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
+compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
+latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
+with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
+naming it, here and in a detailer's image ancestry; a latent that is not a link or
+reaches its own sampler is `malformed`; running out of budget is `work-limit` or
+`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
+selector branch runs share it and other candidates are charged its visit cost.
+Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
+slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
+input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
+every part is identical or exactly one part is non-empty (exact `""`, whitespace is
+text); otherwise `composite`. String sources reproduce the pinned KJNodes code from
+literal settings only: `StringConstant` is its `string`; `StringConstantMultiline`
+with a literal true `strip_newlines` is `replace("\n", "").strip()`;
+`JoinStringMulti` keeps `string_1` (even empty), appends `string_2`..`string_N`
+(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
+ignores later inputs. A linked setting is `unsupported-node`; a list output
+(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
+decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
+RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
+intermediate text is length-checked and charged to the scanned-character budget. If
+the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
+semantics are unavailable") and ingestion is unaffected, because it never imports the table.
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
diff --git a/py/api/inspection_service.py b/py/api/inspection_service.py
index 0b15bd21..787025c7 100644
--- a/py/api/inspection_service.py
+++ b/py/api/inspection_service.py
@@ -9,6 +9,11 @@ try:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
+ unreadable_evidence,
+ )
+ from ...utils.prompt_pair_resolver import (
+ INSPECTION_PROFILE,
+ PREVIEW_INSPECTION_PROFILE,
)
from ...utils.video_metadata import is_video_path
except ImportError:
@@ -16,6 +21,11 @@ except ImportError:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
+ unreadable_evidence,
+ )
+ from utils.prompt_pair_resolver import (
+ INSPECTION_PROFILE,
+ PREVIEW_INSPECTION_PROFILE,
)
from utils.video_metadata import is_video_path
@@ -60,6 +70,28 @@ def _current_file_facts(path: Path) -> os.stat_result:
return observed
+def _in_host_temp(path: Path) -> bool:
+ """Whether a file lies in ComfyUI's temp folder, where previews are written.
+
+ An unknown temp folder reads as no: the file is then inspected as saved
+ output.
+ """
+ try:
+ import folder_paths
+
+ temp = folder_paths.get_temp_directory()
+ except (ImportError, AttributeError, OSError):
+ return False
+ if type(temp) is not str or not temp:
+ return False
+ try:
+ folder = os.path.normcase(os.path.realpath(temp))
+ target = os.path.normcase(os.path.realpath(path))
+ return os.path.commonpath([folder, target]) == folder
+ except (OSError, ValueError):
+ return False
+
+
def inspect_current_file(
path: Path, *, source_label: str, saved: dict | None, library: dict
) -> dict:
@@ -73,24 +105,18 @@ def inspect_current_file(
if any(getattr(before, key) != getattr(after, key) for key in fields):
raise CurrentFileChanged
if inspection is None:
- evidence = {
- "prompts": {
- role: {"status": "unresolved", "text": None, "source": None}
- for role in ("positive", "negative")
- },
- "resolution": {
- "state": "unresolved",
- "sampler_id": None,
- "reason": "Media metadata could not be read",
- "reason_code": None,
- "node": None,
- },
- }
+ evidence = unreadable_evidence()
else:
role_saved = (
None if saved and saved.get("association_status") == "ambiguous" else saved
)
- evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
+ # A preview may be the output of a stage that reaches only previews.
+ profile = (
+ PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
+ )
+ evidence = inspect_prompt_evidence(
+ inspection.metadata, role_saved, profile=profile
+ )
return {
"file": {
"observation": "current-file",
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index 167974dd..5909afdd 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -26,7 +26,10 @@ test('inspection facts survive actual API to browser transport', async ({ page,
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
- expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
+ // Ambiguous branches merge per field: the differing seed is ambiguous, the shared fields known.
+ expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
+ ['ambiguous', null], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
+ expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
index e509be9a..c211c8bf 100644
--- a/tests/browser/prompt-inspector.spec.mjs
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -232,6 +232,17 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
});
+test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
+ const line='Prompt uses a node the inspector does not support (
).';
+ await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
+ positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
+ }}}));
+ await page.goto(workspace);await select(page,'0:uncertain.png');
+ for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
+ await expect(details(page).locator('img[onerror]')).toHaveCount(0);
+ expect(await page.evaluate(()=>window.injected)).toBeUndefined();
+ await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
+});
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index ee5ac6d9..6d25965c 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -458,6 +458,46 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
});
+test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
+ // The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
+ await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
+ body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
+ await route.fulfill({response,json:body});});
+ await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
+ await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
+ const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
+ const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
+ await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
+ await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
+ const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
+ await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
+ await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
+ await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
+ for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
+ await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
+ expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
+});
+
+test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
+ await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
+ body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
+ await route.fulfill({response,json:body});});
+ await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
+ await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
+ await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
+ await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
+ await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
+ await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
+ // Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
+ const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
+ for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
+ const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
+ expect(counts.size).toBeGreaterThan(10);
+ const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
+ const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
+ expect(repeated).toEqual([]);
+});
+
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
diff --git a/tests/browser/workspace_inspector_fixture.py b/tests/browser/workspace_inspector_fixture.py
index cfd8776c..e42eb23f 100644
--- a/tests/browser/workspace_inspector_fixture.py
+++ b/tests/browser/workspace_inspector_fixture.py
@@ -89,7 +89,9 @@ def seed_workspace_inspector(
],
"links": [],
}
- ambiguous = {**complete, "5": complete["3"]}
+ # Two branches that differ only in seed: facts merge per field (spec section 10).
+ branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
+ ambiguous = {**complete, "5": branch}
examples = (
("complete.png", {"prompt": complete, "workflow": workflow}),
("empty.png", {}),
diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs
index cb85c24a..85884f93 100644
--- a/tests/comfyui/execution-history.spec.mjs
+++ b/tests/comfyui/execution-history.spec.mjs
@@ -243,10 +243,10 @@ test.describe('public chronological history with the actual acknowledged host br
const frozen = attempts[0].context;
for (const mode of ['Raw', 'Effective']) {
await run.getByRole('button', { name: mode, exact: true }).click();
- for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
+ for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
- await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
+ await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index 25e06860..9cf262b1 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
# Inspection parity fixtures
-`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
+`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
graph, prompt text, model name and filename prefix in them is original synthetic
content written for this repository. Nothing comes from user output. They are
diff --git a/tests/fixtures/inspection_parity/f19.png b/tests/fixtures/inspection_parity/f19.png
new file mode 100644
index 00000000..1990852e
Binary files /dev/null and b/tests/fixtures/inspection_parity/f19.png differ
diff --git a/tests/fixtures/inspection_parity/f20.png b/tests/fixtures/inspection_parity/f20.png
new file mode 100644
index 00000000..81babeae
Binary files /dev/null and b/tests/fixtures/inspection_parity/f20.png differ
diff --git a/tests/fixtures/inspection_parity/f21.png b/tests/fixtures/inspection_parity/f21.png
new file mode 100644
index 00000000..26ce75c5
Binary files /dev/null and b/tests/fixtures/inspection_parity/f21.png differ
diff --git a/tests/fixtures/inspection_parity/f22.png b/tests/fixtures/inspection_parity/f22.png
new file mode 100644
index 00000000..f2d2e66c
Binary files /dev/null and b/tests/fixtures/inspection_parity/f22.png differ
diff --git a/tests/fixtures/inspection_parity/f23.png b/tests/fixtures/inspection_parity/f23.png
new file mode 100644
index 00000000..f15f1cc3
Binary files /dev/null and b/tests/fixtures/inspection_parity/f23.png differ
diff --git a/tests/fixtures/inspection_parity/f24.png b/tests/fixtures/inspection_parity/f24.png
new file mode 100644
index 00000000..395729d4
Binary files /dev/null and b/tests/fixtures/inspection_parity/f24.png differ
diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json
index 02ff874d..6f32f6ca 100644
--- a/tests/fixtures/inspection_parity/vectors.json
+++ b/tests/fixtures/inspection_parity/vectors.json
@@ -6,9 +6,9 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
- "reason_code": "no-sampler",
+ "state": "ambiguous",
+ "reason": "Multiple sampler branches",
+ "reason_code": null,
"selection": null,
"positive": null,
"negative": null,
@@ -19,24 +19,24 @@
"value": null
},
"steps": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "20"
},
"cfg": {
- "status": "ambiguous",
+ "status": "unresolved",
"value": null
},
"sampler": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
},
"model": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "flux-2-klein-9b-fp8.safetensors"
}
}
}
@@ -48,12 +48,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
- "reason_code": "no-sampler",
+ "state": "absent",
+ "reason": "Guider has no negative input",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
+ "negative": "",
"candidate_count": 0,
"facts": {
"seed": {
@@ -61,8 +61,8 @@
"value": "1000"
},
"steps": {
- "status": "unresolved",
- "value": null
+ "status": "known",
+ "value": "20"
},
"cfg": {
"status": "not-recorded",
@@ -73,8 +73,8 @@
"value": "euler"
},
"scheduler": {
- "status": "unresolved",
- "value": null
+ "status": "known",
+ "value": "Flux2Scheduler"
},
"model": {
"status": "known",
@@ -258,12 +258,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported node semantics or output slot",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -300,12 +300,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported node semantics or output slot",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -342,12 +342,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported node semantics or output slot",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -385,8 +385,8 @@
"expect": {
"metadata_status": "available",
"state": "unresolved",
- "reason": "Unsupported node semantics or output slot",
- "reason_code": "unsupported-node",
+ "reason": "Combined conditioning has different texts",
+ "reason_code": "composite",
"selection": null,
"positive": null,
"negative": null,
@@ -426,12 +426,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported node semantics or output slot",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -510,12 +510,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
- "reason_code": "no-sampler",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -603,28 +603,28 @@
"candidate_count": 0,
"facts": {
"seed": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "42"
},
"steps": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "25"
},
"cfg": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "6.5"
},
"sampler": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "euler"
},
"scheduler": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "normal"
},
"model": {
- "status": "ambiguous",
- "value": null
+ "status": "known",
+ "value": "sdxl_base.safetensors"
}
}
}
@@ -636,12 +636,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported latent ancestry",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -678,12 +678,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "Unsupported latent ancestry",
- "reason_code": "unsupported-node",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -888,12 +888,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
- "state": "unresolved",
- "reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
- "reason_code": "no-sampler",
+ "state": "absent",
+ "reason": "Guider has no negative input",
+ "reason_code": null,
"selection": null,
- "positive": null,
- "negative": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "",
"candidate_count": 0,
"facts": {
"seed": {
@@ -923,6 +923,258 @@
}
}
},
+ {
+ "name": "f19-inspection",
+ "fixture": "f19",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "ambiguous",
+ "reason": "Sampler stages have different prompts",
+ "reason_code": "multistage-differs",
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "ambiguous",
+ "value": null
+ },
+ "steps": {
+ "status": "ambiguous",
+ "value": null
+ },
+ "cfg": {
+ "status": "ambiguous",
+ "value": null
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
+ {
+ "name": "f20-inspection",
+ "fixture": "f20",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
+ "selection": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "known",
+ "value": "42"
+ },
+ "steps": {
+ "status": "known",
+ "value": "25"
+ },
+ "cfg": {
+ "status": "known",
+ "value": "6.5"
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
+ {
+ "name": "f21-inspection",
+ "fixture": "f21",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Text is decided at run time",
+ "reason_code": "runtime-value",
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "known",
+ "value": "42"
+ },
+ "steps": {
+ "status": "known",
+ "value": "25"
+ },
+ "cfg": {
+ "status": "known",
+ "value": "6.5"
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
+ {
+ "name": "f22-inspection",
+ "fixture": "f22",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Text is decided at run time",
+ "reason_code": "runtime-value",
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "known",
+ "value": "42"
+ },
+ "steps": {
+ "status": "known",
+ "value": "25"
+ },
+ "cfg": {
+ "status": "known",
+ "value": "6.5"
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
+ {
+ "name": "f23-inspection",
+ "fixture": "f23",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
+ "selection": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "",
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "known",
+ "value": "42"
+ },
+ "steps": {
+ "status": "known",
+ "value": "25"
+ },
+ "cfg": {
+ "status": "known",
+ "value": "6.5"
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
+ {
+ "name": "f24-inspection",
+ "fixture": "f24",
+ "profile": "inspection",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
+ "selection": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
+ "candidate_count": 0,
+ "facts": {
+ "seed": {
+ "status": "known",
+ "value": "42"
+ },
+ "steps": {
+ "status": "known",
+ "value": "25"
+ },
+ "cfg": {
+ "status": "known",
+ "value": "6.5"
+ },
+ "sampler": {
+ "status": "known",
+ "value": "euler"
+ },
+ "scheduler": {
+ "status": "known",
+ "value": "normal"
+ },
+ "model": {
+ "status": "known",
+ "value": "sdxl_base.safetensors"
+ }
+ }
+ }
+ },
{
"name": "f01-default",
"fixture": "f01",
@@ -1296,5 +1548,107 @@
"candidate_count": 0,
"facts": null
}
+ },
+ {
+ "name": "f19-default",
+ "fixture": "f19",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "resolved",
+ "reason": "",
+ "reason_code": null,
+ "selection": null,
+ "positive": "a red fox in snow, detailed fur",
+ "negative": "blurry, lowres",
+ "candidate_count": 0,
+ "facts": null
+ }
+ },
+ {
+ "name": "f20-default",
+ "fixture": "f20",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Unsupported node semantics or output slot",
+ "reason_code": null,
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": null
+ }
+ },
+ {
+ "name": "f21-default",
+ "fixture": "f21",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Unsupported node semantics or output slot",
+ "reason_code": null,
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": null
+ }
+ },
+ {
+ "name": "f22-default",
+ "fixture": "f22",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Unsupported node semantics or output slot",
+ "reason_code": null,
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": null
+ }
+ },
+ {
+ "name": "f23-default",
+ "fixture": "f23",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Unsupported node semantics or output slot",
+ "reason_code": null,
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": null
+ }
+ },
+ {
+ "name": "f24-default",
+ "fixture": "f24",
+ "profile": "default",
+ "anchor": null,
+ "expect": {
+ "metadata_status": "available",
+ "state": "unresolved",
+ "reason": "Unsupported node semantics or output slot",
+ "reason_code": null,
+ "selection": null,
+ "positive": null,
+ "negative": null,
+ "candidate_count": 0,
+ "facts": null
+ }
}
]
diff --git a/tests/inspection_parity.py b/tests/inspection_parity.py
index dc33cfc9..ff9a2137 100644
--- a/tests/inspection_parity.py
+++ b/tests/inspection_parity.py
@@ -2,7 +2,7 @@
Every graph here is original synthetic content (CC0): neutral texts, generic model
file names and invented filename prefixes. Nothing is copied from user outputs.
-The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
+The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
@@ -66,6 +66,12 @@ COMMITTED = (
"f15",
"f16",
"f17",
+ "f19",
+ "f20",
+ "f21",
+ "f22",
+ "f23",
+ "f24",
)
# Built in a temporary directory at test time (too large to commit).
TEST_TIME = ("f01", "f04", "f05")
@@ -100,6 +106,10 @@ def _enc(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
+def _linked_enc(link):
+ return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
+
+
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
graph = {
"4": {
@@ -691,6 +701,105 @@ def _f17():
return _basic(graph, _ui(graph))
+DETAILER_POS = "a red fox in snow, sharp detailed face"
+
+
+def _f19():
+ """KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
+ graph = _ksampler_graph(_enc(POS), _enc(NEG))
+ graph["10"] = _enc(DETAILER_POS)
+ graph["50"] = {
+ "class_type": "FaceDetailer",
+ "inputs": {
+ "image": ["8", 0],
+ "model": ["4", 0],
+ "clip": ["4", 1],
+ "vae": ["4", 2],
+ "positive": ["10", 0],
+ "negative": ["7", 0],
+ "seed": 7,
+ "steps": 12,
+ "cfg": 4.5,
+ "sampler_name": "euler",
+ "scheduler": "normal",
+ "denoise": 0.4,
+ },
+ }
+ graph["9"]["inputs"]["images"] = ["50", 0]
+ return _basic(graph)
+
+
+def _f20():
+ """``easy ifElse`` with a literal boolean picks the positive branch."""
+ select = {
+ "class_type": "easy ifElse",
+ "inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
+ }
+ extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
+ return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
+
+
+def _f21():
+ """``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
+ join = {
+ "class_type": "JoinStringMulti",
+ "inputs": {
+ "inputcount": 2,
+ "delimiter": ", ",
+ "return_list": True,
+ "string_1": ["11", 0],
+ "string_2": ["12", 0],
+ },
+ }
+ extra = {
+ "10": join,
+ "11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
+ "12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
+ }
+ positive = _linked_enc(["10", 0])
+ return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
+
+
+def _f22():
+ """``PromptSearchList`` (database search at run time) feeds the positive."""
+ search = {
+ "class_type": "PromptSearchList",
+ "inputs": {
+ "text": "fox",
+ "category": "",
+ "tags": "",
+ "min_rating": 0,
+ "limit": 50,
+ },
+ }
+ positive = _linked_enc(["10", 0])
+ return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
+
+
+def _f24():
+ """``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
+ qwen = {
+ "class_type": "TextEncodeQwenImage21",
+ "inputs": {
+ "clip": ["4", 1],
+ "prompt": POS,
+ "negative_prompt": NEG,
+ "vae": ["4", 2],
+ "resolution": 1024,
+ },
+ }
+ graph = _ksampler_graph(qwen, None)
+ del graph["7"]
+ graph["3"]["inputs"]["negative"] = ["6", 1]
+ return _basic(graph)
+
+
+def _f23():
+ """The negative is ``ConditioningZeroOut`` of the positive encoder."""
+ zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
+ return _basic(_ksampler_graph(_enc(POS), zero))
+
+
_BUILDERS = {
"f01": _f01,
"f01b": _f01b,
@@ -714,6 +823,12 @@ _BUILDERS = {
"f15": _f15,
"f16": _f16,
"f17": _f17,
+ "f19": _f19,
+ "f20": _f20,
+ "f21": _f21,
+ "f22": _f22,
+ "f23": _f23,
+ "f24": _f24,
}
diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js
index 7d64f081..7e45d2e0 100644
--- a/tests/js/comfy-metadata.test.js
+++ b/tests/js/comfy-metadata.test.js
@@ -164,6 +164,232 @@ test("facts require model output ancestry with supported loaders and passthrough
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
});
+// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
+function detailerFactGraph(detailer = {}) {
+ const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
+ graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
+ graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
+ return graph;
+}
+const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
+
+test("facts use resolution.sampler_id for detailer-stage nodes", () => {
+ // Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
+ const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
+ assert.deepEqual(statusValues(resolved), {
+ seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ });
+ assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
+ // The upstream KSampler is also accepted when it is the one resolved.
+ assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
+ // Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
+ const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
+ assert.deepEqual(statusValues(ambiguous), {
+ seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ });
+ assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
+ // A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
+ const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
+ assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
+ const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
+ assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
+ // Two detailer stages over one KSampler: three stages merge.
+ const chained = detailerFactGraph({ seed: "42" });
+ chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
+ assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
+ seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ });
+ // Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
+ assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
+ // A sampler_id the graph or the samplers table does not hold never falls back to a guess.
+ for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
+ for (const state of ["resolved", "ambiguous"]) {
+ const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
+ assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
+ }
+ }
+ // Stage ancestry that cannot be walked is unresolved, not a partial merge.
+ const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
+ const unlinked = detailerFactGraph({ image: "not a link" });
+ for (const graph of [cyclic, unlinked]) {
+ assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
+ }
+ const wide = detailerFactGraph();
+ let previous = ["s", 0];
+ for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
+ wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
+ assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
+});
+
+test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
+ const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
+ // f01b's shape: an upstream sampler and a detailer whose settings partly differ.
+ assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
+ seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ });
+ assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
+ // Equal independent branches agree on every field once the server says ambiguous;
+ // without a resolution they stay ambiguous (today's rules, reversed in PR3).
+ const equal = factGraph(); equal.other = structuredClone(equal.s);
+ assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
+ assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
+ // A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
+ const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
+ const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
+ for (const graph of [cyclic, broken, { model: factGraph().model }]) {
+ assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
+ }
+ const many = factGraph();
+ for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
+ assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
+});
+
+test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
+ const shaped = [
+ { positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
+ { pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
+ { sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
+ { model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
+ { model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
+ ];
+ for (const inputs of shaped) {
+ const graph = detailerFactGraph();
+ graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
+ graph.d.inputs.image = ["efficient", 0];
+ for (const sampler_id of ["d", null]) {
+ const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
+ assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
+ }
+ }
+ // Model patches (model alone, or with clip) and listed table classes are walked through.
+ for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
+ const graph = detailerFactGraph();
+ graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
+ graph.d.inputs.image = ["hop", 0];
+ assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
+ }
+});
+
+test("unresolved and absent results over several stages merge facts per field", () => {
+ // Review I-1: the run's settings are facts even when its prompt is not, e.g. an
+ // upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
+ const merged = {
+ seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ };
+ for (const resolution of [
+ { state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
+ { state: "unresolved", sampler_id: null, reason_code: "malformed" },
+ { state: "absent", sampler_id: "d" },
+ ]) {
+ const facts = factsFor(detailerFactGraph(), { resolution });
+ assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
+ assert.ok(factFields.every(field => facts[field].sampler_id === null));
+ }
+ // A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
+ const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
+ assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
+ const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
+ assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
+ // One listed sampler keeps today's rule: its own facts, attributed to it.
+ const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
+ assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
+});
+
+test("a named sampler limits absent and unresolved facts to its own stages", () => {
+ // Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
+ // the detailer's settings never leak into the saved file's facts.
+ const graph = detailerFactGraph();
+ graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
+ graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
+ const own = {
+ seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
+ sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
+ };
+ for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
+ // Without a named sampler every listed sampler still merges.
+ assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
+});
+
+test("stage walks follow only the resolver's links", () => {
+ // Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
+ for (const slot of [-1, 0.5, "0"]) {
+ const graph = detailerFactGraph();
+ graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
+ graph.decode.inputs.extra = ["eff", slot];
+ assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
+ const shaped = detailerFactGraph();
+ shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
+ shaped.d.inputs.image = ["eff", 0];
+ assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
+ }
+ const linked = detailerFactGraph();
+ linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
+ linked.decode.inputs.extra = ["eff", 0];
+ assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
+});
+
+test("Flux2Scheduler provides steps and scheduler is its node type", () => {
+ const graph = customFactGraph();
+ graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
+ const facts = factsFor(graph);
+ assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
+ assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
+ for (const slot of [1, -1]) {
+ const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
+ assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
+ }
+ // Its own inputs cannot replace the node type, and steps keep exact-integer rules.
+ const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
+ assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
+ assert.equal(factsFor(named).steps.status, "unresolved");
+});
+
+test("ModelSampling passthroughs reach UNETLoader", () => {
+ const graph = customFactGraph();
+ graph.g.inputs.model = ["flux", 0];
+ graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
+ graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
+ graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
+ graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
+ assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
+ for (const node of ["flux", "aura", "sd3"]) {
+ const wrongSlot = structuredClone(graph);
+ const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
+ wrongSlot[parent].inputs.model = [node, 1];
+ assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
+ const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
+ assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
+ }
+});
+
+test("vectors.json pins full facts for every inspection fixture", () => {
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
+ const inspection = vectors.filter(vector => vector.profile === "inspection");
+ assert.ok(inspection.length > 0);
+ const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
+ for (const vector of inspection) {
+ const facts = vector.expect.facts;
+ assert.deepEqual(Object.keys(facts), factFields, vector.name);
+ for (const field of factFields) {
+ assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
+ assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
+ }
+ }
+ // f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
+ const f19 = inspection.find(vector => vector.fixture === "f19");
+ assert.equal(f19.expect.state, "ambiguous");
+ assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
+ seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
+ });
+});
+
test("wide workflow lookups retain selected core sampler facts", () => {
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
let linkId = 1;
diff --git a/tests/js/inspection-legacy-ports.test.js b/tests/js/inspection-legacy-ports.test.js
new file mode 100644
index 00000000..b51a3df8
--- /dev/null
+++ b/tests/js/inspection-legacy-ports.test.js
@@ -0,0 +1,119 @@
+// Run with: node --test tests/js/
+// Legacy parser rules ported to the inspection facts (#240, Task 2.7b). Synthetic texts.
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
+
+const fields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
+const FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5";
+
+function parameterFacts(parameters) {
+ const facts = extractGenerationFacts({
+ prompt_graph: { status: "absent", value: null },
+ workflow_graph: { status: "absent", value: null },
+ parameters,
+ recordFormats: { parameters: "original-text" },
+ metadataStatus: "available",
+ });
+ return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
+}
+
+const FOUR_KNOWN = {
+ seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "Euler"],
+ scheduler: ["not-recorded", null], model: ["not-recorded", null],
+};
+const ALL_UNRESOLVED = Object.fromEntries(fields.map(field => [field, ["unresolved", null]]));
+
+test("R4: text starting with the negative delimiter has no positive, as with a leading newline", () => {
+ for (const text of [`Negative prompt: bad\n${FOOTER}`, `\nNegative prompt: bad\n${FOOTER}`, `Negative prompt: a\nNegative prompt: b\n${FOOTER}`]) {
+ assert.deepEqual(parameterFacts(text), ALL_UNRESOLVED, text);
+ }
+ assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
+});
+
+test("R5: template records after the footer and a bare Steps footer", () => {
+ const base = `a cat\nNegative prompt: bad\n${FOOTER}`;
+ for (const trailer of ["\nTemplate: a {cat|dog}", "\nTemplate: a {cat|dog}\nNegative Template: bad", "\nNegative Template: bad\n"]) {
+ assert.deepEqual(parameterFacts(base + trailer), FOUR_KNOWN, trailer);
+ }
+ for (const trailer of ["\nTemplate: a\nTemplate: b", "\nNegative Template: bad\nTemplate: a", "\nTemplate: a\nmore", "\ntrailing"]) {
+ assert.deepEqual(parameterFacts(base + trailer), ALL_UNRESOLVED, trailer);
+ }
+ const bare = parameterFacts("a cat\nNegative prompt: bad\nSteps: 20\nTemplate: a {cat|dog}");
+ assert.deepEqual(bare.steps, ["known", "20"]);
+ assert.ok(["seed", "cfg", "sampler", "scheduler", "model"].every(field => bare[field][0] === "not-recorded"));
+ // A full footer wins over a bare `Steps: N` line in the prompt; two bare ones are not unique.
+ assert.deepEqual(parameterFacts(`a cat\nSteps: 3\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
+ assert.deepEqual(parameterFacts("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20"), ALL_UNRESOLVED);
+});
+
+test("R10: commas inside quoted settings values do not split them", () => {
+ assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}, ADetailer prompt: "face, smile"`), FOUR_KNOWN);
+ assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "say \\"hi, there\\"", Model: base`), { ...FOUR_KNOWN, model: ["known", "base"] });
+ // A quoted value of a fact is its JSON string.
+ assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Model: "base, v2"`).model, ["known", "base, v2"]);
+ // Unquoted JSON (comfy-image-saver hashes) keeps the historical split, field by field.
+ assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Hashes: {"model": "abc", "lora:x": "def"}, Model: base`).model, ["known", "base"]);
+ // An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
+ assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
+});
+
+function graphFacts(graph, resolution) {
+ const facts = extractGenerationFacts({
+ prompt_graph: { status: "parsed", value: graph },
+ workflow_graph: { status: "absent", value: null },
+ metadataStatus: "available",
+ resolution,
+ });
+ return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
+}
+
+function modelGraph(between) {
+ return {
+ ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
+ hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
+ s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
+ };
+}
+
+test("R9: model pass-throughs verified in their sources lead to the loader", () => {
+ for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
+ assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
+ // Only output 0 is the model.
+ const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
+ assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
+ }
+ assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
+});
+
+test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
+ for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
+ const graph = modelGraph("CFGNorm");
+ graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
+ graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
+ assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
+ const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
+ assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
+ }
+});
+
+test("R6: upstream-verified samplers supply facts unless an override input is set", () => {
+ for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) {
+ const graph = modelGraph("CR Apply LoRA Stack");
+ graph.s.class_type = class_type;
+ assert.deepEqual(graphFacts(graph), {
+ seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"],
+ scheduler: ["known", "normal"], model: ["known", "base.safetensors"],
+ }, class_type);
+ for (const name of overrides) {
+ const overridden = structuredClone(graph);
+ overridden.s.inputs[name] = ["script", 0];
+ overridden.script = { class_type: "SomeScript", inputs: {} };
+ assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`);
+ for (const unset of [null, ""]) {
+ overridden.s.inputs[name] = unset;
+ assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`);
+ }
+ }
+ }
+});
diff --git a/tests/js/inspection-semantics-sync.test.js b/tests/js/inspection-semantics-sync.test.js
new file mode 100644
index 00000000..bbbbd38f
--- /dev/null
+++ b/tests/js/inspection-semantics-sync.test.js
@@ -0,0 +1,79 @@
+// Run with: node --test tests/js/
+// comfy-metadata.js stays a classic script for the legacy pages, so its facts
+// tables are inline literals. This test keeps them equal to the one semantics
+// table (web/js/inspection-semantics.json) that Python also loads.
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const metadata = require("../../web/js/comfy-metadata.js");
+
+const tablePath = path.join(__dirname, "..", "..", "web", "js", "inspection-semantics.json");
+const semantics = JSON.parse(fs.readFileSync(tablePath, "utf8"));
+
+function plain(value) {
+ if (value instanceof Set) return [...value];
+ if (Array.isArray(value)) return value.map(plain);
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
+ return value;
+}
+
+test("facts tables equal semantics.facts", () => {
+ assert.equal(typeof metadata._factsTables, "object", "comfy-metadata exposes its facts tables for this test");
+ assert.deepStrictEqual(plain(metadata._factsTables), semantics.facts);
+});
+
+test("facts guider providers are guiders in the semantics table", () => {
+ const providers = Object.entries(semantics.facts.configuration_outputs).filter(([, adapter]) => adapter.kind === "guider");
+ assert.ok(providers.length > 0);
+ for (const [name] of providers) assert.ok(Object.hasOwn(semantics.guiders, name), name);
+});
+
+test("the facts tables are not part of the legacy page contract", () => {
+ assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
+ assert.ok(Object.isFrozen(metadata._factsTables));
+});
+
+// Task 2.1 checked only `facts`; the facts code also reads the samplers table
+// (which classes are samplers, their facts shape and a detailer's image input).
+test("sampler facts table equals semantics.samplers", () => {
+ assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test");
+ // Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver.
+ const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, {
+ facts: entry.facts,
+ ...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }),
+ ...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }),
+ }]));
+ assert.deepStrictEqual(plain(metadata._samplerTable), expected);
+ assert.ok(Object.isFrozen(metadata._samplerTable));
+});
+
+test("detailer-stage samplers, and only they, name an image stage", () => {
+ for (const [name, entry] of Object.entries(semantics.samplers)) {
+ assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
+ }
+});
+
+// The stage walk treats every table class as known (the resolver's `all_classes`).
+test("stage walk table classes equal every class in the semantics table", () => {
+ const classes = new Set();
+ for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
+ for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
+ for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
+ assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
+});
+
+// Review I-2: one source for the stage-walk input lists (Python reads them there too).
+test("stage walk inputs equal semantics.stage_inputs", () => {
+ assert.equal(typeof semantics.stage_inputs, "object");
+ assert.equal(typeof metadata._stageInputs, "object", "comfy-metadata exposes its stage inputs for this test");
+ assert.deepStrictEqual(plain(metadata._stageInputs), semantics.stage_inputs);
+ assert.ok(Object.isFrozen(metadata._stageInputs));
+});
+
+// Review M-3: JS treats every samplers entry as a candidate; the server only these strategies.
+test("every samplers entry is a candidate strategy", () => {
+ for (const [name, entry] of Object.entries(semantics.samplers)) {
+ assert.ok(["ksampler", "custom-advanced", "detailer-stage"].includes(entry.strategy), name);
+ }
+});
diff --git a/tests/js/legacy-metadata-characterization.test.js b/tests/js/legacy-metadata-characterization.test.js
index cb1ed376..53c99068 100644
--- a/tests/js/legacy-metadata-characterization.test.js
+++ b/tests/js/legacy-metadata-characterization.test.js
@@ -3,7 +3,10 @@
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
// parity fixtures, so the #240 workspace work cannot change them by accident.
// Some answers are known to be wrong (f01b shows the preview-only detailer text
-// as both roles; f16 shows the last of two conflicting prompt chunks). They are
+// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
+// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
+// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
+// search text as the prompt). They are
// recorded as-is and intentionally unchanged (spec section 11).
const test = require("node:test");
const assert = require("node:assert/strict");
@@ -56,6 +59,12 @@ const EXPECTED = {
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
+ f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
+ f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
+ f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
+ f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
+ f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
+ f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
};
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js
index 9805a61f..803e04a1 100644
--- a/tests/js/prompt-blocks.test.js
+++ b/tests/js/prompt-blocks.test.js
@@ -70,3 +70,76 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
+
+test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
+ assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
+ assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
+ container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
+ assert.deepEqual(sent,[{positive:'positive',negative:''}]);
+ }finally{scope.dispose();f.restore();}
+});
+
+const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
+const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
+test('unsupported node status names the class',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
+ assert.deepEqual(statusLines(f,container),[line,line]);
+ assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists');
+ }finally{scope.dispose();f.restore();}
+});
+test('runtime and composite statuses',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).';
+ try{
+ const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}});
+ assert.deepEqual(statusLines(f,container),[runtime,runtime]);
+ view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
+ assert.deepEqual(statusLines(f,container),[composite]);
+ assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
+ }finally{scope.dispose();f.restore();}
+});
+test('limit status',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ const line='Prompt exceeds inspection limits.';
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
+ assert.deepEqual(statusLines(f,container),[line,line]);
+ }finally{scope.dispose();f.restore();}
+});
+test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}});
+ assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']);
+ assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0);
+ }finally{scope.dispose();f.restore();}
+});
+test('class names in status lines render as text, never markup',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ const line='Prompt uses a node the inspector does not support (
).';
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
+ assert.deepEqual(statusLines(f,container),[line,line]);
+ assert.equal(f.walk(container,node=>node.tagName==='img').length,0);
+ assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0);
+ }finally{scope.dispose();f.restore();}
+});
+
+test('a context label names nested prompt regions and actions apart from the frozen blocks while the visible heading stays',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
+ const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
+ for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
+ assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
+ assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
+ const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
+ assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
+ }finally{scope.dispose();f.restore();}
+});
diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js
index f327e233..fcb8e4d2 100644
--- a/tests/js/workspace-inspection-state.test.js
+++ b/tests/js/workspace-inspection-state.test.js
@@ -225,6 +225,48 @@ test('record accepts closed reason codes and rejects contradictory ones', () =>
}
});
+test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => {
+ const toRecord = contract('toInspectionRecord');
+ for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) {
+ for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) {
+ const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node });
+ assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node }));
+ }
+ }
+ for (const change of [
+ { reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } },
+ { reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } },
+ { reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } },
+ { reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } },
+ { reason_code: 'unsupported-node', node: [] },
+ { reason_code: 'malformed', node: { id: '7', class_type: 'X' } },
+ { reason_code: 'work-limit', node: { id: '7', class_type: 'X' } },
+ { reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } },
+ { reason_code: 'Unsupported-Node', node: null },
+ { reason_code: '', node: null },
+ ]) {
+ const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change });
+ assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change));
+ }
+});
+
+test('role outcome lines are bounded text on non-resolved roles only', () => {
+ const toRecord = contract('toInspectionRecord');
+ const line = 'Prompt uses a node the inspector does not support (
).';
+ const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line };
+ assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line);
+ for (const [role, record] of [
+ ['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }],
+ ['negative', { status: 'unresolved', text: null, source: null, message: '' }],
+ ['negative', { status: 'unresolved', text: null, source: null, message: 7 }],
+ ['negative', { status: 'unresolved', text: null, source: null, message: null }],
+ ['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }],
+ ]) {
+ const bad = envelope(); bad.prompts[role] = record;
+ assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record));
+ }
+});
+
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
const toRecord = contract('toInspectionRecord');
for (const mutate of [
diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py
index 052de9a5..cc41e1b9 100644
--- a/tests/test_api_prompt_inspector.py
+++ b/tests/test_api_prompt_inspector.py
@@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase):
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
path = self.output_dir / "image.png"
- for content, expected in (
- (b"broken", "unresolved"),
- (None, "absent"),
- ("bad-json", "unresolved"),
+ for content, expected, message in (
+ (b"broken", "unresolved", "Metadata in this file could not be read."),
+ (None, "absent", None),
+ ("bad-json", "unresolved", "Prompt metadata in this file is malformed."),
):
if isinstance(content, bytes):
path.write_bytes(content)
@@ -256,6 +256,7 @@ class TestPromptInspector(ImageAPITestCase):
_, result = await self.inspect()
self.assertEqual(result["prompts"]["positive"]["status"], expected)
self.assertIsNone(result["prompts"]["positive"]["text"])
+ self.assertEqual(result["prompts"]["negative"].get("message"), message)
async def test_compressed_international_and_post_pixel_metadata(self):
import struct
@@ -773,8 +774,11 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed")
async def test_inspect_route_reports_no_sampler_instead_of_absent(self):
- # f17: a BasicGuider-only prompt graph is a record, never "not recorded".
- self.parity_fixture("f17")
+ # f17 with a sampler class the inspector does not know: a prompt graph
+ # is a record, never "not recorded".
+ built = parity.build("f17")
+ built["prompt"]["13"]["class_type"] = "FutureSampler"
+ parity.write_png(self.output_dir / "f17.png", built)
response, result = await self.inspect("f17.png")
self.assertEqual(response.status, 200)
self.assertEqual(
@@ -785,17 +789,32 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["resolution"]["reason_code"], "no-sampler")
self.assertEqual(
result["resolution"]["reason"],
- "No supported sampler pair found (saw SamplerCustomAdvanced)",
+ "No supported sampler pair found (saw FutureSampler)",
+ )
+ self.assertEqual(
+ result["prompts"]["positive"]["message"], "No supported sampler was found."
)
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
- self.parity_fixture("f06a")
+ # f06a's encoder is supported since Task 2.4; an unknown encoder is not.
+ built = parity.build("f06a")
+ built["prompt"]["6"]["class_type"] = "FutureTextEncode"
+ parity.write_png(self.output_dir / "f06a.png", built)
_, result = await self.inspect("f06a.png")
resolution = result["resolution"]
self.assertEqual(resolution["state"], "unresolved")
self.assertEqual(resolution["reason_code"], "unsupported-node")
- self.assertEqual(set(resolution["node"]), {"id", "class_type"})
- self.assertIsInstance(resolution["node"]["class_type"], str)
+ self.assertEqual(
+ resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
+ )
+ self.assertEqual(
+ {role: side.get("message") for role, side in result["prompts"].items()},
+ dict.fromkeys(
+ ("positive", "negative"),
+ "Prompt uses a node the inspector does not support "
+ "(FutureTextEncode).",
+ ),
+ )
png(self.output_dir / "image.png", prompt="{broken")
_, result = await self.inspect()
self.assertEqual(result["resolution"]["reason_code"], "malformed")
diff --git a/tests/test_history_current.py b/tests/test_history_current.py
index 18589ada..d9836ebe 100644
--- a/tests/test_history_current.py
+++ b/tests/test_history_current.py
@@ -449,6 +449,46 @@ class CurrentLocationTests(unittest.TestCase):
)
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
+ def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self):
+ # Saved raw image plus a FaceDetailer that only previews (Codex P2).
+ import json
+
+ from PIL import Image
+ from PIL.PngImagePlugin import PngInfo
+ from tests import inspection_parity as parity
+
+ service = self.module("py.api.inspection_service")
+ graph = parity.build("f19")["prompt"]
+ graph["9"]["class_type"] = "PreviewImage"
+ graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ info = PngInfo()
+ info.add_text("prompt", json.dumps(graph))
+ observed = {}
+ for name, folder in (("output", self.output), ("temp", self.temp)):
+ path = folder / "nested" / "same.png"
+ path.parent.mkdir()
+ with Image.new("RGB", (4, 4)) as image:
+ image.save(path, pnginfo=info)
+ observed[name] = service.inspect_current_file(
+ path, source_label=name, saved=None, library={"status": "none"}
+ )
+ saved = observed["output"]["prompts"]
+ self.assertEqual(saved["positive"]["status"], "resolved")
+ self.assertEqual(saved["positive"]["text"], parity.POS)
+ self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3")
+ preview = observed["temp"]["prompts"]
+ self.assertEqual(preview["positive"]["status"], "ambiguous")
+ self.assertIsNone(preview["positive"]["text"])
+ # An unknown host temp folder treats the file as saved output.
+ self.host_temp = None
+ again = service.inspect_current_file(
+ self.output / "nested" / "same.png",
+ source_label="output",
+ saved=None,
+ library={"status": "none"},
+ )
+ self.assertEqual(again["prompts"]["positive"]["status"], "resolved")
+
class HistoryImportTests(unittest.TestCase):
def test_api_import_does_not_require_a_running_host_server(self):
diff --git a/tests/test_inspection_legacy_ports.py b/tests/test_inspection_legacy_ports.py
new file mode 100644
index 00000000..902ce040
--- /dev/null
+++ b/tests/test_inspection_legacy_ports.py
@@ -0,0 +1,943 @@
+"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
+
+Every graph here is synthetic (neutral texts, generic names). Each class names
+the rule from the parser reconciliation (R1, R3, ...) it ports. The default
+(ingestion) profile must stay exactly as it was, so each rule also checks that
+``DEFAULT_PROFILE`` ignores it.
+"""
+
+import io
+import json
+import tempfile
+import unittest
+import unittest.mock
+import zlib
+from pathlib import Path
+
+from PIL import Image
+
+from utils import prompt_pair_resolver as resolver
+from utils.prompt_inspector import (
+ DUPLICATE_ROLE_BYTES,
+ MetadataReadError,
+ inspect_prompt_evidence,
+ read_inspection_file,
+)
+from utils.prompt_pair_resolver import resolve_prompt_pair
+
+try:
+ from tests import inspection_parity as parity
+except ImportError: # discovered with ``-s tests``
+ import inspection_parity as parity
+
+
+def _inspect(metadata: dict, **options) -> dict:
+ return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
+
+
+def _texts(result: dict) -> tuple:
+ return tuple(
+ result[role]["text"] if isinstance(result[role], dict) else None
+ for role in ("positive", "negative")
+ )
+
+
+def _pm_encoder(text: str) -> dict:
+ """A PromptManager node whose API class was rewritten to CLIPTextEncode."""
+ return {
+ "class_type": "CLIPTextEncode",
+ "inputs": {
+ "text": text,
+ "category": "",
+ "tags": "",
+ "search_text": "",
+ "clip": ["4", 1],
+ },
+ }
+
+
+def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
+ """Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
+
+ KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
+ 17, 113 and 119 is the last-run node's prompt (C) for all three.
+ """
+ prompt = parity.build("f02")["prompt"]
+ prompt["3"]["inputs"]["positive"] = ["17", 0]
+ del prompt["6"]
+ for node_id in ("17", "113", "119"):
+ prompt[node_id] = _pm_encoder(api_text)
+ workflow = {
+ "nodes": [
+ {"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
+ for node_id, widget in zip(("17", "113", "119"), widgets)
+ ]
+ + [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
+ "links": [],
+ }
+ return {"prompt": prompt, "workflow": workflow}
+
+
+class TestOverwrittenPromptManagerText(unittest.TestCase):
+ """R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
+
+ def test_overwritten_text_is_refused_not_resolved(self):
+ result = _inspect(_overwritten())
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "malformed")
+ self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
+ self.assertIsNone(result["positive"])
+
+ def test_the_inspector_passes_the_workflow_to_the_resolver(self):
+ metadata = _overwritten()
+ evidence = inspect_prompt_evidence(
+ {
+ "prompt": json.dumps(metadata["prompt"]),
+ "workflow": json.dumps(metadata["workflow"]),
+ }
+ )
+ self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
+ self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
+ self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
+ self.assertEqual(
+ evidence["prompts"]["positive"]["message"],
+ "Prompt metadata in this file is malformed.",
+ )
+
+ def test_untouched_shapes_still_resolve(self):
+ cases = {
+ # One PromptManager node, or several whose widgets agree.
+ "single": ("a bird",),
+ "agreeing widgets": ("a bird", "a bird", "a bird"),
+ }
+ for label, widgets in cases.items():
+ with self.subTest(label):
+ metadata = _overwritten()
+ metadata["workflow"]["nodes"] = [
+ node
+ for node in metadata["workflow"]["nodes"]
+ if node["type"] != "PromptManager"
+ ] + [
+ {"id": node_id, "type": "PromptManager", "widgets_values": [text]}
+ for node_id, text in zip((17, 113, 119), widgets)
+ ]
+ self.assertEqual(_inspect(metadata)["state"], "resolved")
+ # Distinct API texts are what ran, whatever the widgets say.
+ metadata = _overwritten()
+ metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
+ metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
+ metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
+ self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
+ # A PromptManagerPair widget is its positive and negative: not the signature.
+ metadata = _overwritten()
+ for node in metadata["workflow"]["nodes"]:
+ node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
+ self.assertEqual(_inspect(metadata)["state"], "resolved")
+ # No workflow, or a malformed one, cannot show the signature.
+ orphans = {
+ "nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
+ }
+ for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
+ with self.subTest(workflow=workflow):
+ metadata = _overwritten()
+ metadata["workflow"] = workflow
+ self.assertEqual(_inspect(metadata)["state"], "resolved")
+
+ def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
+ # Only the overwritten nodes are refused: the negative is not one.
+ metadata = _overwritten()
+ metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
+ self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
+
+ def test_overwritten_nodes_inside_subgraphs_are_refused(self):
+ # Review M-1: subgraph nodes run as ":" API IDs.
+ def nested(depth: int = 1) -> dict:
+ metadata = _overwritten()
+ prompt, workflow = metadata["prompt"], metadata["workflow"]
+ prefix = ":".join(["50", "60", "70"][:depth]) + ":"
+ for node_id in ("17", "113", "119"):
+ prompt[prefix + node_id] = prompt.pop(node_id)
+ prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
+ inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
+ outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
+ subgraphs = []
+ for level in range(depth, 0, -1):
+ nodes = (
+ inner
+ if level == depth
+ else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
+ )
+ subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
+ metadata["workflow"] = {
+ "nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
+ "links": [],
+ "definitions": {"subgraphs": subgraphs},
+ }
+ return metadata
+
+ for depth in (1, 2, 3):
+ with self.subTest(depth=depth):
+ for anchor in (None, "9"):
+ result = _inspect(nested(depth), output_node_id=anchor)
+ self.assertEqual(
+ result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
+ )
+ # A subgraph definition with no instance runs nothing.
+ metadata = nested()
+ metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
+ self.assertEqual(_inspect(metadata)["state"], "resolved")
+ # Malformed definitions never raise.
+ for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
+ metadata = nested()
+ metadata["workflow"]["definitions"] = definitions
+ self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
+ # A self-referencing subgraph is bounded.
+ metadata = nested()
+ metadata["workflow"]["definitions"]["subgraphs"].append(
+ {"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
+ )
+ metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
+ self.assertEqual(
+ _inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
+ )
+
+ def test_object_widget_values_name_the_text(self):
+ metadata = _overwritten()
+ for node in metadata["workflow"]["nodes"]:
+ node["widgets_values"] = {"text": node["widgets_values"][0]}
+ self.assertEqual(
+ _inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
+ )
+
+ def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
+ # Review M-4: no PromptManager-like node, no workflow parse.
+
+ from utils import prompt_inspector
+
+ prompt = parity.build("f02")["prompt"]
+ with unittest.mock.patch.object(
+ prompt_inspector,
+ "_parsed_workflow",
+ wraps=prompt_inspector._parsed_workflow,
+ ) as parsed:
+ inspect_prompt_evidence(
+ {"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
+ )
+ self.assertEqual(parsed.call_count, 0)
+ metadata = _overwritten()
+ inspect_prompt_evidence(
+ {
+ "prompt": json.dumps(metadata["prompt"]),
+ "workflow": json.dumps(metadata["workflow"]),
+ }
+ )
+ self.assertEqual(parsed.call_count, 1)
+
+ def test_default_profile_ignores_the_workflow(self):
+ result = resolve_prompt_pair(_overwritten())
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_texts(result), ("a bird", parity.NEG))
+
+
+def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
+ """``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
+
+ A conditioning output feeds the sampler directly; a string output feeds a
+ CLIPTextEncode (node 6).
+ """
+ prompt = parity.build("f02")["prompt"]
+ prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
+ if kind == "conditioning":
+ prompt["3"]["inputs"]["positive"] = ["10", slot]
+ else:
+ prompt["6"]["inputs"]["text"] = ["10", slot]
+ return {"prompt": prompt}
+
+
+class TestHistoricalPromptManagerNodes(unittest.TestCase):
+ """R7: this repository's node classes up to 07a5af3, with their own join."""
+
+ COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
+
+ def test_v2_outputs_and_comma_join(self):
+ inputs = {**self.COMPOSED, "negative_text": " bad hands "}
+ for slot, kind, expected in (
+ (0, "conditioning", "trig, a cat, sky"),
+ (1, "conditioning", "bad hands"),
+ (2, "string", "trig, a cat, sky"),
+ (3, "string", "bad hands"),
+ ):
+ with self.subTest(slot=slot):
+ result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["positive"]["text"], expected)
+
+ def test_comma_rule_matches_the_historical_code(self):
+ cases = (
+ ({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
+ ({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
+ ({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
+ ({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
+ ({"text": "a cat"}, "a cat"),
+ # Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
+ ({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
+ )
+ for inputs, expected in cases:
+ with self.subTest(inputs=inputs):
+ result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
+ self.assertEqual(result["positive"]["text"], expected)
+
+ def test_single_role_classes(self):
+ cases = (
+ ("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
+ ("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
+ ("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
+ ("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
+ ("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
+ ("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
+ ("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
+ # The negative-only encoder never composes.
+ ("PromptManagerNegative", 0, "conditioning", "a cat"),
+ ("PromptManagerNegative", 1, "string", "a cat"),
+ )
+ for class_type, slot, kind, expected in cases:
+ with self.subTest(node=class_type, slot=slot):
+ result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["positive"]["text"], expected)
+ result = _inspect(
+ _historical(
+ "PromptManagerV2Text",
+ 1,
+ {"text": "a cat", "negative_text": " bad "},
+ "string",
+ )
+ )
+ self.assertEqual(result["positive"]["text"], "bad")
+
+ def test_optional_negative_defaults_to_empty(self):
+ result = _inspect(
+ _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
+ )
+ self.assertEqual(result["positive"]["text"], "a cat")
+ prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
+ prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
+ self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
+
+ def test_unlisted_outputs_and_kinds_are_not_guessed(self):
+ # PromptManagerDetailer output 2 is an INT prompt id.
+ result = _inspect(
+ _historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
+ )
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ # A STRING output read as conditioning is a malformed link.
+ result = _inspect(
+ _historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
+ )
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_linked_parts_are_traced(self):
+ source = _historical(
+ "PromptManagerV2",
+ 0,
+ {"text": ["11", 0], "prepend_text": ["12", 0]},
+ "conditioning",
+ )
+ source["prompt"]["11"] = {
+ "class_type": "PrimitiveString",
+ "inputs": {"value": "a cat"},
+ }
+ source["prompt"]["12"] = {
+ "class_type": "PrimitiveString",
+ "inputs": {"value": "trig"},
+ }
+ result = _inspect(source)
+ self.assertEqual(result["positive"]["text"], "trig, a cat")
+ self.assertEqual(
+ [step["node_id"] for step in result["positive"]["provenance"]["path"]],
+ ["10", "12", "11"],
+ )
+
+ def test_overwritten_historical_nodes_are_still_refused(self):
+ metadata = _overwritten()
+ for node_id in ("17", "113", "119"):
+ metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
+ for node in metadata["workflow"]["nodes"]:
+ if node["type"] == "PromptManager":
+ node["type"] = "PromptManagerV2"
+ self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
+
+ def test_default_profile_keeps_them_unsupported(self):
+ result = resolve_prompt_pair(
+ _historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
+ )
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
+
+
+FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
+
+
+def _parameters(text: str, *, legacy: bool = False) -> dict:
+ profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
+ return resolve_prompt_pair({"parameters": text}, profile=profile)
+
+
+class TestA1111Text(unittest.TestCase):
+ """R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
+
+ def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
+ for text in (
+ f"Negative prompt: bad\n{FOOTER}",
+ f"\nNegative prompt: bad\n{FOOTER}",
+ ):
+ with self.subTest(text=text):
+ result = _parameters(text)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
+ self.assertIsNone(result["positive"])
+ # Two delimiters, one at the start, are still two.
+ result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
+ self.assertEqual(result["state"], "ambiguous")
+
+ def test_dynamic_prompts_template_lines_follow_the_footer(self):
+ base = f"a cat\nNegative prompt: bad\n{FOOTER}"
+ for trailer in (
+ "\nTemplate: a {cat|dog}",
+ "\nTemplate: a {cat|dog}\nNegative Template: bad",
+ "\nNegative Template: bad",
+ "\nTemplate: a {cat|dog}\nNegative Template: bad\n",
+ ):
+ with self.subTest(trailer=trailer):
+ result = _parameters(base + trailer)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_texts(result), ("a cat", "bad"))
+ for trailer in (
+ "\nTemplate: a\nTemplate: b",
+ "\nNegative Template: bad\nTemplate: a",
+ "\nTemplate: a\nmore template text",
+ "\ntrailing",
+ ):
+ with self.subTest(trailer=trailer):
+ result = _parameters(base + trailer)
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_footer_with_only_steps(self):
+ result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
+ self.assertEqual(_texts(result), ("a cat", "bad"))
+ result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
+ self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
+ # A full footer wins over a bare ``Steps: N`` line in the prompt text.
+ result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
+ self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
+ result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_default_profile_keeps_the_historical_split(self):
+ result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
+ self.assertEqual(_texts(result), ("Negative prompt: bad", None))
+ for text in (
+ f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
+ "a cat\nNegative prompt: bad\nSteps: 20",
+ ):
+ with self.subTest(text=text):
+ result = _parameters(text, legacy=True)
+ self.assertEqual(
+ result["reason"], "A1111 settings footer is not unique"
+ )
+
+
+def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
+ body = text.encode("latin-1")
+ if kind == b"zTXt":
+ body = b"\0" + zlib.compress(body)
+ elif kind == b"iTXt":
+ body = b"\0\0\0\0" + text.encode("utf-8")
+ return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
+
+
+def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
+ return (
+ f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
+ f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
+ )
+
+
+class TestBatchIndexDuplicates(unittest.TestCase):
+ """R3: an accumulating batch saver repeats ``parameters`` per batch item."""
+
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.directory = Path(temporary.name)
+
+ def png(self, chunks, name="batch.png"):
+ buffer = io.BytesIO()
+ with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
+ image.save(buffer, format="PNG")
+ data = buffer.getvalue()
+ iend = data.rindex(b"IEND") - 4
+ path = self.directory / name
+ path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
+ return path
+
+ def test_copies_differing_only_in_batch_index_read_as_the_last(self):
+ cases = {
+ "same length": [0, 1, 2],
+ "growing length": [8, 9, 10, 11],
+ "any order": [3, 0],
+ "repeat of an earlier copy": [0, 1, 0],
+ }
+ for label, indexes in cases.items():
+ with self.subTest(label):
+ path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
+ read = read_inspection_file(path)
+ self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
+ self.assertEqual(read.record_formats["parameters"], "original-text")
+ evidence = inspect_prompt_evidence(read.metadata)
+ self.assertEqual(evidence["resolution"]["state"], "resolved")
+ self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
+ # Encodings may differ; the decoded copies are compared.
+ path = self.png(
+ [
+ _chunk("parameters", _batch(0)),
+ _chunk("parameters", _batch(1), b"zTXt"),
+ _chunk("parameters", _batch(2), b"iTXt"),
+ ]
+ )
+ self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
+
+ def test_any_other_difference_is_still_a_conflict(self):
+ cases = {
+ "positive": [_batch(0), _batch(1, positive="a dog")],
+ "seed": [_batch(0), _batch(1, seed=6)],
+ "no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
+ "two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
+ "prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
+ # Review I-1: the field is matched only in the settings footer.
+ "prompt field": [
+ _batch(0).replace("a cat", "a cat, Batch index: 0"),
+ _batch(0).replace("a cat", "a cat, Batch index: 1"),
+ ],
+ "prompt only": [
+ "a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
+ "a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
+ ],
+ "negative field": [
+ _batch(0).replace("bad", "bad, Batch index: 0"),
+ _batch(1).replace("bad", "bad, Batch index: 1"),
+ ],
+ "no footer": [
+ "a cat\nBatch index: 0, Batch size: 2",
+ "a cat\nBatch index: 1, Batch size: 2",
+ ],
+ }
+ for label, texts in cases.items():
+ with self.subTest(label):
+ path = self.png([_chunk("parameters", text) for text in texts])
+ with self.assertRaisesRegex(
+ MetadataReadError, "Duplicate prompt metadata"
+ ):
+ read_inspection_file(path)
+ # The rule is only for ``parameters``: prompt graphs stay byte-identical.
+ prompt = json.dumps(parity.build("f02")["prompt"])
+ path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
+ with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
+ read_inspection_file(path)
+
+ def test_decoded_copies_are_bounded(self):
+ # Small compressed copies must not decode unbounded text.
+ text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
+ chunks = [
+ _chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
+ for i in range(10)
+ ]
+ path = self.png(chunks)
+ with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
+ read_inspection_file(path)
+ # The kept copy still fits the role budget when a later one is longer.
+ from utils.prompt_inspector import ROLE_METADATA_BYTES
+
+ first = _batch(9)
+ first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
+ later = first.replace("index: 9", "index: 10")
+ for copy in (later, later + " " * ROLE_METADATA_BYTES):
+ with self.subTest(size=len(copy)):
+ path = self.png(
+ [
+ _chunk("parameters", first, b"zTXt"),
+ _chunk("parameters", copy, b"zTXt"),
+ ]
+ )
+ with self.assertRaisesRegex(
+ MetadataReadError, "exceeds inspection limits"
+ ):
+ read_inspection_file(path)
+
+
+def _f02(**changes) -> dict:
+ """``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
+ prompt = parity.build("f02")["prompt"]
+ prompt.update(changes)
+ return prompt
+
+
+def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
+ """``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
+ prompt = parity.build("f19")["prompt"]
+ if "upscale_model" in inputs:
+ prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
+ if "segs" in inputs:
+ prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
+ prompt["50"] = {
+ "class_type": class_type,
+ "inputs": {
+ image_input: ["8", 0],
+ "model": ["4", 0],
+ "vae": ["4", 2],
+ "positive": list(positive),
+ "negative": ["7", 0],
+ "seed": 7,
+ "steps": 12,
+ "cfg": 4.5,
+ "sampler_name": "euler",
+ "scheduler": "normal",
+ "denoise": 0.4,
+ **inputs,
+ },
+ }
+ return prompt
+
+
+DETAILERS = (
+ ("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
+ ("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
+ ("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
+)
+
+
+class TestInstalledNodeSemantics(unittest.TestCase):
+ """R6 entries verified against the installed node sources (see the report)."""
+
+ def test_upscale_and_segs_detailers_are_stages(self):
+ for class_type, image, extra in DETAILERS:
+ with self.subTest(node=class_type):
+ same = _inspect({"prompt": _stage(class_type, image, **extra)})
+ self.assertEqual(same["state"], "resolved")
+ self.assertEqual(same["sampler_id"], "50")
+ self.assertEqual(_texts(same), (parity.POS, parity.NEG))
+ other = _inspect(
+ {"prompt": _stage(class_type, image, ("10", 0), **extra)}
+ )
+ self.assertEqual(other["state"], "ambiguous")
+ self.assertEqual(other["reason_code"], "multistage-differs")
+ self.assertEqual(
+ sorted(item["sampler_id"] for item in other["candidates"]),
+ ["3", "50"],
+ )
+
+ def test_a_detailer_wildcard_replaces_its_prompt(self):
+ # Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
+ for class_type in ("DetailerForEach", "FaceDetailer"):
+ with self.subTest(node=class_type):
+ result = _inspect(
+ {"prompt": _stage(class_type, "image", wildcard="a smiling face")}
+ )
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "runtime-value")
+ self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
+ linked = _stage(class_type, "image", wildcard=["14", 0])
+ linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
+ result = _inspect({"prompt": linked})
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "14", "class_type": "WildcardSource"}
+ )
+ for empty in ("", None):
+ result = _inspect(
+ {"prompt": _stage(class_type, "image", wildcard=empty)}
+ )
+ self.assertEqual(result["state"], "resolved", empty)
+
+ def test_conditioning_passthroughs_keep_each_role(self):
+ pair = {"positive": ["6", 0], "negative": ["7", 0]}
+ cases = {
+ "ControlNetInpaintingAliMamaApply": (
+ {
+ **pair,
+ "control_net": ["11", 0],
+ "vae": ["4", 2],
+ "image": ["12", 0],
+ "mask": ["12", 1],
+ "strength": 1.0,
+ },
+ 0,
+ 1,
+ ),
+ "LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
+ "FluxKontextMultiReferenceLatentMethod": (
+ {"conditioning": ["6", 0], "reference_latents_method": "offset"},
+ 0,
+ None,
+ ),
+ }
+ for class_type, (inputs, positive, negative) in cases.items():
+ with self.subTest(node=class_type):
+ prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
+ prompt["3"]["inputs"]["positive"] = ["20", positive]
+ if negative is not None:
+ prompt["3"]["inputs"]["negative"] = ["20", negative]
+ result = _inspect({"prompt": prompt})
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_texts(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["positive"]["provenance"]["path"][0]["node_id"], "20"
+ )
+ if negative is None:
+ prompt["3"]["inputs"]["negative"] = ["20", 1]
+ self.assertEqual(
+ _inspect({"prompt": prompt})["reason_code"], "unsupported-node"
+ )
+
+ def test_text_nodes(self):
+ prompt = _f02(
+ **{
+ "6": {
+ "class_type": "Text to Conditioning",
+ "inputs": {"clip": ["4", 1], "text": ["21", 0]},
+ },
+ "21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
+ "22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
+ }
+ )
+ result = _inspect({"prompt": prompt})
+ self.assertEqual(_texts(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ [step["node_id"] for step in result["positive"]["provenance"]["path"]],
+ ["6", "21", "22"],
+ )
+ # Text to Conditioning has one output; TextInput reads only ``text``.
+ prompt["3"]["inputs"]["positive"] = ["6", 1]
+ self.assertEqual(
+ _inspect({"prompt": prompt})["reason_code"], "unsupported-node"
+ )
+
+ def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
+ # R1 before R6: the pass-through must not let overwritten text resolve.
+ metadata = _overwritten()
+ metadata["prompt"]["20"] = {
+ "class_type": "FluxKontextMultiReferenceLatentMethod",
+ "inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
+ }
+ metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
+ result = _inspect(metadata)
+ self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
+
+ def test_default_profile_keeps_them_unsupported(self):
+ for class_type, image, extra in DETAILERS:
+ with self.subTest(node=class_type):
+ result = resolve_prompt_pair(
+ {"prompt": _stage(class_type, image, **extra)}
+ )
+ # The historical rule: the one KSampler, whatever reads its image.
+ self.assertEqual(result["sampler_id"], "3")
+ prompt = _f02(
+ **{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
+ )
+ self.assertEqual(
+ resolve_prompt_pair({"prompt": prompt})["reason"],
+ "Unsupported node semantics or output slot",
+ )
+
+
+def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
+ """``f02`` with its KSampler (node 3) replaced by ``class_type``."""
+ prompt = _f02()
+ sampler = dict(prompt["3"]["inputs"], **inputs)
+ sampler[latent] = sampler.pop("latent_image")
+ prompt["3"] = {"class_type": class_type, "inputs": sampler}
+ return prompt
+
+
+SAMPLERS = (
+ ("ClownsharKSampler_Beta", "latent_image", "options"),
+ ("KSampler (Efficient)", "latent_image", "script"),
+ ("ttN KSampler_v2", "latent", "adv_xyPlot"),
+)
+
+
+class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
+ """R6 entries for nodes not installed here, read from their pinned sources.
+
+ Each source is the commit the maintainer's saved workflows name in the
+ node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
+ file and line).
+ """
+
+ def test_ksampler_shaped_samplers_resolve(self):
+ for class_type, latent, _ in SAMPLERS:
+ with self.subTest(node=class_type):
+ result = _inspect({"prompt": _sampled(class_type, latent)})
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertEqual(_texts(result), (parity.POS, parity.NEG))
+
+ def test_inputs_that_replace_the_prompt_are_not_followed(self):
+ # RES4LYF options may carry a guider; Efficiency scripts and the ttN
+ # XY plot re-run the sampler with other settings.
+ for class_type, latent, override in SAMPLERS:
+ with self.subTest(node=class_type):
+ prompt = _sampled(class_type, latent, **{override: ["30", 0]})
+ prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
+ result = _inspect({"prompt": prompt})
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "30", "class_type": "SomeScript"}
+ )
+
+ def test_chained_samplers_are_stages(self):
+ prompt = _sampled("ClownsharKSampler_Beta")
+ prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
+ prompt["31"]["inputs"]["latent_image"] = ["3", 0]
+ prompt["8"]["inputs"]["samples"] = ["31", 0]
+ # Unanchored, two listed samplers are two branches, as for KSampler.
+ self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
+ result = _inspect({"prompt": prompt}, output_node_id="9")
+ self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
+
+ def test_seed_variance_enhancer_passes_its_conditioning(self):
+ prompt = _f02(
+ **{
+ "20": {
+ "class_type": "SeedVarianceEnhancer",
+ "inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
+ }
+ }
+ )
+ prompt["3"]["inputs"]["positive"] = ["20", 0]
+ self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
+
+ def test_overwritten_text_behind_seed_variance_is_still_refused(self):
+ # R1 before R6: the recon's 24 files differ from this only in names.
+ metadata = _overwritten()
+ metadata["prompt"]["20"] = {
+ "class_type": "SeedVarianceEnhancer",
+ "inputs": {"conditioning": ["17", 0], "seed": 1},
+ }
+ metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
+ result = _inspect(metadata)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
+
+ def test_instantid_advanced_outputs(self):
+ prompt = _f02(
+ **{
+ "20": {
+ "class_type": "ApplyInstantIDAdvanced",
+ "inputs": {
+ "model": ["4", 0],
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "ip_weight": 0.8,
+ },
+ }
+ }
+ )
+ prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
+ self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
+ prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
+ self.assertEqual(
+ _inspect({"prompt": prompt})["reason_code"], "unsupported-node"
+ )
+
+ def test_crystools_conditioning_switch(self):
+ def switch(condition) -> dict:
+ prompt = _f02(
+ **{
+ "11": _pm_encoder("a dog"),
+ "20": {
+ "class_type": "Switch conditioning [Crystools]",
+ "inputs": {
+ "on_true": ["6", 0],
+ "on_false": ["11", 0],
+ "boolean": condition,
+ },
+ },
+ "21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
+ }
+ )
+ prompt["3"]["inputs"]["positive"] = ["20", 0]
+ return _inspect({"prompt": prompt})
+
+ self.assertEqual(_texts(switch(True))[0], parity.POS)
+ self.assertEqual(_texts(switch(False))[0], "a dog")
+ result = switch(["21", 0])
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(
+ sorted(_texts(item)[0] for item in result["candidates"]),
+ sorted([parity.POS, "a dog"]),
+ )
+
+ def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
+ def smz(**inputs) -> dict:
+ node = {
+ "class_type": "smZ CLIPTextEncode",
+ "inputs": {
+ "clip": ["4", 1],
+ "text": "a cat",
+ "text_g": "a cat",
+ "text_l": "a cat",
+ "with_SDXL": False,
+ "parser": "comfy",
+ **inputs,
+ },
+ }
+ return _inspect({"prompt": _f02(**{"6": node})})
+
+ self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
+ # with_SDXL: the connected CLIP decides at run time which inputs are read.
+ self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
+ for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
+ with self.subTest(parts=parts):
+ result = smz(with_SDXL=True, **parts)
+ self.assertEqual(result["reason_code"], "runtime-value")
+ self.assertEqual(
+ result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
+ )
+ linked = smz(with_SDXL=["21", 0])
+ self.assertEqual(linked["reason_code"], "unsupported-node")
+ for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
+ with self.subTest(flag=broken):
+ self.assertEqual(smz(**broken)["reason_code"], "malformed")
+
+ def test_string_sources(self):
+ cases = (
+ ("ttN text", "text"),
+ ("CR Text", "text"),
+ ("CR Prompt Text", "prompt"),
+ ("TextBoxMira", "text"),
+ )
+ for class_type, name in cases:
+ with self.subTest(node=class_type):
+ prompt = _f02(
+ **{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
+ )
+ prompt["6"]["inputs"]["text"] = ["10", 0]
+ self.assertEqual(
+ _texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
+ )
+ # Output 1 of the Comfyroll nodes is their help URL.
+ prompt["6"]["inputs"]["text"] = ["10", 1]
+ self.assertEqual(
+ _inspect({"prompt": prompt})["reason_code"], "unsupported-node"
+ )
+
+ def test_default_profile_keeps_them_unsupported(self):
+ for class_type, latent, _ in SAMPLERS:
+ with self.subTest(node=class_type):
+ result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
+ self.assertEqual(result["reason"], "No supported sampler pair found")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_inspection_outcomes.py b/tests/test_inspection_outcomes.py
new file mode 100644
index 00000000..a3c3555f
--- /dev/null
+++ b/tests/test_inspection_outcomes.py
@@ -0,0 +1,486 @@
+"""Non-resolved prompt outcomes carry one short, truthful line per role."""
+
+import ast
+import json
+import unittest
+import unittest.mock
+from pathlib import Path
+from dataclasses import replace
+
+from utils import prompt_pair_resolver
+from utils.inspection_outcomes import (
+ _UNCODED_LINES,
+ CLASS_NAME_LENGTH,
+ REASON_CODES,
+ outcome_message,
+ project_resolution,
+)
+from utils.inspection_response import encode_inspection_response
+from utils.prompt_inspector import inspect_prompt_evidence
+from utils.prompt_pair_resolver import (
+ EMPTY_POSITIVE_REASON,
+ INSPECTION_PROFILE,
+ MULTISTAGE_UNATTRIBUTED_REASON,
+ SEMANTICS_UNAVAILABLE_REASON,
+)
+
+LIMITS = "Prompt exceeds inspection limits."
+
+
+def _encode(text: str) -> dict:
+ return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
+
+
+def _latent() -> dict:
+ return {
+ "class_type": "EmptyLatentImage",
+ "inputs": {"width": 8, "height": 8, "batch_size": 1},
+ }
+
+
+def _ksampler(positive: str = "7") -> dict:
+ return {
+ "class_type": "KSampler",
+ "inputs": {
+ "positive": [positive, 0],
+ "negative": ["6", 0],
+ "latent_image": ["4", 0],
+ "model": ["9", 0],
+ "seed": 1,
+ "steps": 2,
+ "cfg": 1.0,
+ "sampler_name": "euler",
+ "scheduler": "normal",
+ "denoise": 1.0,
+ },
+ }
+
+
+def _dual_cfg() -> dict:
+ return {
+ "1": _encode("positive"),
+ "6": _encode("negative"),
+ "2": {
+ "class_type": "DualCFGGuider",
+ "inputs": {
+ "cond1": ["1", 0],
+ "cond2": ["1", 0],
+ "negative": ["6", 0],
+ "model": ["9", 0],
+ },
+ },
+ "3": {
+ "class_type": "SamplerCustomAdvanced",
+ "inputs": {
+ "guider": ["2", 0],
+ "noise": ["9", 0],
+ "sampler": ["9", 0],
+ "sigmas": ["9", 0],
+ "latent_image": ["4", 0],
+ },
+ },
+ "4": _latent(),
+ }
+
+
+def _runtime() -> dict:
+ return {
+ "1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
+ "7": {
+ "class_type": "CLIPTextEncode",
+ "inputs": {"text": ["1", 0], "clip": ["9", 0]},
+ },
+ "6": _encode("negative"),
+ "3": _ksampler(),
+ "4": _latent(),
+ }
+
+
+def _composite() -> dict:
+ graph = _runtime()
+ graph["7"] = {
+ "class_type": "ConditioningCombine",
+ "inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
+ }
+ graph["8"] = _encode("other")
+ return graph
+
+
+def _messages(evidence: dict) -> dict:
+ return {role: side.get("message") for role, side in evidence["prompts"].items()}
+
+
+def _statuses(evidence: dict) -> dict:
+ return {role: side["status"] for role, side in evidence["prompts"].items()}
+
+
+class TestOutcomeMessages(unittest.TestCase):
+ def assert_unresolved_with(self, evidence: dict, message: str) -> None:
+ self.assertEqual(
+ _statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
+ )
+ self.assertEqual(
+ _messages(evidence), {"positive": message, "negative": message}
+ )
+
+ def test_unsupported_node_status_names_the_class(self):
+ evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
+ self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
+ self.assert_unresolved_with(
+ evidence,
+ "Prompt uses a node the inspector does not support (DualCFGGuider).",
+ )
+
+ def test_runtime_and_composite_statuses(self):
+ self.assert_unresolved_with(
+ inspect_prompt_evidence({"prompt": _runtime()}),
+ "Prompt is chosen at run time (PromptSearchList).",
+ )
+ self.assert_unresolved_with(
+ inspect_prompt_evidence({"prompt": _composite()}),
+ "Prompt combines several texts (ConditioningCombine).",
+ )
+
+ def test_limit_status(self):
+ tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
+ evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
+ self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
+ self.assert_unresolved_with(evidence, LIMITS)
+ for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
+ self.assertEqual(
+ outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
+ )
+
+ def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
+ for code in REASON_CODES:
+ message = outcome_message({"state": "unresolved", "reason_code": code})
+ self.assertIsInstance(message, str, code)
+ self.assertTrue(message.endswith("."), code)
+ self.assertNotIn("could not be resolved", message)
+ for reason, expected in (
+ (EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
+ (
+ SEMANTICS_UNAVAILABLE_REASON,
+ "The inspector's node table is unavailable.",
+ ),
+ (
+ MULTISTAGE_UNATTRIBUTED_REASON,
+ "Several sampler stages were found; their prompts were not compared.",
+ ),
+ (
+ "Media metadata could not be read",
+ "Metadata in this file could not be read.",
+ ),
+ ):
+ resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
+ self.assertEqual(outcome_message(resolution), expected)
+
+ def test_named_codes_without_a_class_omit_the_name(self):
+ for code, line in (
+ ("unsupported-node", "Prompt uses a node the inspector does not support."),
+ ("runtime-value", "Prompt is chosen at run time."),
+ ("composite", "Prompt combines several texts."),
+ ):
+ for node in (None, {"id": "7", "class_type": None}):
+ resolution = {"state": "unresolved", "reason_code": code, "node": node}
+ self.assertEqual(outcome_message(resolution), line)
+
+ def test_class_name_is_bounded(self):
+ resolution = {
+ "state": "unresolved",
+ "reason_code": "unsupported-node",
+ "node": {"id": "7", "class_type": "N" * 500},
+ }
+ self.assertEqual(
+ outcome_message(resolution),
+ "Prompt uses a node the inspector does not support "
+ f"({'N' * CLASS_NAME_LENGTH}).",
+ )
+ self.assertEqual(CLASS_NAME_LENGTH, 200)
+
+ def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
+ for resolution in (
+ {"state": "resolved", "reason_code": None},
+ {"state": "ambiguous", "reason_code": "multistage-differs"},
+ {"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
+ {"state": "absent", "reason_code": None, "reason": "No parsed"},
+ {"state": "unresolved", "reason_code": "guess"},
+ {"state": "unresolved", "reason_code": None, "reason": "Anything else"},
+ None,
+ ):
+ self.assertIsNone(outcome_message(resolution), resolution)
+
+ def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
+ evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
+ self.assert_unresolved_with(evidence, "No supported sampler was found.")
+ evidence = inspect_prompt_evidence({"prompt": "not-json"})
+ self.assert_unresolved_with(
+ evidence, "Prompt metadata in this file is malformed."
+ )
+ graph = _runtime()
+ graph["7"] = _encode(" ")
+ evidence = inspect_prompt_evidence({"prompt": graph})
+ self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
+ self.assert_unresolved_with(evidence, "Positive prompt is empty.")
+
+ def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
+ side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
+ negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
+ resolved = {
+ "state": "resolved",
+ "positive": side,
+ "negative": negative,
+ "sampler_id": "3",
+ "reason": "",
+ "reason_code": None,
+ "node": None,
+ }
+ with unittest.mock.patch(
+ "utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
+ ):
+ evidence = inspect_prompt_evidence({"prompt": {}})
+ self.assertEqual(
+ evidence["prompts"]["positive"],
+ {
+ "status": "unresolved",
+ "text": None,
+ "source": None,
+ "message": "Positive prompt is empty.",
+ },
+ )
+ self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
+ self.assertNotIn("message", evidence["prompts"]["negative"])
+
+ def test_candidates_and_absence_keep_their_status_lines(self):
+ graph = _runtime()
+ graph["7"] = _encode("positive")
+ graph["5"] = _ksampler()
+ evidence = inspect_prompt_evidence({"prompt": graph})
+ self.assertEqual(evidence["resolution"]["state"], "ambiguous")
+ self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+ evidence = inspect_prompt_evidence({})
+ self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+ graph = _runtime()
+ graph["7"] = _encode("positive")
+ evidence = inspect_prompt_evidence({"prompt": graph})
+ self.assertEqual(evidence["resolution"]["state"], "resolved")
+ self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+
+
+class TestResolutionProjection(unittest.TestCase):
+ def test_closed_codes_and_named_nodes_pass_unchanged(self):
+ for code in REASON_CODES:
+ resolution = {
+ "state": "unresolved",
+ "sampler_id": "3",
+ "reason": "r",
+ "reason_code": code,
+ "node": None,
+ }
+ self.assertEqual(project_resolution(resolution), resolution)
+ named = {
+ "state": "unresolved",
+ "sampler_id": None,
+ "reason": "r",
+ "reason_code": "composite",
+ "node": {"id": "7", "class_type": None},
+ }
+ self.assertEqual(project_resolution(named), named)
+
+ def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
+ base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
+ for code, node in (
+ ("guess", None),
+ (1, None),
+ ("malformed", {"id": "7", "class_type": "X"}),
+ (None, {"id": "7", "class_type": "X"}),
+ ("unsupported-node", {"id": 7, "class_type": "X"}),
+ ("unsupported-node", {"id": "", "class_type": "X"}),
+ ("unsupported-node", {"id": "7"}),
+ ("unsupported-node", {"id": "7", "class_type": 7}),
+ ("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
+ ("unsupported-node", "X"),
+ ):
+ projected = project_resolution({**base, "reason_code": code, "node": node})
+ self.assertIsNone(projected["node"], (code, node))
+ if code not in REASON_CODES:
+ self.assertIsNone(projected["reason_code"], code)
+ for state in ("resolved", "absent"):
+ projected = project_resolution(
+ {**base, "state": state, "reason_code": "work-limit", "node": None}
+ )
+ self.assertIsNone(projected["reason_code"])
+ long_name = project_resolution(
+ {
+ **base,
+ "reason_code": "runtime-value",
+ "node": {"id": "7" * 300, "class_type": "N" * 300},
+ }
+ )["node"]
+ self.assertEqual(
+ long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
+ )
+ unnamed = project_resolution(
+ {
+ **base,
+ "reason_code": "composite",
+ "node": {"id": "7", "class_type": ""},
+ }
+ )["node"]
+ self.assertEqual(unnamed, {"id": "7", "class_type": None})
+
+ def test_wire_carries_role_lines_and_projected_resolution(self):
+ evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
+ resolution = dict(evidence["resolution"], reason_code="guess")
+ body = json.loads(
+ encode_inspection_response(
+ image={"root_index": 0, "path": "a.png", "filename": "a.png"},
+ root_scope="a" * 64,
+ file={
+ "observation": "current-file",
+ "source_label": "output",
+ "media_type": "image",
+ "extension": ".png",
+ "size": 1,
+ "modified_time": 1,
+ },
+ prompts=evidence["prompts"],
+ resolution=resolution,
+ inspection=None,
+ library={"status": "none", "tags": []},
+ )
+ )
+ self.assertEqual(body["prompts"], evidence["prompts"])
+ self.assertIsNone(body["resolution"]["reason_code"])
+ self.assertIsNone(body["resolution"]["node"])
+
+
+# Uncoded unresolved reasons only the default (ingestion) profile emits; the
+# inspector never shows them, so they need no line.
+_DEFAULT_PROFILE_ONLY = frozenset(
+ {"Cyclic or excessive latent ancestry", "Resolution limit reached"}
+)
+
+
+def _uncoded_unresolved_reasons() -> set:
+ """Every literal reason the resolver can return as unresolved without a code.
+
+ Reads the source: ``_Unresolved(reason)`` without a code, and
+ ``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
+ is a string literal or a module constant naming one.
+ """
+ source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
+ tree = ast.parse(source)
+
+ def literal(node):
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return node.value
+ if isinstance(node, ast.Name):
+ value = getattr(prompt_pair_resolver, node.id, None)
+ return value if isinstance(value, str) else None
+ return None
+
+ found = set()
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
+ continue
+ keywords = {item.arg: item.value for item in node.keywords}
+ if node.func.id == "_Unresolved":
+ if len(node.args) < 2 and "code" not in keywords and node.args:
+ reason = literal(node.args[0])
+ if reason is None:
+ raise AssertionError(f"line {node.lineno}: unreadable reason")
+ found.add(reason)
+ elif node.func.id == "_result" and node.args:
+ state = literal(node.args[0])
+ coded = "reason_code" in keywords or len(node.args) > 6
+ reason = keywords.get(
+ "reason", node.args[4] if len(node.args) > 4 else None
+ )
+ if state == "unresolved" and not coded and reason is not None:
+ text = literal(reason)
+ if text is None:
+ raise AssertionError(f"line {node.lineno}: unreadable reason")
+ found.add(text)
+ return found
+
+
+class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
+ """Task 2.7 review M1: a new uncoded unresolved reason fails here."""
+
+ def test_resolver_reasons_without_a_code_have_a_line(self):
+ reasons = _uncoded_unresolved_reasons()
+ self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
+ missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
+ self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
+ # The allowance names only reasons the source still emits.
+ self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
+
+ def test_default_profile_only_reasons_are_coded_in_inspection(self):
+ # The limit outcome is uncoded only on the default profile.
+ source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
+ for limited in ({"max_depth": 0}, {"max_text_length": 0}):
+ with self.subTest(**limited):
+ default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
+ inspected = prompt_pair_resolver.resolve_prompt_pair(
+ source, profile=INSPECTION_PROFILE, **limited
+ )
+ self.assertEqual(default["reason"], "Resolution limit reached")
+ self.assertIsNone(default["reason_code"])
+ self.assertEqual(inspected["reason"], "Resolution limit reached")
+ self.assertIsNotNone(inspected["reason_code"])
+
+
+class TestRoleMessageBound(unittest.TestCase):
+ """Task 2.7 review M4: the encoder bounds role lines as the client does."""
+
+ def encode(self, prompts: dict) -> dict:
+ return json.loads(
+ encode_inspection_response(
+ image={"root_index": 0, "path": "a.png", "filename": "a.png"},
+ root_scope="a" * 64,
+ file={
+ "observation": "current-file",
+ "source_label": "output",
+ "media_type": "image",
+ "extension": ".png",
+ "size": 1,
+ "modified_time": 1,
+ },
+ prompts=prompts,
+ resolution={"state": "unresolved", "reason": "x"},
+ inspection=None,
+ library={"status": "none", "tags": []},
+ )
+ )["prompts"]
+
+ def test_role_messages_are_bounded_text_on_unresolved_roles(self):
+ side = {"status": "unresolved", "text": None, "source": None}
+ prompts = self.encode(
+ {
+ "positive": {**side, "message": "x" * 600},
+ "negative": {**side, "message": "\U0001f600" * 300},
+ }
+ )
+ self.assertEqual(prompts["positive"]["message"], "x" * 512)
+ # The client counts UTF-16 code units.
+ self.assertLessEqual(
+ len(prompts["negative"]["message"].encode("utf-16-le")), 1024
+ )
+ self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
+ for message in ("", None, 7):
+ with self.subTest(message=message):
+ prompts = self.encode(
+ {"positive": {**side, "message": message}, "negative": side}
+ )
+ self.assertNotIn("message", prompts["positive"])
+ resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
+ prompts = self.encode(
+ {"positive": {**resolved, "message": "line"}, "negative": side}
+ )
+ self.assertNotIn("message", prompts["positive"])
+ self.assertEqual(prompts["negative"], side)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_inspection_semantics.py b/tests/test_inspection_semantics.py
new file mode 100644
index 00000000..8739e04f
--- /dev/null
+++ b/tests/test_inspection_semantics.py
@@ -0,0 +1,838 @@
+"""The declarative inspection node semantics table and its loader (#240, spec 7).
+
+``web/js/inspection-semantics.json`` is the single table. Python validates it at
+import and fails loudly; the browser facts tables are checked against its
+``facts`` section by ``tests/js/inspection-semantics-sync.test.js``.
+"""
+
+import copy
+import json
+import tempfile
+import unittest
+import unittest.mock
+from pathlib import Path
+from types import MappingProxyType
+
+from utils import inspection_semantics as semantics
+from utils.inspection_semantics import SemanticsError, load_semantics
+
+REPO = Path(__file__).resolve().parents[1]
+TABLE = REPO / "web" / "js" / "inspection-semantics.json"
+
+
+def _minimal():
+ """A small valid table; mutation tests break exactly one thing in a copy."""
+ return {
+ "schema": 1,
+ "samplers": {
+ "KSampler": {
+ "strategy": "ksampler",
+ "conditioning": {"positive": "positive", "negative": "negative"},
+ "latent": "latent_image",
+ "facts": "ksampler",
+ }
+ },
+ "guiders": {"BasicGuider": {"strategy": "basic", "positive": "conditioning"}},
+ "conditioning": [
+ {
+ "class": "FluxGuidance",
+ "slot": 0,
+ "strategy": "passthrough",
+ "input": "conditioning",
+ }
+ ],
+ "selectors": {},
+ "composites": {},
+ "encoders": [
+ {
+ "class": "CLIPTextEncode",
+ "slot": 0,
+ "strategy": "single",
+ "inputs": ["text"],
+ }
+ ],
+ "strings": [],
+ "runtime": [],
+ "savers": {},
+ "non_file_outputs": [],
+ "stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
+ "facts": {
+ "configuration_outputs": {},
+ "model_outputs": {},
+ "model_passthroughs": [],
+ "scalar_outputs": {},
+ },
+ }
+
+
+class _TableCase(unittest.TestCase):
+ def load_text(self, text):
+ # Windows: the file handle is closed before the directory is removed.
+ with tempfile.TemporaryDirectory() as root:
+ path = Path(root) / "semantics.json"
+ path.write_text(text, encoding="utf-8")
+ return load_semantics(path)
+
+ def load_doc(self, doc):
+ return self.load_text(json.dumps(doc))
+
+ def assertRejected(self, doc, fragment):
+ with self.assertRaises(SemanticsError) as caught:
+ self.load_doc(doc)
+ self.assertIn(fragment, str(caught.exception))
+
+
+class SpecEntriesTest(unittest.TestCase):
+ def test_semantics_file_validates_and_lists_spec_entries(self):
+ table = load_semantics()
+
+ self.assertEqual(semantics.SEMANTICS_PATH, TABLE)
+ self.assertEqual(
+ semantics.SEMANTICS_PATH,
+ Path(semantics.__file__).resolve().parents[1]
+ / "web"
+ / "js"
+ / "inspection-semantics.json",
+ )
+
+ pair = {"positive": "positive", "negative": "negative"}
+ for name in ("KSampler", "KSamplerAdvanced"):
+ self.assertEqual(
+ table.samplers[name],
+ {
+ "strategy": "ksampler",
+ "conditioning": pair,
+ "latent": "latent_image",
+ "facts": "ksampler",
+ },
+ )
+ self.assertEqual(
+ table.samplers["SamplerCustom"],
+ {
+ "strategy": "ksampler",
+ "conditioning": pair,
+ "latent": "latent_image",
+ "facts": "custom",
+ },
+ )
+ self.assertEqual(
+ table.samplers["SamplerCustomAdvanced"],
+ {
+ "strategy": "custom-advanced",
+ "conditioning": {"guider": "guider"},
+ "latent": "latent_image",
+ "facts": "custom-advanced",
+ },
+ )
+ # D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
+ # A non-empty wildcard replaces the detail prompt (Impact core.py:267).
+ self.assertEqual(
+ table.samplers["FaceDetailer"],
+ {
+ "strategy": "detailer-stage",
+ "conditioning": pair,
+ "image_stage": "image",
+ "facts": "ksampler",
+ "prompt_overrides": ("wildcard",),
+ },
+ )
+
+ self.assertEqual(
+ table.guiders["CFGGuider"],
+ {"strategy": "cfg", "positive": "positive", "negative": "negative"},
+ )
+ self.assertEqual(
+ table.guiders["BasicGuider"],
+ {
+ "strategy": "basic",
+ "positive": "conditioning",
+ "absent_reason": "Guider has no negative input",
+ },
+ )
+ # D17: DualCFGGuider has two positives and stays an unsupported node.
+ self.assertNotIn("DualCFGGuider", table.guiders)
+ self.assertNotIn("DualCFGGuider", table.all_classes)
+
+ single_input = (
+ "FluxGuidance",
+ "ReferenceLatent",
+ "ConditioningSetArea",
+ "ConditioningSetAreaPercentage",
+ "ConditioningSetAreaStrength",
+ "ConditioningSetMask",
+ "ConditioningSetTimestepRange",
+ "ControlNetApply",
+ )
+ for name in single_input:
+ self.assertEqual(
+ table.conditioning[(name, 0)],
+ {"strategy": "passthrough", "input": "conditioning"},
+ )
+ self.assertNotIn((name, 1), table.conditioning)
+ for name in (
+ "ControlNetApplyAdvanced",
+ "ControlNetApplySD3",
+ "InpaintModelConditioning",
+ "WanImageToVideo",
+ ):
+ self.assertEqual(
+ table.conditioning[(name, 0)],
+ {"strategy": "passthrough", "input": "positive"},
+ )
+ self.assertEqual(
+ table.conditioning[(name, 1)],
+ {"strategy": "passthrough", "input": "negative"},
+ )
+ # Slot 2 (a latent) is not conditioning; spec 7.4 handles origins.
+ self.assertNotIn((name, 2), table.conditioning)
+ self.assertEqual(
+ table.conditioning[("ConditioningZeroOut", 0)],
+ {"strategy": "zeroed", "input": "conditioning"},
+ )
+
+ self.assertEqual(
+ table.selectors["easy ifElse"],
+ {
+ "strategy": "boolean-select",
+ "condition": "boolean",
+ "branches": {"true": "on_true", "false": "on_false"},
+ },
+ )
+
+ self.assertEqual(
+ table.composites["ConditioningCombine"],
+ {"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
+ )
+ for name in ("ConditioningConcat", "ConditioningAverage"):
+ self.assertEqual(
+ table.composites[name],
+ {
+ "strategy": "combine",
+ "inputs": ("conditioning_to", "conditioning_from"),
+ },
+ )
+
+ encoders = {
+ ("CLIPTextEncode", 0): ("single", ("text",)),
+ ("CLIPTextEncodeSDXL", 0): ("parts-equal", ("text_g", "text_l")),
+ ("CLIPTextEncodeFlux", 0): ("parts-equal", ("clip_l", "t5xxl")),
+ ("CLIPTextEncodeSD3", 0): ("parts-equal", ("clip_l", "clip_g", "t5xxl")),
+ ("TextEncodeQwenImageEdit", 0): ("single", ("prompt",)),
+ ("TextEncodeQwenImageEditPlus", 0): ("single", ("prompt",)),
+ ("TextEncodeQwenImage21", 0): ("single", ("prompt",)),
+ ("TextEncodeQwenImage21", 1): ("single", ("negative_prompt",)),
+ # D11: a plain text encoder, no composition.
+ ("KikoTextEncode", 0): ("single", ("text",)),
+ }
+ for key, (strategy, inputs) in encoders.items():
+ self.assertEqual(
+ table.encoders[key], {"strategy": strategy, "inputs": inputs}, key
+ )
+ self.assertNotIn(("TextEncodeQwenImage21", 2), table.encoders)
+
+ self.assertEqual(
+ table.strings[("StringConstant", 0)],
+ {"strategy": "literal", "inputs": ("string",)},
+ )
+ self.assertEqual(
+ table.strings[("StringConstantMultiline", 0)],
+ {
+ "strategy": "strip-newlines",
+ "inputs": ("string",),
+ "params": {"flag": "strip_newlines"},
+ },
+ )
+ self.assertEqual(
+ table.strings[("JoinStringMulti", 0)],
+ {
+ "strategy": "join-multi",
+ "inputs": ("string_1",),
+ "params": {
+ "count": "inputcount",
+ "delimiter": "delimiter",
+ "list_flag": "return_list",
+ "input_prefix": "string_",
+ },
+ },
+ )
+
+ # D18: decided at run time.
+ self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
+
+ comfy = "{name}_{counter:05}_.{ext}"
+ for name, image in (
+ ("SaveImage", "images"),
+ ("SaveAnimatedWEBP", "images"),
+ ("SaveAnimatedPNG", "images"),
+ ("SaveVideo", "video"),
+ ("SaveWEBM", "images"),
+ ):
+ self.assertEqual(
+ table.savers[name],
+ {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": image,
+ "pattern": comfy,
+ },
+ )
+ self.assertEqual(
+ table.savers["VHS_VideoCombine"],
+ {
+ "strategy": "vhs-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}.{ext}",
+ },
+ )
+ # Until its naming is verified in pinned source it stays terminal-unknown.
+ self.assertNotIn("KikoSaveImage", table.all_classes)
+ self.assertEqual(
+ table.non_file_outputs,
+ frozenset(
+ {
+ "PreviewImage",
+ "PreviewAny",
+ "SaveImageWebsocket",
+ "Image Comparer (rgthree)",
+ }
+ ),
+ )
+
+ # Not listed this slice: they stay unsupported-node with the class named.
+ for name in (
+ "ImpactWildcardProcessor",
+ "RegexReplace",
+ "LoRAfromText",
+ "XYZPrompt",
+ ):
+ self.assertNotIn(name, table.all_classes)
+ # Existing code adapters stay in code, unchanged.
+ for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
+ self.assertNotIn(name, table.all_classes)
+
+ def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
+ # Task 2.7b (R6): input names and slots read from each node's source;
+ # the report lists the file and line for every one.
+ table = load_semantics()
+ pair = {"positive": "positive", "negative": "negative"}
+ for name, image in (
+ ("DetailerForEach", "image"),
+ ("UltimateSDUpscale", "image"),
+ ("UltimateSDUpscaleNoUpscale", "upscaled_image"),
+ ):
+ entry = dict(table.samplers[name])
+ self.assertEqual(
+ entry.pop("prompt_overrides", None) is not None,
+ name == "DetailerForEach",
+ name,
+ )
+ self.assertEqual(
+ entry,
+ {
+ "strategy": "detailer-stage",
+ "conditioning": pair,
+ "image_stage": image,
+ "facts": "ksampler",
+ },
+ name,
+ )
+ for (name, slot), source in {
+ ("ControlNetInpaintingAliMamaApply", 0): "positive",
+ ("ControlNetInpaintingAliMamaApply", 1): "negative",
+ ("LTXVConditioning", 0): "positive",
+ ("LTXVConditioning", 1): "negative",
+ ("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
+ }.items():
+ self.assertEqual(
+ table.conditioning[(name, slot)],
+ {"strategy": "passthrough", "input": source},
+ )
+ self.assertNotIn(
+ ("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
+ )
+ self.assertEqual(
+ table.encoders[("Text to Conditioning", 0)],
+ {"strategy": "single", "inputs": ("text",)},
+ )
+ for name in ("TextInput", "DisplayText"):
+ self.assertEqual(
+ table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
+ )
+
+ def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
+ # Review I-2: Python's resolver and comfy-metadata.js read these lists here.
+ stage_inputs = load_semantics().stage_inputs
+ self.assertEqual(
+ stage_inputs["shaped"],
+ (
+ "latent_image",
+ "guider",
+ "positive",
+ "negative",
+ "pipe",
+ "basic_pipe",
+ "detailer_pipe",
+ "sampler",
+ "sigmas",
+ "noise",
+ "text_embeds",
+ "hyvid_embeds",
+ ),
+ )
+ self.assertEqual(
+ stage_inputs["with_model"],
+ ("conditioning", "neg_conditioning", "samples", "latent"),
+ )
+ # The default profile's own sampler-shape names stay a subset of the table's.
+ from utils.prompt_pair_resolver import _SAMPLER_LIKE_INPUTS
+
+ self.assertLessEqual(set(_SAMPLER_LIKE_INPUTS), set(stage_inputs["shaped"]))
+
+ def test_facts_section_mirrors_the_browser_tables(self):
+ facts = load_semantics().facts
+ self.assertEqual(
+ facts["configuration_outputs"],
+ {
+ "RandomNoise": {"kind": "noise", "fields": ("noise_seed",)},
+ "KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)},
+ "BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")},
+ # D8: Flux2Scheduler has no scheduler input; its type names it.
+ "Flux2Scheduler": {
+ "kind": "sigmas",
+ "fields": ("steps",),
+ "type_fields": ("scheduler",),
+ },
+ "CFGGuider": {"kind": "guider", "fields": ("cfg", "model")},
+ "BasicGuider": {"kind": "guider", "fields": ("model",)},
+ },
+ )
+ self.assertEqual(
+ facts["model_outputs"],
+ {
+ "CheckpointLoader": "ckpt_name",
+ "CheckpointLoaderSimple": "ckpt_name",
+ "unCLIPCheckpointLoader": "ckpt_name",
+ "UNETLoader": "unet_name",
+ },
+ )
+ self.assertEqual(
+ facts["model_passthroughs"],
+ (
+ "LoraLoader",
+ "LoraLoaderModelOnly",
+ "ModelSamplingFlux",
+ "ModelSamplingAuraFlow",
+ "ModelSamplingSD3",
+ # Task 2.7b (R9): model in, model out, verified in their sources.
+ "Lora Loader (LoraManager)",
+ "Power Lora Loader (rgthree)",
+ "ModelPatchTorchSettings",
+ "CFGNorm",
+ "PerturbedAttentionGuidance",
+ "CR Apply LoRA Stack",
+ ),
+ )
+ self.assertEqual(
+ facts["scalar_outputs"],
+ {
+ "PrimitiveInt": "number",
+ "PrimitiveFloat": "number",
+ "PrimitiveString": "string",
+ "PrimitiveStringMultiline": "string",
+ },
+ )
+
+ def test_table_is_loaded_lazily_and_once(self):
+ # Importing the module reads nothing; the inspection path asks for it.
+ self.assertFalse(hasattr(semantics, "SEMANTICS"))
+ semantics.get_semantics.cache_clear()
+ first = semantics.get_semantics()
+ self.assertIs(first, semantics.get_semantics())
+ self.assertEqual(first, load_semantics())
+
+ def test_loaded_table_is_frozen(self):
+ table = load_semantics()
+ self.assertIsInstance(table.samplers, MappingProxyType)
+ self.assertIsInstance(table.samplers["KSampler"], MappingProxyType)
+ with self.assertRaises(TypeError):
+ table.samplers["Other"] = {}
+ with self.assertRaises(TypeError):
+ table.samplers["KSampler"]["latent"] = "x"
+ with self.assertRaises(AttributeError):
+ table.samplers = {}
+ self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
+
+
+class ValidationTest(_TableCase):
+ def test_minimal_table_loads(self):
+ table = self.load_doc(_minimal())
+ self.assertEqual(table.encoders[("CLIPTextEncode", 0)]["inputs"], ("text",))
+ self.assertEqual(table.runtime, frozenset())
+
+ def test_unknown_strategy_rejected(self):
+ for category, mutate in (
+ ("samplers", lambda d: d["samplers"]["KSampler"].update(strategy="dual")),
+ # A known strategy of another category is still unknown here.
+ ("conditioning", lambda d: d["conditioning"][0].update(strategy="single")),
+ ("encoders", lambda d: d["encoders"][0].update(strategy="guess")),
+ ("guiders", lambda d: d["guiders"]["BasicGuider"].update(strategy=None)),
+ ):
+ with self.subTest(category=category):
+ doc = _minimal()
+ mutate(doc)
+ self.assertRejected(doc, "unknown strategy")
+
+ def test_strategies_are_the_closed_spec_set(self):
+ self.assertEqual(
+ dict(semantics.STRATEGIES),
+ {
+ "samplers": frozenset(
+ {"ksampler", "custom-advanced", "detailer-stage"}
+ ),
+ "guiders": frozenset({"cfg", "basic"}),
+ "conditioning": frozenset({"passthrough", "zeroed"}),
+ "selectors": frozenset({"boolean-select"}),
+ "composites": frozenset({"combine"}),
+ "encoders": frozenset(
+ {"single", "parts-equal", "pm-compose", "flag-parts"}
+ ),
+ "strings": frozenset(
+ {"literal", "strip-newlines", "join-multi", "concat"}
+ ),
+ "savers": frozenset({"comfy-counter", "vhs-counter"}),
+ },
+ )
+ with self.assertRaises(TypeError):
+ semantics.STRATEGIES["samplers"] = frozenset()
+
+ def test_duplicate_output_key_rejected(self):
+ doc = _minimal()
+ doc["encoders"].append(copy.deepcopy(doc["encoders"][0]))
+ self.assertRejected(doc, "duplicate")
+
+ doc = _minimal()
+ doc["conditioning"].append(
+ {
+ "class": "CLIPTextEncode",
+ "slot": 0,
+ "strategy": "passthrough",
+ "input": "conditioning",
+ }
+ )
+ self.assertRejected(doc, "duplicate")
+
+ doc = _minimal()
+ doc["runtime"] = ["KSampler"]
+ self.assertRejected(doc, "duplicate")
+
+ doc = _minimal()
+ doc["non_file_outputs"] = ["PreviewImage", "PreviewImage"]
+ self.assertRejected(doc, "duplicate")
+
+ # JSON objects with a repeated key would silently keep the last one.
+ text = json.dumps(_minimal())
+ entry = json.dumps(_minimal()["samplers"]["KSampler"])
+ text = text.replace(
+ '"samplers": {', '"samplers": {"KSampler": ' + entry + ", ", 1
+ )
+ with self.assertRaises(SemanticsError) as caught:
+ self.load_text(text)
+ self.assertIn("duplicate", str(caught.exception))
+
+ def test_slot_must_be_nonnegative_int(self):
+ for slot in (-1, 1.5, True, "0", None, 2**31):
+ with self.subTest(slot=slot):
+ doc = _minimal()
+ doc["encoders"][0]["slot"] = slot
+ self.assertRejected(doc, "slot")
+ doc = _minimal()
+ del doc["conditioning"][0]["slot"]
+ self.assertRejected(doc, "slot")
+
+ def test_missing_input_name_rejected(self):
+ cases = (
+ lambda d: d["samplers"]["KSampler"]["conditioning"].pop("negative"),
+ lambda d: d["samplers"]["KSampler"].pop("latent"),
+ lambda d: d["guiders"]["BasicGuider"].update(positive=""),
+ lambda d: d["conditioning"][0].pop("input"),
+ lambda d: d["encoders"][0].update(inputs=[]),
+ lambda d: d["encoders"][0].update(inputs=["text", "text"]),
+ lambda d: d["encoders"][0].update(inputs=[3]),
+ lambda d: d["encoders"][0].update(inputs="text"),
+ )
+ for index, mutate in enumerate(cases):
+ with self.subTest(case=index):
+ doc = _minimal()
+ mutate(doc)
+ self.assertRejected(doc, "input")
+
+ def test_entry_shape_is_strict(self):
+ cases = (
+ (lambda d: d.update(extra={}), "category"),
+ (lambda d: d.pop("guiders"), "category"),
+ (lambda d: d.update(schema=2), "schema"),
+ (lambda d: d["samplers"]["KSampler"].update(note="x"), "field"),
+ (lambda d: d["samplers"]["KSampler"].update(facts="flux"), "facts"),
+ (lambda d: d["guiders"]["BasicGuider"].update(negative="n"), "field"),
+ (lambda d: d["encoders"][0].update({"class": ""}), "class"),
+ (lambda d: d["facts"].update(extra={}), "facts"),
+ (
+ lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"),
+ "facts",
+ ),
+ (lambda d: d.pop("stage_inputs"), "category"),
+ (lambda d: d["stage_inputs"].update(extra=["x"]), "stage_inputs"),
+ (lambda d: d["stage_inputs"].pop("with_model"), "stage_inputs"),
+ (lambda d: d["stage_inputs"].update(shaped=[]), "input name"),
+ (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["facts"]["configuration_outputs"].update(
+ X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
+ ),
+ "input name",
+ ),
+ (
+ lambda d: d["facts"]["configuration_outputs"].update(
+ X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]}
+ ),
+ "type_fields",
+ ),
+ (
+ lambda d: d["facts"]["configuration_outputs"].update(
+ X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]}
+ ),
+ "field",
+ ),
+ )
+ for index, (mutate, fragment) in enumerate(cases):
+ with self.subTest(case=index):
+ doc = _minimal()
+ mutate(doc)
+ self.assertRejected(doc, fragment)
+
+ def test_strategy_specific_requirements(self):
+ doc = _minimal()
+ doc["samplers"]["X"] = {
+ "strategy": "custom-advanced",
+ "conditioning": {"positive": "positive", "negative": "negative"},
+ "latent": "latent_image",
+ "facts": "custom-advanced",
+ }
+ self.assertRejected(doc, "input")
+
+ doc = _minimal()
+ doc["samplers"]["D"] = {
+ "strategy": "detailer-stage",
+ "conditioning": {"positive": "positive", "negative": "negative"},
+ "facts": "ksampler",
+ }
+ self.assertRejected(doc, "input")
+
+ doc = _minimal()
+ doc["guiders"]["G"] = {"strategy": "cfg", "positive": "positive"}
+ self.assertRejected(doc, "input")
+
+ doc = _minimal()
+ doc["encoders"][0].update(strategy="parts-equal", inputs=["text_g"])
+ self.assertRejected(doc, "input")
+
+ doc = _minimal()
+ doc["savers"]["S"] = {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}.{ext}",
+ }
+ self.assertRejected(doc, "pattern")
+
+ doc = _minimal()
+ doc["selectors"]["easy ifElse"] = {
+ "strategy": "boolean-select",
+ "condition": "boolean",
+ "branches": {"true": "on_true"},
+ }
+ self.assertRejected(doc, "input")
+
+ doc = _minimal()
+ doc["strings"].append(
+ {
+ "class": "JoinStringMulti",
+ "slot": 0,
+ "strategy": "join-multi",
+ "inputs": ["string_1"],
+ "params": {"count": "inputcount"},
+ }
+ )
+ self.assertRejected(doc, "params")
+
+ def test_unreadable_table_fails_loudly(self):
+ with self.assertRaises(SemanticsError):
+ self.load_text("{not json")
+ with self.assertRaises(SemanticsError):
+ self.load_text("[]")
+ with self.assertRaises(SemanticsError):
+ self.load_text(
+ json.dumps(_minimal()).replace('"schema": 1', '"schema": NaN')
+ )
+ with tempfile.TemporaryDirectory() as root:
+ with self.assertRaises(SemanticsError):
+ load_semantics(Path(root) / "missing.json")
+
+ def test_oversized_table_is_rejected_before_parsing(self):
+ text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
+ with self.assertRaises(SemanticsError) as caught:
+ self.load_text(text)
+ self.assertIn("exceeds", str(caught.exception))
+
+ def test_deeply_nested_table_is_a_semantics_error(self):
+ # Past the nesting bound, and far past the interpreter's recursion limit.
+ for depth in (semantics.MAX_NESTING + 1, 100_000):
+ with self.subTest(depth=depth):
+ nested = "[" * depth + "]" * depth
+ text = json.dumps(_minimal()).replace(
+ '"selectors": {}', '"selectors": ' + nested
+ )
+ with self.assertRaises(SemanticsError) as caught:
+ self.load_text(text)
+ self.assertIn("nest", str(caught.exception))
+ self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
+ # Whichever interpreter limit the parser hits, it is a SemanticsError.
+ with unittest.mock.patch.object(
+ semantics.json, "loads", side_effect=RecursionError
+ ):
+ with self.assertRaises(SemanticsError) as caught:
+ self.load_doc(_minimal())
+ self.assertIn("nests deeper", str(caught.exception))
+
+
+def _depth(value, level=1):
+ children = value.values() if isinstance(value, dict) else value
+ if not isinstance(value, (dict, list)):
+ return level - 1
+ return max([level] + [_depth(item, level + 1) for item in children])
+
+
+class SamplerOptionsTest(_TableCase):
+ """Task 2.7b: optional sampler lists are input names, or the table fails."""
+
+ def test_upstream_verified_ports_are_listed_with_their_source_inputs(self):
+ # Not installed here: read from the pinned upstream commits the saved
+ # workflows name (the report lists repository, commit, file and line).
+ table = load_semantics()
+ pair = {"positive": "positive", "negative": "negative"}
+ for name, latent, prompt_overrides, fact_overrides in (
+ (
+ "ClownsharKSampler_Beta",
+ "latent_image",
+ ("options",),
+ ("options", "sigmas"),
+ ),
+ ("KSampler (Efficient)", "latent_image", ("script",), ("script",)),
+ ("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)),
+ ):
+ self.assertEqual(
+ table.samplers[name],
+ {
+ "strategy": "ksampler",
+ "conditioning": pair,
+ "latent": latent,
+ "facts": "ksampler",
+ "prompt_overrides": prompt_overrides,
+ "fact_overrides": fact_overrides,
+ },
+ name,
+ )
+ for (name, slot), source in {
+ ("SeedVarianceEnhancer", 0): "conditioning",
+ ("ApplyInstantIDAdvanced", 1): "positive",
+ ("ApplyInstantIDAdvanced", 2): "negative",
+ }.items():
+ self.assertEqual(
+ table.conditioning[(name, slot)],
+ {"strategy": "passthrough", "input": source},
+ )
+ self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning)
+ self.assertEqual(
+ table.selectors["Switch conditioning [Crystools]"],
+ table.selectors["easy ifElse"],
+ )
+ self.assertEqual(
+ table.encoders[("smZ CLIPTextEncode", 0)],
+ {
+ "strategy": "flag-parts",
+ "inputs": ("text", "text_g", "text_l"),
+ "params": {"flag": "with_SDXL"},
+ },
+ )
+ for name, source in (
+ ("ttN text", "text"),
+ ("CR Text", "text"),
+ ("CR Prompt Text", "prompt"),
+ ("TextBoxMira", "text"),
+ ):
+ self.assertEqual(
+ table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)}
+ )
+ self.assertNotIn((name, 1), table.strings)
+
+ def test_flag_parts_needs_a_flag_and_two_inputs(self):
+ doc = _minimal()
+ entry = {
+ "class": "smZ CLIPTextEncode",
+ "slot": 0,
+ "strategy": "flag-parts",
+ "inputs": ["text", "text_g"],
+ "params": {"flag": "with_SDXL"},
+ }
+ doc["encoders"].append(entry)
+ self.assertEqual(
+ self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"],
+ {"flag": "with_SDXL"},
+ )
+ for change in (
+ {"params": {}},
+ {"params": {"flag": "x", "other": "y"}},
+ {"params": {"flag": ""}},
+ {"params": None},
+ {"inputs": ["text"]},
+ {"compose": True},
+ ):
+ with self.subTest(change=change):
+ broken = _minimal()
+ broken["encoders"].append({**entry, **change})
+ with self.assertRaises(SemanticsError):
+ self.load_doc(broken)
+ broken = _minimal()
+ broken["encoders"].append(
+ {key: item for key, item in entry.items() if key != "params"}
+ )
+ with self.assertRaises(SemanticsError):
+ self.load_doc(broken)
+
+ def test_prompt_overrides_are_input_names(self):
+ doc = _minimal()
+ doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
+ sampler = self.load_doc(doc).samplers["KSampler"]
+ self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
+ for value in ([], "wildcard", [""], ["a", "a"], [1]):
+ with self.subTest(value=value):
+ doc["samplers"]["KSampler"]["prompt_overrides"] = value
+ with self.assertRaises(SemanticsError):
+ self.load_doc(doc)
+ doc["samplers"]["KSampler"].pop("prompt_overrides")
+ doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"]
+ sampler = self.load_doc(doc).samplers["KSampler"]
+ self.assertEqual(sampler["fact_overrides"], ("script", "sigmas"))
+ doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"]
+ with self.assertRaises(SemanticsError):
+ self.load_doc(doc)
+ doc["samplers"]["KSampler"].pop("fact_overrides")
+ doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
+ self.assertRejected(doc, "unknown field")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py
index 3ab99435..cda53ada 100644
--- a/tests/test_prompt_pair_resolver.py
+++ b/tests/test_prompt_pair_resolver.py
@@ -1,9 +1,17 @@
"""Conservative prompt-pair resolution contracts, independent of ComfyUI."""
+import collections
+import json
import random
+import subprocess # nosec B404 - fixed interpreter command, no shell
+import sys
+import tempfile
import unittest
from dataclasses import replace
+from pathlib import Path
+from unittest import mock
+from utils import inspection_semantics
from utils import prompt_pair_resolver as resolver
from utils.prompt_pair_resolver import OutputAdapter, resolve_prompt_pair
@@ -906,12 +914,15 @@ class TestResolverProfiles(unittest.TestCase):
self.assertEqual(result["state"], "resolved")
def test_prompt_graph_without_supported_sampler_is_unresolved_not_absent(self):
- metadata = {"prompt": parity.build("f17")["prompt"]}
+ # f17 with its sampler renamed to a class the inspector does not know.
+ source = parity.build("f17")["prompt"]
+ source["13"]["class_type"] = "FutureSampler"
+ metadata = {"prompt": source}
result = resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "no-sampler")
)
- self.assertIn("SamplerCustomAdvanced", result["reason"])
+ self.assertIn("FutureSampler", result["reason"])
self.assertIsNone(result["positive"])
default = resolve_prompt_pair(metadata)
self.assertEqual(default["state"], "absent")
@@ -1043,5 +1054,2272 @@ class TestResolverProfiles(unittest.TestCase):
self.assertEqual(result["reason"], "Resolution visit limit reached")
+def _guided(guider: dict = None) -> dict:
+ """``f07b`` (SamplerCustomAdvanced + CFGGuider), optionally another guider."""
+ source = parity.build("f07b")["prompt"]
+ if guider is not None:
+ source["21"] = guider
+ return source
+
+
+def _basic_guided() -> dict:
+ """``f17`` with its BasicGuider reading the CLIPTextEncode directly."""
+ source = parity.build("f17")["prompt"]
+ source["22"]["inputs"]["conditioning"] = ["6", 0]
+ del source["26"]
+ return source
+
+
+def _inspect(source: dict, **options) -> dict:
+ return resolve_prompt_pair(
+ {"prompt": source}, profile=resolver.INSPECTION_PROFILE, **options
+ )
+
+
+class TestGuiderSamplers(unittest.TestCase):
+ """SamplerCustomAdvanced follows its guider through the semantics table."""
+
+ def test_sampler_custom_advanced_with_cfg_guider_resolves(self):
+ result = _inspect(_guided())
+ self.assertEqual(result["state"], "resolved")
+ self.assertIsNone(result["reason_code"])
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertEqual(result["positive"]["text"], parity.POS)
+ self.assertEqual(result["negative"]["text"], parity.NEG)
+ provenance = result["positive"]["provenance"]
+ self.assertEqual(
+ (provenance["sampler_id"], provenance["guider_id"]), ("3", "21")
+ )
+ self.assertEqual(provenance["node_id"], "6")
+ self.assertEqual(provenance["path"], [{"node_id": "6", "output_slot": 0}])
+ self.assertEqual(result["negative"]["provenance"]["node_id"], "7")
+ # The saver anchor reaches the same sampler.
+ anchored = _inspect(_guided(), output_node_id="9")
+ self.assertEqual(anchored, result)
+
+ def test_basic_guider_negative_is_absent_by_design(self):
+ from utils.prompt_inspector import inspect_prompt_evidence
+
+ result = _inspect(_basic_guided())
+ self.assertEqual(result["state"], "absent")
+ self.assertEqual(result["reason"], "Guider has no negative input")
+ self.assertIsNone(result["reason_code"])
+ self.assertEqual(result["positive"]["text"], parity.POS)
+ self.assertIsNone(result["negative"])
+ evidence = inspect_prompt_evidence({"prompt": _basic_guided()})
+ self.assertEqual(evidence["prompts"]["positive"]["status"], "resolved")
+ self.assertEqual(evidence["prompts"]["positive"]["text"], parity.POS)
+ self.assertEqual(evidence["prompts"]["negative"]["status"], "absent")
+ self.assertEqual(
+ evidence["resolution"]["reason"], "Guider has no negative input"
+ )
+
+ def test_basic_guider_empty_positive_is_not_absent(self):
+ source = _basic_guided()
+ source["6"]["inputs"]["text"] = " "
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], "Positive prompt is empty")
+
+ def test_dual_cfg_guider_is_unsupported_node(self):
+ dual = {
+ "class_type": "DualCFGGuider",
+ "inputs": {
+ "model": ["4", 0],
+ "cond1": ["6", 0],
+ "cond2": ["6", 0],
+ "negative": ["7", 0],
+ "cfg_conds": 8.0,
+ "cfg_cond2_negative": 8.0,
+ },
+ }
+ result = _inspect(_guided(dual))
+ self.assertEqual(
+ (result["state"], result["reason_code"], result["node"]["class_type"]),
+ ("unresolved", "unsupported-node", "DualCFGGuider"),
+ )
+ self.assertEqual(result["node"], {"id": "21", "class_type": "DualCFGGuider"})
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertIsNone(result["positive"])
+
+ def test_unsupported_node_names_first_blocking_node(self):
+ source = _guided()
+ source["30"] = {
+ "class_type": "FutureConditioner",
+ "inputs": {"conditioning": ["6", 0]},
+ }
+ source["31"] = {
+ "class_type": "OtherConditioner",
+ "inputs": {"conditioning": ["7", 0]},
+ }
+ source["21"]["inputs"]["positive"] = ["30", 0]
+ source["21"]["inputs"]["negative"] = ["31", 0]
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "30", "class_type": "FutureConditioner"}
+ )
+ # With the positive fixed, the negative's node is the first blocker.
+ source["21"]["inputs"]["positive"] = ["6", 0]
+ result = _inspect(source)
+ self.assertEqual(result["node"], {"id": "31", "class_type": "OtherConditioner"})
+ self.assertEqual(result["positive"]["text"], parity.POS)
+
+ def test_default_profile_ignores_semantics(self):
+ for source in (_guided(), _basic_guided()):
+ result = resolve_prompt_pair({"prompt": source})
+ self.assertIn(result["state"], ("unresolved", "absent"))
+ self.assertEqual(result["reason"], "No supported sampler pair found")
+ self.assertIsNone(result["reason_code"])
+ self.assertIsNone(result["positive"])
+ # Without semantics the inspection bounds alone do not follow guiders.
+ plain = replace(resolver.INSPECTION_PROFILE, semantics=False)
+ result = resolve_prompt_pair({"prompt": _guided()}, profile=plain)
+ self.assertEqual(result["reason_code"], "no-sampler")
+
+ def test_guider_reached_through_a_reroute(self):
+ source = _guided()
+ source["40"] = {"class_type": "Reroute", "inputs": {"value": ["21", 0]}}
+ source["3"]["inputs"]["guider"] = ["40", 0]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["positive"]["provenance"]["guider_id"], "21")
+
+ def test_malformed_guider_links_and_slots_never_guess(self):
+ cases = {
+ "missing": None,
+ "literal": "CFGGuider",
+ "dangling": ["99", 0],
+ "slot": ["21", 1],
+ }
+ for label, value in cases.items():
+ with self.subTest(label=label):
+ source = _guided()
+ if value is None:
+ del source["3"]["inputs"]["guider"]
+ else:
+ source["3"]["inputs"]["guider"] = value
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertIsNone(result["positive"])
+ expected = "unsupported-node" if label == "slot" else "malformed"
+ self.assertEqual(result["reason_code"], expected)
+ source = _guided()
+ source["21"]["inputs"]["model"] = ["21", 0]
+ source["40"] = {"class_type": "Reroute", "inputs": {"value": ["40", 0]}}
+ source["3"]["inputs"]["guider"] = ["40", 0]
+ self.assertEqual(_inspect(source)["reason"], "Cyclic guider ancestry")
+ source["40"]["inputs"] = {"value": ["21", 0], "input": ["21", 0]}
+ result = _inspect(source)
+ self.assertEqual(result["reason"], "Reroute input is not uniquely identifiable")
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_guider_hops_draw_from_the_work_budgets(self):
+ self.assertEqual(_inspect(_guided(), max_visits=2)["reason_code"], "work-limit")
+ self.assertEqual(_inspect(_guided())["state"], "resolved")
+ source = _guided()
+ previous = "21"
+ for index in range(70):
+ source[f"r{index}"] = {
+ "class_type": "Reroute",
+ "inputs": {"value": [previous, 0]},
+ }
+ previous = f"r{index}"
+ source["3"]["inputs"]["guider"] = [previous, 0]
+ self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
+
+ def test_custom_advanced_upstream_of_a_sampler_is_multistage(self):
+ source = _guided()
+ source["41"] = {
+ "class_type": "KSampler",
+ "inputs": {
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "latent_image": ["3", 0],
+ },
+ }
+ source["8"]["inputs"]["samples"] = ["41", 0]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["sampler_id"], "41")
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], "Multistage sampler ancestry is ambiguous")
+ unanchored = _inspect(source)
+ self.assertEqual(unanchored["state"], "ambiguous")
+ self.assertEqual(
+ sorted(item["sampler_id"] for item in unanchored["candidates"]),
+ ["3", "41"],
+ )
+
+
+WOLF = "a grey wolf in fog"
+
+
+def _encoded(text: str) -> dict:
+ return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
+
+
+def _with_positive(node: dict, extra: dict = None) -> dict:
+ """``f02``'s KSampler graph whose positive input reads ``node`` (as node 6)."""
+ source = parity.build("f02")["prompt"]
+ source["6"] = node
+ source.update(extra or {})
+ return source
+
+
+def _if_else(condition, on_true, on_false) -> dict:
+ return {
+ "class_type": "easy ifElse",
+ "inputs": {"boolean": condition, "on_true": on_true, "on_false": on_false},
+ }
+
+
+def _image_exists() -> dict:
+ return {"class_type": "ImageExists", "inputs": {"image": ["13", 0]}}
+
+
+def _sides(result: dict) -> tuple:
+ return tuple(
+ result[role]["text"] if isinstance(result[role], dict) else None
+ for role in ("positive", "negative")
+ )
+
+
+class TestConditioningSemantics(unittest.TestCase):
+ """Pass-throughs, zeroed conditioning, selectors and composites (inspection)."""
+
+ def test_flux_guidance_passthrough_resolves(self):
+ result = _inspect(parity.build("f06c")["prompt"])
+ self.assertEqual(result["state"], "resolved")
+ self.assertIsNone(result["reason_code"])
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ provenance = result["positive"]["provenance"]
+ self.assertEqual(
+ provenance["path"],
+ [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
+ )
+ self.assertEqual(provenance["text_source"], "graph-inferred")
+ # f17: BasicGuider <- FluxGuidance <- CLIPTextEncode, negative absent.
+ guided = _inspect(parity.build("f17")["prompt"])
+ self.assertEqual(guided["state"], "absent")
+ self.assertEqual(guided["reason"], "Guider has no negative input")
+ self.assertEqual(guided["positive"]["text"], parity.POS)
+
+ def test_controlnet_apply_advanced_maps_slots(self):
+ source = parity.build("f06e")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["negative"]["provenance"]["path"],
+ [{"node_id": "7", "output_slot": 1}, {"node_id": "11", "output_slot": 0}],
+ )
+ # Slot 0 of the negative's node is its positive output, never the negative.
+ source["3"]["inputs"]["negative"] = ["7", 0]
+ self.assertEqual(_sides(_inspect(source)), (parity.POS, parity.POS))
+ # An output slot the table does not list is unsupported, named.
+ source["3"]["inputs"]["negative"] = ["7", 2]
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "7", "class_type": "ControlNetApplyAdvanced"}
+ )
+
+ def test_reference_latent_and_set_area_pass_through(self):
+ chain = [
+ ("ReferenceLatent", {"latent": ["5", 0]}),
+ ("ConditioningSetArea", {"width": 64, "height": 64, "x": 0, "y": 0}),
+ ("ConditioningSetAreaPercentage", {"width": 1.0, "height": 1.0}),
+ ("ConditioningSetAreaStrength", {"strength": 1.0}),
+ ("ConditioningSetMask", {"mask": ["40", 0], "strength": 1.0}),
+ ("ConditioningSetTimestepRange", {"start": 0.0, "end": 1.0}),
+ ("ControlNetApply", {"control_net": ["41", 0], "strength": 1.0}),
+ ]
+ extra, previous = {"10": _encoded(parity.POS)}, "10"
+ for index, (name, inputs) in enumerate(chain):
+ node_id = f"2{index}"
+ extra[node_id] = {
+ "class_type": name,
+ "inputs": {**inputs, "conditioning": [previous, 0]},
+ }
+ previous = node_id
+ source = _with_positive(extra.pop(previous), extra)
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ path = result["positive"]["provenance"]["path"]
+ self.assertEqual(
+ [entry["node_id"] for entry in path],
+ ["6", "25", "24", "23", "22", "21", "20", "10"],
+ )
+
+ def test_inpaint_model_conditioning_slots(self):
+ inpaint = {
+ "class_type": "InpaintModelConditioning",
+ "inputs": {
+ "positive": ["10", 0],
+ "negative": ["11", 0],
+ "vae": ["4", 2],
+ "pixels": ["13", 0],
+ "mask": ["14", 0],
+ "noise_mask": True,
+ },
+ }
+ extra = {"10": _encoded(parity.POS), "11": _encoded(parity.NEG)}
+ source = _with_positive(inpaint, extra)
+ source["3"]["inputs"]["negative"] = ["6", 1]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["positive"]["provenance"]["output_slot"], 0)
+ self.assertEqual(result["negative"]["provenance"]["output_slot"], 1)
+
+ def test_zeroed_negative_is_explicit_empty_with_distinct_source(self):
+ from utils.prompt_inspector import inspect_prompt_evidence
+
+ source = parity.build("f23")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ negative = result["negative"]
+ self.assertEqual((negative["text"], negative["storage_text"]), ("", ""))
+ self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning")
+ self.assertEqual(
+ negative["provenance"]["path"], [{"node_id": "7", "output_slot": 0}]
+ )
+ self.assertEqual(result["positive"]["text"], parity.POS)
+ self.assertEqual(
+ result["positive"]["provenance"]["text_source"], "graph-inferred"
+ )
+ evidence = inspect_prompt_evidence({"prompt": source})["prompts"]
+ self.assertEqual(
+ evidence["negative"],
+ {"status": "resolved", "text": "", "source": "zeroed-conditioning"},
+ )
+ # The source survives pass-throughs and reroutes in front of it.
+ source["30"] = {
+ "class_type": "FluxGuidance",
+ "inputs": {"guidance": 3.5, "conditioning": ["7", 0]},
+ }
+ source["31"] = {"class_type": "Reroute", "inputs": {"value": ["30", 0]}}
+ source["3"]["inputs"]["negative"] = ["31", 0]
+ negative = _inspect(source)["negative"]
+ self.assertEqual(negative["text"], "")
+ self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning")
+ # A zeroed positive is an empty positive, never a resolved prompt.
+ source["3"]["inputs"]["positive"] = ["7", 0]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason"], "Positive prompt is empty")
+
+ def test_if_else_literal_boolean_selects_branch(self):
+ source = parity.build("f20")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ [entry["node_id"] for entry in result["positive"]["provenance"]["path"]],
+ ["6", "10"],
+ )
+ source["6"]["inputs"]["boolean"] = False
+ self.assertEqual(_inspect(source)["positive"]["text"], WOLF)
+ # The branch that is not taken is never traced.
+ source["6"]["inputs"]["on_true"] = ["99", 0]
+ self.assertEqual(_inspect(source)["state"], "resolved")
+ for literal in (1, "true", None):
+ with self.subTest(literal=literal):
+ source["6"]["inputs"]["boolean"] = literal
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "malformed")
+ self.assertIsNone(result["positive"])
+
+ def test_if_else_linked_boolean_converging_branches_resolve(self):
+ guidance = {
+ "class_type": "FluxGuidance",
+ "inputs": {"guidance": 3.5, "conditioning": ["10", 0]},
+ }
+ extra = {"10": _encoded(parity.POS), "11": guidance, "12": _image_exists()}
+ source = _with_positive(_if_else(["12", 1], ["11", 0], ["10", 0]), extra)
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertEqual(result["candidates"], [])
+ # Two encoders with the same text converge as well.
+ source["11"] = _encoded(parity.POS)
+ self.assertEqual(_inspect(source)["state"], "resolved")
+
+ def test_if_else_linked_boolean_diverging_branches_are_ambiguous_with_candidates(
+ self,
+ ):
+ extra = {
+ "10": _encoded(parity.POS),
+ "11": _encoded(WOLF),
+ "12": _image_exists(),
+ }
+ source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
+ result = _inspect(source)
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertIsNone(result["positive"])
+ self.assertIsNone(result["negative"])
+ candidates = result["candidates"]
+ self.assertEqual([item["state"] for item in candidates], ["resolved"] * 2)
+ self.assertEqual(
+ [_sides(item) for item in candidates],
+ [(parity.POS, parity.NEG), (WOLF, parity.NEG)],
+ )
+ self.assertEqual(
+ [
+ item["positive"]["provenance"]["path"][-1]["node_id"]
+ for item in candidates
+ ],
+ ["10", "11"],
+ )
+ # One selector read by both roles is one runtime choice, not four pairs.
+ source["3"]["inputs"]["negative"] = ["6", 0]
+ candidates = _inspect(source)["candidates"]
+ self.assertEqual(
+ [_sides(item) for item in candidates],
+ [(parity.POS, parity.POS), (WOLF, WOLF)],
+ )
+ # Independent selectors on each role multiply.
+ source["7"] = _if_else(["16", 1], ["10", 0], ["14", 0])
+ source["14"] = _encoded(parity.NEG)
+ source["16"] = _image_exists()
+ source["3"]["inputs"]["negative"] = ["7", 0]
+ candidates = _inspect(source)["candidates"]
+ self.assertEqual(len(candidates), 4)
+ self.assertEqual(len({_sides(item) for item in candidates}), 4)
+
+ def test_selectors_sharing_one_condition_choose_together(self):
+ extra = {
+ "10": _encoded("pos A"),
+ "11": _encoded("pos B"),
+ "14": _encoded("neg A"),
+ "15": _encoded("neg B"),
+ "12": _image_exists(),
+ "16": _image_exists(),
+ }
+ source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
+ source["7"] = _if_else(["12", 1], ["14", 0], ["15", 0])
+ result = _inspect(source)
+ self.assertEqual(result["state"], "ambiguous")
+ # One runtime value drives both selectors: never "pos A" with "neg B".
+ self.assertEqual(
+ [_sides(item) for item in result["candidates"]],
+ [("pos A", "neg A"), ("pos B", "neg B")],
+ )
+ # Another output slot of the same node is another value.
+ source["7"]["inputs"]["boolean"] = ["12", 0]
+ self.assertEqual(len(_inspect(source)["candidates"]), 4)
+ # Two independent conditions give the full cross product.
+ source["7"]["inputs"]["boolean"] = ["16", 1]
+ candidates = _inspect(source)["candidates"]
+ self.assertEqual(
+ sorted(_sides(item) for item in candidates),
+ [
+ ("pos A", "neg A"),
+ ("pos A", "neg B"),
+ ("pos B", "neg A"),
+ ("pos B", "neg B"),
+ ],
+ )
+
+ def test_diverging_branch_that_cannot_be_traced_is_never_guessed(self):
+ extra = {
+ "10": _encoded(parity.POS),
+ "11": {"class_type": "FutureEncoder", "inputs": {"text": WOLF}},
+ "12": _image_exists(),
+ }
+ source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["node"], {"id": "11", "class_type": "FutureEncoder"})
+ self.assertIsNone(result["positive"])
+
+ def test_selector_branches_are_bounded(self):
+ extra, previous = {"12": _image_exists()}, None
+ for index in range(6):
+ extra[f"a{index}"] = _encoded(f"left {index}")
+ extra[f"b{index}"] = _encoded(f"right {index}")
+ # Each selector reads its own condition: independent choices.
+ extra[f"e{index}"] = _image_exists()
+ extra[f"s{index}"] = _if_else(
+ [f"e{index}", 1], [f"a{index}", 0], [f"b{index}", 0]
+ )
+ extra[f"c{index}"] = {
+ "class_type": "ConditioningCombine",
+ "inputs": {
+ "conditioning_1": [f"s{index}", 0],
+ "conditioning_2": [previous or f"s{index}", 0],
+ },
+ }
+ previous = f"c{index}"
+ source = _with_positive(
+ {"class_type": "Reroute", "inputs": {"value": [previous, 0]}}, extra
+ )
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "work-limit")
+ self.assertEqual(result["candidates"], [])
+ # Branch tracing draws from the per-candidate visit allowance too.
+ small = _with_positive(
+ _if_else(["12", 1], ["10", 0], ["11", 0]),
+ {"10": _encoded(parity.POS), "11": _encoded(WOLF), "12": _image_exists()},
+ )
+ self.assertEqual(_inspect(small, max_visits=3)["reason_code"], "work-limit")
+
+ def test_conditioning_combine_distinct_texts_is_composite(self):
+ source = parity.build("f06d")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(
+ (result["state"], result["reason_code"]), ("unresolved", "composite")
+ )
+ self.assertEqual(
+ result["node"], {"id": "6", "class_type": "ConditioningCombine"}
+ )
+ self.assertIsNone(result["positive"])
+ for name, inputs in (
+ ("ConditioningConcat", ("conditioning_to", "conditioning_from")),
+ ("ConditioningAverage", ("conditioning_to", "conditioning_from")),
+ ):
+ with self.subTest(name=name):
+ source["6"] = {
+ "class_type": name,
+ "inputs": {inputs[0]: ["10", 0], inputs[1]: ["11", 0]},
+ }
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "composite")
+ self.assertEqual(result["node"], {"id": "6", "class_type": name})
+ # A zeroed part is a different text too.
+ source["12"] = {
+ "class_type": "ConditioningZeroOut",
+ "inputs": {"conditioning": ["10", 0]},
+ }
+ source["6"]["inputs"]["conditioning_from"] = ["12", 0]
+ self.assertEqual(_inspect(source)["reason_code"], "composite")
+
+ def test_conditioning_combine_identical_texts_resolve(self):
+ source = parity.build("f06d")["prompt"]
+ source["11"]["inputs"]["text"] = parity.POS
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ source["6"]["inputs"]["conditioning_2"] = ["10", 0]
+ self.assertEqual(_inspect(source)["state"], "resolved")
+ # A missing part is malformed, never "the other part".
+ del source["6"]["inputs"]["conditioning_2"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_selector_sources_and_string_branches(self):
+ # Converging branches with different text sources name no single source.
+ source = parity.build("f23")["prompt"]
+ source["12"] = _image_exists()
+ source["14"] = _encoded("")
+ source["15"] = _if_else(["12", 1], ["7", 0], ["14", 0])
+ source["3"]["inputs"]["negative"] = ["15", 0]
+ negative = _inspect(source)["negative"]
+ self.assertEqual(negative["text"], "")
+ self.assertEqual(negative["provenance"]["text_source"], "graph-inferred")
+ source["3"]["inputs"]["negative"] = ["7", 0]
+ source["15"]["inputs"]["on_false"] = ["7", 0]
+ source["3"]["inputs"]["negative"] = ["15", 0]
+ negative = _inspect(source)["negative"]
+ self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning")
+ # A selector also forwards string inputs, including literal text.
+ source = parity.build("f02")["prompt"]
+ source["12"] = _image_exists()
+ source["20"] = _if_else(["12", 1], "same words", "same words")
+ source["6"]["inputs"]["text"] = ["20", 0]
+ result = _inspect(source)
+ self.assertEqual(result["positive"]["text"], "same words")
+ source["20"]["inputs"]["on_false"] = "other words"
+ result = _inspect(source)
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(
+ [item["positive"]["text"] for item in result["candidates"]],
+ ["same words", "other words"],
+ )
+
+ def test_default_profile_keeps_conditioning_nodes_unsupported(self):
+ for name in ("f06c", "f06d", "f06e", "f20", "f23"):
+ with self.subTest(fixture=name):
+ result = resolve_prompt_pair({"prompt": parity.build(name)["prompt"]})
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(
+ result["reason"], "Unsupported node semantics or output slot"
+ )
+ self.assertIsNone(result["reason_code"])
+
+
+def _primitive(value: str) -> dict:
+ return {"class_type": "PrimitiveString", "inputs": {"value": value}}
+
+
+def _text_encoded(link: list) -> dict:
+ """A CLIPTextEncode (node 6 in ``_with_positive``) reading a linked string."""
+ return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
+
+
+def _string_positive(node: dict, extra: dict = None) -> dict:
+ """``f02`` whose positive encoder reads string node 10 (``node``)."""
+ return _with_positive(_text_encoded(["10", 0]), {"10": node, **(extra or {})})
+
+
+def _multiline(text, flag) -> dict:
+ return {
+ "class_type": "StringConstantMultiline",
+ "inputs": {"string": text, "strip_newlines": flag},
+ }
+
+
+def _join(count, delimiter, return_list=False, **strings) -> dict:
+ return {
+ "class_type": "JoinStringMulti",
+ "inputs": {
+ "inputcount": count,
+ "delimiter": delimiter,
+ "return_list": return_list,
+ **strings,
+ },
+ }
+
+
+def _sd3(clip_l: str, clip_g: str, t5xxl: str) -> dict:
+ return {
+ "class_type": "CLIPTextEncodeSD3",
+ "inputs": {
+ "clip": ["4", 1],
+ "clip_l": clip_l,
+ "clip_g": clip_g,
+ "t5xxl": t5xxl,
+ "empty_padding": "none",
+ },
+ }
+
+
+def _positive(result: dict) -> tuple:
+ return result["state"], _role_text_of(result, "positive"), result["reason_code"]
+
+
+def _role_text_of(result: dict, role: str):
+ side = result[role]
+ return side["text"] if isinstance(side, dict) else None
+
+
+F01B_POSITIVE = f"{parity.POS}, Scene 0: unique shot description soft winter light,"
+
+
+class TestEncoderAndStringSemantics(unittest.TestCase):
+ """Text encoders, string sources and runtime nodes (inspection, Task 2.4)."""
+
+ def test_sdxl_equal_parts_resolve(self):
+ result = _inspect(parity.build("f06a")["prompt"])
+ self.assertEqual(result["state"], "resolved")
+ self.assertIsNone(result["reason_code"])
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ provenance = result["positive"]["provenance"]
+ self.assertEqual(provenance["path"], [{"node_id": "6", "output_slot": 0}])
+ self.assertEqual(provenance["text_source"], "graph-inferred")
+ self.assertEqual(result["positive"]["storage_text"], parity.POS)
+ # Linked parts are followed; equal texts still resolve, path deduplicated.
+ source = parity.build("f06a")["prompt"]
+ source["6"]["inputs"]["text_g"] = ["10", 0]
+ source["6"]["inputs"]["text_l"] = ["10", 0]
+ source["10"] = _primitive(parity.POS)
+ result = _inspect(source)
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
+ )
+
+ def test_sdxl_distinct_parts_are_composite(self):
+ source = parity.build("f06a")["prompt"]
+ source["6"]["inputs"]["text_l"] = "a second style"
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "composite")
+ self.assertEqual(result["reason"], "Encoder parts have different texts")
+ self.assertEqual(
+ result["node"], {"id": "6", "class_type": "CLIPTextEncodeSDXL"}
+ )
+ self.assertIsNone(result["positive"])
+ # Whitespace is text: emptiness is exact, never trimmed into agreement.
+ source["6"]["inputs"]["text_l"] = " "
+ self.assertEqual(_inspect(source)["reason_code"], "composite")
+ source["6"]["inputs"]["text_l"] = parity.POS + " "
+ self.assertEqual(_inspect(source)["reason_code"], "composite")
+
+ def test_sdxl_single_nonempty_part_resolves(self):
+ for empty in ("text_l", "text_g"):
+ with self.subTest(empty=empty):
+ source = parity.build("f06a")["prompt"]
+ source["6"]["inputs"][empty] = ""
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["positive"]["storage_text"], parity.POS)
+ # A linked empty part counts as empty; both paths are recorded.
+ source = parity.build("f06a")["prompt"]
+ source["6"]["inputs"]["text_l"] = ["10", 0]
+ source["10"] = _primitive("")
+ result = _inspect(source)
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
+ )
+ # All parts empty: an empty positive, never a resolved prompt.
+ source["6"]["inputs"]["text_g"] = ""
+ self.assertEqual(_inspect(source)["reason"], "Positive prompt is empty")
+ # A missing part is malformed, never read as empty.
+ source = parity.build("f06a")["prompt"]
+ del source["6"]["inputs"]["text_l"]
+ self.assertEqual(_inspect(source)["reason_code"], "malformed")
+
+ def test_flux_and_sd3_encoders(self):
+ result = _inspect(parity.build("f06b")["prompt"])
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ source = parity.build("f06b")["prompt"]
+ source["6"]["inputs"]["t5xxl"] = "a longer natural language description"
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "composite")
+ self.assertEqual(result["node"]["class_type"], "CLIPTextEncodeFlux")
+ cases = [
+ ((parity.POS, parity.POS, parity.POS), ("resolved", parity.POS, None)),
+ (("", "", parity.POS), ("resolved", parity.POS, None)),
+ (("", parity.POS, ""), ("resolved", parity.POS, None)),
+ ((parity.POS, "", WOLF), ("unresolved", None, "composite")),
+ ((parity.POS, parity.POS, WOLF), ("unresolved", None, "composite")),
+ # Spec 7.2: only "all equal" or "exactly one non-empty" resolve.
+ ((parity.POS, parity.POS, ""), ("unresolved", None, "composite")),
+ ]
+ for parts, expected in cases:
+ with self.subTest(parts=parts):
+ self.assertEqual(
+ _positive(_inspect(_with_positive(_sd3(*parts)))), expected
+ )
+
+ def test_qwen_edit_plus_prompt(self):
+ extra = {
+ "13": {"class_type": "LoadImage", "inputs": {"image": "reference.png"}}
+ }
+ for name in ("TextEncodeQwenImageEditPlus", "TextEncodeQwenImageEdit"):
+ with self.subTest(node=name):
+ inputs = {"clip": ["4", 1], "prompt": parity.POS, "vae": ["4", 2]}
+ images = ("image1", "image2") if name.endswith("Plus") else ("image",)
+ inputs.update({image: ["13", 0] for image in images})
+ node = {"class_type": name, "inputs": inputs}
+ result = _inspect(_with_positive(node, extra))
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 0}],
+ )
+
+ def test_qwen_image21_two_outputs(self):
+ source = parity.build("f24")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(
+ result["negative"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 1}],
+ )
+ self.assertEqual(result["negative"]["storage_text"], parity.NEG)
+ # Slot 2 is the latent output, never a prompt.
+ source["3"]["inputs"]["negative"] = ["6", 2]
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "6", "class_type": "TextEncodeQwenImage21"}
+ )
+
+ def test_kiko_text_encode_plain_text(self):
+ node = {"class_type": "KikoTextEncode", "inputs": {"clip": ["4", 1]}}
+ node["inputs"]["text"] = parity.POS
+ result = _inspect(_with_positive(node))
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["positive"]["storage_text"], parity.POS)
+ # No composition: a linked text arrives unchanged.
+ node["inputs"]["text"] = ["10", 0]
+ extra = {"10": {"class_type": "PrimitiveStringMultiline"}}
+ extra["10"]["inputs"] = {"value": " " + parity.POS + "\n"}
+ result = _inspect(_with_positive(node, extra))
+ self.assertEqual(result["positive"]["text"], " " + parity.POS + "\n")
+
+ def test_string_constant_multiline_strip_newlines_exact(self):
+ result = _inspect(_string_positive(_multiline("a\n b \n", True)))
+ self.assertEqual(_positive(result), ("resolved", "a b", None))
+ self.assertEqual(result["positive"]["storage_text"], "a b")
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
+ )
+ result = _inspect(_string_positive(_multiline("a\n b \n", False)))
+ self.assertEqual(_positive(result), ("resolved", "a\n b \n", None))
+ # Only "\n" is removed; other whitespace inside the text is kept.
+ result = _inspect(_string_positive(_multiline("\ta\r\n\tb\n\n", True)))
+ self.assertEqual(result["positive"]["text"], "a\r\tb")
+ # A linked text is followed; a linked flag is unsupported (not guessed).
+ linked = _string_positive(
+ _multiline(["11", 0], True), {"11": _primitive("x\ny")}
+ )
+ self.assertEqual(_inspect(linked)["positive"]["text"], "xy")
+ flag = _string_positive(
+ _multiline("a\nb", ["12", 0]),
+ {"12": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}},
+ )
+ result = _inspect(flag)
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": "10", "class_type": "StringConstantMultiline"}
+ )
+ for value in ("true", 1, None):
+ with self.subTest(flag=value):
+ result = _inspect(_string_positive(_multiline("a\nb", value)))
+ self.assertEqual(result["reason_code"], "malformed")
+ missing = _multiline("a", True)
+ del missing["inputs"]["strip_newlines"]
+ self.assertEqual(
+ _inspect(_string_positive(missing))["reason_code"], "malformed"
+ )
+ # StringConstant is the literal strategy: its string, unchanged.
+ constant = {"class_type": "StringConstant", "inputs": {"string": " x\n"}}
+ result = _inspect(_string_positive(constant))
+ self.assertEqual(_positive(result), ("resolved", " x\n", None))
+
+ def test_join_string_multi_exact_semantics(self):
+ strings = {
+ "string_1": ["11", 0],
+ "string_2": "b",
+ "string_3": ["13", 0],
+ "string_4": ["14", 0],
+ "string_6": "beyond the count",
+ }
+ extra = {
+ "11": _primitive(""),
+ "13": _primitive(""),
+ "14": _primitive(" d "),
+ }
+ join = _join(5, "\\n|", **strings)
+ result = _inspect(_string_positive(join, extra))
+ # string_1 is kept although empty; empty and missing later parts are
+ # skipped; inputs past ``inputcount`` are ignored; the delimiter is
+ # used exactly as written (no escape processing, no trimming).
+ self.assertEqual(_positive(result), ("resolved", "\\n|b\\n| d ", None))
+ self.assertEqual(result["positive"]["storage_text"], "\\n|b\\n| d ")
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [
+ {"node_id": "6", "output_slot": 0},
+ {"node_id": "10", "output_slot": 0},
+ {"node_id": "11", "output_slot": 0},
+ {"node_id": "13", "output_slot": 0},
+ {"node_id": "14", "output_slot": 0},
+ ],
+ )
+ # One link read by several parts is listed once in the provenance path.
+ twice = _join(3, " ", string_1=["11", 0], string_2=["11", 0], string_3="c")
+ result = _inspect(_string_positive(twice, {"11": _primitive("w")}))
+ self.assertEqual(result["positive"]["text"], "w w c")
+ self.assertEqual(
+ result["positive"]["provenance"]["path"],
+ [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}]
+ + [{"node_id": "11", "output_slot": 0}],
+ )
+ two = _join(2, " ", string_1="a red fox", string_2="in snow")
+ self.assertEqual(
+ _inspect(_string_positive(two))["positive"]["text"], "a red fox in snow"
+ )
+ # Settings must be literal values the node would accept.
+ linked = {"12": {"class_type": "PrimitiveInt", "inputs": {"value": 2}}}
+ for field, value, code in (
+ ("inputcount", ["12", 0], "unsupported-node"),
+ ("delimiter", ["12", 0], "unsupported-node"),
+ ("inputcount", 0, "malformed"),
+ ("inputcount", 1001, "malformed"),
+ ("inputcount", True, "malformed"),
+ ("inputcount", "2", "malformed"),
+ ("delimiter", 1, "malformed"),
+ ("return_list", "false", "malformed"),
+ ("return_list", 0, "malformed"),
+ ):
+ with self.subTest(field=field, value=value):
+ node = _join(2, " ", string_1="a", string_2="b")
+ node["inputs"][field] = value
+ result = _inspect(_string_positive(node, linked))
+ self.assertEqual(result["reason_code"], code)
+ if code == "unsupported-node":
+ self.assertEqual(result["node"]["class_type"], "JoinStringMulti")
+ for field in ("inputcount", "delimiter", "return_list", "string_1"):
+ with self.subTest(missing=field):
+ node = _join(2, " ", string_1="a", string_2="b")
+ del node["inputs"][field]
+ result = _inspect(_string_positive(node))
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_join_string_multi_return_list_is_runtime_value(self):
+ source = parity.build("f21")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "runtime-value")
+ self.assertEqual(result["reason"], "Text is decided at run time")
+ self.assertEqual(result["node"], {"id": "10", "class_type": "JoinStringMulti"})
+ # A linked list flag is a run-time value too.
+ source["10"]["inputs"]["return_list"] = ["20", 0]
+ source["20"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
+ self.assertEqual(_inspect(source)["reason_code"], "runtime-value")
+ # With the flag literally false the same parts join.
+ source["10"]["inputs"]["return_list"] = False
+ self.assertEqual(_inspect(source)["positive"]["text"], "a red fox, in snow")
+
+ def test_prompt_search_list_is_runtime_value(self):
+ from utils.prompt_inspector import inspect_prompt_evidence
+
+ source = parity.build("f22")["prompt"]
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "runtime-value")
+ self.assertEqual(result["node"], {"id": "10", "class_type": "PromptSearchList"})
+ self.assertIsNone(result["positive"])
+ # Never a list item: every output slot is decided at run time.
+ source["6"]["inputs"]["text"] = ["10", 3]
+ self.assertEqual(_inspect(source)["reason_code"], "runtime-value")
+ evidence = inspect_prompt_evidence({"prompt": parity.build("f22")["prompt"]})
+ self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
+ self.assertIsNone(evidence["prompts"]["positive"]["text"])
+
+ # The preview-only FaceDetailer no longer competes with the saved branch.
+ def test_f01b_single_flux2_branch_resolves_with_absent_negative(self):
+ result = _inspect(parity.build("f01b")["prompt"])
+ self.assertEqual(result["state"], "absent")
+ self.assertEqual(result["positive"]["text"], F01B_POSITIVE)
+ self.assertIsNone(result["negative"])
+
+ def test_f01b_branch_text_is_the_exact_join(self):
+ # Anchored at the branch's saver, over its EmptyFlux2LatentImage origin:
+ # the join, multiline strings and encoder alone give the exact text.
+ source = parity.build("f01b")["prompt"]
+ self.assertEqual(source["1300:7"]["class_type"], "EmptyFlux2LatentImage")
+ result = _inspect(source, output_node_id="1400")
+ self.assertEqual(result["state"], "absent")
+ self.assertEqual(result["reason"], "Guider has no negative input")
+ self.assertEqual(result["positive"]["text"], F01B_POSITIVE)
+ self.assertEqual(result["positive"]["storage_text"], F01B_POSITIVE)
+ self.assertIsNone(result["negative"])
+
+ def test_encoder_and_string_outputs_keep_their_kinds(self):
+ sdxl = parity.build("f06a")["prompt"]["6"]
+ # A conditioning output read as a string, and a string output read as
+ # conditioning, are malformed links, not unsupported nodes.
+ join = _join(2, " ", string_1=["11", 0], string_2="b")
+ result = _inspect(_string_positive(join, {"11": sdxl}))
+ self.assertEqual(result["reason_code"], "malformed")
+ self.assertEqual(result["reason"], "Output slot has an incompatible type")
+ for node in (_multiline("a", True), _join(2, " ", string_1="a")):
+ with self.subTest(node=node["class_type"]):
+ 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.
+ 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["reason_code"], "unsupported-node")
+ self.assertEqual(result["node"], {"id": "10", "class_type": name})
+
+ def test_string_strategies_bound_every_intermediate(self):
+ limited = replace(resolver.INSPECTION_PROFILE, max_text_length=10)
+ join = _join(2, " ", string_1="abcdef", string_2="ghijk")
+ result = resolve_prompt_pair(
+ {"prompt": _string_positive(join)}, profile=limited
+ )
+ self.assertEqual(result["reason_code"], "text-limit")
+ # Exactly at the limit is fine.
+ join["inputs"]["string_2"] = "ghi"
+ result = resolve_prompt_pair(
+ {"prompt": _string_positive(join)}, profile=limited
+ )
+ self.assertEqual(result["positive"]["text"], "abcdef ghi")
+ # The scanned-character budget covers stripping, joining and comparing.
+ long_text = "x\n" * 5_000
+ cases = {
+ "strip": _string_positive(_multiline(long_text, True)),
+ "join": _string_positive(
+ _join(2, " ", string_1=long_text, string_2=long_text)
+ ),
+ "parts": _with_positive(_sd3(long_text, long_text, long_text)),
+ }
+ scanned = replace(resolver.INSPECTION_PROFILE, max_scanned_units=8_000)
+ for label, source in cases.items():
+ with self.subTest(strategy=label):
+ self.assertEqual(_inspect(source)["state"], "resolved")
+ result = resolve_prompt_pair({"prompt": source}, profile=scanned)
+ self.assertEqual(result["reason_code"], "work-limit")
+
+ def test_listed_strategies_without_code_here_are_unsupported(self):
+ # ``pm-compose`` and ``concat`` are schema-valid but implemented by the
+ # existing adapters only; a table row using them is never guessed at.
+ table = inspection_semantics.load_semantics()
+ encoders = dict(table.encoders)
+ encoders[("CLIPTextEncodeSDXL", 0)] = {
+ "strategy": "pm-compose",
+ "inputs": ("text",),
+ }
+ strings = dict(table.strings)
+ strings[("StringConstant", 0)] = {"strategy": "concat", "inputs": ("string",)}
+ patched = replace(table, encoders=encoders, strings=strings)
+ constant = {"class_type": "StringConstant", "inputs": {"string": "x"}}
+ with mock.patch.object(resolver, "_inspection_semantics", return_value=patched):
+ for source, name in (
+ (parity.build("f06a")["prompt"], "CLIPTextEncodeSDXL"),
+ (_string_positive(constant), "StringConstant"),
+ ):
+ with self.subTest(node=name):
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(result["node"]["class_type"], name)
+
+ def test_unreadable_string_sources_keep_pair_execution_evidence(self):
+ from utils.pair_execution import EXECUTION_KEY
+
+ record = {
+ "text": "recorded words",
+ "negative_text": "neg",
+ "positive_text": "recorded words",
+ "negative_effective_text": "neg",
+ "prepend_text": "",
+ "append_text": "",
+ }
+ evidence = {
+ EXECUTION_KEY: {"version": 1, "nodes": {"8": {"records": [record]}}}
+ }
+
+ def paired(source: dict, extra: dict) -> dict:
+ graph = parity.build("f02")["prompt"]
+ graph["8"] = {
+ "class_type": "PromptManagerPair",
+ "inputs": {
+ "text": ["10", 0],
+ "negative_text": "neg",
+ "prepend_text": "",
+ "append_text": "",
+ },
+ }
+ graph["3"]["inputs"]["positive"] = ["8", 0]
+ graph["3"]["inputs"]["negative"] = ["8", 1]
+ graph.update({"10": source, **extra})
+ return graph
+
+ flag = {"11": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}}
+ unknown = {"11": {"class_type": "FutureNode", "inputs": {}}}
+ # Text that cannot be read statically is unverifiable, not a mismatch:
+ # the pair's unique execution record still decides (as it did before
+ # these nodes were listed).
+ cases = {
+ "linked strip flag": (_multiline("recorded words", ["11", 0]), flag),
+ "linked delimiter": (
+ _join(2, ["11", 0], string_1="recorded", string_2="words"),
+ unknown,
+ ),
+ "linked count": (
+ _join(["11", 0], " ", string_1="recorded", string_2="words"),
+ unknown,
+ ),
+ "list output": (
+ _join(2, " ", True, string_1="recorded", string_2="words"),
+ {},
+ ),
+ "search list": ({"class_type": "PromptSearchList", "inputs": {}}, {}),
+ }
+ for label, (source, extra) in cases.items():
+ with self.subTest(source=label):
+ result = resolve_prompt_pair(
+ {"prompt": paired(source, extra), **evidence},
+ profile=resolver.INSPECTION_PROFILE,
+ )
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), ("recorded words", "neg"))
+ provenance = result["positive"]["provenance"]
+ self.assertEqual(provenance["text_source"], "execution")
+ # A source that does resolve is still verified against the record.
+ readable = _join(2, " ", string_1="other", string_2="words")
+ result = resolve_prompt_pair(
+ {"prompt": paired(readable, {}), **evidence},
+ profile=resolver.INSPECTION_PROFILE,
+ )
+ self.assertEqual(result["reason_code"], "malformed")
+ self.assertEqual(
+ result["reason"], "Execution evidence does not match recorded node inputs"
+ )
+ matching = _join(2, " ", string_1="recorded", string_2="words")
+ result = resolve_prompt_pair(
+ {"prompt": paired(matching, {}), **evidence},
+ profile=resolver.INSPECTION_PROFILE,
+ )
+ self.assertEqual(result["positive"]["provenance"]["text_source"], "execution")
+
+ def test_default_profile_keeps_encoder_and_string_nodes_unsupported(self):
+ for name in ("f06a", "f06b", "f21", "f22", "f24"):
+ with self.subTest(fixture=name):
+ result = resolve_prompt_pair({"prompt": parity.build(name)["prompt"]})
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(
+ result["reason"], "Unsupported node semantics or output slot"
+ )
+ self.assertIsNone(result["reason_code"])
+
+
+def _detailed(same_prompt: bool = False) -> dict:
+ """``f19``: KSampler 3 -> VAEDecode 8 -> FaceDetailer 50 -> SaveImage 9."""
+ source = parity.build("f19")["prompt"]
+ if same_prompt:
+ source["50"]["inputs"]["positive"] = ["6", 0]
+ return source
+
+
+def _detailer(image: list, positive: list, negative: list = None) -> dict:
+ return {
+ "class_type": "FaceDetailer",
+ "inputs": {
+ "image": image,
+ "model": ["4", 0],
+ "positive": positive,
+ "negative": negative or ["7", 0],
+ "seed": 1,
+ "steps": 8,
+ },
+ }
+
+
+class TestDetailerStages(unittest.TestCase):
+ """D19: a detailer is a sampler stage judged with its upstream sampler."""
+
+ def test_detailer_with_the_upstream_prompt_resolves(self):
+ result = _inspect(_detailed(same_prompt=True))
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["sampler_id"], "50")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["candidates"], [])
+ self.assertEqual(
+ _inspect(_detailed(same_prompt=True), output_node_id="9"), result
+ )
+
+ def test_detailer_with_a_different_prompt_is_ambiguous_with_both_stages(self):
+ from utils.prompt_inspector import inspect_prompt_evidence
+
+ result = _inspect(_detailed())
+ self.assertEqual(
+ (result["state"], result["reason_code"]),
+ ("ambiguous", "multistage-differs"),
+ )
+ self.assertEqual(result["sampler_id"], "50")
+ self.assertIsNone(result["positive"])
+ self.assertIsNone(result["negative"])
+ self.assertEqual(
+ [(item["sampler_id"], _sides(item)) for item in result["candidates"]],
+ [
+ ("50", (parity.DETAILER_POS, parity.NEG)),
+ ("3", (parity.POS, parity.NEG)),
+ ],
+ )
+ self.assertEqual(_inspect(_detailed(), output_node_id="9"), result)
+ # Neither the upstream prompt nor the detailer prompt is shown as the answer.
+ prompts = inspect_prompt_evidence({"prompt": _detailed()})["prompts"]
+ self.assertEqual(
+ [prompts[role]["status"] for role in ("positive", "negative")],
+ ["ambiguous", "ambiguous"],
+ )
+ self.assertNotIn(parity.POS, str(prompts))
+
+ def test_detailer_of_a_loaded_image_is_a_single_stage(self):
+ source = _detailed()
+ source["13"] = {"class_type": "LoadImage", "inputs": {"image": "input.png"}}
+ source["50"]["inputs"]["image"] = ["13", 0]
+ source["9"]["inputs"]["images"] = ["50", 0]
+ source["8"]["inputs"]["samples"] = ["3", 0]
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.DETAILER_POS, parity.NEG))
+ self.assertEqual(result["sampler_id"], "50")
+
+ def test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor(self):
+ source = _detailed(same_prompt=True)
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source)
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(result["reason"], "Multiple sampler branches")
+ self.assertEqual(
+ sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
+ )
+ raw = _inspect(source, output_node_id="14")
+ self.assertEqual((raw["state"], raw["sampler_id"]), ("resolved", "3"))
+ detailed = _inspect(source, output_node_id="9")
+ self.assertEqual(
+ (detailed["state"], detailed["sampler_id"]), ("resolved", "50")
+ )
+
+ def test_a_preview_of_the_upstream_image_is_not_another_output(self):
+ source = _detailed()
+ source["14"] = {"class_type": "PreviewImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "multistage-differs")
+ self.assertEqual(len(result["candidates"]), 2)
+
+ def test_chained_detailers_compare_every_stage(self):
+ source = _detailed(same_prompt=True)
+ source["51"] = _detailer(["50", 0], ["6", 0])
+ source["9"]["inputs"]["images"] = ["51", 0]
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "51"))
+ source["50"]["inputs"]["positive"] = ["10", 0]
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "multistage-differs")
+ self.assertEqual(
+ [item["sampler_id"] for item in result["candidates"]], ["51", "50", "3"]
+ )
+
+ def test_unresolved_stage_blocks_the_chain(self):
+ source = _detailed(same_prompt=True)
+ source["30"] = {"class_type": "FutureConditioner", "inputs": {"c": ["6", 0]}}
+ source["3"]["inputs"]["positive"] = ["30", 0]
+ source["50"]["inputs"]["positive"] = ["10", 0]
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("unresolved", "50"))
+ self.assertEqual(
+ result["node"], {"id": "30", "class_type": "FutureConditioner"}
+ )
+ self.assertIsNone(result["positive"])
+
+ def test_malformed_and_cyclic_detailer_ancestry_never_guesses(self):
+ source = _detailed()
+ del source["50"]["inputs"]["image"]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(
+ (result["state"], result["reason_code"]), ("unresolved", "malformed")
+ )
+ source = _detailed()
+ source["8"]["inputs"]["samples"] = ["51", 0]
+ source["51"] = _detailer(["50", 0], ["6", 0])
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_detailer_image_ancestry_is_walked_generically(self):
+ source = _detailed(same_prompt=True)
+ source["60"] = {
+ "class_type": "ImageBlend",
+ "inputs": {"image1": ["8", 0], "image2": ["8", 0], "blend_factor": 0.5},
+ }
+ source["61"] = {
+ "class_type": "ImageBlend",
+ "inputs": {"image1": ["8", 0], "image2": ["60", 0]},
+ }
+ source["62"] = {"class_type": "ImageScale", "inputs": {"image": ["60", 0]}}
+ source["50"]["inputs"]["image"] = ["61", 0]
+ source["63"] = {"class_type": "PreviewImage", "inputs": {"images": ["62", 0]}}
+ source["junk"] = "not a node"
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "50"))
+ # A detailer whose image ancestry returns to itself is malformed.
+ source = _detailed()
+ source["64"] = {"class_type": "ImageScale", "inputs": {"image": ["50", 0]}}
+ source["50"]["inputs"]["image"] = ["64", 0]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["reason"], "Cyclic sampler stage ancestry")
+ self.assertEqual(result["reason_code"], "malformed")
+
+ def test_detailer_stage_walks_draw_from_the_work_budget(self):
+ self.assertEqual(
+ _inspect(_detailed(), max_visits=6)["reason_code"], "work-limit"
+ )
+ # Too little budget to absorb the upstream sampler keeps both as
+ # candidates: more ambiguity, never a chosen prompt.
+ starved = _inspect(_detailed(), max_visits=3)
+ self.assertEqual(starved["state"], "ambiguous")
+ self.assertEqual(len(starved["candidates"]), 2)
+ self.assertIsNone(starved["positive"])
+ source = _detailed()
+ previous = "8"
+ for index in range(70):
+ source[f"u{index}"] = {
+ "class_type": "ImageScale",
+ "inputs": {"image": [previous, 0]},
+ }
+ previous = f"u{index}"
+ source["50"]["inputs"]["image"] = [previous, 0]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["reason_code"], "depth-limit")
+
+ def test_long_detailer_chains_are_depth_limited(self):
+ for count in (70, 600):
+ source = _detailed(same_prompt=True)
+ previous = "50"
+ for index in range(count):
+ node_id = f"d{index}"
+ source[node_id] = _detailer([previous, 0], ["6", 0])
+ previous = node_id
+ source["9"]["inputs"]["images"] = [previous, 0]
+ for options in ({}, {"output_node_id": "9"}):
+ with self.subTest(count=count, options=options):
+ result = _inspect(source, **options)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "depth-limit")
+ self.assertIsNone(result["positive"])
+ # A chain within the bound still resolves.
+ source = _detailed(same_prompt=True)
+ previous = "50"
+ for index in range(20):
+ source[f"d{index}"] = _detailer([previous, 0], ["6", 0])
+ previous = f"d{index}"
+ source["9"]["inputs"]["images"] = [previous, 0]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "d19"))
+
+ def test_a_preview_only_detailer_never_blocks_the_saved_branch(self):
+ # Codex P2: the detailer feeds only a PreviewImage; the raw image is saved.
+ source = _detailed()
+ source["9"]["class_type"] = "PreviewImage"
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(result["candidates"], [])
+ # Every non-file output counts as a preview, through any image chain.
+ for name in sorted(inspection_semantics.load_semantics().non_file_outputs):
+ with self.subTest(output=name):
+ source["15"] = {
+ "class_type": "ImageScale",
+ "inputs": {"image": ["50", 0]},
+ }
+ source["9"] = {"class_type": name, "inputs": {"images": ["15", 0]}}
+ self.assertEqual(_inspect(source)["sampler_id"], "3")
+
+ def test_a_preview_only_sampler_never_blocks_the_saved_sampler(self):
+ source = _detailed()
+ source["9"]["inputs"]["images"] = ["8", 0]
+ source["16"] = {"class_type": "VAEDecode", "inputs": {"samples": ["50", 0]}}
+ del source["50"]
+ source["50"] = {
+ "class_type": "KSampler",
+ "inputs": {**source["3"]["inputs"], "positive": ["10", 0]},
+ }
+ source["17"] = {"class_type": "PreviewImage", "inputs": {"images": ["16", 0]}}
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
+ # Ingestion keeps its historical answer (D1).
+ legacy = resolve_prompt_pair({"prompt": source})
+ self.assertEqual(legacy["state"], "ambiguous")
+
+ def test_stages_that_write_files_stay_candidates(self):
+ # Raw and detailed double saves both write files (the H1 exception).
+ source = _detailed()
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ source["15"] = {"class_type": "PreviewImage", "inputs": {"images": ["50", 0]}}
+ result = _inspect(source)
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(
+ sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
+ )
+ # A stage with no output at all is not known to be preview-only.
+ source = _detailed()
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ del source["9"]
+ self.assertEqual(_inspect(source)["state"], "ambiguous")
+
+ def test_when_every_stage_is_preview_only_none_is_dropped(self):
+ source = _detailed()
+ source["9"]["class_type"] = "PreviewImage"
+ source["14"] = {"class_type": "PreviewImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source)
+ self.assertEqual(result["reason_code"], "multistage-differs")
+ self.assertEqual(len(result["candidates"]), 2)
+
+ def test_preview_files_keep_preview_only_stages(self):
+ # A preview file in the temp folder may be the preview stage's own output.
+ source = _detailed()
+ source["9"]["class_type"] = "PreviewImage"
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ result = resolve_prompt_pair(
+ {"prompt": source}, profile=resolver.PREVIEW_INSPECTION_PROFILE
+ )
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(len(result["candidates"]), 2)
+
+ def test_a_failed_preview_walk_keeps_every_stage(self):
+ source = _detailed()
+ source["9"]["class_type"] = "PreviewImage"
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ limit = resolver._Unresolved("Resolution work limit reached", "work-limit")
+ with mock.patch.object(
+ resolver._GraphResolver, "producers_of", side_effect=limit
+ ):
+ result = _inspect(source)
+ # More ambiguity, never a chosen prompt.
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(len(result["candidates"]), 2)
+ self.assertIsNone(result["positive"])
+
+ def test_preview_only_walks_are_linear_in_long_chains(self):
+ # 600 chained detailers ending in a preview, beside a saved raw image:
+ # one pass decides every stage, so the walk itself never runs out.
+ source = _detailed(same_prompt=True)
+ previous = "50"
+ for index in range(600):
+ source[f"d{index}"] = _detailer([previous, 0], ["6", 0])
+ previous = f"d{index}"
+ source["9"] = {
+ "class_type": "PreviewImage",
+ "inputs": {"images": [previous, 0]},
+ }
+ source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
+ result = _inspect(source)
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
+
+ def test_default_profile_keeps_the_upstream_sampler(self):
+ # Ingestion is unchanged (D1): the detailer is not a sampler there.
+ result = resolve_prompt_pair({"prompt": _detailed()})
+ self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+
+
+MULTISTAGE = "Multistage sampler ancestry is ambiguous"
+OTHER = "a white owl at dusk"
+
+
+def _latent_from(node: dict, extra: dict = None) -> dict:
+ """``f02``'s KSampler graph whose ``latent_image`` reads ``node`` (as node 5)."""
+ return parity._ksampler_graph(
+ parity._enc(parity.POS), parity._enc(parity.NEG), latent=node, extra=extra
+ )
+
+
+def _image_to_image(positive: str = OTHER) -> dict:
+ """KSampler 3 -> VAEDecode 8 -> VAEEncode 60 -> KSampler 61 -> SaveImage 63."""
+ source = _latent_from(None)
+ source.update(
+ {
+ "60": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["8", 0], "vae": ["4", 2]},
+ },
+ "62": parity._enc(positive),
+ "61": {
+ "class_type": "KSampler",
+ "inputs": {
+ **source["3"]["inputs"],
+ "positive": ["62", 0],
+ "latent_image": ["60", 0],
+ "denoise": 0.6,
+ },
+ },
+ "64": {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["61", 0], "vae": ["4", 2]},
+ },
+ "63": {
+ "class_type": "SaveImage",
+ "inputs": {"filename_prefix": "img2img", "images": ["64", 0]},
+ },
+ }
+ )
+ # The first stage's own output is only a preview.
+ source["9"]["class_type"] = "PreviewImage"
+ return source
+
+
+class TestLatentAncestry(unittest.TestCase):
+ """Any latent origin without an upstream sampler is a single stage."""
+
+ def test_vaeencode_from_load_image_is_single_stage(self):
+ source = parity.build("f10")["prompt"]
+ for anchor in (None, "9"):
+ with self.subTest(anchor=anchor):
+ result = _inspect(source, output_node_id=anchor)
+ self.assertEqual(result["state"], "resolved")
+ self.assertIsNone(result["reason_code"])
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ # Ingestion is unchanged (D1).
+ legacy = resolve_prompt_pair({"prompt": source})
+ self.assertEqual(legacy["state"], "unresolved")
+ self.assertEqual(legacy["reason"], "Unsupported latent ancestry")
+
+ def test_model_specific_empty_latents_are_single_stage(self):
+ size = {"width": 1024, "height": 1024, "batch_size": 1}
+ origins = {
+ "f11": parity.build("f11")["prompt"],
+ "EmptyLatentBatch": _latent_from(
+ {"class_type": "EmptyLatentBatch", "inputs": dict(size)}
+ ),
+ "EmptyFlux2LatentImage": _latent_from(
+ {"class_type": "EmptyFlux2LatentImage", "inputs": dict(size)}
+ ),
+ "SetLatentNoiseMask": _latent_from(
+ {
+ "class_type": "SetLatentNoiseMask",
+ "inputs": {"samples": ["60", 0], "mask": ["50", 1]},
+ },
+ {
+ "50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
+ "60": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
+ },
+ },
+ ),
+ "Reroute": _latent_from(
+ {"class_type": "Reroute", "inputs": {"value": ["60", 0]}},
+ {"60": {"class_type": "EmptyLatentBatch", "inputs": dict(size)}},
+ ),
+ }
+ for name in ("InpaintModelConditioning", "WanImageToVideo"):
+ # Slots 0 and 1 carry the conditioning, slot 2 is the latent origin.
+ source = _latent_from(
+ {
+ "class_type": name,
+ "inputs": {
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "vae": ["4", 2],
+ "pixels": ["50", 0],
+ "start_image": ["50", 0],
+ "mask": ["50", 1],
+ "width": 832,
+ "height": 480,
+ },
+ },
+ {"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}},
+ )
+ source["3"]["inputs"].update(
+ positive=["5", 0], negative=["5", 1], latent_image=["5", 2]
+ )
+ origins[name] = source
+ for name, source in origins.items():
+ with self.subTest(origin=name):
+ result = _inspect(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(result["sampler_id"], "3")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ self.assertEqual(_inspect(source, output_node_id="9"), result)
+
+ def test_upstream_sampler_through_vaedecode_vaeencode_is_multistage(self):
+ for positive in (OTHER, parity.POS):
+ with self.subTest(positive=positive):
+ result = _inspect(_image_to_image(positive), output_node_id="63")
+ # Until PR3 compares stage texts, never one stage's prompt.
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["sampler_id"], "61")
+ self.assertEqual(result["reason"], MULTISTAGE)
+ self.assertIsNone(result["reason_code"])
+ self.assertIsNone(result["positive"])
+ self.assertIsNone(result["negative"])
+ # An upstream sampler reached through a non-latent input is a stage too.
+ source = _latent_from(
+ {
+ "class_type": "InpaintModelConditioning",
+ "inputs": {
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "vae": ["4", 2],
+ "pixels": ["71", 0],
+ "mask": ["50", 1],
+ },
+ },
+ {
+ "50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
+ "70": {
+ "class_type": "KSampler",
+ "inputs": {
+ "model": ["4", 0],
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "latent_image": ["72", 0],
+ },
+ },
+ "71": {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["70", 0], "vae": ["4", 2]},
+ },
+ "72": {"class_type": "EmptyLatentImage", "inputs": {}},
+ },
+ )
+ source["3"]["inputs"]["latent_image"] = ["5", 2]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(
+ (result["state"], result["reason"]), ("unresolved", MULTISTAGE)
+ )
+ # A latent that reaches its own sampler is malformed, not a stage.
+ source = _latent_from(
+ {"class_type": "SetLatentNoiseMask", "inputs": {"samples": ["3", 0]}}
+ )
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(
+ (result["state"], result["reason_code"]), ("unresolved", "malformed")
+ )
+ self.assertEqual(result["reason"], "Cyclic latent ancestry")
+ source["3"]["inputs"]["latent_image"] = None
+ self.assertEqual(_inspect(source)["reason_code"], "malformed")
+
+ def test_unlisted_pipe_and_custom_samplers_in_the_ancestry_are_unsupported(self):
+ # Review I-1: stages whose inputs are pipes, embeds or sampler parts
+ # (easy-use, Impact pipes, video and custom samplers) must never be
+ # walked through to a silent single-stage pick of the final prompt.
+ easy = _latent_from(None)
+ easy["3"]["inputs"]["positive"] = ["62", 0]
+ easy.update(
+ {
+ "62": parity._enc(OTHER),
+ "20": {
+ "class_type": "easy fullLoader",
+ "inputs": {"positive": "first pass", "negative": "bad"},
+ },
+ "21": {"class_type": "easy preSampling", "inputs": {"pipe": ["20", 0]}},
+ "22": {"class_type": "easy kSampler", "inputs": {"pipe": ["21", 0]}},
+ "23": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["22", 1], "vae": ["4", 2]},
+ },
+ }
+ )
+ easy["3"]["inputs"]["latent_image"] = ["23", 0]
+ pipes = _latent_from(None)
+ pipes.update(
+ {
+ "50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
+ "20": {
+ "class_type": "easy fullLoader",
+ "inputs": {"positive": "segs prompt", "negative": "n"},
+ },
+ "24": {
+ "class_type": "easy pipeToBasicPipe",
+ "inputs": {"pipe": ["20", 0]},
+ },
+ "26": {
+ "class_type": "BasicPipeToDetailerPipe",
+ "inputs": {"basic_pipe": ["24", 0]},
+ },
+ "25": {
+ "class_type": "FaceDetailerPipe",
+ "inputs": {"image": ["50", 0], "detailer_pipe": ["26", 0]},
+ },
+ "23": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["25", 0], "vae": ["4", 2]},
+ },
+ }
+ )
+ pipes["3"]["inputs"]["latent_image"] = ["23", 0]
+ cases = {"easy": (easy, "22", "easy kSampler")}
+ # Breadth-first, the first unlisted pipe consumer met is named.
+ cases["pipes"] = (pipes, "25", "FaceDetailerPipe")
+ for label, embeds in (
+ ("text_embeds", "WanVideoSampler"),
+ ("hyvid_embeds", "HyVideoSampler"),
+ ("sigmas", "MysteryCustomSampler"),
+ ("noise", "MysteryNoiseSampler"),
+ ("sampler", "MysterySamplerStage"),
+ ):
+ source = _latent_from(None)
+ source.update(
+ {
+ "30": {"class_type": "MysteryEmbeds", "inputs": {}},
+ "31": {"class_type": embeds, "inputs": {label: ["30", 0]}},
+ "32": {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["31", 0], "vae": ["4", 2]},
+ },
+ "23": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["32", 0], "vae": ["4", 2]},
+ },
+ }
+ )
+ source["3"]["inputs"]["latent_image"] = ["23", 0]
+ cases[label] = (source, "31", embeds)
+ for label, (source, node_id, class_type) in cases.items():
+ with self.subTest(case=label):
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(result["reason"], "Unsupported latent ancestry")
+ self.assertEqual(
+ result["node"], {"id": node_id, "class_type": class_type}
+ )
+ self.assertIsNone(result["positive"])
+ # A LoRA in a walked CLIP chain is not a stage (``model`` is not one).
+ source = _latent_from(
+ {
+ "class_type": "InpaintModelConditioning",
+ "inputs": {
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ "vae": ["4", 2],
+ "pixels": ["50", 0],
+ "mask": ["50", 1],
+ },
+ },
+ {
+ "50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
+ "40": {
+ "class_type": "LoraLoader",
+ "inputs": {"model": ["4", 0], "clip": ["4", 1]},
+ },
+ },
+ )
+ source["6"]["inputs"]["clip"] = ["40", 1]
+ source["3"]["inputs"].update(
+ positive=["5", 0], negative=["5", 1], latent_image=["5", 2]
+ )
+ self.assertEqual(_inspect(source)["state"], "resolved")
+
+ def test_unlisted_model_driven_samplers_in_the_ancestry_are_unsupported(self):
+ # Re-review I-1b: a sampler linking ``model`` with conditioning or a
+ # latent under other names (XlabsSampler, generic video samplers).
+ xlabs = _latent_from(None)
+ xlabs["3"]["inputs"]["positive"] = ["62", 0]
+ xlabs.update(
+ {
+ "62": parity._enc(OTHER),
+ "30": {
+ "class_type": "CLIPTextEncodeFlux",
+ "inputs": {
+ "clip": ["4", 1],
+ "t5xxl": "first stage",
+ "clip_l": "first stage",
+ "guidance": 3.5,
+ },
+ },
+ "31": parity._enc("neg"),
+ "22": {
+ "class_type": "XlabsSampler",
+ "inputs": {
+ "model": ["4", 0],
+ "conditioning": ["30", 0],
+ "neg_conditioning": ["31", 0],
+ "noise_seed": 1,
+ },
+ },
+ "28": {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["22", 0], "vae": ["4", 2]},
+ },
+ "23": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["28", 0], "vae": ["4", 2]},
+ },
+ }
+ )
+ xlabs["3"]["inputs"]["latent_image"] = ["23", 0]
+ video = _latent_from(None)
+ video.update(
+ {
+ "40": {"class_type": "EmptyLatentImage", "inputs": {}},
+ "22": {
+ "class_type": "SomeVideoSampler",
+ "inputs": {"model": ["4", 0], "samples": ["40", 0], "seed": 1},
+ },
+ "23": {
+ "class_type": "LatentUpscale",
+ "inputs": {"samples": ["22", 0], "width": 768, "height": 768},
+ },
+ }
+ )
+ video["3"]["inputs"]["latent_image"] = ["23", 0]
+ reference = _latent_from(None)
+ reference.update(
+ {
+ "22": {
+ "class_type": "MysteryLatentStage",
+ "inputs": {"model": ["4", 0], "latent": ["5", 0]},
+ },
+ }
+ )
+ reference["3"]["inputs"]["latent_image"] = ["22", 0]
+ cases = {
+ "XlabsSampler": (xlabs, "22"),
+ "SomeVideoSampler": (video, "22"),
+ "MysteryLatentStage": (reference, "22"),
+ }
+ for class_type, (source, node_id) in cases.items():
+ with self.subTest(case=class_type):
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(
+ result["node"], {"id": node_id, "class_type": class_type}
+ )
+ self.assertIsNone(result["positive"])
+ # ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a
+ # model patch, never a stage.
+ patched = _latent_from(
+ {
+ "class_type": "MysteryLatentFromModel",
+ "inputs": {"model": ["41", 0], "vae": ["4", 2]},
+ },
+ {
+ "40": {
+ "class_type": "LoraLoader",
+ "inputs": {"model": ["4", 0], "clip": ["4", 1]},
+ },
+ "41": {
+ "class_type": "ModelSamplingFlux",
+ "inputs": {"model": ["40", 0], "max_shift": 1.15},
+ },
+ },
+ )
+ self.assertEqual(_inspect(patched)["state"], "resolved")
+
+ def test_unlisted_stage_in_a_detailer_image_ancestry_is_unsupported(self):
+ # Review M-1: the detailer's upstream-stage walk uses the same rule.
+ source = _detailed(same_prompt=True)
+ source.update(
+ {
+ "20": {"class_type": "easy fullLoader", "inputs": {"positive": "x"}},
+ "22": {"class_type": "easy kSampler", "inputs": {"pipe": ["20", 0]}},
+ }
+ )
+ source["50"]["inputs"]["image"] = ["22", 1]
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(result["node"], {"id": "22", "class_type": "easy kSampler"})
+
+ def test_unclassified_sampler_shaped_latent_node_is_unsupported_and_named(self):
+ # An unlisted node with sampler inputs may itself be an earlier stage:
+ # never call the chain single-stage across it.
+ source = _latent_from(
+ {
+ "class_type": "MysteryLatentSampler",
+ "inputs": {
+ "model": ["4", 0],
+ "positive": ["6", 0],
+ "latent_image": ["60", 0],
+ },
+ },
+ {"60": {"class_type": "EmptyLatentImage", "inputs": {}}},
+ )
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(result["reason"], "Unsupported latent ancestry")
+ self.assertEqual(
+ result["node"], {"id": "5", "class_type": "MysteryLatentSampler"}
+ )
+ self.assertIsNone(result["positive"])
+ # The rule holds at any depth (review M-3): behind a VAEEncode.
+ source = _latent_from(
+ {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["60", 0], "vae": ["4", 2]},
+ },
+ {
+ "50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
+ "60": {
+ # Task 2.7b lists UltimateSDUpscale; an unlisted upscaler
+ # of the same shape keeps this rule's coverage.
+ "class_type": "MysteryUpscaler",
+ "inputs": {
+ "image": ["50", 0],
+ "model": ["4", 0],
+ "positive": ["6", 0],
+ "negative": ["7", 0],
+ },
+ },
+ },
+ )
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["reason_code"], "unsupported-node")
+ self.assertEqual(result["node"], {"id": "60", "class_type": "MysteryUpscaler"})
+ # The listed stage is now an earlier sampler stage in the ancestry.
+ source["60"]["class_type"] = "UltimateSDUpscale"
+ result = _inspect(source, output_node_id="9")
+ self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
+
+ def test_latent_walk_budget_exhaustion_is_work_limit(self):
+ # 2,100 distinct images in the latent ancestry: more than one
+ # candidate's 2,000 visits, and never reported as single stage.
+ extra = {
+ f"i{index}": {"class_type": "LoadImage", "inputs": {"image": f"{index}"}}
+ for index in range(2_100)
+ }
+ extra["50"] = {
+ "class_type": "ImageBatchMulti",
+ "inputs": {f"image_{index}": [f"i{index}", 0] for index in range(2_100)},
+ }
+ source = _latent_from(
+ {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
+ },
+ extra,
+ )
+ result = _inspect(source)
+ self.assertEqual(result["state"], "unresolved")
+ self.assertEqual(result["reason_code"], "work-limit")
+ self.assertIsNone(result["positive"])
+ self.assertEqual(
+ _inspect(parity.build("f10")["prompt"], max_visits=2)["reason_code"],
+ "work-limit",
+ )
+ # A chain longer than the depth limit is ``depth-limit``.
+ source = _latent_from({"class_type": "EmptyLatentImage", "inputs": {}})
+ previous = "5"
+ for index in range(70):
+ source[f"u{index}"] = {
+ "class_type": "LatentUpscaleBy",
+ "inputs": {"samples": [previous, 0], "scale_by": 1.0},
+ }
+ previous = f"u{index}"
+ source["3"]["inputs"]["latent_image"] = [previous, 0]
+ self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
+ source["3"]["inputs"]["latent_image"] = ["u10", 0]
+ self.assertEqual(_inspect(source)["state"], "resolved")
+
+
+class _CountingDeque(collections.deque):
+ """Counts every item queued by the resolver's breadth-first walks."""
+
+ queued = 0
+
+ def append(self, item):
+ _CountingDeque.queued += 1
+ super().append(item)
+
+ def extend(self, items):
+ for item in items:
+ self.append(item)
+
+
+def _dense_layers(
+ prefix: str, bottom: list, layers: int = 4, width: int = 40, fan: int = None
+):
+ """A hub over ``layers`` x ``width`` nodes; each links to ``fan`` (default:
+ all) nodes of the next layer, so every node is shared by many consumers."""
+ nodes = {}
+ fan = fan or width
+ for layer in range(layers):
+ for index in range(width):
+ below = (
+ [
+ [f"{prefix}{layer + 1}_{(index + step) % width}", 0]
+ for step in range(fan)
+ ]
+ if layer + 1 < layers
+ else [bottom]
+ )
+ nodes[f"{prefix}{layer}_{index}"] = {
+ "class_type": "DenseBlend",
+ "inputs": {f"in_{k}": link for k, link in enumerate(below)},
+ }
+ # One hub reads the whole first layer.
+ nodes[f"{prefix}top"] = {
+ "class_type": "DenseBlend",
+ "inputs": {f"in_{k}": [f"{prefix}0_{k}", 0] for k in range(width)},
+ }
+ return nodes, [f"{prefix}top", 0], layers * width + 1
+
+
+class TestBreadthFirstWalkWork(unittest.TestCase):
+ """Dense shared fan-in: each node is queued at most once per walk."""
+
+ def counted(self, source: dict, **options) -> tuple:
+ _CountingDeque.queued = 0
+ with mock.patch.object(resolver, "deque", _CountingDeque):
+ result = _inspect(source, **options)
+ return result, _CountingDeque.queued
+
+ def test_dense_latent_ancestry_queues_each_node_once(self):
+ source = _latent_from({"class_type": "EmptyLatentImage", "inputs": {}})
+ dense, top, count = _dense_layers("L", ["5", 0])
+ source.update(dense)
+ source["3"]["inputs"]["latent_image"] = top
+ result, queued = self.counted(source)
+ self.assertEqual(result["state"], "resolved")
+ self.assertEqual(_sides(result), (parity.POS, parity.NEG))
+ # The dense layers plus the empty latent, each queued once.
+ self.assertLessEqual(queued, count + 1)
+
+ def test_dense_detailer_image_ancestry_queues_each_node_once(self):
+ source = _detailed(same_prompt=True)
+ expected = _inspect(source, output_node_id="9")
+ dense, top, count = _dense_layers("D", source["50"]["inputs"]["image"])
+ source.update(dense)
+ source["50"]["inputs"]["image"] = top
+ result, queued = self.counted(source, output_node_id="9")
+ self.assertEqual(_outcomes(result), _outcomes(expected))
+ # Dense layers + the decoder + its sampler, plus the latent walk of
+ # that sampler (one EmptyLatentImage).
+ self.assertLessEqual(queued, count + 3)
+
+ def test_dense_fan_in_beyond_the_budget_is_work_limit(self):
+ source = _latent_from({"class_type": "EmptyLatentImage", "inputs": {}})
+ dense, top, count = _dense_layers("W", ["5", 0], layers=3, width=800, fan=40)
+ source.update(dense)
+ source["3"]["inputs"]["latent_image"] = top
+ result, queued = self.counted(source)
+ self.assertEqual(result["reason_code"], "work-limit")
+ self.assertLessEqual(queued, count + 1)
+
+
+def _wan_selector_graph(width: int, selectors: int, latent_origin: bool) -> dict:
+ """Review I-3: WanImageToVideo over ``width``-input joins and LoRAs, with
+ ``selectors`` diverging runtime selectors on the positive side."""
+ source = _latent_from(None)
+ source["50"] = {"class_type": "LoadImage", "inputs": {"image": "a.png"}}
+ clip = ["4", 1]
+ for index in range(5):
+ model = ["4", 0] if index == 0 else [f"lo{index - 1}", 0]
+ source[f"lo{index}"] = {
+ "class_type": "LoraLoader",
+ "inputs": {"model": model, "clip": clip, "lora_name": f"l{index}"},
+ }
+ clip = [f"lo{index}", 1]
+ ends = []
+ for prefix, text in (("P", "pos"), ("N", "neg")):
+ parts = {}
+ for index in range(width):
+ source[f"{prefix}l{index}"] = _multiline(f"{text} {index}", False)
+ parts[f"string_{index + 1}"] = [f"{prefix}l{index}", 0]
+ source[f"{prefix}j"] = _join(width, " ", **parts)
+ source[f"{prefix}e"] = {
+ "class_type": "CLIPTextEncode",
+ "inputs": {"text": [f"{prefix}j", 0], "clip": clip},
+ }
+ ends.append(f"{prefix}e")
+ previous = ends[0]
+ for index in range(selectors):
+ source[f"alt{index}"] = _encoded(f"alternative {index}")
+ source[f"ie{index}"] = {
+ "class_type": "ImageExists",
+ "inputs": {"image": ["50", 0]},
+ }
+ source[f"sel{index}"] = _if_else(
+ [f"ie{index}", 1], [previous, 0], [f"alt{index}", 0]
+ )
+ previous = f"sel{index}"
+ source["5"] = {
+ "class_type": "WanImageToVideo",
+ "inputs": {
+ "positive": [previous, 0],
+ "negative": [ends[1], 0],
+ "vae": ["4", 2],
+ "start_image": ["50", 0],
+ "width": 832,
+ "height": 480,
+ },
+ }
+ source["3"]["inputs"].update(positive=["5", 0], negative=["5", 1])
+ if latent_origin:
+ source["3"]["inputs"]["latent_image"] = ["5", 2]
+ else:
+ source["5x"] = {"class_type": "EmptyHunyuanLatentVideo", "inputs": {}}
+ source["3"]["inputs"]["latent_image"] = ["5x", 0]
+ return source
+
+
+class TestLatentWalkReuse(unittest.TestCase):
+ """Review I-3: one latent walk per sampler per resolution, reuse charged."""
+
+ def counted(self, source: dict) -> tuple:
+ walks, visits = [], []
+ walk, spend = resolver._GraphResolver.latent_stages, resolver._Budget.spend
+
+ def counted_walk(self, *args):
+ walks.append(args[0])
+ return walk(self, *args)
+
+ def counted_spend(self):
+ visits.append(1)
+ return spend(self)
+
+ with (
+ mock.patch.object(resolver._GraphResolver, "latent_stages", counted_walk),
+ mock.patch.object(resolver._Budget, "spend", counted_spend),
+ ):
+ result = _inspect(source)
+ return result, len(walks), len(visits)
+
+ def test_selector_runs_reuse_one_latent_walk(self):
+ result, walks, visits = self.counted(_wan_selector_graph(100, 3, True))
+ self.assertEqual(result["state"], "ambiguous")
+ self.assertEqual(len(result["candidates"]), 8)
+ self.assertEqual(walks, 1)
+ separate, _, baseline = self.counted(_wan_selector_graph(100, 3, False))
+ self.assertEqual(_outcomes(separate).keys(), _outcomes(result).keys())
+ # One walk over the conditioning subgraph on top of the text tracing.
+ self.assertLess(visits, baseline + 300)
+
+ def test_reused_walk_cost_is_charged_to_each_candidate(self):
+ # Two samplers share one latent origin: the second candidate is charged
+ # the walk's cost, so its outcome does not depend on tracing order.
+ source = _latent_from(
+ {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
+ },
+ {"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}},
+ )
+ source["41"] = {"class_type": "KSampler", "inputs": dict(source["3"]["inputs"])}
+ source["42"] = {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["41", 0], "vae": ["4", 2]},
+ }
+ source["43"] = {
+ "class_type": "SaveImage",
+ "inputs": {"filename_prefix": "b", "images": ["42", 0]},
+ }
+ # Both candidates share the latent walk's cached cost.
+ full = _inspect(source)
+ self.assertEqual(
+ [item["state"] for item in full["candidates"]], ["resolved", "resolved"]
+ )
+ for budget in range(1, 12):
+ with self.subTest(max_visits=budget):
+ both = _inspect(source, max_visits=budget)
+ states = [
+ (item["state"], item["reason_code"]) for item in both["candidates"]
+ ]
+ alone = [
+ _inspect(source, max_visits=budget, output_node_id=anchor)
+ for anchor in ("9", "43")
+ ]
+ self.assertEqual(
+ states, [(item["state"], item["reason_code"]) for item in alone]
+ )
+
+
+REPO = Path(__file__).resolve().parents[1]
+SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
+
+
+class TestSemanticsIsolation(unittest.TestCase):
+ """Only inspection reads the semantics table; a bad table never breaks ingestion."""
+
+ def resolve_with_table(self, text: str, *values):
+ # Windows: the table is written and closed before it is read or removed.
+ with tempfile.TemporaryDirectory() as root:
+ path = Path(root) / "semantics.json"
+ path.write_text(text, encoding="utf-8")
+ inspection_semantics.get_semantics.cache_clear()
+ try:
+ with (
+ mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
+ mock.patch.object(resolver, "_LOGGER") as logger,
+ ):
+ self.logged = logger.error.call_args_list
+ return [
+ resolve_prompt_pair(value, profile=profile)
+ for value in values
+ for profile in (
+ resolver.DEFAULT_PROFILE,
+ resolver.INSPECTION_PROFILE,
+ )
+ ]
+ finally:
+ inspection_semantics.get_semantics.cache_clear()
+
+ def test_stage_shaped_inputs_come_from_the_table(self):
+ # Review I-2: the stage-walk input lists live in the semantics table, so
+ # a key added there changes the inspection walk and never the default one.
+ source = _latent_from(None)
+ source.update(
+ {
+ "30": {"class_type": "MysteryEmbeds", "inputs": {}},
+ "31": {"class_type": "MysteryStage", "inputs": {"stage_x": ["30", 0]}},
+ "32": {
+ "class_type": "VAEDecode",
+ "inputs": {"samples": ["31", 0], "vae": ["4", 2]},
+ },
+ "23": {
+ "class_type": "VAEEncode",
+ "inputs": {"pixels": ["32", 0], "vae": ["4", 2]},
+ },
+ }
+ )
+ source["3"]["inputs"]["latent_image"] = ["23", 0]
+ table = json.loads(
+ (REPO / "web" / "js" / "inspection-semantics.json").read_text("utf-8")
+ )
+ plain_default, plain = self.resolve_with_table(json.dumps(table), source)
+ table["stage_inputs"]["shaped"].append("stage_x")
+ default, inspected = self.resolve_with_table(json.dumps(table), source)
+ self.assertEqual(plain["state"], "resolved")
+ self.assertEqual(inspected["state"], "unresolved")
+ self.assertEqual(inspected["reason_code"], "unsupported-node")
+ self.assertEqual(inspected["node"], {"id": "31", "class_type": "MysteryStage"})
+ self.assertEqual(default, plain_default)
+
+ def test_corrupt_table_leaves_default_resolution_working(self):
+ for text in ("{not json", "[" * 100_000, "{}"):
+ with self.subTest(text=text[:10]):
+ default, inspected = self.resolve_with_table(text, graph())
+ self.assertEqual(default["state"], "resolved")
+ self.assertEqual(default["positive"]["text"], "portrait")
+ self.assertEqual(inspected["state"], "unresolved")
+ self.assertEqual(inspected["reason"], SEMANTICS_UNAVAILABLE)
+ self.assertIsNone(inspected["reason_code"])
+ self.assertIsNone(inspected["positive"])
+ self.assertEqual(len(self.logged), 1)
+
+ def test_corrupt_table_only_affects_graph_inspection(self):
+ parameters = {"parameters": "cat\nNegative prompt: dog\nSteps: 1, Sampler: x"}
+ results = self.resolve_with_table("{not json", parameters, {})
+ self.assertEqual([item["state"] for item in results[:2]], ["resolved"] * 2)
+ self.assertEqual([item["state"] for item in results[2:]], ["absent"] * 2)
+ self.assertEqual(self.logged, [])
+
+ def test_corrupt_table_is_a_clear_inspector_outcome(self):
+ from utils.prompt_inspector import inspect_prompt_evidence
+
+ with tempfile.TemporaryDirectory() as root:
+ path = Path(root) / "semantics.json"
+ path.write_text("{not json", encoding="utf-8")
+ inspection_semantics.get_semantics.cache_clear()
+ try:
+ with (
+ mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
+ self.assertLogs("prompt_manager.prompt_pair_resolver", "ERROR"),
+ ):
+ evidence = inspect_prompt_evidence({"prompt": graph()})
+ finally:
+ inspection_semantics.get_semantics.cache_clear()
+ self.assertEqual(evidence["resolution"]["state"], "unresolved")
+ self.assertEqual(evidence["resolution"]["reason"], SEMANTICS_UNAVAILABLE)
+ self.assertEqual(
+ [evidence["prompts"][role]["status"] for role in ("positive", "negative")],
+ ["unresolved", "unresolved"],
+ )
+
+ def test_default_resolution_never_imports_the_semantics_table(self):
+ code = (
+ "import sys\n"
+ "from utils.prompt_pair_resolver import resolve_prompt_pair\n"
+ "graph = {'1': {'class_type': 'CLIPTextEncode', 'inputs': {'text': 'a'}},"
+ " '3': {'class_type': 'KSampler',"
+ " 'inputs': {'positive': ['1', 0], 'negative': ['1', 0]}}}\n"
+ "assert resolve_prompt_pair(graph)['state'] == 'resolved'\n"
+ "assert 'utils.inspection_semantics' not in sys.modules\n"
+ )
+ # Fixed interpreter and literal code, no shell.
+ completed = subprocess.run( # nosec B603
+ [sys.executable, "-c", code],
+ cwd=REPO,
+ capture_output=True,
+ timeout=60,
+ check=False,
+ )
+ self.assertEqual(completed.returncode, 0, completed.stderr.decode("utf-8"))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index 71e23ada..25d68a5f 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -19,7 +19,7 @@
},
"comfy-metadata.js": {
"lines": 97,
- "branches": 91,
+ "branches": 92,
"functions": 98,
"statements": 97
},
diff --git a/utils/inspection_outcomes.py b/utils/inspection_outcomes.py
new file mode 100644
index 00000000..295daf1c
--- /dev/null
+++ b/utils/inspection_outcomes.py
@@ -0,0 +1,111 @@
+"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary.
+
+The inspector shows one line per unresolved role in place of the generic
+"Prompt could not be resolved." Ambiguous and absent roles keep their status
+copy, so a candidate is never presented as a confirmed prompt. The wire
+projection accepts exactly the closed ``reason_code`` vocabulary and names a
+blocking node only beside a code that names one.
+"""
+
+from typing import Any, Optional
+
+from .prompt_pair_resolver import (
+ EMPTY_POSITIVE_REASON,
+ MULTISTAGE_UNATTRIBUTED_REASON,
+ SEMANTICS_UNAVAILABLE_REASON,
+)
+
+REASON_CODES = frozenset(
+ {
+ "work-limit",
+ "depth-limit",
+ "text-limit",
+ "graph-ceiling",
+ "unsupported-node",
+ "runtime-value",
+ "composite",
+ "no-sampler",
+ "malformed",
+ "multistage-differs",
+ }
+)
+LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"})
+# Codes whose outcome names the blocking node: ``node: {id, class_type}``.
+NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"})
+# Bound for a node ID or class name on the wire and in a line.
+CLASS_NAME_LENGTH = 200
+# Bound for a role's line on the wire, in UTF-16 code units (the client's count).
+MESSAGE_LENGTH = 512
+UNREADABLE_METADATA_REASON = "Media metadata could not be read"
+_NODE_KEYS = frozenset({"id", "class_type"})
+
+_NAMED_LINES = {
+ "unsupported-node": "Prompt uses a node the inspector does not support",
+ "runtime-value": "Prompt is chosen at run time",
+ "composite": "Prompt combines several texts",
+}
+_CODED_LINES = {
+ **dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."),
+ "no-sampler": "No supported sampler was found.",
+ "malformed": "Prompt metadata in this file is malformed.",
+ "multistage-differs": "Sampler stages have different prompts.",
+}
+# Uncoded outcomes, keyed by the resolver's exact reason.
+_UNCODED_LINES = {
+ EMPTY_POSITIVE_REASON: "Positive prompt is empty.",
+ MULTISTAGE_UNATTRIBUTED_REASON: (
+ "Several sampler stages were found; their prompts were not compared."
+ ),
+ SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.",
+ UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.",
+}
+
+
+def _text(value: Any) -> Optional[str]:
+ return value if isinstance(value, str) else None
+
+
+def _class_name(node: Any) -> Optional[str]:
+ if not isinstance(node, dict):
+ return None
+ name = node.get("class_type")
+ return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None
+
+
+def outcome_message(resolution: Any) -> Optional[str]:
+ """One line for an unresolved outcome, or ``None`` to keep the status copy."""
+ if not isinstance(resolution, dict) or resolution.get("state") != "unresolved":
+ return None
+ code = _text(resolution.get("reason_code"))
+ if code in _NAMED_LINES:
+ name = _class_name(resolution.get("node"))
+ return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}."
+ if code is None:
+ return _UNCODED_LINES.get(_text(resolution.get("reason")))
+ return _CODED_LINES.get(code)
+
+
+def _node(code: Optional[str], node: Any) -> Optional[dict]:
+ if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS:
+ return None
+ node_id, name = node["id"], node["class_type"]
+ if not isinstance(node_id, str) or not node_id:
+ return None
+ if name is not None and not isinstance(name, str):
+ return None
+ return {
+ "id": node_id[:CLASS_NAME_LENGTH],
+ "class_type": name[:CLASS_NAME_LENGTH] if name else None,
+ }
+
+
+def project_resolution(resolution: dict) -> dict:
+ """Fail closed: an unknown or contradictory code and its node are dropped."""
+ code = _text(resolution.get("reason_code"))
+ if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"):
+ code = None
+ return {
+ **resolution,
+ "reason_code": code,
+ "node": _node(code, resolution.get("node")),
+ }
diff --git a/utils/inspection_response.py b/utils/inspection_response.py
index b8a46697..02c37d3a 100644
--- a/utils/inspection_response.py
+++ b/utils/inspection_response.py
@@ -3,7 +3,9 @@
import json
import math
from decimal import Decimal, InvalidOperation
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Any, Literal, Optional
+
+from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
if TYPE_CHECKING:
from .prompt_inspector import InspectionRead
@@ -312,6 +314,29 @@ def _library_projection(library: dict) -> dict:
return result
+def _bounded_message(value: Any) -> Optional[str]:
+ """A role line as the client accepts it: 1 to 512 UTF-16 code units."""
+ if not isinstance(value, str) or not value:
+ return None
+ value = value[:MESSAGE_LENGTH]
+ while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
+ value = value[:-1]
+ return value
+
+
+def _bounded_messages(prompts: dict) -> dict:
+ """Keep a role ``message`` only as bounded text on a non-resolved role."""
+ bounded = {}
+ for role, side in prompts.items():
+ if isinstance(side, dict) and "message" in side:
+ side = {key: item for key, item in side.items() if key != "message"}
+ message = _bounded_message(prompts[role]["message"])
+ if message is not None and side.get("status") != "resolved":
+ side["message"] = message
+ bounded[role] = side
+ return bounded
+
+
def _encode_inspection(
*,
identity: dict,
@@ -328,8 +353,8 @@ def _encode_inspection(
"inspection_version": 1,
**identity,
"file": _file_projection(file, inspection),
- "prompts": prompts,
- "resolution": resolution,
+ "prompts": _bounded_messages(prompts),
+ "resolution": project_resolution(resolution),
"metadata": metadata,
"library": _library_projection(library),
}
diff --git a/utils/inspection_semantics.py b/utils/inspection_semantics.py
new file mode 100644
index 00000000..802d2f14
--- /dev/null
+++ b/utils/inspection_semantics.py
@@ -0,0 +1,492 @@
+"""Load and validate the declarative inspection node semantics table (#240).
+
+The table lives once in ``web/js/inspection-semantics.json`` so a browser-local
+resolver can load the same data. It maps node classes (or ``(class, output
+slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
+exact input names that strategy reads. Code implements each strategy; adding a
+node that reuses one is a data-only change.
+
+Only the inspection resolver profile consumes this table, through
+``get_semantics()``, which reads and validates it on first use and caches only a
+valid result. Importing this module reads nothing, and the default (ingestion)
+profile never imports it, so a missing or bad table can never break ingestion.
+An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
+silently changing what the inspector resolves. The file is bounded in size and
+nesting before it is validated. Every class appears in exactly one category;
+classes that are not listed stay ``unsupported-node``.
+"""
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from types import MappingProxyType
+from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
+
+SEMANTICS_PATH = (
+ Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
+)
+SCHEMA_VERSION = 1
+# The real table is a few KiB and four levels deep; these only reject nonsense.
+MAX_TABLE_BYTES = 1024 * 1024
+MAX_NESTING = 8
+_NAME_LENGTH = 128
+# Comfy output slots are small; this only rejects nonsense, never a real node.
+_MAX_SLOT = 255
+
+STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
+ {
+ "samplers": frozenset({"ksampler", "custom-advanced", "detailer-stage"}),
+ "guiders": frozenset({"cfg", "basic"}),
+ "conditioning": frozenset({"passthrough", "zeroed"}),
+ "selectors": frozenset({"boolean-select"}),
+ "composites": frozenset({"combine"}),
+ "encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
+ "strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
+ "savers": frozenset({"comfy-counter", "vhs-counter"}),
+ }
+)
+# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
+FACT_SHAPES = frozenset({"ksampler", "custom", "custom-advanced"})
+SAVER_PATTERNS = MappingProxyType(
+ {
+ "comfy-counter": "{name}_{counter:05}_.{ext}",
+ "vhs-counter": "{name}_{counter:05}.{ext}",
+ }
+)
+_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"})
+_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
+_SCALAR_KINDS = frozenset({"number", "string"})
+_TYPE_FIELDS = frozenset({"type_fields"})
+_CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers")
+_SLOT_CATEGORIES = ("conditioning", "encoders", "strings")
+_SET_CATEGORIES = ("runtime", "non_file_outputs")
+_FACTS_TABLES = (
+ "configuration_outputs",
+ "model_outputs",
+ "model_passthroughs",
+ "scalar_outputs",
+)
+_CATEGORIES = frozenset(
+ ("schema",)
+ + _CLASS_CATEGORIES
+ + _SLOT_CATEGORIES
+ + _SET_CATEGORIES
+ + ("stage_inputs", "facts")
+)
+# Input names that make an unlisted node a possible sampler stage in an ancestry
+# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
+# without a ``positive`` link), and those that do so beside a linked ``model``
+# (``with_model``: XlabsSampler's ``conditioning``, video samplers' ``samples``).
+# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
+# The resolver and comfy-metadata.js both read these lists.
+_STAGE_INPUT_LISTS = ("shaped", "with_model")
+_STRING_PARAMS = MappingProxyType(
+ {
+ "literal": frozenset(),
+ "concat": frozenset(),
+ "strip-newlines": frozenset({"flag"}),
+ "join-multi": frozenset({"count", "delimiter", "list_flag", "input_prefix"}),
+ }
+)
+
+Frozen = Union[Mapping[str, Any], Tuple[Any, ...], str, int, None]
+
+
+class SemanticsError(ValueError):
+ """The semantics table is unreadable or violates its schema."""
+
+
+@dataclass(frozen=True)
+class Semantics:
+ """Validated, deeply frozen semantics. Slot categories key by ``(class, slot)``."""
+
+ samplers: Mapping[str, Mapping[str, Any]]
+ guiders: Mapping[str, Mapping[str, Any]]
+ conditioning: Mapping[Tuple[str, int], Mapping[str, Any]]
+ selectors: Mapping[str, Mapping[str, Any]]
+ composites: Mapping[str, Mapping[str, Any]]
+ encoders: Mapping[Tuple[str, int], Mapping[str, Any]]
+ strings: Mapping[Tuple[str, int], Mapping[str, Any]]
+ runtime: FrozenSet[str]
+ savers: Mapping[str, Mapping[str, Any]]
+ non_file_outputs: FrozenSet[str]
+ stage_inputs: Mapping[str, Tuple[str, ...]]
+ facts: Mapping[str, Any]
+ all_classes: FrozenSet[str]
+
+
+def _freeze(value: Any) -> Frozen:
+ if isinstance(value, dict):
+ return MappingProxyType({key: _freeze(item) for key, item in value.items()})
+ if isinstance(value, list):
+ return tuple(_freeze(item) for item in value)
+ return value
+
+
+def _reject_duplicate_keys(pairs: list) -> dict:
+ result: Dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise SemanticsError(f"duplicate key {key!r}")
+ result[key] = value
+ return result
+
+
+def _reject_constant(name: str) -> None:
+ raise SemanticsError(f"non-finite number {name} is not allowed")
+
+
+def _name(value: Any, where: str, what: str = "input") -> str:
+ if not isinstance(value, str) or not value or len(value) > _NAME_LENGTH:
+ raise SemanticsError(f"{where}: missing or invalid {what} name {value!r}")
+ return value
+
+
+def _names(value: Any, where: str, minimum: int = 1) -> None:
+ if not isinstance(value, list) or len(value) < minimum:
+ raise SemanticsError(f"{where}: needs at least {minimum} input name(s)")
+ for item in value:
+ _name(item, where)
+ if len(set(value)) != len(value):
+ raise SemanticsError(f"{where}: repeated input name")
+
+
+def _fields(
+ entry: Any, where: str, required: FrozenSet[str], optional: FrozenSet[str]
+) -> None:
+ if not isinstance(entry, dict):
+ raise SemanticsError(f"{where}: entry must be an object")
+ missing = sorted(required - entry.keys())
+ if missing:
+ raise SemanticsError(f"{where}: missing input field(s) {missing}")
+ unknown = sorted(entry.keys() - required - optional)
+ if unknown:
+ raise SemanticsError(f"{where}: unknown field(s) {unknown}")
+
+
+def _strategy(entry: Any, category: str, where: str) -> str:
+ strategy = entry.get("strategy") if isinstance(entry, dict) else None
+ if not isinstance(strategy, str) or strategy not in STRATEGIES[category]:
+ raise SemanticsError(f"{where}: unknown strategy {strategy!r}")
+ return strategy
+
+
+def _check_sampler(entry: dict, strategy: str, where: str) -> None:
+ stage = "image_stage" if strategy == "detailer-stage" else "latent"
+ _fields(
+ entry,
+ where,
+ frozenset({"strategy", "conditioning", stage, "facts"}),
+ _SAMPLER_OPTIONS,
+ )
+ _name(entry[stage], where)
+ # Inputs that, when set, replace the prompt the sampler's conditioning
+ # inputs carry (a detailer's wildcard); the prompt is then not read.
+ # ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set,
+ # replace the settings the facts would read (a script or an XY plot).
+ for option in sorted(_SAMPLER_OPTIONS & entry.keys()):
+ _names(entry[option], f"{where}.{option}")
+ keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
+ conditioning = entry["conditioning"]
+ _fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
+ for key in keys:
+ _name(conditioning[key], where + ".conditioning")
+ if entry["facts"] not in FACT_SHAPES:
+ raise SemanticsError(f"{where}: unknown facts shape {entry['facts']!r}")
+
+
+def _check_guider(entry: dict, strategy: str, where: str) -> None:
+ if strategy == "cfg":
+ _fields(
+ entry, where, frozenset({"strategy", "positive", "negative"}), frozenset()
+ )
+ _name(entry["negative"], where)
+ else:
+ # Negative absent by design; its reason is fixed text, not an input.
+ _fields(
+ entry,
+ where,
+ frozenset({"strategy", "positive"}),
+ frozenset({"absent_reason"}),
+ )
+ if "absent_reason" in entry:
+ _name(entry["absent_reason"], where, "absent reason")
+ _name(entry["positive"], where)
+
+
+def _check_selector(entry: dict, strategy: str, where: str) -> None:
+ _fields(entry, where, frozenset({"strategy", "condition", "branches"}), frozenset())
+ _name(entry["condition"], where)
+ branches = entry["branches"]
+ _fields(branches, where + ".branches", frozenset({"true", "false"}), frozenset())
+ _name(branches["true"], where)
+ _name(branches["false"], where)
+
+
+def _check_composite(entry: dict, strategy: str, where: str) -> None:
+ _fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
+ _names(entry["inputs"], where, minimum=2)
+
+
+def _check_saver(entry: dict, strategy: str, where: str) -> None:
+ _fields(
+ entry,
+ where,
+ frozenset({"strategy", "prefix_input", "image_input", "pattern"}),
+ frozenset(),
+ )
+ _name(entry["prefix_input"], where)
+ _name(entry["image_input"], where)
+ if entry["pattern"] != SAVER_PATTERNS[strategy]:
+ raise SemanticsError(f"{where}: pattern does not match {strategy}")
+
+
+def _check_conditioning(entry: dict, strategy: str, where: str) -> None:
+ _fields(entry, where, frozenset({"strategy", "input"}), frozenset())
+ _name(entry["input"], where)
+
+
+def _check_encoder(entry: dict, strategy: str, where: str) -> None:
+ if strategy == "flag-parts":
+ # A literal boolean ``flag`` false reads the first input; true needs
+ # every input equal (smZ CLIPTextEncode's with_SDXL).
+ _fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
+ params = entry["params"]
+ if not isinstance(params, dict) or set(params) != {"flag"}:
+ raise SemanticsError(f"{where}: params must be exactly ['flag']")
+ _name(params["flag"], where + ".params")
+ _names(entry["inputs"], where, minimum=2)
+ return
+ _fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"}))
+ _names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1)
+ if "compose" in entry and not isinstance(entry["compose"], bool):
+ raise SemanticsError(f"{where}: compose must be a boolean")
+
+
+def _check_string(entry: dict, strategy: str, where: str) -> None:
+ required = _STRING_PARAMS[strategy]
+ if required:
+ _fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
+ params = entry["params"]
+ if not isinstance(params, dict) or set(params) != required:
+ raise SemanticsError(f"{where}: params must be exactly {sorted(required)}")
+ for value in params.values():
+ _name(value, where + ".params")
+ else:
+ _fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
+ _names(entry["inputs"], where)
+
+
+_CHECKS: Mapping[str, Callable[[dict, str, str], None]] = MappingProxyType(
+ {
+ "samplers": _check_sampler,
+ "guiders": _check_guider,
+ "selectors": _check_selector,
+ "composites": _check_composite,
+ "savers": _check_saver,
+ "conditioning": _check_conditioning,
+ "encoders": _check_encoder,
+ "strings": _check_string,
+ }
+)
+
+
+class _Owners:
+ """Each class belongs to one category; each (class, slot) is listed once."""
+
+ def __init__(self) -> None:
+ self._category: Dict[str, str] = {}
+ self._outputs: set = set()
+
+ def claim(self, name: str, category: str, slot: Optional[int] = None) -> None:
+ owner = self._category.setdefault(name, category)
+ if owner != category:
+ raise SemanticsError(f"duplicate class {name!r} in {owner} and {category}")
+ key = (name, slot)
+ if slot is not None and key in self._outputs:
+ raise SemanticsError(f"{category}: duplicate output key {key!r}")
+ self._outputs.add(key)
+
+ def classes(self) -> FrozenSet[str]:
+ return frozenset(self._category)
+
+
+def _class_category(doc: dict, category: str, owners: _Owners) -> Mapping:
+ table = doc[category]
+ if not isinstance(table, dict):
+ raise SemanticsError(f"category {category} must be an object")
+ result = {}
+ for name, entry in table.items():
+ where = f"{category}[{name!r}]"
+ _name(name, where, "class")
+ strategy = _strategy(entry, category, where)
+ _CHECKS[category](entry, strategy, where)
+ owners.claim(name, category)
+ result[name] = _freeze(entry)
+ return MappingProxyType(result)
+
+
+def _slot(entry: dict, where: str) -> int:
+ slot = entry.get("slot")
+ # bool is an int subclass; a JSON true is not an output slot.
+ if type(slot) is not int or not 0 <= slot <= _MAX_SLOT:
+ raise SemanticsError(f"{where}: slot must be a non-negative int, not {slot!r}")
+ return slot
+
+
+def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
+ rows = doc[category]
+ if not isinstance(rows, list):
+ raise SemanticsError(f"category {category} must be a list")
+ result = {}
+ for index, row in enumerate(rows):
+ where = f"{category}[{index}]"
+ if not isinstance(row, dict):
+ raise SemanticsError(f"{where}: entry must be an object")
+ name = _name(row.get("class"), where, "class")
+ slot = _slot(row, where)
+ entry = {
+ key: value for key, value in row.items() if key not in ("class", "slot")
+ }
+ strategy = _strategy(entry, category, where)
+ _CHECKS[category](entry, strategy, where)
+ owners.claim(name, category, slot)
+ result[(name, slot)] = _freeze(entry)
+ return MappingProxyType(result)
+
+
+def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
+ rows = doc[category]
+ if not isinstance(rows, list):
+ raise SemanticsError(f"category {category} must be a list")
+ seen: set = set()
+ for name in rows:
+ _name(name, category, "class")
+ if name in seen:
+ raise SemanticsError(f"{category}: duplicate class {name!r}")
+ seen.add(name)
+ owners.claim(name, category)
+ return frozenset(seen)
+
+
+def _string_map(table: Any, where: str, allowed: Optional[FrozenSet[str]]) -> None:
+ if not isinstance(table, dict):
+ raise SemanticsError(f"{where} must be an object")
+ for name, value in table.items():
+ _name(name, where, "class")
+ _name(value, where)
+ if allowed is not None and value not in allowed:
+ raise SemanticsError(f"{where}[{name!r}]: unknown kind {value!r}")
+
+
+def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
+ table = doc["stage_inputs"]
+ if not isinstance(table, dict) or set(table) != set(_STAGE_INPUT_LISTS):
+ raise SemanticsError(
+ f"stage_inputs must have exactly {list(_STAGE_INPUT_LISTS)}"
+ )
+ for key in _STAGE_INPUT_LISTS:
+ _names(table[key], f"stage_inputs.{key}")
+ if "model" in table[key]:
+ raise SemanticsError(f"stage_inputs.{key}: model is a patch input")
+ if set(table["shaped"]) & set(table["with_model"]):
+ raise SemanticsError("stage_inputs: an input is listed in both lists")
+ return _freeze(table)
+
+
+def _facts(doc: dict) -> Mapping[str, Any]:
+ facts = doc["facts"]
+ if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
+ raise SemanticsError(f"facts must have exactly {list(_FACTS_TABLES)}")
+ configuration = facts["configuration_outputs"]
+ if not isinstance(configuration, dict):
+ raise SemanticsError("facts.configuration_outputs must be an object")
+ for name, adapter in configuration.items():
+ where = f"facts.configuration_outputs[{name!r}]"
+ _name(name, where, "class")
+ _fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS)
+ if adapter["kind"] not in _CONFIGURATION_KINDS:
+ raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}")
+ _names(adapter["fields"], where)
+ if "type_fields" in adapter:
+ # A fact whose value is the provider's node type (D8: Flux2Scheduler).
+ _names(adapter["type_fields"], where)
+ if set(adapter["type_fields"]) & set(adapter["fields"]):
+ raise SemanticsError(f"{where}: type_fields repeat an input field")
+ _string_map(facts["model_outputs"], "facts.model_outputs", None)
+ _string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS)
+ passthroughs = facts["model_passthroughs"]
+ _names(passthroughs, "facts.model_passthroughs", minimum=0)
+ return _freeze(facts)
+
+
+def _check_nesting(doc: Any) -> None:
+ """Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
+ stack = [(doc, 1)]
+ while stack:
+ value, level = stack.pop()
+ if not isinstance(value, (dict, list)):
+ continue
+ if level > MAX_NESTING:
+ raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
+ items = value.values() if isinstance(value, dict) else value
+ stack.extend((item, level + 1) for item in items)
+
+
+def _validate(doc: Any) -> Semantics:
+ if not isinstance(doc, dict):
+ raise SemanticsError("semantics table must be an object")
+ if set(doc) != _CATEGORIES:
+ missing = sorted(_CATEGORIES - doc.keys())
+ unknown = sorted(doc.keys() - _CATEGORIES)
+ raise SemanticsError(f"category mismatch: missing {missing}, unknown {unknown}")
+ if type(doc["schema"]) is not int or doc["schema"] != SCHEMA_VERSION:
+ raise SemanticsError(f"unsupported schema {doc['schema']!r}")
+ owners = _Owners()
+ tables: Dict[str, Any] = {}
+ for category in _CLASS_CATEGORIES:
+ tables[category] = _class_category(doc, category, owners)
+ for category in _SLOT_CATEGORIES:
+ tables[category] = _slot_category(doc, category, owners)
+ for category in _SET_CATEGORIES:
+ tables[category] = _set_category(doc, category, owners)
+ return Semantics(
+ stage_inputs=_stage_inputs(doc),
+ facts=_facts(doc),
+ all_classes=owners.classes(),
+ **tables,
+ )
+
+
+def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
+ """Read and validate a semantics table; ``SemanticsError`` on any problem."""
+ source = Path(path) if path is not None else SEMANTICS_PATH
+ try:
+ with source.open("rb") as stream:
+ data = stream.read(MAX_TABLE_BYTES + 1)
+ if len(data) > MAX_TABLE_BYTES:
+ raise SemanticsError(
+ f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
+ )
+ doc = json.loads(
+ data.decode("utf-8"),
+ object_pairs_hook=_reject_duplicate_keys,
+ parse_constant=_reject_constant,
+ )
+ except SemanticsError:
+ raise
+ except RecursionError:
+ raise SemanticsError(
+ f"semantics table {source.name} nests deeper than {MAX_NESTING}"
+ ) from None
+ except (OSError, UnicodeDecodeError, ValueError) as error:
+ raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
+ _check_nesting(doc)
+ return _validate(doc)
+
+
+@lru_cache(maxsize=1)
+def get_semantics() -> Semantics:
+ """The repository table, loaded on first use; failures are not cached."""
+ return load_semantics()
diff --git a/utils/prompt_inspector.py b/utils/prompt_inspector.py
index f38aa9a5..62b95157 100644
--- a/utils/prompt_inspector.py
+++ b/utils/prompt_inspector.py
@@ -3,6 +3,7 @@
import hashlib
import io
import json
+import re
import struct
import subprocess # nosec B404
import threading
@@ -13,8 +14,11 @@ from typing import Any
from PIL import Image
+from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message
from .pair_execution import EXECUTION_KEY
from .prompt_pair_resolver import (
+ _FOOTER,
+ EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
ResolverProfile,
resolve_prompt_pair,
@@ -39,6 +43,23 @@ MAX_PNG_CHUNKS = 10_000
_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"}
_TEXT_CHUNKS = (b"tEXt", b"zTXt", b"iTXt")
+# An accumulating batch saver writes one A1111 ``parameters`` record per batch
+# item, identical except this field (R3); Pillow keeps the last one.
+# Matched only inside the record's one ``Steps: N, ...`` settings footer line.
+_BATCH_INDEX = re.compile(r"(?:^|(?<=, ))Batch index: \d+(?=, |$)")
+
+
+def _batch_normalized(text: str) -> str | None:
+ """``text`` with its footer's single batch index blanked, else ``None``."""
+ footers = list(_FOOTER.finditer(text))
+ if len(footers) != 1:
+ return None
+ line, count = _BATCH_INDEX.subn("Batch index: #", footers[0].group())
+ if count != 1:
+ return None
+ return text[: footers[0].start()] + line + text[footers[0].end() :]
+
+
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -121,13 +142,14 @@ class _PngRecords:
self.digests: dict[str, tuple[int, bytes]] = {}
self.role_encoded = self.role_decoded = 0
self.duplicate_workflow = self.duplicate_role = 0
+ self.duplicate_decoded = 0
def skip(self, key: str, length: int) -> bool:
"""Decide from the keyword and length alone whether to seek past a chunk."""
if key not in _METADATA_KEYS or key in self.limited:
return True # never retained, or a duplicate of a limited workflow
if key in self.digests:
- if self.digests[key][0] != length:
+ if self.digests[key][0] != length and key != "parameters":
raise MetadataReadError("Duplicate prompt metadata")
if key == "workflow":
if self.duplicate_workflow + length > DUPLICATE_WORKFLOW_BYTES:
@@ -155,7 +177,11 @@ class _PngRecords:
digest = hashlib.sha256(kind + data).digest()
if key in self.digests:
if self.digests[key][1] != digest:
- raise MetadataReadError("Duplicate prompt metadata")
+ if key != "parameters":
+ raise MetadataReadError("Duplicate prompt metadata")
+ self.batch_copy(kind, data)
+ # The kept copy is the last one; later repeats compare with it.
+ self.digests[key] = (len(data), digest)
return
self.digests[key] = (len(data), digest)
workflow = key == "workflow"
@@ -176,6 +202,27 @@ class _PngRecords:
raise MetadataReadError("Metadata exceeds inspection limits")
self.found[key] = value
+ def batch_copy(self, kind: bytes, data: bytes) -> None:
+ """Keep a later ``parameters`` copy that differs only in its batch index.
+
+ Each copy must carry exactly one ``Batch index: N`` settings field; any
+ other difference is still a conflict. Decoding every copy draws from the
+ duplicate role budget, so small compressed copies stay bounded.
+ """
+ _, value = _png_text(kind, data, ROLE_METADATA_BYTES)
+ size = ROLE_METADATA_BYTES + 1 if value is None else len(value.encode())
+ self.duplicate_decoded += size
+ if value is None or self.duplicate_decoded > DUPLICATE_ROLE_BYTES:
+ raise MetadataReadError("Metadata exceeds inspection limits")
+ kept = self.found["parameters"]
+ normalized = _batch_normalized(kept)
+ if normalized is None or normalized != _batch_normalized(value):
+ raise MetadataReadError("Duplicate prompt metadata")
+ self.role_decoded += max(0, size - len(kept.encode()))
+ if self.role_decoded > ROLE_METADATA_BYTES:
+ raise MetadataReadError("Metadata exceeds inspection limits")
+ self.found["parameters"] = value
+
def _png_metadata(stream: Any, size: int) -> tuple[dict, frozenset, int, int]:
"""CRC-check retained text and structural chunks, seeking past everything else.
@@ -400,6 +447,70 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di
return {"status": status, "text": text, "source": source}
+_EMPTY_POSITIVE = outcome_message(
+ {"state": "unresolved", "reason": EMPTY_POSITIVE_REASON}
+)
+
+
+def _explained(prompts: dict, resolution: dict) -> dict:
+ """Each unresolved role carries the outcome's line; other roles are kept."""
+ message = outcome_message(resolution)
+ if message is None:
+ return prompts
+ return {
+ role: (
+ {**side, "message": message}
+ if side["status"] == "unresolved" and "message" not in side
+ else side
+ )
+ for role, side in prompts.items()
+ }
+
+
+def unreadable_evidence() -> dict:
+ """Both roles when the file's metadata could not be read at all."""
+ resolution = {
+ "state": "unresolved",
+ "sampler_id": None,
+ "reason": UNREADABLE_METADATA_REASON,
+ "reason_code": None,
+ "node": None,
+ }
+ prompts = {role: _side("unresolved") for role in ("positive", "negative")}
+ return {"prompts": _explained(prompts, resolution), "resolution": resolution}
+
+
+def _may_hold_prompt_manager(metadata: dict) -> bool:
+ """Whether the API graph can show the R1 signature, so the workflow is needed.
+
+ PromptManager classes, or a ``CLIPTextEncode`` carrying PromptManager's own
+ ``search_text`` input (an older save rewrote the class).
+ """
+ graph = metadata.get("prompt")
+ if not isinstance(graph, dict):
+ return False
+ for node in graph.values():
+ if not isinstance(node, dict):
+ continue
+ name = node.get("class_type")
+ inputs = node.get("inputs")
+ if isinstance(name, str) and "promptmanager" in name.lower():
+ return True
+ if isinstance(inputs, dict) and "search_text" in inputs:
+ return True
+ return False
+
+
+def _parsed_workflow(value: Any) -> dict | None:
+ """The UI workflow as an object, or ``None`` (absent, invalid or not JSON)."""
+ if isinstance(value, str):
+ try:
+ value = json.loads(value)
+ except (ValueError, RecursionError):
+ return None
+ return value if isinstance(value, dict) else None
+
+
def inspect_prompt_evidence(
metadata: dict,
saved: dict | None = None,
@@ -413,6 +524,7 @@ def inspect_prompt_evidence(
directly with its default profile. ``resolution`` carries the closed
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
"""
+ workflow = metadata.get("workflow")
metadata = {
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
}
@@ -447,21 +559,26 @@ def inspect_prompt_evidence(
},
}
canonical = dict(metadata)
+ malformed = {
+ "prompts": _explained(
+ {role: _side("unresolved") for role in ("positive", "negative")},
+ resolution,
+ ),
+ "resolution": resolution,
+ }
if "prompt" not in canonical and "parameters" not in canonical:
- return {
- "prompts": {role: _side("unresolved") for role in ("positive", "negative")},
- "resolution": resolution,
- }
+ return malformed
if isinstance(canonical.get("prompt"), str):
try:
canonical["prompt"] = json.loads(canonical["prompt"])
except (ValueError, RecursionError):
- return {
- "prompts": {
- role: _side("unresolved") for role in ("positive", "negative")
- },
- "resolution": resolution,
- }
+ return malformed
+ workflow = (
+ _parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
+ )
+ if workflow is not None and "prompt" in canonical:
+ # Only to recognize overwritten PromptManager text (R1); never a role.
+ canonical["workflow"] = workflow
resolved = resolve_prompt_pair(canonical, profile=profile)
state = resolved["state"]
result = {}
@@ -475,18 +592,19 @@ def inspect_prompt_evidence(
result[role] = _side(
"resolved", side["text"], side["provenance"]["text_source"]
)
+ elif state == "resolved" and isinstance(side, dict):
+ # A resolved pair withholds only an empty positive.
+ result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE}
else:
result[role] = _side(state if state != "resolved" else "unresolved")
- return {
- "prompts": result,
- "resolution": {
- "state": state,
- "sampler_id": resolved.get("sampler_id"),
- "reason": resolved.get("reason", ""),
- "reason_code": resolved.get("reason_code"),
- "node": resolved.get("node"),
- },
+ resolution = {
+ "state": state,
+ "sampler_id": resolved.get("sampler_id"),
+ "reason": resolved.get("reason", ""),
+ "reason_code": resolved.get("reason_code"),
+ "node": resolved.get("node"),
}
+ return {"prompts": _explained(result, resolution), "resolution": resolution}
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py
index c64a6b89..1e8d4d2a 100644
--- a/utils/prompt_pair_resolver.py
+++ b/utils/prompt_pair_resolver.py
@@ -13,7 +13,8 @@ Future nodes can register declarative ``OutputAdapter`` entries by
Effective text uses a unique bounded execution record for traced paired nodes
when available; cached graph-only results remain explicitly inferred.
The resolver has no
-ComfyUI, database, configuration or filesystem dependencies.
+ComfyUI, database or configuration dependencies; its only file read is the
+inspection profile's node semantics table (``utils.inspection_semantics``).
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
monitor, admin import) keeps the historical behavior exactly, including the
@@ -23,14 +24,20 @@ shared by every candidate and attribution walk, a node ceiling backstop, and
bounds on built provenance and text. It also names why a result is not resolved
(``reason_code``, and ``node`` for the first unsupported node), and a prompt graph
without a supported sampler is ``unresolved`` (``no-sampler``), never ``absent``.
+When the semantics table cannot be loaded, inspection of a prompt graph is
+``unresolved`` with a fixed reason; ingestion never loads the table.
"""
+import logging
import re
+from collections import deque
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping, Optional, Tuple
from .pair_execution import ExecutionEvidenceError, read_pair_execution
+_LOGGER = logging.getLogger("prompt_manager.prompt_pair_resolver")
+
@dataclass(frozen=True)
class OutputAdapter:
@@ -46,6 +53,12 @@ class OutputAdapter:
kind: str
compose: bool = False
storage_input: bool = False
+ # Historical PromptManager classes (inspection only): ``comma`` strips the
+ # text and joins prepend/append with ", " exactly as their code did;
+ # ``strip`` only strips. ``default`` stands in for a missing optional input.
+ comma: bool = False
+ strip: bool = False
+ default: Optional[str] = None
_SAMPLERS = frozenset({"KSampler", "KSamplerAdvanced", "SamplerCustom"})
@@ -69,6 +82,25 @@ for _name in ("PrimitiveString", "PrimitiveStringMultiline"):
_DEFAULT_ADAPTERS[(_name, 0)] = OutputAdapter("value", "string")
for _name in ("Text Multiline", "String Literal", "ShowText|pysssss"):
_DEFAULT_ADAPTERS[(_name, 0)] = OutputAdapter("text", "string")
+# This repository's node classes up to 07a5af3 (``prompt_manager_*.py``), still in
+# saved workflows. Inspection only: the default profile keeps them unsupported.
+_COMMA = OutputAdapter("text", "conditioning", comma=True)
+_NEGATIVE = OutputAdapter("negative_text", "conditioning", strip=True, default="")
+_HISTORICAL_ADAPTERS = {
+ ("PromptManagerV2", 0): _COMMA,
+ ("PromptManagerV2", 1): _NEGATIVE,
+ ("PromptManagerV2", 2): replace(_COMMA, kind="string"),
+ ("PromptManagerV2", 3): replace(_NEGATIVE, kind="string"),
+ ("PromptManagerV2Text", 0): replace(_COMMA, kind="string"),
+ ("PromptManagerV2Text", 1): replace(_NEGATIVE, kind="string"),
+ ("PromptManagerNegative", 0): OutputAdapter("text", "conditioning", strip=True),
+ ("PromptManagerNegative", 1): OutputAdapter("text", "string", strip=True),
+ ("PromptManagerPositiveText", 0): replace(_COMMA, kind="string"),
+ ("PromptManagerNegativeText", 0): replace(_COMMA, kind="string"),
+}
+for _name in ("PromptManagerPositive", "PromptManagerDetailer"):
+ _HISTORICAL_ADAPTERS[(_name, 0)] = _COMMA
+ _HISTORICAL_ADAPTERS[(_name, 1)] = replace(_COMMA, kind="string")
@dataclass(frozen=True)
@@ -80,7 +112,9 @@ class ResolverProfile:
bounds linear work across the whole resolution: input keys validated or
scanned, characters stripped or joined, and provenance entries copied, each
charged before the work is done. ``None`` disables each bound.
- ``semantics`` and ``selection`` are reserved for later slices and stay off.
+ ``semantics`` reads the declarative node table (inspection only; loaded
+ lazily so the default profile never imports it). ``selection`` is reserved
+ for a later slice and stays off.
"""
name: str
@@ -95,6 +129,10 @@ class ResolverProfile:
max_path_entries: Optional[int] = None
max_built_chars: Optional[int] = None
max_scanned_units: Optional[int] = None
+ # Unanchored inspection of a saved file drops stages whose outputs reach
+ # only non-file outputs (PreviewImage and the like): they cannot have
+ # written it. A preview file itself may be such a stage's output.
+ drop_preview_only_stages: bool = False
@property
def legacy(self) -> bool:
@@ -118,12 +156,49 @@ INSPECTION_PROFILE = ResolverProfile(
max_text_length=100_000,
node_ceiling=20_000,
legacy_node_gate=None,
+ semantics=True,
max_path_entries=64 * 20_000,
max_built_chars=16 * 100_000,
# About four passes over the 2 MiB role-record budget.
max_scanned_units=8_000_000,
+ drop_preview_only_stages=True,
)
+# Inspection of a file in the host temp folder (where previews are written).
+PREVIEW_INSPECTION_PROFILE = replace(
+ INSPECTION_PROFILE, name="inspection-preview", drop_preview_only_stages=False
+)
+# Without the semantics table every sampler reads its pair directly.
+_DIRECT_SAMPLER = {
+ "strategy": "ksampler",
+ "conditioning": {"positive": "positive", "negative": "negative"},
+ "latent": "latent_image",
+}
+# Strategies whose nodes are prompt candidates. A detailer stage (D19) re-samples
+# an upstream image: it is judged together with the samplers found in its image
+# ancestry, and a sampler consumed only by detailers is not a candidate alone.
+_CANDIDATE_STRATEGIES = frozenset({"ksampler", "custom-advanced", "detailer-stage"})
+_DETAILER = "detailer-stage"
+_GUIDER_ABSENT_REASON = "Guider has no negative input"
+# ConditioningZeroOut discards its input text: an explicit empty prompt.
+_ZEROED_SOURCE = "zeroed-conditioning"
+# Runtime selector choices expanded per sampler (the spec's candidate cap).
+_MAX_BRANCH_CANDIDATES = 32
+# Statically undecidable text (a search, a list output): never pick an item.
+_RUNTIME_REASON = "Text is decided at run time"
+# Uncoded outcomes the inspector explains by their exact reason.
+EMPTY_POSITIVE_REASON = "Positive prompt is empty"
+MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
+_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
+# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
+# with the last-run node's prompt (comfy-metadata.js ``createContext``).
+OVERWRITTEN_PROMPT_REASON = "Saved PromptManager text was overwritten by another node"
+# Trace outcomes that leave an input unverifiable against execution evidence.
+_UNVERIFIABLE_CODES = frozenset({"unsupported-node", "runtime-value"})
+# JoinStringMulti's own ``inputcount`` range; anything else never executed.
+_MAX_JOIN_INPUTS = 1000
_SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative")
+# The stage-walk input lists (``stage_inputs``) live in the semantics table; the
+# default profile never reads them.
_NO_SAMPLER_NAMES = 3
_CLASS_NAME_LENGTH = 64
@@ -139,6 +214,20 @@ class _Unresolved(Exception):
self.node = node
+class _Diverging(Exception):
+ """A runtime selector whose branches trace to different texts.
+
+ Raised from inside tracing; ``_GraphResolver.branches`` resolves the sampler
+ again once per choice, so each candidate is internally consistent.
+ """
+
+ def __init__(self, key: Tuple[str, int]) -> None:
+ super().__init__(key)
+ # The condition output (node ID, slot): one runtime value, so every
+ # selector it drives is forced together.
+ self.key = key
+
+
def _node_ref(node_id: str, class_type: Any) -> dict:
name = class_type[:_CLASS_NAME_LENGTH] if isinstance(class_type, str) else None
return {"id": node_id[:_CLASS_NAME_LENGTH], "class_type": name}
@@ -188,6 +277,10 @@ def _link(value: Any) -> bool:
)
+def _or_empty(value: Any) -> Any:
+ return "" if value is None else value
+
+
def _result(
state: str,
positive: Optional[dict] = None,
@@ -210,6 +303,19 @@ def _result(
}
+def _role_text(side: Any) -> Optional[str]:
+ return side["text"] if isinstance(side, dict) else None
+
+
+def _outcome_key(result: Dict[str, Any]) -> tuple:
+ """What a reader sees of a result: state and the two role texts."""
+ return (
+ result["state"],
+ _role_text(result["positive"]),
+ _role_text(result["negative"]),
+ )
+
+
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
"""The default profile's wire: today's keys plus ``reason_code: None``."""
legacy = {key: value for key, value in result.items() if key != "node"}
@@ -230,12 +336,17 @@ class _GraphResolver:
*,
budget: Optional[_Budget] = None,
profile: ResolverProfile = DEFAULT_PROFILE,
+ semantics: Optional[Any] = None,
+ overwritten: frozenset = frozenset(),
) -> None:
# ``max_visits`` is the per-candidate allowance; ``profile`` supplies only
# the path selection and whole-resolution bounds. ``resolve_text_input``
# relies on the legacy default profile here.
self.graph = graph
- self.adapters = {**_DEFAULT_ADAPTERS, **adapters}
+ historical = _HISTORICAL_ADAPTERS if semantics is not None else {}
+ self.adapters = {**_DEFAULT_ADAPTERS, **historical, **adapters}
+ # Nodes whose saved text is not their own (inspection; R1).
+ self.overwritten = overwritten
self.per_candidate_visits = max_visits
self.budget = budget if budget is not None else _Budget(max_visits)
self.budget.reset(max_visits)
@@ -243,6 +354,7 @@ class _GraphResolver:
self.path_entries = profile.max_path_entries
self.built_chars = profile.max_built_chars
self.scanned = profile.max_scanned_units
+ self.drop_preview_only = profile.drop_preview_only_stages
self.checked: dict = {}
# Unfolded visit cost of each memo entry (inspection): a memo hit charges
# it to the current candidate, so each candidate's outcome is independent
@@ -259,6 +371,28 @@ class _GraphResolver:
self.active: set = set()
self.metadata = metadata or {}
self.text_sources: dict = {}
+ # Forced runtime selector choices (inspection); a result traced through
+ # one is "tainted" and never memoized, since it depends on the choice.
+ self.choices: dict = {}
+ self.tainted = False
+ # Latent-walk outcome per sampler (inspection): ``(error or None, visit
+ # cost)``. It never depends on selector choices, so one walk serves every
+ # branch run; ``latent_charged`` holds the samplers already paid for by
+ # the current candidate.
+ self.latent_walks: dict = {}
+ self.latent_charged: set = set()
+ # ``semantics`` (inspection) names the samplers and guiders it follows.
+ self.semantics = semantics
+ if semantics is None:
+ self.sampler_classes = self.stage_classes = _SAMPLERS
+ else:
+ self.sampler_classes = frozenset(
+ name
+ for name, entry in semantics.samplers.items()
+ if entry["strategy"] in _CANDIDATE_STRATEGIES
+ )
+ # Any listed sampler upstream in latent ancestry is an earlier stage.
+ self.stage_classes = frozenset(semantics.samplers)
def node(self, node_id: str) -> Tuple[str, dict]:
if self.legacy:
@@ -293,11 +427,15 @@ class _GraphResolver:
self.scanned -= units
def unsupported(self, message: str, node_id: str) -> _Unresolved:
+ return self.named(message, "unsupported-node", node_id)
+
+ def named(self, message: str, code: str, node_id: str) -> _Unresolved:
+ """An outcome that names the node it is about (bounded class name)."""
node = self.graph.get(node_id)
class_type = node.get("class_type") if isinstance(node, dict) else None
if isinstance(class_type, str):
class_type = class_type[:_CLASS_NAME_LENGTH]
- return _Unresolved(message, "unsupported-node", _node_ref(node_id, class_type))
+ return _Unresolved(message, code, _node_ref(node_id, class_type))
def bounded(self, text: str) -> str:
if len(text) > self.max_text_length:
@@ -335,11 +473,14 @@ class _GraphResolver:
return self.memo[key]
allowance = self.budget.allowance
outer_deepest = self.deepest
+ outer_tainted, self.tainted = self.tainted, False
self.deepest = depth
try:
self.budget.spend()
name, inputs = self.node(node_id)
self.scan(len(inputs))
+ if node_id in self.overwritten:
+ raise _Unresolved(OVERWRITTEN_PROMPT_REASON, "malformed")
self.active.add(key)
if name == "Reroute" and slot == 0:
keys = [key for key in ("value", "input", "in") if key in inputs]
@@ -348,12 +489,12 @@ class _GraphResolver:
"Reroute input is not uniquely identifiable", "malformed"
)
text, path, storage_text = self.text(inputs[keys[0]], kind, depth + 1)
+ if self.semantics is not None:
+ self.inherit_source(node_id, slot, [path])
elif (name, slot) in self.adapters:
adapter = self.adapters[(name, slot)]
if not isinstance(adapter, OutputAdapter) or adapter.kind != kind:
- raise _Unresolved(
- "Output slot has an incompatible type", "malformed"
- )
+ raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
evidence = (
self.execution_record(node_id, inputs)
if name == "PromptManagerPair"
@@ -370,6 +511,8 @@ class _GraphResolver:
storage_text = evidence[adapter.input_name]
path = []
self.text_sources[(node_id, slot)] = "execution"
+ elif adapter.comma or adapter.strip:
+ text, path, storage_text = self.historical(adapter, inputs, depth)
else:
text, path, storage_text = self.text(
inputs.get(adapter.input_name), "string", depth + 1
@@ -438,6 +581,10 @@ class _GraphResolver:
parts.append(part)
text = self.built(delimiter.join(parts))
storage_text = text
+ elif self.semantics is not None and self.follows(name, slot, kind):
+ text, path, storage_text = self.semantic(
+ node_id, name, slot, kind, inputs, depth
+ )
else:
raise self.unsupported(
"Unsupported node semantics or output slot", node_id
@@ -455,14 +602,326 @@ class _GraphResolver:
self.path_entries is not None and self.path_entries < 0
):
raise _Unresolved("Provenance path limit reached", "work-limit")
- self.memo[key] = (text, path, storage_text)
- self.costs[key] = allowance - self.budget.allowance
- self.heights[key] = self.deepest - depth
+ if not self.tainted:
+ self.memo[key] = (text, path, storage_text)
+ self.costs[key] = allowance - self.budget.allowance
+ self.heights[key] = self.deepest - depth
return text, path, storage_text
finally:
self.deepest = max(outer_deepest, self.deepest)
+ self.tainted = outer_tainted or self.tainted
self.active.discard(key)
+ def historical(
+ self, adapter: OutputAdapter, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """``prompt_manager_*.py`` at 07a5af3: strip, then join with ", ".
+
+ Prepend and append are stripped and skipped when empty; the separator is
+ left out when either side is empty or already ends with a comma. The
+ stripped main text is the recipe.
+ """
+ text, path, _ = self.text(
+ inputs.get(adapter.input_name, adapter.default), "string", depth + 1
+ )
+ self.scan(len(text))
+ text = text.strip()
+ storage_text = text
+ if adapter.comma:
+ # ``if prepend_text:`` skipped a missing or null part (07a5af3).
+ before, before_path, _ = self.text(
+ _or_empty(inputs.get("prepend_text")), "string", depth + 1
+ )
+ after, after_path, _ = self.text(
+ _or_empty(inputs.get("append_text")), "string", depth + 1
+ )
+ self.scan(len(before) + len(after) + len(before_path) + len(after_path))
+ before, after = before.strip(), after.strip()
+ if before:
+ joint = ", " if text and not before.endswith(",") else ""
+ text = f"{before}{joint}{text}"
+ if after:
+ joint = ", " if text and not text.endswith(",") else ""
+ text = f"{text}{joint}{after}"
+ text = self.built(self.bounded(text))
+ path = before_path + path + after_path
+ return text, path, storage_text
+
+ def follows(self, name: str, slot: int, kind: str) -> bool:
+ """Whether the semantics table traces this output (inspection only)."""
+ semantics = self.semantics
+ if name in semantics.runtime:
+ return True
+ if (name, slot) in semantics.encoders or (name, slot) in semantics.strings:
+ return True
+ if slot == 0 and name in semantics.selectors:
+ return True
+ return kind == "conditioning" and (
+ (name, slot) in semantics.conditioning
+ or (slot == 0 and name in semantics.composites)
+ )
+
+ def semantic(
+ self, node_id: str, name: str, slot: int, kind: str, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """Trace a table-listed output: runtime, encoder, string, pass-through,
+ zeroed, selector or composite.
+
+ The caller (``text``) prepends this node to the returned path.
+ """
+ semantics = self.semantics
+ if name in semantics.runtime:
+ raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
+ for table, expected, build in (
+ (semantics.encoders, "conditioning", self.encoded),
+ (semantics.strings, "string", self.stringed),
+ ):
+ listed = table.get((name, slot))
+ if listed is not None:
+ if kind != expected:
+ raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
+ return build(node_id, listed, inputs, depth)
+ entry = (
+ semantics.conditioning.get((name, slot)) if kind == "conditioning" else None
+ )
+ if entry is not None and entry["strategy"] == "zeroed":
+ # The upstream text is discarded, so it is not traced.
+ self.text_sources[(node_id, slot)] = _ZEROED_SOURCE
+ return "", [], ""
+ if entry is not None: # passthrough: one input carries the text
+ traced = self.text(inputs.get(entry["input"]), kind, depth + 1)
+ self.inherit_source(node_id, slot, [traced[1]])
+ return traced
+ if name in semantics.selectors:
+ return self.select(node_id, semantics.selectors[name], inputs, kind, depth)
+ parts = [
+ self.text(inputs.get(input_name), kind, depth + 1)
+ for input_name in semantics.composites[name]["inputs"]
+ ]
+ if len({part[0] for part in parts}) != 1:
+ # Several texts are in effect at once: not one sendable prompt.
+ raise self.named(
+ "Combined conditioning has different texts", "composite", node_id
+ )
+ return self.merged(node_id, slot, parts)
+
+ def select(
+ self, node_id: str, entry: Mapping, inputs: dict, kind: str, depth: int
+ ) -> Tuple[str, list, str]:
+ """A literal condition picks one branch; a linked one is a runtime choice.
+
+ A runtime choice traces both branches. Equal texts converge; different
+ texts raise ``_Diverging`` unless this choice is already forced.
+ """
+ condition = inputs.get(entry["condition"])
+ branches = entry["branches"]
+ if type(condition) is bool:
+ choice: Optional[bool] = condition
+ elif not _link(condition):
+ raise _Unresolved("Malformed selector condition", "malformed")
+ elif (str(condition[0]), condition[1]) in self.choices:
+ choice = self.choices[(str(condition[0]), condition[1])]
+ self.tainted = True
+ else:
+ choice = None
+ if choice is not None:
+ branch = branches["true" if choice else "false"]
+ traced = self.text(inputs.get(branch), kind, depth + 1)
+ self.inherit_source(node_id, 0, [traced[1]])
+ return traced
+ parts = [
+ self.text(inputs.get(branches[label]), kind, depth + 1)
+ for label in ("true", "false")
+ ]
+ if parts[0][0] != parts[1][0]:
+ raise _Diverging((str(condition[0]), condition[1]))
+ return self.merged(node_id, 0, parts)
+
+ def merged(self, node_id: str, slot: int, parts: list) -> Tuple[str, list, str]:
+ """One text reached through several followed paths (deduplicated)."""
+ text, merged, storage_text = self.joined_paths(parts, parts)
+ self.inherit_source(node_id, slot, [part[1] for part in parts])
+ return text, merged, storage_text
+
+ def joined_paths(self, chosen: list, parts: list) -> Tuple[str, list, str]:
+ """``chosen``'s one text with every part's followed path, deduplicated."""
+ text = chosen[0][0]
+ storages = {part[2] for part in chosen}
+ storage_text = chosen[0][2] if len(storages) == 1 else text
+ paths = [part[1] for part in parts]
+ self.scan(sum(len(path) for path in paths))
+ merged, seen = [], set()
+ for path in paths:
+ for step in path:
+ identity = (step["node_id"], step["output_slot"])
+ if identity not in seen:
+ seen.add(identity)
+ merged.append(step)
+ return text, merged, storage_text
+
+ def encoded(
+ self, node_id: str, entry: Mapping, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """A text encoder's prompt (``single`` or ``parts-equal``).
+
+ ``single`` reads one string input unchanged; image, VAE and other inputs
+ are not text. ``parts-equal`` reads every listed part: identical parts
+ are that text, exactly one non-empty part (exact ``""`` emptiness) is
+ that text, and anything else is several texts in effect at once
+ (``composite``, D6). Like ``CLIPTextEncode``, an encoder's text source
+ stays ``graph-inferred``.
+ """
+ names = entry["inputs"]
+ if entry["strategy"] == "single":
+ return self.text(inputs.get(names[0]), "string", depth + 1)
+ if entry["strategy"] == "flag-parts":
+ return self.flagged(node_id, entry, inputs, depth)
+ if entry["strategy"] != "parts-equal":
+ raise self.unsupported("Unsupported encoder strategy", node_id)
+ parts = [self.text(inputs.get(name), "string", depth + 1) for name in names]
+ # Comparing the parts reads every character once.
+ self.scan(sum(len(part[0]) for part in parts))
+ if len({part[0] for part in parts}) == 1:
+ return self.joined_paths(parts, parts)
+ filled = [part for part in parts if part[0] != ""]
+ if len(filled) == 1:
+ return self.joined_paths(filled, parts)
+ raise self.named("Encoder parts have different texts", "composite", node_id)
+
+ def flagged(
+ self, node_id: str, entry: Mapping, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """smZ CLIPTextEncode: ``text`` unless its SDXL flag may apply.
+
+ With the literal flag false the first input is encoded. With it true,
+ the connected CLIP's model class decides at run time which inputs are
+ read, so the prompt is known only when every input is the same text.
+ """
+ names = entry["inputs"]
+ if not self.setting(node_id, inputs, entry["params"]["flag"], bool):
+ return self.text(inputs.get(names[0]), "string", depth + 1)
+ parts = [self.text(inputs.get(name), "string", depth + 1) for name in names]
+ self.scan(sum(len(part[0]) for part in parts))
+ if len({part[0] for part in parts}) == 1:
+ return self.joined_paths(parts, parts)
+ raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
+
+ def stringed(
+ self, node_id: str, entry: Mapping, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """A string source: ``literal``, ``strip-newlines`` or ``join-multi``.
+
+ Each reproduces the pinned node's code exactly, and only from literal
+ settings; a linked setting is never guessed.
+ """
+ strategy = entry["strategy"]
+ source = entry["inputs"][0]
+ if strategy == "literal":
+ return self.text(inputs.get(source), "string", depth + 1)
+ if strategy == "strip-newlines":
+ # KJNodes StringConstantMultiline: replace("\n", "").strip().
+ flag = self.setting(node_id, inputs, entry["params"]["flag"], bool)
+ text, path, storage_text = self.text(
+ inputs.get(source), "string", depth + 1
+ )
+ if not flag:
+ return text, path, storage_text
+ self.scan(len(text))
+ text = self.built(self.bounded(text.replace("\n", "").strip()))
+ return text, path, text
+ if strategy == "join-multi":
+ return self.join_multi(node_id, entry, inputs, depth)
+ raise self.unsupported("Unsupported string strategy", node_id)
+
+ def setting(self, node_id: str, inputs: dict, name: str, kind: type) -> Any:
+ """A literal node setting; linked is ``unsupported-node``, else malformed."""
+ if name not in inputs:
+ raise _Unresolved("Missing string node setting", "malformed")
+ value = inputs[name]
+ if _link(value):
+ raise self.unsupported("Linked string node setting", node_id)
+ if type(value) is not kind:
+ raise _Unresolved("Malformed string node setting", "malformed")
+ return value
+
+ def join_multi(
+ self, node_id: str, entry: Mapping, inputs: dict, depth: int
+ ) -> Tuple[str, list, str]:
+ """KJNodes JoinStringMulti with a literal ``return_list`` of false.
+
+ ``string_1`` is always kept (even empty); ``string_2`` .. ``string_N``
+ (``N`` = ``inputcount``) are appended after the literal delimiter unless
+ missing or exactly ``""``; inputs past ``N`` are ignored. A list output
+ (or a linked flag) is decided at run time.
+ """
+ params = entry["params"]
+ flag = params["list_flag"]
+ if flag not in inputs:
+ raise _Unresolved("Missing string node setting", "malformed")
+ if _link(inputs[flag]) or inputs[flag] is True:
+ raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
+ if inputs[flag] is not False:
+ raise _Unresolved("Malformed string node setting", "malformed")
+ count = self.setting(node_id, inputs, params["count"], int)
+ delimiter = self.setting(node_id, inputs, params["delimiter"], str)
+ if not 1 <= count <= _MAX_JOIN_INPUTS:
+ raise _Unresolved("Malformed string node setting", "malformed")
+ self.scan(count)
+ names = [entry["inputs"][0]] + [
+ f"{params['input_prefix']}{index}" for index in range(2, count + 1)
+ ]
+ parts: list = []
+ path: list = []
+ seen: set = set()
+ size = 0
+ for position, input_name in enumerate(names):
+ if position and input_name not in inputs:
+ continue
+ part, followed, _ = self.text(inputs.get(input_name), "string", depth + 1)
+ self.scan(len(part) + len(followed))
+ for step in followed: # one link read by several parts is listed once
+ identity = (step["node_id"], step["output_slot"])
+ if identity not in seen:
+ seen.add(identity)
+ path.append(step)
+ if position and not part:
+ continue
+ size += len(part) + (len(delimiter) if parts else 0)
+ if size > self.max_text_length:
+ raise _Unresolved(
+ "Resolved text exceeds the length limit", "text-limit"
+ )
+ parts.append(part)
+ text = self.built(delimiter.join(parts))
+ return text, path, text
+
+ def source_of(self, path: list) -> str:
+ if not path:
+ return "graph-inferred"
+ origin = path[0]
+ return self.text_sources.get(
+ (origin["node_id"], origin["output_slot"]), "graph-inferred"
+ )
+
+ def inherit_source(self, node_id: str, slot: int, paths: list) -> None:
+ """A forwarding node reports its input's text source when unambiguous."""
+ sources = {self.source_of(path) for path in paths}
+ if len(sources) == 1:
+ self.text_sources[(node_id, slot)] = sources.pop()
+ else:
+ self.text_sources.pop((node_id, slot), None)
+
+ def unverifiable(self, error: _Unresolved) -> bool:
+ """Whether a failed input trace only means "cannot be read statically".
+
+ Such an input is not checked against a pair's execution record. The
+ default profile keeps its historical message test; inspection decides
+ by code, so every unsupported or run-time source qualifies.
+ """
+ if self.semantics is None:
+ return str(error) == "Unsupported node semantics or output slot"
+ return error.code in _UNVERIFIABLE_CODES
+
def execution_record(self, node_id: str, inputs: dict) -> Optional[dict]:
try:
record = read_pair_execution(self.metadata, node_id)
@@ -474,7 +933,7 @@ class _GraphResolver:
try:
expected = self.text(inputs.get(name, ""), "string")[0]
except _Unresolved as error:
- if str(error) == "Unsupported node semantics or output slot":
+ if self.unverifiable(error):
value = inputs.get(name)
if _link(value):
source_name, _ = self.node(str(value[0]))
@@ -502,26 +961,77 @@ class _GraphResolver:
)
return record
- def side(self, value: Any, sampler_id: str) -> dict:
- text, path, storage_text = self.text(value, "conditioning")
+ def side(
+ self,
+ value: Any,
+ sampler_id: str,
+ guider_id: Optional[str] = None,
+ depth: int = 0,
+ ) -> dict:
+ text, path, storage_text = self.text(value, "conditioning", depth)
origin = path[0]
- return {
- "text": text,
- "storage_text": storage_text,
- "provenance": {
- "format": "comfyui",
- "sampler_id": sampler_id,
- **origin,
- "path": path,
- "text_source": self.text_sources.get(
- (origin["node_id"], origin["output_slot"]), "graph-inferred"
- ),
- },
+ provenance = {
+ "format": "comfyui",
+ "sampler_id": sampler_id,
+ **origin,
+ "path": path,
+ "text_source": self.text_sources.get(
+ (origin["node_id"], origin["output_slot"]), "graph-inferred"
+ ),
}
+ if guider_id is not None:
+ # The conditioning path starts below the guider the sampler reads.
+ provenance["guider_id"] = guider_id
+ return {"text": text, "storage_text": storage_text, "provenance": provenance}
+
+ def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]:
+ """Follow a sampler's ``guider`` link (through Reroutes) to a listed guider.
+
+ Returns the guider's ID and inputs, its conditioning input names, the
+ reason used when it has no negative input, and the depth reached. Each
+ hop draws a visit; an unlisted guider class is ``unsupported-node``.
+ """
+ seen: set = set()
+ depth = 0
+ while True:
+ if depth >= self.max_depth:
+ raise _Unresolved("Resolution depth limit reached", "depth-limit")
+ if not _link(value):
+ raise _Unresolved("Expected a guider link", "malformed")
+ node_id, slot = str(value[0]), value[1]
+ if node_id in seen:
+ raise _Unresolved("Cyclic guider ancestry", "malformed")
+ seen.add(node_id)
+ self.budget.spend()
+ name, inputs = self.node(node_id)
+ self.scan(len(inputs))
+ depth += 1
+ if name == "Reroute" and slot == 0:
+ keys = [key for key in ("value", "input", "in") if key in inputs]
+ if len(keys) != 1:
+ raise _Unresolved(
+ "Reroute input is not uniquely identifiable", "malformed"
+ )
+ value = inputs[keys[0]]
+ continue
+ entry = self.semantics.guiders.get(name)
+ if entry is None or slot != 0:
+ raise self.unsupported("Unsupported guider", node_id)
+ conditioning = {
+ "positive": entry["positive"],
+ "negative": entry.get("negative"),
+ }
+ reason = entry.get("absent_reason", _GUIDER_ABSENT_REASON)
+ return node_id, inputs, conditioning, reason, depth
+
+ def sampler_entry(self, name: str) -> Mapping:
+ if self.semantics is None:
+ return _DIRECT_SAMPLER
+ return self.semantics.samplers[name]
def pair(self, sampler_id: str) -> dict:
- positive = negative = None
self.budget.reset(self.per_candidate_visits)
+ self.latent_charged = set()
if not self.legacy and self.budget.total <= 0:
return _result(
"unresolved",
@@ -529,23 +1039,342 @@ class _GraphResolver:
reason="Resolution visit limit reached",
reason_code="work-limit",
)
+ if self.semantics is None:
+ return self.sides(sampler_id)
+ return self.branches(sampler_id)
+
+ def stage(self, sampler_id: str, later: frozenset) -> dict:
+ """One sampler's result; a detailer is compared with its upstream stages.
+
+ ``later`` holds the detailers already being judged below this one, so a
+ cyclic stage ancestry is malformed instead of recursing, and a chain
+ deeper than ``max_depth`` stages is ``depth-limit``.
+ """
+ if len(later) >= self.max_depth:
+ return _result(
+ "unresolved",
+ sampler_id=sampler_id,
+ reason="Resolution depth limit reached",
+ reason_code="depth-limit",
+ )
+ own = self.sides(sampler_id)
+ if own["state"] == "unresolved":
+ return own
+ name, inputs = self.node(sampler_id)
+ entry = self.semantics.samplers.get(name)
+ if entry is None or entry["strategy"] != _DETAILER:
+ return own
try:
- _, inputs = self.node(sampler_id)
+ upstream = self.upstream_stages(
+ sampler_id, inputs.get(entry["image_stage"])
+ )
+ if any(node_id in later for node_id in upstream):
+ raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
+ except _Unresolved as error:
+ return _result(
+ "unresolved",
+ sampler_id=sampler_id,
+ reason=str(error),
+ reason_code=error.code,
+ node=error.node,
+ )
+ if not upstream:
+ return own
+ later = later | {sampler_id}
+ stages = [self.stage(node_id, later) for node_id in upstream]
+ blocked = next((item for item in stages if item["state"] == "unresolved"), None)
+ if blocked is not None:
+ # A stage that cannot be read cannot be compared: never pick one.
+ return _result(
+ "unresolved",
+ sampler_id=sampler_id,
+ reason=blocked["reason"],
+ reason_code=blocked["reason_code"],
+ node=blocked["node"],
+ )
+ flat: list = []
+ for item in [own] + stages:
+ flat.extend(item["candidates"] if item["state"] == "ambiguous" else [item])
+ if len({_outcome_key(item) for item in flat}) == 1:
+ return own
+ return _result(
+ "ambiguous",
+ sampler_id=sampler_id,
+ reason="Sampler stages have different prompts",
+ candidates=flat,
+ reason_code="multistage-differs",
+ )
+
+ @staticmethod
+ def walk_from(value: list) -> Tuple[deque, set]:
+ """A breadth-first queue holding one start link, and its queued IDs."""
+ start = str(value[0])
+ return deque([(start, 0)]), {start}
+
+ def enqueue(self, queue: deque, queued: set, inputs: dict, depth: int) -> None:
+ """Queue each linked input once per walk; every edge is charged as scanned.
+
+ Deduplicating at enqueue keeps a dense shared fan-in to one entry per
+ node, so the queue never outgrows the nodes the visit budget pays for.
+ Breadth-first, a node's first enqueue is at its least depth, as before.
+ """
+ self.scan(len(inputs))
+ for item in inputs.values():
+ if _link(item):
+ key = str(item[0])
+ if key not in queued:
+ queued.add(key)
+ queue.append((key, depth))
+
+ def stage_shaped(self, name: str, inputs: dict) -> bool:
+ """An unlisted node that may itself be a sampler stage."""
+ if name in self.semantics.all_classes:
+ return False
+ stage_inputs = self.semantics.stage_inputs
+ if any(_link(inputs.get(key)) for key in stage_inputs["shaped"]):
+ return True
+ return _link(inputs.get("model")) and any(
+ _link(inputs.get(key)) for key in stage_inputs["with_model"]
+ )
+
+ def upstream_stages(self, origin: str, value: Any) -> list:
+ """Samplers reached from a detailer's image input, in breadth-first order.
+
+ Every link input of non-sampler nodes is walked (decoders, upscalers,
+ compositors), stopping at listed samplers. Each node draws a visit.
+ """
+ if not _link(value):
+ raise _Unresolved("Expected an image link", "malformed")
+ found: list = []
+ queue, queued = self.walk_from(value)
+ while queue:
+ node_id, depth = queue.popleft()
+ if node_id == origin:
+ raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
+ if depth >= self.max_depth:
+ raise _Unresolved("Resolution depth limit reached", "depth-limit")
+ self.budget.spend()
+ name, inputs = self.node(node_id)
+ if name in self.stage_classes:
+ found.append(node_id)
+ continue
+ if self.stage_shaped(name, inputs):
+ raise self.unsupported("Unsupported sampler stage ancestry", node_id)
+ self.enqueue(queue, queued, inputs, depth + 1)
+ return found
+
+ def final_stages(self, sampler_ids: list) -> list:
+ """Drop samplers that only feed detailers: each is judged in that chain.
+
+ A sampler whose output also reaches a terminal node other than a later
+ stage or a preview stays a candidate of its own (the file may be its
+ output). Any walk that fails or runs out of budget absorbs nothing.
+ With ``drop_preview_only_stages``, a stage whose outputs reach only
+ non-file outputs is dropped first, unless every stage is.
+ """
+ if self.drop_preview_only:
+ sampler_ids = self.file_stages(sampler_ids)
+ detailers = [
+ node_id
+ for node_id in sampler_ids
+ if self.semantics.samplers[self.graph[node_id]["class_type"]]["strategy"]
+ == _DETAILER
+ ]
+ if not detailers:
+ return sampler_ids
+ try:
+ consumers = self.consumers()
+ except _Unresolved:
+ return sampler_ids
+ absorbed: set = set()
+ for detailer in detailers:
+ self.budget.reset(self.per_candidate_visits)
+ try:
+ name, inputs = self.node(detailer)
+ image = inputs.get(self.semantics.samplers[name]["image_stage"])
+ for node_id in self.upstream_stages(detailer, image):
+ if not self.reaches_output(node_id, consumers):
+ absorbed.add(node_id)
+ except _Unresolved:
+ continue
+ kept = [node_id for node_id in sampler_ids if node_id not in absorbed]
+ return kept or sampler_ids
+
+ def file_stages(self, sampler_ids: list) -> list:
+ """Stages not proven preview-only; all of them when every one is.
+
+ A stage is preview-only when its outputs, followed through any later
+ stage, reach a non-file output and no other terminal node. A stage
+ without consumers is a terminal of its own and is kept. Two backward
+ passes over the graph decide every stage at once (linear work, charged
+ like ``consumers``); a pass that fails keeps every stage.
+ """
+ if len(sampler_ids) < 2:
+ return sampler_ids
+ try:
+ consumers = self.consumers()
+ previews = self.non_file_outputs()
+ terminals = [
+ node_id
+ for node_id, node in self.graph.items()
+ if isinstance(node, dict)
+ and node_id not in previews
+ and not consumers.get(node_id)
+ ]
+ to_file = self.producers_of(terminals, previews)
+ to_preview = self.producers_of(list(previews), previews)
+ except _Unresolved:
+ return sampler_ids
+ kept = [
+ node_id
+ for node_id in sampler_ids
+ if node_id in to_file or node_id not in to_preview
+ ]
+ return kept or sampler_ids
+
+ def non_file_outputs(self) -> set:
+ names = self.semantics.non_file_outputs
+ return {
+ node_id
+ for node_id, node in self.graph.items()
+ if isinstance(node, dict) and node.get("class_type") in names
+ }
+
+ def producers_of(self, starts: list, previews: set) -> set:
+ """Nodes whose output reaches one of ``starts``, never through a preview."""
+ found, queue = set(starts), deque(starts)
+ while queue:
+ node = self.graph[queue.popleft()]
+ inputs = node.get("inputs")
+ if not isinstance(inputs, dict):
+ continue
+ self.scan(len(inputs))
+ for value in inputs.values():
+ if not _link(value):
+ continue
+ producer = str(value[0])
+ if (
+ producer in found
+ or producer in previews
+ or not isinstance(self.graph.get(producer), dict)
+ ):
+ continue
+ found.add(producer)
+ queue.append(producer)
+ return found
+
+ def consumers(self) -> dict:
+ """Reverse links: node ID to the IDs of nodes reading one of its outputs."""
+ consumers: dict = {}
+ for node_id, node in self.graph.items():
+ if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
+ continue
+ self.scan(len(node["inputs"]))
+ for value in node["inputs"].values():
+ if _link(value):
+ consumers.setdefault(str(value[0]), []).append(node_id)
+ return consumers
+
+ def reaches_output(self, node_id: str, consumers: dict) -> bool:
+ """Whether a sampler's output reaches a terminal node besides later stages."""
+ seen, queue = {node_id}, deque([node_id])
+ while queue:
+ current = queue.popleft()
+ for consumer in consumers.get(current, ()):
+ if consumer in seen:
+ continue
+ seen.add(consumer)
+ self.budget.spend()
+ name = str(self.graph[consumer].get("class_type", ""))
+ if (
+ name in self.stage_classes
+ or name in self.semantics.non_file_outputs
+ ):
+ continue
+ if not consumers.get(consumer):
+ return True
+ queue.append(consumer)
+ return False
+
+ def branches(self, sampler_id: str) -> dict:
+ """Resolve once per runtime selector choice that changes a role text.
+
+ Every run draws from the same candidate allowance. Results that agree
+ collapse to one; otherwise the sampler is ``ambiguous`` with one
+ candidate per choice set, in true-before-false order.
+ """
+ pending: list = [{}]
+ results: list = []
+ try:
+ while pending:
+ self.choices = pending.pop(0)
+ try:
+ results.append(self.stage(sampler_id, frozenset()))
+ except _Diverging as diverging:
+ if len(results) + len(pending) + 2 > _MAX_BRANCH_CANDIDATES:
+ raise _Unresolved("Too many selector branches", "work-limit")
+ pending.extend(
+ {**self.choices, diverging.key: choice}
+ for choice in (True, False)
+ )
+ except _Unresolved as error:
+ return _result(
+ "unresolved",
+ sampler_id=sampler_id,
+ reason=str(error),
+ reason_code=error.code,
+ node=error.node,
+ )
+ finally:
+ self.choices = {}
+ if all(item["state"] == "unresolved" for item in results):
+ return results[0]
+ if len({_outcome_key(item) for item in results}) == 1:
+ return results[0]
+ return _result(
+ "ambiguous",
+ sampler_id=sampler_id,
+ reason="Selector branches have different prompts",
+ candidates=results,
+ )
+
+ def sides(self, sampler_id: str) -> dict:
+ positive = negative = None
+ try:
+ name, inputs = self.node(sampler_id)
+ entry = self.sampler_entry(name)
+ self.check_overrides(sampler_id, entry, inputs)
# A second sampler in the latent ancestry denotes a multistage run;
# associating the final image with one prompt pair would be a guess.
- if "latent_image" in inputs:
- self.latent_source(inputs["latent_image"], set(), 0)
- positive = self.side(inputs.get("positive"), sampler_id)
+ latent = entry.get("latent")
+ if latent is not None and latent in inputs:
+ if self.semantics is None:
+ self.latent_source(inputs[latent], set(), 0)
+ else:
+ self.latent_checked(sampler_id, inputs[latent])
+ guider_id, depth = None, 0
+ conditioning = entry["conditioning"]
+ absent_reason = "Negative input is absent"
+ if entry["strategy"] == "custom-advanced":
+ guider_id, inputs, conditioning, guider_reason, depth = self.guider(
+ inputs.get(conditioning["guider"])
+ )
+ if conditioning["negative"] is None:
+ absent_reason = guider_reason
+ positive = self.side(
+ inputs.get(conditioning["positive"]), sampler_id, guider_id, depth
+ )
if not positive["text"].strip():
- raise _Unresolved("Positive prompt is empty")
- if "negative" not in inputs:
+ raise _Unresolved(EMPTY_POSITIVE_REASON)
+ negative_name = conditioning["negative"]
+ if negative_name is None or negative_name not in inputs:
return _result(
"absent",
positive,
sampler_id=sampler_id,
- reason="Negative input is absent",
+ reason=absent_reason,
)
- negative = self.side(inputs["negative"], sampler_id)
+ negative = self.side(inputs[negative_name], sampler_id, guider_id, depth)
return _result("resolved", positive, negative, sampler_id)
except _Unresolved as error:
return _result(
@@ -558,6 +1387,21 @@ class _GraphResolver:
node=error.node,
)
+ def check_overrides(self, sampler_id: str, entry: Mapping, inputs: dict) -> None:
+ """A set prompt override replaces the conditioning inputs' prompt.
+
+ Exact ``""`` (or a missing input) leaves the prompt alone. A literal is
+ processed at run time (a detailer wildcard); a link is a source the
+ inspector does not follow, so it is named.
+ """
+ for name in entry.get("prompt_overrides", ()):
+ value = inputs.get(name)
+ if value is None or value == "":
+ continue
+ if _link(value):
+ raise self.unsupported("Unsupported prompt override", str(value[0]))
+ raise self.named(_RUNTIME_REASON, "runtime-value", sampler_id)
+
def latent_source(self, value: Any, seen: set, depth: int) -> None:
if not _link(value) or depth >= self.max_depth:
code = "malformed" if not _link(value) else "depth-limit"
@@ -572,9 +1416,9 @@ class _GraphResolver:
self.budget.spend()
seen.add(node_id)
name, inputs = self.node(node_id)
- if name in _SAMPLERS:
+ if name in self.stage_classes:
# ``multistage-differs`` is reserved for when stage texts are compared.
- raise _Unresolved("Multistage sampler ancestry is ambiguous")
+ raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
if name in ("EmptyLatentImage", "EmptySD3LatentImage") and slot == 0:
return
if name == "Reroute" and slot == 0 and "value" in inputs:
@@ -582,6 +1426,63 @@ class _GraphResolver:
return
raise self.unsupported("Unsupported latent ancestry", node_id)
+ def latent_checked(self, sampler_id: str, value: Any) -> None:
+ """``latent_stages`` once per sampler per resolution.
+
+ A candidate reusing another candidate's walk is charged its visit cost
+ (as the text memo does), so outcomes stay independent of order; branch
+ runs of one candidate share its allowance and pay once. A walk that ran
+ out of work is never reused, since it depends on the remaining budget.
+ """
+ if sampler_id in self.latent_walks:
+ error, cost = self.latent_walks[sampler_id]
+ if sampler_id not in self.latent_charged:
+ self.budget.charge(cost)
+ self.latent_charged.add(sampler_id)
+ else:
+ before = self.budget.allowance
+ try:
+ self.latent_stages(sampler_id, value)
+ error = None
+ except _Unresolved as raised:
+ if raised.code == "work-limit":
+ raise
+ error = raised
+ self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
+ self.latent_charged.add(sampler_id)
+ if error is not None:
+ node = dict(error.node) if error.node is not None else None
+ raise _Unresolved(str(error), error.code, node)
+
+ def latent_stages(self, origin: str, value: Any) -> None:
+ """Semantics table: is there an earlier sampler in the latent ancestry?
+
+ Every link input of non-sampler nodes is walked breadth-first (encoders,
+ decoders, masks, conditioning), stopping at listed samplers; each node
+ draws a visit. No sampler found means a single stage whatever the origin
+ (VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
+ node with sampler-shaped inputs could itself be a stage, so it is
+ ``unsupported-node``. Budget or depth exhaustion is never single stage.
+ """
+ if not _link(value):
+ raise _Unresolved("Unknown latent ancestry", "malformed")
+ queue, queued = self.walk_from(value)
+ while queue:
+ node_id, depth = queue.popleft()
+ if node_id == origin:
+ raise _Unresolved("Cyclic latent ancestry", "malformed")
+ if depth >= self.max_depth:
+ raise _Unresolved("Resolution depth limit reached", "depth-limit")
+ self.budget.spend()
+ name, inputs = self.node(node_id)
+ if name in self.stage_classes:
+ # ``multistage-differs`` is reserved for when stage texts are
+ # compared (PR3); until then no stage's prompt is chosen.
+ raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
+ if self.stage_shaped(name, inputs):
+ raise self.unsupported("Unsupported latent ancestry", node_id)
+ self.enqueue(queue, queued, inputs, depth + 1)
+
def anchor(self, node_id: str) -> str:
seen = set()
slot = 0
@@ -594,7 +1495,7 @@ class _GraphResolver:
self.budget.spend()
seen.add(node_id)
name, inputs = self.node(node_id)
- if name in _SAMPLERS and slot == 0:
+ if name in self.sampler_classes and slot == 0:
return node_id
key = {
"SaveImage": "images",
@@ -611,7 +1512,53 @@ class _GraphResolver:
raise _Unresolved("Output ancestry limit reached", code)
-def _parameters(value: Any, max_text_length: int) -> dict:
+_FOOTER = re.compile(r"(?m)^Steps:\s*\d+\s*,[^\n]*$")
+# Inspection also reads a footer of ``Steps: N`` alone (no other settings).
+_BARE_FOOTER = re.compile(r"(?m)^Steps:\s*\d+\s*$")
+# sd-dynamic-prompts writes these one-line records after the settings footer.
+_FOOTER_TRAILERS = ("Template:", "Negative Template:")
+
+
+def _trailer_lines(text: str) -> bool:
+ """Whether ``text`` after the footer is empty or only template records.
+
+ At most one ``Template:`` line, then at most one ``Negative Template:``
+ line; anything else (a multi-line template included) is not recognized.
+ """
+ lines = re.split(r"\r?\n|\r", text.strip()) if text.strip() else []
+ prefixes = [
+ next(
+ (
+ index
+ for index, name in enumerate(_FOOTER_TRAILERS)
+ if line.startswith(name)
+ ),
+ None,
+ )
+ for line in lines
+ ]
+ return None not in prefixes and prefixes == sorted(set(prefixes))
+
+
+def _footer(value: str, inspection: bool) -> Optional[re.Match]:
+ """The unique A1111 settings footer, or ``None``.
+
+ The default profile keeps the historical rule (one ``Steps: N, ...`` line,
+ and it is the last line). Inspection (R5) accepts a bare ``Steps: N``
+ footer when there is no full one, and template records after it.
+ """
+ footers = list(_FOOTER.finditer(value))
+ if inspection and not footers:
+ footers = list(_BARE_FOOTER.finditer(value))
+ if len(footers) != 1:
+ return None
+ rest = value[footers[0].end() :]
+ if inspection and _trailer_lines(rest):
+ return footers[0]
+ return None if rest.strip() else footers[0]
+
+
+def _parameters(value: Any, max_text_length: int, inspection: bool = False) -> dict:
if not isinstance(value, str) or not value:
return _result("absent", reason="No A1111 parameters")
if len(value) > max_text_length:
@@ -620,21 +1567,24 @@ def _parameters(value: Any, max_text_length: int) -> dict:
reason="Parameters exceed the length limit",
reason_code="text-limit",
)
- footers = list(re.finditer(r"(?m)^Steps:\s*\d+\s*,[^\n]*$", value))
- if len(footers) != 1 or value[footers[0].end() :].strip():
+ footer = _footer(value, inspection)
+ if footer is None:
return _result(
"unresolved",
reason="A1111 settings footer is not unique",
reason_code="malformed",
)
- body = value[: footers[0].start()].rstrip("\r\n")
+ body = value[: footer.start()].rstrip("\r\n")
+ if inspection:
+ # R4: text that starts with the delimiter has an empty positive (#165).
+ body = "\n" + body
delimiter = "\nNegative prompt:"
if body.count(delimiter) > 1:
return _result("ambiguous", reason="Multiple negative prompt delimiters")
positive, found, negative = body.partition(delimiter)
positive = positive.strip()
if not positive:
- return _result("unresolved", reason="Positive prompt is empty")
+ return _result("unresolved", reason=EMPTY_POSITIVE_REASON)
provenance = {
"format": "a1111",
"field": "parameters",
@@ -706,7 +1656,8 @@ def resolve_prompt_pair(
Accepts ``{"prompt": api_graph, "parameters": optional_a1111_text}`` or a
direct API graph. An optional saver/sampler node ID anchors branch selection.
- Unanchored multiple samplers are ambiguous, even when their texts are equal.
+ Unanchored multiple samplers are ambiguous, even when their texts are equal
+ (the inspection profile first drops stages that only reach previews).
``profile`` selects the bounds; explicit ``max_*`` keywords override it.
"""
profile = _profile_with(profile, max_visits, max_depth, max_text_length)
@@ -714,6 +1665,24 @@ def resolve_prompt_pair(
return _legacy_result(result) if profile.legacy else result
+SEMANTICS_UNAVAILABLE_REASON = "Inspection node semantics are unavailable"
+
+
+def _inspection_semantics() -> Optional[Any]:
+ """The validated node table, or ``None`` when it cannot be loaded.
+
+ Imported here, not at module level: ingestion (the default profile) must
+ keep working whatever state the table file is in.
+ """
+ from .inspection_semantics import SemanticsError, get_semantics
+
+ try:
+ return get_semantics()
+ except SemanticsError as error:
+ _LOGGER.error("Inspection node semantics table is unusable: %s", error)
+ return None
+
+
def _limit_result(profile: ResolverProfile, graph: dict) -> Optional[Dict[str, Any]]:
if profile.legacy:
if (
@@ -751,7 +1720,9 @@ def _resolve(
if "prompt" in metadata:
graph = metadata["prompt"]
elif "parameters" in metadata:
- return _parameters(metadata["parameters"], profile.max_text_length)
+ return _parameters(
+ metadata["parameters"], profile.max_text_length, not profile.legacy
+ )
else:
graph = metadata
if not isinstance(graph, dict) or not graph:
@@ -763,6 +1734,11 @@ def _resolve(
limited = _limit_result(profile, graph)
if limited is not None:
return limited
+ semantics = None
+ if profile.semantics:
+ semantics = _inspection_semantics()
+ if semantics is None:
+ return _result("unresolved", reason=SEMANTICS_UNAVAILABLE_REASON)
normalized = {str(node_id): node for node_id, node in graph.items()}
if len(normalized) != len(graph):
return _result(
@@ -770,6 +1746,9 @@ def _resolve(
reason="Graph node IDs are not unique",
reason_code="malformed",
)
+ overwritten = frozenset()
+ if semantics is not None and "prompt" in metadata:
+ overwritten = _overwritten_prompt_nodes(metadata.get("workflow"), normalized)
resolver = _GraphResolver(
normalized,
adapters or {},
@@ -779,6 +1758,8 @@ def _resolve(
metadata if "prompt" in metadata else None,
budget=_Budget(profile.total_visits),
profile=profile,
+ semantics=semantics,
+ overwritten=overwritten,
)
if output_node_id is not None:
try:
@@ -793,8 +1774,10 @@ def _resolve(
for node_id, node in normalized.items()
if isinstance(node, dict)
and isinstance(node.get("class_type"), str)
- and node["class_type"] in _SAMPLERS
+ and node["class_type"] in resolver.sampler_classes
]
+ if semantics is not None:
+ sampler_ids = resolver.final_stages(sampler_ids)
if not sampler_ids:
return _no_sampler(normalized, profile)
if len(sampler_ids) > 1:
@@ -806,6 +1789,99 @@ def _resolve(
return resolver.pair(sampler_ids[0])
+# Bounds for walking a UI workflow's subgraph instances (R1 only).
+_MAX_SUBGRAPH_DEPTH = 8
+_MAX_WORKFLOW_NODES = 100_000
+
+
+def _subgraph_nodes(workflow: dict) -> Dict[str, list]:
+ """Subgraph definition ID to its node list (malformed entries are skipped)."""
+ definitions = workflow.get("definitions")
+ subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None
+ found: Dict[str, list] = {}
+ for subgraph in subgraphs if isinstance(subgraphs, list) else ():
+ if isinstance(subgraph, dict) and isinstance(subgraph.get("id"), str):
+ nodes = subgraph.get("nodes")
+ if isinstance(nodes, list):
+ found[subgraph["id"]] = nodes
+ return found
+
+
+def _widget_text(values: Any) -> Optional[str]:
+ """A PromptManager node's text widget: the first value, or ``text`` by name."""
+ if isinstance(values, list) and values and isinstance(values[0], str):
+ return values[0]
+ if isinstance(values, dict) and isinstance(values.get("text"), str):
+ return values["text"]
+ return None
+
+
+def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
+ """API node ID to widget text for every PromptManager node that runs.
+
+ Nodes inside a subgraph run as ``":"`` API IDs (nested
+ instances chain their IDs); a definition without an instance runs nothing.
+ The walk is bounded in depth and in nodes visited.
+ """
+ nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
+ if not isinstance(nodes, list):
+ return {}
+ subgraphs = _subgraph_nodes(workflow)
+ widgets: Dict[str, str] = {}
+ stack, visited = [(nodes, "", 0)], 0
+ while stack:
+ members, prefix, depth = stack.pop()
+ for node in members:
+ visited += 1
+ if visited > _MAX_WORKFLOW_NODES:
+ return widgets
+ if not isinstance(node, dict):
+ continue
+ kind, node_id = node.get("type"), node.get("id")
+ if not isinstance(node_id, (str, int)) or isinstance(node_id, bool):
+ continue
+ api_id = f"{prefix}{node_id}"
+ if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH:
+ stack.append((subgraphs[kind], api_id + ":", depth + 1))
+ continue
+ text = _widget_text(node.get("widgets_values"))
+ if (
+ isinstance(kind, str)
+ and kind != "PromptManagerPair"
+ and "promptmanager" in kind.lower()
+ and text is not None
+ ):
+ widgets[api_id] = text
+ return widgets
+
+
+def _overwritten_prompt_nodes(workflow: Any, graph: dict) -> frozenset:
+ """API node IDs whose saved PromptManager text is another node's (R1).
+
+ The pre-3.2.3 signature (comfy-metadata.js ``createContext``): two or more
+ workflow nodes of a PromptManager type other than ``PromptManagerPair``
+ share one API ``text`` while their text widgets differ. Their API class may
+ have been rewritten to ``CLIPTextEncode``, so nodes are matched by ID,
+ including nodes inside subgraphs (unlike the legacy viewer). The widget
+ text is never substituted: it was not recorded as executed, so these nodes
+ are refused instead.
+ """
+ widgets = _prompt_manager_widgets(workflow)
+ by_text: Dict[str, list] = {}
+ for node_id, widget in widgets.items():
+ node = graph.get(node_id)
+ inputs = node.get("inputs") if isinstance(node, dict) else None
+ text = inputs.get("text") if isinstance(inputs, dict) else None
+ if isinstance(text, str):
+ by_text.setdefault(text, []).append(node_id)
+ return frozenset(
+ node_id
+ for ids in by_text.values()
+ if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1
+ for node_id in ids
+ )
+
+
def _no_sampler(graph: dict, profile: ResolverProfile) -> Dict[str, Any]:
reason = "No supported sampler pair found"
if profile.legacy:
diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js
index 044dc340..a74e2de0 100644
--- a/web/js/comfy-metadata.js
+++ b/web/js/comfy-metadata.js
@@ -403,8 +403,53 @@
}
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
- const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]);
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
+ // These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
+ // (facts shape, and a detailer stage's image input), the other four its `facts`
+ // section (tests/js/inspection-semantics-sync.test.js). They stay inline literals
+ // because the legacy pages load this file as a classic script.
+ const SAMPLERS = Object.freeze({
+ KSampler: { facts: "ksampler" },
+ KSamplerAdvanced: { facts: "ksampler" },
+ SamplerCustom: { facts: "custom" },
+ SamplerCustomAdvanced: { facts: "custom-advanced" },
+ FaceDetailer: { facts: "ksampler", image_stage: "image" },
+ DetailerForEach: { facts: "ksampler", image_stage: "image" },
+ UltimateSDUpscale: { facts: "ksampler", image_stage: "image" },
+ UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" },
+ // `fact_overrides`: inputs that, when set, replace the settings read here.
+ ClownsharKSampler_Beta: { facts: "ksampler", fact_overrides: ["options", "sigmas"] },
+ "KSampler (Efficient)": { facts: "ksampler", fact_overrides: ["script"] },
+ "ttN KSampler_v2": { facts: "ksampler", fact_overrides: ["adv_xyPlot"] },
+ });
+ // Stages merged for one result: the resolver's candidate cap. Together with
+ // MAX_VISITS per candidate walk these are deliberately tighter than the server's
+ // budgets (2,000 visits, depth 64), so the JS walk always fails closed first.
+ const MAX_STAGES = 32;
+ // Every class the semantics table lists (the resolver's `all_classes`): a stage walk
+ // passes these, and treats any other node shaped like a sampler as an unknown stage.
+ const TABLE_CLASSES = new Set([
+ "KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced", "FaceDetailer",
+ "CFGGuider", "BasicGuider", "easy ifElse", "ConditioningCombine", "ConditioningConcat",
+ "ConditioningAverage", "SaveImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", "SaveVideo",
+ "SaveWEBM", "VHS_VideoCombine", "FluxGuidance", "ReferenceLatent", "ConditioningSetArea",
+ "ConditioningSetAreaPercentage", "ConditioningSetAreaStrength", "ConditioningSetMask", "ConditioningSetTimestepRange", "ControlNetApply",
+ "ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
+ "CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
+ "TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
+ "JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
+ "Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
+ "ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
+ "TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
+ "SeedVarianceEnhancer", "ApplyInstantIDAdvanced", "Switch conditioning [Crystools]", "smZ CLIPTextEncode", "ttN text",
+ "CR Text", "CR Prompt Text", "TextBoxMira",
+ ]);
+ // The table's `stage_inputs` (the resolver reads the same lists): inputs that make an
+ // unlisted node a possible stage, and those that do so beside a linked `model`.
+ const STAGE_INPUTS = Object.freeze({
+ shaped: ["latent_image", "guider", "positive", "negative", "pipe", "basic_pipe", "detailer_pipe", "sampler", "sigmas", "noise", "text_embeds", "hyvid_embeds"],
+ with_model: ["conditioning", "neg_conditioning", "samples", "latent"],
+ });
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
const SCALAR_OUTPUTS = Object.freeze({
PrimitiveInt: "number", PrimitiveFloat: "number",
@@ -414,11 +459,17 @@
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
});
- const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
+ const MODEL_PASSTHROUGHS = new Set([
+ "LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3",
+ "Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance",
+ "CR Apply LoRA Stack",
+ ]);
const CONFIGURATION_OUTPUTS = Object.freeze({
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] },
+ // D8: no scheduler input; the node type is the scheduler configuration.
+ Flux2Scheduler: { kind: "sigmas", fields: ["steps"], type_fields: ["scheduler"] },
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
BasicGuider: { kind: "guider", fields: ["model"] },
});
@@ -463,24 +514,85 @@
const seen = new Set();
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
if (!node) return fact("unresolved", source, samplerId);
+ if (keys.some(name => adapter.type_fields?.includes(name))) {
+ const value = exactFactValue(node.class_type, field);
+ return fact(value === null ? "unresolved" : "known", source, samplerId, value);
+ }
const key = keys.find(name => adapter.fields.includes(name) && Object.hasOwn(node.inputs, name));
return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen);
}
- function graphFacts(graph, source, resolution) {
- if (!object(graph)) return factSet("unresolved", source);
- // Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
- // and the server's node ceiling arrives as a `limited` projection.
- const entries = Object.entries(graph);
- if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source);
- const candidates = entries.filter(([, node]) => GENERATION_SAMPLERS.has(node.class_type));
- const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id);
- if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
- const [samplerId, sampler] = selected || candidates[0];
- const ctx = createContext(graph, null, true);
+ const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined);
+
+ // The resolver's `_link`: an integer or string id and a non-negative integer slot.
+ // Stage walks use it so they cross exactly the server's edges; the legacy `isLink` stays.
+ const stageLink = value => Array.isArray(value) && value.length === 2 &&
+ (typeof value[0] === "string" || Number.isInteger(value[0])) && Number.isInteger(value[1]) && value[1] >= 0;
+ const linked = (inputs, key) => Object.hasOwn(inputs, key) && stageLink(inputs[key]);
+ /** An unlisted node that may itself be a sampler stage: the walk cannot judge it. */
+ const stageShaped = ({ class_type, inputs }) => !TABLE_CLASSES.has(class_type) &&
+ (STAGE_INPUTS.shaped.some(key => linked(inputs, key)) || (linked(inputs, "model") && STAGE_INPUTS.with_model.some(key => linked(inputs, key))));
+
+ /** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */
+ function upstreamStages(graph, origin, value, budget) {
+ if (!stageLink(value)) return null;
+ const found = [], queue = [String(value[0])], queued = new Set(queue);
+ for (let head = 0; head < queue.length; head++) {
+ const id = queue[head];
+ if (id === origin || ++budget.visits > MAX_VISITS || !Object.hasOwn(graph, id)) return null;
+ if (samplerEntry(graph, id)) { found.push(id); continue; }
+ if (stageShaped(graph[id])) return null;
+ for (const input of Object.values(graph[id].inputs)) {
+ const next = stageLink(input) ? String(input[0]) : null;
+ if (next !== null && !queued.has(next)) { queued.add(next); queue.push(next); }
+ }
+ }
+ return found;
+ }
+
+ /**
+ * Collect a final stage and every stage in its image ancestry into `stages`, as the
+ * resolver judges a detailer chain. `later` holds the detailers already being judged
+ * below this one, so a cyclic chain fails instead of recursing. False when the chain
+ * cannot be walked within the bounds.
+ */
+ // `stages` is shared on purpose across one result's candidates: re-adding a stage is
+ // harmless, and MAX_STAGES caps the union, not each candidate.
+ function collectStages(graph, id, later, stages, budget) {
+ stages.add(id);
+ if (stages.size > MAX_STAGES || later.size >= MAX_STAGES) return false;
+ const entry = samplerEntry(graph, id);
+ if (entry.image_stage === undefined) return true;
+ const inputs = graph[id].inputs;
+ const upstream = upstreamStages(graph, id, Object.hasOwn(inputs, entry.image_stage) ? inputs[entry.image_stage] : undefined, budget);
+ if (upstream === null || upstream.some(stage => later.has(stage))) return false;
+ const below = new Set(later).add(id);
+ return upstream.every(stage => collectStages(graph, stage, below, stages, budget));
+ }
+
+ /** Spec section 10 merge: equal known values stay known, differing ones are ambiguous. */
+ function mergeFacts(sets, source) {
+ if (sets.length === 1) return sets[0];
+ return Object.fromEntries(FACT_FIELDS.map(field => {
+ const facts = sets.map(set => set[field]);
+ if (new Set(facts.filter(item => item.status === "known").map(item => item.value)).size > 1) return [field, fact("ambiguous", source)];
+ const [first] = facts;
+ const agreed = facts.every(item => item.status === first.status && item.value === first.value);
+ // A merged fact names no single sampler: it is every stage's value.
+ return [field, agreed ? fact(first.status, source, null, first.value, first.reason) : fact("unresolved", source)];
+ }));
+ }
+
+ function samplerFacts(ctx, samplerId, source) {
+ const sampler = ctx.graph[samplerId];
const inputs = sampler.inputs;
- const advanced = sampler.class_type === "SamplerCustomAdvanced";
- const custom = advanced || sampler.class_type === "SamplerCustom";
+ const { facts: shape, fact_overrides: overrides = [] } = SAMPLERS[sampler.class_type];
+ // A set override (a script, an XY plot, custom sigmas) replaces the settings below.
+ if (overrides.some(name => Object.hasOwn(inputs, name) && inputs[name] !== null && inputs[name] !== "")) {
+ return Object.fromEntries(FACT_FIELDS.map(name => [name, fact("unresolved", source, samplerId)]));
+ }
+ const advanced = shape === "custom-advanced";
+ const custom = advanced || shape === "custom";
let settings = inputs;
let settingsAdapter;
const settingsPath = new Set();
@@ -510,6 +622,45 @@
return facts;
}
+ /**
+ * Facts follow the server's resolution. Its `sampler_id` drives them alone when
+ * resolved. Any other result naming a sampler (an ambiguous multistage chain, or an
+ * absent or unresolved prompt of the branch the server chose, e.g. after dropping a
+ * preview-only stage) merges per field across that sampler's stages, independent of
+ * any selection (D5). Ambiguous without a named sampler (no candidates on the wire
+ * until PR3), and unresolved or absent over several listed samplers, merge the same
+ * way across every listed sampler and its stages. Otherwise (no resolution, or one
+ * listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous.
+ */
+ function graphFacts(graph, source, resolution) {
+ if (!object(graph)) return factSet("unresolved", source);
+ // Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
+ // and the server's node ceiling arrives as a `limited` projection.
+ const entries = Object.entries(graph);
+ if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source);
+ const ctx = createContext(graph, null, true);
+ const state = object(resolution) ? resolution.state : undefined;
+ const named = typeof resolution?.sampler_id === "string" ? resolution.sampler_id : null;
+ if (["resolved", "ambiguous", "absent", "unresolved"].includes(state) && named !== null) {
+ if (!samplerEntry(graph, named)) return factSet("unresolved", source);
+ if (state === "resolved") return samplerFacts(ctx, named, source);
+ const stages = new Set();
+ if (!collectStages(graph, named, new Set(), stages, { visits: 0 })) return factSet("unresolved", source);
+ return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
+ }
+ const candidates = entries.filter(([id]) => samplerEntry(graph, id));
+ // Review I-1: an unresolved or absent prompt does not make the run's settings
+ // unknown, so several listed samplers merge the same way.
+ if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
+ // The server's branches: every listed sampler, a detailer judged with its stages.
+ const stages = new Set();
+ if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
+ return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
+ }
+ if (candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
+ return samplerFacts(ctx, candidates[0][0], source);
+ }
+
function validFactWorkflow(workflow) {
if (!object(workflow) || !Array.isArray(workflow.nodes) || (workflow.links !== undefined && !Array.isArray(workflow.links))) return false;
const ids = new Set(), links = new Set(), setters = new Set();
@@ -531,18 +682,51 @@
return true;
}
+ // The inspection resolver's `_footer` (Python): R5 also reads a bare `Steps: N`
+ // footer, and sd-dynamic-prompts' one-line template records after it.
+ const FOOTER_TRAILERS = ["Template:", "Negative Template:"];
+ function trailerLines(text) {
+ const lines = text.trim().split(/\r?\n|\r/).filter((line, index, all) => all.length > 1 || line);
+ const order = lines.map(line => FOOTER_TRAILERS.findIndex(name => line.startsWith(name)));
+ return !order.includes(-1) && order.every((value, index) => index === 0 || value > order[index - 1]);
+ }
+ function settingsFooter(parameters) {
+ let matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
+ if (!matches.length) matches = [...parameters.matchAll(/^Steps:\s*\d+\s*$/gm)];
+ if (matches.length !== 1) return null;
+ return trailerLines(parameters.slice(matches[0].index + matches[0][0].length)) ? matches[0] : null;
+ }
+ /** R10: split settings on commas outside A1111's quoted values; unbalanced quotes split as before. */
+ function settingsParts(line) {
+ const parts = [];
+ let start = 0, quoted = false;
+ for (let index = 0; index < line.length; index++) {
+ if (quoted && line[index] === "\\") index++;
+ else if (line[index] === '"') quoted = !quoted;
+ else if (line[index] === "," && !quoted) { parts.push(line.slice(start, index)); start = index + 1; }
+ }
+ if (quoted) return line.split(",");
+ parts.push(line.slice(start));
+ return parts;
+ }
+ function settingValue(value) {
+ if (!/^"(?:\\.|[^\\"])*"$/.test(value)) return value;
+ try { return JSON.parse(value); } catch { return value; }
+ }
+
function parameterFacts(parameters) {
const source = "embedded-parameters";
- const matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
- if (matches.length !== 1 || parameters.slice(matches[0].index + matches[0][0].length).trim()) return factSet("unresolved", source);
- const body = parameters.slice(0, matches[0].index).replace(/[\r\n]+$/, "");
+ const footer = settingsFooter(parameters);
+ if (!footer) return factSet("unresolved", source);
+ // R4: like the resolver, text starting with the delimiter has an empty positive.
+ const body = "\n" + parameters.slice(0, footer.index).replace(/[\r\n]+$/, "");
if (!body.split("\nNegative prompt:")[0].trim() || body.split("\nNegative prompt:").length > 2) return factSet("unresolved", source);
const settings = new Map();
- for (const part of matches[0][0].split(",")) {
+ for (const part of settingsParts(footer[0])) {
const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/);
if (!match) return factSet("unresolved", source);
const [, key, value] = match;
- settings.set(key, settings.has(key) ? null : value);
+ settings.set(key, settings.has(key) ? null : settingValue(value));
}
const keys = { seed: "Seed", steps: "Steps", cfg: "CFG scale", sampler: "Sampler", scheduler: "Schedule type", model: "Model" };
return Object.fromEntries(FACT_FIELDS.map(field => {
@@ -575,6 +759,18 @@
}
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
+ // For the semantics sync test only; not part of the legacy page contract.
+ Object.defineProperty(api, "_factsTables", {
+ value: Object.freeze({
+ configuration_outputs: CONFIGURATION_OUTPUTS,
+ model_outputs: MODEL_OUTPUTS,
+ model_passthroughs: MODEL_PASSTHROUGHS,
+ scalar_outputs: SCALAR_OUTPUTS,
+ }),
+ });
+ Object.defineProperty(api, "_samplerTable", { value: SAMPLERS });
+ Object.defineProperty(api, "_tableClasses", { value: TABLE_CLASSES });
+ Object.defineProperty(api, "_stageInputs", { value: STAGE_INPUTS });
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ComfyMetadata = api;
})(typeof window !== "undefined" ? window : globalThis);
diff --git a/web/js/inspection-semantics.json b/web/js/inspection-semantics.json
new file mode 100644
index 00000000..5d770d86
--- /dev/null
+++ b/web/js/inspection-semantics.json
@@ -0,0 +1,270 @@
+{
+ "schema": 1,
+ "samplers": {
+ "KSampler": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent_image",
+ "facts": "ksampler"
+ },
+ "KSamplerAdvanced": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent_image",
+ "facts": "ksampler"
+ },
+ "SamplerCustom": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent_image",
+ "facts": "custom"
+ },
+ "SamplerCustomAdvanced": {
+ "strategy": "custom-advanced",
+ "conditioning": { "guider": "guider" },
+ "latent": "latent_image",
+ "facts": "custom-advanced"
+ },
+ "FaceDetailer": {
+ "strategy": "detailer-stage",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "image_stage": "image",
+ "facts": "ksampler",
+ "prompt_overrides": ["wildcard"]
+ },
+ "DetailerForEach": {
+ "strategy": "detailer-stage",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "image_stage": "image",
+ "facts": "ksampler",
+ "prompt_overrides": ["wildcard"]
+ },
+ "UltimateSDUpscale": {
+ "strategy": "detailer-stage",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "image_stage": "image",
+ "facts": "ksampler"
+ },
+ "UltimateSDUpscaleNoUpscale": {
+ "strategy": "detailer-stage",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "image_stage": "upscaled_image",
+ "facts": "ksampler"
+ },
+ "ClownsharKSampler_Beta": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent_image",
+ "facts": "ksampler",
+ "prompt_overrides": ["options"],
+ "fact_overrides": ["options", "sigmas"]
+ },
+ "KSampler (Efficient)": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent_image",
+ "facts": "ksampler",
+ "prompt_overrides": ["script"],
+ "fact_overrides": ["script"]
+ },
+ "ttN KSampler_v2": {
+ "strategy": "ksampler",
+ "conditioning": { "positive": "positive", "negative": "negative" },
+ "latent": "latent",
+ "facts": "ksampler",
+ "prompt_overrides": ["adv_xyPlot"],
+ "fact_overrides": ["adv_xyPlot"]
+ }
+ },
+ "guiders": {
+ "CFGGuider": { "strategy": "cfg", "positive": "positive", "negative": "negative" },
+ "BasicGuider": {
+ "strategy": "basic",
+ "positive": "conditioning",
+ "absent_reason": "Guider has no negative input"
+ }
+ },
+ "conditioning": [
+ { "class": "FluxGuidance", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ReferenceLatent", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ConditioningSetArea", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ConditioningSetAreaPercentage", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ConditioningSetAreaStrength", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ConditioningSetMask", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ConditioningSetTimestepRange", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ControlNetApply", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ControlNetApplyAdvanced", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "ControlNetApplyAdvanced", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "ControlNetApplySD3", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "ControlNetApplySD3", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "InpaintModelConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "InpaintModelConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "WanImageToVideo", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "WanImageToVideo", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "ControlNetInpaintingAliMamaApply", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "ControlNetInpaintingAliMamaApply", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "LTXVConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
+ { "class": "LTXVConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
+ { "class": "FluxKontextMultiReferenceLatentMethod", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "SeedVarianceEnhancer", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
+ { "class": "ApplyInstantIDAdvanced", "slot": 1, "strategy": "passthrough", "input": "positive" },
+ { "class": "ApplyInstantIDAdvanced", "slot": 2, "strategy": "passthrough", "input": "negative" },
+ { "class": "ConditioningZeroOut", "slot": 0, "strategy": "zeroed", "input": "conditioning" }
+ ],
+ "selectors": {
+ "easy ifElse": {
+ "strategy": "boolean-select",
+ "condition": "boolean",
+ "branches": { "true": "on_true", "false": "on_false" }
+ },
+ "Switch conditioning [Crystools]": {
+ "strategy": "boolean-select",
+ "condition": "boolean",
+ "branches": { "true": "on_true", "false": "on_false" }
+ }
+ },
+ "composites": {
+ "ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
+ "ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
+ "ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
+ },
+ "encoders": [
+ { "class": "CLIPTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
+ { "class": "CLIPTextEncodeSDXL", "slot": 0, "strategy": "parts-equal", "inputs": ["text_g", "text_l"] },
+ { "class": "CLIPTextEncodeFlux", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "t5xxl"] },
+ { "class": "CLIPTextEncodeSD3", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "clip_g", "t5xxl"] },
+ { "class": "TextEncodeQwenImageEdit", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
+ { "class": "TextEncodeQwenImageEditPlus", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
+ { "class": "TextEncodeQwenImage21", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
+ { "class": "TextEncodeQwenImage21", "slot": 1, "strategy": "single", "inputs": ["negative_prompt"] },
+ { "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
+ { "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] },
+ {
+ "class": "smZ CLIPTextEncode",
+ "slot": 0,
+ "strategy": "flag-parts",
+ "inputs": ["text", "text_g", "text_l"],
+ "params": { "flag": "with_SDXL" }
+ }
+ ],
+ "strings": [
+ { "class": "StringConstant", "slot": 0, "strategy": "literal", "inputs": ["string"] },
+ { "class": "TextInput", "slot": 0, "strategy": "literal", "inputs": ["text"] },
+ { "class": "DisplayText", "slot": 0, "strategy": "literal", "inputs": ["text"] },
+ { "class": "ttN text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
+ { "class": "CR Text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
+ { "class": "CR Prompt Text", "slot": 0, "strategy": "literal", "inputs": ["prompt"] },
+ { "class": "TextBoxMira", "slot": 0, "strategy": "literal", "inputs": ["text"] },
+ {
+ "class": "StringConstantMultiline",
+ "slot": 0,
+ "strategy": "strip-newlines",
+ "inputs": ["string"],
+ "params": { "flag": "strip_newlines" }
+ },
+ {
+ "class": "JoinStringMulti",
+ "slot": 0,
+ "strategy": "join-multi",
+ "inputs": ["string_1"],
+ "params": {
+ "count": "inputcount",
+ "delimiter": "delimiter",
+ "list_flag": "return_list",
+ "input_prefix": "string_"
+ }
+ }
+ ],
+ "runtime": ["PromptSearchList"],
+ "savers": {
+ "SaveImage": {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}_.{ext}"
+ },
+ "SaveAnimatedWEBP": {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}_.{ext}"
+ },
+ "SaveAnimatedPNG": {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}_.{ext}"
+ },
+ "SaveVideo": {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "video",
+ "pattern": "{name}_{counter:05}_.{ext}"
+ },
+ "SaveWEBM": {
+ "strategy": "comfy-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}_.{ext}"
+ },
+ "VHS_VideoCombine": {
+ "strategy": "vhs-counter",
+ "prefix_input": "filename_prefix",
+ "image_input": "images",
+ "pattern": "{name}_{counter:05}.{ext}"
+ }
+ },
+ "non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
+ "stage_inputs": {
+ "shaped": [
+ "latent_image",
+ "guider",
+ "positive",
+ "negative",
+ "pipe",
+ "basic_pipe",
+ "detailer_pipe",
+ "sampler",
+ "sigmas",
+ "noise",
+ "text_embeds",
+ "hyvid_embeds"
+ ],
+ "with_model": ["conditioning", "neg_conditioning", "samples", "latent"]
+ },
+ "facts": {
+ "configuration_outputs": {
+ "RandomNoise": { "kind": "noise", "fields": ["noise_seed"] },
+ "KSamplerSelect": { "kind": "sampler", "fields": ["sampler_name"] },
+ "BasicScheduler": { "kind": "sigmas", "fields": ["steps", "scheduler"] },
+ "Flux2Scheduler": { "kind": "sigmas", "fields": ["steps"], "type_fields": ["scheduler"] },
+ "CFGGuider": { "kind": "guider", "fields": ["cfg", "model"] },
+ "BasicGuider": { "kind": "guider", "fields": ["model"] }
+ },
+ "model_outputs": {
+ "CheckpointLoader": "ckpt_name",
+ "CheckpointLoaderSimple": "ckpt_name",
+ "unCLIPCheckpointLoader": "ckpt_name",
+ "UNETLoader": "unet_name"
+ },
+ "model_passthroughs": [
+ "LoraLoader",
+ "LoraLoaderModelOnly",
+ "ModelSamplingFlux",
+ "ModelSamplingAuraFlow",
+ "ModelSamplingSD3",
+ "Lora Loader (LoraManager)",
+ "Power Lora Loader (rgthree)",
+ "ModelPatchTorchSettings",
+ "CFGNorm",
+ "PerturbedAttentionGuidance",
+ "CR Apply LoRA Stack"
+ ],
+ "scalar_outputs": {
+ "PrimitiveInt": "number",
+ "PrimitiveFloat": "number",
+ "PrimitiveString": "string",
+ "PrimitiveStringMultiline": "string"
+ }
+ }
+}
diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs
index a6f048f7..3987ced7 100644
--- a/web/js/workspace/file-inspector.mjs
+++ b/web/js/workspace/file-inspector.mjs
@@ -85,7 +85,7 @@ function formatBytes(value) {
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
-export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
+export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -145,7 +145,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
}
if (chips.childNodes.length) inner.append(chips);
const prompts = el('div'); inner.append(prompts);
- const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false});
+ const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (library?.status === 'linked') {
const vocabulary = el('div', undefined, 'file-vocabulary');
@@ -226,7 +226,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
inner.append(raw);
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
- const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
+ const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
}
if (record.historyTarget) {
diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs
index 3997a1d6..9b7fb6b5 100644
--- a/web/js/workspace/inspection-state.mjs
+++ b/web/js/workspace/inspection-state.mjs
@@ -9,6 +9,10 @@ const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model'];
const RECORD_FORMATS = ['original-text', 'reserialized-json', 'invalid', 'limited'];
const TEXTLESS_FORMATS = ['invalid', 'limited'];
const REASON_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling', 'unsupported-node', 'runtime-value', 'composite', 'no-sampler', 'malformed', 'multistage-differs'];
+// Only these codes name the blocking node; node IDs and class names are bounded.
+const NAMED_CODES = ['unsupported-node', 'runtime-value', 'composite'];
+const NAME_LENGTH = 200, MESSAGE_LENGTH = 512;
+const boundedText = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const validScope = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) && value.length === 64;
@@ -77,7 +81,8 @@ function promptsFor(value) {
if (record === undefined) continue;
if (!object(record) || typeof record.status !== 'string' ||
(record.text !== undefined && record.text !== null && typeof record.text !== 'string') ||
- (record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role');
+ (record.status === 'resolved' && !Object.hasOwn(confirmed, role)) ||
+ (record.message !== undefined && (record.status === 'resolved' || !boundedText(record.message, MESSAGE_LENGTH)))) throw new TypeError('Invalid inspection role');
}
return value;
}
@@ -141,7 +146,8 @@ function resolutionFor(value) {
const code = value.reason_code ?? null, node = value.node ?? null;
// Codes explain a non-resolved outcome; a blocking node is named only with its code.
if ((code !== null && (!REASON_CODES.includes(code) || ['resolved', 'absent'].includes(value.state))) ||
- (node !== null && (code === null || !object(node) || typeof node.id !== 'string' || (node.class_type !== null && typeof node.class_type !== 'string')))) throw new TypeError('Invalid inspection resolution reason');
+ (node !== null && (!NAMED_CODES.includes(code) || !object(node) || Object.keys(node).sort().join() !== 'class_type,id' ||
+ !boundedText(node.id, NAME_LENGTH) || (node.class_type !== null && !boundedText(node.class_type, NAME_LENGTH))))) throw new TypeError('Invalid inspection resolution reason');
return value;
}
diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs
index fc53cffa..5c3db6e0 100644
--- a/web/js/workspace/prompt-blocks.mjs
+++ b/web/js/workspace/prompt-blocks.mjs
@@ -11,6 +11,7 @@ const sources = {
execution: 'Recorded at execution',
'graph-inferred': 'Inferred from the saved workflow',
'embedded-parameters': 'Embedded generation parameters',
+ 'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
'saved-association': 'Saved library association',
};
const statuses = {
@@ -40,7 +41,11 @@ function childScope(parentScope) {
return scope;
}
-export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true}) {
+export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
+ // A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
+ // context so assistive technology never meets two identically named prompt regions in one dialog.
+ const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
+ const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
@@ -86,7 +91,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
const record = state.prompts?.[role];
const block = el('section', undefined, `prompt-block prompt-${role}`);
- block.setAttribute('aria-label', title);
+ block.setAttribute('aria-label', contextLabel ? named(role) : title);
const heading = el('div', undefined, 'prompt-block-heading');
heading.append(el('h3', title));
block.append(heading);
@@ -100,12 +105,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
block.append(text);
}
const actions = el('div', undefined, 'prompt-actions');
- const send = createSendButton(`Send ${role} prompt`);
+ const send = createSendButton(`Send ${action(role)}`);
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
const copy = createButton('', 'ca-icon-button');
- copy.setAttribute('aria-label', `Copy ${role} prompt`);
- copy.title = `Copy ${role} prompt`;
+ copy.setAttribute('aria-label', `Copy ${action(role)}`);
+ copy.title = `Copy ${action(role)}`;
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
@@ -113,7 +118,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
heading.append(actions);
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
block.append(status);
- } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
+ } else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
container.append(block);
}
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
diff --git a/web/js/workspace/recorded-run.mjs b/web/js/workspace/recorded-run.mjs
index 8bfb8429..29b815bb 100644
--- a/web/js/workspace/recorded-run.mjs
+++ b/web/js/workspace/recorded-run.mjs
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
const receiptNodes = new Map();
const evidenceValues = new Map();
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
- const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
+ const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
function selectedReceipt() {
const selected = latest.selection.receipt;
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));