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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
hypermind/eval/replay.py
ADDED
|
@@ -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
|
+
]
|