wiretruth 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.
- wiretruth/__init__.py +3 -0
- wiretruth/__main__.py +4 -0
- wiretruth/checks.py +504 -0
- wiretruth/cli.py +417 -0
- wiretruth/envs.py +120 -0
- wiretruth/formats.py +1128 -0
- wiretruth/grading.py +164 -0
- wiretruth/lint.py +275 -0
- wiretruth/providers/__init__.py +133 -0
- wiretruth/providers/openai_chat.py +363 -0
- wiretruth/recorder.py +481 -0
- wiretruth/replay.py +391 -0
- wiretruth/results.py +216 -0
- wiretruth/runner.py +353 -0
- wiretruth/schemas/bug-classes.schema.json +29 -0
- wiretruth/schemas/call.schema.json +113 -0
- wiretruth/schemas/common.schema.json +98 -0
- wiretruth/schemas/fixture.schema.json +243 -0
- wiretruth/schemas/observation.schema.json +191 -0
- wiretruth/schemas/results.schema.json +91 -0
- wiretruth/schemas/scenario.schema.json +91 -0
- wiretruth/schemas/shim-manifest.schema.json +125 -0
- wiretruth/schemas/upstream.schema.json +60 -0
- wiretruth/secret_scan.py +190 -0
- wiretruth-0.1.0.dist-info/METADATA +158 -0
- wiretruth-0.1.0.dist-info/RECORD +30 -0
- wiretruth-0.1.0.dist-info/WHEEL +5 -0
- wiretruth-0.1.0.dist-info/entry_points.txt +2 -0
- wiretruth-0.1.0.dist-info/licenses/LICENSE +21 -0
- wiretruth-0.1.0.dist-info/top_level.txt +1 -0
wiretruth/__init__.py
ADDED
wiretruth/__main__.py
ADDED
wiretruth/checks.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Pure requirement checks against an observation, wire truth and the replay request log.
|
|
2
|
+
|
|
3
|
+
Checks validate scenario arguments before looking at the observation. Only primary values count;
|
|
4
|
+
raw metadata cannot satisfy or contradict a value comparison. Independent signals (such as a
|
|
5
|
+
raised error) can still satisfy a check. Within a check, satisfaction precedes contradiction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Literal, TypeAlias, cast, get_args
|
|
15
|
+
|
|
16
|
+
from wiretruth.formats import (
|
|
17
|
+
ErrorCategory,
|
|
18
|
+
Finish,
|
|
19
|
+
JsonObject,
|
|
20
|
+
JsonValue,
|
|
21
|
+
Observation,
|
|
22
|
+
Surface,
|
|
23
|
+
Truth,
|
|
24
|
+
)
|
|
25
|
+
from wiretruth.replay import LoggedRequest
|
|
26
|
+
|
|
27
|
+
Status = Literal["SATISFIED", "CONTRADICTED", "NOT_OBSERVABLE"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CheckResult:
|
|
32
|
+
status: Status
|
|
33
|
+
evidence: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Check: TypeAlias = Callable[[Observation, Truth, Sequence[LoggedRequest], JsonObject], CheckResult]
|
|
37
|
+
_USAGE_FIELDS = ("input", "cached_input", "output", "reasoning", "total")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _keys(args: JsonObject, required: tuple[str, ...] = (), optional: tuple[str, ...] = ()) -> None:
|
|
41
|
+
missing = set(required) - args.keys()
|
|
42
|
+
unknown = args.keys() - set(required + optional)
|
|
43
|
+
if missing or unknown:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"invalid check args: missing={sorted(missing)}, unknown={sorted(unknown)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _finish_arg(args: JsonObject) -> Finish:
|
|
50
|
+
_keys(args, ("value",))
|
|
51
|
+
if args["value"] not in get_args(Finish):
|
|
52
|
+
raise ValueError("value must be a canonical finish")
|
|
53
|
+
return cast(Finish, args["value"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _list_arg(args: JsonObject, key: str, choices: Sequence[str]) -> list[str]:
|
|
57
|
+
value = args[key]
|
|
58
|
+
if (
|
|
59
|
+
not isinstance(value, list)
|
|
60
|
+
or not value
|
|
61
|
+
or any(not isinstance(item, str) or item not in choices for item in value)
|
|
62
|
+
):
|
|
63
|
+
raise ValueError(f"{key} must be a non-empty list of {', '.join(choices)}")
|
|
64
|
+
return cast(list[str], value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _path_arg(args: JsonObject) -> str:
|
|
68
|
+
value = args["path"]
|
|
69
|
+
if not isinstance(value, str) or not all(value.split(".")):
|
|
70
|
+
raise ValueError("path must be a non-empty dotted path without empty segments")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _show(value: object) -> str:
|
|
75
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
76
|
+
return text if len(text) <= 160 else text[:159] + "…"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _finish_evidence(obs: Observation, truth: Truth) -> str:
|
|
80
|
+
finish = obs.finish
|
|
81
|
+
reported = (
|
|
82
|
+
"finish absent"
|
|
83
|
+
if finish is None
|
|
84
|
+
else (
|
|
85
|
+
f"finish.value={_show(finish.value)} ({finish.surface}"
|
|
86
|
+
f"{': ' + finish.source if finish.source else ''})"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return f"{reported}; truth.finish={_show(truth.finish)}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _primary_finish(obs: Observation) -> Finish | None:
|
|
93
|
+
return obs.finish.value if obs.finish and obs.finish.surface == "primary" else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fold(results: Sequence[CheckResult]) -> CheckResult:
|
|
97
|
+
status: Status = "SATISFIED"
|
|
98
|
+
if any(result.status == "CONTRADICTED" for result in results):
|
|
99
|
+
status = "CONTRADICTED"
|
|
100
|
+
elif any(result.status == "NOT_OBSERVABLE" for result in results):
|
|
101
|
+
status = "NOT_OBSERVABLE"
|
|
102
|
+
return CheckResult(status, "; ".join(result.evidence for result in results))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def finish_is(
|
|
106
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
107
|
+
) -> CheckResult:
|
|
108
|
+
"""Compare a primary finish, including one exposed on an error, with ``value``.
|
|
109
|
+
|
|
110
|
+
Raw, absent and null finishes are NOT_OBSERVABLE, whatever the outcome.
|
|
111
|
+
"""
|
|
112
|
+
expected = _finish_arg(args)
|
|
113
|
+
value = _primary_finish(obs)
|
|
114
|
+
evidence = f"{_finish_evidence(obs, truth)}; required finish={_show(expected)}"
|
|
115
|
+
if value == expected:
|
|
116
|
+
return CheckResult("SATISFIED", evidence)
|
|
117
|
+
if value is not None:
|
|
118
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
119
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def finish_is_not(
|
|
123
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
124
|
+
) -> CheckResult:
|
|
125
|
+
"""Only a primary finish equal to ``value``, without a raised outcome, contradicts.
|
|
126
|
+
|
|
127
|
+
A raised outcome satisfies first. A raw-only or absent finish also satisfies: the library
|
|
128
|
+
doesn't report the forbidden value.
|
|
129
|
+
"""
|
|
130
|
+
forbidden = _finish_arg(args)
|
|
131
|
+
evidence = (
|
|
132
|
+
f"outcome={obs.outcome}; {_finish_evidence(obs, truth)}; "
|
|
133
|
+
f"forbidden finish={_show(forbidden)}"
|
|
134
|
+
)
|
|
135
|
+
if obs.outcome != "raised" and _primary_finish(obs) == forbidden:
|
|
136
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
137
|
+
return CheckResult("SATISFIED", evidence)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def truncation_is_signalled(
|
|
141
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
142
|
+
) -> CheckResult:
|
|
143
|
+
"""Primary length or a truncation error satisfies, even with structured success.
|
|
144
|
+
|
|
145
|
+
Read obs.finish whatever the outcome: a documented finish on an error is primary, with
|
|
146
|
+
source naming that error field. The manifest-declared path is already in the observation;
|
|
147
|
+
this check does no manifest lookup. Raw/absent finishes do not count. Satisfaction is tested
|
|
148
|
+
before contradiction; no_success_structured_value judges structured success separately.
|
|
149
|
+
"""
|
|
150
|
+
_keys(args)
|
|
151
|
+
value = _primary_finish(obs)
|
|
152
|
+
category = obs.error.category if obs.error else None
|
|
153
|
+
evidence = (
|
|
154
|
+
f"{_finish_evidence(obs, truth)}; error.category={_show(category)}; "
|
|
155
|
+
f"structured.returned_as_success={_show(obs.structured.returned_as_success)}"
|
|
156
|
+
)
|
|
157
|
+
if value == "length" or category == "truncation":
|
|
158
|
+
return CheckResult("SATISFIED", evidence)
|
|
159
|
+
if value in ("stop", "tool_calls") or obs.structured.returned_as_success:
|
|
160
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
161
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def text_matches_truth(
|
|
165
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
166
|
+
) -> CheckResult:
|
|
167
|
+
"""Compare text exactly; raw, absent or null text is NOT_OBSERVABLE."""
|
|
168
|
+
_keys(args)
|
|
169
|
+
value = obs.text.value if obs.text else None
|
|
170
|
+
surface = obs.text.surface if obs.text else "absent"
|
|
171
|
+
evidence = f"text.value={_show(value)} ({surface}); truth.text={_show(truth.text)}"
|
|
172
|
+
if surface != "primary" or value is None:
|
|
173
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
174
|
+
return CheckResult("SATISFIED" if value == truth.text else "CONTRADICTED", evidence)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def no_replacement_chars(
|
|
178
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
179
|
+
) -> CheckResult:
|
|
180
|
+
"""Look for U+FFFD in primary text; raw, absent or null text is NOT_OBSERVABLE."""
|
|
181
|
+
_keys(args)
|
|
182
|
+
value = obs.text.value if obs.text else None
|
|
183
|
+
surface = obs.text.surface if obs.text else "absent"
|
|
184
|
+
evidence = f"text.value={_show(value)} ({surface})"
|
|
185
|
+
if surface != "primary" or value is None:
|
|
186
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
187
|
+
count = value.count("\ufffd")
|
|
188
|
+
return CheckResult(
|
|
189
|
+
"CONTRADICTED" if count else "SATISFIED", f"{evidence}; U+FFFD count={count}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def no_success_structured_value(
|
|
194
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
195
|
+
) -> CheckResult:
|
|
196
|
+
"""An attempted structured result must not be returned as success.
|
|
197
|
+
|
|
198
|
+
If structured output was not attempted, the check is NOT_OBSERVABLE.
|
|
199
|
+
"""
|
|
200
|
+
_keys(args)
|
|
201
|
+
structured = obs.structured
|
|
202
|
+
evidence = (
|
|
203
|
+
f"structured.attempted={_show(structured.attempted)}; "
|
|
204
|
+
f"structured.returned_as_success={_show(structured.returned_as_success)}"
|
|
205
|
+
)
|
|
206
|
+
if not structured.attempted:
|
|
207
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
208
|
+
return CheckResult("CONTRADICTED" if structured.returned_as_success else "SATISFIED", evidence)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _json_equal(left: JsonValue, right: JsonValue) -> bool:
|
|
212
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
213
|
+
return type(left) is type(right) and left == right
|
|
214
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
215
|
+
return left.keys() == right.keys() and all(
|
|
216
|
+
_json_equal(value, right[key]) for key, value in left.items()
|
|
217
|
+
)
|
|
218
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
219
|
+
return len(left) == len(right) and all(
|
|
220
|
+
_json_equal(a, b) for a, b in zip(left, right, strict=True)
|
|
221
|
+
)
|
|
222
|
+
return left == right
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def tool_calls_match_truth(
|
|
226
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
227
|
+
) -> CheckResult:
|
|
228
|
+
"""Compare calls in order by count, id, name and parsed arguments, ignoring raw formatting.
|
|
229
|
+
|
|
230
|
+
A null truth id (the provider sends none) accepts any id the library generated. Two empty
|
|
231
|
+
lists satisfy. No calls against expected ones contradict on a returned outcome without an
|
|
232
|
+
error, and are NOT_OBSERVABLE otherwise. Known mismatches contradict; otherwise unavailable
|
|
233
|
+
parsed arguments are NOT_OBSERVABLE.
|
|
234
|
+
"""
|
|
235
|
+
_keys(args)
|
|
236
|
+
evidence = (
|
|
237
|
+
f"outcome={obs.outcome}; tool_calls count={len(obs.tool_calls)}; "
|
|
238
|
+
f"truth count={len(truth.tool_calls)}"
|
|
239
|
+
)
|
|
240
|
+
if not obs.tool_calls and truth.tool_calls and (obs.outcome != "returned" or obs.error):
|
|
241
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
242
|
+
if len(obs.tool_calls) != len(truth.tool_calls):
|
|
243
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
244
|
+
results = []
|
|
245
|
+
for index, (call, expected) in enumerate(zip(obs.tool_calls, truth.tool_calls, strict=True)):
|
|
246
|
+
detail = (
|
|
247
|
+
f"tool_calls[{index}] id={_show(call.id)}, name={_show(call.name)}, "
|
|
248
|
+
f"args={_show(call.arguments)}, parsed={_show(call.arguments_parsed_ok)}; "
|
|
249
|
+
f"truth id={_show(expected.id)}, name={_show(expected.name)}, "
|
|
250
|
+
f"args={_show(expected.arguments)}, parsed={_show(expected.arguments_parsed_ok)}"
|
|
251
|
+
)
|
|
252
|
+
status: Status = "SATISFIED"
|
|
253
|
+
if (expected.id is not None and call.id != expected.id) or call.name != expected.name:
|
|
254
|
+
status = "CONTRADICTED"
|
|
255
|
+
elif not call.arguments_parsed_ok or not expected.arguments_parsed_ok:
|
|
256
|
+
status = "NOT_OBSERVABLE"
|
|
257
|
+
elif not _json_equal(call.arguments, expected.arguments):
|
|
258
|
+
status = "CONTRADICTED"
|
|
259
|
+
results.append(CheckResult(status, detail))
|
|
260
|
+
return _fold(results)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def no_complete_tool_call_from_partial_args(
|
|
264
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
265
|
+
) -> CheckResult:
|
|
266
|
+
"""Reject calls returned without an error whose arguments are partial or repaired.
|
|
267
|
+
|
|
268
|
+
A returned call is treated as complete. A raised outcome or an error signal (an error object
|
|
269
|
+
or a primary ``error`` finish) satisfies first.
|
|
270
|
+
Match truth by id, falling back to position when no id matches. A false parsed flag (the
|
|
271
|
+
shim's reading of the raw arguments) or partial truth arguments make a returned call partial.
|
|
272
|
+
"""
|
|
273
|
+
_keys(args)
|
|
274
|
+
if obs.outcome == "raised" or obs.error is not None or _primary_finish(obs) == "error":
|
|
275
|
+
return CheckResult("SATISFIED", f"outcome={obs.outcome}; error signalled")
|
|
276
|
+
for index, call in enumerate(obs.tool_calls):
|
|
277
|
+
expected = next(
|
|
278
|
+
(item for item in truth.tool_calls if item.id is not None and item.id == call.id),
|
|
279
|
+
truth.tool_calls[index] if index < len(truth.tool_calls) else None,
|
|
280
|
+
)
|
|
281
|
+
if not call.arguments_parsed_ok or (
|
|
282
|
+
expected is not None and not expected.arguments_parsed_ok
|
|
283
|
+
):
|
|
284
|
+
return CheckResult(
|
|
285
|
+
"CONTRADICTED",
|
|
286
|
+
f"tool_calls[{index}] id={_show(call.id)} returned without error; "
|
|
287
|
+
f"arguments_parsed_ok={_show(call.arguments_parsed_ok)}, "
|
|
288
|
+
"truth.arguments_parsed_ok="
|
|
289
|
+
f"{_show(expected.arguments_parsed_ok if expected else None)}",
|
|
290
|
+
)
|
|
291
|
+
return CheckResult("SATISFIED", f"{len(obs.tool_calls)} tool calls; no partial args returned")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _usage_value(obs: Observation, field: str) -> tuple[int | None, Surface]:
|
|
295
|
+
if obs.usage is None:
|
|
296
|
+
return None, "absent"
|
|
297
|
+
return cast("int | None", getattr(obs.usage, field)), obs.usage.surface.get(field, "absent")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _usage_field(obs: Observation, truth: Truth, field: str) -> CheckResult:
|
|
301
|
+
value, surface = _usage_value(obs, field)
|
|
302
|
+
expected = cast("int | None", getattr(truth.usage, field))
|
|
303
|
+
alternatives = tuple(truth.usage.alternatives.get(field, {}).values())
|
|
304
|
+
evidence = f"usage.{field}={_show(value)} ({surface}); truth.usage.{field}={_show(expected)}"
|
|
305
|
+
if alternatives:
|
|
306
|
+
evidence += f"; alternatives={_show(alternatives)}"
|
|
307
|
+
if surface == "raw":
|
|
308
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
309
|
+
if expected is None and (surface == "absent" or value is None):
|
|
310
|
+
return CheckResult(
|
|
311
|
+
"SATISFIED", f"{evidence}; wire carried no {field}; library reported none"
|
|
312
|
+
)
|
|
313
|
+
if surface != "primary" or value is None:
|
|
314
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
315
|
+
if value == expected or value in alternatives:
|
|
316
|
+
return CheckResult("SATISFIED", evidence)
|
|
317
|
+
if expected is None:
|
|
318
|
+
evidence += f"; library reported {field}={value} but the wire carried no {field}"
|
|
319
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def usage_matches_truth(
|
|
323
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
324
|
+
) -> CheckResult:
|
|
325
|
+
"""Compare primary fields with truth and every declared alternative for that field.
|
|
326
|
+
|
|
327
|
+
Raw-only values are NOT_OBSERVABLE. Null truth accepts primary null or absence; a primary
|
|
328
|
+
number (including zero) contradicts unless it is an alternative. Fold fields as CONTRADICTED
|
|
329
|
+
before NOT_OBSERVABLE before SATISFIED, with evidence for each field.
|
|
330
|
+
"""
|
|
331
|
+
_keys(args, ("fields",))
|
|
332
|
+
fields = _list_arg(args, "fields", _USAGE_FIELDS)
|
|
333
|
+
return _fold([_usage_field(obs, truth, field) for field in fields])
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def usage_total_reconciles(
|
|
337
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
338
|
+
) -> CheckResult:
|
|
339
|
+
"""Compare primary total, or reconstruct it from primary input and output.
|
|
340
|
+
|
|
341
|
+
A present primary total is authoritative; alternatives are accepted. With null truth.total,
|
|
342
|
+
apply the same reports-none rule as usage_matches_truth, without reconstructing. Raw total
|
|
343
|
+
alone is NOT_OBSERVABLE, but primary components can independently reconstruct a known total.
|
|
344
|
+
Missing input/output is NOT_OBSERVABLE. Try input + output with either, both or neither of
|
|
345
|
+
the separately reported primary cached_input and reasoning counts added.
|
|
346
|
+
"""
|
|
347
|
+
_keys(args)
|
|
348
|
+
total, surface = _usage_value(obs, "total")
|
|
349
|
+
if truth.usage.total is None or (surface == "primary" and total is not None):
|
|
350
|
+
return _usage_field(obs, truth, "total")
|
|
351
|
+
values = {field: _usage_value(obs, field) for field in _USAGE_FIELDS[:-1]}
|
|
352
|
+
evidence = f"usage.total={_show(total)} ({surface}); " + "; ".join(
|
|
353
|
+
f"usage.{field}={_show(value)} ({where})" for field, (value, where) in values.items()
|
|
354
|
+
)
|
|
355
|
+
evidence += f"; truth.usage.total={truth.usage.total}"
|
|
356
|
+
input_value, input_surface = values["input"]
|
|
357
|
+
output_value, output_surface = values["output"]
|
|
358
|
+
if (
|
|
359
|
+
input_surface != "primary"
|
|
360
|
+
or output_surface != "primary"
|
|
361
|
+
or (input_value is None or output_value is None)
|
|
362
|
+
):
|
|
363
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
364
|
+
totals = {input_value + output_value}
|
|
365
|
+
for field in ("cached_input", "reasoning"):
|
|
366
|
+
value, where = values[field]
|
|
367
|
+
if where == "primary" and value is not None:
|
|
368
|
+
totals |= {total + value for total in totals}
|
|
369
|
+
accepted = {truth.usage.total, *truth.usage.alternatives.get("total", {}).values()}
|
|
370
|
+
evidence += f"; reconstructed totals={sorted(totals)}; accepted totals={sorted(accepted)}"
|
|
371
|
+
return CheckResult("SATISFIED" if totals & accepted else "CONTRADICTED", evidence)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def outcome_is_error(
|
|
375
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
376
|
+
) -> CheckResult:
|
|
377
|
+
"""A raised outcome must have a listed category, if categories were requested.
|
|
378
|
+
|
|
379
|
+
Raised with another category is NOT_OBSERVABLE: it failed loudly with the wrong cause.
|
|
380
|
+
A returned outcome with an error object or primary error finish is also NOT_OBSERVABLE,
|
|
381
|
+
rather than silent success.
|
|
382
|
+
"""
|
|
383
|
+
_keys(args, optional=("categories",))
|
|
384
|
+
categories = (
|
|
385
|
+
_list_arg(args, "categories", get_args(ErrorCategory)) if "categories" in args else None
|
|
386
|
+
)
|
|
387
|
+
category = obs.error.category if obs.error else None
|
|
388
|
+
evidence = (
|
|
389
|
+
f"outcome={obs.outcome}; error.category={_show(category)}; {_finish_evidence(obs, truth)}"
|
|
390
|
+
)
|
|
391
|
+
if categories is not None:
|
|
392
|
+
evidence += f"; required categories={_show(categories)}"
|
|
393
|
+
if obs.outcome == "raised" and (categories is None or category in categories):
|
|
394
|
+
return CheckResult("SATISFIED", evidence)
|
|
395
|
+
if obs.outcome == "returned" and obs.error is None and _primary_finish(obs) != "error":
|
|
396
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
397
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def stream_not_reported_complete(
|
|
401
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
402
|
+
) -> CheckResult:
|
|
403
|
+
"""A raised outcome satisfies before a true completion signal can contradict."""
|
|
404
|
+
_keys(args)
|
|
405
|
+
evidence = (
|
|
406
|
+
f"stream.completed={_show(obs.stream.completed)}; outcome={obs.outcome}; "
|
|
407
|
+
f"truth.stream_completed={_show(truth.stream_completed)}"
|
|
408
|
+
)
|
|
409
|
+
if obs.stream.completed is False or obs.outcome == "raised":
|
|
410
|
+
return CheckResult("SATISFIED", evidence)
|
|
411
|
+
if obs.stream.completed is True:
|
|
412
|
+
return CheckResult("CONTRADICTED", evidence)
|
|
413
|
+
return CheckResult("NOT_OBSERVABLE", evidence)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _at_path(body: JsonValue, path: str) -> tuple[bool, JsonValue]:
|
|
417
|
+
for part in path.split("."):
|
|
418
|
+
if part.isascii() and part.isdecimal():
|
|
419
|
+
index = int(part)
|
|
420
|
+
if not isinstance(body, list) or index >= len(body):
|
|
421
|
+
return False, None
|
|
422
|
+
body = body[index]
|
|
423
|
+
elif isinstance(body, dict) and part in body:
|
|
424
|
+
body = body[part]
|
|
425
|
+
else:
|
|
426
|
+
return False, None
|
|
427
|
+
return True, body
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def request_field_equals(
|
|
431
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
432
|
+
) -> CheckResult:
|
|
433
|
+
"""Every logged request must have the requested JSON value at a dotted path.
|
|
434
|
+
|
|
435
|
+
Numeric segments index lists. Non-JSON bodies have no paths. No requests is NOT_OBSERVABLE;
|
|
436
|
+
any mismatch contradicts, otherwise any missing path is NOT_OBSERVABLE. JSON booleans and
|
|
437
|
+
numbers differ, including inside arrays and objects.
|
|
438
|
+
"""
|
|
439
|
+
_keys(args, ("path", "value"))
|
|
440
|
+
path = _path_arg(args)
|
|
441
|
+
if not req_log:
|
|
442
|
+
return CheckResult(
|
|
443
|
+
"NOT_OBSERVABLE", f"no logged requests; required {path}={_show(args['value'])}"
|
|
444
|
+
)
|
|
445
|
+
results = []
|
|
446
|
+
for index, request in enumerate(req_log):
|
|
447
|
+
present, value = _at_path(request.json_body, path)
|
|
448
|
+
status: Status = "NOT_OBSERVABLE"
|
|
449
|
+
if present:
|
|
450
|
+
status = "SATISFIED" if _json_equal(value, args["value"]) else "CONTRADICTED"
|
|
451
|
+
evidence = f"request[{index}].json_body.{path}={_show(value) if present else 'absent'}"
|
|
452
|
+
results.append(CheckResult(status, f"{evidence}; required={_show(args['value'])}"))
|
|
453
|
+
return _fold(results)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def request_field_absent(
|
|
457
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
458
|
+
) -> CheckResult:
|
|
459
|
+
"""A path must be absent from every request; non-JSON bodies and no requests satisfy."""
|
|
460
|
+
_keys(args, ("path",))
|
|
461
|
+
path = _path_arg(args)
|
|
462
|
+
for index, request in enumerate(req_log):
|
|
463
|
+
present, value = _at_path(request.json_body, path)
|
|
464
|
+
if present:
|
|
465
|
+
return CheckResult("CONTRADICTED", f"request[{index}].json_body.{path}={_show(value)}")
|
|
466
|
+
return CheckResult("SATISFIED", f"{path} absent from all {len(req_log)} logged requests")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def request_count_at_most(
|
|
470
|
+
obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
|
|
471
|
+
) -> CheckResult:
|
|
472
|
+
"""Count every logged request, including retries and non-JSON bodies."""
|
|
473
|
+
_keys(args, ("n",))
|
|
474
|
+
n = args["n"]
|
|
475
|
+
if not isinstance(n, int) or isinstance(n, bool) or n < 0:
|
|
476
|
+
raise ValueError("n must be an integer >= 0")
|
|
477
|
+
count = len(req_log)
|
|
478
|
+
return CheckResult(
|
|
479
|
+
"SATISFIED" if count <= n else "CONTRADICTED", f"request count={count}; maximum={n}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
CHECKS: Mapping[str, Check] = MappingProxyType(
|
|
484
|
+
{
|
|
485
|
+
check.__name__: check
|
|
486
|
+
for check in (
|
|
487
|
+
finish_is,
|
|
488
|
+
finish_is_not,
|
|
489
|
+
truncation_is_signalled,
|
|
490
|
+
text_matches_truth,
|
|
491
|
+
no_replacement_chars,
|
|
492
|
+
no_success_structured_value,
|
|
493
|
+
tool_calls_match_truth,
|
|
494
|
+
no_complete_tool_call_from_partial_args,
|
|
495
|
+
usage_matches_truth,
|
|
496
|
+
usage_total_reconciles,
|
|
497
|
+
outcome_is_error,
|
|
498
|
+
stream_not_reported_complete,
|
|
499
|
+
request_field_equals,
|
|
500
|
+
request_field_absent,
|
|
501
|
+
request_count_at_most,
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
)
|