feat(inspect): local candidate selection matching the inspector

The browser port of the inspection resolver now answers every record set
the server does, so no path is left to the server any more:

- several sampler candidates, with the identical-branch rule (#270): a
  compare grid whose branches all resolve to one pair (text and recipe)
  is resolved, any other mix is ambiguous;
- stages whose outputs reach only previews are dropped first;
- detailer stages are judged with the samplers in their image ancestry
  (multistage-differs, cycles, depth and the per-stage visit budgets),
  and a sampler that only feeds detailers is not a candidate of its own;
- A1111 `parameters`, with Python's multiline anchors, `\s` and Unicode
  `\d`, the bare footer and Template trailers, counted in code points.

The parity corpus gains the selection cases and a seeded fuzzer, and
every inspection vector now gives the server's prompts, resolution and
facts locally. 16 of 17 mutations are caught; the uncaught one (not
flattening an ambiguous upstream stage) cannot change any answer.
This commit is contained in:
Vito Sansevero
2026-10-22 17:43:33 -07:00
parent 564049b373
commit 564de9e3f1
11 changed files with 1032 additions and 100 deletions
File diff suppressed because one or more lines are too long
+22 -14
View File
@@ -14,9 +14,11 @@
// the carrier rule alone, as the server selects from ffprobe's tag map.
// A {records} entry (records null = an unreadable file) answers
// {classified: null, result: null, metadata} for the projection alone.
// A {mode: 'resolve'} entry (PR L3) answers {evidence} or {gap: true}: resolveLocalEvidence of its
// {path}'s local read, or of its {records} texts, with the repository semantics table
// (gap: a path the port leaves to Task 9).
// A {mode: 'resolve'} entry (PR L3) answers {evidence}: resolveLocalEvidence of its {records} texts
// with the repository semantics table. With a {path} it reads the file locally and also answers
// {facts} (and {unreadable: true} with the server's unreadable evidence when nothing could be read):
// extractGenerationFacts on the local payload through projectInspectionPayload, exactly as
// tests/js/support/inspection-parity-facts.mjs reads the server's response for vectors.json.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
@@ -31,17 +33,20 @@ const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace');
const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs')));
const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs')));
const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
const { resolveLocalEvidence, compileSemantics, LocalResolverGap } = await import(moduleUrl(path.join(web, 'local-resolver.mjs')));
const { resolveLocalEvidence, compileSemantics, unreadableEvidence } = await import(moduleUrl(path.join(web, 'local-resolver.mjs')));
const { buildLocalPayload } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
const { projectInspectionPayload } = await import(moduleUrl(path.join(web, 'inspection-state.mjs')));
const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '..', '..', '..', 'web', 'js', 'inspection-semantics.json'), 'utf8')));
const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
/** The local evidence for one set of record texts, or {gap: true}. */
function resolved(records) {
try {
return { evidence: resolveLocalEvidence(records, semantics) };
} catch (error) {
if (error instanceof LocalResolverGap) return { gap: true };
throw error;
}
/** The local evidence for one set of record texts. */
const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) });
/** The six facts for one local read (no legacy fill here, as in the vectors' own harness). */
function localFacts(read, file, classification, evidence) {
const payload = { ...buildLocalPayload({ read, file, classification }), prompts: evidence.prompts, resolution: evidence.resolution };
const record = projectInspectionPayload(payload, { kind: 'local-file', key: 'local:1', path: file.name }, null, extractGenerationFacts);
return Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }]));
}
const selectionRead = (selection) => selection.status === 'complete' ?
@@ -54,9 +59,12 @@ for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
const { name } = entry;
if (entry.mode === 'resolve') {
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
const { classification, result } = await readLocalFile(await fs.openAsBlob(entry.path), { pngMetadata });
const blob = await fs.openAsBlob(entry.path);
const { classification, result } = await readLocalFile(blob, { pngMetadata });
const read = classification === null ? null : toInspectionRead(classification, result).read;
output[name] = read === null ? { unreadable: true } : resolved(read.records);
if (classification === null) { output[name] = { unreadable: true }; continue; }
const answer = read === null ? { unreadable: true, evidence: unreadableEvidence() } : resolved(read.records);
output[name] = { ...answer, facts: localFacts(result, { name: path.basename(entry.path), size: blob.size }, classification, answer.evidence) };
continue;
}
if (entry.records !== undefined) {
+32 -10
View File
@@ -1,4 +1,4 @@
// PR L3 Task 8: the server's inspection resolver in this browser. The values below are what CPython answers
// PR L3 Tasks 8-9: the server's inspection resolver in this browser. The values below are what CPython answers
// (json.loads, len, str.strip, repr, utils/prompt_inspector.inspect_prompt_evidence); the full comparison with the
// server runs in tests/test_local_inspect_parity.py through tests/js/support/local-inspect-oracle.mjs.
import test from 'node:test';
@@ -82,9 +82,35 @@ test('execution evidence replaces a PromptManagerPair text when it matches the n
assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs');
});
test('several samplers and A1111 parameters are left to Task 9', () => {
assert.throws(() => resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics), resolver.LocalResolverGap);
assert.throws(() => resolver.resolveLocalEvidence({parameters: 'a fox\nSteps: 20, Sampler: euler'}, semantics), resolver.LocalResolverGap);
test('identical sampler branches resolve to their shared pair, differing ones stay ambiguous (#270)', () => {
const shared = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics);
assert.deepEqual(shared.resolution, {state: 'resolved', sampler_id: null, reason: 'Every sampler branch uses the same prompt pair', reason_code: null, node: null});
assert.equal(shared.prompts.positive.text, 'a fox');
const differ = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler({positive: ['8', 0]}), '8': encode('a cat')}))}, semantics);
assert.deepEqual(differ.resolution, {state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches', reason_code: null, node: null});
assert.equal(differ.prompts.positive.status, 'ambiguous');
});
test('a detailer stage is compared with the sampler in its image ancestry', () => {
const graph = base({'10': {class_type: 'VAEDecode', inputs: {samples: ['3', 0]}}, '8': encode('a cat'), '12': {class_type: 'SaveImage', inputs: {images: ['11', 0]}},
'11': {class_type: 'FaceDetailer', inputs: {image: ['10', 0], model: ['4', 0], positive: ['8', 0], negative: ['7', 0]}}});
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph)}, semantics);
assert.deepEqual(evidence.resolution, {state: 'ambiguous', sampler_id: '11', reason: 'Sampler stages have different prompts', reason_code: 'multistage-differs', node: null});
assert.equal(evidence.prompts.positive.message, undefined);
});
test('A1111 parameters split on the unique settings footer as Python does', () => {
const read = text => resolver.resolveLocalEvidence({parameters: text}, semantics);
assert.deepEqual(read('a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler').prompts, {
positive: {status: 'resolved', text: 'a fox', source: 'embedded-parameters'}, negative: {status: 'resolved', text: 'blur', source: 'embedded-parameters'},
});
// Python's multiline ^ and $ break only at \n: a U+2028 or a lone \r never starts the footer line.
for (const text of ['a fox\u2028Steps: 20, x', 'a fox\rSteps: 20, x']) assert.equal(read(text).resolution.reason, 'A1111 settings footer is not unique');
// \d is any Unicode decimal digit and \s spans line breaks, as in Python's re.
assert.equal(read('a fox\nSteps: \u0662\u0660, x').resolution.reason, 'Negative prompt delimiter is absent');
assert.equal(read('a fox\nSteps:\n 20 \n, x').resolution.state, 'absent');
assert.equal(read('a fox\nSteps: 1, x\nNegative Template: n\nTemplate: t').resolution.reason_code, 'malformed');
assert.equal(read('\u{1f600}'.repeat(60000) + '\nSteps: 1, x').resolution.state, 'absent', 'the length limit counts code points');
});
test('an unusable semantics table is refused', () => {
@@ -98,15 +124,11 @@ test('committed server answers equal the local evidence', () => {
const canonical = value => Array.isArray(value) ? value.map(canonical) :
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
let compared = 0;
for (const {name, records, gap, evidence} of vectors) {
if (gap) {
assert.throws(() => resolver.resolveLocalEvidence(records, semantics), resolver.LocalResolverGap, name);
continue;
}
for (const {name, records, evidence} of vectors) {
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
compared++;
}
assert.ok(compared > 140, `compared ${compared}`);
assert.ok(compared > 280, `compared ${compared}`);
});
test('a list or object workflow node type is malformed evidence, as on the server (#286)', () => {
+31 -15
View File
@@ -7,7 +7,8 @@ same bytes. ``golden_cases`` names one case per traced strategy, bound and
malformed shape; ``fuzz_cases`` builds coherent single-sampler graphs from the
same node vocabulary, then corrupts some of them. All content is synthetic.
``single_chain`` says, from the server's own sampler table, whether a record
set resolves without candidate selection (the Task 8 subset).
set resolves without candidate selection (the Task 8 subset);
``local_resolver_selection_cases`` holds the candidate-selection cases.
"""
import argparse
@@ -1300,26 +1301,41 @@ def single_chain(records):
)
def _utf8(records):
"""Whether the record texts can be written as UTF-8 (no lone surrogate)."""
try:
json.dumps(records, ensure_ascii=False).encode("utf-8")
except UnicodeEncodeError:
return False
return True
def committed_vectors():
"""The committed record sets with the server's evidence, in a stable order."""
try:
from tests import local_resolver_selection_cases as selection
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import local_resolver_selection_cases as selection
named = {**golden_cases(), **selection.selection_cases()}
cases = {
name: records
for name, records in golden_cases().items()
if len(json.dumps(records)) <= MAX_VECTOR_CHARS
for name, records in named.items()
if len(json.dumps(records)) <= MAX_VECTOR_CHARS and _utf8(records)
}
cases.update(fuzz_cases(*COMMITTED_FUZZ))
vectors = []
for name, records in cases.items():
gap = not single_chain(records)
evidence = (
None
if gap
else inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
)
vectors.append(
{"name": name, "records": records, "gap": gap, "evidence": evidence}
)
return {"version": 1, "vectors": vectors}
cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ))
vectors = [
{
"name": name,
"records": records,
"evidence": inspect_prompt_evidence(
records, None, profile=INSPECTION_PROFILE
),
}
for name, records in cases.items()
]
return {"version": 2, "vectors": vectors}
def main():
+574
View File
@@ -0,0 +1,574 @@
"""Candidate-selection record sets for the local resolver parity test (PR L3 Task 9).
``local_resolver_cases`` covers one sampler's tracing. These cases reach the
rest of ``_resolve``: several sampler candidates and the identical-branch rule
(#270), stages that only reach previews, detailer stages and their upstream
samplers (multistage), and A1111 ``parameters`` with every footer shape. The
server's ``inspect_prompt_evidence`` is the oracle; nothing here states an
expected answer. All content is synthetic.
"""
import json
import random
try:
from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
def _encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
def _sampler(positive, negative, latent=None, name="KSampler"):
inputs = {"seed": 1, "model": ["4", 0], "positive": positive}
if negative is not None:
inputs["negative"] = negative
inputs["latent_image"] = latent or ["5", 0]
return {"class_type": name, "inputs": inputs}
def _decode(sampler_id):
return {"class_type": "VAEDecode", "inputs": {"samples": [sampler_id, 0]}}
def _save(image, name="SaveImage"):
return {"class_type": name, "inputs": {"images": image}}
def _detailer(image, positive, negative, name="FaceDetailer", **extra):
inputs = {"image": image, "model": ["4", 0], "positive": positive}
inputs["negative"] = negative
inputs.update(extra)
return {"class_type": name, "inputs": inputs}
def _base(**nodes):
graph = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
"6": _encode("a fox"),
"7": _encode("blurry"),
"8": _encode("a cat"),
}
graph.update(nodes)
return graph
def _samplers():
"""Several unanchored samplers: identical, differing and mixed branches."""
same = _sampler(["6", 0], ["7", 0])
cases = {
"two-identical": _base(**{"3": same, "9": same}),
"two-differ": _base(**{"3": same, "9": _sampler(["8", 0], ["7", 0])}),
"two-negative-differs": _base(**{"3": same, "9": _sampler(["6", 0], ["8", 0])}),
"identical-without-negative": _base(
**{"3": _sampler(["6", 0], None), "9": _sampler(["6", 0], None)}
),
"one-unresolved": _base(**{"3": same, "9": _sampler(["missing", 0], ["7", 0])}),
"both-unresolved": _base(
**{"3": _sampler(["x", 0], ["7", 0]), "9": _sampler(["y", 0], ["7", 0])}
),
"three-identical-one-advanced": _base(
**{
"3": same,
"9": same,
"10": _sampler(["6", 0], ["7", 0], name="KSamplerAdvanced"),
}
),
# Equal texts, different recipes: a PromptManager pair against an encoder.
"same-text-other-recipe": _base(
**{
"3": same,
"9": _sampler(["11", 0], ["7", 0]),
"11": {
"class_type": "PromptManager",
"inputs": {
"text": "fox",
"prepend_text": "a",
"append_text": "",
"clip": ["4", 1],
},
},
}
),
"multistage-latent": _base(
**{"3": same, "9": _sampler(["6", 0], ["7", 0], latent=["3", 0])}
),
}
selector = {
"class_type": "easy ifElse",
"inputs": {"boolean": ["4", 0], "on_true": ["6", 0], "on_false": ["8", 0]},
}
cases["selector-and-plain"] = _base(
**{"3": same, "9": _sampler(["12", 0], ["7", 0]), "12": selector}
)
return cases
def _previews():
"""Stages whose outputs reach only previews are not the saved file's."""
same, other = _sampler(["6", 0], ["7", 0]), _sampler(["8", 0], ["7", 0])
return {
"preview-dropped": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0]),
"16": _save(["14", 0], "PreviewImage"),
}
),
"all-previews-kept": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0], "PreviewImage"),
"16": _save(["14", 0], "Image Comparer (rgthree)"),
}
),
"preview-and-file": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0]),
"16": _save(["14", 0], "PreviewImage"),
"17": _save(["14", 0]),
}
),
"unconsumed-stage-kept": _base(
**{
"3": same,
"9": other,
"14": _decode("9"),
"16": _save(["14", 0], "PreviewImage"),
}
),
# A list or object class_type is never a preview class (#286).
"preview-unhashable-class": _base(
**{
"3": same,
"9": other,
"14": _decode("9"),
"16": {"class_type": ["PreviewImage"], "inputs": {"images": ["14", 0]}},
"17": {"class_type": {"a": 1}, "inputs": {"images": ["3", 0]}},
}
),
"preview-through-bad-node": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"15": _save(["13", 0]),
"16": _save(["9", 0], "PreviewImage"),
"18": "not a node",
"19": {"class_type": "Note", "inputs": {"x": ["18", 0]}},
}
),
}
def _detailers():
"""Detailer stages judged with the samplers in their image ancestry."""
same = _sampler(["6", 0], ["7", 0])
cases = {
"detailer-same-prompt": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-differs": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-and-own-output": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
"12": _save(["11", 0]),
"13": _save(["10", 0]),
}
),
"detailer-image-literal": _base(
**{
"11": _detailer("not a link", ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-cycle": _base(
**{
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"10": {"class_type": "ImageScale", "inputs": {"image": ["11", 0]}},
"12": _save(["11", 0]),
}
),
"detailer-chain": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"21": _detailer(
["11", 0], ["8", 0], ["7", 0], name="UltimateSDUpscale"
),
"12": _save(["21", 0]),
}
),
"detailer-chain-same": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"21": _detailer(["11", 0], ["6", 0], ["7", 0], name="DetailerForEach"),
"12": _save(["21", 0]),
}
),
"detailer-two-upstream": _base(
**{
"3": same,
"9": _sampler(["8", 0], ["7", 0]),
"10": {
"class_type": "ImageBlend",
"inputs": {"image1": ["13", 0], "image2": ["14", 0]},
},
"13": _decode("3"),
"14": _decode("9"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-unsupported-stage": _base(
**{
"10": {
"class_type": "MysterySampler",
"inputs": {"positive": ["6", 0], "model": ["4", 0]},
},
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-upstream-unresolved": _base(
**{
"3": _sampler(["gone", 0], ["7", 0]),
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-upstream-selector": _base(
**{
"3": _sampler(["15", 0], ["7", 0]),
"15": {
"class_type": "easy ifElse",
"inputs": {
"boolean": ["4", 0],
"on_true": ["6", 0],
"on_false": ["8", 0],
},
},
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-wildcard": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0], wildcard="[x|y]"),
"12": _save(["11", 0]),
}
),
"detailer-no-upscaled-image": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(
["10", 0], ["6", 0], ["7", 0], name="UltimateSDUpscaleNoUpscale"
),
"12": _save(["11", 0]),
}
),
"detailer-no-sampler-upstream": _base(
**{
"10": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
}
deep = {"3": _sampler(["6", 0], ["7", 0]), "d0": _decode("3")}
for index in range(1, 70):
deep[f"d{index}"] = {
"class_type": "ImageScale",
"inputs": {"image": [f"d{index - 1}", 0]},
}
deep["11"] = _detailer(["d69", 0], ["6", 0], ["7", 0])
deep["12"] = _save(["11", 0])
cases["detailer-deep-ancestry"] = _base(**deep)
# More nested detailer stages than the depth bound.
stacked = {"3": _sampler(["6", 0], ["7", 0]), "s0": _decode("3")}
previous = "s0"
for index in range(1, 67):
stacked[f"s{index}"] = _detailer([previous, 0], ["6", 0], ["7", 0])
previous = f"s{index}"
stacked["12"] = _save([previous, 0])
cases["detailer-stacked-past-depth"] = _base(**stacked)
# Each detailer walks a wide shared image ancestry: the total visit budget
# runs out across stages before any candidate is read.
wide = {
f"l{index}": {"class_type": "LoadImage", "inputs": {}} for index in range(1500)
}
wide["hub"] = {
"class_type": "ImageBatchMany",
"inputs": {f"i{index}": [f"l{index}", 0] for index in range(1500)},
}
for index in range(15):
wide[f"w{index}"] = _detailer(["hub", 0], ["6", 0], ["7", 0])
wide[f"o{index}"] = _save([f"w{index}", 0])
cases["detailers-exhaust-total-visits"] = _base(**wide)
cases["detailer-mutual-cycle"] = _base(
**{
"11": _detailer(["21", 0], ["6", 0], ["7", 0]),
"21": _detailer(["11", 0], ["6", 0], ["7", 0]),
"12": _save(["21", 0]),
}
)
# The sampler's output reaches a preview through more nodes than one
# detailer's allowance: the walk runs out, so the sampler is not absorbed.
long = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
previous = "10"
for index in range(2100):
long[f"c{index}"] = {
"class_type": "ImageScale",
"inputs": {"image": [previous, 0]},
}
previous = f"c{index}"
long["p"] = _save([previous, 0], "PreviewImage")
long["11"] = _detailer(["10", 0], ["6", 0], ["7", 0])
long["12"] = _save(["11", 0])
cases["detailer-output-walk-exhausted"] = _base(**long)
# Every detailer's ancestry walk has its own allowance: the first one is
# wide, and the second still absorbs the first.
chained = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
chained["h1"] = {
"class_type": "ImageBatchMany",
"inputs": {
"i": ["10", 0],
**{f"i{index}": [f"a{index}", 0] for index in range(1500)},
},
}
chained["h2"] = {
"class_type": "ImageBatchMany",
"inputs": {
"i": ["11", 0],
**{f"i{index}": [f"b{index}", 0] for index in range(600)},
},
}
for index in range(1500):
chained[f"a{index}"] = {"class_type": "LoadImage", "inputs": {}}
for index in range(600):
chained[f"b{index}"] = {"class_type": "LoadImage", "inputs": {}}
chained["11"] = _detailer(["h1", 0], ["6", 0], ["7", 0])
chained["21"] = _detailer(["h2", 0], ["6", 0], ["7", 0])
chained["12"] = _save(["21", 0])
cases["detailers-each-reset"] = _base(**chained)
return cases
_FOOTER = "Steps: 20, Sampler: euler, CFG scale: 7"
def _parameters():
"""A1111 ``parameters`` texts: every footer shape and delimiter rule."""
texts = {
"basic": f"a fox\nNegative prompt: blur\n{_FOOTER}",
"no-negative": f"a fox\n{_FOOTER}",
"two-delimiters": f"a\nNegative prompt: b\nNegative prompt: c\n{_FOOTER}",
"leading-delimiter": f"Negative prompt: blur\n{_FOOTER}",
"blank-positive": f" \x85\nNegative prompt: blur\n{_FOOTER}",
"footer-not-last": f"a fox\n{_FOOTER}\nmore text",
"two-footers": f"a\n{_FOOTER}\n{_FOOTER}",
"bare-footer": "a fox\nNegative prompt: blur\nSteps: 20",
"bare-and-full": f"a fox\nSteps: 5\n{_FOOTER}",
"two-bare": "a\nSteps: 1\nSteps: 2",
"template": f"a fox\n{_FOOTER}\nTemplate: {{a|b}}",
"both-templates": f"a\n{_FOOTER}\nTemplate: x\nNegative Template: y",
"templates-reversed": f"a\n{_FOOTER}\nNegative Template: y\nTemplate: x",
"two-templates": f"a\n{_FOOTER}\nTemplate: x\nTemplate: y",
"template-multiline": f"a\n{_FOOTER}\nTemplate: x\nmore",
"crlf": f"a fox\r\nNegative prompt: blur\r\n{_FOOTER}\r\n",
"cr-only": f"a fox\rNegative prompt: blur\r{_FOOTER}",
"cr-template": f"a\n{_FOOTER}\rTemplate: x",
"line-separator": f"a fox
{_FOOTER}",
"next-line": f"a fox\x85{_FOOTER}",
"spaced-steps": "a fox\nSteps:\n 20 \n, x",
"steps-newline-bare": "a fox\nSteps:\n20\n",
"arabic-digits": "a fox\nSteps: ٢٠, Sampler: euler",
"fullwidth-digits": "a fox\nSteps: 2, x",
"no-digits": "a fox\nSteps: , x",
"trailing-spaces": f"a fox \nNegative prompt: blur \n{_FOOTER} \n \n",
"empty-negative": f"a fox\nNegative prompt:\n{_FOOTER}",
"astral-within-limit": "\U0001f600" * 60_000 + f"\n{_FOOTER}",
"over-limit": "x" * 100_001,
"at-limit": "x" * (100_000 - len(_FOOTER) - 1) + "\n" + _FOOTER,
"empty": "",
"only-footer": _FOOTER,
"lone-surrogate": f"\ud800 fox\nNegative prompt: \udfff\n{_FOOTER}",
}
cases = {f"parameters-{name}": {"parameters": text} for name, text in texts.items()}
cases["parameters-with-execution"] = {
"parameters": texts["basic"],
EXECUTION_KEY: json.dumps({"version": 1, "nodes": {}}),
}
return cases
def selection_cases():
"""Named record sets for every Task 9 path, each a ``read_inspection_file`` dict."""
cases = {}
for group in (_samplers(), _previews(), _detailers()):
for name, graph in group.items():
cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)}
cases.update(_parameters())
return cases
class _Selection(_Builder):
"""Random graphs with several samplers, outputs, previews and detailers."""
def sampler(self):
rng = self.rng
positive = self.conditioning(0)
negative = self.conditioning(0) if rng.random() < 0.85 else None
name = rng.choice(["KSampler", "KSampler", "KSamplerAdvanced", "SamplerCustom"])
node_id = self.new_id()
self.nodes[node_id] = _sampler(positive, negative, name=name)
return node_id
def shared(self, ids):
"""A copy of an earlier sampler: an identical branch."""
node_id = self.new_id()
self.nodes[node_id] = json.loads(json.dumps(self.nodes[self.rng.choice(ids)]))
return node_id
def output(self, image):
rng = self.rng
choice = rng.randrange(5)
if choice == 4:
return
name = ["SaveImage", "SaveImage", "PreviewImage", "PreviewAny"][choice]
self.nodes[self.new_id()] = _save(image, name)
def detailer(self, image):
rng = self.rng
node_id = self.new_id()
positive = self.conditioning(1) if rng.random() < 0.5 else None
name = rng.choice(list(_DETAILER_NAMES))
node = _detailer(image, positive, self.conditioning(1), name=name)
if positive is None:
node["inputs"]["positive"] = self.reuse_positive()
if name == "UltimateSDUpscaleNoUpscale":
node["inputs"]["upscaled_image"] = node["inputs"].pop("image")
if name in ("FaceDetailer", "DetailerForEach") and rng.random() < 0.15:
node["inputs"]["wildcard"] = rng.choice(["", "x", ["4", 0]])
self.nodes[node_id] = node
return node_id
def reuse_positive(self):
samplers = [
node
for node in self.nodes.values()
if node.get("class_type")
in ("KSampler", "KSamplerAdvanced", "SamplerCustom")
]
if samplers:
return self.rng.choice(samplers)["inputs"]["positive"]
return self.conditioning(1)
def build(self):
rng = self.rng
ids = [self.sampler()]
for _ in range(rng.randrange(3)):
ids.append(self.shared(ids) if rng.random() < 0.4 else self.sampler())
for sampler_id in list(ids):
decoded = self.new_id()
self.nodes[decoded] = _decode(sampler_id)
image = [decoded, 0]
for _ in range(rng.randrange(3)):
if rng.random() < 0.5:
image = [self.detailer(image), 0]
else:
self.output(image)
self.output(image)
if rng.random() < 0.1:
self.nodes[sampler_id]["inputs"]["latent_image"] = [rng.choice(ids), 0]
graph = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
}
graph.update(self.nodes)
return graph
_DETAILER_NAMES = (
"FaceDetailer",
"DetailerForEach",
"UltimateSDUpscale",
"UltimateSDUpscaleNoUpscale",
)
_PARAMETER_LINES = [
"Negative prompt:",
"Negative prompt: blur",
"Steps: 20, Sampler: euler",
"Steps: 7",
"Steps: x",
"Template: t",
"Negative Template: n",
"Steps:٣, a",
]
def _parameter_text(rng):
parts = []
for _ in range(rng.randint(1, 5)):
if rng.random() < 0.5:
parts.append(rng.choice(_PARAMETER_LINES))
else:
parts.append(rng.choice(SPACES) + rng.choice(WORDS) + rng.choice(SPACES))
joints = ["\n", "\n", "\r\n", "\r", "
", " "]
return "".join(part + rng.choice(joints) for part in parts).rstrip(
rng.choice(["", "\n", " "])
)
def selection_fuzz_cases(seed, count):
"""Seeded several-sampler graphs (sometimes corrupted) and parameters texts."""
rng = random.Random(seed)
cases = {}
for index in range(count):
if rng.random() < 0.2:
cases[f"select-fuzz-{seed}-{index}"] = {"parameters": _parameter_text(rng)}
continue
builder = _Selection(rng)
graph = builder.build()
if rng.random() < 0.2:
builder.nodes = graph
builder.corrupt()
records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)}
records.update(builder.records)
cases[f"select-fuzz-{seed}-{index}"] = records
return cases
+117 -24
View File
@@ -51,17 +51,19 @@ from utils.prompt_inspector import (
read_inspection_file,
)
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
from utils.prompt_pair_resolver import INSPECTION_PROFILE
try:
from tests import inspection_parity as parity
from tests import local_resolver_cases as resolver_cases
from tests import local_resolver_selection_cases as selection_cases
from tests.open_handles import assert_closed
from tests.test_utils_video_metadata import HAVE_FFMPEG, write_video
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import inspection_parity as parity
import local_resolver_cases as resolver_cases
import local_resolver_selection_cases as selection_cases
from open_handles import assert_closed
from test_utils_video_metadata import HAVE_FFMPEG, write_video
@@ -1121,14 +1123,13 @@ def _server_evidence(records):
class LocalResolverParityTests(unittest.TestCase):
"""``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (PR L3 Task 8).
"""``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (L3 Tasks 8-9).
Python is the oracle: every expected value is the server's own answer on the
same record texts. Task 8 ports one sampler's tracing; a record set whose
graph has several sampler candidates or a detailer stage, or only A1111
``parameters``, is a gap the port reports until Task 9, and exactly those
are gaps (``local_resolver_cases.single_chain`` decides from the server's
table).
same record texts. Task 8 ported one sampler's tracing; Task 9 ports
candidate selection (several samplers, the identical-branch rule of #270,
preview-only stages, detailer stages) and A1111 ``parameters``, so no
record set is left to the server any more.
"""
def setUp(self):
@@ -1138,7 +1139,7 @@ class LocalResolverParityTests(unittest.TestCase):
self.directory = Path(temporary.name)
def _compare(self, cases):
"""Assert each record set equal or a recorded gap; return the server answers."""
"""Assert each record set's local evidence equal; return the server answers."""
observed = _run_oracle(
self,
[
@@ -1150,13 +1151,6 @@ class LocalResolverParityTests(unittest.TestCase):
for name, records in cases.items():
with self.subTest(case=name):
local = observed[name]
self.assertEqual(
local.get("gap", False),
not resolver_cases.single_chain(records),
"a gap is exactly a Task 9 path",
)
if local.get("gap"):
continue
server = _server_evidence(records)
answers[name] = server
self.assertEqual(
@@ -1184,20 +1178,17 @@ class LocalResolverParityTests(unittest.TestCase):
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
if vector["profile"] == "inspection"
}
compared = gaps = 0
compared = 0
for name, path in files.items():
with self.subTest(fixture=name):
local = observed[name]
try:
records = read_inspection_file(path).metadata
except MetadataReadError:
self.assertEqual(local, {"unreadable": True})
self.assertTrue(local["unreadable"])
continue
self.assertEqual(
local.get("gap", False), not resolver_cases.single_chain(records)
)
if local.get("gap"):
gaps += 1
# The single-chain subset (Task 8); the rest is Task 9's test below.
if not resolver_cases.single_chain(records):
continue
server = _server_evidence(records)
self.assertEqual(
@@ -1210,9 +1201,111 @@ class LocalResolverParityTests(unittest.TestCase):
server["resolution"]["reason_code"], expect["reason_code"]
)
compared += 1
# f01, f09 (several samplers) and f19 (multistage detailer) wait for Task 9.
self.assertGreaterEqual(compared, 20)
self.assertGreaterEqual(gaps, 1)
def test_local_roles_match_inspection_vectors(self):
"""Every inspection vector: local prompts, resolution and facts equal Images'.
The facts are ``extractGenerationFacts`` (with the legacy fill) on the
local payload, against the facts the committed vectors pin for the
server's response on the same file.
"""
vectors = [
vector
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
if vector["profile"] == "inspection"
]
files = {}
for vector in vectors:
name = vector["fixture"]
if name in parity.TEST_TIME:
path = parity.fixture_path(name, self.directory)
parity.write_png(path, parity.build(name))
else:
path = parity.fixture_path(name)
files[name] = path
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "path": str(path)}
for name, path in files.items()
],
)
for vector in vectors:
name = vector["fixture"]
with self.subTest(fixture=name):
local, expect = observed[name], vector["expect"]
try:
server = _server_evidence(
read_inspection_file(files[name]).metadata
)
except MetadataReadError:
server = unreadable_evidence()
self.assertTrue(local["unreadable"])
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
)
resolution, prompts = (
local["evidence"]["resolution"],
local["evidence"]["prompts"],
)
self.assertEqual(
(
resolution["state"],
resolution["reason"],
resolution["reason_code"],
),
(expect["state"], expect["reason"], expect["reason_code"]),
)
self.assertEqual(
[
parity._role_text(
prompts[role]["status"], prompts[role]["text"]
)
for role in ("positive", "negative")
],
[expect["positive"], expect["negative"]],
)
self.assertEqual(local["facts"], expect["facts"])
self.assertEqual(len(vectors), 28)
def test_local_roles_match_selection_record_sets(self):
answers = self._compare(selection_cases.selection_cases())
reasons = {answer["resolution"]["reason"] for answer in answers.values()}
self.assertTrue(
{
"Every sampler branch uses the same prompt pair",
"Multiple sampler branches",
"Sampler stages have different prompts",
"Cyclic sampler stage ancestry",
"Expected an image link",
"Unsupported sampler stage ancestry",
"Resolution depth limit reached",
"A1111 settings footer is not unique",
"Multiple negative prompt delimiters",
"Negative prompt delimiter is absent",
"Parameters exceed the length limit",
"No A1111 parameters",
}
<= reasons
)
# A stage reaching only previews is dropped: one sampler is named.
self.assertEqual(
answers["select-preview-dropped"]["resolution"]["sampler_id"], "3"
)
def test_local_roles_match_fuzzed_selection_sets(self):
cases = {}
for seed in range(4):
cases.update(selection_cases.selection_fuzz_cases(seed, 150))
answers = self._compare(cases)
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
self.assertGreater(reasons.count("Multiple sampler branches"), 20)
self.assertGreater(reasons.count("Sampler stages have different prompts"), 0)
self.assertGreater(
reasons.count("Every sampler branch uses the same prompt pair"), 0
)
self.assertGreater(reasons.count("A1111 settings footer is not unique"), 0)
def test_local_roles_match_golden_record_sets(self):
answers = self._compare(resolver_cases.golden_cases())
+55
View File
@@ -0,0 +1,55 @@
// A1111 `parameters` for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): a port of
// utils/prompt_pair_resolver.py `_parameters`, `_footer` and `_trailer_lines` for the inspection profile.
// Python's `re` differs from JavaScript's in three ways that matter here, so the patterns spell them out:
// multiline `^`/`$` break only at "\n" (not "\r", U+2028 or U+2029), `\s` is the `str.isspace()` set and `\d`
// is any Unicode decimal digit (Nd). Lengths count code points. No DOM.
import { PY_SPACE, pyLen, pyStrip } from './py-json.mjs';
import { EMPTY_POSITIVE_REASON, result } from './local-resolver-support.mjs';
const START = '(?<=^|\\n)', END = '(?=\\n|$)', SPACE = `[${PY_SPACE}]*`;
// `_FOOTER` and `_BARE_FOOTER` (inspection reads a bare `Steps: N` when there is no full footer).
const FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE},[^\\n]*${END}`, 'gu');
const BARE_FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE}${END}`, 'gu');
const TRAILERS = ['Template:', 'Negative Template:'];
const DELIMITER = '\nNegative prompt:';
/** `_trailer_lines`: empty, or at most one `Template:` line then at most one `Negative Template:` line. */
function trailerLines(text) {
const stripped = pyStrip(text);
const lines = stripped ? stripped.split(/\r?\n|\r/) : [];
const prefixes = lines.map(line => {
const index = TRAILERS.findIndex(name => line.startsWith(name));
return index === -1 ? null : index;
});
return !prefixes.includes(null) && prefixes.every((prefix, index) => index === 0 || prefix > prefixes[index - 1]);
}
/** `_footer(value, inspection=True)`: the unique settings footer match, or null. */
function footer(value) {
let footers = [...value.matchAll(FOOTER)];
if (!footers.length) footers = [...value.matchAll(BARE_FOOTER)];
if (footers.length !== 1) return null;
const [match] = footers;
return trailerLines(value.slice(match.index + match[0].length)) ? match : null;
}
const rstripLineBreaks = text => text.replace(/[\r\n]+$/, '');
/** `_parameters(value, max_text_length, inspection=True)`. */
export function parameters(value, maxTextLength) {
if (typeof value !== 'string' || !value) return result('absent', { reason: 'No A1111 parameters' });
if (pyLen(value) > maxTextLength) return result('unresolved', { reason: 'Parameters exceed the length limit', reasonCode: 'text-limit' });
const match = footer(value);
if (match === null) return result('unresolved', { reason: 'A1111 settings footer is not unique', reasonCode: 'malformed' });
// R4: text that starts with the delimiter has an empty positive (#165).
const body = `\n${rstripLineBreaks(value.slice(0, match.index))}`;
const parts = body.split(DELIMITER);
if (parts.length > 2) return result('ambiguous', { reason: 'Multiple negative prompt delimiters' });
const positive = pyStrip(parts[0]);
if (!positive) return result('unresolved', { reason: EMPTY_POSITIVE_REASON });
const provenance = { format: 'a1111', field: 'parameters', text_source: 'embedded-parameters' };
const side = (text, role) => ({ text, storage_text: text, provenance: { ...provenance, role } });
if (parts.length === 1) return result('absent', { positive: side(positive, 'positive'), reason: 'Negative prompt delimiter is absent' });
const negative = pyStrip(parts[1]);
return result('resolved', { positive: side(positive, 'positive'), negative: side(negative, 'negative') });
}
+174
View File
@@ -0,0 +1,174 @@
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `upstream_stages`, `final_stages`, `file_stages`,
// `non_file_outputs`, `producers_of`, `consumers`, `reaches_output`, plus `_outcome_key`, `_recipe_key` and
// `_shared_pair` (#270). Each function takes the resolver whose budgets and caches it spends, so the order of
// work, and with it every limit, is the server's. No DOM.
import { isDict, pyGet, pyStr } from './py-json.mjs';
import { DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, result } from './local-resolver-support.mjs';
const roleText = side => side !== null && typeof side === 'object' ? side.text : null;
const storageText = side => side !== null && typeof side === 'object' ? side.storage_text : null;
/** `_outcome_key`: what a reader sees of a result, the state and the two role texts. */
export const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]);
/** `_recipe_key`: the outcome plus each side's stored recipe. */
const recipeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative), storageText(item.positive), storageText(item.negative)]);
/** `_shared_pair`: the one pair every sampler branch resolved to, or null (#270). */
export function sharedPair(candidates) {
const first = candidates[0];
if (first.state !== 'resolved') return null;
if (new Set(candidates.map(recipeKey)).size !== 1) return null;
return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates });
}
/** `stage`: one sampler's result; a detailer is compared with its upstream stages. */
export function stage(resolver, samplerId, later) {
if (later.size >= resolver.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' });
const own = resolver.sides(samplerId);
if (own.state === 'unresolved') return own;
const [name, inputs] = resolver.node(samplerId);
const entry = resolver.semantics.samplers.get(name);
if (entry === undefined || entry.strategy !== DETAILER) return own;
let upstream;
try {
upstream = upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
if (upstream.some(nodeId => later.has(nodeId))) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
}
if (!upstream.length) return own;
const deeper = new Set([...later, samplerId]);
const stages = upstream.map(nodeId => stage(resolver, nodeId, deeper));
const blocked = stages.find(item => item.state === 'unresolved');
// A stage that cannot be read cannot be compared: never pick one.
if (blocked !== undefined) return result('unresolved', { samplerId, reason: blocked.reason, reasonCode: blocked.reason_code, node: blocked.node });
const flat = [];
for (const item of [own, ...stages]) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item]));
if (new Set(flat.map(outcomeKey)).size === 1) return own;
return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' });
}
/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */
function upstreamStages(resolver, origin, value) {
if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed');
const found = [], start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
for (let head = 0; head < queue.length; head++) {
const [nodeId, depth] = queue[head];
if (nodeId === origin) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
if (depth >= resolver.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
resolver.budget.spend();
const [name, inputs] = resolver.node(nodeId);
if (resolver.stageClasses.has(name)) { found.push(nodeId); continue; }
if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported sampler stage ancestry', nodeId);
resolver.enqueue(queue, queued, inputs, depth + 1);
}
return found;
}
const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type')).strategy;
/** `final_stages`: drop samplers that only feed detailers (each is judged in that chain). */
export function finalStages(resolver, samplerIds) {
samplerIds = fileStages(resolver, samplerIds);
const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER);
if (!detailers.length) return samplerIds;
let reverse;
try { reverse = consumers(resolver); } catch (error) {
if (!(error instanceof Unresolved)) throw error;
return samplerIds;
}
const absorbed = new Set();
for (const detailer of detailers) {
resolver.budget.reset(resolver.perCandidateVisits);
try {
const [name, inputs] = resolver.node(detailer);
const image = pyGet(inputs, resolver.semantics.samplers.get(name).image_stage);
for (const nodeId of upstreamStages(resolver, detailer, image)) {
if (!reachesOutput(resolver, nodeId, reverse)) absorbed.add(nodeId);
}
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
}
}
const kept = samplerIds.filter(nodeId => !absorbed.has(nodeId));
return kept.length ? kept : samplerIds;
}
/** `file_stages`: stages not proven preview-only; all of them when every one is. */
function fileStages(resolver, samplerIds) {
if (samplerIds.length < 2) return samplerIds;
let toFile, toPreview;
try {
const reverse = consumers(resolver), previews = nonFileOutputs(resolver);
const terminals = [...resolver.graph].filter(([nodeId, node]) => isDict(node) && !previews.has(nodeId) && !reverse.get(nodeId)?.length).map(([nodeId]) => nodeId);
toFile = producersOf(resolver, terminals, previews);
toPreview = producersOf(resolver, [...previews], previews);
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return samplerIds;
}
const kept = samplerIds.filter(nodeId => toFile.has(nodeId) || !toPreview.has(nodeId));
return kept.length ? kept : samplerIds;
}
function nonFileOutputs(resolver) {
const names = resolver.semantics.nonFileOutputs, found = new Set();
for (const [nodeId, node] of resolver.graph) {
// `node.get("class_type") in names`: only a string can be a listed class.
if (isDict(node) && names.has(pyGet(node, 'class_type'))) found.add(nodeId);
}
return found;
}
/** `producers_of`: nodes whose output reaches one of `starts`, never through a preview. */
function producersOf(resolver, starts, previews) {
const found = new Set(starts), queue = [...starts];
for (let head = 0; head < queue.length; head++) {
const inputs = pyGet(resolver.graph.get(queue[head]), 'inputs');
if (!isDict(inputs)) continue;
resolver.scan(inputs.size);
for (const value of inputs.values()) {
if (!isLink(value)) continue;
const producer = String(value[0]);
if (found.has(producer) || previews.has(producer) || !isDict(pyGet(resolver.graph, producer))) continue;
found.add(producer);
queue.push(producer);
}
}
return found;
}
/** `consumers`: reverse links, node ID to the IDs of nodes reading one of its outputs. */
function consumers(resolver) {
const reverse = new Map();
for (const [nodeId, node] of resolver.graph) {
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue;
const inputs = node.get('inputs');
resolver.scan(inputs.size);
for (const value of inputs.values()) {
if (!isLink(value)) continue;
const producer = String(value[0]);
if (!reverse.has(producer)) reverse.set(producer, []);
reverse.get(producer).push(nodeId);
}
}
return reverse;
}
/** `reaches_output`: whether a sampler's output reaches a terminal node besides later stages. */
function reachesOutput(resolver, nodeId, reverse) {
const seen = new Set([nodeId]), queue = [nodeId];
for (let head = 0; head < queue.length; head++) {
for (const consumer of reverse.get(queue[head]) ?? []) {
if (seen.has(consumer)) continue;
seen.add(consumer);
resolver.budget.spend();
const name = pyStr(pyGet(resolver.graph.get(consumer), 'class_type', ''));
if (resolver.stageClasses.has(name) || resolver.semantics.nonFileOutputs.has(name)) continue;
if (!reverse.get(consumer)?.length) return true;
queue.push(consumer);
}
}
return false;
}
+5 -11
View File
@@ -1,16 +1,8 @@
// Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Task 8):
// Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Tasks 8-9):
// the constants of utils/prompt_pair_resolver.py and utils/inspection_outcomes.py, utils/pair_execution.py
// `read_pair_execution` and the semantics table check of utils/inspection_semantics.py. No DOM.
import { PyFloat, PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyTruthy, pyEqualsOne, pyStr, pyGet } from './py-json.mjs';
/** A resolution path Task 9 ports (several samplers, a detailer stage, A1111 parameters). */
export class LocalResolverGap extends Error {
constructor(message) {
super(message);
this.name = 'LocalResolverGap';
}
}
/** The semantics table is unusable: the server's `SemanticsError`. */
export class SemanticsError extends Error {
constructor(message) {
@@ -19,7 +11,7 @@ export class SemanticsError extends Error {
}
}
// INSPECTION_PROFILE (prompt_pair_resolver.py 152-166).
// INSPECTION_PROFILE (prompt_pair_resolver.py 152-166); its drop_preview_only_stages is always on here.
export const PROFILE = Object.freeze({
perCandidateVisits: 2000, totalVisits: 20000, maxDepth: 64, maxTextLength: 100000, nodeCeiling: 20000,
maxPathEntries: 64 * 20000, maxBuiltChars: 16 * 100000, maxScannedUnits: 8000000,
@@ -37,8 +29,10 @@ export const ZEROED_SOURCE = 'zeroed-conditioning';
export const MAX_BRANCH_CANDIDATES = 32;
export const RUNTIME_REASON = 'Text is decided at run time';
export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty';
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
export const MALFORMED_EVIDENCE_REASON = 'Prompt evidence is malformed';
export const UNREADABLE_METADATA_REASON = 'Media metadata could not be read';
export const SEMANTICS_UNAVAILABLE_REASON = 'Inspection node semantics are unavailable';
export const INCOMPATIBLE_SLOT = 'Output slot has an incompatible type';
export const OVERWRITTEN_PROMPT_REASON = 'Saved PromptManager text was overwritten by another node';
@@ -67,7 +61,7 @@ export const 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.",
'Media metadata could not be read': 'Metadata in this file could not be read.',
[UNREADABLE_METADATA_REASON]: 'Metadata in this file could not be read.',
};
// Output adapters (prompt_pair_resolver.py 44-105): [inputName, kind, {compose, storageInput, comma, strip, default}].
+19 -23
View File
@@ -4,8 +4,8 @@
// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow
// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`.
//
// Task 8 ports execution evidence and graph tracing for one sampler. Candidate selection among several samplers,
// detailer stages and A1111 `parameters` are Task 9; until then they raise `LocalResolverGap`. No DOM.
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several
// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM.
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
import {
ADAPTERS,
@@ -13,13 +13,11 @@ import {
Budget,
CANDIDATE_STRATEGIES,
CLASS_NAME_LENGTH,
DETAILER,
Diverging,
EMPTY_POSITIVE,
EMPTY_POSITIVE_REASON,
GUIDER_ABSENT_REASON,
INCOMPATIBLE_SLOT,
LocalResolverGap,
MALFORMED_EVIDENCE_REASON,
MAX_BRANCH_CANDIDATES,
MAX_JOIN_INPUTS,
@@ -33,6 +31,7 @@ import {
RUNTIME_REASON,
SAMPLER_LIKE_INPUTS,
SEMANTICS_UNAVAILABLE_REASON,
UNREADABLE_METADATA_REASON,
UNVERIFIABLE_CODES,
Unresolved,
ZEROED_SOURCE,
@@ -45,8 +44,10 @@ import {
sideOf,
slotKey,
} from './local-resolver-support.mjs';
import { finalStages, outcomeKey, sharedPair, stage } from './local-resolver-stages.mjs';
import { parameters } from './local-parameters.mjs';
export { LocalResolverGap, SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
class GraphResolver {
constructor(graph, metadata, semantics, overwritten) {
@@ -481,24 +482,13 @@ class GraphResolver {
return this.branches(samplerId);
}
/** One sampler's result. A detailer stage compared with its upstream stages is Task 9. */
stage(samplerId, later) {
if (later.size >= this.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' });
const own = this.sides(samplerId);
if (own.state === 'unresolved') return own;
const [name] = this.node(samplerId);
const entry = this.semantics.samplers.get(name);
if (entry === undefined || entry.strategy !== DETAILER) return own;
throw new LocalResolverGap('Detailer stages are compared in Task 9');
}
branches(samplerId) {
const pending = [new Map()], results = [];
try {
while (pending.length) {
this.choices = pending.shift();
try {
results.push(this.stage(samplerId, new Set()));
results.push(stage(this, samplerId, new Set()));
} catch (error) {
if (!(error instanceof Diverging)) throw error;
if (results.length + pending.length + 2 > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
@@ -609,8 +599,6 @@ class GraphResolver {
}
}
const roleText = side => side !== null && typeof side === 'object' ? side.text : null;
const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]);
/** `_sampler_like_classes`: distinct classes, in graph order, of nodes shaped like samplers or guiders. */
function samplerLikeClasses(graph) {
@@ -697,7 +685,7 @@ function resolve(metadata, semantics) {
if (!isDict(metadata) || !metadata.size) return result('absent', { reason: 'No parsed prompt metadata' });
let graph;
if (metadata.has('prompt')) graph = metadata.get('prompt');
else if (metadata.has('parameters')) throw new LocalResolverGap('A1111 parameters are resolved in Task 9');
else if (metadata.has('parameters')) return parameters(metadata.get('parameters'), PROFILE.maxTextLength);
else graph = metadata;
if (!isDict(graph) || !graph.size) return result('unresolved', { reason: 'API graph is missing or malformed', reasonCode: 'malformed' });
if (graph.size > PROFILE.nodeCeiling) return result('unresolved', { reason: 'Prompt graph exceeds the inspection node ceiling', reasonCode: 'graph-ceiling' });
@@ -710,10 +698,12 @@ function resolve(metadata, semantics) {
}
}
const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten);
const samplerIds = [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' && resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id);
const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&
resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id));
if (!samplerIds.length) return noSampler(graph);
if (samplerIds.length > 1 || samplerIds.some(id => semantics.samplers.get(graph.get(id).get('class_type')).strategy === DETAILER)) {
throw new LocalResolverGap('Several sampler candidates are selected in Task 9');
if (samplerIds.length > 1) {
const candidates = samplerIds.map(id => resolver.pair(id));
return sharedPair(candidates) ?? result('ambiguous', { reason: 'Multiple sampler branches', candidates });
}
return resolver.pair(samplerIds[0]);
}
@@ -743,6 +733,12 @@ function parsedWorkflow(value) {
return isDict(value) ? value : null;
}
/** `unreadable_evidence`: both roles when the file's metadata could not be read at all. */
export function unreadableEvidence() {
const resolution = { state: 'unresolved', sampler_id: null, reason: UNREADABLE_METADATA_REASON, reason_code: null, node: null };
return { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, resolution), resolution };
}
/**
* `inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)`: `{prompts, resolution}` for the records a
* local read found (`{prompt, workflow, parameters, comfyassets_prompt_pairs}` texts). `semantics` is
+2 -2
View File
@@ -183,8 +183,8 @@ export function pySlice(text, count) {
}
// `str.isspace()`: Unicode Zs plus the bidi classes B, S and WS (not U+FEFF, unlike `trim()`).
const SPACE = '\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
const LEADING = new RegExp(`^[${SPACE}]+`), TRAILING = new RegExp(`[${SPACE}]+$`);
export const PY_SPACE = '\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
const LEADING = new RegExp(`^[${PY_SPACE}]+`), TRAILING = new RegExp(`[${PY_SPACE}]+$`);
/** `text.strip()`. */
export const pyStrip = text => text.replace(LEADING, '').replace(TRAILING, '');