Files
ComfyAssets-ComfyUI_PromptM…/utils/inspection_response.py
T
Vito 048d599386 fix(inspect): bound inspection work, not graph size (#244)
* test(inspect): add synthetic inspection parity fixtures and legacy characterization

* test(inspect): pin test-time parity vectors and keep fixtures fully synthetic

* fix(inspect): bound resolver work instead of graph size

* fix(inspect): bound shared-node and stripped-text work in the inspection profile

Keep one memo per resolution whose entries carry their unfolded visit cost;
a memo hit charges that cost to the current candidate's allowance only.
Cache node validation and charge input keys, stripped characters and copied
provenance entries to a whole-resolution work budget before doing the work.
The default profile is unchanged.

* fix(inspect): keep memoized depth honest across inspection candidates

A memo entry now records its height below the reusing depth; a hit raises
depth-limit exactly where a fresh walk would, so each candidate's outcome
matches its anchored result and does not depend on trace or node order.
The default profile is unchanged.

* test(inspect): pin memo height propagation

Three samplers share one chain, each later one hanging below a memo hit.
The third candidate must report depth-limit in both trace orders and when
anchored; removing the memo-hit height propagation fails this test. The
provenance test now asserts a plain subset.

* fix(inspect): tolerate ComfyUI node metadata quirks in projections

Projections now take a kind. Each prompt node keeps exactly class_type and
inputs; _meta, is_changed and other node keys are validated and dropped, so
"is_changed": [NaN] no longer marks the whole prompt graph malformed. A
nonfinite value inside inputs, anywhere in a workflow, or in a retained
carrier member projects to null and is never reported as a number. The
carrier applies the prompt/workflow kinds to its members. Exact numbers are
unchanged (Decimal spelling, unsafe integers as strings) and duplicate keys
are still malformed. The 500-node gate is replaced by a 20,000-node backstop
(workflow count includes subgraph definitions). Raw records are untouched.

The f14 inspection vector's facts become known; default vectors are unchanged.

* fix(inspect): budget metadata per record so large workflows never hide prompts

PNG inspection no longer counts every text chunk against one shared 2 MiB
budget. Role records (prompt, parameters, the pair execution record) keep a
2 MiB budget together, encoded and decoded, and exceeding it still fails the
whole read. A workflow has its own 4 MiB budget: a chunk over it is skipped
by seek, and a compressed one stops inflating at budget + 1. Only the
workflow is then reported as limited (record format "limited", no raw text,
workflow_graph limited); prompts, facts and the prompt graph are unaffected.

Each text chunk's keyword is read first (at most 80 bytes). Chunks that are
not retained are skipped by length without being read or checksummed, so
memory stays bounded. Byte-identical duplicates of a retained record are
accepted and do not consume budget; differing duplicates still raise
"Duplicate prompt metadata", and a duplicate of a limited workflow stays
limited. The chunk-count limit is unchanged.

InspectionRead gains limited_records (empty by default). The reader is only
used by the inspection routes, so ingestion, scan and import are unchanged.

Vectors: f04-inspection (2.5 MB workflow, within the new workflow budget)
and f15-inspection (identical duplicate chunks) now read and resolve, with
the same outcome and facts as f05-inspection. Default vectors are unchanged.

* fix(inspect): keep prompt evidence longest under the response budget

* fix(inspect): bound identical duplicate chunk reads

* fix(inspect): fail closed when role duplicates exceed their budget

* fix(inspect): use the bounded inspection profile end to end

Inspection routes resolve with INSPECTION_PROFILE and expose reason_code
and node on the resolution; scan, live monitor and admin import keep the
default profile (pinned by spies over the real resolver on f03).

JS facts drop the entries and workflow node-count gates; per-lookup visit
sets and the server's node ceiling bound the work. The validator accepts
the textless `limited` workflow format, follows the workflow-first
response omission order, and checks the closed reason-code vocabulary.
The file inspector labels limited records and disables their workflow
download.

vectors.json: 12 inspection vectors gain their reason codes; f03 facts
become known and f01 facts ambiguous. Default vectors are unchanged. A
new parity test asserts vectors.json facts through the workspace JS.

* chore(tools): add read-only inspection corpus tool

tools/inspection_corpus.py runs the inspection route pipeline and the
workspace JS facts of a chosen code tree over a media folder, read-only,
and reports the #240 invariants H1 to H8 with aggregate counts. Rows hold
statuses and SHA-256 digests of resolved role texts, never file names or
prompt text unless --with-paths is given. Outputs inside the root or a code
tree are refused.

The facts-over-vectors test now fails instead of skipping when CI is set
and Node.js is missing.

* docs(testing): document bounded inspection work

* fix(tools): fail the corpus run when facts or inspection did not run

Rows whose facts were never computed (no or exiting Node.js) now fail H3,
and a new harness line fails on any tool exception or missing facts, so a
run where nothing was measured can no longer exit 0. Unreadable files stay
inspector outcomes. A missing --node binary is refused, an exited harness
is reaped, H8 has a 50 ms floor for tiny baselines, and JSONL rows are
sorted by id.

* docs(inspect): correct ceiling wording and bandit annotations

Describe the two 20,000-node ceilings (prompt entries, workflow UI plus
subgraph nodes) in TESTING.md, and use bare nosec ids so Bandit stops
warning about unparsed test names.

* test(inspect): make parity observation tuples explicit
2026-10-19 09:54:29 -07:00

401 lines
13 KiB
Python

"""Exact inspection-only graph projection and bounded JSON wire encoding."""
import json
import math
from decimal import Decimal, InvalidOperation
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from .prompt_inspector import InspectionRead
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
MAX_LIBRARY_BYTES = 32 * 1024
_MAX_SAFE_INTEGER = 2**53 - 1
_RECORD_KEYS = frozenset(
{"prompt", "workflow", "parameters", "comfyassets_prompt_pairs"}
)
_FORMATS = frozenset({"original-text", "reserialized-json", "invalid", "limited"})
_PROMPT_NODE_KEYS = frozenset({"class_type", "inputs"})
MAX_PROJECTION_DEPTH = 64
# Backstop, not an expected outcome: executable API nodes for a prompt; UI nodes
# including subgraph definitions for a workflow.
GRAPH_NODE_CEILING = 20_000
ProjectionKind = Literal["prompt", "workflow", "carrier"]
class _ProjectionLimit(ValueError):
"""A present graph cannot be projected within inspection limits."""
def _pairs(items: list[tuple]) -> dict:
result = {}
for key, value in items:
if key in result:
raise ValueError("Duplicate JSON key")
result[key] = value
return result
class _Nonfinite:
"""A parsed ``NaN``/``Infinity`` literal: never a fact, projected as null."""
_NONFINITE = _Nonfinite()
def _nonfinite(value: str) -> _Nonfinite:
return _NONFINITE
def _normalize(value: object, depth: int = 1, *, retain: bool = True) -> object:
"""Walk bounded source once; unretained values are only validated as JSON."""
if isinstance(value, (dict, list)):
if depth > MAX_PROJECTION_DEPTH:
raise _ProjectionLimit()
if isinstance(value, dict):
result = {} if retain else None
for key, child in value.items():
normalized = _normalize(child, depth + 1, retain=retain)
if retain:
result[key] = normalized
else:
result = [] if retain else None
for child in value:
normalized = _normalize(child, depth + 1, retain=retain)
if retain:
result.append(normalized)
return result
if not retain or value is _NONFINITE:
return None
if isinstance(value, Decimal):
if not value.is_finite():
raise ValueError("Nonfinite JSON number")
# str keeps large exponents compact and never rounds through a float.
return str(value)
if type(value) is int and abs(value) > _MAX_SAFE_INTEGER:
return str(value)
return value
def _project_prompt(tree: object, depth: int = 1) -> dict:
"""Each API node keeps exactly ``class_type`` and ``inputs``.
Other node keys (``_meta``, ``is_changed``, future keys) are validated and
dropped, so a nonfinite value there never reaches the projection.
"""
if not isinstance(tree, dict):
raise ValueError("Prompt graph must be an object of nodes")
if depth + 1 > MAX_PROJECTION_DEPTH: # the root and its node objects
raise _ProjectionLimit()
result = {}
for node_id, node in tree.items():
if not isinstance(node, dict):
result[node_id] = _normalize(node, depth + 1)
continue
projected = {}
for key, child in node.items():
keep = key in _PROMPT_NODE_KEYS
normalized = _normalize(child, depth + 2, retain=keep)
if keep:
projected[key] = normalized
result[node_id] = projected
return result
def _project(tree: object, kind: str, depth: int = 1) -> object:
if kind == "prompt":
return _project_prompt(tree, depth)
if kind == "workflow":
return _normalize(tree, depth)
if not isinstance(tree, dict):
raise ValueError("Malformed carrier")
result = {}
for key, member in tree.items():
if key not in ("prompt", "workflow"):
_normalize(member, depth + 1, retain=False)
elif isinstance(member, str):
result[key] = member # projected by its own kind when read
elif key == "prompt" and not isinstance(member, dict):
result[key] = _normalize(member, depth + 1)
else:
result[key] = _project(member, key, depth + 1)
return result
def _strict_projection(text: str, *, kind: ProjectionKind) -> object:
"""Parse exactly (Decimal, no duplicate keys) and project by record kind."""
try:
tree = json.loads(
text,
parse_float=Decimal,
object_pairs_hook=_pairs,
parse_constant=_nonfinite,
)
return _project(tree, kind)
except RecursionError:
raise _ProjectionLimit() from None
except InvalidOperation:
raise ValueError("JSON decimal exceeds supported range") from None
def _node_count(value: dict, *, workflow: bool) -> int:
if not workflow:
return len(value)
nodes = value.get("nodes")
count = len(nodes) if isinstance(nodes, list) else 0
definitions = value.get("definitions")
subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None
for subgraph in subgraphs if isinstance(subgraphs, list) else ():
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
count += len(inner) if isinstance(inner, list) else 0
return count
def _graph(value: object, *, workflow: bool = False) -> dict:
if not isinstance(value, dict):
return {"status": "malformed", "value": None}
if _node_count(value, workflow=workflow) > GRAPH_NODE_CEILING:
return {"status": "limited", "value": None}
return {"status": "parsed", "value": value}
def _metadata(inspection: "InspectionRead | None") -> dict:
formats = (
{}
if inspection is None
else {
key: value
for key, value in inspection.record_formats.items()
if key in _RECORD_KEYS and value in _FORMATS
}
)
if inspection is not None:
# A record over its read budget has no text: like ``invalid``, raw omits
# it, and its graph is ``limited`` rather than parsed from nothing.
for key in inspection.limited_records & _RECORD_KEYS:
formats[key] = "limited"
raw = (
{}
if inspection is None
else {
key: value
for key, value in inspection.metadata.items()
if key in _RECORD_KEYS
and isinstance(value, str)
and formats.get(key) != "limited"
}
)
result = {
"status": (
"unreadable"
if inspection is None
else ("available" if raw or formats else "absent")
),
"omitted_reason": None,
"raw": raw,
"record_formats": formats,
"parameters": raw.get("parameters"),
}
carrier, failed = None, None
if inspection is not None and inspection.video_carrier_text is not None:
try:
carrier = _strict_projection(inspection.video_carrier_text, kind="carrier")
except _ProjectionLimit:
failed = "limited"
except (ValueError, TypeError, OverflowError):
failed = "malformed"
for key, field in (("prompt", "prompt_graph"), ("workflow", "workflow_graph")):
graph = {"status": "absent", "value": None}
if key in raw or key in formats:
if failed or formats.get(key) == "limited":
graph["status"] = failed or "limited"
elif formats.get(key) == "invalid":
graph["status"] = "malformed"
else:
try:
if carrier is not None:
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
elif formats.get(key) == "reserialized-json":
raise _ProjectionLimit()
else:
value = _strict_projection(raw[key], kind=key)
graph = _graph(value, workflow=key == "workflow")
except _ProjectionLimit:
graph["status"] = "limited"
except (ValueError, TypeError, KeyError, OverflowError):
graph["status"] = "malformed"
result[field] = graph
return result
def _bytes(value: dict) -> bytes:
# JSON preserves isolated surrogate evidence through escaped code units.
return json.dumps(
value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
).encode("utf-8", "backslashreplace")
def _finite(value: object, *, positive: bool = False) -> bool:
try:
return (
type(value) in (int, float)
and math.isfinite(value)
and (value > 0 if positive else value >= 0)
)
except OverflowError:
return False
def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
source = dict(file)
if inspection is not None:
source.update(
format=inspection.format, width=inspection.width, height=inspection.height
)
result = {"observation": "current-file"}
for key in ("source_label", "extension", "format"):
value = source.get(key)
if value is None and key == "format":
result[key] = None
elif isinstance(value, str):
result[key] = value
if source.get("media_type") in ("image", "video"):
result["media_type"] = source["media_type"]
for key in ("size", "modified_time", "width", "height"):
if key not in source:
continue
value = source[key]
if value is None or _finite(value, positive=key in ("width", "height")):
if key not in ("width", "height") or value is None or type(value) is int:
result[key] = value
return result
def _library_projection(library: dict) -> dict:
status = library.get("status")
result = {
"status": (
status
if status in ("linked", "none", "ambiguous", "unavailable", "limited")
else "unavailable"
),
"scope": "saved-library",
}
for key in ("positive_id", "pair_id"):
value = library.get(key)
if value is None or type(value) is int and 0 < value <= _MAX_SAFE_INTEGER:
result[key] = value
for key in ("title", "category"):
value = library.get(key)
if value is None or isinstance(value, str):
result[key] = value
scope = library.get("metadata_scope")
if scope in ("pair", "positive", None):
result["metadata_scope"] = scope
tags = library.get("tags")
result["tags"] = (
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
)
if len(_bytes(result)) > MAX_LIBRARY_BYTES:
return {
"status": "limited",
"scope": "saved-library",
"positive_id": None,
"pair_id": None,
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
}
return result
def _encode_inspection(
*,
identity: dict,
file: dict,
prompts: dict,
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
) -> bytes:
"""Serialize exactly once per budget step, preserving identity and roles."""
metadata = _metadata(inspection)
response = {
"success": True,
"inspection_version": 1,
**identity,
"file": _file_projection(file, inspection),
"prompts": prompts,
"resolution": resolution,
"metadata": metadata,
"library": _library_projection(library),
}
body = _bytes(response)
# Omission order keeps prompt evidence longest: the workflow graph first,
# then the prompt graph, then all raw records atomically.
for field in ("workflow_graph", "prompt_graph"):
if len(body) <= MAX_RESPONSE_BYTES:
break
if metadata[field]["status"] != "absent":
metadata[field] = {"status": "limited", "value": None}
body = _bytes(response)
if len(body) > MAX_RESPONSE_BYTES:
metadata.update(
raw={},
parameters=None,
status="limited",
omitted_reason="response-size-limit",
)
body = _bytes(response)
if len(body) > MAX_RESPONSE_BYTES:
raise ValueError("Inspection response exceeds limits")
return body
def encode_inspection_response(
*,
image: dict,
root_scope: str,
file: dict,
prompts: dict,
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
) -> bytes:
"""Preserve the existing rooted wire and its bounded metadata projection."""
return _encode_inspection(
identity={"root_scope": root_scope, "image": image},
file=file,
prompts=prompts,
resolution=resolution,
inspection=inspection,
library=library,
)
def encode_history_inspection_response(
*,
current_output: dict,
file: dict,
prompts: dict,
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
) -> bytes:
"""Use occurrence identity with the same current-file evidence projection."""
return _encode_inspection(
identity={
"history_version": 1,
"history_epoch": current_output["history_epoch"],
"current_output": current_output,
},
file=file,
prompts=prompts,
resolution=resolution,
inspection=inspection,
library=library,
)