typevet 0.1.0.dev1__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.
Files changed (133) hide show
  1. typevet/__init__.py +67 -0
  2. typevet/_version.py +43 -0
  3. typevet/adapters/__init__.py +16 -0
  4. typevet/adapters/diagnostics/__init__.py +59 -0
  5. typevet/adapters/diagnostics/fields.py +108 -0
  6. typevet/adapters/diagnostics/generation_events.py +75 -0
  7. typevet/adapters/diagnostics/http_events.py +91 -0
  8. typevet/adapters/diagnostics/logs.py +116 -0
  9. typevet/adapters/diagnostics/redaction.py +94 -0
  10. typevet/adapters/diagnostics/settings.py +77 -0
  11. typevet/adapters/inbound/__init__.py +65 -0
  12. typevet/adapters/inbound/api.py +61 -0
  13. typevet/adapters/inbound/backend_settings.py +608 -0
  14. typevet/adapters/inbound/cord_semantic_acceptance_cli.py +135 -0
  15. typevet/adapters/inbound/helpers.py +67 -0
  16. typevet/adapters/inbound/settings.py +154 -0
  17. typevet/adapters/outbound/__init__.py +62 -0
  18. typevet/adapters/outbound/async_fake.py +123 -0
  19. typevet/adapters/outbound/async_llama_cpp.py +168 -0
  20. typevet/adapters/outbound/chat_completion.py +148 -0
  21. typevet/adapters/outbound/fake.py +119 -0
  22. typevet/adapters/outbound/gemma/__init__.py +84 -0
  23. typevet/adapters/outbound/gemma/answer_binding.py +290 -0
  24. typevet/adapters/outbound/gemma/scoring_prefix.py +102 -0
  25. typevet/adapters/outbound/gemma/served_template.py +131 -0
  26. typevet/adapters/outbound/gemma_native_vision_factory.py +286 -0
  27. typevet/adapters/outbound/generation_finite.py +50 -0
  28. typevet/adapters/outbound/http_errors.py +38 -0
  29. typevet/adapters/outbound/judgment_scoring.py +471 -0
  30. typevet/adapters/outbound/llama_cpp.py +187 -0
  31. typevet/adapters/outbound/llama_cpp_http.py +103 -0
  32. typevet/adapters/outbound/llama_cpp_multimodal.py +145 -0
  33. typevet/adapters/outbound/llama_cpp_scoring.py +333 -0
  34. typevet/adapters/outbound/vllm_content.py +63 -0
  35. typevet/adapters/outbound/vllm_generation.py +163 -0
  36. typevet/adapters/outbound/vllm_generation_async.py +257 -0
  37. typevet/adapters/outbound/vllm_http.py +121 -0
  38. typevet/adapters/outbound/vllm_judgment_factory.py +210 -0
  39. typevet/adapters/outbound/vllm_scoring.py +372 -0
  40. typevet/domain/__init__.py +193 -0
  41. typevet/domain/candidate_scoring_request.py +134 -0
  42. typevet/domain/candidate_scoring_response.py +96 -0
  43. typevet/domain/candidate_scoring_validate.py +197 -0
  44. typevet/domain/decision_compile.py +368 -0
  45. typevet/domain/decision_execute.py +229 -0
  46. typevet/domain/decisions.py +129 -0
  47. typevet/domain/errors.py +235 -0
  48. typevet/domain/field_instructions.py +134 -0
  49. typevet/domain/judgment_answers.py +198 -0
  50. typevet/domain/judgment_normalize.py +243 -0
  51. typevet/domain/judgment_questions.py +187 -0
  52. typevet/domain/judgment_response.py +102 -0
  53. typevet/domain/media.py +101 -0
  54. typevet/domain/models.py +115 -0
  55. typevet/domain/question_schema.py +254 -0
  56. typevet/domain/scoring_stage.py +34 -0
  57. typevet/evaluation/__init__.py +19 -0
  58. typevet/evaluation/consumer_http_accounting.py +135 -0
  59. typevet/evaluation/cord_expense_call_accounting.py +108 -0
  60. typevet/evaluation/cord_expense_live_harness.py +75 -0
  61. typevet/evaluation/cord_expense_receipt_requirement.py +212 -0
  62. typevet/evaluation/cord_expense_smoke.py +328 -0
  63. typevet/evaluation/cord_semantic_acceptance.py +463 -0
  64. typevet/evaluation/cord_semantic_acceptance_report.py +90 -0
  65. typevet/evaluation/cord_semantic_metrics.py +57 -0
  66. typevet/evaluation/datasets/__init__.py +30 -0
  67. typevet/evaluation/datasets/banking77.py +270 -0
  68. typevet/evaluation/datasets/boolq.py +550 -0
  69. typevet/evaluation/datasets/boolq_download.py +132 -0
  70. typevet/evaluation/datasets/civil_comments.py +450 -0
  71. typevet/evaluation/datasets/clinc.py +125 -0
  72. typevet/evaluation/datasets/clinc_domains.json +172 -0
  73. typevet/evaluation/datasets/clinc_download.py +103 -0
  74. typevet/evaluation/datasets/clinc_plus_intent_names.json +153 -0
  75. typevet/evaluation/datasets/clinc_rows.py +306 -0
  76. typevet/evaluation/datasets/clinc_shard.py +156 -0
  77. typevet/evaluation/datasets/cord_expense.py +381 -0
  78. typevet/evaluation/datasets/difraud.py +256 -0
  79. typevet/evaluation/datasets/go_emotions.py +368 -0
  80. typevet/evaluation/datasets/go_emotions_download.py +154 -0
  81. typevet/evaluation/datasets/hyperpartisan.py +562 -0
  82. typevet/evaluation/datasets/partner_guard.py +170 -0
  83. typevet/evaluation/datasets/psai.py +415 -0
  84. typevet/evaluation/datasets/psai_download.py +167 -0
  85. typevet/evaluation/datasets/psai_evidence_pilot.py +570 -0
  86. typevet/evaluation/datasets/psai_schema.py +157 -0
  87. typevet/evaluation/datasets/psai_stream.py +166 -0
  88. typevet/evaluation/datasets/psai_vision.py +534 -0
  89. typevet/evaluation/datasets/psai_vision_controls.py +502 -0
  90. typevet/evaluation/datasets/pubmedqa.py +420 -0
  91. typevet/evaluation/experiment_identity.py +677 -0
  92. typevet/evaluation/psai_vision_consumer_accounting.py +190 -0
  93. typevet/evaluation/psai_vision_consumer_dispatch.py +216 -0
  94. typevet/evaluation/psai_vision_consumer_harness.py +342 -0
  95. typevet/evaluation/psai_vision_consumer_live.py +332 -0
  96. typevet/evaluation/psai_vision_consumer_live_identity.py +156 -0
  97. typevet/evaluation/psai_vision_consumer_live_receipt.py +132 -0
  98. typevet/evaluation/psai_vision_consumer_live_router.py +162 -0
  99. typevet/evaluation/psai_vision_consumer_offline.py +418 -0
  100. typevet/evaluation/psai_vision_consumer_outcomes.py +224 -0
  101. typevet/evaluation/psai_vision_consumer_protocol.py +69 -0
  102. typevet/evaluation/psai_vision_consumer_receipt.py +161 -0
  103. typevet/evaluation/psai_vision_consumer_receipt_structure.py +263 -0
  104. typevet/evaluation/psai_vision_probability_evidence.py +336 -0
  105. typevet/evaluation/runner/__init__.py +56 -0
  106. typevet/evaluation/runner/core.py +104 -0
  107. typevet/evaluation/runner/datasets.py +186 -0
  108. typevet/evaluation/runner/live_gate.py +149 -0
  109. typevet/evaluation/runner/report.py +116 -0
  110. typevet/evaluation/tpjep/__init__.py +81 -0
  111. typevet/evaluation/tpjep/loader.py +237 -0
  112. typevet/evaluation/tpjep/outcome.py +101 -0
  113. typevet/evaluation/tpjep/records.py +428 -0
  114. typevet/evaluation/tpjep/runner.py +325 -0
  115. typevet/ports/__init__.py +39 -0
  116. typevet/ports/async_generation.py +57 -0
  117. typevet/ports/framing.py +59 -0
  118. typevet/ports/generation.py +58 -0
  119. typevet/ports/judgment.py +71 -0
  120. typevet/ports/scoring.py +64 -0
  121. typevet/py.typed +0 -0
  122. typevet/runtime/__init__.py +50 -0
  123. typevet/runtime/categorical.py +196 -0
  124. typevet/runtime/judgment.py +17 -0
  125. typevet/runtime/llama_cpp_gemma_vision.py +28 -0
  126. typevet/runtime/scoring_prefix.py +14 -0
  127. typevet/runtime/vllm_judgment.py +28 -0
  128. typevet/testing/__init__.py +22 -0
  129. typevet/testing/fakes.py +152 -0
  130. typevet-0.1.0.dev1.dist-info/METADATA +125 -0
  131. typevet-0.1.0.dev1.dist-info/RECORD +133 -0
  132. typevet-0.1.0.dev1.dist-info/WHEEL +4 -0
  133. typevet-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,161 @@
1
+ """Receipt serialization and acceptance for the PSAI consumer harness ([#177][i177]).
2
+
3
+ Examples:
4
+ ```python
5
+ from typevet.evaluation.psai_vision_consumer_receipt import (
6
+ evaluate_consumer_receipt_acceptance,
7
+ serialize_answer,
8
+ )
9
+ from typevet.domain.judgment_answers import NoulAnswer
10
+
11
+ payload = serialize_answer(NoulAnswer(noul=0.5))
12
+ assert payload["kind"] == "Noul"
13
+ ```
14
+
15
+ See Also:
16
+ - [typevet.evaluation.psai_vision_consumer_harness][]: orchestration
17
+ - [typevet.evaluation.psai_vision_consumer_receipt_structure][]: structural checks
18
+
19
+ Acceptance runs structural fail-closed checks first, then semantic gold
20
+ replay when fixture identity pins on the receipt are valid.
21
+
22
+ [i177]: https://github.com/Alberto-Codes/typevet/issues/177
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from collections.abc import Mapping
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from typevet.domain.judgment_answers import (
34
+ Answer,
35
+ ChoiceAnswer,
36
+ NoulAnswer,
37
+ ScoreAnswer,
38
+ )
39
+ from typevet.evaluation.psai_vision_consumer_outcomes import expected_outcome_failures
40
+ from typevet.evaluation.psai_vision_consumer_receipt_structure import (
41
+ protocol_structural_failures,
42
+ )
43
+
44
+
45
+ def serialize_answer(answer: Answer) -> dict[str, Any]:
46
+ """Map a public ``Answer`` to a JSON-serializable dict.
47
+
48
+ Args:
49
+ answer: Typed judgment answer.
50
+
51
+ Returns:
52
+ Dict with ``kind`` and type-specific fields.
53
+
54
+ Raises:
55
+ TypeError: When ``answer`` is not a known ``Answer`` subtype.
56
+ """
57
+ if isinstance(answer, NoulAnswer):
58
+ return {"kind": "Noul", "noul": answer.noul}
59
+ if isinstance(answer, ChoiceAnswer):
60
+ return {
61
+ "kind": "Choice",
62
+ "choice": answer.choice,
63
+ "confidence": answer.confidence,
64
+ "probabilities": dict(answer.probabilities),
65
+ }
66
+ if isinstance(answer, ScoreAnswer):
67
+ return {
68
+ "kind": "Score",
69
+ "score": answer.score,
70
+ "confidence": answer.confidence,
71
+ "legend": {str(k): v for k, v in answer.legend.items()},
72
+ "probabilities": {str(k): v for k, v in answer.probabilities.items()},
73
+ }
74
+ msg = f"unsupported answer type: {type(answer)!r}"
75
+ raise TypeError(msg)
76
+
77
+
78
+ def consumer_receipt_basename(
79
+ *,
80
+ protocol_revision: int,
81
+ wheel_sha256: str | None,
82
+ model_id: str,
83
+ ) -> str:
84
+ """Return a stable receipt filename for one proof configuration.
85
+
86
+ Args:
87
+ protocol_revision: Frozen protocol revision integer.
88
+ wheel_sha256: Optional wheel digest from the environment.
89
+ model_id: Model id under test.
90
+
91
+ Returns:
92
+ Filename including ``.json`` suffix.
93
+ """
94
+ wheel_part = (wheel_sha256 or "unknown-wheel")[:16]
95
+ model_slug = re.sub(r"[^a-zA-Z0-9._-]+", "_", model_id)[:48]
96
+ return f"consumer-receipt-p{protocol_revision}-{wheel_part}-{model_slug}.json"
97
+
98
+
99
+ def resolve_receipt_write_path(
100
+ out_dir: Path,
101
+ *,
102
+ protocol_revision: int,
103
+ wheel_sha256: str | None,
104
+ model_id: str,
105
+ ) -> Path:
106
+ """Pick a non-colliding receipt path under ``out_dir``.
107
+
108
+ Args:
109
+ out_dir: Directory for receipts.
110
+ protocol_revision: Frozen protocol revision.
111
+ wheel_sha256: Optional wheel digest.
112
+ model_id: Model id under test.
113
+
114
+ Returns:
115
+ Path that does not yet exist (appends ``-attempt-N`` when needed).
116
+
117
+ Raises:
118
+ OSError: When no free attempt suffix is available.
119
+ """
120
+ out_dir.mkdir(parents=True, exist_ok=True)
121
+ base = out_dir / consumer_receipt_basename(
122
+ protocol_revision=protocol_revision,
123
+ wheel_sha256=wheel_sha256,
124
+ model_id=model_id,
125
+ )
126
+ if not base.exists():
127
+ return base
128
+ for attempt in range(1, 1000):
129
+ candidate = base.with_name(base.stem + f"-attempt-{attempt}" + base.suffix)
130
+ if not candidate.exists():
131
+ return candidate
132
+ msg = f"could not allocate receipt path under {out_dir}"
133
+ raise OSError(msg)
134
+
135
+
136
+ def evaluate_consumer_receipt_acceptance(
137
+ receipt: Mapping[str, Any],
138
+ ) -> tuple[bool, list[str]]:
139
+ """Check frozen acceptance conditions on a saved receipt.
140
+
141
+ Args:
142
+ receipt: Consumer proof receipt dict.
143
+
144
+ Returns:
145
+ ``(accepted, failure_messages)``. Structural violations (empty body,
146
+ stripped matrix, bad counts) reject before gold outcome comparison.
147
+
148
+ """
149
+ failures = protocol_structural_failures(receipt)
150
+ failures.extend(expected_outcome_failures(receipt))
151
+ return (not failures, failures)
152
+
153
+
154
+ def write_receipt_json(path: Path, receipt: Mapping[str, Any]) -> None:
155
+ """Write ``receipt`` as indented JSON to ``path``.
156
+
157
+ Args:
158
+ path: Destination file.
159
+ receipt: Serializable receipt mapping.
160
+ """
161
+ path.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
@@ -0,0 +1,263 @@
1
+ """Structural fail-closed checks for consumer receipts ([#177][i177]).
2
+
3
+ Examples:
4
+ ```python
5
+ from typevet.evaluation.psai_vision_consumer_receipt_structure import (
6
+ protocol_structural_failures,
7
+ )
8
+
9
+ assert protocol_structural_failures({}) == ["receipt is empty"]
10
+ ```
11
+
12
+ See Also:
13
+ - [typevet.evaluation.psai_vision_consumer_protocol][]: frozen pins
14
+
15
+ [i177]: https://github.com/Alberto-Codes/typevet/issues/177
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+ from math import isfinite
22
+ from typing import Any
23
+
24
+ from typevet.evaluation.psai_vision_consumer_accounting import (
25
+ FROZEN_CONSUMER_CASE_UIDS,
26
+ TEXT_ANNOTATION_JUDGE_UIDS,
27
+ )
28
+ from typevet.evaluation.psai_vision_consumer_protocol import (
29
+ FROZEN_JUDGMENT_CALLS,
30
+ FROZEN_PROTOCOL_REVISION,
31
+ FROZEN_SCORING_REQUESTS,
32
+ expected_matrix_row_keys,
33
+ expected_paired_ordering_uids,
34
+ )
35
+
36
+
37
+ def _finite_prob(value: object) -> bool:
38
+ return isinstance(value, (int, float)) and isfinite(float(value))
39
+
40
+
41
+ def _noul_answer_failures(name: str, payload: Mapping[str, Any]) -> list[str]:
42
+ prob = payload.get("noul")
43
+ if not isinstance(prob, (int, float)) or not isfinite(float(prob)):
44
+ return [f"answer {name!r} has non-finite noul"]
45
+ prob_float = float(prob)
46
+ if not 0.0 <= prob_float <= 1.0:
47
+ return [f"answer {name!r} noul out of [0,1]"]
48
+ return []
49
+
50
+
51
+ def _mapping_prob_failures(
52
+ name: str,
53
+ probs: object,
54
+ *,
55
+ label: str,
56
+ ) -> list[str]:
57
+ if not isinstance(probs, Mapping):
58
+ return []
59
+ failures: list[str] = []
60
+ for key, value in probs.items():
61
+ if not _finite_prob(value):
62
+ failures.append(f"answer {name!r} {label} prob {key!r} non-finite")
63
+ return failures
64
+
65
+
66
+ def _answer_probability_failures(answers: Mapping[str, Any]) -> list[str]:
67
+ failures: list[str] = []
68
+ for name, payload in answers.items():
69
+ if not isinstance(payload, Mapping):
70
+ failures.append(f"answer {name!r} is not an object")
71
+ continue
72
+ kind = payload.get("kind")
73
+ if kind == "Noul":
74
+ failures.extend(_noul_answer_failures(name, payload))
75
+ elif kind == "Choice":
76
+ failures.extend(
77
+ _mapping_prob_failures(
78
+ name, payload.get("probabilities"), label="choice"
79
+ )
80
+ )
81
+ elif kind == "Score":
82
+ failures.extend(
83
+ _mapping_prob_failures(
84
+ name, payload.get("probabilities"), label="score"
85
+ )
86
+ )
87
+ return failures
88
+
89
+
90
+ def _revision_and_count_failures(receipt: Mapping[str, Any]) -> list[str]:
91
+ failures: list[str] = []
92
+ revision = receipt.get("consumer_live_protocol_revision")
93
+ if revision != FROZEN_PROTOCOL_REVISION:
94
+ failures.append(
95
+ f"consumer_live_protocol_revision {revision!r} != {FROZEN_PROTOCOL_REVISION}"
96
+ )
97
+ judgment = receipt.get("judgment_call_count")
98
+ scoring = receipt.get("scoring_request_count")
99
+ if not isinstance(judgment, int) or judgment <= 0:
100
+ failures.append("judgment_call_count missing or zero")
101
+ if not isinstance(scoring, int) or scoring <= 0:
102
+ failures.append("scoring_request_count missing or zero")
103
+ if judgment != FROZEN_JUDGMENT_CALLS:
104
+ failures.append(
105
+ f"judgment_call_count {judgment!r} != frozen {FROZEN_JUDGMENT_CALLS}"
106
+ )
107
+ if scoring != FROZEN_SCORING_REQUESTS:
108
+ failures.append(
109
+ f"scoring_request_count {scoring!r} != frozen {FROZEN_SCORING_REQUESTS}"
110
+ )
111
+ require_live = receipt.get("require_live") is True
112
+ aux = receipt.get("auxiliary_http_count")
113
+ if require_live and (not isinstance(aux, int) or aux <= 0):
114
+ failures.append("auxiliary_http_count missing or zero for live receipt")
115
+ return failures
116
+
117
+
118
+ def _capability_failures(receipt: Mapping[str, Any]) -> list[str]:
119
+ failures: list[str] = []
120
+ require_live = receipt.get("require_live") is True
121
+ capability = receipt.get("capability")
122
+ if not isinstance(capability, Mapping):
123
+ return ["missing capability block"]
124
+ if require_live and capability.get("vision") is not True:
125
+ failures.append("capability.vision is not true for live receipt")
126
+ elif capability.get("vision") is False:
127
+ failures.append("capability.vision is false")
128
+ negative = receipt.get("unsupported_capability_negative")
129
+ if not isinstance(negative, Mapping):
130
+ failures.append("missing unsupported_capability_negative block")
131
+ elif negative.get("ok") is not True:
132
+ failures.append("unsupported_capability_negative did not pass")
133
+ return failures
134
+
135
+
136
+ def _matrix_row_failures(matrix: list[Any]) -> list[str]:
137
+ failures: list[str] = []
138
+ expected_keys = expected_matrix_row_keys()
139
+ seen_keys: set[tuple[str, str, str]] = set()
140
+ for row in matrix:
141
+ if not isinstance(row, Mapping):
142
+ return ["matrix row is not an object"]
143
+ uid = row.get("unique_data_id")
144
+ condition = row.get("condition")
145
+ leg = row.get("leg")
146
+ if not isinstance(uid, str) or not isinstance(condition, str):
147
+ return ["matrix row missing unique_data_id or condition"]
148
+ if not isinstance(leg, str):
149
+ return [f"matrix row {uid!r} missing leg"]
150
+ key = (uid, condition, leg)
151
+ if (
152
+ uid not in FROZEN_CONSUMER_CASE_UIDS
153
+ and uid not in TEXT_ANNOTATION_JUDGE_UIDS
154
+ ):
155
+ failures.append(f"matrix row unknown unique_data_id {uid!r}")
156
+ if key not in expected_keys:
157
+ failures.append(f"matrix row unexpected key {key!r}")
158
+ if key in seen_keys:
159
+ failures.append(f"duplicate matrix row {key!r}")
160
+ seen_keys.add(key)
161
+ answers = row.get("answers")
162
+ if not isinstance(answers, Mapping) or not answers:
163
+ failures.append(f"matrix row {key!r} missing answers map")
164
+ else:
165
+ failures.extend(_answer_probability_failures(answers))
166
+ missing = expected_keys - seen_keys
167
+ if missing:
168
+ failures.append(f"missing matrix rows for keys {sorted(missing)!r}")
169
+ return failures
170
+
171
+
172
+ def _matrix_failures(receipt: Mapping[str, Any]) -> list[str]:
173
+ matrix = receipt.get("matrix_rows")
174
+ if not isinstance(matrix, list) or not matrix:
175
+ return ["matrix_rows missing or empty"]
176
+ failures = _matrix_row_failures(matrix)
177
+ judgment = receipt.get("judgment_call_count")
178
+ if isinstance(judgment, int) and len(matrix) != judgment:
179
+ failures.append(f"matrix_rows {len(matrix)} != judgment_call_count {judgment}")
180
+ return failures
181
+
182
+
183
+ def _paired_ordering_failures(receipt: Mapping[str, Any]) -> list[str]:
184
+ pairs = receipt.get("paired_ordering")
185
+ if not isinstance(pairs, list) or not pairs:
186
+ return ["paired_ordering missing or empty"]
187
+ failures: list[str] = []
188
+ pair_uids: set[str] = set()
189
+ for pair in pairs:
190
+ if not isinstance(pair, Mapping):
191
+ return ["paired_ordering entry is not an object"]
192
+ uid = pair.get("unique_data_id")
193
+ if not isinstance(uid, str):
194
+ return ["paired_ordering entry missing unique_data_id"]
195
+ if uid not in expected_paired_ordering_uids():
196
+ failures.append(f"paired_ordering unknown unique_data_id {uid!r}")
197
+ if uid in pair_uids:
198
+ failures.append(f"duplicate paired_ordering uid {uid!r}")
199
+ pair_uids.add(uid)
200
+ if not pair.get("ordered"):
201
+ failures.append(f"paired_ordering failed for row {uid!r}")
202
+ missing_pairs = expected_paired_ordering_uids() - pair_uids
203
+ if missing_pairs:
204
+ failures.append(f"missing paired_ordering for {sorted(missing_pairs)!r}")
205
+ return failures
206
+
207
+
208
+ def _observed_scoring_failures(receipt: Mapping[str, Any]) -> list[str]:
209
+ failures: list[str] = []
210
+ scoring = receipt.get("scoring_request_count")
211
+ observed = receipt.get("scoring_requests_observed")
212
+ if isinstance(scoring, int) and isinstance(observed, int) and observed != scoring:
213
+ failures.append(
214
+ f"scoring_requests_observed {observed} != scoring_request_count {scoring}"
215
+ )
216
+ matrix = receipt.get("matrix_rows")
217
+ if not isinstance(matrix, list) or not isinstance(observed, int):
218
+ return failures
219
+ answer_total = sum(
220
+ len(row["answers"])
221
+ for row in matrix
222
+ if isinstance(row, Mapping) and isinstance(row.get("answers"), Mapping)
223
+ )
224
+ if answer_total != observed:
225
+ failures.append(
226
+ f"scoring_requests_observed {observed} != "
227
+ f"serialized answer count {answer_total}"
228
+ )
229
+ return failures
230
+
231
+
232
+ def _pin_failures(receipt: Mapping[str, Any]) -> list[str]:
233
+ failures: list[str] = []
234
+ manifest_pin = receipt.get("manifest_sha256")
235
+ if not isinstance(manifest_pin, str) or not manifest_pin.strip():
236
+ failures.append("manifest_sha256 pin missing")
237
+ image_pins = receipt.get("frozen_case_image_digests")
238
+ if not isinstance(image_pins, list) or len(image_pins) != len(
239
+ FROZEN_CONSUMER_CASE_UIDS
240
+ ):
241
+ failures.append("frozen_case_image_digests pin missing or incomplete")
242
+ return failures
243
+
244
+
245
+ def protocol_structural_failures(receipt: Mapping[str, Any]) -> list[str]:
246
+ """Fail-closed structural checks independent of machine-local paths.
247
+
248
+ Args:
249
+ receipt: Consumer proof receipt dict.
250
+
251
+ Returns:
252
+ Human-readable failure messages (empty when structure matches rev 2).
253
+ """
254
+ if not receipt:
255
+ return ["receipt is empty"]
256
+ failures: list[str] = []
257
+ failures.extend(_revision_and_count_failures(receipt))
258
+ failures.extend(_capability_failures(receipt))
259
+ failures.extend(_matrix_failures(receipt))
260
+ failures.extend(_paired_ordering_failures(receipt))
261
+ failures.extend(_observed_scoring_failures(receipt))
262
+ failures.extend(_pin_failures(receipt))
263
+ return failures