fix(inspector): guard fact ancestry and inspection state

This commit is contained in:
Vito Sansevero
2026-10-18 04:30:07 -07:00
parent 4e669f8cba
commit 3555003054
4 changed files with 447 additions and 43 deletions
+235 -2
View File
@@ -45,12 +45,16 @@ test("facts do not choose equal sampler branches or unrelated model", () => {
});
test("facts respect named scalar outputs and custom scheduler paths", () => {
const graph = factGraph({ seed: ["primitive", 0], steps: ["settings", 2], cfg: ["settings", 3], sampler_name: ["settings", 0], scheduler: ["settings", 1], model: ["lora", 0] });
const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
graph.settings = { class_type: "SamplerCombo", inputs: { sampler_name: "euler", scheduler: "normal", steps: "50", cfg: "0" } };
graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
graph.s.inputs.steps = ["settings", 0];
assert.equal(factsFor(graph).steps.status, "unresolved");
const custom = {
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
@@ -68,6 +72,231 @@ test("facts respect named scalar outputs and custom scheduler paths", () => {
assert.equal(factsFor(custom).sampler.value, "euler");
});
test("facts require supported scalar producer types and output slots", () => {
const cases = [
["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
];
for (const [class_type, input, value, supported] of cases) {
for (const slot of [0, 1, 999, -1, 0.5]) {
const graph = factGraph({ [input]: ["settings", slot] });
graph.settings = { class_type, inputs: { value, [input]: value } };
const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
assert.equal(fact.value, supported && slot === 0 ? value : null);
}
}
const graph = factGraph({ seed: ["primitive", 0] });
graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
graph.primitive.inputs = { value: "42", seed: "987" };
assert.equal(factsFor(graph).seed.value, "42");
for (const [input, class_type, inputs] of [
["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
["steps", "BasicScheduler", { steps: "42" }],
["cfg", "CFGGuider", { cfg: "7" }],
]) {
const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
}
});
function customFactGraph() {
return {
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
};
}
test("facts require supported custom providers and guider outputs", () => {
assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
for (const slot of [1, 999, -1, 0.5]) {
const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
}
for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
if (input === "guider" && class_type === "BasicGuider") continue;
const graph = customFactGraph(); graph[nodeId].class_type = class_type;
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
}
}
const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
assert.equal(factsFor(basic).model.value, "flux.safetensors");
assert.equal(factsFor(basic).cfg.status, "not-recorded");
});
test("facts require model output ancestry with supported loaders and passthroughs", () => {
for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
for (const slot of [0, 1, 2, 999, -1, 0.5]) {
const graph = factGraph({ model: ["loader", slot] });
graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
}
}
for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
for (const slot of [0, 1, 2, 999]) {
const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
}
}
for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
assert.equal(factsFor(graph).model.status, "unresolved");
}
for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
});
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;
for (let index = 0; index < 20; index++) {
nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
}
for (let index = 0; index < 40; index++) {
nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
}
nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
{ id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
{ id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
});
test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
function workflow(type, slot, mode) {
const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
{ id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
if (type === "GetNode") {
nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
pass.inputs = [];
}
return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
}
const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
for (const slot of [0, 1, 999, -1, 0.5]) {
assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
}
}
assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
const duplicate = workflow("LoraLoader", 0, 4);
duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
assert.equal(model(cycle).status, "unresolved");
const unnamed = workflow("GetNode", 0);
delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
delete unnamed.nodes.find(node => node.id === "set").widgets_values;
assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
const scalar = factGraph({ seed: ["bypass", 0] });
scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
const graph = customFactGraph(), target = graph.s.inputs[input];
graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
}
});
function factWorkflowFromGraph(graph) {
const nodes = [], links = [];
for (const [id, node] of Object.entries(graph)) {
const inputs = [], widgets_values_named = {};
for (const [name, value] of Object.entries(node.inputs)) {
if (Array.isArray(value)) {
const link = links.length + 1;
links.push([link, value[0], value[1], id, inputs.length, "*"]);
inputs.push({ name, link });
} else widgets_values_named[name] = value;
}
nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
}
return { nodes, links };
}
test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
function chain(graph, reroutes, primitives, model = false) {
let link = model ? ["model", 0] : null;
for (let index = 0; index < primitives; index++) {
graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
link = [`p${index}`, 0];
}
for (let index = 0; index < reroutes; index++) {
graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
link = [`r${index}`, 0];
}
return link;
}
for (const [reroutes, known] of [[32, true], [33, false]]) {
const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
const noise = customFactGraph();
noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
noise.s.inputs.noise = ["noiseAlias", 0];
assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
const guider = customFactGraph();
guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
guider.s.inputs.guider = ["guiderAlias", 0];
assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
}
const cycle = factGraph();
cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
assert.equal(inspect(cycle).seed.status, "unresolved");
cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
assert.equal(inspect(cycle).model.status, "unresolved");
for (const [reroutes, known] of [[32, true], [33, false]]) {
const graph = factGraph(), value = chain(graph, reroutes, 31);
const workflow = factWorkflowFromGraph(graph);
const sourceLink = workflow.links.length + 1;
workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
}
});
test("workflow sampler selection retains prototype-named node identities", () => {
const workflow = { nodes: [
{ id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
{ id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
], links: [] };
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
});
test("workflow-only facts are configuration without creating sendable roles", () => {
const workflow = { nodes: [
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
@@ -136,6 +365,10 @@ test("conservative facts bound cycles fan-out visits and link depth", () => {
graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
assert.equal(factsFor(graph).seed.status, "unresolved");
assert.equal(factsFor(graph).model.status, "unresolved");
graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
const chain = depth => {
const value = factGraph({ seed: ["p0", 0] });
for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
@@ -78,6 +78,7 @@ test('record format validation never guesses original text', () => {
assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid');
data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit';
data.metadata.prompt_graph = { status: 'limited', value: null };
data.metadata.workflow_graph = { status: 'limited', value: null };
const limited = toRecord(data, entry, extractGenerationFacts);
assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
assert.equal(limited.generation.seed.status, 'unresolved');
@@ -88,6 +89,46 @@ test('record format validation never guesses original text', () => {
assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing');
});
test('record accepts actual atomic omission producer states', () => {
const toRecord = contract('toInspectionRecord');
for (const wholeRaw of [false, true]) {
const data = envelope();
data.metadata.record_formats.workflow = 'invalid';
data.metadata.prompt_graph = { status: 'limited', value: null };
data.metadata.workflow_graph = { status: 'limited', value: null };
if (wholeRaw) Object.assign(data.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' });
let record;
assert.doesNotThrow(() => { record = toRecord(data, image(), extractGenerationFacts); }, 'actual producer omission remains renderable');
assert.deepEqual(record.metadata.raw, wholeRaw ? {} : { prompt: '{"rounded":"raw copy"}' });
assert.equal(record.metadata.status, wholeRaw ? 'limited' : 'available');
assert.equal(record.metadata.omitted_reason, wholeRaw ? 'response-size-limit' : null);
assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
assert.equal(record.prompts.positive.text, 'exact\npositive');
assert.equal(record.download.url, image().url);
assert.ok(Object.values(record.generation).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited'));
}
const absent = envelope();
Object.assign(absent.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, record_formats: { parameters: 'original-text' }, parameters: null, prompt_graph: { status: 'absent', value: null } });
assert.equal(toRecord(absent, image(), extractGenerationFacts).generation.seed.reason, 'projection-limited');
});
test('record rejects contradictory omission provenance before extracting facts', () => {
const toRecord = contract('toInspectionRecord');
for (const mutate of [
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit' }),
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {} }),
data => { data.metadata.raw = {}; },
data => { data.metadata.record_formats.workflow = 'original-text'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; },
data => { Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, prompt_graph: { status: 'limited', value: null } }); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; },
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.raw.workflow = 'null'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
]) {
const data = envelope(); mutate(data); let extractions = 0;
assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError);
assert.equal(extractions, 0, 'contradictory transport cannot produce known facts');
}
});
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
const toRecord = contract('toInspectionRecord');
for (const mutate of [
@@ -226,3 +267,45 @@ test('disposed or superseded responses never publish', async () => {
await controller.select(newer); await controller.retry(); controller.clear();
assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4);
});
const invalidSelections = () => [null, undefined, 0, 'new.png', {}, { ...image(), path: '../new.png' }, { ...image(), root_scope: null }, { ...image(), root_scope: 'malformed' }];
test('invalid attempted selections clear completed actions and retry validation only', async () => {
for (const invalid of invalidSelections()) {
const { api, requests } = pendingApi();
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
const previous = controller.select(image()); requests[0].resolve(envelope()); await previous;
assert.equal(controller.state.prompts.positive.text, 'exact\npositive');
let rejected, thrown;
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
assert.equal(controller.state.record, null, 'invalid navigation cannot retain completed actions'); assert.equal(controller.state.prompts, undefined);
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
await rejected;
assert.deepEqual([controller.state.loading, controller.state.error, controller.state.refreshRequired, controller.state.message], [false, true, false, 'Could not read details from this file.']);
await controller.retry(); await controller.retry(); assert.equal(requests.length, 1);
const valid = controller.select(image()); assert.equal(requests.length, 2);
requests[1].resolve(envelope()); await valid;
await controller.select({ ...image() }); assert.equal(requests.length, 2);
controller.dispose();
}
});
test('invalid attempted selections abort pending reads and suppress late results', async () => {
for (const invalid of invalidSelections()) {
const { api, requests } = pendingApi(), changes = [];
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
controller.subscribe(state => changes.push(state));
const previous = controller.select(image());
let rejected, thrown;
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
assert.equal(requests[0].signal.aborted, true);
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
await rejected; const count = changes.length;
requests[0].resolve(envelope()); await previous;
assert.equal(changes.length, count); assert.equal(controller.state.error, true);
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
await controller.retry(); assert.equal(requests.length, 1);
controller.dispose();
}
});
+107 -32
View File
@@ -59,7 +59,7 @@
* their workflow widget text back. Otherwise the API graph is what actually ran
* (expanded dynamic prompts, linked text) and always wins.
*/
function createContext(graph, workflow, conservative = false, budget = { remaining: MAX_VISITS }) {
function createContext(graph, workflow, conservative = false) {
const widgets = new Map();
for (const n of workflowNodes(workflow)) {
const text = widgetsOf(n)[0];
@@ -82,7 +82,7 @@
ids.forEach((id) => widgetText.set(id, widgets.get(id)));
}
}
return { graph, widgetText, conservative, budget };
return { graph, widgetText, conservative };
}
/**
@@ -92,7 +92,7 @@
*/
function enter(ctx, link, seen) {
if (!isLink(link) || seen.size >= MAX_VISITS) return [];
if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64 || ctx.budget.remaining-- <= 0)) return [];
if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64)) return [];
const id = String(link[0]);
const node = !ctx.conservative || Object.hasOwn(ctx.graph, id) ? ctx.graph[id] : undefined;
const key = `${id}:${link[1]}`;
@@ -110,13 +110,15 @@
if (!isLink(value)) return value;
const [, node] = enter(ctx, value, seen);
if (!node) return undefined;
if (ctx.conservative) {
if (node[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && value[1] === 0 ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
const kind = ["seed", "noise_seed", "steps", "cfg"].includes(name) ? "number" : "string";
return value[1] === 0 && SCALAR_OUTPUTS[node.class_type] === kind && Object.hasOwn(node.inputs, "value") ?
resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
}
if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) {
return resolveScalar(ctx, node.inputs[name], name, seen);
}
if (ctx.conservative) {
return value[1] === 0 && /^(Primitive(Int|Float|String|Boolean|Node)|PrimitiveStringMultiline)$/.test(node.class_type)
&& Object.hasOwn(node.inputs, "value") ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
}
for (const v of Object.values(node.inputs)) {
const resolved = resolveScalar(ctx, v, name, seen);
if (resolved !== undefined && typeof resolved !== "object") return resolved;
@@ -191,11 +193,11 @@
const [, node] = enter(ctx, link, seen);
if (!node) return undefined;
if (ctx.conservative) {
const names = MODEL_NAME_KEYS.filter(key => Object.hasOwn(node.inputs, key));
if (names.length) {
const values = names.map(key => resolveScalar(ctx, node.inputs[key], key, new Set(seen)));
return values.every(value => typeof value === "string" && value.trim() && value === values[0]) ? values[0] : undefined;
}
if (link[1] !== 0) return undefined;
if (node[WORKFLOW_PASSTHROUGH]) return traceModelName(ctx, node.inputs.value, seen);
const key = Object.hasOwn(MODEL_OUTPUTS, node.class_type) ? MODEL_OUTPUTS[node.class_type] : undefined;
if (key) return resolveScalar(ctx, node.inputs[key], key, seen);
return MODEL_PASSTHROUGHS.has(node.class_type) ? traceModelName(ctx, node.inputs.model, seen) : undefined;
}
const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string");
return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen);
@@ -290,12 +292,56 @@
return out;
}
function factWorkflowGraph(workflow) {
const nodes = workflowNodes(workflow), links = new Map(), setters = new Map();
for (const link of workflow.links || []) {
if (Array.isArray(link)) links.set(link[0], { from: link[1], slot: link[2] });
else links.set(link.id, { from: link.origin_id, slot: link.origin_slot });
}
const source = id => {
const link = links.get(id);
if (!link || !["string", "number"].includes(typeof link.from) || !Number.isSafeInteger(link.slot) || link.slot < 0) return undefined;
return [String(link.from), link.slot];
};
for (const node of nodes) {
const name = widgetsOf(node)[0];
if (node.type === "SetNode" && node.mode !== MODE_MUTED && typeof name === "string") setters.set(name, node.inputs?.length === 1 ? node.inputs[0].link : undefined);
}
const graph = Object.create(null);
for (const node of nodes) {
if (node.mode === MODE_MUTED) continue;
let pass = PASS_THROUGH_TYPES.has(node.type), link, kind = "value";
if (pass) {
const name = widgetsOf(node)[0];
if (node.type === "GetNode") link = typeof name === "string" ? setters.get(name) : undefined;
else if (node.inputs?.length === 1) link = node.inputs[0].link;
}
else if (node.mode === MODE_BYPASSED) {
if (!MODEL_PASSTHROUGHS.has(node.type)) continue;
const models = (node.inputs || []).filter(input => input.name === "model");
pass = true; kind = "model"; link = models.length === 1 ? models[0].link : undefined;
}
if (pass) {
// Retain the actual alias hop so a fact's full original ancestry shares one depth/visit set.
graph[String(node.id)] = { class_type: node.type, inputs: { value: source(link) }, [WORKFLOW_PASSTHROUGH]: kind };
continue;
}
const inputs = namedWidgets(node);
for (const input of node.inputs || []) {
if (input.link !== null && input.link !== undefined) inputs[input.name] = source(input.link);
}
graph[String(node.id)] = { class_type: node.type, inputs };
}
return graph;
}
/**
* Convert a UI workflow ({nodes, links}) into the API graph shape so images
* without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and
* bypassed nodes are resolved to their real source; muted nodes are dropped.
*/
function workflowToGraph(workflow, conservative = false, budget = { remaining: MAX_VISITS }) {
function workflowToGraph(workflow, conservative = false) {
if (conservative) return factWorkflowGraph(workflow);
const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null);
const byId = new Map(nodes.map((n) => [String(n.id), n]));
const links = new Map();
@@ -311,8 +357,7 @@
const source = (linkId, hops = 0) => {
const link = links.get(linkId);
const node = link && byId.get(String(link.from));
if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS ||
(conservative && (hops >= 64 || budget.remaining-- <= 0))) return undefined;
if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS) return undefined;
const next = (id) => source(id, hops + 1);
if (node.type === "Reroute") return next(node.inputs && node.inputs[0] && node.inputs[0].link);
if (node.type === "GetNode") return next(setters.get(widgetsOf(node)[0]));
@@ -331,7 +376,7 @@
const inputs = namedWidgets(n);
for (const i of n.inputs || []) {
const src = i && i.link !== null && i.link !== undefined ? source(i.link) : undefined;
if (src || (conservative && i && i.link !== null && i.link !== undefined)) inputs[i.name] = src;
if (src) inputs[i.name] = src;
}
graph[String(n.id)] = { class_type: n.type, inputs };
}
@@ -359,6 +404,24 @@
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");
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
const SCALAR_OUTPUTS = Object.freeze({
PrimitiveInt: "number", PrimitiveFloat: "number",
PrimitiveString: "string", PrimitiveStringMultiline: "string",
});
const MODEL_OUTPUTS = Object.freeze({
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
});
const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
const CONFIGURATION_OUTPUTS = Object.freeze({
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] },
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
BasicGuider: { kind: "guider", fields: ["model"] },
});
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
function fact(status, source = null, samplerId = null, value = null, reason = null) {
@@ -372,7 +435,7 @@
function exactFactValue(value, field) {
if (typeof value === "number") {
// The projection carries decimals as strings; a float here cannot prove exactness.
if (!Number.isFinite(value) || !Number.isSafeInteger(value)) return null;
if (!["seed", "steps", "cfg"].includes(field) || !Number.isFinite(value) || !Number.isSafeInteger(value)) return null;
value = String(value);
}
if (typeof value !== "string" || !value.trim()) return null;
@@ -389,15 +452,22 @@
return fact(value === null ? "unresolved" : "known", source, samplerId, value);
}
function providerFact(ctx, link, keys, field, source, samplerId) {
function configurationOutput(ctx, link, kind, seen) {
const [id, node] = enter(ctx, link, seen);
if (node?.[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && link[1] === 0 ? configurationOutput(ctx, node.inputs.value, kind, seen) : [];
const adapter = node && CONFIGURATION_OUTPUTS[node.class_type];
return node && link[1] === 0 && adapter?.kind === kind ? [id, node, adapter] : [];
}
function providerFact(ctx, link, kind, keys, field, source, samplerId) {
const seen = new Set();
const [, node] = enter(ctx, link, seen);
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
if (!node) return fact("unresolved", source, samplerId);
const key = keys.find(name => Object.hasOwn(node.inputs, name));
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, budget) {
function graphFacts(graph, source, resolution) {
if (!object(graph)) return factSet("unresolved", source);
const entries = Object.entries(graph);
if (entries.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
@@ -406,24 +476,30 @@
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, budget);
const ctx = createContext(graph, null, true);
const inputs = sampler.inputs;
const advanced = sampler.class_type === "SamplerCustomAdvanced";
const custom = advanced || sampler.class_type === "SamplerCustom";
let settings = inputs;
let settingsAdapter;
const settingsPath = new Set();
if (advanced) {
const [, guider] = enter(ctx, inputs.guider, settingsPath);
settings = guider && ["CFGGuider", "BasicGuider"].includes(guider.class_type) ? guider.inputs : null;
const [, guider, adapter] = configurationOutput(ctx, inputs.guider, "guider", settingsPath);
settings = guider ? guider.inputs : null;
settingsAdapter = adapter;
}
const fromSettings = (key, field) => settings ? scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath)) : fact("unresolved", source, samplerId);
const fromSettings = (key, field) => {
if (!settings) return fact("unresolved", source, samplerId);
if (settingsAdapter && !settingsAdapter.fields.includes(key)) return fact(Object.hasOwn(settings, key) ? "unresolved" : "not-recorded", source, samplerId);
return scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath));
};
const seedKey = Object.hasOwn(inputs, "seed") ? "seed" : "noise_seed";
const facts = {
seed: advanced ? providerFact(ctx, inputs.noise, ["noise_seed", "seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId),
steps: custom ? providerFact(ctx, inputs.sigmas, ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId),
seed: advanced ? providerFact(ctx, inputs.noise, "noise", ["noise_seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId),
steps: custom ? providerFact(ctx, inputs.sigmas, "sigmas", ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId),
cfg: fromSettings("cfg", "cfg"),
sampler: custom ? providerFact(ctx, inputs.sampler, ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId),
scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, ["scheduler"], "scheduler", source, samplerId),
sampler: custom ? providerFact(ctx, inputs.sampler, "sampler", ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId),
scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, "sigmas", ["scheduler"], "scheduler", source, samplerId),
model: fact(settings && !Object.hasOwn(settings, "model") ? "not-recorded" : "unresolved", source, samplerId),
};
if (settings && Object.hasOwn(settings, "model")) {
@@ -483,13 +559,12 @@
if (!record || record.status === "absent") continue;
if (record.status === "limited") return factSet("unresolved", source, "projection-limited");
if (record.status !== "parsed") return factSet("unresolved", source);
const budget = { remaining: MAX_VISITS };
if (source === "workflow") {
if (record.value?.nodes?.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
if (!validFactWorkflow(record.value)) return factSet("unresolved", source);
return graphFacts(workflowToGraph(record.value, true, budget), source, null, budget);
return graphFacts(workflowToGraph(record.value, true), source, null);
}
return graphFacts(record.value, source, resolution, budget);
return graphFacts(record.value, source, resolution);
}
if (metadataStatus === "limited") return factSet("unresolved", null, "projection-limited");
if (metadataStatus === "unreadable") return factSet("unresolved", null, "metadata-unreadable");
+22 -9
View File
@@ -107,10 +107,15 @@ function metadataFor(value) {
}
if (value.parameters !== (raw.parameters ?? null)) throw new TypeError('Invalid inspection parameters projection');
const prompt = graphFor(value.prompt_graph), workflow = graphFor(value.workflow_graph);
const wholeRawOmitted = value.status === 'limited';
if (wholeRawOmitted && (Object.keys(raw).length || value.parameters !== null)) throw new TypeError('Invalid atomic raw omission');
if (!wholeRawOmitted && Object.entries(formats).some(([key, format]) => format !== 'invalid' && !Object.hasOwn(raw, key))) throw new TypeError('Missing represented raw record');
const invalidGraphOmitted = [['prompt', prompt], ['workflow', workflow]].some(([key, graph]) => formats[key] === 'invalid' && graph.status === 'limited');
for (const [key, graph] of [['prompt', prompt], ['workflow', workflow]]) {
const hasRecord = Object.hasOwn(formats, key);
if ((formats[key] === 'invalid' && graph.status !== 'malformed') ||
if ((formats[key] === 'invalid' && !['malformed', 'limited'].includes(graph.status)) ||
hasRecord !== (graph.status !== 'absent')) throw new TypeError('Invalid graph representation provenance');
if (hasRecord && (wholeRawOmitted || invalidGraphOmitted) && graph.status !== 'limited') throw new TypeError('Invalid atomic graph omission');
}
if ((value.status === 'limited') !== (value.omitted_reason === 'response-size-limit')) throw new TypeError('Invalid metadata omission status');
if (['absent', 'unreadable'].includes(value.status) && (Object.keys(raw).length || Object.keys(formats).length || prompt.status !== 'absent' || workflow.status !== 'absent')) throw new TypeError('Invalid unavailable metadata');
@@ -188,6 +193,7 @@ const emptyState = () => ({ loading: false, error: false, record: null, prompts:
export function createInspectionController({ api, extractFacts }) {
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
let attempted, hasAttempt = false;
const listeners = new Set();
function publish(update) {
if (disposed) return;
@@ -204,18 +210,25 @@ export function createInspectionController({ api, extractFacts }) {
get state() { return state; },
select(image) {
if (disposed) return;
const scope = validateIdentity(image), key = imageKey(image);
if (current?.key === key && current.scope === scope) return pending;
current = { key, scope, image: { ...image } };
return read(current.image);
let selected = null;
try {
const scope = validateIdentity(image), key = imageKey(image);
if (current?.key === key && current.scope === scope) return pending;
selected = { key, scope };
} catch {
// The loader publishes validation errors after invalidating the previous source.
}
loader.dispose(); loader = makeLoader(); current = selected;
attempted = object(image) ? { ...image } : image; hasAttempt = true;
return read(attempted);
},
retry() {
if (disposed || !current || state.refreshRequired) return;
return read(current.image);
if (disposed || !hasAttempt || state.refreshRequired) return;
return read(attempted);
},
clear() {
if (disposed) return;
loader.dispose(); loader = makeLoader(); current = null; pending = null;
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;
publish({});
},
subscribe(listener) {
@@ -225,7 +238,7 @@ export function createInspectionController({ api, extractFacts }) {
},
dispose() {
if (disposed) return;
disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; state = Object.freeze(emptyState());
disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; attempted = undefined; hasAttempt = false; state = Object.freeze(emptyState());
},
};
}