feat(inspect): local execution evidence and prompt tracing
A browser port of the server's inspection resolver for one sampler
(PR L3 Task 8, LI-1). local-resolver.mjs answers inspect_prompt_evidence's
{prompts, resolution} for a local read's records: execution evidence
(read_pair_execution and its per-node match), every traced strategy
(encoders, strings, concatenations, PromptManager and its historical
classes, passthrough, zeroed, selector branches, composites, guiders),
the latent stage walk, the R1 overwritten-text guard and the inspection
profile's visit, depth, text, path, built and scanned bounds, with the
same reason codes and outcome lines. py-json.mjs reads the records as
CPython's json.loads does (dict order and last-wins keys, int versus
float, BigInt past 2^53, NaN) and measures and strips text as str does.
Several sampler candidates, detailer stages and A1111 parameters raise
LocalResolverGap until Task 9. Nothing is wired into Inspect yet.
Python is the oracle: test_local_inspect_parity compares the port with
the server on the parity corpus, 107 named record sets, every work bound
at its edge and 600 seeded fuzzed graphs, and requires gaps to be exactly
the Task 9 paths. Sixteen porting mutations each fail the suite. The
Node unit test replays committed server answers, which the Python suite
keeps current.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -14,6 +14,9 @@
|
||||
// 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).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
@@ -28,6 +31,18 @@ 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 semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '..', '..', '..', 'web', 'js', 'inspection-semantics.json'), 'utf8')));
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
const selectionRead = (selection) => selection.status === 'complete' ?
|
||||
{ records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null;
|
||||
@@ -37,6 +52,13 @@ for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const output = {};
|
||||
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
|
||||
const { name } = entry;
|
||||
if (entry.mode === 'resolve') {
|
||||
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
|
||||
const { classification, result } = await readLocalFile(await fs.openAsBlob(entry.path), { pngMetadata });
|
||||
const read = classification === null ? null : toInspectionRead(classification, result).read;
|
||||
output[name] = read === null ? { unreadable: true } : resolved(read.records);
|
||||
continue;
|
||||
}
|
||||
if (entry.records !== undefined) {
|
||||
const read = entry.records === null ? null : {
|
||||
records: entry.records,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// PR L3 Task 8: 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)));
|
||||
});
|
||||
|
||||
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('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('an unusable semantics table is refused', () => {
|
||||
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
|
||||
});
|
||||
|
||||
// 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, gap, evidence} of vectors) {
|
||||
if (gap) {
|
||||
assert.throws(() => resolver.resolveLocalEvidence(records, semantics), resolver.LocalResolverGap, name);
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
|
||||
compared++;
|
||||
}
|
||||
assert.ok(compared > 140, `compared ${compared}`);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,12 +51,17 @@ from utils.prompt_inspector import (
|
||||
read_inspection_file,
|
||||
)
|
||||
|
||||
from utils.prompt_inspector import inspect_prompt_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.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
|
||||
from open_handles import assert_closed
|
||||
from test_utils_video_metadata import HAVE_FFMPEG, write_video
|
||||
|
||||
@@ -1111,5 +1116,168 @@ class LocalProjectionParityTests(unittest.TestCase):
|
||||
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
|
||||
|
||||
|
||||
def _server_evidence(records):
|
||||
return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
|
||||
|
||||
|
||||
class LocalResolverParityTests(unittest.TestCase):
|
||||
"""``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (PR L3 Task 8).
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.addCleanup(assert_closed, self, temporary.name)
|
||||
self.directory = Path(temporary.name)
|
||||
|
||||
def _compare(self, cases):
|
||||
"""Assert each record set equal or a recorded gap; return the server answers."""
|
||||
observed = _run_oracle(
|
||||
self,
|
||||
[
|
||||
{"name": name, "mode": "resolve", "records": records}
|
||||
for name, records in cases.items()
|
||||
],
|
||||
)
|
||||
answers = {}
|
||||
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(
|
||||
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||
)
|
||||
return answers
|
||||
|
||||
def test_local_roles_match_execution_and_single_chain_vectors(self):
|
||||
files = {}
|
||||
for name in parity.COMMITTED:
|
||||
files[name] = parity.fixture_path(name)
|
||||
for name in parity.TEST_TIME:
|
||||
path = parity.fixture_path(name, self.directory)
|
||||
parity.write_png(path, parity.build(name))
|
||||
files[name] = path
|
||||
observed = _run_oracle(
|
||||
self,
|
||||
[
|
||||
{"name": name, "mode": "resolve", "path": str(path)}
|
||||
for name, path in files.items()
|
||||
],
|
||||
)
|
||||
vectors = {
|
||||
vector["fixture"]: vector
|
||||
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
|
||||
if vector["profile"] == "inspection"
|
||||
}
|
||||
compared = gaps = 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})
|
||||
continue
|
||||
self.assertEqual(
|
||||
local.get("gap", False), not resolver_cases.single_chain(records)
|
||||
)
|
||||
if local.get("gap"):
|
||||
gaps += 1
|
||||
continue
|
||||
server = _server_evidence(records)
|
||||
self.assertEqual(
|
||||
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||
)
|
||||
# The committed vector agrees with the server read here.
|
||||
expect = vectors[name]["expect"]
|
||||
self.assertEqual(server["resolution"]["state"], expect["state"])
|
||||
self.assertEqual(
|
||||
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_golden_record_sets(self):
|
||||
answers = self._compare(resolver_cases.golden_cases())
|
||||
codes = {answer["resolution"]["reason_code"] for answer in answers.values()}
|
||||
self.assertTrue(
|
||||
{
|
||||
"malformed",
|
||||
"unsupported-node",
|
||||
"runtime-value",
|
||||
"composite",
|
||||
"work-limit",
|
||||
"depth-limit",
|
||||
"text-limit",
|
||||
"graph-ceiling",
|
||||
"no-sampler",
|
||||
None,
|
||||
}
|
||||
<= codes
|
||||
)
|
||||
sources = {
|
||||
side["source"]
|
||||
for answer in answers.values()
|
||||
for side in answer["prompts"].values()
|
||||
}
|
||||
self.assertTrue(
|
||||
{"execution", "graph-inferred", "zeroed-conditioning"} <= sources
|
||||
)
|
||||
|
||||
def test_committed_resolver_vectors_are_current(self):
|
||||
"""The Node unit test's committed answers are still the server's."""
|
||||
committed = json.loads(resolver_cases.VECTORS_PATH.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
committed,
|
||||
json.loads(json.dumps(resolver_cases.committed_vectors())),
|
||||
"regenerate with: python3 -m tests.local_resolver_cases --write",
|
||||
)
|
||||
|
||||
def test_local_roles_match_at_every_work_bound(self):
|
||||
answers = self._compare(resolver_cases.boundary_cases())
|
||||
for name in ("visits", "depth", "scanned"):
|
||||
self.assertEqual(answers[f"{name}-edge"]["resolution"]["state"], "resolved")
|
||||
self.assertIn(
|
||||
answers[f"{name}-over"]["resolution"]["reason_code"],
|
||||
("work-limit", "depth-limit"),
|
||||
)
|
||||
self.assertEqual(
|
||||
answers["named-astral"]["resolution"]["node"]["class_type"],
|
||||
"\U0001f600" * 64,
|
||||
)
|
||||
|
||||
def test_local_roles_match_fuzzed_record_sets(self):
|
||||
cases = {}
|
||||
for seed in range(4):
|
||||
cases.update(resolver_cases.fuzz_cases(seed, 150))
|
||||
answers = self._compare(cases)
|
||||
states = [answer["resolution"]["state"] for answer in answers.values()]
|
||||
# Coherent graphs reach real results, not only early refusals.
|
||||
self.assertGreater(states.count("resolved"), len(states) // 5)
|
||||
self.assertGreater(states.count("ambiguous"), 0)
|
||||
self.assertGreater(states.count("unresolved"), len(states) // 10)
|
||||
self.assertGreaterEqual(
|
||||
len({answer["resolution"]["reason_code"] for answer in answers.values()}),
|
||||
5,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+19
-1
@@ -181,7 +181,7 @@
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 92,
|
||||
"branches": 93,
|
||||
"functions": 92,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -335,6 +335,18 @@
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/local-resolver-support.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 80,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/local-resolver.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 83,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/local-session.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
@@ -377,6 +389,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/py-json.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/recorded-run.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 83,
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Task 8):
|
||||
// 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) {
|
||||
super(message);
|
||||
this.name = 'SemanticsError';
|
||||
}
|
||||
}
|
||||
|
||||
// INSPECTION_PROFILE (prompt_pair_resolver.py 152-166).
|
||||
export const PROFILE = Object.freeze({
|
||||
perCandidateVisits: 2000, totalVisits: 20000, maxDepth: 64, maxTextLength: 100000, nodeCeiling: 20000,
|
||||
maxPathEntries: 64 * 20000, maxBuiltChars: 16 * 100000, maxScannedUnits: 8000000,
|
||||
});
|
||||
export const EXECUTION_KEY = 'comfyassets_prompt_pairs';
|
||||
export const ROLE_METADATA_KEYS = new Set(['prompt', 'parameters', EXECUTION_KEY]);
|
||||
// pair_execution.py
|
||||
export const MAX_EXECUTION_RECORDS = 16, MAX_EXECUTION_NODES = 64, MAX_EXECUTION_TEXT = 100000, MAX_EXECUTION_JSON = 500000;
|
||||
export const EXECUTION_FIELDS = ['text', 'negative_text', 'positive_text', 'negative_effective_text', 'prepend_text', 'append_text'];
|
||||
|
||||
export const CANDIDATE_STRATEGIES = new Set(['ksampler', 'custom-advanced', 'detailer-stage']);
|
||||
export const DETAILER = 'detailer-stage';
|
||||
export const GUIDER_ABSENT_REASON = 'Guider has no negative input';
|
||||
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 MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
|
||||
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';
|
||||
export const UNVERIFIABLE_CODES = new Set(['unsupported-node', 'runtime-value']);
|
||||
export const MAX_JOIN_INPUTS = 1000;
|
||||
export const SAMPLER_LIKE_INPUTS = ['latent_image', 'guider', 'positive', 'negative'];
|
||||
export const NO_SAMPLER_NAMES = 3;
|
||||
export const CLASS_NAME_LENGTH = 64;
|
||||
export const MAX_SUBGRAPH_DEPTH = 8, MAX_WORKFLOW_NODES = 100000;
|
||||
|
||||
// inspection_outcomes.py
|
||||
export const OUTCOME_NAME_LENGTH = 200;
|
||||
export const LIMIT_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling'];
|
||||
export const NAMED_LINES = {
|
||||
'unsupported-node': 'Prompt uses a node the inspector does not support',
|
||||
'runtime-value': 'Prompt is chosen at run time',
|
||||
composite: 'Prompt combines several texts',
|
||||
};
|
||||
export const CODED_LINES = {
|
||||
...Object.fromEntries(LIMIT_CODES.map(code => [code, 'Prompt exceeds inspection limits.'])),
|
||||
'no-sampler': 'No supported sampler was found.',
|
||||
malformed: 'Prompt metadata in this file is malformed.',
|
||||
'multistage-differs': 'Sampler stages have different prompts.',
|
||||
};
|
||||
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.',
|
||||
};
|
||||
|
||||
// Output adapters (prompt_pair_resolver.py 44-105): [inputName, kind, {compose, storageInput, comma, strip, default}].
|
||||
export const adapter = (inputName, kind, options = {}) => Object.freeze({ inputName, kind, compose: false, storageInput: false, comma: false, strip: false, default: null, ...options });
|
||||
export const slotKey = (name, slot) => `${name}\u0000${String(slot)}`;
|
||||
export const ADAPTERS = new Map();
|
||||
for (const name of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit']) {
|
||||
ADAPTERS.set(slotKey(name, 0), adapter('text', 'conditioning', { compose: true }));
|
||||
ADAPTERS.set(slotKey(name, 1), adapter('text', 'string', { compose: true }));
|
||||
}
|
||||
ADAPTERS.set(slotKey('PromptManagerPair', 0), adapter('text', 'conditioning', { compose: true }));
|
||||
ADAPTERS.set(slotKey('PromptManagerPair', 2), adapter('text', 'string', { compose: true }));
|
||||
ADAPTERS.set(slotKey('PromptManagerPair', 1), adapter('negative_text', 'conditioning', { storageInput: true }));
|
||||
ADAPTERS.set(slotKey('PromptManagerPair', 3), adapter('negative_text', 'string', { storageInput: true }));
|
||||
ADAPTERS.set(slotKey('PromptManagerText', 0), adapter('text', 'string', { compose: true }));
|
||||
ADAPTERS.set(slotKey('CLIPTextEncode', 0), adapter('text', 'conditioning'));
|
||||
for (const name of ['PrimitiveString', 'PrimitiveStringMultiline']) ADAPTERS.set(slotKey(name, 0), adapter('value', 'string'));
|
||||
for (const name of ['Text Multiline', 'String Literal', 'ShowText|pysssss']) ADAPTERS.set(slotKey(name, 0), adapter('text', 'string'));
|
||||
// Historical PromptManager classes (inspection only).
|
||||
export const COMMA = { comma: true }, NEGATIVE = { strip: true, default: '' };
|
||||
const historical = [
|
||||
['PromptManagerV2', 0, 'text', 'conditioning', COMMA], ['PromptManagerV2', 1, 'negative_text', 'conditioning', NEGATIVE],
|
||||
['PromptManagerV2', 2, 'text', 'string', COMMA], ['PromptManagerV2', 3, 'negative_text', 'string', NEGATIVE],
|
||||
['PromptManagerV2Text', 0, 'text', 'string', COMMA], ['PromptManagerV2Text', 1, 'negative_text', 'string', NEGATIVE],
|
||||
['PromptManagerNegative', 0, 'text', 'conditioning', { strip: true }], ['PromptManagerNegative', 1, 'text', 'string', { strip: true }],
|
||||
['PromptManagerPositiveText', 0, 'text', 'string', COMMA], ['PromptManagerNegativeText', 0, 'text', 'string', COMMA],
|
||||
];
|
||||
for (const name of ['PromptManagerPositive', 'PromptManagerDetailer']) historical.push([name, 0, 'text', 'conditioning', COMMA], [name, 1, 'text', 'string', COMMA]);
|
||||
for (const [name, slot, input, kind, options] of historical) ADAPTERS.set(slotKey(name, slot), adapter(input, kind, options));
|
||||
// `known_slots` in execution_record: every adapter slot per class.
|
||||
export const ADAPTER_SLOTS = new Map();
|
||||
for (const key of ADAPTERS.keys()) {
|
||||
const [name, slot] = key.split('\u0000');
|
||||
if (!ADAPTER_SLOTS.has(name)) ADAPTER_SLOTS.set(name, new Set());
|
||||
ADAPTER_SLOTS.get(name).add(slot);
|
||||
}
|
||||
|
||||
export class Unresolved extends Error {
|
||||
constructor(message, code = null, node = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.node = node;
|
||||
}
|
||||
}
|
||||
export class Diverging extends Error {
|
||||
constructor(key) {
|
||||
super('diverging');
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
/** `_link`: a two-item list of (str or int) and a non-negative int, never a bool. */
|
||||
export function isLink(value) {
|
||||
if (!isList(value) || value.length !== 2) return false;
|
||||
const [source, slot] = value;
|
||||
return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0);
|
||||
}
|
||||
export const nodeRef = (nodeId, classType) => ({ id: pySlice(nodeId, CLASS_NAME_LENGTH), class_type: typeof classType === 'string' ? pySlice(classType, CLASS_NAME_LENGTH) : null });
|
||||
|
||||
export function result(state, { positive = null, negative = null, samplerId = null, reason = '', candidates = [], reasonCode = null, node = null } = {}) {
|
||||
return { state, positive, negative, sampler_id: samplerId, reason, candidates, reason_code: reasonCode, node };
|
||||
}
|
||||
|
||||
export class Budget {
|
||||
constructor(total) {
|
||||
this.total = total;
|
||||
this.allowance = total;
|
||||
}
|
||||
reset(allowance) { this.allowance = allowance; }
|
||||
spend() {
|
||||
if (this.total <= 0 || this.allowance <= 0) throw new Unresolved('Resolution visit limit reached', 'work-limit');
|
||||
this.total -= 1;
|
||||
this.allowance -= 1;
|
||||
}
|
||||
charge(cost) {
|
||||
if (cost > this.allowance) throw new Unresolved('Resolution visit limit reached', 'work-limit');
|
||||
this.allowance -= cost;
|
||||
}
|
||||
}
|
||||
|
||||
/** `read_pair_execution`: one distinct record for a traced node, or null; malformed evidence throws `Unresolved`. */
|
||||
export function readPairExecution(metadata, nodeId) {
|
||||
const evidence = message => new Unresolved(message, 'malformed');
|
||||
if (!isDict(metadata) || !metadata.has(EXECUTION_KEY)) return null;
|
||||
let envelope = metadata.get(EXECUTION_KEY);
|
||||
if (typeof envelope === 'string') {
|
||||
if (pyLen(envelope) > MAX_EXECUTION_JSON) throw evidence('Execution evidence exceeds the size limit');
|
||||
try { envelope = pyJsonLoads(envelope); } catch (error) {
|
||||
if (error instanceof PyValueError) throw evidence('Malformed execution evidence JSON');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!isDict(envelope) || !pyEqualsOne(pyGet(envelope, 'version'))) throw evidence('Unsupported execution evidence format');
|
||||
const nodes = pyGet(envelope, 'nodes');
|
||||
if (pyTruthy(pyGet(envelope, 'truncated')) || !isDict(nodes) || nodes.size > MAX_EXECUTION_NODES) throw evidence('Truncated or malformed execution evidence');
|
||||
if (!nodes.has(nodeId)) return null;
|
||||
const node = nodes.get(nodeId);
|
||||
if (!isDict(node) || pyTruthy(pyGet(node, 'truncated'))) throw evidence('Truncated or malformed node execution evidence');
|
||||
const records = pyGet(node, 'records');
|
||||
if (!isList(records) || !records.length || records.length > MAX_EXECUTION_RECORDS) throw evidence('Malformed node execution records');
|
||||
const unique = new Map();
|
||||
for (const record of records) {
|
||||
if (!isDict(record) || EXECUTION_FIELDS.some(key => typeof pyGet(record, key) !== 'string' || pyLen(record.get(key)) > MAX_EXECUTION_TEXT)) {
|
||||
throw evidence('Malformed execution text values');
|
||||
}
|
||||
const clean = Object.fromEntries(EXECUTION_FIELDS.map(key => [key, record.get(key)]));
|
||||
if (clean.negative_effective_text !== clean.negative_text) throw evidence('Negative execution text must be unchanged');
|
||||
unique.set(JSON.stringify(EXECUTION_FIELDS.map(key => clean[key])), clean);
|
||||
}
|
||||
if (unique.size !== 1) throw evidence('Multiple distinct executions have no image anchor');
|
||||
return [...unique.values()][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated semantics table (utils/inspection_semantics.py), from the parsed JSON document. Throws
|
||||
* `SemanticsError` for any shape the server rejects; the caller then answers "semantics unavailable".
|
||||
*/
|
||||
export function compileSemantics(doc) {
|
||||
const fail = message => { throw new SemanticsError(message); };
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
if (!plain(doc) || doc.schema !== 1) fail('unsupported semantics table');
|
||||
const owners = new Map();
|
||||
const claim = (name, category) => {
|
||||
if (owners.has(name) && owners.get(name) !== category) fail(`duplicate class ${name}`);
|
||||
owners.set(name, category);
|
||||
};
|
||||
const byClass = category => {
|
||||
if (!plain(doc[category])) fail(`category ${category} must be an object`);
|
||||
const table = new Map();
|
||||
for (const [name, entry] of Object.entries(doc[category])) {
|
||||
if (!plain(entry) || typeof entry.strategy !== 'string') fail(`${category}[${name}]`);
|
||||
claim(name, category);
|
||||
table.set(name, entry);
|
||||
}
|
||||
return table;
|
||||
};
|
||||
const bySlot = category => {
|
||||
if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`);
|
||||
const table = new Map();
|
||||
for (const row of doc[category]) {
|
||||
if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail(`${category} row`);
|
||||
const key = slotKey(row.class, row.slot);
|
||||
if (table.has(key)) fail(`${category}: duplicate output key`);
|
||||
claim(row.class, category);
|
||||
const { class: _name, slot: _slot, ...entry } = row;
|
||||
table.set(key, entry);
|
||||
}
|
||||
return table;
|
||||
};
|
||||
const bySet = category => {
|
||||
if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`);
|
||||
const set = new Set();
|
||||
for (const name of doc[category]) {
|
||||
if (typeof name !== 'string' || set.has(name)) fail(`${category}: duplicate class`);
|
||||
claim(name, category);
|
||||
set.add(name);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const samplers = byClass('samplers'), guiders = byClass('guiders'), selectors = byClass('selectors');
|
||||
const composites = byClass('composites'), savers = byClass('savers');
|
||||
const conditioning = bySlot('conditioning'), encoders = bySlot('encoders'), strings = bySlot('strings');
|
||||
const runtime = bySet('runtime'), nonFileOutputs = bySet('non_file_outputs');
|
||||
const stageInputs = doc.stage_inputs;
|
||||
if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs');
|
||||
return Object.freeze({
|
||||
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs,
|
||||
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
/** `outcome_message`: one line for an unresolved outcome, or null to keep the status copy. */
|
||||
export function outcomeMessage(resolution) {
|
||||
if (resolution?.state !== 'unresolved') return null;
|
||||
const code = typeof resolution.reason_code === 'string' ? resolution.reason_code : null;
|
||||
if (Object.hasOwn(NAMED_LINES, code)) {
|
||||
const node = resolution.node, raw = node !== null && typeof node === 'object' ? node.class_type : null;
|
||||
const name = typeof raw === 'string' && raw ? pySlice(raw, OUTCOME_NAME_LENGTH) : null;
|
||||
return name ? `${NAMED_LINES[code]} (${name}).` : `${NAMED_LINES[code]}.`;
|
||||
}
|
||||
if (code === null) return Object.hasOwn(UNCODED_LINES, resolution.reason) ? UNCODED_LINES[resolution.reason] : null;
|
||||
return Object.hasOwn(CODED_LINES, code) ? CODED_LINES[code] : null;
|
||||
}
|
||||
|
||||
export const sideOf = (status, text = null, source = null) => ({ status, text, source });
|
||||
export function explained(prompts, resolution) {
|
||||
const message = outcomeMessage(resolution);
|
||||
if (message === null) return prompts;
|
||||
return Object.fromEntries(Object.entries(prompts).map(([role, side]) => [role, side.status === 'unresolved' && !Object.hasOwn(side, 'message') ? { ...side, message } : side]));
|
||||
}
|
||||
export const EMPTY_POSITIVE = outcomeMessage({ state: 'unresolved', reason: EMPTY_POSITIVE_REASON });
|
||||
@@ -0,0 +1,776 @@
|
||||
// The server's inspection resolver in this browser (#249 local Inspect, PR L3 Task 8, decision LI-1): a port of
|
||||
// utils/prompt_inspector.py `inspect_prompt_evidence` with utils/prompt_pair_resolver.py (INSPECTION_PROFILE) and
|
||||
// utils/pair_execution.py `read_pair_execution`, driven by web/js/inspection-semantics.json. Python is the oracle:
|
||||
// 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.
|
||||
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
|
||||
import {
|
||||
ADAPTERS,
|
||||
ADAPTER_SLOTS,
|
||||
Budget,
|
||||
CANDIDATE_STRATEGIES,
|
||||
CLASS_NAME_LENGTH,
|
||||
DETAILER,
|
||||
Diverging,
|
||||
EMPTY_POSITIVE,
|
||||
EMPTY_POSITIVE_REASON,
|
||||
GUIDER_ABSENT_REASON,
|
||||
INCOMPATIBLE_SLOT,
|
||||
LocalResolverGap,
|
||||
MAX_BRANCH_CANDIDATES,
|
||||
MAX_JOIN_INPUTS,
|
||||
MAX_SUBGRAPH_DEPTH,
|
||||
MAX_WORKFLOW_NODES,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
NO_SAMPLER_NAMES,
|
||||
OVERWRITTEN_PROMPT_REASON,
|
||||
PROFILE,
|
||||
ROLE_METADATA_KEYS,
|
||||
RUNTIME_REASON,
|
||||
SAMPLER_LIKE_INPUTS,
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
UNVERIFIABLE_CODES,
|
||||
Unresolved,
|
||||
ZEROED_SOURCE,
|
||||
compileSemantics,
|
||||
explained,
|
||||
isLink,
|
||||
nodeRef,
|
||||
readPairExecution,
|
||||
result,
|
||||
sideOf,
|
||||
slotKey,
|
||||
} from './local-resolver-support.mjs';
|
||||
|
||||
export { LocalResolverGap, SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
|
||||
|
||||
class GraphResolver {
|
||||
constructor(graph, metadata, semantics, overwritten) {
|
||||
this.graph = graph;
|
||||
this.overwritten = overwritten;
|
||||
this.perCandidateVisits = PROFILE.perCandidateVisits;
|
||||
this.budget = new Budget(PROFILE.totalVisits);
|
||||
this.budget.reset(PROFILE.perCandidateVisits);
|
||||
this.pathEntries = PROFILE.maxPathEntries;
|
||||
this.builtChars = PROFILE.maxBuiltChars;
|
||||
this.scanned = PROFILE.maxScannedUnits;
|
||||
this.checked = new Map();
|
||||
this.costs = new Map();
|
||||
this.heights = new Map();
|
||||
this.deepest = 0;
|
||||
this.maxDepth = PROFILE.maxDepth;
|
||||
this.maxTextLength = PROFILE.maxTextLength;
|
||||
this.memo = new Map();
|
||||
this.active = new Set();
|
||||
this.metadata = metadata ?? new Map();
|
||||
this.textSources = new Map();
|
||||
this.choices = new Map();
|
||||
this.tainted = false;
|
||||
this.latentWalks = new Map();
|
||||
this.latentCharged = new Set();
|
||||
this.semantics = semantics;
|
||||
this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name));
|
||||
this.stageClasses = new Set(semantics.samplers.keys());
|
||||
}
|
||||
|
||||
node(nodeId) {
|
||||
if (!this.checked.has(nodeId)) {
|
||||
try {
|
||||
this.checked.set(nodeId, this.checkedNode(nodeId));
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved) || error.code === 'work-limit') throw error;
|
||||
this.checked.set(nodeId, error);
|
||||
}
|
||||
}
|
||||
const checked = this.checked.get(nodeId);
|
||||
if (checked instanceof Unresolved) throw new Unresolved(checked.message, checked.code, checked.node);
|
||||
return checked;
|
||||
}
|
||||
checkedNode(nodeId) {
|
||||
const node = pyGet(this.graph, nodeId);
|
||||
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) throw new Unresolved('Missing or malformed graph node', 'malformed');
|
||||
const inputs = node.get('inputs');
|
||||
this.scan(inputs.size);
|
||||
return [pyStr(pyGet(node, 'class_type', '')), inputs];
|
||||
}
|
||||
scan(units) {
|
||||
if (units > this.scanned) {
|
||||
this.scanned = -1;
|
||||
throw new Unresolved('Resolution work limit reached', 'work-limit');
|
||||
}
|
||||
this.scanned -= units;
|
||||
}
|
||||
unsupported(message, nodeId) { return this.named(message, 'unsupported-node', nodeId); }
|
||||
named(message, code, nodeId) {
|
||||
const node = pyGet(this.graph, nodeId);
|
||||
const classType = isDict(node) ? pyGet(node, 'class_type') : null;
|
||||
return new Unresolved(message, code, nodeRef(nodeId, classType));
|
||||
}
|
||||
bounded(text) {
|
||||
if (pyLen(text) > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||
return text;
|
||||
}
|
||||
built(text) {
|
||||
this.builtChars -= pyLen(text);
|
||||
if (this.builtChars < 0) throw new Unresolved('Resolution work limit reached', 'work-limit');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `text`: [text, path, storageText] for a string value or a typed link. */
|
||||
text(value, kind, depth = 0) {
|
||||
this.deepest = Math.max(this.deepest, depth);
|
||||
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||
if (typeof value === 'string' && kind === 'string') return [this.bounded(value), [], value];
|
||||
if (!isLink(value)) throw new Unresolved('Expected a typed graph link or text input', 'malformed');
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
const key = `${slotKey(nodeId, slot)}\u0000${kind}`;
|
||||
if (this.active.has(key)) throw new Unresolved('Cyclic prompt graph', 'malformed');
|
||||
if (this.memo.has(key)) {
|
||||
const height = this.heights.get(key);
|
||||
if (depth + height >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||
this.budget.charge(this.costs.get(key));
|
||||
this.deepest = Math.max(this.deepest, depth + height);
|
||||
return this.memo.get(key);
|
||||
}
|
||||
const allowance = this.budget.allowance, outerDeepest = this.deepest, outerTainted = this.tainted;
|
||||
this.tainted = false;
|
||||
this.deepest = depth;
|
||||
try {
|
||||
this.budget.spend();
|
||||
const [name, inputs] = this.node(nodeId);
|
||||
this.scan(inputs.size);
|
||||
if (this.overwritten.has(nodeId)) throw new Unresolved(OVERWRITTEN_PROMPT_REASON, 'malformed');
|
||||
this.active.add(key);
|
||||
let text, path, storageText;
|
||||
const adapted = ADAPTERS.get(slotKey(name, slot));
|
||||
if (name === 'Reroute' && slot === 0) {
|
||||
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
|
||||
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
|
||||
[text, path, storageText] = this.text(inputs.get(keys[0]), kind, depth + 1);
|
||||
this.inheritSource(nodeId, slot, [path]);
|
||||
} else if (adapted) {
|
||||
if (adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
|
||||
const evidence = name === 'PromptManagerPair' ? this.executionRecord(nodeId, inputs) : null;
|
||||
if (evidence !== null) {
|
||||
text = evidence[adapted.inputName === 'text' ? 'positive_text' : 'negative_effective_text'];
|
||||
storageText = evidence[adapted.inputName];
|
||||
path = [];
|
||||
this.textSources.set(slotKey(nodeId, slot), 'execution');
|
||||
} else if (adapted.comma || adapted.strip) {
|
||||
[text, path, storageText] = this.historical(adapted, inputs, depth);
|
||||
} else {
|
||||
[text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1);
|
||||
if (adapted.compose || adapted.storageInput) storageText = text;
|
||||
if (adapted.compose) {
|
||||
const [before, beforePath] = this.text(pyGet(inputs, 'prepend_text', ''), 'string', depth + 1);
|
||||
const [after, afterPath] = this.text(pyGet(inputs, 'append_text', ''), 'string', depth + 1);
|
||||
this.scan(pyLen(before) + pyLen(after) + beforePath.length + path.length + afterPath.length);
|
||||
text = this.built(this.bounded([pyStrip(before), text, pyStrip(after)].filter(part => part).join(' ')));
|
||||
path = [...beforePath, ...path, ...afterPath];
|
||||
}
|
||||
}
|
||||
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
|
||||
[text, path] = this.concatenate(name, inputs, depth);
|
||||
storageText = text;
|
||||
} else if (this.follows(name, slot, kind)) {
|
||||
[text, path, storageText] = this.semantic(nodeId, name, slot, kind, inputs, depth);
|
||||
} else {
|
||||
throw this.unsupported('Unsupported node semantics or output slot', nodeId);
|
||||
}
|
||||
this.scan(path.length + 1);
|
||||
path = [{ node_id: nodeId, output_slot: slot }, ...path];
|
||||
const pathLimit = this.maxDepth * Math.max(1, this.perCandidateVisits);
|
||||
this.pathEntries -= path.length;
|
||||
if (path.length > pathLimit || this.pathEntries < 0) throw new Unresolved('Provenance path limit reached', 'work-limit');
|
||||
const traced = [text, path, storageText];
|
||||
if (!this.tainted) {
|
||||
this.memo.set(key, traced);
|
||||
this.costs.set(key, allowance - this.budget.allowance);
|
||||
this.heights.set(key, this.deepest - depth);
|
||||
}
|
||||
return traced;
|
||||
} finally {
|
||||
this.deepest = Math.max(outerDeepest, this.deepest);
|
||||
this.tainted = outerTainted || this.tainted;
|
||||
this.active.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** `StringConcatenate` / `Text Concatenate` (the `text` branch). */
|
||||
concatenate(name, inputs, depth) {
|
||||
let keys, delimiter, clean;
|
||||
if (name === 'StringConcatenate') {
|
||||
keys = ['string_a', 'string_b'];
|
||||
delimiter = pyGet(inputs, 'delimiter', '');
|
||||
clean = false;
|
||||
} else {
|
||||
keys = [...inputs.keys()].filter(item => /^text_[a-z]$/.test(item)).sort();
|
||||
delimiter = pyGet(inputs, 'delimiter', ' ');
|
||||
clean = pyStr(pyGet(inputs, 'clean_whitespace', 'true')).toLowerCase() === 'true';
|
||||
}
|
||||
if (!keys.length || typeof delimiter !== 'string') throw new Unresolved('Malformed concatenation', 'malformed');
|
||||
const parts = [], path = [];
|
||||
let size = 0;
|
||||
for (const inputName of keys) {
|
||||
let [part, followed] = this.text(pyGet(inputs, inputName, ''), 'string', depth + 1);
|
||||
this.scan(pyLen(part) + followed.length);
|
||||
if (clean) part = pyStrip(part);
|
||||
path.push(...followed);
|
||||
if (part || name === 'StringConcatenate') {
|
||||
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
|
||||
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return [this.built(parts.join(delimiter)), path];
|
||||
}
|
||||
|
||||
/** `prompt_manager_*.py` at 07a5af3: strip, then join with ", ". */
|
||||
historical(adapted, inputs, depth) {
|
||||
let [text, path] = this.text(pyGet(inputs, adapted.inputName, adapted.default), 'string', depth + 1);
|
||||
this.scan(pyLen(text));
|
||||
text = pyStrip(text);
|
||||
const storageText = text;
|
||||
if (adapted.comma) {
|
||||
let [before, beforePath] = this.text(pyGet(inputs, 'prepend_text') ?? '', 'string', depth + 1);
|
||||
let [after, afterPath] = this.text(pyGet(inputs, 'append_text') ?? '', 'string', depth + 1);
|
||||
this.scan(pyLen(before) + pyLen(after) + beforePath.length + afterPath.length);
|
||||
before = pyStrip(before); after = pyStrip(after);
|
||||
if (before) text = `${before}${text && !before.endsWith(',') ? ', ' : ''}${text}`;
|
||||
if (after) text = `${text}${text && !text.endsWith(',') ? ', ' : ''}${after}`;
|
||||
text = this.built(this.bounded(text));
|
||||
path = [...beforePath, ...path, ...afterPath];
|
||||
}
|
||||
return [text, path, storageText];
|
||||
}
|
||||
|
||||
follows(name, slot, kind) {
|
||||
const semantics = this.semantics, key = slotKey(name, slot);
|
||||
if (semantics.runtime.has(name)) return true;
|
||||
if (semantics.encoders.has(key) || semantics.strings.has(key)) return true;
|
||||
if (slot === 0 && semantics.selectors.has(name)) return true;
|
||||
return kind === 'conditioning' && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name)));
|
||||
}
|
||||
|
||||
semantic(nodeId, name, slot, kind, inputs, depth) {
|
||||
const semantics = this.semantics, key = slotKey(name, slot);
|
||||
if (semantics.runtime.has(name)) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||
for (const [table, expected, build] of [[semantics.encoders, 'conditioning', 'encoded'], [semantics.strings, 'string', 'stringed']]) {
|
||||
const listed = table.get(key);
|
||||
if (listed !== undefined) {
|
||||
if (kind !== expected) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
|
||||
return this[build](nodeId, listed, inputs, depth);
|
||||
}
|
||||
}
|
||||
const entry = kind === 'conditioning' ? semantics.conditioning.get(key) : undefined;
|
||||
if (entry !== undefined && entry.strategy === 'zeroed') {
|
||||
this.textSources.set(slotKey(nodeId, slot), ZEROED_SOURCE);
|
||||
return ['', [], ''];
|
||||
}
|
||||
if (entry !== undefined) {
|
||||
const traced = this.text(pyGet(inputs, entry.input), kind, depth + 1);
|
||||
this.inheritSource(nodeId, slot, [traced[1]]);
|
||||
return traced;
|
||||
}
|
||||
if (semantics.selectors.has(name)) return this.select(nodeId, semantics.selectors.get(name), inputs, kind, depth);
|
||||
const parts = semantics.composites.get(name).inputs.map(inputName => this.text(pyGet(inputs, inputName), kind, depth + 1));
|
||||
if (new Set(parts.map(part => part[0])).size !== 1) throw this.named('Combined conditioning has different texts', 'composite', nodeId);
|
||||
return this.merged(nodeId, slot, parts);
|
||||
}
|
||||
|
||||
select(nodeId, entry, inputs, kind, depth) {
|
||||
const condition = pyGet(inputs, entry.condition), branches = entry.branches;
|
||||
let choice;
|
||||
if (typeof condition === 'boolean') choice = condition;
|
||||
else if (!isLink(condition)) throw new Unresolved('Malformed selector condition', 'malformed');
|
||||
else if (this.choices.has(slotKey(String(condition[0]), condition[1]))) {
|
||||
choice = this.choices.get(slotKey(String(condition[0]), condition[1]));
|
||||
this.tainted = true;
|
||||
} else choice = null;
|
||||
if (choice !== null) {
|
||||
const traced = this.text(pyGet(inputs, branches[choice ? 'true' : 'false']), kind, depth + 1);
|
||||
this.inheritSource(nodeId, 0, [traced[1]]);
|
||||
return traced;
|
||||
}
|
||||
const parts = ['true', 'false'].map(label => this.text(pyGet(inputs, branches[label]), kind, depth + 1));
|
||||
if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1]));
|
||||
return this.merged(nodeId, 0, parts);
|
||||
}
|
||||
|
||||
merged(nodeId, slot, parts) {
|
||||
const traced = this.joinedPaths(parts, parts);
|
||||
this.inheritSource(nodeId, slot, parts.map(part => part[1]));
|
||||
return traced;
|
||||
}
|
||||
|
||||
joinedPaths(chosen, parts) {
|
||||
const text = chosen[0][0];
|
||||
const storages = new Set(chosen.map(part => part[2]));
|
||||
const storageText = storages.size === 1 ? chosen[0][2] : text;
|
||||
const paths = parts.map(part => part[1]);
|
||||
this.scan(paths.reduce((sum, path) => sum + path.length, 0));
|
||||
const merged = [], seen = new Set();
|
||||
for (const path of paths) {
|
||||
for (const step of path) {
|
||||
const identity = slotKey(step.node_id, step.output_slot);
|
||||
if (!seen.has(identity)) { seen.add(identity); merged.push(step); }
|
||||
}
|
||||
}
|
||||
return [text, merged, storageText];
|
||||
}
|
||||
|
||||
encoded(nodeId, entry, inputs, depth) {
|
||||
const names = entry.inputs;
|
||||
if (entry.strategy === 'single') return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
|
||||
if (entry.strategy === 'flag-parts') return this.flagged(nodeId, entry, inputs, depth);
|
||||
if (entry.strategy !== 'parts-equal') throw this.unsupported('Unsupported encoder strategy', nodeId);
|
||||
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
|
||||
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
|
||||
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
|
||||
const filled = parts.filter(part => part[0] !== '');
|
||||
if (filled.length === 1) return this.joinedPaths(filled, parts);
|
||||
throw this.named('Encoder parts have different texts', 'composite', nodeId);
|
||||
}
|
||||
|
||||
flagged(nodeId, entry, inputs, depth) {
|
||||
const names = entry.inputs;
|
||||
if (!this.setting(nodeId, inputs, entry.params.flag, 'bool')) return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
|
||||
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
|
||||
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
|
||||
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
|
||||
throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||
}
|
||||
|
||||
stringed(nodeId, entry, inputs, depth) {
|
||||
const strategy = entry.strategy, source = entry.inputs[0];
|
||||
if (strategy === 'literal') return this.text(pyGet(inputs, source), 'string', depth + 1);
|
||||
if (strategy === 'strip-newlines') {
|
||||
const flag = this.setting(nodeId, inputs, entry.params.flag, 'bool');
|
||||
const traced = this.text(pyGet(inputs, source), 'string', depth + 1);
|
||||
if (!flag) return traced;
|
||||
this.scan(pyLen(traced[0]));
|
||||
const text = this.built(this.bounded(pyStrip(traced[0].replaceAll('\n', ''))));
|
||||
return [text, traced[1], text];
|
||||
}
|
||||
if (strategy === 'join-multi') return this.joinMulti(nodeId, entry, inputs, depth);
|
||||
throw this.unsupported('Unsupported string strategy', nodeId);
|
||||
}
|
||||
|
||||
/** A literal node setting of exactly `type(value) is kind`; linked is `unsupported-node`, else malformed. */
|
||||
setting(nodeId, inputs, name, kind) {
|
||||
if (!inputs.has(name)) throw new Unresolved('Missing string node setting', 'malformed');
|
||||
const value = inputs.get(name);
|
||||
if (isLink(value)) throw this.unsupported('Linked string node setting', nodeId);
|
||||
const typed = { bool: typeof value === 'boolean', int: isInt(value), str: typeof value === 'string' }[kind];
|
||||
if (!typed) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||
return value;
|
||||
}
|
||||
|
||||
joinMulti(nodeId, entry, inputs, depth) {
|
||||
const params = entry.params, flag = params.list_flag;
|
||||
if (!inputs.has(flag)) throw new Unresolved('Missing string node setting', 'malformed');
|
||||
if (isLink(inputs.get(flag)) || inputs.get(flag) === true) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||
if (inputs.get(flag) !== false) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||
const count = this.setting(nodeId, inputs, params.count, 'int');
|
||||
const delimiter = this.setting(nodeId, inputs, params.delimiter, 'str');
|
||||
if (!(count >= 1 && count <= MAX_JOIN_INPUTS)) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||
const total = Number(count);
|
||||
this.scan(total);
|
||||
const names = [entry.inputs[0]];
|
||||
for (let index = 2; index <= total; index++) names.push(`${params.input_prefix}${index}`);
|
||||
const parts = [], path = [], seen = new Set();
|
||||
let size = 0;
|
||||
names.forEach((inputName, position) => {
|
||||
if (position && !inputs.has(inputName)) return;
|
||||
const [part, followed] = this.text(pyGet(inputs, inputName), 'string', depth + 1);
|
||||
this.scan(pyLen(part) + followed.length);
|
||||
for (const step of followed) {
|
||||
const identity = slotKey(step.node_id, step.output_slot);
|
||||
if (!seen.has(identity)) { seen.add(identity); path.push(step); }
|
||||
}
|
||||
if (position && !part) return;
|
||||
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
|
||||
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||
parts.push(part);
|
||||
});
|
||||
const text = this.built(parts.join(delimiter));
|
||||
return [text, path, text];
|
||||
}
|
||||
|
||||
sourceOf(path) {
|
||||
if (!path.length) return 'graph-inferred';
|
||||
return this.textSources.get(slotKey(path[0].node_id, path[0].output_slot)) ?? 'graph-inferred';
|
||||
}
|
||||
inheritSource(nodeId, slot, paths) {
|
||||
const sources = new Set(paths.map(path => this.sourceOf(path)));
|
||||
if (sources.size === 1) this.textSources.set(slotKey(nodeId, slot), [...sources][0]);
|
||||
else this.textSources.delete(slotKey(nodeId, slot));
|
||||
}
|
||||
unverifiable(error) { return UNVERIFIABLE_CODES.has(error.code); }
|
||||
|
||||
executionRecord(nodeId, inputs) {
|
||||
const record = readPairExecution(this.metadata, nodeId);
|
||||
if (record === null) return null;
|
||||
for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) {
|
||||
let expected;
|
||||
try {
|
||||
expected = this.text(pyGet(inputs, name, ''), 'string')[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Unresolved && this.unverifiable(error)) {
|
||||
const value = pyGet(inputs, name);
|
||||
if (isLink(value)) {
|
||||
const [sourceName] = this.node(String(value[0]));
|
||||
const known = ADAPTER_SLOTS.get(sourceName);
|
||||
if (known && !known.has(String(value[1]))) throw error;
|
||||
if (['StringConcatenate', 'Text Concatenate', 'Reroute'].includes(sourceName) && value[1] !== 0) throw error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (name === 'prepend_text' || name === 'append_text') expected = pyStrip(expected);
|
||||
if (expected !== record[name]) throw new Unresolved('Execution evidence does not match recorded node inputs', 'malformed');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
side(value, samplerId, guiderId = null, depth = 0) {
|
||||
const [text, path, storageText] = this.text(value, 'conditioning', depth);
|
||||
const origin = path[0];
|
||||
const provenance = {
|
||||
format: 'comfyui', sampler_id: samplerId, ...origin, path,
|
||||
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
|
||||
};
|
||||
if (guiderId !== null) provenance.guider_id = guiderId;
|
||||
return { text, storage_text: storageText, provenance };
|
||||
}
|
||||
|
||||
guider(value) {
|
||||
const seen = new Set();
|
||||
let depth = 0;
|
||||
for (;;) {
|
||||
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||
if (!isLink(value)) throw new Unresolved('Expected a guider link', 'malformed');
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
if (seen.has(nodeId)) throw new Unresolved('Cyclic guider ancestry', 'malformed');
|
||||
seen.add(nodeId);
|
||||
this.budget.spend();
|
||||
const [name, inputs] = this.node(nodeId);
|
||||
this.scan(inputs.size);
|
||||
depth += 1;
|
||||
if (name === 'Reroute' && slot === 0) {
|
||||
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
|
||||
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
|
||||
value = inputs.get(keys[0]);
|
||||
continue;
|
||||
}
|
||||
const entry = this.semantics.guiders.get(name);
|
||||
if (entry === undefined || slot !== 0) throw this.unsupported('Unsupported guider', nodeId);
|
||||
return [nodeId, inputs, { positive: entry.positive, negative: entry.negative ?? null }, entry.absent_reason ?? GUIDER_ABSENT_REASON, depth];
|
||||
}
|
||||
}
|
||||
|
||||
pair(samplerId) {
|
||||
this.budget.reset(this.perCandidateVisits);
|
||||
this.latentCharged = new Set();
|
||||
if (this.budget.total <= 0) return result('unresolved', { samplerId, reason: 'Resolution visit limit reached', reasonCode: 'work-limit' });
|
||||
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()));
|
||||
} 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');
|
||||
for (const choice of [true, false]) pending.push(new Map([...this.choices, [error.key, choice]]));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||
} finally {
|
||||
this.choices = new Map();
|
||||
}
|
||||
if (results.every(item => item.state === 'unresolved')) return results[0];
|
||||
if (new Set(results.map(outcomeKey)).size === 1) return results[0];
|
||||
return result('ambiguous', { samplerId, reason: 'Selector branches have different prompts', candidates: results });
|
||||
}
|
||||
|
||||
sides(samplerId) {
|
||||
let positive = null, negative = null;
|
||||
try {
|
||||
let [name, inputs] = this.node(samplerId);
|
||||
const entry = this.semantics.samplers.get(name);
|
||||
this.checkOverrides(samplerId, entry, inputs);
|
||||
const latent = entry.latent;
|
||||
if (latent !== undefined && latent !== null && inputs.has(latent)) this.latentChecked(samplerId, inputs.get(latent));
|
||||
let guiderId = null, depth = 0, conditioning = entry.conditioning, absentReason = 'Negative input is absent';
|
||||
if (entry.strategy === 'custom-advanced') {
|
||||
let guiderReason;
|
||||
[guiderId, inputs, conditioning, guiderReason, depth] = this.guider(pyGet(inputs, conditioning.guider));
|
||||
if (conditioning.negative === null) absentReason = guiderReason;
|
||||
}
|
||||
positive = this.side(pyGet(inputs, conditioning.positive), samplerId, guiderId, depth);
|
||||
if (!pyStrip(positive.text)) throw new Unresolved(EMPTY_POSITIVE_REASON);
|
||||
const negativeName = conditioning.negative;
|
||||
if (negativeName === null || negativeName === undefined || !inputs.has(negativeName)) {
|
||||
return result('absent', { positive, samplerId, reason: absentReason });
|
||||
}
|
||||
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
|
||||
return result('resolved', { positive, negative, samplerId });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||
}
|
||||
}
|
||||
|
||||
checkOverrides(samplerId, entry, inputs) {
|
||||
for (const name of entry.prompt_overrides ?? []) {
|
||||
const value = pyGet(inputs, name);
|
||||
if (value === null || value === '') continue;
|
||||
if (isLink(value)) throw this.unsupported('Unsupported prompt override', String(value[0]));
|
||||
throw this.named(RUNTIME_REASON, 'runtime-value', samplerId);
|
||||
}
|
||||
}
|
||||
|
||||
latentChecked(samplerId, value) {
|
||||
let error;
|
||||
if (this.latentWalks.has(samplerId)) {
|
||||
const [walked, cost] = this.latentWalks.get(samplerId);
|
||||
error = walked;
|
||||
if (!this.latentCharged.has(samplerId)) {
|
||||
this.budget.charge(cost);
|
||||
this.latentCharged.add(samplerId);
|
||||
}
|
||||
} else {
|
||||
const before = this.budget.allowance;
|
||||
try {
|
||||
this.latentStages(samplerId, value);
|
||||
error = null;
|
||||
} catch (raised) {
|
||||
if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised;
|
||||
error = raised;
|
||||
}
|
||||
this.latentWalks.set(samplerId, [error, before - this.budget.allowance]);
|
||||
this.latentCharged.add(samplerId);
|
||||
}
|
||||
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
|
||||
}
|
||||
|
||||
enqueue(queue, queued, inputs, depth) {
|
||||
this.scan(inputs.size);
|
||||
for (const item of inputs.values()) {
|
||||
if (!isLink(item)) continue;
|
||||
const key = String(item[0]);
|
||||
if (!queued.has(key)) { queued.add(key); queue.push([key, depth]); }
|
||||
}
|
||||
}
|
||||
|
||||
stageShaped(name, inputs) {
|
||||
if (this.semantics.allClasses.has(name)) return false;
|
||||
const { shaped, withModel } = this.semantics.stageInputs;
|
||||
if (shaped.some(key => isLink(pyGet(inputs, key)))) return true;
|
||||
return isLink(pyGet(inputs, 'model')) && withModel.some(key => isLink(pyGet(inputs, key)));
|
||||
}
|
||||
|
||||
latentStages(origin, value) {
|
||||
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
|
||||
const 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 latent ancestry', 'malformed');
|
||||
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||
this.budget.spend();
|
||||
const [name, inputs] = this.node(nodeId);
|
||||
if (this.stageClasses.has(name)) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
|
||||
if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId);
|
||||
this.enqueue(queue, queued, inputs, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const names = [];
|
||||
for (const node of graph.values()) {
|
||||
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue;
|
||||
const name = pyGet(node, 'class_type');
|
||||
if (typeof name !== 'string' || !name || names.includes(name)) continue;
|
||||
if (SAMPLER_LIKE_INPUTS.some(key => isLink(pyGet(node.get('inputs'), key)))) {
|
||||
names.push(pySlice(name, CLASS_NAME_LENGTH));
|
||||
if (names.length === NO_SAMPLER_NAMES) break;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function noSampler(graph) {
|
||||
const seen = samplerLikeClasses(graph);
|
||||
const reason = `No supported sampler pair found${seen.length ? ` (saw ${seen.join(', ')})` : ''}`;
|
||||
return result('unresolved', { reason, reasonCode: 'no-sampler' });
|
||||
}
|
||||
|
||||
/** Python `kind in mapping` for a workflow `type`: an unhashable list or dict raises `TypeError` there. */
|
||||
function hashable(value) {
|
||||
if (isList(value) || isDict(value)) throw new TypeError("unhashable type in workflow node 'type'");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `_prompt_manager_widgets`: API node ID to widget text for every PromptManager node that runs. */
|
||||
function promptManagerWidgets(workflow) {
|
||||
const nodes = isDict(workflow) ? pyGet(workflow, 'nodes') : null;
|
||||
if (!isList(nodes)) return new Map();
|
||||
const definitions = pyGet(workflow, 'definitions');
|
||||
const listed = isDict(definitions) ? pyGet(definitions, 'subgraphs') : null;
|
||||
const subgraphs = new Map();
|
||||
for (const subgraph of isList(listed) ? listed : []) {
|
||||
if (isDict(subgraph) && typeof pyGet(subgraph, 'id') === 'string' && isList(pyGet(subgraph, 'nodes'))) subgraphs.set(subgraph.get('id'), subgraph.get('nodes'));
|
||||
}
|
||||
const widgets = new Map(), stack = [[nodes, '', 0]];
|
||||
let visited = 0;
|
||||
while (stack.length) {
|
||||
const [members, prefix, depth] = stack.pop();
|
||||
for (const node of members) {
|
||||
visited += 1;
|
||||
if (visited > MAX_WORKFLOW_NODES) return widgets;
|
||||
if (!isDict(node)) continue;
|
||||
const kind = pyGet(node, 'type'), nodeId = pyGet(node, 'id');
|
||||
if (!(typeof nodeId === 'string' || isInt(nodeId))) continue;
|
||||
const apiId = `${prefix}${String(nodeId)}`;
|
||||
if (typeof hashable(kind) === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) {
|
||||
stack.push([subgraphs.get(kind), `${apiId}:`, depth + 1]);
|
||||
continue;
|
||||
}
|
||||
const values = pyGet(node, 'widgets_values');
|
||||
const text = isList(values) && values.length && typeof values[0] === 'string' ? values[0] :
|
||||
isDict(values) && typeof pyGet(values, 'text') === 'string' ? values.get('text') : null;
|
||||
if (typeof kind === 'string' && kind !== 'PromptManagerPair' && kind.toLowerCase().includes('promptmanager') && text !== null) widgets.set(apiId, text);
|
||||
}
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/** `_overwritten_prompt_nodes` (R1). */
|
||||
function overwrittenPromptNodes(workflow, graph) {
|
||||
const widgets = promptManagerWidgets(workflow), byText = new Map();
|
||||
for (const [nodeId] of widgets) {
|
||||
const node = pyGet(graph, nodeId);
|
||||
const inputs = isDict(node) ? pyGet(node, 'inputs') : null;
|
||||
const text = isDict(inputs) ? pyGet(inputs, 'text') : null;
|
||||
if (typeof text === 'string') {
|
||||
if (!byText.has(text)) byText.set(text, []);
|
||||
byText.get(text).push(nodeId);
|
||||
}
|
||||
}
|
||||
const found = new Set();
|
||||
for (const ids of byText.values()) {
|
||||
if (ids.length > 1 && new Set(ids.map(id => widgets.get(id))).size > 1) ids.forEach(id => found.add(id));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `_resolve` for an unanchored inspection (output_node_id None). */
|
||||
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 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' });
|
||||
if (semantics === null) return result('unresolved', { reason: SEMANTICS_UNAVAILABLE_REASON });
|
||||
const overwritten = metadata.has('prompt') ? overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph) : new Set();
|
||||
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);
|
||||
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');
|
||||
}
|
||||
return resolver.pair(samplerIds[0]);
|
||||
}
|
||||
|
||||
|
||||
/** `_may_hold_prompt_manager`. */
|
||||
function mayHoldPromptManager(canonical) {
|
||||
const graph = pyGet(canonical, 'prompt');
|
||||
if (!isDict(graph)) return false;
|
||||
for (const node of graph.values()) {
|
||||
if (!isDict(node)) continue;
|
||||
const name = pyGet(node, 'class_type'), inputs = pyGet(node, 'inputs');
|
||||
if (typeof name === 'string' && name.toLowerCase().includes('promptmanager')) return true;
|
||||
if (isDict(inputs) && inputs.has('search_text')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `_parsed_workflow`: the UI workflow as a dict, or null. */
|
||||
function parsedWorkflow(value) {
|
||||
if (typeof value === 'string') {
|
||||
try { value = pyJsonLoads(value); } catch (error) {
|
||||
if (error instanceof PyValueError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return isDict(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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
|
||||
* `compileSemantics(...)`'s table, or null when it could not be loaded.
|
||||
*/
|
||||
export function resolveLocalEvidence(records, semantics) {
|
||||
const source = records !== null && typeof records === 'object' ? records : {};
|
||||
const metadata = new Map(Object.entries(source).filter(([key]) => ROLE_METADATA_KEYS.has(key)));
|
||||
const malformedResolution = { state: 'unresolved', sampler_id: null, reason: 'Prompt evidence is malformed', reason_code: 'malformed', node: null };
|
||||
if (!metadata.size) {
|
||||
return {
|
||||
prompts: { positive: sideOf('absent'), negative: sideOf('absent') },
|
||||
resolution: { state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null },
|
||||
};
|
||||
}
|
||||
const malformed = { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, malformedResolution), resolution: malformedResolution };
|
||||
if (!metadata.has('prompt') && !metadata.has('parameters')) return malformed;
|
||||
if (typeof metadata.get('prompt') === 'string') {
|
||||
try { metadata.set('prompt', pyJsonLoads(metadata.get('prompt'))); } catch (error) {
|
||||
if (error instanceof PyValueError) return malformed;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const workflow = mayHoldPromptManager(metadata) ? parsedWorkflow(source.workflow ?? null) : null;
|
||||
if (workflow !== null && metadata.has('prompt')) metadata.set('workflow', workflow);
|
||||
const resolved = resolve(metadata, semantics);
|
||||
const state = resolved.state, prompts = {};
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const side = resolved[role];
|
||||
const present = side !== null && typeof side === 'object';
|
||||
if (state !== 'ambiguous' && present && (role === 'negative' || pyStrip(side.text))) prompts[role] = sideOf('resolved', side.text, side.provenance.text_source);
|
||||
else if (state === 'resolved' && present) prompts[role] = { ...sideOf('unresolved'), message: EMPTY_POSITIVE };
|
||||
else prompts[role] = sideOf(state !== 'resolved' ? state : 'unresolved');
|
||||
}
|
||||
const resolution = { state, sampler_id: resolved.sampler_id, reason: resolved.reason, reason_code: resolved.reason_code, node: resolved.node };
|
||||
return { prompts: explained(prompts, resolution), resolution };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Python's value model for the local inspection resolver (#249 local Inspect, PR L3): the server's resolver
|
||||
// (utils/prompt_pair_resolver.py) runs on `json.loads` output, so the browser port reads the same text the same way.
|
||||
// Objects are `Map`s (insertion order, a repeated key keeps its first position and its last value, as a dict does),
|
||||
// integers are numbers (or BigInt past 2^53 - 1), floats are `PyFloat`, None is null. Strings are measured, sliced and
|
||||
// stripped as Python's `str` is: by code point, with `str.isspace()` whitespace. No DOM.
|
||||
|
||||
/** A Python float: distinct from an int (`1.0` is not a link slot, `type(x) is int` is false). */
|
||||
export class PyFloat {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** `ValueError` from `json.loads` (malformed text, or an integer past `sys.int_info.str_digits_check_threshold`). */
|
||||
export class PyValueError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'PyValueError';
|
||||
}
|
||||
}
|
||||
|
||||
// CPython's default int max str digits (PEP 651 / gh-95778).
|
||||
const INT_MAX_STR_DIGITS = 4300;
|
||||
const MAX_SAFE_DIGITS = '9007199254740991';
|
||||
const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/y;
|
||||
const STRING_STOP = /["\\\u0000-\u001f]/g;
|
||||
const HEX4 = /^[0-9a-fA-F]{4}$/;
|
||||
const ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
||||
// The C scanner's constants, in its order of checks.
|
||||
const CONSTANTS = [['null', null], ['true', true], ['false', false], ['NaN', new PyFloat(NaN)],
|
||||
['Infinity', new PyFloat(Infinity)], ['-Infinity', new PyFloat(-Infinity)]];
|
||||
|
||||
export const isDict = value => value instanceof Map;
|
||||
export const isList = value => Array.isArray(value);
|
||||
/** `isinstance(value, int) and not isinstance(value, bool)`. */
|
||||
export const isInt = value => (typeof value === 'number' && Number.isInteger(value)) || typeof value === 'bigint';
|
||||
|
||||
function numberValue(match) {
|
||||
const [literal, fraction, exponent] = match;
|
||||
if (fraction || exponent) return new PyFloat(Number(literal));
|
||||
const digits = literal[0] === '-' ? literal.slice(1) : literal;
|
||||
if (digits.length > INT_MAX_STR_DIGITS) throw new PyValueError('Exceeds the limit for integer string conversion');
|
||||
if (digits.length > MAX_SAFE_DIGITS.length || (digits.length === MAX_SAFE_DIGITS.length && digits > MAX_SAFE_DIGITS)) return BigInt(literal);
|
||||
// `-0` is the int 0.
|
||||
return Number(literal) || 0;
|
||||
}
|
||||
|
||||
/** `json.loads(text)` with Python's defaults (C scanner): NaN/Infinity accepted, a repeated key's last value wins. */
|
||||
export function pyJsonLoads(text) {
|
||||
if (typeof text !== 'string') throw new TypeError('JSON text must be a string');
|
||||
if (text.startsWith('')) throw new PyValueError('Unexpected UTF-8 BOM (decode using utf-8-sig)');
|
||||
let pos = 0;
|
||||
const fail = message => new PyValueError(`${message}: char ${pos}`);
|
||||
const skip = () => {
|
||||
while (pos < text.length) {
|
||||
const c = text.charCodeAt(pos);
|
||||
if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break;
|
||||
pos++;
|
||||
}
|
||||
};
|
||||
function string() {
|
||||
pos++;
|
||||
let out = '';
|
||||
for (;;) {
|
||||
STRING_STOP.lastIndex = pos;
|
||||
const stop = STRING_STOP.exec(text);
|
||||
if (!stop) throw fail('Unterminated string starting at');
|
||||
out += text.slice(pos, stop.index);
|
||||
pos = stop.index + 1;
|
||||
if (stop[0] === '"') return out;
|
||||
if (stop[0] !== '\\') throw fail('Invalid control character at');
|
||||
const escape = text[pos];
|
||||
if (escape === 'u') {
|
||||
const hex = text.slice(pos + 1, pos + 5);
|
||||
if (!HEX4.test(hex)) throw fail('Invalid \\uXXXX escape');
|
||||
pos += 5;
|
||||
const code = parseInt(hex, 16);
|
||||
if (code >= 0xd800 && code <= 0xdbff && text[pos] === '\\' && text[pos + 1] === 'u') {
|
||||
const low = text.slice(pos + 2, pos + 6);
|
||||
if (!HEX4.test(low)) throw fail('Invalid \\uXXXX escape');
|
||||
const lowCode = parseInt(low, 16);
|
||||
if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
|
||||
out += String.fromCharCode(code, lowCode);
|
||||
pos += 6;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += String.fromCharCode(code);
|
||||
} else if (Object.hasOwn(ESCAPES, escape)) {
|
||||
out += ESCAPES[escape];
|
||||
pos++;
|
||||
} else throw fail('Invalid \\escape');
|
||||
}
|
||||
}
|
||||
function key() {
|
||||
if (text[pos] !== '"') throw fail('Expecting property name enclosed in double quotes');
|
||||
const name = string();
|
||||
skip();
|
||||
if (text[pos] !== ':') throw fail("Expecting ':' delimiter");
|
||||
pos++;
|
||||
skip();
|
||||
return name;
|
||||
}
|
||||
function scalar() {
|
||||
if (text[pos] === '"') return string();
|
||||
for (const [literal, value] of CONSTANTS) {
|
||||
if (text.startsWith(literal, pos)) {
|
||||
pos += literal.length;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
NUMBER.lastIndex = pos;
|
||||
const match = NUMBER.exec(text);
|
||||
if (!match) throw fail('Expecting value');
|
||||
pos += match[0].length;
|
||||
return numberValue(match);
|
||||
}
|
||||
skip();
|
||||
const stack = [];
|
||||
let result;
|
||||
for (;;) {
|
||||
let value;
|
||||
if (text[pos] === '{') {
|
||||
pos++; skip();
|
||||
const frame = { value: new Map(), object: true, key: null };
|
||||
if (text[pos] === '}') pos++;
|
||||
else { frame.key = key(); stack.push(frame); continue; }
|
||||
value = frame.value;
|
||||
} else if (text[pos] === '[') {
|
||||
pos++; skip();
|
||||
const frame = { value: [], object: false };
|
||||
if (text[pos] === ']') pos++;
|
||||
else { stack.push(frame); continue; }
|
||||
value = frame.value;
|
||||
} else value = scalar();
|
||||
for (;;) {
|
||||
if (!stack.length) { result = value; break; }
|
||||
const frame = stack[stack.length - 1];
|
||||
if (frame.object) frame.value.set(frame.key, value);
|
||||
else frame.value.push(value);
|
||||
skip();
|
||||
if (text[pos] === ',') {
|
||||
pos++; skip();
|
||||
if (frame.object) frame.key = key();
|
||||
break;
|
||||
}
|
||||
if (text[pos] !== (frame.object ? '}' : ']')) throw fail("Expecting ',' delimiter");
|
||||
pos++;
|
||||
stack.pop();
|
||||
value = frame.value;
|
||||
}
|
||||
if (!stack.length) break;
|
||||
}
|
||||
skip();
|
||||
if (pos !== text.length) throw fail('Extra data');
|
||||
return result;
|
||||
}
|
||||
|
||||
/** `len(text)`: code points (a lone surrogate counts once). */
|
||||
export function pyLen(text) {
|
||||
let pairs = 0;
|
||||
for (let index = 0; index < text.length - 1; index++) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = text.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) { pairs++; index++; }
|
||||
}
|
||||
}
|
||||
return text.length - pairs;
|
||||
}
|
||||
|
||||
/** `text[:count]` by code points. */
|
||||
export function pySlice(text, count) {
|
||||
if (text.length <= count) return text;
|
||||
let end = 0;
|
||||
for (let taken = 0; taken < count && end < text.length; taken++) {
|
||||
const code = text.charCodeAt(end);
|
||||
const next = text.charCodeAt(end + 1);
|
||||
end += code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff ? 2 : 1;
|
||||
}
|
||||
return text.slice(0, end);
|
||||
}
|
||||
|
||||
// `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}]+$`);
|
||||
/** `text.strip()`. */
|
||||
export const pyStrip = text => text.replace(LEADING, '').replace(TRAILING, '');
|
||||
|
||||
/** `bool(value)`. */
|
||||
export function pyTruthy(value) {
|
||||
if (value === null || value === undefined || value === false) return false;
|
||||
if (value === true) return true;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'bigint') return value !== 0n;
|
||||
if (value instanceof PyFloat) return value.value !== 0;
|
||||
if (typeof value === 'string' || isList(value)) return value.length > 0;
|
||||
if (isDict(value)) return value.size > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `value == 1` (True and 1.0 equal it too). */
|
||||
export const pyEqualsOne = value => value === 1 || value === 1n || value === true || (value instanceof PyFloat && value.value === 1);
|
||||
|
||||
/** `repr(float)`: the shortest round-trip digits, fixed for exponents -4..15, else `d.ddde+XX`. */
|
||||
export function pyFloatRepr(value) {
|
||||
if (Number.isNaN(value)) return 'nan';
|
||||
if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf';
|
||||
if (value === 0) return Object.is(value, -0) ? '-0.0' : '0.0';
|
||||
const [mantissa, exponentText] = value.toExponential().split('e');
|
||||
const exponent = Number(exponentText);
|
||||
const sign = mantissa.startsWith('-') ? '-' : '';
|
||||
const digits = mantissa.replace(/^-/, '').replace('.', '');
|
||||
if (exponent < -4 || exponent >= 16) {
|
||||
const rest = digits.slice(1);
|
||||
return `${sign}${digits[0]}${rest ? `.${rest}` : ''}e${exponent < 0 ? '-' : '+'}${String(Math.abs(exponent)).padStart(2, '0')}`;
|
||||
}
|
||||
if (exponent < 0) return `${sign}0.${'0'.repeat(-exponent - 1)}${digits}`;
|
||||
const whole = digits.slice(0, exponent + 1).padEnd(exponent + 1, '0'), fraction = digits.slice(exponent + 1);
|
||||
return `${sign}${whole}.${fraction || '0'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `str(value)` where the resolver compares the result with node class names (`str(node.get("class_type", ""))`).
|
||||
* A container's text never equals a class name, so it is a marker only.
|
||||
*/
|
||||
export function pyStr(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return 'None';
|
||||
if (value === true) return 'True';
|
||||
if (value === false) return 'False';
|
||||
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
||||
if (value instanceof PyFloat) return pyFloatRepr(value.value);
|
||||
return '\u0000container';
|
||||
}
|
||||
|
||||
/** `mapping.get(key, fallback)`. */
|
||||
export const pyGet = (mapping, key, fallback = null) => mapping.has(key) ? mapping.get(key) : fallback;
|
||||
Reference in New Issue
Block a user