* fix(inspector): pass text through unknown nodes instead of refusing them (#381) The inspector refused any node class missing from the semantics table, so a PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the inspector does not support". The inspection resolver (Python and browser) now reads through unknown nodes: a text input is the text, positive/negative route by output slot, a conditioning input is followed alone, otherwise every linked input except core type inputs (pass_through.skip_inputs). PromptManager nodes are authoritative; several different texts become one candidate each ("Several prompts reach the sampler"). A seeded text node with no conditioning input is generated at run time, and a recorded PromptManager execution is not checked against text that passed through an unknown node. INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile. A legacy parity gate records every committed graph whose inspection positive matches the legacy parser's; no graph may leave it. * fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381) Review of the pass-through: a walk through an unknown node never overrides a PromptManagerPair's execution record (any outcome but work-limit is unverifiable while the record is checked); the run-time seed rule reads only string outputs; a node with positive/negative inputs follows only the role of the requested slot, nothing when it is unlinked; one text offered twice is one candidate. * fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381) An inspected file's name now anchors the inspection on the one saver whose filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_]; the samplers in that saver's image ancestry are the candidates. No match, several matches or an unreadable walk anchor nothing, so the file stays "several possible prompts". In a latent walk, an earlier sampler reading the same conditioning links as the later one is the same prompt, not an unattributed stage. Python and the browser resolver behave the same; the legacy parity gate states each graph that differs from legacy on purpose. * fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381) Review of the saver anchor: a same-links earlier stage is skipped only when it is not a detailer, sets no prompt override and continues no other sampler, so an earlier prompt or override behind it is never hidden. A work limit while tracing a saver prefix ends the match scan without an anchor instead of counting that saver as no match. * test(inspector): cover every plain-first-stage outcome on both resolvers (#381) The frontend coverage gate found local-resolver-stages.mjs branches at 92.44% (< 93). Each way an earlier stage stops being a plain first stage (no latent, literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a resolver test and an anchor-first-stage-* parity vector.
175 lines
13 KiB
JavaScript
175 lines
13 KiB
JavaScript
// 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';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
|
||
const py = await import('../../web/js/workspace/py-json.mjs');
|
||
const resolver = await import('../../web/js/workspace/local-resolver.mjs');
|
||
const semantics = resolver.compileSemantics(JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8')));
|
||
|
||
test('json.loads keeps Python value kinds and dict order', () => {
|
||
const value = py.pyJsonLoads('{"9": 1, "10": 1.0, "a": NaN, "9": [true, -0, 9007199254740993, -Infinity]}');
|
||
assert.ok(value instanceof Map);
|
||
// A repeated key keeps its first position and takes its last value; integer-like keys keep text order.
|
||
assert.deepEqual([...value.keys()], ['9', '10', 'a']);
|
||
const [flag, zero, big, negative] = value.get('9');
|
||
assert.equal(flag, true); assert.equal(zero, 0); assert.equal(big, 9007199254740993n);
|
||
assert.ok(negative instanceof py.PyFloat); assert.equal(negative.value, -Infinity);
|
||
assert.ok(value.get('10') instanceof py.PyFloat, '1.0 is a float, not an int');
|
||
assert.ok(Number.isNaN(value.get('a').value));
|
||
assert.equal(py.isInt(value.get('10')), false);
|
||
assert.equal(py.pyJsonLoads('"\\ud83d\\ude00\\ud800"'), '\u{1f600}\ud800');
|
||
for (const bad of ['{}', '{"a":1,}', '[01]', '{"a" 1}', '"\u0001"', '"\\x"', '1 2', '-', `${'9'.repeat(4301)}`, '"\\ud800\\uzzzz"']) {
|
||
assert.throws(() => py.pyJsonLoads(bad), py.PyValueError, bad);
|
||
}
|
||
assert.equal(py.pyJsonLoads(` ${'9'.repeat(4300)}\n`), BigInt('9'.repeat(4300)));
|
||
});
|
||
|
||
// One depth cap for both browser CPython-JSON parsers (py-json.mjs here, local-video.mjs for video envelopes): every
|
||
// array or object counts, an empty one too, as CPython's scanner enters recursion for each; past the cap it refuses.
|
||
test('json.loads refuses nesting past the shared depth cap, counting every container', () => {
|
||
const video = import('../../web/js/workspace/local-video.mjs');
|
||
assert.equal(py.JSON_MAX_DEPTH, 1000);
|
||
const nest = (depth, inner = '') => '['.repeat(depth) + inner + ']'.repeat(depth);
|
||
const objects = (depth, inner) => '{"a":'.repeat(depth) + inner + '}'.repeat(depth);
|
||
for (const text of [nest(1000), nest(1000, '1'), nest(999, '{}'), objects(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => py.pyJsonLoads(text));
|
||
for (const text of [nest(1001), nest(1001, '1'), nest(1000, '{}'), objects(1001, '1'), objects(1000, '[]')]) {
|
||
assert.throws(() => py.pyJsonLoads(text), py.PyValueError);
|
||
}
|
||
return video.then(({ pythonJsonLoads, PythonJsonError }) => {
|
||
for (const text of [nest(1000), nest(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => pythonJsonLoads(text));
|
||
for (const text of [nest(1001), nest(1001, '1'), objects(1000, '[]')]) {
|
||
assert.throws(() => pythonJsonLoads(text), error => error instanceof PythonJsonError && error.recursion === true);
|
||
}
|
||
});
|
||
});
|
||
|
||
test('str helpers measure, slice and strip as CPython', () => {
|
||
assert.equal(py.pyLen('a\u{1f600}\ud800'), 3);
|
||
assert.equal(py.pySlice('\u{1f600}\u{1f600}x', 2), '\u{1f600}\u{1f600}');
|
||
// str.isspace() includes \x1c-\x1f and \x85 and excludes , unlike String.prototype.trim.
|
||
assert.equal(py.pyStrip('\x1c\x85 a b \x1f'), 'a b');
|
||
assert.equal(py.pyStrip('a'), 'a');
|
||
assert.deepEqual([1.0, 0.0001, 1e-5, 1e16, 123456789012345.6, -2.5, 1e22].map(py.pyFloatRepr),
|
||
['1.0', '0.0001', '1e-05', '1e+16', '123456789012345.6', '-2.5', '1e+22']);
|
||
assert.deepEqual([null, true, 0, 1n, new py.PyFloat(2), '', [], new Map()].map(py.pyTruthy), [false, true, false, true, true, false, false, false]);
|
||
assert.deepEqual([1, 1n, true, new py.PyFloat(1), '1', 2].map(py.pyEqualsOne), [true, true, true, true, false, false]);
|
||
});
|
||
|
||
const encode = text => ({class_type: 'CLIPTextEncode', inputs: {text, clip: ['4', 1]}});
|
||
const sampler = (extra = {}) => ({class_type: 'KSampler', inputs: {seed: 1, positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], model: ['4', 0], ...extra}});
|
||
const base = (nodes = {}) => ({'3': sampler(), '4': {class_type: 'CheckpointLoaderSimple', inputs: {ckpt_name: 'm.safetensors'}},
|
||
'5': {class_type: 'EmptyLatentImage', inputs: {width: 64, height: 64, batch_size: 1}}, '6': encode('a fox'), '7': encode('blurry'), ...nodes});
|
||
|
||
test('a single chain resolves both roles with their sources', () => {
|
||
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(base())}, semantics);
|
||
assert.deepEqual(evidence, {
|
||
prompts: {positive: {status: 'resolved', text: 'a fox', source: 'graph-inferred'}, negative: {status: 'resolved', text: 'blurry', source: 'graph-inferred'}},
|
||
resolution: {state: 'resolved', sampler_id: '3', reason: '', reason_code: null, node: null},
|
||
});
|
||
});
|
||
|
||
test('records without roles, malformed text and a missing sampler answer as the server', () => {
|
||
assert.deepEqual(resolver.resolveLocalEvidence({workflow: '{}'}, semantics).resolution,
|
||
{state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null});
|
||
const malformed = resolver.resolveLocalEvidence({prompt: '{broken'}, semantics);
|
||
assert.equal(malformed.resolution.reason, 'Prompt evidence is malformed');
|
||
assert.equal(malformed.prompts.positive.message, 'Prompt metadata in this file is malformed.');
|
||
const none = resolver.resolveLocalEvidence({prompt: JSON.stringify({'1': encode('x'), '2': {class_type: 'MySampler', inputs: {positive: ['1', 0]}}})}, semantics);
|
||
assert.deepEqual(none.resolution, {state: 'unresolved', sampler_id: null, reason: 'No supported sampler pair found (saw MySampler)', reason_code: 'no-sampler', node: null});
|
||
assert.equal(none.prompts.negative.message, 'No supported sampler was found.');
|
||
assert.equal(resolver.resolveLocalEvidence({prompt: JSON.stringify(base()), parameters: 'x'}, null).resolution.reason, 'Inspection node semantics are unavailable');
|
||
});
|
||
|
||
test('a float output slot is not a link, and a runtime node is named', () => {
|
||
const floatSlot = resolver.resolveLocalEvidence({prompt: JSON.stringify(base()).replace('"positive":["6",0]', '"positive":["6",0.0]')}, semantics);
|
||
assert.deepEqual(floatSlot.resolution, {state: 'unresolved', sampler_id: '3', reason: 'Expected a typed graph link or text input', reason_code: 'malformed', node: null});
|
||
const runtime = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'6': {class_type: 'PromptSearchList', inputs: {}}}))}, semantics);
|
||
assert.deepEqual(runtime.resolution.node, {id: '6', class_type: 'PromptSearchList'});
|
||
assert.equal(runtime.prompts.positive.message, 'Prompt is chosen at run time (PromptSearchList).');
|
||
});
|
||
|
||
test('execution evidence replaces a PromptManagerPair text when it matches the node inputs', () => {
|
||
const pair = {class_type: 'PromptManagerPair', inputs: {text: 'fox', negative_text: 'blur', prepend_text: '', append_text: ''}};
|
||
const record = {text: 'fox', negative_text: 'blur', positive_text: 'fox, executed', negative_effective_text: 'blur', prepend_text: '', append_text: ''};
|
||
const graph = base({'3': sampler({positive: ['8', 0], negative: ['8', 1]}), '8': pair});
|
||
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [record]}}})}, semantics);
|
||
assert.deepEqual(evidence.prompts.positive, {status: 'resolved', text: 'fox, executed', source: 'execution'});
|
||
const stale = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [{...record, text: 'other'}]}}})}, semantics);
|
||
assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs');
|
||
});
|
||
|
||
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', () => {
|
||
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
|
||
});
|
||
|
||
test('the lineage section is compiled, and a bad one refuses the table', () => {
|
||
const doc = JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'));
|
||
assert.deepEqual(semantics.lineage.get('Image Filter\u00001'), { strategy: 'continue', input: 'latents' });
|
||
// Lineage describes no prompt: its classes are not table classes for the stage walk.
|
||
assert.equal(semantics.allClasses.has('VAEEncode'), false);
|
||
const row = { class: 'VAEEncode', slot: 0, strategy: 'continue', input: 'pixels' };
|
||
for (const lineage of [undefined, {}, [null], [{ ...row, slot: -1 }], [{ ...row, strategy: 'passthrough' }], [{ ...row, input: '' }], [row, row]]) {
|
||
assert.throws(() => resolver.compileSemantics({ ...doc, lineage }), resolver.SemanticsError, JSON.stringify(lineage));
|
||
}
|
||
});
|
||
|
||
// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by
|
||
// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite).
|
||
test('committed server answers equal the local evidence', () => {
|
||
const {vectors} = JSON.parse(fs.readFileSync(new URL('../fixtures/local-inspect/resolver-vectors.json', import.meta.url), 'utf8'));
|
||
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, evidence} of vectors) {
|
||
// A case may name the inspected file (`__file_name`, #381 saver anchoring); it is no record.
|
||
const {__file_name: fileName = null, ...roles} = records;
|
||
assert.deepEqual(canonical(resolver.resolveLocalEvidence(roles, semantics, fileName)), canonical(evidence), name);
|
||
compared++;
|
||
}
|
||
assert.ok(compared > 280, `compared ${compared}`);
|
||
});
|
||
|
||
test('a list or object workflow node type is skipped, as on the server (#286)', () => {
|
||
const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}});
|
||
for (const type of [['PromptManager'], {name: 'PromptManager'}]) {
|
||
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics);
|
||
assert.equal(evidence.resolution.state, 'resolved');
|
||
assert.equal(evidence.prompts.positive.status, 'resolved');
|
||
assert.equal(evidence.prompts.positive.text, 'a fox');
|
||
}
|
||
});
|