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 ADDED
@@ -0,0 +1,3 @@
1
+ """wiretruth: replay real LLM provider responses through LLM SDKs and grade what they report."""
2
+
3
+ __version__ = "0.1.0"
wiretruth/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from wiretruth.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ )