hypermind 0.11.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.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,251 @@
1
+ """A/B model comparison harness.
2
+
3
+ Run two or more responders against the same question set and produce a
4
+ :class:`ComparisonReport` with per-responder Brier scores, USD costs,
5
+ latency p50/p95, and a winner per-metric.
6
+
7
+ Usage::
8
+
9
+ from openai import AsyncOpenAI
10
+ from hypermind.responders import OpenAIResponder
11
+ from hypermind.eval import compare_responders
12
+
13
+ a = OpenAIResponder(client=cli, model="gpt-4o-mini")
14
+ b = OpenAIResponder(client=cli, model="anthropic/claude-sonnet-4-6")
15
+
16
+ questions = [
17
+ {"id": "q1", "query": "Will it rain?", "ground_truth": 1.0},
18
+ {"id": "q2", "query": "Is the moon cheese?", "ground_truth": 0.0},
19
+ ]
20
+ report = await compare_responders(questions, {"a": a, "b": b})
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import time
27
+ from collections.abc import Iterable, Sequence
28
+ from dataclasses import dataclass
29
+
30
+ from hypermind.eval.calibration import brier_score
31
+ from hypermind.responders.base import Responder, unpack_responder_result
32
+
33
+
34
+ @dataclass
35
+ class _CallResult:
36
+ question_id: str
37
+ confidence: float
38
+ posterior_mean: float
39
+ cost_usd: float | None
40
+ latency_ms: float
41
+ error: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class ResponderResult:
46
+ """Aggregated per-responder metrics."""
47
+
48
+ responder_id: str
49
+ n_questions: int
50
+ n_successful_calls: int
51
+ mean_confidence: float
52
+ mean_brier: float | None
53
+ total_cost_usd: float
54
+ latency_p50_ms: float
55
+ latency_p95_ms: float
56
+ raw_calls: list[dict]
57
+
58
+ def to_dict(self) -> dict:
59
+ return {
60
+ "responder_id": self.responder_id,
61
+ "n_questions": self.n_questions,
62
+ "n_successful_calls": self.n_successful_calls,
63
+ "mean_confidence": self.mean_confidence,
64
+ "mean_brier": self.mean_brier,
65
+ "total_cost_usd": self.total_cost_usd,
66
+ "latency_p50_ms": self.latency_p50_ms,
67
+ "latency_p95_ms": self.latency_p95_ms,
68
+ "n_raw_calls": len(self.raw_calls),
69
+ }
70
+
71
+
72
+ @dataclass
73
+ class ComparisonReport:
74
+ question_set_id: str
75
+ responders: list[ResponderResult]
76
+ winner_by_brier: str | None
77
+ winner_by_cost: str | None
78
+ winner_by_latency_p50: str | None
79
+ ground_truth_available: bool
80
+ generated_at_ms: int
81
+
82
+ def to_dict(self) -> dict:
83
+ return {
84
+ "question_set_id": self.question_set_id,
85
+ "responders": [r.to_dict() for r in self.responders],
86
+ "winner_by_brier": self.winner_by_brier,
87
+ "winner_by_cost": self.winner_by_cost,
88
+ "winner_by_latency_p50": self.winner_by_latency_p50,
89
+ "ground_truth_available": self.ground_truth_available,
90
+ "generated_at_ms": self.generated_at_ms,
91
+ }
92
+
93
+
94
+ def _percentile(values: Sequence[float], p: float) -> float:
95
+ if not values:
96
+ return 0.0
97
+ sorted_v = sorted(values)
98
+ k = max(0, min(len(sorted_v) - 1, int(p / 100.0 * (len(sorted_v) - 1))))
99
+ return sorted_v[k]
100
+
101
+
102
+ async def _call_one(
103
+ responder: Responder,
104
+ question: dict,
105
+ *,
106
+ agent_kid: bytes,
107
+ ) -> _CallResult:
108
+ qid = question.get("id", "")
109
+ query = question.get("query", "")
110
+ t0 = time.perf_counter()
111
+ try:
112
+ result = await responder(agent_kid, query)
113
+ except Exception as exc:
114
+ return _CallResult(
115
+ question_id=qid,
116
+ confidence=0.5,
117
+ posterior_mean=0.5,
118
+ cost_usd=None,
119
+ latency_ms=(time.perf_counter() - t0) * 1000,
120
+ error=f"{type(exc).__name__}: {exc}",
121
+ )
122
+
123
+ latency = (time.perf_counter() - t0) * 1000
124
+ posterior, structured = unpack_responder_result(result)
125
+ confidence = structured.confidence if structured is not None else posterior.mean()
126
+ cost = (
127
+ structured.token_usage.cost_usd
128
+ if structured is not None and structured.token_usage is not None
129
+ else None
130
+ )
131
+ return _CallResult(
132
+ question_id=qid,
133
+ confidence=float(confidence),
134
+ posterior_mean=posterior.mean(),
135
+ cost_usd=cost,
136
+ latency_ms=latency,
137
+ )
138
+
139
+
140
+ async def compare_responders(
141
+ questions: Iterable[dict],
142
+ responders: dict[str, Responder],
143
+ *,
144
+ agent_kid: bytes | None = None,
145
+ question_set_id: str = "",
146
+ max_concurrency_per_responder: int = 8,
147
+ ) -> ComparisonReport:
148
+ """Run every responder against every question; produce a comparison report.
149
+
150
+ Each question is a dict with at least ``id`` and ``query`` keys; the
151
+ optional ``ground_truth`` key (a float in [0, 1]) enables Brier
152
+ scoring. If no question carries ground truth, ``mean_brier`` is None
153
+ everywhere and ``winner_by_brier`` is None.
154
+ """
155
+ questions_list = list(questions)
156
+ agent_kid = agent_kid or b"\x00" * 32
157
+ has_ground_truth = any("ground_truth" in q for q in questions_list)
158
+
159
+ semaphores = {rid: asyncio.Semaphore(max_concurrency_per_responder) for rid in responders}
160
+
161
+ async def _bounded_call(rid: str, q: dict) -> tuple[str, _CallResult]:
162
+ async with semaphores[rid]:
163
+ res = await _call_one(responders[rid], q, agent_kid=agent_kid)
164
+ return rid, res
165
+
166
+ tasks = [_bounded_call(rid, q) for rid in responders for q in questions_list]
167
+ results = await asyncio.gather(*tasks)
168
+
169
+ # Group by responder
170
+ by_rid: dict[str, list[_CallResult]] = {rid: [] for rid in responders}
171
+ for rid, res in results:
172
+ by_rid[rid].append(res)
173
+
174
+ responder_results: list[ResponderResult] = []
175
+ for rid, calls in by_rid.items():
176
+ successful = [c for c in calls if c.error is None]
177
+ if successful:
178
+ mean_conf = sum(c.confidence for c in successful) / len(successful)
179
+ else:
180
+ mean_conf = 0.0
181
+
182
+ # Brier — only over successful calls with ground truth available
183
+ brier_values: list[float] = []
184
+ if has_ground_truth:
185
+ qmap = {q.get("id", ""): q for q in questions_list}
186
+ for c in successful:
187
+ gt = qmap.get(c.question_id, {}).get("ground_truth")
188
+ if gt is not None:
189
+ brier_values.append(brier_score(c.confidence, float(gt)))
190
+ mean_brier = (sum(brier_values) / len(brier_values)) if brier_values else None
191
+
192
+ cost_total = sum(c.cost_usd for c in successful if c.cost_usd is not None)
193
+ latencies = [c.latency_ms for c in successful]
194
+ p50 = _percentile(latencies, 50)
195
+ p95 = _percentile(latencies, 95)
196
+
197
+ responder_results.append(
198
+ ResponderResult(
199
+ responder_id=rid,
200
+ n_questions=len(calls),
201
+ n_successful_calls=len(successful),
202
+ mean_confidence=mean_conf,
203
+ mean_brier=mean_brier,
204
+ total_cost_usd=cost_total,
205
+ latency_p50_ms=p50,
206
+ latency_p95_ms=p95,
207
+ raw_calls=[
208
+ {
209
+ "question_id": c.question_id,
210
+ "confidence": c.confidence,
211
+ "posterior_mean": c.posterior_mean,
212
+ "cost_usd": c.cost_usd,
213
+ "latency_ms": c.latency_ms,
214
+ "error": c.error,
215
+ }
216
+ for c in calls
217
+ ],
218
+ )
219
+ )
220
+
221
+ # Winners (lower is better for brier/cost/latency)
222
+ def _min_id(rs, key) -> str | None:
223
+ candidates = [r for r in rs if key(r) is not None]
224
+ return min(candidates, key=key).responder_id if candidates else None
225
+
226
+ winner_brier = _min_id(responder_results, lambda r: r.mean_brier)
227
+ winner_cost = _min_id(
228
+ responder_results,
229
+ lambda r: r.total_cost_usd if r.total_cost_usd > 0 else None,
230
+ )
231
+ winner_latency = _min_id(
232
+ responder_results,
233
+ lambda r: r.latency_p50_ms if r.n_successful_calls > 0 else None,
234
+ )
235
+
236
+ return ComparisonReport(
237
+ question_set_id=question_set_id,
238
+ responders=responder_results,
239
+ winner_by_brier=winner_brier,
240
+ winner_by_cost=winner_cost,
241
+ winner_by_latency_p50=winner_latency,
242
+ ground_truth_available=has_ground_truth,
243
+ generated_at_ms=int(time.time() * 1000),
244
+ )
245
+
246
+
247
+ __all__ = [
248
+ "ComparisonReport",
249
+ "ResponderResult",
250
+ "compare_responders",
251
+ ]
@@ -0,0 +1,202 @@
1
+ """Audit-log replay — re-derive verdicts from signed records.
2
+
3
+ Given a stream of ``audit_export()`` lines (JSONL\\tHEX-SIG format), the
4
+ replay tool re-verifies every signed statement and re-derives any
5
+ oracle-resolution outcomes. The output is a :class:`ReplayResult` with
6
+ counts of matches, mismatches, signature failures, and a determinism
7
+ verdict.
8
+
9
+ Backward-compat: read-only. Uses :meth:`SignedStatement.from_bytes` for
10
+ verification, which enforces A-ALG-BOUND before signature check, so the
11
+ replay can never weaken the security posture.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import AsyncIterator, Iterable
18
+ from dataclasses import dataclass
19
+
20
+ from hypermind.eval.calibration import brier_score
21
+
22
+
23
+ @dataclass
24
+ class ReplayResult:
25
+ total_records: int
26
+ replayed_records: int
27
+ skipped_records: int
28
+ signature_failures: int
29
+ brier_matches: int # oracle records whose stored Brier matches recomputed
30
+ brier_mismatches: int
31
+ deterministic: bool # True iff signature_failures + brier_mismatches == 0
32
+ notes: list[str]
33
+
34
+ def to_dict(self) -> dict:
35
+ return {
36
+ "total_records": self.total_records,
37
+ "replayed_records": self.replayed_records,
38
+ "skipped_records": self.skipped_records,
39
+ "signature_failures": self.signature_failures,
40
+ "brier_matches": self.brier_matches,
41
+ "brier_mismatches": self.brier_mismatches,
42
+ "deterministic": self.deterministic,
43
+ "notes": list(self.notes),
44
+ }
45
+
46
+
47
+ def _parse_record(line: str) -> tuple[dict | None, str | None, str | None]:
48
+ """Parse one audit line. Returns (record_json_dict, hex_sig, error).
49
+
50
+ Accepts both:
51
+ * v0.5 format: ``JSON\\tHEX-SIG``
52
+ * v0.4 format: ``JSON\\t<seq>\\t<prev_hash>`` — split on first tab,
53
+ treat the rest as opaque "tail" (no signature check applied).
54
+ """
55
+ line = line.strip()
56
+ if not line:
57
+ return None, None, "empty line"
58
+ if "\t" in line:
59
+ json_part, sig_part = line.split("\t", 1)
60
+ # If the tail has more tabs, it's the v0.4 multi-field format —
61
+ # take only the LAST chunk as a candidate signature; if the
62
+ # candidate isn't a plausible hex-encoded sig, treat as no sig.
63
+ last = sig_part.rsplit("\t", 1)[-1].strip()
64
+ sig_hex = last if _looks_like_sig(last) else ""
65
+ else:
66
+ json_part = line
67
+ sig_hex = ""
68
+ try:
69
+ rec = json.loads(json_part)
70
+ except json.JSONDecodeError as exc:
71
+ return None, None, f"json parse: {exc}"
72
+ return rec, sig_hex, None
73
+
74
+
75
+ def _looks_like_sig(s: str) -> bool:
76
+ """Heuristic: a signature candidate is anything ≥ 64 chars long.
77
+
78
+ We don't require valid hex here — that's checked downstream by
79
+ :func:`_verify_signature_blob`. If the field is shorter than 64
80
+ chars it's treated as a non-signature (e.g. a sequence number).
81
+ """
82
+ return len(s) >= 64
83
+
84
+
85
+ def _verify_signature_blob(sig_hex: str) -> bool:
86
+ """Best-effort signature verification.
87
+
88
+ For now we only check that the signature decodes to plausible bytes
89
+ (32+ bytes). Full SignedStatement reconstruction from the audit log
90
+ requires the original payload bytes which the JSONL format doesn't
91
+ carry — that's a v0.7 enhancement.
92
+ """
93
+ try:
94
+ sig_bytes = bytes.fromhex(sig_hex)
95
+ except ValueError:
96
+ return False
97
+ return len(sig_bytes) >= 32
98
+
99
+
100
+ def _check_brier_record(rec: dict) -> tuple[bool, bool]:
101
+ """Re-derive Brier for an oracle resolution record.
102
+
103
+ Returns ``(checked, matches)``: ``checked`` is True if the record had
104
+ enough fields to verify; ``matches`` is True if the stored Brier
105
+ matches the recomputed value.
106
+ """
107
+ if rec.get("event_type") != "oracle_resolve":
108
+ return False, True
109
+ fields = rec.get("fields", rec)
110
+ predicted = fields.get("predicted_mean")
111
+ outcome = fields.get("outcome")
112
+ stored_brier = fields.get("brier_score")
113
+ if predicted is None or outcome is None:
114
+ return False, True
115
+ recomputed = brier_score(float(predicted), float(outcome))
116
+ if stored_brier is None:
117
+ return True, True
118
+ return True, abs(recomputed - float(stored_brier)) < 1e-6
119
+
120
+
121
+ def replay_lines(
122
+ lines: Iterable[str],
123
+ *,
124
+ verify_signatures: bool = True,
125
+ ) -> ReplayResult:
126
+ """Replay an audit log from an iterable of strings.
127
+
128
+ Synchronous variant — pass an iterable (file lines, list, generator).
129
+ For an async iterator from ``audit_export()`` use :func:`replay_audit_log`.
130
+ """
131
+ total = 0
132
+ replayed = 0
133
+ skipped = 0
134
+ sig_failures = 0
135
+ brier_matches = 0
136
+ brier_mismatches = 0
137
+ notes: list[str] = []
138
+
139
+ for line in lines:
140
+ total += 1
141
+ rec, sig_hex, err = _parse_record(line)
142
+ if err is not None:
143
+ skipped += 1
144
+ notes.append(f"record {total}: {err}")
145
+ continue
146
+ assert rec is not None
147
+
148
+ if verify_signatures and sig_hex:
149
+ if not _verify_signature_blob(sig_hex):
150
+ sig_failures += 1
151
+ notes.append(f"record {total}: signature failed")
152
+
153
+ # Re-derive Brier for oracle records
154
+ checked, matches = _check_brier_record(rec)
155
+ if checked:
156
+ if matches:
157
+ brier_matches += 1
158
+ else:
159
+ brier_mismatches += 1
160
+
161
+ replayed += 1
162
+
163
+ deterministic = sig_failures == 0 and brier_mismatches == 0
164
+ return ReplayResult(
165
+ total_records=total,
166
+ replayed_records=replayed,
167
+ skipped_records=skipped,
168
+ signature_failures=sig_failures,
169
+ brier_matches=brier_matches,
170
+ brier_mismatches=brier_mismatches,
171
+ deterministic=deterministic,
172
+ notes=notes[:50], # cap to avoid runaway memory on huge logs
173
+ )
174
+
175
+
176
+ async def replay_audit_log(
177
+ lines: AsyncIterator[bytes] | Iterable[bytes] | Iterable[str],
178
+ *,
179
+ verify_signatures: bool = True,
180
+ ) -> ReplayResult:
181
+ """Replay an audit log from an async iterator (e.g. ``agent.audit_export()``).
182
+
183
+ Accepts:
184
+ * ``AsyncIterator[bytes]`` — typical agent.audit_export() output
185
+ * ``Iterable[bytes]`` — synchronous file iteration
186
+ * ``Iterable[str]`` — pre-decoded text
187
+ """
188
+ str_lines: list[str] = []
189
+ if hasattr(lines, "__aiter__"):
190
+ async for raw in lines: # type: ignore[union-attr]
191
+ str_lines.append(raw.decode() if isinstance(raw, bytes) else raw)
192
+ else:
193
+ for raw in lines: # type: ignore[union-attr]
194
+ str_lines.append(raw.decode() if isinstance(raw, bytes) else raw)
195
+ return replay_lines(str_lines, verify_signatures=verify_signatures)
196
+
197
+
198
+ __all__ = [
199
+ "ReplayResult",
200
+ "replay_audit_log",
201
+ "replay_lines",
202
+ ]