nitelos 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nitelos/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ """Nitelos: verify the outcome, not the attempt.
2
+
3
+ Importing this package has no side effects: no logging handlers, no network,
4
+ no optional model or evaluation dependencies.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from nitelos.backends import DeterministicBackend
10
+ from nitelos.errors import (
11
+ AbstentionReason,
12
+ BackendErrorCode,
13
+ ConfigurationError,
14
+ DecisionCode,
15
+ EvidenceValidationError,
16
+ InputErrorCode,
17
+ Issue,
18
+ LimitationCode,
19
+ RequestValidationError,
20
+ )
21
+ from nitelos.evidence import (
22
+ ArtifactStore,
23
+ PreparedEvidence,
24
+ PreparedImage,
25
+ prepare_evidence,
26
+ validate_evidence_ref,
27
+ )
28
+ from nitelos.policy import TrustBinding, VerificationPolicy
29
+ from nitelos.schemas import (
30
+ ActionEvidence,
31
+ AssertionCriterion,
32
+ BackendDescriptor,
33
+ BackendFinding,
34
+ Basis,
35
+ Channel,
36
+ ColorConversion,
37
+ CriterionEvaluation,
38
+ CriterionResult,
39
+ CriterionState,
40
+ Decision,
41
+ Diagnostics,
42
+ ErrorInfo,
43
+ Evidence,
44
+ EvidenceRef,
45
+ ExecutionStatus,
46
+ Grounding,
47
+ Measurements,
48
+ MediaType,
49
+ ObservationEvidence,
50
+ Operator,
51
+ Phase,
52
+ PreprocessingRecord,
53
+ ReportedStatus,
54
+ Scope,
55
+ Score,
56
+ ScoreKind,
57
+ ScreenshotEvidence,
58
+ VerificationRequest,
59
+ VerificationResult,
60
+ VisualCriterion,
61
+ canonical_json,
62
+ parse_request,
63
+ )
64
+ from nitelos.trace import Trace, parse_trace
65
+ from nitelos.verifier import Verifier
66
+
67
+ __all__ = [
68
+ "AbstentionReason",
69
+ "ActionEvidence",
70
+ "ArtifactStore",
71
+ "AssertionCriterion",
72
+ "Backend",
73
+ "BackendCapabilities",
74
+ "BackendDescriptor",
75
+ "BackendErrorCode",
76
+ "BackendFinding",
77
+ "Basis",
78
+ "Channel",
79
+ "ColorConversion",
80
+ "ConfigurationError",
81
+ "CriterionEvaluation",
82
+ "CriterionResult",
83
+ "CriterionState",
84
+ "Decision",
85
+ "DecisionCode",
86
+ "DeterministicBackend",
87
+ "Diagnostics",
88
+ "ErrorInfo",
89
+ "Evidence",
90
+ "EvidenceRef",
91
+ "EvidenceValidationError",
92
+ "ExecutionStatus",
93
+ "Grounding",
94
+ "InputErrorCode",
95
+ "Issue",
96
+ "LimitationCode",
97
+ "Measurements",
98
+ "MediaType",
99
+ "ObservationEvidence",
100
+ "Operator",
101
+ "Phase",
102
+ "PreparedEvidence",
103
+ "PreparedImage",
104
+ "PreprocessingRecord",
105
+ "ReportedStatus",
106
+ "RequestValidationError",
107
+ "Scope",
108
+ "Score",
109
+ "ScoreKind",
110
+ "ScreenshotEvidence",
111
+ "Trace",
112
+ "TrustBinding",
113
+ "VerificationPolicy",
114
+ "VerificationRequest",
115
+ "VerificationResult",
116
+ "Verifier",
117
+ "VisualCriterion",
118
+ "__version__",
119
+ "canonical_json",
120
+ "parse_request",
121
+ "parse_trace",
122
+ "prepare_evidence",
123
+ "validate_evidence_ref",
124
+ ]
nitelos/_json.py ADDED
@@ -0,0 +1,242 @@
1
+ """Bounded strict JSON parsing (private module).
2
+
3
+ Parses exactly the RFC 8259 grammar (objects, arrays, strings, numbers,
4
+ true/false/null) with these ingestion rules from document 03:
5
+
6
+ - duplicate object keys are rejected and reported at the duplicated key's path;
7
+ - NaN/Infinity tokens are syntax errors and numbers that overflow to a
8
+ non-finite float are rejected;
9
+ - nesting deeper than the caller's bound is rejected (containers count one
10
+ level each; the root value is level 1);
11
+ - integer literals beyond Python's conversion limit are rejected as too large.
12
+
13
+ Error messages carry no input values, only a JSON-style path (``$`` root,
14
+ ``.key`` members, ``[i]`` items).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from enum import StrEnum
21
+
22
+ _DIGITS = "0123456789"
23
+ _ESCAPES = {'"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t"}
24
+ _WHITESPACE = " \t\n\r"
25
+
26
+
27
+ class JsonErrorKind(StrEnum):
28
+ SYNTAX = "SYNTAX"
29
+ DUPLICATE_KEY = "DUPLICATE_KEY"
30
+ NON_FINITE = "NON_FINITE"
31
+ TOO_DEEP = "TOO_DEEP"
32
+ TOO_LARGE = "TOO_LARGE"
33
+
34
+
35
+ class JsonError(ValueError):
36
+ """Strict-JSON violation with a kind, a JSON-style path and a value-free message."""
37
+
38
+ def __init__(self, kind: JsonErrorKind, path: str, message: str) -> None:
39
+ super().__init__(f"{kind} at {path}: {message}")
40
+ self.kind = kind
41
+ self.path = path
42
+
43
+
44
+ class _Parser:
45
+ def __init__(self, text: str, max_depth: int) -> None:
46
+ self._text = text
47
+ self._n = len(text)
48
+ self._i = 0
49
+ self._max_depth = max_depth
50
+
51
+ def parse_document(self) -> object:
52
+ value = self._value(1, "$")
53
+ self._skip_ws()
54
+ if self._i != self._n:
55
+ raise JsonError(JsonErrorKind.SYNTAX, "$", "unexpected trailing characters")
56
+ return value
57
+
58
+ def _skip_ws(self) -> None:
59
+ while self._i < self._n and self._text[self._i] in _WHITESPACE:
60
+ self._i += 1
61
+
62
+ def _value(self, depth: int, path: str) -> object:
63
+ if depth > self._max_depth:
64
+ raise JsonError(JsonErrorKind.TOO_DEEP, path, "nesting exceeds the allowed depth")
65
+ self._skip_ws()
66
+ if self._i >= self._n:
67
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unexpected end of input")
68
+ ch = self._text[self._i]
69
+ if ch == "{":
70
+ return self._object(depth, path)
71
+ if ch == "[":
72
+ return self._array(depth, path)
73
+ if ch == '"':
74
+ return self._string(path)
75
+ if ch == "t":
76
+ return self._literal("true", True, path)
77
+ if ch == "f":
78
+ return self._literal("false", False, path)
79
+ if ch == "n":
80
+ return self._literal("null", None, path)
81
+ if ch == "-" or ch in _DIGITS:
82
+ return self._number(path)
83
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unexpected character")
84
+
85
+ def _object(self, depth: int, path: str) -> dict[str, object]:
86
+ self._i += 1 # '{'
87
+ obj: dict[str, object] = {}
88
+ self._skip_ws()
89
+ if self._i < self._n and self._text[self._i] == "}":
90
+ self._i += 1
91
+ return obj
92
+ while True:
93
+ self._skip_ws()
94
+ if self._i >= self._n or self._text[self._i] != '"':
95
+ raise JsonError(JsonErrorKind.SYNTAX, path, "expected an object key")
96
+ key = self._string(path)
97
+ key_path = f"{path}.{key}"
98
+ if key in obj:
99
+ raise JsonError(JsonErrorKind.DUPLICATE_KEY, key_path, "duplicate object key")
100
+ self._skip_ws()
101
+ if self._i >= self._n or self._text[self._i] != ":":
102
+ raise JsonError(JsonErrorKind.SYNTAX, key_path, "expected ':' after object key")
103
+ self._i += 1
104
+ obj[key] = self._value(depth + 1, key_path)
105
+ self._skip_ws()
106
+ if self._i >= self._n:
107
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unterminated object")
108
+ ch = self._text[self._i]
109
+ if ch == ",":
110
+ self._i += 1
111
+ continue
112
+ if ch == "}":
113
+ self._i += 1
114
+ return obj
115
+ raise JsonError(JsonErrorKind.SYNTAX, path, "expected ',' or '}' in object")
116
+
117
+ def _array(self, depth: int, path: str) -> list[object]:
118
+ self._i += 1 # '['
119
+ items: list[object] = []
120
+ self._skip_ws()
121
+ if self._i < self._n and self._text[self._i] == "]":
122
+ self._i += 1
123
+ return items
124
+ while True:
125
+ items.append(self._value(depth + 1, f"{path}[{len(items)}]"))
126
+ self._skip_ws()
127
+ if self._i >= self._n:
128
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unterminated array")
129
+ ch = self._text[self._i]
130
+ if ch == ",":
131
+ self._i += 1
132
+ continue
133
+ if ch == "]":
134
+ self._i += 1
135
+ return items
136
+ raise JsonError(JsonErrorKind.SYNTAX, path, "expected ',' or ']' in array")
137
+
138
+ def _literal(self, word: str, value: object, path: str) -> object:
139
+ if self._text[self._i : self._i + len(word)] != word:
140
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unexpected character")
141
+ self._i += len(word)
142
+ return value
143
+
144
+ def _string(self, path: str) -> str:
145
+ self._i += 1 # '"'
146
+ chunks: list[str] = []
147
+ while True:
148
+ if self._i >= self._n:
149
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unterminated string")
150
+ ch = self._text[self._i]
151
+ if ch == '"':
152
+ self._i += 1
153
+ return "".join(chunks)
154
+ if ch == "\\":
155
+ chunks.append(self._escape(path))
156
+ elif ch < " ":
157
+ raise JsonError(JsonErrorKind.SYNTAX, path, "control character in string")
158
+ else:
159
+ chunks.append(ch)
160
+ self._i += 1
161
+
162
+ def _escape(self, path: str) -> str:
163
+ self._i += 1 # '\'
164
+ if self._i >= self._n:
165
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unterminated escape")
166
+ esc = self._text[self._i]
167
+ if esc in _ESCAPES:
168
+ self._i += 1
169
+ return _ESCAPES[esc]
170
+ if esc != "u":
171
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid escape")
172
+ code = self._hex4(path)
173
+ if 0xD800 <= code <= 0xDBFF:
174
+ if self._text[self._i : self._i + 2] != "\\u":
175
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unpaired surrogate")
176
+ self._i += 1 # land on the 'u' of the low escape for _hex4
177
+ low = self._hex4(path)
178
+ if not 0xDC00 <= low <= 0xDFFF:
179
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unpaired surrogate")
180
+ return chr(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
181
+ if 0xDC00 <= code <= 0xDFFF:
182
+ raise JsonError(JsonErrorKind.SYNTAX, path, "unpaired surrogate")
183
+ return chr(code)
184
+
185
+ def _hex4(self, path: str) -> int:
186
+ # self._i points at the 'u' of a \u escape
187
+ digits = self._text[self._i + 1 : self._i + 5]
188
+ if len(digits) != 4:
189
+ raise JsonError(JsonErrorKind.SYNTAX, path, "incomplete unicode escape")
190
+ try:
191
+ code = int(digits, 16)
192
+ except ValueError:
193
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid unicode escape") from None
194
+ self._i += 5 # 'u' plus the four hex digits
195
+ return code
196
+
197
+ def _number(self, path: str) -> int | float:
198
+ start = self._i
199
+ if self._text[self._i] == "-":
200
+ self._i += 1
201
+ if self._i >= self._n:
202
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid number")
203
+ ch = self._text[self._i]
204
+ if ch == "0":
205
+ self._i += 1
206
+ elif ch in _DIGITS:
207
+ while self._i < self._n and self._text[self._i] in _DIGITS:
208
+ self._i += 1
209
+ else:
210
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid number")
211
+ is_float = False
212
+ if self._i < self._n and self._text[self._i] == ".":
213
+ is_float = True
214
+ self._i += 1
215
+ if self._i >= self._n or self._text[self._i] not in _DIGITS:
216
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid number")
217
+ while self._i < self._n and self._text[self._i] in _DIGITS:
218
+ self._i += 1
219
+ if self._i < self._n and self._text[self._i] in "eE":
220
+ is_float = True
221
+ self._i += 1
222
+ if self._i < self._n and self._text[self._i] in "+-":
223
+ self._i += 1
224
+ if self._i >= self._n or self._text[self._i] not in _DIGITS:
225
+ raise JsonError(JsonErrorKind.SYNTAX, path, "invalid number")
226
+ while self._i < self._n and self._text[self._i] in _DIGITS:
227
+ self._i += 1
228
+ text = self._text[start : self._i]
229
+ if is_float:
230
+ value = float(text)
231
+ if not math.isfinite(value):
232
+ raise JsonError(JsonErrorKind.NON_FINITE, path, "number is not finite")
233
+ return value
234
+ try:
235
+ return int(text)
236
+ except ValueError:
237
+ raise JsonError(JsonErrorKind.TOO_LARGE, path, "integer literal is too large") from None
238
+
239
+
240
+ def parse_json(text: str, *, max_depth: int) -> object:
241
+ """Parse ``text`` as strict JSON with the documented ingestion rules."""
242
+ return _Parser(text, max_depth).parse_document()
@@ -0,0 +1,33 @@
1
+ """Nitelos backends.
2
+
3
+ The deterministic assertion evaluator is core and dependency-free; the
4
+ optional local model adapter will live in ``nitelos.backends.smolvlm``
5
+ (NIT-008) and is never imported from here. ``base`` defines the Backend
6
+ protocol, capabilities and the reusable finding validator (NIT-007).
7
+ """
8
+
9
+ from nitelos.backends.base import (
10
+ Backend,
11
+ BackendCapabilities,
12
+ BackendOutputError,
13
+ InferenceContext,
14
+ PreparedVisualCriterion,
15
+ validate_backend_finding,
16
+ )
17
+ from nitelos.backends.deterministic import DeterministicBackend, evaluate_assertion, evaluate_reference
18
+ from nitelos.schemas import BackendDescriptor, BackendFinding, Score
19
+
20
+ __all__ = [
21
+ "Backend",
22
+ "BackendCapabilities",
23
+ "BackendDescriptor",
24
+ "BackendFinding",
25
+ "BackendOutputError",
26
+ "DeterministicBackend",
27
+ "InferenceContext",
28
+ "PreparedVisualCriterion",
29
+ "Score",
30
+ "evaluate_assertion",
31
+ "evaluate_reference",
32
+ "validate_backend_finding",
33
+ ]
@@ -0,0 +1,203 @@
1
+ """Backend protocol, capabilities and output validation (NIT-007).
2
+
3
+ Authority: docs/planning/04-verification-backends.md ("Protocol and capability
4
+ negotiation", prepared interface details), 03 (finding/reference/score rules),
5
+ 07 (backend output is untrusted; a custom backend is trusted executable code).
6
+ Custom backends are injected as instances — no registry, import strings or
7
+ entry-point discovery.
8
+
9
+ Everything a backend receives is immutable and minimal: a
10
+ ``PreparedVisualCriterion`` (criterion id, description, decoded images in
11
+ declared reference order — no file paths) and an ``InferenceContext``
12
+ (objective, bounded action context, cooperative deadline — no metadata
13
+ dictionary, trust bindings, labels or credentials). Backend output is
14
+ untrusted: :func:`validate_backend_finding` enforces the doc-03 rules before
15
+ anything reaches a result, and violations raise :class:`BackendOutputError`
16
+ with a stable backend code and a value-free message. The helper is public so
17
+ custom backend authors can run the same conformance check the engine does.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass
24
+ from typing import Any, Protocol, runtime_checkable
25
+
26
+ from nitelos.context import ActionContextItem
27
+ from nitelos.errors import BackendErrorCode, EvidenceValidationError
28
+ from nitelos.evidence import PreparedImage, validate_evidence_ref
29
+ from nitelos.schemas import (
30
+ BackendFinding,
31
+ CriterionState,
32
+ Grounding,
33
+ ScoreKind,
34
+ ScreenshotEvidence,
35
+ )
36
+
37
+ __all__ = [
38
+ "Backend",
39
+ "BackendCapabilities",
40
+ "BackendOutputError",
41
+ "InferenceContext",
42
+ "PreparedVisualCriterion",
43
+ "validate_backend_finding",
44
+ ]
45
+
46
+
47
+ class BackendOutputError(Exception):
48
+ """Invalid backend output; carries a stable backend code and a safe message.
49
+
50
+ Raised by :func:`validate_backend_finding` (and by the verifier around
51
+ ``infer``). The message never contains backend output or exception text.
52
+ """
53
+
54
+ def __init__(self, code: BackendErrorCode, message: str) -> None:
55
+ super().__init__(f"{code}: {message}")
56
+ self.code = code
57
+ self.safe_message = message
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class BackendCapabilities:
62
+ """Truthful limits of one backend (doc 04); maxima are ints, never null.
63
+
64
+ Non-image backends declare ``max_images_per_criterion = 0``;
65
+ unlimited/null maxima are not representable by design.
66
+ """
67
+
68
+ evidence_kinds: tuple[str, ...] = ()
69
+ criterion_kinds: tuple[str, ...] = ()
70
+ supports_text: bool = False
71
+ supports_images: bool = False
72
+ supports_structured_decisions: bool = False
73
+ supports_probabilities: bool = False
74
+ supports_calibrated_probabilities: bool = False
75
+ supports_native_abstention: bool = False
76
+ supports_evidence_references: bool = False
77
+ local_execution: bool = False
78
+ supports_native_batch: bool = False
79
+ max_images_per_criterion: int = 0
80
+ max_text_tokens: int = 0
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class PreparedVisualCriterion:
85
+ """What a visual backend infers on: id, description, decoded images."""
86
+
87
+ criterion_id: str
88
+ description: str
89
+ images: tuple[PreparedImage, ...]
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class InferenceContext:
94
+ """Model context: objective, bounded actions, cooperative deadline.
95
+
96
+ ``actions`` holds NIT-009's ActionContextItem records (last-eight action
97
+ name/status view); until that lands the verifier passes an empty tuple.
98
+ ``deadline`` is an absolute monotonic timestamp or None when no deadline
99
+ is configured; backends check it cooperatively between steps. There is no
100
+ metadata dictionary, and trust bindings or labels never appear here.
101
+ """
102
+
103
+ objective: str
104
+ actions: tuple[ActionContextItem, ...] = ()
105
+ deadline: float | None = None
106
+
107
+
108
+ @runtime_checkable
109
+ class Backend(Protocol):
110
+ """The visual backend protocol; registration is instance injection."""
111
+
112
+ @property
113
+ def descriptor(self) -> Any: ...
114
+
115
+ @property
116
+ def capabilities(self) -> BackendCapabilities: ...
117
+
118
+ def infer(self, prepared: PreparedVisualCriterion, context: InferenceContext) -> BackendFinding:
119
+ """Return one finding for the prepared criterion (synchronous)."""
120
+
121
+ def close(self) -> None:
122
+ """Release resources; idempotent."""
123
+
124
+
125
+ def validate_backend_finding(
126
+ finding: Any,
127
+ *,
128
+ criterion_id: str,
129
+ images: Mapping[str, PreparedImage],
130
+ already_emitted: frozenset[str] = frozenset(),
131
+ ) -> BackendFinding:
132
+ """Validate one backend finding against doc 03; returns it when valid.
133
+
134
+ ``images`` are the prepared images actually supplied to that inference,
135
+ keyed by evidence id — box bounds are checked against their real decoded
136
+ dimensions. Raises :class:`BackendOutputError` with INVALID_OUTPUT for a
137
+ missing/malformed finding, a foreign or duplicate criterion id or an
138
+ illegal state; INVALID_EVIDENCE_REFERENCE for invented references,
139
+ references outside the supplied images, decisive findings without at
140
+ least one supplied-image reference, pointers on screenshots,
141
+ out-of-bounds boxes and self-asserted ``assertion_checked`` grounding;
142
+ and UNSUPPORTED_CALIBRATION for a ``calibrated_probability`` score (a
143
+ capability flag alone cannot authorize it in v0.1).
144
+ """
145
+ if not isinstance(finding, BackendFinding):
146
+ raise BackendOutputError(BackendErrorCode.INVALID_OUTPUT, "backend returned no valid finding")
147
+ if finding.criterion_id != criterion_id or criterion_id in already_emitted:
148
+ raise BackendOutputError(BackendErrorCode.INVALID_OUTPUT, "finding cites a foreign criterion id")
149
+ if not isinstance(finding.state, CriterionState):
150
+ raise BackendOutputError(BackendErrorCode.INVALID_OUTPUT, "finding state is not a legal state")
151
+ if finding.score is not None and finding.score.kind == ScoreKind.CALIBRATED_PROBABILITY:
152
+ raise BackendOutputError(
153
+ BackendErrorCode.UNSUPPORTED_CALIBRATION, "calibrated probabilities are not supported in v0.1"
154
+ )
155
+ decisive = finding.state in (CriterionState.SUPPORTED, CriterionState.REFUTED)
156
+ if decisive and not finding.evidence_refs:
157
+ raise BackendOutputError(
158
+ BackendErrorCode.INVALID_EVIDENCE_REFERENCE,
159
+ "a decisive finding must reference at least one supplied image",
160
+ )
161
+ for reference in finding.evidence_refs:
162
+ if reference.evidence_id not in images:
163
+ raise BackendOutputError(
164
+ BackendErrorCode.INVALID_EVIDENCE_REFERENCE, "finding references unprepared evidence"
165
+ )
166
+ if reference.grounding == Grounding.ASSERTION_CHECKED:
167
+ raise BackendOutputError(
168
+ BackendErrorCode.INVALID_EVIDENCE_REFERENCE,
169
+ "backends cannot self-assert assertion_checked grounding",
170
+ )
171
+ try:
172
+ validate_evidence_ref(
173
+ reference,
174
+ {evidence_id: _as_screenshot(evidence_id) for evidence_id in images},
175
+ images,
176
+ )
177
+ except EvidenceValidationError:
178
+ raise BackendOutputError(
179
+ BackendErrorCode.INVALID_EVIDENCE_REFERENCE,
180
+ "finding reference violates pointer/box kind or bounds rules",
181
+ ) from None
182
+ return finding
183
+
184
+
185
+ def _as_screenshot(evidence_id: str) -> ScreenshotEvidence:
186
+ """Minimal valid screenshot model for the shared EvidenceRef kind rules."""
187
+ return ScreenshotEvidence.model_validate(
188
+ {
189
+ "id": evidence_id,
190
+ "kind": "screenshot",
191
+ "sequence": 0,
192
+ "captured_at": "2024-06-01T10:00:00Z",
193
+ "checkpoint_id": "cp",
194
+ "source_id": "backend-validation",
195
+ "subject": {"form": "validation"},
196
+ "phase": "final",
197
+ "sha256": "0" * 64,
198
+ "artifact_id": f"art-{evidence_id}",
199
+ "media_type": "image/png",
200
+ "width": 4,
201
+ "height": 4,
202
+ }
203
+ )