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
hypermind/agent.py ADDED
@@ -0,0 +1,2896 @@
1
+ """HyperMindAgent — the primary user-facing class.
2
+
3
+ v0.1 in-process implementation. Real deployments swap the GossipBus and
4
+ TransparencyService instances for libp2p + a real SCITT TS; the public
5
+ API is unchanged.
6
+
7
+ This class implements:
8
+ - publish_claim / subscribe / on_dispute / dispute / synthesize
9
+ - consult (panel selection + aggregation)
10
+ - reputation_of / oracle_resolve / audit_export
11
+ - introspect / why_blocked / discover_mentors / request_vouch
12
+ - working_memory / checkpoint
13
+ - sandbox testbed factory
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import hashlib
20
+ import json
21
+ import secrets
22
+ import time
23
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
24
+ from dataclasses import dataclass, field
25
+ from datetime import timedelta
26
+ from typing import Any, Literal
27
+
28
+ import cbor2
29
+
30
+ # L4 separability hook (spec §23): when an encrypted envelope arrives and
31
+ # HPKE decrypt fails, the agent MUST still verify the signed metadata and
32
+ # emit a structured BlockReason("PAYLOAD-OPAQUE"), preserving the audit trail.
33
+ from hypermind.agent_card import AgentCard
34
+ from hypermind.capability import CapToken, TokenBucket
35
+ from hypermind.consult import (
36
+ ArgumentDAG,
37
+ ArgumentEdge,
38
+ CalibTruthSnapshot,
39
+ ConsultResult,
40
+ PanelMember,
41
+ RouteStrategy,
42
+ build_posterior_mixture,
43
+ select_panel,
44
+ )
45
+ from hypermind.contract_net import (
46
+ RFP,
47
+ Award,
48
+ Bid,
49
+ BidScorer,
50
+ ReputationWeightedScorer,
51
+ award_preimage,
52
+ passes_bidder_filter,
53
+ topic_scope_for_rfp,
54
+ )
55
+ from hypermind.crypto.hashing import blake3_32
56
+ from hypermind.crypto.hlc import HLC
57
+ from hypermind.crypto.signing import ALG_ED25519, ALG_MLDSA65_ED25519, Keypair
58
+ from hypermind.dispute import Dispute
59
+ from hypermind.errors import (
60
+ AuthError,
61
+ BlockReason,
62
+ DisputeError,
63
+ HyperMindError,
64
+ ProtocolVersionError,
65
+ RampError,
66
+ RateLimitError,
67
+ )
68
+ from hypermind.knowledge.reputation import ReputationKernel, ReputationSnapshot
69
+ from hypermind.knowledge.transparency import (
70
+ TransparencyService,
71
+ WitnessCosignature,
72
+ )
73
+ from hypermind.observability.recorder import get_recorder
74
+ from hypermind.pin_policy import PinPolicy
75
+ from hypermind.revocation import InMemoryRevocationStore, RevocationStore
76
+ from hypermind.transport.bus import GossipBus, InProcessBus, Subscription
77
+ from hypermind.uncertainty import Uncertainty
78
+ from hypermind.wire import (
79
+ EncryptedStatement,
80
+ Kid,
81
+ SignedStatement,
82
+ new_namespace_id,
83
+ )
84
+
85
+ ClaimType = Literal[
86
+ "eval_result",
87
+ "threat_indicator",
88
+ "synthesis",
89
+ "oracle_resolution",
90
+ "custom",
91
+ "task_submit",
92
+ "task_complete",
93
+ "task_fail",
94
+ "agent_card",
95
+ ]
96
+
97
+
98
+ # -----------------------------------------------------------------------
99
+ # BlockReason is defined in `hypermind.errors` (re-exported above) so that
100
+ # every `HyperMindError` may carry one as `reason=`. See errors.py for the
101
+ # stability contract on the [rule_id] __str__ prefix.
102
+ # -----------------------------------------------------------------------
103
+
104
+
105
+ # -----------------------------------------------------------------------
106
+ # Vouch / mentor records (used by discover_mentors / request_vouch)
107
+ # -----------------------------------------------------------------------
108
+
109
+
110
+ @dataclass
111
+ class VouchRequest:
112
+ requester: bytes
113
+ mentor: bytes
114
+ room: str
115
+ issued_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
116
+
117
+
118
+ @dataclass
119
+ class RFPHandle:
120
+ """Issuer-side handle returned by :meth:`HyperMindAgent.request_for_proposal`.
121
+
122
+ Wraps the in-memory :class:`hypermind.contract_net.RFP` and offers
123
+ an awaitable :meth:`bids` helper that resolves once at least
124
+ ``min_count`` bids are recorded against the RFP.
125
+ """
126
+
127
+ rfp: Any # contract_net.RFP — annotated as Any to avoid forward-ref import cycles
128
+ agent: HyperMindAgent
129
+
130
+ @property
131
+ def rfp_id(self) -> bytes:
132
+ return self.rfp.rfp_id
133
+
134
+ async def bids(self, *, min_count: int = 1, timeout_s: float = 5.0) -> list[Any]:
135
+ """Wait for ≥ ``min_count`` bids to land, then return the list.
136
+
137
+ Polls `agent._world.rfp_bid_event`; resets the event after each
138
+ wake so multiple awaiters can each see new arrivals.
139
+ """
140
+ world = self.agent._world
141
+ if world is None:
142
+ return []
143
+ deadline = time.monotonic() + timeout_s
144
+ while True:
145
+ current = list(world.bids_by_rfp.get(self.rfp_id, []))
146
+ if len(current) >= min_count:
147
+ return current
148
+ remaining = deadline - time.monotonic()
149
+ if remaining <= 0:
150
+ return current
151
+ world.rfp_bid_event.clear()
152
+ try:
153
+ await asyncio.wait_for(world.rfp_bid_event.wait(), timeout=remaining)
154
+ except TimeoutError:
155
+ return list(world.bids_by_rfp.get(self.rfp_id, []))
156
+
157
+
158
+ @dataclass
159
+ class BidHandle:
160
+ """Bidder-side handle returned by :meth:`HyperMindAgent.bid`.
161
+
162
+ The bidder can `await handle.award(timeout_s=...)` to block until
163
+ the auctioneer publishes an AWARD against the RFP. Returns the
164
+ :class:`hypermind.contract_net.Award` if this bidder won, or `None`
165
+ if another bidder won (or the timeout fired before AWARD).
166
+ """
167
+
168
+ bid: Any
169
+ agent: HyperMindAgent
170
+
171
+ @property
172
+ def bid_kid(self) -> bytes:
173
+ return self.bid.bid_kid
174
+
175
+ async def award(self, *, timeout_s: float = 5.0) -> Any | None:
176
+ world = self.agent._world
177
+ if world is None:
178
+ return None
179
+ deadline = time.monotonic() + timeout_s
180
+ while True:
181
+ current = world.awards.get(self.bid.rfp_id)
182
+ if current is not None and not current.revoked:
183
+ return current if current.winning_bid_kid == self.bid_kid else None
184
+ remaining = deadline - time.monotonic()
185
+ if remaining <= 0:
186
+ return None
187
+ world.rfp_award_event.clear()
188
+ try:
189
+ await asyncio.wait_for(world.rfp_award_event.wait(), timeout=remaining)
190
+ except TimeoutError:
191
+ return None
192
+
193
+
194
+ @dataclass
195
+ class AwardHandle:
196
+ """Issuer-side handle returned by :meth:`HyperMindAgent.award`."""
197
+
198
+ award: Any
199
+ agent: HyperMindAgent
200
+
201
+ @property
202
+ def award_kid(self) -> bytes:
203
+ return self.award.award_kid
204
+
205
+ @property
206
+ def winning_bid_kid(self) -> bytes:
207
+ return self.award.winning_bid_kid
208
+
209
+ @property
210
+ def revoked(self) -> bool:
211
+ # Re-read the world's view; revoke_award() replaces the Award.
212
+ world = self.agent._world
213
+ if world is None:
214
+ return self.award.revoked
215
+ current = world.awards.get(self.award.rfp_id)
216
+ if current is None:
217
+ return self.award.revoked
218
+ return current.revoked
219
+
220
+
221
+ @dataclass
222
+ class _DraftEntry:
223
+ statement: SignedStatement
224
+ confirmer_acks: set[bytes] = field(default_factory=set)
225
+
226
+
227
+ # -----------------------------------------------------------------------
228
+ # Shared world (the in-process namespace).
229
+ # -----------------------------------------------------------------------
230
+
231
+
232
+ # Process-global registry of in-process namespaces (ROADMAP §0.4.1).
233
+ # Used by cross-namespace federated CONSULT to discover sibling worlds
234
+ # without callers having to thread `_NamespaceWorld` references manually.
235
+ _NAMESPACE_REGISTRY: dict[bytes, _NamespaceWorld] = {}
236
+
237
+
238
+ def _register_namespace(world: _NamespaceWorld) -> None:
239
+ _NAMESPACE_REGISTRY[bytes(world.namespace_id)] = world
240
+
241
+
242
+ def _lookup_namespace(namespace_id: bytes) -> _NamespaceWorld | None:
243
+ return _NAMESPACE_REGISTRY.get(bytes(namespace_id))
244
+
245
+
246
+ @dataclass
247
+ class _NamespaceWorld:
248
+ """All agents in the same testbed share one of these.
249
+
250
+ Holds the gossip bus, transparency service, reputation kernel, and a
251
+ central registry of agents so `discover_mentors`, `consult`, and
252
+ `reputation_of` can find peers.
253
+ """
254
+
255
+ namespace: str
256
+ namespace_id: bytes
257
+ bus: GossipBus
258
+ ts: TransparencyService
259
+ reputation: ReputationKernel
260
+ agents: dict[bytes, HyperMindAgent] = field(default_factory=dict)
261
+ sealed: dict[Kid, SignedStatement] = field(default_factory=dict)
262
+ disputes: dict[Kid, Dispute] = field(default_factory=dict)
263
+ # Panel votes recorded for ORACLE_RESOLUTION back-propagation
264
+ # (ROADMAP §0.3.2). Map claim_kid → list of (voter_kid, vote_bool,
265
+ # topic). Used by `oracle_resolve(..., backprop=True)` to reward
266
+ # voters who agreed with the oracle.
267
+ panel_votes: dict[Kid, list[tuple[bytes, bool, str]]] = field(default_factory=dict)
268
+ # rooms map name → {min reputation to publish, members}
269
+ rooms: dict[str, dict[str, Any]] = field(default_factory=dict)
270
+ #: Signalled whenever a new dispute is added to self.disputes.
271
+ dispute_event: asyncio.Event = field(default_factory=asyncio.Event)
272
+ #: Optional durable storage backend (StorageBackend). When attached,
273
+ #: every sealed claim, dispute, and reputation snapshot is mirrored
274
+ #: to disk so process restart preserves world state.
275
+ storage: Any = None
276
+ #: Optional :class:`TokenCostTracker` (v0.6+). When attached, every
277
+ #: LLM responder records its token usage there for /v1/cost endpoint.
278
+ cost_tracker: Any = None
279
+ #: Minimum acceptable `protocol_version` for inbound statements
280
+ #: (ROADMAP §0.5.1; rule_id HM-PROTO-VER-001). Statements with
281
+ #: ``protocol_version < min_protocol_version`` are rejected
282
+ #: pre-signature-verify with :class:`ProtocolVersionError`.
283
+ min_protocol_version: int = 0
284
+ #: Per-namespace Recorder (WS-G v0.7). Each sandbox/testbed gets
285
+ #: its own scoped recorder so per-namespace observability does not
286
+ #: pummel the process-global ring buffer. ``None`` = lazy-init
287
+ #: in ``__post_init__`` from ``namespace_id``.
288
+ recorder: Any = None
289
+ #: Per-namespace key revocation store (P1.4 / spec §12). Verifiers
290
+ #: consult this before accepting a CapToken or SignedStatement —
291
+ #: a revoked kid past its ``revoked_at_hlc`` raises AuthError
292
+ #: (rule_id ``HM-KEY-REVOKED-001``). Lazy-initialised to an empty
293
+ #: ``InMemoryRevocationStore`` so sandboxes have a working store
294
+ #: without explicit setup.
295
+ revocation_store: RevocationStore | None = None
296
+ #: Trust & Access Layer — Rooms + vouch log + decay. Lazy-init in
297
+ #: ``__post_init__`` so the world's ReputationKernel is fully built
298
+ #: before TAL takes a reference to it.
299
+ tal: Any = None
300
+ # T3 (Contract-Net §27) — in-memory views of open RFPs, received
301
+ # bids, and finalised awards. Each map is keyed by the canonical
302
+ # identifier of the record type:
303
+ # - rfps: rfp_id (32 bytes) → RFP
304
+ # - bids_by_rfp: rfp_id → list[Bid]
305
+ # - awards: rfp_id → Award
306
+ # The signed wire records ride on top of self.sealed; these maps
307
+ # are denormalised projections so the auctioneer can query "all
308
+ # bids for this RFP" without scanning the full sealed registry.
309
+ rfps: dict[bytes, Any] = field(default_factory=dict)
310
+ bids_by_rfp: dict[bytes, list[Any]] = field(default_factory=dict)
311
+ awards: dict[bytes, Any] = field(default_factory=dict)
312
+ #: Set when a bid is delivered for an open RFP — used by RFPHandle
313
+ #: to wake bidders waiting via `await rfp_handle.bids(...)`.
314
+ rfp_bid_event: asyncio.Event = field(default_factory=asyncio.Event)
315
+ #: Set when an AWARD lands; used by BidHandle to resolve.
316
+ rfp_award_event: asyncio.Event = field(default_factory=asyncio.Event)
317
+
318
+ def __post_init__(self) -> None:
319
+ if self.revocation_store is None:
320
+ self.revocation_store = InMemoryRevocationStore()
321
+ if self.tal is None:
322
+ from hypermind.tal import TAL
323
+
324
+ self.tal = TAL(self.reputation)
325
+ if self.recorder is None:
326
+ # Late import to avoid recorder<->agent circular import.
327
+ from hypermind.observability.recorder import Recorder
328
+
329
+ ns_tag = (
330
+ self.namespace_id.hex()
331
+ if isinstance(self.namespace_id, (bytes, bytearray))
332
+ else str(self.namespace_id)
333
+ )
334
+ self.recorder = Recorder.scoped(ns_tag)
335
+ # ROADMAP §0.4.1 — register so cross-namespace federated
336
+ # CONSULT can discover this world by namespace_id.
337
+ _register_namespace(self)
338
+ # P1-04: auto-wire gossip bus into the TS so append_and_cosign()
339
+ # fires STH cross-gossip (Gap 10) without per-agent setup.
340
+ ns_hex = (
341
+ self.namespace_id.hex()
342
+ if isinstance(self.namespace_id, (bytes, bytearray))
343
+ else str(self.namespace_id)
344
+ )
345
+ self.ts.attach_gossip(self.bus, ns_hex)
346
+ # If a durable backend is attached, replay any prior state into
347
+ # this in-memory world so restart is transparent. This is a no-op
348
+ # when storage is None (the default).
349
+ if self.storage is not None:
350
+ self._restore_from_storage()
351
+
352
+ def _restore_from_storage(self) -> None:
353
+ """Re-load claims, disputes, and reputation from the storage backend.
354
+
355
+ Idempotent: safe to call multiple times; later calls are no-ops
356
+ once state is materialised.
357
+ """
358
+ try:
359
+ for stmt in self.storage.load_claims():
360
+ self.sealed.setdefault(stmt.kid, stmt)
361
+ for kid, dispute in self.storage.load_disputes().items():
362
+ self.disputes.setdefault(kid, dispute)
363
+ for kid, topic, alpha, beta, last_ms in self.storage.load_reputation():
364
+ p = self.reputation._get(kid, topic)
365
+ p.alpha = alpha
366
+ p.beta = beta
367
+ p.last_update_ms = last_ms
368
+ except Exception:
369
+ # Restore is best-effort: a corrupt row should not prevent
370
+ # process startup. Operators should run `hmctl doctor` to
371
+ # diagnose. Real production code paths would log here.
372
+ pass
373
+
374
+ def persist_claim(self, stmt: SignedStatement) -> None:
375
+ """Mirror a sealed claim to durable storage if attached. Best-effort."""
376
+ if self.storage is None:
377
+ return
378
+ try:
379
+ self.storage.append_claim(stmt)
380
+ except Exception:
381
+ pass
382
+
383
+ def persist_dispute(self, kid: Kid, dispute: Dispute) -> None:
384
+ """Mirror a dispute to durable storage if attached. Best-effort."""
385
+ if self.storage is None:
386
+ return
387
+ try:
388
+ self.storage.append_dispute(kid, dispute)
389
+ except Exception:
390
+ pass
391
+
392
+ def checkpoint_reputation(self) -> None:
393
+ """Snapshot every Beta posterior to storage. Call before close()."""
394
+ if self.storage is None:
395
+ return
396
+ rows: list[tuple[bytes, str, float, float, int]] = []
397
+ for (kid, topic), p in self.reputation._posteriors.items():
398
+ rows.append((kid, topic, p.alpha, p.beta, p.last_update_ms))
399
+ try:
400
+ self.storage.snapshot_reputation(rows)
401
+ except Exception:
402
+ pass
403
+
404
+ def check_protocol_version(self, stmt: SignedStatement) -> None:
405
+ """Reject ``stmt`` if its protocol_version is below policy.
406
+
407
+ Called pre-signature-verify so refusal is policy-shaped, not a
408
+ crypto failure (HM-PROTO-VER-001).
409
+ """
410
+ if stmt.protocol_version < self.min_protocol_version:
411
+ raise ProtocolVersionError(
412
+ f"statement protocol_version={stmt.protocol_version} is "
413
+ f"below namespace min_protocol_version="
414
+ f"{self.min_protocol_version}",
415
+ statement_version=stmt.protocol_version,
416
+ min_required=self.min_protocol_version,
417
+ )
418
+
419
+ def start_anti_entropy(
420
+ self,
421
+ *,
422
+ interval_s: float = 30.0,
423
+ max_backoff_s: float = 300.0,
424
+ ) -> None:
425
+ """Start the self-healing AE background coroutine (spec §10.2, P2-02).
426
+
427
+ Spawns a :class:`BackgroundAELoop` that periodically reconciles this
428
+ world's sealed-claim set against all known peer worlds registered in
429
+ the global namespace registry. On failure the loop backs off
430
+ exponentially up to ``max_backoff_s`` then retries.
431
+
432
+ Safe to call multiple times — subsequent calls are no-ops if the
433
+ loop is already running.
434
+ """
435
+ from hypermind.transport.anti_entropy import BackgroundAELoop
436
+
437
+ if hasattr(self, "_ae_loop") and self._ae_loop.running: # type: ignore[has-type]
438
+ return
439
+
440
+ async def _run_ae() -> None:
441
+ local_kids = set(self.sealed.keys())
442
+ for ns_id, remote_world in _NAMESPACE_REGISTRY.items():
443
+ if ns_id == self.namespace_id:
444
+ continue
445
+ remote_kids = set(remote_world.sealed.keys())
446
+ # Local-only: we have something the remote doesn't — push it.
447
+ for kid in local_kids - remote_kids:
448
+ stmt = self.sealed.get(kid)
449
+ if stmt is not None:
450
+ remote_world.sealed.setdefault(kid, stmt)
451
+ # Remote-only: pull it locally.
452
+ for kid in remote_kids - local_kids:
453
+ stmt = remote_world.sealed.get(kid)
454
+ if stmt is not None:
455
+ self.sealed.setdefault(kid, stmt)
456
+
457
+ self._ae_loop: BackgroundAELoop = BackgroundAELoop(
458
+ run_ae=_run_ae,
459
+ interval_s=interval_s,
460
+ max_backoff_s=max_backoff_s,
461
+ )
462
+ self._ae_loop.start()
463
+
464
+
465
+ # -----------------------------------------------------------------------
466
+ # HyperMindAgent
467
+ # -----------------------------------------------------------------------
468
+
469
+
470
+ class HyperMindAgent:
471
+ """Async-first entry point for the HyperMind protocol SDK (spec §3).
472
+
473
+ Every public verb is ``async def``. For callers that cannot use ``await``,
474
+ :class:`hypermind.sync.SyncAgent` wraps the same surface synchronously.
475
+ The preferred factory for new code is ``await hypermind.open(mode="sandbox")``;
476
+ for multi-agent tests use :meth:`testbed_cohort` as an async context manager.
477
+
478
+ Examples:
479
+ Single agent (sandbox):
480
+
481
+ >>> import hypermind
482
+ >>> async with await hypermind.open(mode="sandbox") as agent:
483
+ ... kid = await agent.publish_claim(
484
+ ... topic="ai-safety/eval",
485
+ ... payload={"score": 0.91},
486
+ ... )
487
+
488
+ Multi-agent testbed:
489
+
490
+ >>> from hypermind.testing import testbed_cohort
491
+ >>> async with testbed_cohort(3) as (alice, bob, carol):
492
+ ... result = await alice.consult(
493
+ ... topic="threat-intel",
494
+ ... question="Is this indicator benign?",
495
+ ... panel=[bob.keypair.kid, carol.keypair.kid],
496
+ ... )
497
+ """
498
+
499
+ def __init__(
500
+ self,
501
+ *,
502
+ keypair: Keypair,
503
+ namespace: str,
504
+ rooms: list[str] | None = None,
505
+ pin_policy: PinPolicy | None = None,
506
+ bootstraps: list[str] | None = None,
507
+ hlc_skew_max: timedelta = timedelta(seconds=2),
508
+ sandbox: bool = False,
509
+ cap_token: CapToken | None = None,
510
+ _world: _NamespaceWorld | None = None,
511
+ role: Literal["agent", "oracle", "pinner"] = "agent",
512
+ ) -> None:
513
+ self.keypair = keypair
514
+ self.namespace = namespace
515
+ self.rooms = list(rooms or [])
516
+ self.pin_policy = pin_policy
517
+ self.bootstraps = list(bootstraps or [])
518
+ self.hlc_skew_max = hlc_skew_max
519
+ self.is_sandbox = sandbox
520
+ self.cap_token = cap_token
521
+ self.role = role
522
+ self._memory: Any = None # optional RelataMemoryStore (P1); see attach_memory
523
+ self._closed = False
524
+ self._world = _world # in-process namespace; injected by testbed()
525
+ self._hlc = HLC.now(node_id=keypair.kid)
526
+ self._dispute_callbacks: dict[Kid, list[Callable[[Dispute], Any]]] = {}
527
+ self._subscriptions: list[Subscription] = []
528
+ self._working_memory: dict[str, Any] = {}
529
+ # P1-03: per-(root_kid + action) token buckets for rate-caveat enforcement.
530
+ self._rate_buckets: dict[str, Any] = {}
531
+ # T5-02: optional placement hint biasing topic→host selection.
532
+ # See `HyperMindAgent.place_on()`.
533
+ self._placement_hint: str | None = None
534
+ # 30-day ramp accounting (see RAMP-* invariants)
535
+ self._ramp_start_ms = int(time.time() * 1000)
536
+ self._ramp_paused = False
537
+ self._ramp_paused_days = 0
538
+ self._observed_outcomes = 0 # progresses ramp toward "active"
539
+ # WS-G v0.7: prefer the world's per-namespace recorder when one
540
+ # exists; fall back to the process-global recorder for no-world
541
+ # (unit-test) agents so existing tests keep their semantics.
542
+ if self._world is not None and getattr(self._world, "recorder", None) is not None:
543
+ self._recorder = self._world.recorder
544
+ else:
545
+ self._recorder = get_recorder()
546
+
547
+ if self._world is None:
548
+ # No-network mode: the agent is callable but cannot publish
549
+ # to remote peers. Useful for unit-testing crypto/wire.
550
+ return
551
+ self._world.agents[keypair.kid] = self
552
+
553
+ def attach_storage(self, backend: Any) -> None:
554
+ """Attach a durable :class:`~hypermind.storage.backend.StorageBackend`
555
+ and replay any prior state into this world.
556
+
557
+ Prefer this over poking ``agent._world.storage`` directly — it also
558
+ fires the crash-recovery replay so a restart is transparent. Call once
559
+ after construction; the backend must already be ``open()``-ed.
560
+ """
561
+ if self._world is None:
562
+ raise RuntimeError(
563
+ "attach_storage() requires a networked world; this agent was "
564
+ "constructed in no-network mode."
565
+ )
566
+ self._world.storage = backend
567
+ self._world._restore_from_storage()
568
+
569
+ def attach_memory(self, store: Any) -> None:
570
+ """Attach a per-agent private memory store (P1 — path b).
571
+
572
+ The store (e.g. :class:`~hypermind.mind.memory_store.RelataMemoryStore`)
573
+ must already be ``open()``-ed. This is the agent's *unsigned* scratchpad
574
+ of learnings/experiences — distinct from the signed ledger attached via
575
+ :meth:`attach_storage`. Enables :meth:`remember` / :meth:`recall`.
576
+ """
577
+ self._memory = store
578
+
579
+ async def remember(
580
+ self, content: str, *, confidence: float = 1.0, memory_class: str = "semantic"
581
+ ) -> str:
582
+ """Store a learning/experience in private memory; return its id.
583
+
584
+ Requires a memory store attached via :meth:`attach_memory`.
585
+ """
586
+ if self._memory is None:
587
+ raise RuntimeError(
588
+ "remember() requires a memory store; call attach_memory() first."
589
+ )
590
+ return self._memory.remember(content, confidence=confidence, memory_class=memory_class)
591
+
592
+ async def remember_batch(
593
+ self,
594
+ items: list,
595
+ *,
596
+ session_id: str | None = None,
597
+ ) -> list[str]:
598
+ """Batch store learnings; return ids index-aligned with ``items``.
599
+
600
+ Requires a memory store attached via :meth:`attach_memory`.
601
+ Each item is a ``str`` or a ``dict`` with ``content`` + optional fields.
602
+ """
603
+ if self._memory is None:
604
+ raise RuntimeError(
605
+ "remember_batch() requires a memory store; call attach_memory() first."
606
+ )
607
+ return self._memory.remember_batch(items, session_id=session_id)
608
+
609
+ async def recall(
610
+ self, query: str, *, top_k: int = 5, as_of: str | None = None
611
+ ) -> list[dict[str, Any]]:
612
+ """Recall from private memory. Pass ``as_of`` for bi-temporal recall
613
+ ("what did I know at T?"). Requires :meth:`attach_memory`.
614
+ """
615
+ if self._memory is None:
616
+ raise RuntimeError(
617
+ "recall() requires a memory store; call attach_memory() first."
618
+ )
619
+ return self._memory.recall(query, top_k=top_k, as_of=as_of)
620
+
621
+ # ---- factories ------------------------------------------------------
622
+
623
+ @classmethod
624
+ async def open(
625
+ cls,
626
+ *,
627
+ mode: Literal["sandbox", "libp2p", "production"] = "sandbox",
628
+ namespace: str = "default",
629
+ pq: bool = True,
630
+ legacy_ed25519: bool = False,
631
+ **kwargs: Any,
632
+ ) -> HyperMindAgent:
633
+ """Canonical factory — single entry point for v1.0.
634
+
635
+ `mode="sandbox"` (default): in-process bus, single agent, suitable
636
+ for demos and tests.
637
+ `mode="libp2p"`: production federation transport (lands v0.2 —
638
+ currently raises NotImplementedError).
639
+
640
+ ``pq`` (default True, v0.7+): generate a Composite Ed25519 +
641
+ ML-DSA-65 keypair so this agent's outbound traffic is
642
+ post-quantum-secure under hybrid composition. Pass
643
+ ``legacy_ed25519=True`` to force the classical-only path
644
+ (emits ``DeprecationWarning``); ``pq=False`` is treated as a
645
+ synonym.
646
+
647
+ Use this instead of `from_env()` (deprecated) or `sandbox()` (kept
648
+ for ≤5-line demos).
649
+ """
650
+ if legacy_ed25519:
651
+ pq = False
652
+ if not pq:
653
+ import warnings as _w
654
+
655
+ _w.warn(
656
+ "classical-only signatures will be the legacy path from v0.7; "
657
+ "use pq=True for hybrid",
658
+ DeprecationWarning,
659
+ stacklevel=2,
660
+ )
661
+ if mode == "sandbox":
662
+ return cls.sandbox(namespace=namespace, pq=pq, **kwargs)
663
+ elif mode == "production":
664
+ # In-process agent + a durable backend resolved from the env.
665
+ # RelataDB is the default when configured (RELATA_URL /
666
+ # HYPERMIND_STORAGE); falls back to SQLite, then in-memory.
667
+ from hypermind.storage import storage_from_env
668
+
669
+ agent = cls.sandbox(namespace=namespace, pq=pq, **kwargs)
670
+ backend = storage_from_env(namespace_id=namespace)
671
+ if backend is not None:
672
+ backend.open()
673
+ agent.attach_storage(backend)
674
+ else:
675
+ import warnings as _w
676
+
677
+ _w.warn(
678
+ "mode='production' but no storage configured; running "
679
+ "in-memory. Set RELATA_URL (RelataDB) or "
680
+ "HYPERMIND_STORAGE=sqlite:/path for durability.",
681
+ stacklevel=2,
682
+ )
683
+ return agent
684
+ elif mode == "libp2p":
685
+ # T5-01: the libp2p path is feature-flagged on the optional
686
+ # `hypermind_libp2p` PyO3 binding (companion crate in
687
+ # ``transport-rust/``). If the binding is not installed,
688
+ # surface a clear, actionable error pointing at the install
689
+ # extra rather than constructing a bus that would later
690
+ # raise on every method call.
691
+ from hypermind.transport.libp2p import (
692
+ Libp2pBackendNotInstalled,
693
+ Libp2pBus,
694
+ backend_available,
695
+ )
696
+
697
+ if not backend_available():
698
+ raise Libp2pBackendNotInstalled()
699
+ Libp2pBus(**kwargs) # pragma: no cover (binding not yet on PyPI)
700
+ raise NotImplementedError( # pragma: no cover
701
+ "libp2p backend wiring lands with the hypermind-libp2p "
702
+ "binding (v0.10.1+); construct a HyperMindAgent around "
703
+ "the bus directly until then"
704
+ )
705
+ else:
706
+ raise ValueError(f"Unknown mode={mode!r}; expected 'sandbox' or 'libp2p'")
707
+
708
+ @classmethod
709
+ def from_env(
710
+ cls,
711
+ *,
712
+ rooms: list[str] | None = None,
713
+ sandbox: bool = False,
714
+ pq: bool = True,
715
+ legacy_ed25519: bool = False,
716
+ ) -> HyperMindAgent:
717
+ """Production entry point.
718
+
719
+ v0.1 ships only the sandbox (in-process) backend. The libp2p
720
+ production backend ships in v0.2; see the roadmap for status.
721
+
722
+ For local development, prefer `HyperMindAgent.sandbox()` (single
723
+ agent) or `HyperMindAgent.testbed()` (cohort) — both are clearer
724
+ than `from_env(sandbox=True)`.
725
+
726
+ .. deprecated:: 0.7
727
+ Use :meth:`HyperMindAgent.open` instead.
728
+ """
729
+ import warnings
730
+
731
+ warnings.warn(
732
+ "HyperMindAgent.from_env() is deprecated; use "
733
+ "HyperMindAgent.open(mode=...) instead. "
734
+ "See docs/migration/v0.7.md.",
735
+ PendingDeprecationWarning,
736
+ stacklevel=2,
737
+ )
738
+ if not sandbox:
739
+ raise NotImplementedError(
740
+ "Production transport not yet available in v0.1.\n"
741
+ " • For a single local agent: HyperMindAgent.sandbox()\n"
742
+ " • For a multi-agent cohort: HyperMindAgent.testbed(n=...)\n"
743
+ " • The libp2p production backend ships in v0.2."
744
+ )
745
+ if legacy_ed25519:
746
+ pq = False
747
+ return cls.sandbox(
748
+ room=(rooms[0] if rooms else "default"),
749
+ pq=pq,
750
+ )
751
+
752
+ @classmethod
753
+ def sandbox(
754
+ cls,
755
+ *,
756
+ room: str = "default",
757
+ namespace: str = "sandbox",
758
+ ramp_complete: bool = True,
759
+ pq: bool = True,
760
+ legacy_ed25519: bool = False,
761
+ ) -> HyperMindAgent:
762
+ """Build a SINGLE pre-vouched agent in a sandbox namespace.
763
+
764
+ The most common starter pattern — use this when you want one
765
+ local agent to experiment with. Returns an open agent (use
766
+ `async with` to clean up).
767
+
768
+ For multi-agent scenarios use `HyperMindAgent.testbed(n)`; for
769
+ async-friendly cohort cleanup use `hypermind.testbed_cohort(n)`.
770
+
771
+ ``pq`` (default True, v0.7+): generate a Composite Ed25519 +
772
+ ML-DSA-65 keypair. Pass ``legacy_ed25519=True`` (or ``pq=False``)
773
+ to fall back to the classical-only path; that path emits a
774
+ ``DeprecationWarning`` since v0.7.
775
+ """
776
+ if legacy_ed25519:
777
+ pq = False
778
+ return cls.testbed(
779
+ agents=1,
780
+ room=room,
781
+ namespace=namespace,
782
+ ramp_complete=ramp_complete,
783
+ pq=pq,
784
+ )[0]
785
+
786
+ @staticmethod
787
+ def testbed(
788
+ agents: int = 2,
789
+ *,
790
+ room: str = "default",
791
+ namespace: str = "sandbox",
792
+ ramp_complete: bool = True,
793
+ pq: bool = True,
794
+ legacy_ed25519: bool = False,
795
+ recorder: Any = None,
796
+ ) -> list[HyperMindAgent]:
797
+ """Build N pre-vouched agents sharing a namespace.
798
+
799
+ Used by tests and by `hmctl sandbox cohort`. Returned agents are
800
+ already past the 30-day ramp (so `publish_claim` works
801
+ immediately). Set `ramp_complete=False` to test ramp gates.
802
+
803
+ ``pq`` (default True, v0.7+): mint Composite ML-DSA-65 + Ed25519
804
+ keypairs for both witnesses and agents. Pass
805
+ ``legacy_ed25519=True`` (or ``pq=False``) for the classical-only
806
+ path; that path emits a ``DeprecationWarning``.
807
+
808
+ .. deprecated:: 0.5
809
+ External callers should migrate to
810
+ :func:`hypermind.testing.testbed_cohort` (an async-with cohort
811
+ with automatic cleanup). The list-returning factory keeps
812
+ working for now and is scheduled for removal in v1.0.
813
+ """
814
+ # ROADMAP §0.5.4: external callers see DeprecationWarning; SDK-internal
815
+ # callers (sandbox(), testbed_cohort()) suppress it so demos stay clean.
816
+ import sys as _sys
817
+ import warnings as _warnings
818
+
819
+ frame = _sys._getframe(1)
820
+ caller_module = frame.f_globals.get("__name__", "")
821
+ if not caller_module.startswith("hypermind."):
822
+ _warnings.warn(
823
+ "HyperMindAgent.testbed(...) returning a plain list is "
824
+ "deprecated; use hypermind.testing.testbed_cohort(n=...) "
825
+ "for an async-with cohort with automatic cleanup. "
826
+ "The list factory is scheduled for removal in v1.0.",
827
+ DeprecationWarning,
828
+ stacklevel=2,
829
+ )
830
+
831
+ if legacy_ed25519:
832
+ pq = False
833
+ if not pq:
834
+ import warnings as _w
835
+
836
+ _w.warn(
837
+ "classical-only signatures will be the legacy path from v0.7; "
838
+ "use pq=True for hybrid",
839
+ DeprecationWarning,
840
+ stacklevel=2,
841
+ )
842
+ kp_alg = ALG_MLDSA65_ED25519 if pq else ALG_ED25519
843
+ world = _NamespaceWorld(
844
+ namespace=namespace,
845
+ namespace_id=new_namespace_id(),
846
+ bus=InProcessBus(),
847
+ ts=TransparencyService(name=f"{namespace}-ts"),
848
+ reputation=ReputationKernel(),
849
+ # I0: caller-supplied scoped recorder; lazy-init falls back to namespace_id
850
+ recorder=recorder,
851
+ )
852
+ # Mint witness cohort (3 witnesses for the sandbox).
853
+ world.ts.witness_keypairs = [Keypair.generate(alg=kp_alg) for _ in range(3)]
854
+ world.ts.min_witness_quorum = 1 # sandbox quorum
855
+
856
+ agents_list: list[HyperMindAgent] = []
857
+ for _ in range(agents):
858
+ kp = Keypair.generate(alg=kp_alg)
859
+ agent = HyperMindAgent(
860
+ keypair=kp,
861
+ namespace=namespace,
862
+ rooms=[room],
863
+ pin_policy=None,
864
+ sandbox=True,
865
+ _world=world,
866
+ )
867
+ if ramp_complete:
868
+ # Bootstrap reputation above threshold.
869
+ world.reputation.record_outcome(kp.kid, room, correct=True, weight=10.0)
870
+ agent._observed_outcomes = 200
871
+ world.rooms.setdefault(room, {"min_rep": 0.0, "members": set()})["members"].add(kp.kid)
872
+ agents_list.append(agent)
873
+ return agents_list
874
+
875
+ @classmethod
876
+ def join_testbed(cls, existing: HyperMindAgent, *, room: str | None = None) -> HyperMindAgent:
877
+ """Add a new agent to an existing testbed (shared world)."""
878
+ if existing._world is None:
879
+ raise RuntimeError(
880
+ "join_testbed() requires an existing testbed agent.\n"
881
+ " Hint: build a base cohort with HyperMindAgent.testbed(n=…) "
882
+ "first, then join_testbed(any_member) to add another agent."
883
+ )
884
+ kp = Keypair.generate()
885
+ agent = HyperMindAgent(
886
+ keypair=kp,
887
+ namespace=existing.namespace,
888
+ rooms=existing.rooms if room is None else [room],
889
+ sandbox=True,
890
+ _world=existing._world,
891
+ )
892
+ for r in agent.rooms:
893
+ existing._world.rooms.setdefault(r, {"min_rep": 0.0, "members": set()})["members"].add(
894
+ kp.kid
895
+ )
896
+ existing._world.reputation.record_outcome(kp.kid, r, correct=True, weight=10.0)
897
+ agent._observed_outcomes = 200
898
+ return agent
899
+
900
+ # ---- async lifecycle ------------------------------------------------
901
+
902
+ async def __aenter__(self) -> HyperMindAgent:
903
+ return self
904
+
905
+ async def __aexit__(self, *_exc: Any) -> None:
906
+ await self.close()
907
+
908
+ async def close(self) -> None:
909
+ if self._closed:
910
+ return
911
+ # Stop any running deliberator task before deregistering.
912
+ _delib = getattr(self, "_mind_deliberator", None)
913
+ if _delib is not None:
914
+ await _delib.stop()
915
+ for sub in self._subscriptions:
916
+ sub.unsubscribe()
917
+ # Deregister from the namespace world so long-running test suites
918
+ # and notebook kernels don't leak agents/worlds (closes self-heal
919
+ # Cycle-3 Finding #17). When the world has no agents left, also
920
+ # drop it from the process-global registry.
921
+ if self._world is not None:
922
+ self._world.agents.pop(self.keypair.kid, None)
923
+ if not self._world.agents:
924
+ _NAMESPACE_REGISTRY.pop(bytes(self._world.namespace_id), None)
925
+ self._closed = True
926
+
927
+ # ---- placement (T5-02) ---------------------------------------------
928
+
929
+ def place_on(self, host_kid: str | None) -> None:
930
+ """Hint that this agent's topics should land on ``host_kid``.
931
+
932
+ The hint is consumed by placement policies (see
933
+ :mod:`hypermind.transport.placement`): when a candidate host
934
+ list contains the hinted kid, that host wins regardless of the
935
+ consistent-hash score. Pass ``None`` to clear the hint and fall
936
+ back to the default placement policy.
937
+ """
938
+ self._placement_hint = host_kid
939
+
940
+ @property
941
+ def placement_hint(self) -> str | None:
942
+ """The current placement hint, or ``None`` if unset."""
943
+ return self._placement_hint
944
+
945
+ # ---- core verbs -----------------------------------------------------
946
+
947
+ async def publish_claim(
948
+ self,
949
+ payload: dict[str, Any],
950
+ *,
951
+ claim_type: ClaimType = "custom",
952
+ uncertainty: Uncertainty,
953
+ parent_kids: Sequence[Kid] = (),
954
+ topic: str | None = None,
955
+ ) -> Kid:
956
+ """Sign + seal + cosign + gossip a claim, return its kid."""
957
+ self._require_open()
958
+ self._enforce_ramp()
959
+ topic = topic or (self.rooms[0] if self.rooms else "default")
960
+ if topic not in self.rooms:
961
+ raise self._block(
962
+ "ROOM-NOT-JOINED",
963
+ "spec/07-trust-access-layer.md §7.A",
964
+ f"agent has not joined room {topic!r}.\n"
965
+ f" Joined rooms: {self.rooms!r}\n"
966
+ f" Hint: pass `topic=` matching a joined room, or rebuild "
967
+ f"the agent with `room=` set to the desired room.",
968
+ )
969
+
970
+ self._require_cap("publish_claim", topic=topic)
971
+
972
+ # Build payload bytes (CBOR canonical; payload itself is opaque
973
+ # to the wire layer, but encoding it deterministically guarantees
974
+ # cross-impl reproducibility).
975
+ # v0.6 — embed the publisher's predicted mean so oracle_resolve()
976
+ # can record a Brier score for closed-loop calibration. The
977
+ # extra field is opaque to the wire layer and is ignored by
978
+ # legacy verifiers.
979
+ encoded = cbor2.dumps(
980
+ {
981
+ "claim_type": claim_type,
982
+ "topic": topic,
983
+ "payload": payload,
984
+ "parent_kids": list(parent_kids),
985
+ "predicted_mean": float(uncertainty.mean()),
986
+ },
987
+ canonical=True,
988
+ )
989
+
990
+ self._hlc = self._hlc.tick()
991
+ confirmer_set = self._select_confirmers(topic, exclude=self.keypair.kid)
992
+ statement = SignedStatement.sign(
993
+ keypair=self.keypair,
994
+ record_type="SEAL",
995
+ namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
996
+ hlc=self._hlc,
997
+ payload=encoded,
998
+ receipt_uncertainty=None,
999
+ confirmer_set=confirmer_set,
1000
+ )
1001
+ if self._world is not None:
1002
+ # Outbound gate (HM-PROTO-VER-001; ROADMAP §0.5.1).
1003
+ self._world.check_protocol_version(statement)
1004
+
1005
+ # Append to TS log + cosign atomically (closes self-heal Cycle-1
1006
+ # Finding #6: cosign must bind the (root, size) pair atomically).
1007
+ if self._world:
1008
+ _proof, cosigs = self._world.ts.append_and_cosign(statement.kid)
1009
+ self._world.sealed[statement.kid] = statement
1010
+ self._world.persist_claim(statement)
1011
+ await self._world.bus.publish(
1012
+ f"/hm/{self.namespace}/{topic}/seal",
1013
+ self._encode_envelope(statement, cosigs=cosigs),
1014
+ )
1015
+
1016
+ self._recorder.emit(
1017
+ namespace=self.namespace,
1018
+ agent_id=self.keypair.kid.hex(),
1019
+ event_type="seal",
1020
+ ts_hlc=str(self._hlc),
1021
+ kid=statement.kid.hex(),
1022
+ topic=topic,
1023
+ claim_type=claim_type,
1024
+ )
1025
+ self._recorder.incr(
1026
+ "hypermind_seal_total",
1027
+ namespace=self.namespace,
1028
+ topic=topic,
1029
+ result="ok",
1030
+ )
1031
+ return statement.kid
1032
+
1033
+ async def subscribe(
1034
+ self,
1035
+ topic: str,
1036
+ callback: Callable[[ClaimEvent], Awaitable[None]],
1037
+ ) -> Subscription:
1038
+ """Subscribe to all sealed claims on a topic."""
1039
+ self._require_open()
1040
+ if not self._world:
1041
+ raise RuntimeError(
1042
+ "subscribe() requires a world.\n"
1043
+ " Hint: build the agent via HyperMindAgent.sandbox() or "
1044
+ "HyperMindAgent.testbed(n=…) so a shared in-process world is "
1045
+ "set up. Naked-constructor agents are crypto-only and have no "
1046
+ "transport."
1047
+ )
1048
+
1049
+ world = self._world
1050
+
1051
+ async def _handler(raw: bytes) -> None:
1052
+ envelope = cbor2.loads(raw)
1053
+ # L4 separability (spec §23): if the envelope carries an
1054
+ # EncryptedStatement and HPKE decrypt raises, we MUST still
1055
+ # verify the signed metadata and emit a structured
1056
+ # BlockReason("PAYLOAD-OPAQUE"). The signed-but-undecryptable
1057
+ # record stays visible to the audit trail.
1058
+ if (
1059
+ EncryptedStatement is not None
1060
+ and isinstance(envelope, dict)
1061
+ and "encrypted_statement" in envelope
1062
+ ):
1063
+ try:
1064
+ enc = EncryptedStatement.from_bytes(envelope["encrypted_statement"])
1065
+ plaintext = enc.decrypt(self.keypair) # type: ignore[attr-defined]
1066
+ envelope = dict(envelope)
1067
+ envelope["statement"] = plaintext
1068
+ except Exception as exc:
1069
+ signed_meta = envelope.get("statement")
1070
+ meta_kid_hex: str | None = None
1071
+ try:
1072
+ if signed_meta is not None:
1073
+ meta_stmt = SignedStatement.from_bytes(signed_meta)
1074
+ meta_kid_hex = meta_stmt.kid.hex()
1075
+ except Exception:
1076
+ meta_kid_hex = None
1077
+ self._recorder.emit(
1078
+ namespace=self.namespace,
1079
+ agent_id=self.keypair.kid.hex(),
1080
+ event_type="payload_opaque",
1081
+ severity="warning",
1082
+ rule_id="PAYLOAD-OPAQUE",
1083
+ decrypt_error=type(exc).__name__,
1084
+ signed_meta_kid=meta_kid_hex,
1085
+ )
1086
+ self._recorder.incr(
1087
+ "hypermind_payload_opaque_total",
1088
+ namespace=self.namespace,
1089
+ rule_id="PAYLOAD-OPAQUE",
1090
+ )
1091
+ return
1092
+ stmt = SignedStatement.from_bytes(envelope["statement"])
1093
+ # Policy gate: reject pre-signature-verify if protocol_version is
1094
+ # below the namespace's min_protocol_version (HM-PROTO-VER-001;
1095
+ # ROADMAP §0.5.1). Errors are recorded so refusal is auditable.
1096
+ try:
1097
+ world.check_protocol_version(stmt)
1098
+ except ProtocolVersionError as e:
1099
+ self._recorder.incr(
1100
+ "hypermind_protocol_version_rejects_total",
1101
+ namespace=self.namespace,
1102
+ rule_id=e.rule_id or "HM-PROTO-VER-001",
1103
+ )
1104
+ self._recorder.emit(
1105
+ namespace=self.namespace,
1106
+ agent_id=self.keypair.kid.hex(),
1107
+ event_type="protocol_version_reject",
1108
+ severity="warning",
1109
+ statement_version=e.statement_version,
1110
+ min_required=e.min_required,
1111
+ )
1112
+ return
1113
+ # I6 — Receive-side cosignature verification (Henrik's
1114
+ # CVE-class fix). When the inbound envelope carries
1115
+ # WitnessCosignatures, every one MUST verify against this
1116
+ # world's transparency service before delivery into the mind
1117
+ # layer. Pre-I6 envelopes (no "size" field) are accepted as
1118
+ # before for back-compat — same-process testbed traffic.
1119
+ cosig_count = 0
1120
+ cosig_failures = 0
1121
+ cosig_verified = 0
1122
+ for raw_cosig in envelope.get("cosigs") or []:
1123
+ if not isinstance(raw_cosig, dict):
1124
+ continue
1125
+ cosig_count += 1
1126
+ size = raw_cosig.get("size")
1127
+ if size is None:
1128
+ # Legacy envelope; cannot verify. Skip without
1129
+ # failing so old peers don't get rejected mid-rollout.
1130
+ continue
1131
+ try:
1132
+ from hypermind.knowledge.transparency import WitnessCosignature
1133
+
1134
+ cosig = WitnessCosignature(
1135
+ witness_kid=raw_cosig["witness"],
1136
+ sth_root=raw_cosig["sth"],
1137
+ tree_size=int(size),
1138
+ signature=raw_cosig["sig"],
1139
+ )
1140
+ if world.ts.verify_cosignature(cosig):
1141
+ cosig_verified += 1
1142
+ else:
1143
+ cosig_failures += 1
1144
+ except Exception:
1145
+ cosig_failures += 1
1146
+ if cosig_failures > 0:
1147
+ self._recorder.emit(
1148
+ namespace=self.namespace,
1149
+ agent_id=self.keypair.kid.hex(),
1150
+ event_type="cosig_failed",
1151
+ severity="warning",
1152
+ claim_kid=stmt.kid.hex(),
1153
+ failures=cosig_failures,
1154
+ verified=cosig_verified,
1155
+ total=cosig_count,
1156
+ )
1157
+ # Drop the message — never deliver an unverifiable claim.
1158
+ return
1159
+ if cosig_verified > 0:
1160
+ self._recorder.emit(
1161
+ namespace=self.namespace,
1162
+ agent_id=self.keypair.kid.hex(),
1163
+ event_type="cosig_verified",
1164
+ claim_kid=stmt.kid.hex(),
1165
+ verified=cosig_verified,
1166
+ )
1167
+ await callback(ClaimEvent(statement=stmt, raw=envelope))
1168
+
1169
+ sub = await self._world.bus.subscribe(f"/hm/{self.namespace}/{topic}/seal", _handler)
1170
+ self._subscriptions.append(sub)
1171
+ return sub
1172
+
1173
+ def on_dispute(
1174
+ self,
1175
+ target_kid: Kid,
1176
+ callback: Callable[[Dispute], Any],
1177
+ ) -> Subscription:
1178
+ """Register a synchronous callback for disputes against `target_kid`."""
1179
+ self._require_open()
1180
+ self._dispute_callbacks.setdefault(target_kid, []).append(callback)
1181
+
1182
+ def _cancel() -> None:
1183
+ try:
1184
+ self._dispute_callbacks[target_kid].remove(callback)
1185
+ except (ValueError, KeyError):
1186
+ pass
1187
+
1188
+ return Subscription(topic=f"dispute:{target_kid.hex()}", _cancel=_cancel)
1189
+
1190
+ async def dispute(
1191
+ self,
1192
+ target_kid: Kid,
1193
+ *,
1194
+ reason: str,
1195
+ bond_topic_scope: str,
1196
+ evidence_cid: bytes | None = None,
1197
+ ) -> Kid:
1198
+ """Open a counter-statement against an existing claim."""
1199
+ self._require_open()
1200
+ self._enforce_ramp()
1201
+ self._require_cap("dispute", topic=bond_topic_scope or None)
1202
+ if not bond_topic_scope:
1203
+ raise DisputeError(
1204
+ "bond_topic_scope required (TAL-ESCROW-EXCLUSIVE).\n"
1205
+ " Hint: pass the topic (e.g. 'ai-safety/eval') the bond "
1206
+ "is scoped to. Bonds cannot be wash-traded across topics."
1207
+ )
1208
+ if self._world and target_kid not in self._world.sealed:
1209
+ sealed_count = len(self._world.sealed)
1210
+ sample = [k.hex()[:12] + "…" for k in list(self._world.sealed)[:3]]
1211
+ raise DisputeError(
1212
+ f"target kid {target_kid.hex()[:12]}… not found among "
1213
+ f"{sealed_count} sealed claim(s) in this namespace.\n"
1214
+ f" First few known kids: {sample!r}\n"
1215
+ f" Hint: dispute targets must be SEALED claims in the SAME "
1216
+ f"namespace as the disputer. Did you mean a different agent's "
1217
+ f"claim, or did you accidentally pass an agent_id?"
1218
+ )
1219
+
1220
+ # Build dispute payload.
1221
+ encoded = cbor2.dumps(
1222
+ {
1223
+ "reason": reason,
1224
+ "evidence_cid": evidence_cid,
1225
+ },
1226
+ canonical=True,
1227
+ )
1228
+
1229
+ bond_amount = 100.0 # sandbox default
1230
+ bond: dict[str, Any] = {
1231
+ "namespace_id": self._world.namespace_id if self._world else new_namespace_id(),
1232
+ "amount": bond_amount,
1233
+ "escrow_kid": self.keypair.kid,
1234
+ "topic_scope_hash": _hash_topic_scope(bond_topic_scope),
1235
+ }
1236
+
1237
+ self._hlc = self._hlc.tick()
1238
+ statement = SignedStatement.sign(
1239
+ keypair=self.keypair,
1240
+ record_type="DISPUTE",
1241
+ namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
1242
+ hlc=self._hlc,
1243
+ payload=encoded,
1244
+ dispute_state="OPEN",
1245
+ dispute_target_kid=target_kid,
1246
+ dispute_subtype="OPEN",
1247
+ bond_commitment=bond,
1248
+ )
1249
+
1250
+ if self._world:
1251
+ d = Dispute(
1252
+ target_kid=target_kid,
1253
+ initiator=self.keypair.kid,
1254
+ bond_topic_scope=bond_topic_scope,
1255
+ bond_amount=bond_amount,
1256
+ )
1257
+ self._world.disputes[statement.kid] = d
1258
+ self._world.sealed[statement.kid] = statement
1259
+ self._world.persist_claim(statement)
1260
+ self._world.persist_dispute(statement.kid, d)
1261
+ self._world.dispute_event.set()
1262
+
1263
+ # Notify the original claim's publisher synchronously so
1264
+ # `on_dispute` callbacks fire deterministically in tests.
1265
+ target_stmt = self._world.sealed.get(target_kid)
1266
+ if target_stmt is not None:
1267
+ target_publisher = target_stmt.issuer_kid
1268
+ publisher_agent = self._world.agents.get(target_publisher)
1269
+ if publisher_agent is not None:
1270
+ for cb in publisher_agent._dispute_callbacks.get(target_kid, []):
1271
+ await _invoke_dispute_callback(cb, d, recorder=self._recorder)
1272
+
1273
+ await self._world.bus.publish(
1274
+ f"/hm/{self.namespace}/dispute",
1275
+ self._encode_envelope(statement),
1276
+ )
1277
+
1278
+ self._recorder.emit(
1279
+ namespace=self.namespace,
1280
+ agent_id=self.keypair.kid.hex(),
1281
+ event_type="dispute_open",
1282
+ ts_hlc=str(self._hlc),
1283
+ kid=statement.kid.hex(),
1284
+ target_kid=target_kid.hex(),
1285
+ bond_topic_scope=bond_topic_scope,
1286
+ )
1287
+ self._recorder.incr("hypermind_dispute_open_total")
1288
+ return statement.kid
1289
+
1290
+ # ---- T3: Contract-Net Protocol (§27) -------------------------------
1291
+
1292
+ async def request_for_proposal(
1293
+ self,
1294
+ *,
1295
+ topic: str,
1296
+ requirements: dict[str, Any],
1297
+ deadline_ms: int,
1298
+ bidder_filter: dict[str, Any] | None = None,
1299
+ ) -> RFPHandle:
1300
+ """Publish an RFP_OPEN record on this namespace's gossip bus.
1301
+
1302
+ Returns an :class:`RFPHandle` that the issuer can await for
1303
+ incoming bids and use to compute / publish the AWARD.
1304
+
1305
+ Spec §27.2 normative MUSTs:
1306
+ - The RFP is signed (this method's SignedStatement.sign() call).
1307
+ - The bidder_filter (if supplied) is enforced at AWARD time;
1308
+ bids below the floor are published as BID_REJECT records
1309
+ before the AWARD is computed.
1310
+ """
1311
+ self._require_open()
1312
+ self._enforce_ramp()
1313
+ self._require_cap("request_for_proposal", topic=topic)
1314
+ if topic not in self.rooms:
1315
+ raise self._block(
1316
+ "ROOM-NOT-JOINED",
1317
+ "spec/27-contract-net.md §27.2",
1318
+ f"agent has not joined room {topic!r} (required for RFP).\n"
1319
+ f" Joined rooms: {self.rooms!r}",
1320
+ )
1321
+
1322
+ encoded = cbor2.dumps(
1323
+ {
1324
+ "topic": topic,
1325
+ "requirements": requirements,
1326
+ "deadline_ms": int(deadline_ms),
1327
+ "bidder_filter": bidder_filter,
1328
+ },
1329
+ canonical=True,
1330
+ )
1331
+ self._hlc = self._hlc.tick()
1332
+ statement = SignedStatement.sign(
1333
+ keypair=self.keypair,
1334
+ record_type="RFP_OPEN",
1335
+ namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
1336
+ hlc=self._hlc,
1337
+ payload=encoded,
1338
+ )
1339
+ rfp_id = statement.kid # rfp_id ≡ kid of the RFP_OPEN envelope
1340
+ rfp = RFP(
1341
+ rfp_id=rfp_id,
1342
+ issuer_kid=self.keypair.kid,
1343
+ topic=topic,
1344
+ requirements=dict(requirements),
1345
+ deadline_hlc_ms=int(deadline_ms),
1346
+ bidder_filter=dict(bidder_filter) if bidder_filter else None,
1347
+ )
1348
+ if self._world is not None:
1349
+ self._world.sealed[rfp_id] = statement
1350
+ self._world.rfps[rfp_id] = rfp
1351
+ self._world.bids_by_rfp.setdefault(rfp_id, [])
1352
+ self._world.persist_claim(statement)
1353
+ await self._world.bus.publish(
1354
+ f"/hm/{self.namespace}/cnp/rfp",
1355
+ self._encode_envelope(statement),
1356
+ )
1357
+ self._recorder.emit(
1358
+ namespace=self.namespace,
1359
+ agent_id=self.keypair.kid.hex(),
1360
+ event_type="cnp_rfp_open",
1361
+ ts_hlc=str(self._hlc),
1362
+ rfp_id=rfp_id.hex(),
1363
+ topic=topic,
1364
+ )
1365
+ self._recorder.incr("hypermind_cnp_rfp_total", topic=topic)
1366
+ return RFPHandle(rfp=rfp, agent=self)
1367
+
1368
+ async def bid(
1369
+ self,
1370
+ *,
1371
+ rfp_id: Kid,
1372
+ payload: dict[str, Any],
1373
+ score_self: float,
1374
+ ) -> BidHandle:
1375
+ """Publish a BID record against an open RFP.
1376
+
1377
+ ``score_self`` is the bidder's claimed posterior on delivering
1378
+ the requirement; it is fed into the auctioneer's scorer (default
1379
+ :class:`ReputationWeightedScorer`). The auctioneer multiplies it
1380
+ by the bidder's topic reputation, so a low-rep bidder cannot
1381
+ win simply by claiming `score_self=1.0`.
1382
+ """
1383
+ self._require_open()
1384
+ self._enforce_ramp()
1385
+ if not (0.0 <= float(score_self) <= 1.0):
1386
+ raise ValueError("score_self must be in [0.0, 1.0]")
1387
+ if self._world is None:
1388
+ raise RuntimeError("bid() requires a world (use sandbox/testbed)")
1389
+ if rfp_id not in self._world.rfps:
1390
+ raise HyperMindError(
1391
+ f"bid: rfp_id {rfp_id.hex()[:12]}… not found in this namespace.\n"
1392
+ f" Hint: bid() must be called inside the same namespace as "
1393
+ f"request_for_proposal()."
1394
+ )
1395
+ rfp_obj: RFP = self._world.rfps[rfp_id]
1396
+ self._require_cap("bid", topic=rfp_obj.topic)
1397
+
1398
+ encoded = cbor2.dumps(
1399
+ {
1400
+ "rfp_id": bytes(rfp_id),
1401
+ "bidder_kid": self.keypair.kid,
1402
+ "payload": payload,
1403
+ "bid_score_self": float(score_self),
1404
+ },
1405
+ canonical=True,
1406
+ )
1407
+ self._hlc = self._hlc.tick()
1408
+ statement = SignedStatement.sign(
1409
+ keypair=self.keypair,
1410
+ record_type="BID",
1411
+ namespace_id=self._world.namespace_id,
1412
+ hlc=self._hlc,
1413
+ payload=encoded,
1414
+ )
1415
+ bid_obj = Bid(
1416
+ rfp_id=bytes(rfp_id),
1417
+ bid_kid=statement.kid,
1418
+ bidder_kid=self.keypair.kid,
1419
+ payload=dict(payload),
1420
+ bid_score_self=float(score_self),
1421
+ )
1422
+ self._world.sealed[statement.kid] = statement
1423
+ self._world.bids_by_rfp.setdefault(bytes(rfp_id), []).append(bid_obj)
1424
+ self._world.persist_claim(statement)
1425
+ self._world.rfp_bid_event.set()
1426
+ # Reset the event after a tick so subsequent waiters can re-arm.
1427
+ await self._world.bus.publish(
1428
+ f"/hm/{self.namespace}/cnp/bid",
1429
+ self._encode_envelope(statement),
1430
+ )
1431
+ self._recorder.emit(
1432
+ namespace=self.namespace,
1433
+ agent_id=self.keypair.kid.hex(),
1434
+ event_type="cnp_bid",
1435
+ ts_hlc=str(self._hlc),
1436
+ bid_kid=statement.kid.hex(),
1437
+ rfp_id=bytes(rfp_id).hex(),
1438
+ )
1439
+ self._recorder.incr("hypermind_cnp_bid_total", topic=rfp_obj.topic)
1440
+ return BidHandle(bid=bid_obj, agent=self)
1441
+
1442
+ async def award(
1443
+ self,
1444
+ rfp_id: Kid,
1445
+ winning_bid_kid: Kid | None = None,
1446
+ *,
1447
+ signer_quorum: int = 1,
1448
+ co_signers: list[Any] | None = None,
1449
+ scorer: BidScorer | None = None,
1450
+ ) -> AwardHandle:
1451
+ """Compute (or accept) the winning bid and publish an AWARD record.
1452
+
1453
+ - ``winning_bid_kid``: caller-selected winner; when ``None``, the
1454
+ configured ``scorer`` (default :class:`ReputationWeightedScorer`)
1455
+ picks the highest-scoring bid that passed the bidder_filter.
1456
+ - ``signer_quorum``: 1 ⇒ ordinary single-signer AWARD (Ed25519 or
1457
+ hybrid PQ per the agent's keypair). >1 ⇒ FROST k-of-n threshold
1458
+ signature; ``co_signers`` MUST then be a list of length n
1459
+ containing FROST-share-bearing :class:`Keypair`-shape objects
1460
+ (see :mod:`hypermind.crypto.frost`).
1461
+
1462
+ Spec §27.3 normative MUSTs:
1463
+ - When ``signer_quorum > 1``, the AWARD payload MUST carry
1464
+ the kids of all n FROST signers (so verifiers can re-derive
1465
+ the group public key) and the aggregated (R, z) signature
1466
+ binds :func:`contract_net.award_preimage` (rfp_id, winning_bid_kid).
1467
+ """
1468
+ self._require_open()
1469
+ if self._world is None:
1470
+ raise RuntimeError("award() requires a world (use sandbox/testbed)")
1471
+ if rfp_id not in self._world.rfps:
1472
+ raise HyperMindError(f"award: rfp_id {rfp_id.hex()[:12]}… not found")
1473
+ rfp_obj: RFP = self._world.rfps[rfp_id]
1474
+ if rfp_obj.issuer_kid != self.keypair.kid:
1475
+ raise AuthError(
1476
+ "only the RFP issuer may publish AWARD",
1477
+ failed_caveat="cnp_award_issuer",
1478
+ telemetry={"rule_id": "HM-CNP-AWARD-ISSUER"},
1479
+ )
1480
+ self._require_cap("award", topic=rfp_obj.topic)
1481
+ scorer = scorer or ReputationWeightedScorer()
1482
+
1483
+ # Filter bids and emit BID_REJECT for filtered-out ones.
1484
+ all_bids: list[Bid] = list(self._world.bids_by_rfp.get(rfp_id, []))
1485
+ eligible: list[Bid] = []
1486
+ rejected: list[tuple[Bid, str]] = []
1487
+ for b in all_bids:
1488
+ ok, rule_id = passes_bidder_filter(
1489
+ b, rfp_obj.bidder_filter, self._world.reputation, rfp_obj.topic
1490
+ )
1491
+ if ok:
1492
+ eligible.append(b)
1493
+ else:
1494
+ rejected.append((b, rule_id or "HM-CNP-BIDDER-FILTER"))
1495
+
1496
+ for rejected_bid, rule_id in rejected:
1497
+ await self._publish_bid_reject(
1498
+ rfp_id=rfp_id,
1499
+ bid_kid=rejected_bid.bid_kid,
1500
+ reason_rule_id=rule_id,
1501
+ topic=rfp_obj.topic,
1502
+ )
1503
+
1504
+ if winning_bid_kid is None:
1505
+ if not eligible:
1506
+ raise HyperMindError(
1507
+ "award: no eligible bids after bidder_filter; RFP cannot be awarded"
1508
+ )
1509
+ scored = [
1510
+ (
1511
+ scorer.score(b, rfp_obj.requirements, self._world.reputation, rfp_obj.topic),
1512
+ b,
1513
+ )
1514
+ for b in eligible
1515
+ ]
1516
+ # Stable winner pick — highest score wins; ties break on bid_kid bytes.
1517
+ scored.sort(key=lambda x: (x[0], x[1].bid_kid), reverse=True)
1518
+ winning_bid_kid = scored[0][1].bid_kid
1519
+
1520
+ # Locate the winning bid object.
1521
+ win_bid = next((b for b in all_bids if b.bid_kid == winning_bid_kid), None)
1522
+ if win_bid is None:
1523
+ raise HyperMindError(f"award: winning_bid_kid {winning_bid_kid.hex()[:12]}… not found")
1524
+
1525
+ signer_kids: list[bytes]
1526
+ unprotected_extras: dict[str, Any] = {}
1527
+ if signer_quorum > 1:
1528
+ if not co_signers or len(co_signers) < signer_quorum:
1529
+ raise ValueError(
1530
+ f"award: signer_quorum={signer_quorum} requires "
1531
+ f"len(co_signers) ≥ {signer_quorum}"
1532
+ )
1533
+ # FROST k-of-n threshold sign over the canonical preimage.
1534
+ from hypermind.crypto import frost
1535
+
1536
+ # Each co_signer is expected to carry a FrostShare in
1537
+ # `.frost_share`; the auctioneer's own kid is included as the
1538
+ # nominal "issuer" so the SignedStatement envelope retains a
1539
+ # stable kid attribution. The FROST aggregate R||z lives in
1540
+ # the unprotected header `frost_signature` for verifiers, and
1541
+ # the AWARD record's signature itself is over the canonical
1542
+ # sig_input (Ed25519 of the auctioneer) — the FROST aggregate
1543
+ # is the auxiliary k-of-n attestation, NOT the only proof.
1544
+ preimage = award_preimage(rfp_id, winning_bid_kid)
1545
+ # Accept either FrostShare instances (preferred) or objects
1546
+ # exposing a `.frost_share` attribute (e.g. a wrapper class).
1547
+ shares: list[Any] = []
1548
+ for s in co_signers[:signer_quorum]:
1549
+ if isinstance(s, frost.FrostShare):
1550
+ shares.append(s)
1551
+ elif hasattr(s, "frost_share"):
1552
+ shares.append(s.frost_share)
1553
+ else:
1554
+ raise ValueError(
1555
+ "award: every co_signer must be a FrostShare or "
1556
+ "expose a `.frost_share` attribute"
1557
+ )
1558
+ R, z = frost.threshold_sign(shares, preimage)
1559
+ # Derive a stable identifier per signer: index-tagged short kid
1560
+ # over the share's index + group_public. The per-signer Ed25519
1561
+ # kid is not part of FROST; we record signer indices instead so
1562
+ # auditors can verify which shares contributed.
1563
+ signer_kids = [
1564
+ hashlib.sha256(
1565
+ b"hm:cnp-frost-signer:" + i.to_bytes(2, "big") + bytes(shares[0].group_public)
1566
+ ).digest()
1567
+ for i in [sh.index for sh in shares]
1568
+ ]
1569
+ unprotected_extras["frost_signature"] = {"R": R, "z": z}
1570
+ unprotected_extras["frost_quorum"] = [int(signer_quorum), len(co_signers)]
1571
+ unprotected_extras["frost_signer_indices"] = [int(sh.index) for sh in shares]
1572
+ unprotected_extras["frost_group_public"] = bytes(shares[0].group_public)
1573
+ else:
1574
+ signer_kids = [self.keypair.kid]
1575
+
1576
+ encoded = cbor2.dumps(
1577
+ {
1578
+ "rfp_id": bytes(rfp_id),
1579
+ "winning_bid_kid": bytes(winning_bid_kid),
1580
+ "winning_bidder_kid": win_bid.bidder_kid,
1581
+ "award_signer_kids": [bytes(s) for s in signer_kids],
1582
+ },
1583
+ canonical=True,
1584
+ )
1585
+ self._hlc = self._hlc.tick()
1586
+ statement = SignedStatement.sign(
1587
+ keypair=self.keypair,
1588
+ record_type="AWARD",
1589
+ namespace_id=self._world.namespace_id,
1590
+ hlc=self._hlc,
1591
+ payload=encoded,
1592
+ )
1593
+ if unprotected_extras:
1594
+ statement.unprotected.update(unprotected_extras)
1595
+
1596
+ award_obj = Award(
1597
+ rfp_id=bytes(rfp_id),
1598
+ award_kid=statement.kid,
1599
+ winning_bid_kid=bytes(winning_bid_kid),
1600
+ winning_bidder_kid=win_bid.bidder_kid,
1601
+ award_signer_kids=[bytes(s) for s in signer_kids],
1602
+ )
1603
+ self._world.sealed[statement.kid] = statement
1604
+ self._world.awards[bytes(rfp_id)] = award_obj
1605
+ self._world.persist_claim(statement)
1606
+ self._world.rfp_award_event.set()
1607
+ await self._world.bus.publish(
1608
+ f"/hm/{self.namespace}/cnp/award",
1609
+ self._encode_envelope(statement),
1610
+ )
1611
+ self._recorder.emit(
1612
+ namespace=self.namespace,
1613
+ agent_id=self.keypair.kid.hex(),
1614
+ event_type="cnp_award",
1615
+ ts_hlc=str(self._hlc),
1616
+ award_kid=statement.kid.hex(),
1617
+ rfp_id=bytes(rfp_id).hex(),
1618
+ winning_bid_kid=bytes(winning_bid_kid).hex(),
1619
+ quorum=signer_quorum,
1620
+ n_signers=len(signer_kids),
1621
+ )
1622
+ self._recorder.incr(
1623
+ "hypermind_cnp_award_total",
1624
+ topic=rfp_obj.topic,
1625
+ quorum=str(signer_quorum),
1626
+ )
1627
+ return AwardHandle(award=award_obj, agent=self)
1628
+
1629
+ async def _publish_bid_reject(
1630
+ self,
1631
+ *,
1632
+ rfp_id: Kid,
1633
+ bid_kid: Kid,
1634
+ reason_rule_id: str,
1635
+ topic: str,
1636
+ ) -> Kid:
1637
+ """Publish a BID_REJECT record (used by award() and dispute_award())."""
1638
+ encoded = cbor2.dumps(
1639
+ {
1640
+ "rfp_id": bytes(rfp_id),
1641
+ "bid_kid": bytes(bid_kid),
1642
+ "reason_rule_id": reason_rule_id,
1643
+ },
1644
+ canonical=True,
1645
+ )
1646
+ self._hlc = self._hlc.tick()
1647
+ statement = SignedStatement.sign(
1648
+ keypair=self.keypair,
1649
+ record_type="BID_REJECT",
1650
+ namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
1651
+ hlc=self._hlc,
1652
+ payload=encoded,
1653
+ )
1654
+ if self._world is not None:
1655
+ self._world.sealed[statement.kid] = statement
1656
+ self._world.persist_claim(statement)
1657
+ await self._world.bus.publish(
1658
+ f"/hm/{self.namespace}/cnp/bid_reject",
1659
+ self._encode_envelope(statement),
1660
+ )
1661
+ self._recorder.emit(
1662
+ namespace=self.namespace,
1663
+ agent_id=self.keypair.kid.hex(),
1664
+ event_type="cnp_bid_reject",
1665
+ rule_id=reason_rule_id,
1666
+ bid_kid=bytes(bid_kid).hex(),
1667
+ rfp_id=bytes(rfp_id).hex(),
1668
+ )
1669
+ self._recorder.incr(
1670
+ "hypermind_cnp_bid_reject_total",
1671
+ topic=topic,
1672
+ rule_id=reason_rule_id,
1673
+ )
1674
+ return statement.kid
1675
+
1676
+ async def dispute_award(
1677
+ self,
1678
+ *,
1679
+ rfp_id: Kid,
1680
+ award_kid: Kid,
1681
+ evidence: dict[str, Any],
1682
+ ) -> Kid:
1683
+ """Open a dispute against an AWARD record.
1684
+
1685
+ Wraps the standard dispute FSM; the bond's topic_scope_hash binds
1686
+ to ``"rfp:" + hex(rfp_id)`` (see
1687
+ :func:`contract_net.topic_scope_for_rfp`) so closing the dispute
1688
+ on a different topic-scope is rejected by ``Dispute.close()``.
1689
+
1690
+ On a successful CLOSED outcome (decision="upheld"), the caller
1691
+ SHOULD invoke :meth:`revoke_award` to publish a BID_REJECT for
1692
+ the previously-winning bid; the RFP can then be re-awarded.
1693
+ """
1694
+ if self._world is None:
1695
+ raise RuntimeError("dispute_award() requires a world")
1696
+ if award_kid not in self._world.sealed:
1697
+ raise DisputeError(f"award_kid {award_kid.hex()[:12]}… not found in sealed registry")
1698
+ scope = topic_scope_for_rfp(rfp_id)
1699
+ # Reuse the existing dispute() verb; it already enforces caps,
1700
+ # bond topic-scope hashing, and FSM open.
1701
+ return await self.dispute(
1702
+ award_kid,
1703
+ reason=str(evidence.get("reason", "award disputed")),
1704
+ bond_topic_scope=scope,
1705
+ evidence_cid=cbor2.dumps(evidence, canonical=True),
1706
+ )
1707
+
1708
+ async def revoke_award(
1709
+ self,
1710
+ *,
1711
+ rfp_id: Kid,
1712
+ award_kid: Kid,
1713
+ ) -> Kid:
1714
+ """Mark an AWARD revoked by publishing BID_REJECT for the winning bid.
1715
+
1716
+ Called by the RFP issuer after a successful dispute upholds the
1717
+ complaint. The Award record in self._world.awards is mutated
1718
+ (revoked=True) so a subsequent re-award can be issued. Returns
1719
+ the kid of the BID_REJECT envelope.
1720
+ """
1721
+ if self._world is None:
1722
+ raise RuntimeError("revoke_award() requires a world")
1723
+ award_obj = self._world.awards.get(bytes(rfp_id))
1724
+ if award_obj is None or award_obj.award_kid != award_kid:
1725
+ raise HyperMindError(
1726
+ f"revoke_award: no current award for rfp_id={rfp_id.hex()[:12]}… "
1727
+ f"matching award_kid={award_kid.hex()[:12]}…"
1728
+ )
1729
+ rfp_obj: RFP = self._world.rfps[rfp_id]
1730
+ reject_kid = await self._publish_bid_reject(
1731
+ rfp_id=rfp_id,
1732
+ bid_kid=award_obj.winning_bid_kid,
1733
+ reason_rule_id="HM-CNP-AWARD-REVOKED",
1734
+ topic=rfp_obj.topic,
1735
+ )
1736
+ # Replace with a revoked sentinel so re-award can proceed.
1737
+ self._world.awards[bytes(rfp_id)] = Award(
1738
+ rfp_id=award_obj.rfp_id,
1739
+ award_kid=award_obj.award_kid,
1740
+ winning_bid_kid=award_obj.winning_bid_kid,
1741
+ winning_bidder_kid=award_obj.winning_bidder_kid,
1742
+ award_signer_kids=list(award_obj.award_signer_kids),
1743
+ revoked=True,
1744
+ )
1745
+ # Drop the disqualified bid from the eligible list so a re-award
1746
+ # will pick a different winner.
1747
+ bids = self._world.bids_by_rfp.get(bytes(rfp_id), [])
1748
+ self._world.bids_by_rfp[bytes(rfp_id)] = [
1749
+ b for b in bids if b.bid_kid != award_obj.winning_bid_kid
1750
+ ]
1751
+ return reject_kid
1752
+
1753
+ async def synthesize(
1754
+ self,
1755
+ parent_kids: list[Kid],
1756
+ summary_payload: dict[str, Any],
1757
+ *,
1758
+ model_attribution: dict[str, Any] | None = None,
1759
+ uncertainty: Uncertainty,
1760
+ ) -> Kid:
1761
+ """Aggregate parent claims into a synthesis. Provenance preserved."""
1762
+ self._require_cap("synthesize")
1763
+ if not parent_kids:
1764
+ raise ValueError(
1765
+ "synthesize() requires at least one parent_kid.\n"
1766
+ " Hint: pass the kids of claims you want to aggregate, e.g. "
1767
+ "agent.synthesize(parent_kids=[k1, k2], ...)."
1768
+ )
1769
+ merged = {
1770
+ "summary": summary_payload,
1771
+ "parents": [k.hex() for k in parent_kids],
1772
+ "model_attribution": model_attribution,
1773
+ }
1774
+ return await self.publish_claim(
1775
+ merged,
1776
+ claim_type="synthesis",
1777
+ uncertainty=uncertainty,
1778
+ parent_kids=list(parent_kids),
1779
+ )
1780
+
1781
+ async def consult(
1782
+ self,
1783
+ query: str,
1784
+ *,
1785
+ panel_size: int = 5,
1786
+ route_strategy: RouteStrategy = "disagreement",
1787
+ timeout: timedelta = timedelta(seconds=30),
1788
+ topic: str | None = None,
1789
+ responder: Callable[[bytes, str], Awaitable[Any]] | None = None,
1790
+ cross_namespace: list[bytes] | None = None,
1791
+ # ---- v0.6 deliberation knobs (backward-compat defaults) ----
1792
+ max_deliberation_rounds: int = 1,
1793
+ convergence_threshold: float = 0.05,
1794
+ # P3-09: blind window buffer — hold responses for this many ms before
1795
+ # scoring, preventing late agents from conditioning on early results.
1796
+ blind_window_ms: int = 0,
1797
+ ) -> ConsultResult:
1798
+ """Aggregate posteriors from a panel of peer agents.
1799
+
1800
+ ``cross_namespace`` (ROADMAP §0.4.1): list of namespace_id bytes
1801
+ whose worlds are also eligible for panel selection. Each cross-
1802
+ namespace participant's reputation is read from THEIR namespace,
1803
+ not the calling namespace — reputation is per-namespace by
1804
+ invariant.
1805
+ """
1806
+ self._require_open()
1807
+ self._require_cap("consult", topic=topic)
1808
+ if not self._world:
1809
+ raise RuntimeError(
1810
+ "consult() requires a world.\n"
1811
+ " Hint: build the agent via HyperMindAgent.sandbox() or "
1812
+ "HyperMindAgent.testbed(n=…) so peer agents are reachable."
1813
+ )
1814
+ topic = topic or (self.rooms[0] if self.rooms else "default")
1815
+
1816
+ # Per-candidate world map: {kid → world they belong to}. The
1817
+ # local namespace's agents map first; cross-namespace overlays
1818
+ # follow. Reputation is always read from the agent's HOME world.
1819
+ candidate_worlds: dict[bytes, _NamespaceWorld] = {}
1820
+ for kid in self._world.agents.keys():
1821
+ if kid == self.keypair.kid:
1822
+ continue
1823
+ if topic in self._world.agents[kid].rooms:
1824
+ candidate_worlds[kid] = self._world
1825
+
1826
+ for ns_id in cross_namespace or []:
1827
+ other = _lookup_namespace(bytes(ns_id))
1828
+ if other is None or other is self._world:
1829
+ continue
1830
+ for kid, agent in other.agents.items():
1831
+ if kid == self.keypair.kid:
1832
+ continue
1833
+ if topic in agent.rooms and kid not in candidate_worlds:
1834
+ candidate_worlds[kid] = other
1835
+
1836
+ candidates = list(candidate_worlds.keys())
1837
+ if not candidates:
1838
+ raise HyperMindError(
1839
+ f"consult: no candidates available for topic {topic!r}.\n"
1840
+ f" Hint: at least one OTHER agent must be in the same room "
1841
+ f"and namespace. Use HyperMindAgent.testbed(n=…, room={topic!r}) "
1842
+ f"to spin up a cohort."
1843
+ )
1844
+
1845
+ def _rep_for(c: bytes) -> float:
1846
+ # Read reputation from the candidate's HOME world — per-namespace
1847
+ # by invariant (ROADMAP §0.4.1).
1848
+ #
1849
+ # v0.6 — multiply by per-(agent, topic) calibration adjustment.
1850
+ # Well-calibrated agents get up to 2.0× weight; overconfident
1851
+ # agents get down to 0.5×. Returns 1.0 (no-op) before any
1852
+ # oracle resolutions have populated the Brier tracker, so v0.5
1853
+ # behaviour is preserved on day one.
1854
+ home = candidate_worlds.get(c, self._world)
1855
+ assert home is not None # candidates only exist when a world is set
1856
+ base_score = home.reputation.snapshot(c, topic).score
1857
+ try:
1858
+ adj = home.reputation.calibration_adjustment(c, topic)
1859
+ except Exception:
1860
+ adj = 1.0
1861
+ return base_score * adj
1862
+
1863
+ chosen = select_panel(
1864
+ candidates,
1865
+ panel_size,
1866
+ strategy=route_strategy,
1867
+ posterior_estimate=lambda c: _default_posterior_for(_rep_for(c)),
1868
+ reputation=_rep_for,
1869
+ )
1870
+
1871
+ # Each peer responds with its posterior; for sandbox we inject a
1872
+ # default responder (Beta scaled by reputation) unless one is provided.
1873
+ # v0.6+ responders may return ``(Uncertainty, StructuredResponse)`` —
1874
+ # the unpacker normalises both forms transparently.
1875
+ from hypermind.deliberation import run_deliberation
1876
+
1877
+ route_reason = route_strategy if isinstance(route_strategy, str) else "callable"
1878
+
1879
+ def _emit_responder_error(kid: bytes, exc: BaseException) -> None:
1880
+ try:
1881
+ self._recorder.emit(
1882
+ event_type="consult_responder_error",
1883
+ severity="warn",
1884
+ fields={
1885
+ "responder_kid": kid.hex(),
1886
+ "error_type": type(exc).__name__,
1887
+ "error_msg": str(exc)[:200],
1888
+ },
1889
+ namespace=self.namespace,
1890
+ agent_id=self.keypair.kid.hex(),
1891
+ )
1892
+ except Exception:
1893
+ pass # never let a recorder failure mask the consult
1894
+
1895
+ # P3-09: blind window — sleep before collecting responses so that
1896
+ # agents dispatched concurrently cannot condition on peers that finish
1897
+ # early. The sleep runs BEFORE response collection so the buffer
1898
+ # applies uniformly to all responders in this round.
1899
+ if blind_window_ms > 0:
1900
+ await asyncio.sleep(blind_window_ms / 1000.0)
1901
+
1902
+ try:
1903
+ deliberation = await run_deliberation(
1904
+ chosen=chosen,
1905
+ query=query,
1906
+ responder=responder,
1907
+ default_posterior_for=_default_posterior_for,
1908
+ rep_for=_rep_for,
1909
+ max_rounds=max(1, int(max_deliberation_rounds)),
1910
+ convergence_threshold=convergence_threshold,
1911
+ # Each round gets the full timeout — multi-round panels
1912
+ # naturally take longer, and we'd rather honour the
1913
+ # caller's per-round budget than divide it.
1914
+ timeout_per_round_s=timeout.total_seconds(),
1915
+ route_reason=route_reason,
1916
+ on_responder_error=_emit_responder_error,
1917
+ )
1918
+ except TimeoutError as e:
1919
+ raise HyperMindError(
1920
+ f"consult: timed out after {timeout.total_seconds()}s waiting "
1921
+ f"for {panel_size} panel response(s).\n"
1922
+ f" Hint: increase `timeout=` or check that responder agents are "
1923
+ f"healthy. Default in-process responders should reply instantly."
1924
+ ) from e
1925
+
1926
+ panel: list[PanelMember] = list(deliberation.final_panel)
1927
+
1928
+ # P2-10: Annotate cross-namespace panel members with their home
1929
+ # namespace_id and a snapshot of their foreign reputation.
1930
+ # Same-namespace members keep namespace_id=None (no annotation).
1931
+ for member in panel:
1932
+ home_world = candidate_worlds.get(member.agent_id)
1933
+ if home_world is not None and home_world is not self._world:
1934
+ member.namespace_id = (
1935
+ home_world.namespace_id.hex()
1936
+ if isinstance(home_world.namespace_id, (bytes, bytearray))
1937
+ else str(home_world.namespace_id)
1938
+ )
1939
+ member.foreign_reputation_snapshot = _rep_for(member.agent_id)
1940
+
1941
+ structured_panel: list[Any] = [m.structured for m in panel if m.structured is not None]
1942
+
1943
+ if max_deliberation_rounds > 1:
1944
+ try:
1945
+ self._recorder.emit(
1946
+ event_type="consult_deliberation",
1947
+ severity="info",
1948
+ fields={
1949
+ "n_rounds": deliberation.n_rounds,
1950
+ "converged": deliberation.converged,
1951
+ "converged_reason": deliberation.converged_reason,
1952
+ "final_disagreement": deliberation.final_disagreement,
1953
+ },
1954
+ namespace=self.namespace,
1955
+ agent_id=self.keypair.kid.hex(),
1956
+ )
1957
+ except Exception:
1958
+ pass
1959
+
1960
+ if not panel:
1961
+ raise HyperMindError(
1962
+ "consult: every panel responder failed; cannot aggregate.",
1963
+ reason=BlockReason(
1964
+ rule_id="CONSULT-PANEL-INSUFFICIENT",
1965
+ spec_ref="spec/09-consult.md#panel-aggregation",
1966
+ hint=(
1967
+ "Ensure at least one panel member is available"
1968
+ " and above reputation threshold."
1969
+ ),
1970
+ ),
1971
+ )
1972
+
1973
+ # Final round's aggregate is canonical; aggregate_panel() is
1974
+ # deterministic so this is identical to recomputing.
1975
+ aggregated = deliberation.final_posterior
1976
+ disagreement = deliberation.final_disagreement
1977
+
1978
+ # Issue a CONSULT-shaped receipt sealing the result.
1979
+ self._hlc = self._hlc.tick()
1980
+ receipt = SignedStatement.sign(
1981
+ keypair=self.keypair,
1982
+ record_type="RECEIPT",
1983
+ namespace_id=self._world.namespace_id,
1984
+ hlc=self._hlc,
1985
+ payload=cbor2.dumps(
1986
+ {
1987
+ "query": query,
1988
+ "panel": [m.agent_id for m in panel],
1989
+ "aggregated": aggregated.to_cbor(),
1990
+ "disagreement": disagreement,
1991
+ },
1992
+ canonical=True,
1993
+ ),
1994
+ receipt_uncertainty=aggregated,
1995
+ )
1996
+ self._world.ts.append_and_cosign(receipt.kid)
1997
+ self._world.sealed[receipt.kid] = receipt
1998
+ self._world.persist_claim(receipt)
1999
+
2000
+ composition_hash = blake3_32(b"".join(sorted(c for c in chosen))).hex()
2001
+
2002
+ # Build argument DAG: receipt elicits each member, plus revision
2003
+ # edges from any deliberation rounds (qualifies/rebuts/supports).
2004
+ elicitation_edges = [
2005
+ ArgumentEdge(
2006
+ src_kid=receipt.kid,
2007
+ dst_kid=m.agent_id,
2008
+ edge_type="elicits",
2009
+ )
2010
+ for m in panel
2011
+ ]
2012
+ revision_edges = deliberation.all_revision_edges()
2013
+ return ConsultResult(
2014
+ query=query,
2015
+ posterior=aggregated,
2016
+ panel=panel,
2017
+ argument_dag=ArgumentDAG(
2018
+ edges=elicitation_edges + revision_edges,
2019
+ ),
2020
+ disagreement=disagreement,
2021
+ calibration_truth_axis=CalibTruthSnapshot.from_metrics(
2022
+ calibration_brier=aggregated.brier(1)
2023
+ if aggregated.mean() > 0.5
2024
+ else aggregated.brier(0),
2025
+ truth_coverage_ratio=0.0, # no oracle yet in sandbox
2026
+ ),
2027
+ receipt_kid=receipt.kid,
2028
+ panel_composition_hash=composition_hash,
2029
+ structured_panel=structured_panel,
2030
+ mixture=build_posterior_mixture(panel),
2031
+ )
2032
+
2033
+ async def consult_stream(
2034
+ self,
2035
+ query: str,
2036
+ *,
2037
+ panel_size: int = 5,
2038
+ topic: str | None = None,
2039
+ blind_window_ms: int = 0,
2040
+ ) -> AsyncIterator[dict[str, Any]]:
2041
+ """Streaming variant of consult(). Yields partial results as panel members respond.
2042
+
2043
+ Each yielded item:
2044
+ {
2045
+ "member_kid": str, # hex agent id
2046
+ "posterior": {"mean": float | None},
2047
+ "reasoning": str | None,
2048
+ "namespace_id": str | None, # set for cross-namespace members
2049
+ }
2050
+
2051
+ `blind_window_ms` inserts an artificial delay between yields so callers
2052
+ can simulate a "blind window" where members cannot see earlier answers.
2053
+ """
2054
+ result = await self.consult(query, panel_size=panel_size, topic=topic)
2055
+ for member in result.panel:
2056
+ if blind_window_ms > 0:
2057
+ await asyncio.sleep(blind_window_ms / 1000.0)
2058
+ agent_id = member.agent_id
2059
+ kid_str = agent_id.hex() if isinstance(agent_id, (bytes, bytearray)) else str(agent_id)
2060
+ yield {
2061
+ "member_kid": kid_str,
2062
+ "posterior": {
2063
+ "mean": member.posterior.mean() if member.posterior is not None else None
2064
+ },
2065
+ "reasoning": getattr(member, "reasoning", None)
2066
+ or (member.structured.reasoning if member.structured is not None else None),
2067
+ "namespace_id": member.namespace_id,
2068
+ }
2069
+
2070
+ async def reputation_of(
2071
+ self,
2072
+ other: bytes,
2073
+ *,
2074
+ topic: str | None = None,
2075
+ ) -> ReputationSnapshot:
2076
+ if not self._world:
2077
+ raise RuntimeError(
2078
+ "reputation_of() requires a world.\n"
2079
+ " Hint: build the agent via HyperMindAgent.sandbox() or "
2080
+ "HyperMindAgent.testbed(n=…)."
2081
+ )
2082
+ topic = topic or (self.rooms[0] if self.rooms else "default")
2083
+ return self._world.reputation.snapshot(other, topic)
2084
+
2085
+ async def record_panel_vote(
2086
+ self,
2087
+ claim_kid: Kid,
2088
+ voter_kid: bytes,
2089
+ vote: bool,
2090
+ *,
2091
+ topic: str | None = None,
2092
+ ) -> None:
2093
+ """Record that ``voter_kid`` voted ``vote`` on ``claim_kid``.
2094
+
2095
+ Used to enable ORACLE_RESOLUTION back-propagation
2096
+ (ROADMAP §0.3.2): when an oracle later resolves the same claim,
2097
+ voters who agreed with the oracle's decision earn a reputation
2098
+ success; those who disagreed earn a failure. Per-topic.
2099
+ """
2100
+ if self._world is None:
2101
+ raise RuntimeError("record_panel_vote() requires a world")
2102
+ topic = topic or (self.rooms[0] if self.rooms else "default")
2103
+ self._world.panel_votes.setdefault(claim_kid, []).append(
2104
+ (bytes(voter_kid), bool(vote), topic)
2105
+ )
2106
+
2107
+ async def oracle_resolve(
2108
+ self,
2109
+ claim_kid: Kid,
2110
+ *,
2111
+ outcome: bool,
2112
+ evidence_cid: bytes | None = None,
2113
+ backprop: bool = True,
2114
+ ) -> Kid:
2115
+ """Oracle role only: resolve a claim with ground truth.
2116
+
2117
+ Closes the calibration loop — the publisher's reputation is
2118
+ updated based on the agreement between their claim and the
2119
+ outcome.
2120
+ """
2121
+ if not isinstance(outcome, bool):
2122
+ raise TypeError(
2123
+ f"oracle_resolve outcome must be bool, got {type(outcome).__name__!r}: {outcome!r}"
2124
+ )
2125
+ self._require_cap("oracle_resolve")
2126
+ if self.role != "oracle":
2127
+ raise AuthError(
2128
+ "only oracle agents may oracle_resolve",
2129
+ failed_caveat="role",
2130
+ )
2131
+ if self._world is None:
2132
+ raise RuntimeError(
2133
+ "oracle_resolve() requires a world.\n"
2134
+ " Hint: build the oracle via HyperMindAgent.testbed(…) and set "
2135
+ "`agent.role = 'oracle'` post-construction."
2136
+ )
2137
+ target = self._world.sealed.get(claim_kid)
2138
+ if target is None:
2139
+ raise HyperMindError(
2140
+ f"oracle_resolve: claim {claim_kid.hex()[:12]}… not found in "
2141
+ f"this namespace ({len(self._world.sealed)} sealed claim(s)).\n"
2142
+ f" Hint: oracles can only resolve claims that exist in their "
2143
+ f"own world. Check the namespace matches the claim's origin."
2144
+ )
2145
+
2146
+ # Compute outcome-adjustment for the publisher's reputation.
2147
+ decoded = cbor2.loads(target.payload)
2148
+ topic = decoded.get("topic", "default")
2149
+
2150
+ publisher = target.issuer_kid
2151
+ # Record outcome for the publisher; weight outcomes 1.0.
2152
+ self._world.reputation.record_outcome(publisher, topic, correct=outcome)
2153
+
2154
+ # v0.6 — closed-loop calibration. If the publisher embedded their
2155
+ # predicted mean (publish_claim does this by default), record a
2156
+ # Brier score on the publisher's per-(agent, topic) tracker so
2157
+ # future consult dispatches can shrink/boost their effective_n.
2158
+ predicted = decoded.get("predicted_mean")
2159
+ if predicted is not None:
2160
+ try:
2161
+ self._world.reputation.brier_tracker.record(
2162
+ agent_kid=publisher,
2163
+ topic=topic,
2164
+ predicted_confidence=float(predicted),
2165
+ outcome=1.0 if outcome else 0.0,
2166
+ )
2167
+ except Exception:
2168
+ pass
2169
+
2170
+ # ROADMAP §0.3.2: back-propagate to panel voters who weighed in
2171
+ # on this claim. Voters who agreed with the oracle earn one
2172
+ # success outcome on the claim's topic; those who disagreed earn
2173
+ # one failure outcome. Bounded by per-claim, per-voter de-dup so
2174
+ # repeated oracle calls don't double-count.
2175
+ if backprop:
2176
+ votes = self._world.panel_votes.get(claim_kid, [])
2177
+ already = self._world.panel_votes.get(b"_resolved:" + claim_kid, [])
2178
+ already_set = {(v[0], v[2]) for v in already}
2179
+ newly_credited: list[tuple[bytes, bool, str]] = []
2180
+ for voter, vote, vote_topic in votes:
2181
+ key = (voter, vote_topic)
2182
+ if key in already_set:
2183
+ continue
2184
+ self._world.reputation.record_outcome(voter, vote_topic, correct=(vote == outcome))
2185
+ newly_credited.append((voter, vote, vote_topic))
2186
+ already_set.add(key)
2187
+ if newly_credited:
2188
+ self._world.panel_votes.setdefault(b"_resolved:" + claim_kid, []).extend(
2189
+ newly_credited
2190
+ )
2191
+
2192
+ # Emit an ORACLE_RESOLUTION receipt.
2193
+ self._hlc = self._hlc.tick()
2194
+ statement = SignedStatement.sign(
2195
+ keypair=self.keypair,
2196
+ record_type="RECEIPT",
2197
+ namespace_id=self._world.namespace_id,
2198
+ hlc=self._hlc,
2199
+ payload=cbor2.dumps(
2200
+ {
2201
+ "kind": "oracle_resolution",
2202
+ "target_kid": claim_kid,
2203
+ "outcome": outcome,
2204
+ "evidence_cid": evidence_cid,
2205
+ },
2206
+ canonical=True,
2207
+ ),
2208
+ receipt_uncertainty=Uncertainty.beta(
2209
+ 10 if outcome else 1,
2210
+ 1 if outcome else 10,
2211
+ ),
2212
+ )
2213
+ self._world.ts.append_and_cosign(statement.kid)
2214
+ self._world.sealed[statement.kid] = statement
2215
+ self._world.persist_claim(statement)
2216
+ return statement.kid
2217
+
2218
+ async def audit_export(self, *, since_hlc: str | None = None) -> AsyncIterator[bytes]:
2219
+ """Yield signed JSONL audit records since the given HLC, in a
2220
+ signed hash-chain so a verifier can detect truncation/reorder.
2221
+
2222
+ Closes self-heal Cycle-1 Findings #5 (chain) and #8 (HLC ordering):
2223
+ - Each record is signed over `(prev_hash || seq || session_id || body)`
2224
+ so dropping or reordering breaks the chain.
2225
+ - `since_hlc` filtering is performed by parsing both endpoints'
2226
+ `(logical_ms, counter)` so lexicographic string comparison
2227
+ doesn't misorder integer-bearing strings.
2228
+ - A final "end-of-audit" record signs the last hash so silent
2229
+ truncation at the tail is detectable.
2230
+ - bytes-valued telemetry fields are hex-encoded for JSON.
2231
+ """
2232
+ import hashlib as _h
2233
+ import secrets as _s
2234
+
2235
+ recorder = self._recorder
2236
+ # Snapshot under the recorder's lock so concurrent emit() during
2237
+ # iteration cannot reorder/skip entries (Finding #5 from Cycle-1
2238
+ # data review). The internal lock is exposed deliberately.
2239
+ with recorder._lock:
2240
+ snapshot = list(recorder.records)
2241
+ # WS-G v0.7: when the agent uses a per-namespace scoped recorder,
2242
+ # also pull the process-global recorder's view so direct callers
2243
+ # of ``get_recorder().emit(...)`` (operator hooks, tests) still
2244
+ # surface in the audit export. Scoped recorders fan-out to the
2245
+ # global, so the same ``LogRecord`` instance appears in both
2246
+ # buffers — id() dedup is exact.
2247
+ _global = get_recorder()
2248
+ if _global is not recorder:
2249
+ with _global._lock:
2250
+ seen_ids = {id(r) for r in snapshot}
2251
+ for r in _global.records:
2252
+ if id(r) not in seen_ids:
2253
+ snapshot.append(r)
2254
+ snapshot.sort(key=lambda r: r.ts_wall_ms)
2255
+
2256
+ since_l, since_c = _parse_hlc_for_filter(since_hlc)
2257
+ session_id = _s.token_bytes(16).hex()
2258
+ prev_hash = b"\x00" * 32
2259
+ seq = 0
2260
+
2261
+ def _emit(body_dict: dict[str, Any]) -> bytes:
2262
+ nonlocal prev_hash, seq
2263
+ body = json.dumps(_jsonable(body_dict), sort_keys=True).encode()
2264
+ preimage = prev_hash + seq.to_bytes(8, "big") + session_id.encode() + body
2265
+ sig = self.keypair.sign(preimage)
2266
+ prev_hash = _h.sha256(preimage + sig).digest()
2267
+ line = (
2268
+ body
2269
+ + b"\t"
2270
+ + sig.hex().encode()
2271
+ + b"\t"
2272
+ + str(seq).encode()
2273
+ + b"\t"
2274
+ + session_id.encode()
2275
+ + b"\n"
2276
+ )
2277
+ seq += 1
2278
+ return line
2279
+
2280
+ for record in snapshot:
2281
+ rec_l, rec_c = _parse_hlc_for_filter(record.ts_hlc)
2282
+ if since_hlc and (rec_l, rec_c) < (since_l, since_c):
2283
+ continue
2284
+ yield _emit(
2285
+ {
2286
+ "ts_wall_ms": record.ts_wall_ms,
2287
+ "ts_hlc": record.ts_hlc,
2288
+ "namespace": record.namespace,
2289
+ "agent_id": record.agent_id,
2290
+ "event_type": record.event_type,
2291
+ "severity": record.severity,
2292
+ **record.fields,
2293
+ }
2294
+ )
2295
+
2296
+ # End-of-audit terminator binds the final hash; verifiers detect
2297
+ # truncation when this is missing.
2298
+ yield _emit({"event_type": "audit_end", "total": seq})
2299
+
2300
+ # ---- working memory ------------------------------------------------
2301
+
2302
+ def working_memory(self) -> dict[str, Any]:
2303
+ """Mutable scratch space; not gossiped until checkpoint()."""
2304
+ return self._working_memory
2305
+
2306
+ async def checkpoint(self, *, label: str | None = None) -> Kid:
2307
+ """Seal the current working memory as an immutable kid.
2308
+
2309
+ Snapshots the working-memory dict synchronously before the
2310
+ async publish; concurrent mutations after the snapshot do NOT
2311
+ affect the sealed payload (closes self-heal Cycle-1 Finding #6
2312
+ from data review).
2313
+ """
2314
+ self._require_cap("checkpoint")
2315
+ snapshot = _deep_snapshot(self._working_memory)
2316
+ return await self.publish_claim(
2317
+ {"working_memory": snapshot, "label": label},
2318
+ claim_type="custom",
2319
+ uncertainty=Uncertainty.beta(1, 1),
2320
+ )
2321
+
2322
+ # ---- AgentCard publication (spec §3.2 / P3-01) ---------------------
2323
+
2324
+ async def publish_card(self, card: AgentCard | None = None) -> Kid:
2325
+ """Publish this agent's self-description as a sealed claim.
2326
+
2327
+ If ``card`` is ``None`` an :class:`~hypermind.agent_card.AgentCard`
2328
+ is auto-built from the agent's current attributes: ``keypair.kid``,
2329
+ ``rooms`` (used as ``topics``), ``role``, and the installed SDK
2330
+ version.
2331
+
2332
+ The card rides in a standard SEAL envelope as ``claim_type="agent_card"``
2333
+ so peers can discover it through the normal transparency log.
2334
+
2335
+ Returns the 32-byte statement kid so callers can cite or audit
2336
+ the published card.
2337
+ """
2338
+ if card is None:
2339
+ from hypermind import __version__ as _sdk_version
2340
+
2341
+ card = AgentCard(
2342
+ agent_kid=self.keypair.kid,
2343
+ display_name="",
2344
+ topics=list(self.rooms),
2345
+ roles=[self.role],
2346
+ capabilities=[],
2347
+ endpoint="",
2348
+ sdk_version=_sdk_version,
2349
+ )
2350
+ return await self.publish_claim(
2351
+ card.to_payload(),
2352
+ claim_type="agent_card",
2353
+ uncertainty=Uncertainty.beta(1, 1),
2354
+ )
2355
+
2356
+ # ---- DX surface (spec/08 — MUST-expose) ----------------------------
2357
+
2358
+ async def introspect(self) -> dict[str, Any]:
2359
+ """Full capability snapshot — spec/08-onboarding-contract.md §8.A."""
2360
+ return {
2361
+ "version": "0.1.0a0",
2362
+ "wire_version": 0,
2363
+ "agent_id": self.keypair.kid.hex(),
2364
+ "did_key": self.keypair.did_key,
2365
+ "namespace": self.namespace,
2366
+ "rooms": self.rooms,
2367
+ "role": self.role,
2368
+ "sandbox": self.is_sandbox,
2369
+ "ramp": {
2370
+ "started_at_ms": self._ramp_start_ms,
2371
+ "paused": self._ramp_paused,
2372
+ "paused_days": self._ramp_paused_days,
2373
+ "observed_outcomes": self._observed_outcomes,
2374
+ "complete": self._observed_outcomes >= 200,
2375
+ },
2376
+ "capabilities": {
2377
+ "publish_claim": self._observed_outcomes >= 200,
2378
+ "dispute": self._observed_outcomes >= 200,
2379
+ "consult": True,
2380
+ "oracle_resolve": self.role == "oracle",
2381
+ },
2382
+ }
2383
+
2384
+ async def why_blocked(self, action: str) -> BlockReason:
2385
+ """Explain why an action is gated. Carries `rule_id + spec_ref`.
2386
+
2387
+ TAL-ONB-TRANSPARENT invariant: every refusal MUST carry these.
2388
+ """
2389
+ if action in ("publish_claim", "dispute", "synthesize"):
2390
+ if self._ramp_paused:
2391
+ return BlockReason(
2392
+ rule_id="RAMP-PAUSED",
2393
+ spec_ref="spec/07-trust-access-layer.md §RAMP_PAUSE",
2394
+ human_explanation=(
2395
+ "namespace ramp is paused; publish capabilities are gated "
2396
+ "until RAMP_RESUME is signed by ≥3 founding cohort"
2397
+ ),
2398
+ current_value="paused",
2399
+ required_value="active",
2400
+ )
2401
+ if self._observed_outcomes < 200:
2402
+ return BlockReason(
2403
+ rule_id="ADMIT-RAMP-INCOMPLETE",
2404
+ spec_ref="spec/08-onboarding-contract.md §8.B",
2405
+ human_explanation=(
2406
+ "agent has not completed the 30-day reputation ramp; "
2407
+ "200 observed outcomes required"
2408
+ ),
2409
+ current_value=self._observed_outcomes,
2410
+ required_value=200,
2411
+ retry_after_seconds=86400,
2412
+ )
2413
+ if action == "oracle_resolve" and self.role != "oracle":
2414
+ return BlockReason(
2415
+ rule_id="ORACLE-ROLE-REQUIRED",
2416
+ spec_ref="spec/04-knowledge-model.md §ORACLE_RESOLUTION",
2417
+ human_explanation="only agents with role=oracle may oracle_resolve",
2418
+ current_value=self.role,
2419
+ required_value="oracle",
2420
+ )
2421
+ return BlockReason(
2422
+ rule_id="UNBLOCKED",
2423
+ spec_ref="spec/08-onboarding-contract.md §8.C",
2424
+ human_explanation=f"action {action!r} is not currently blocked",
2425
+ )
2426
+
2427
+ async def discover_mentors(self, room: str) -> list[bytes]:
2428
+ """Return ranked mentor candidates for `room` (high reputation, active)."""
2429
+ if not self._world:
2430
+ return []
2431
+ members = self._world.rooms.get(room, {}).get("members", set())
2432
+ rep = self._world.reputation
2433
+ ranked = sorted(
2434
+ (m for m in members if m != self.keypair.kid),
2435
+ key=lambda m: rep.snapshot(m, room).score,
2436
+ reverse=True,
2437
+ )
2438
+ # If a TAL Room exists for this name, filter to its members so
2439
+ # the reputation gate is enforced. Fall back to the legacy
2440
+ # `world.rooms` view when no TAL Room has been created.
2441
+ tal = getattr(self._world, "tal", None)
2442
+ if tal is not None and tal.get_room(room) is not None:
2443
+ ranked = [m for m in ranked if tal.trust_query(m, room).in_room]
2444
+ return ranked
2445
+
2446
+ async def request_vouch(self, mentor: bytes, room: str) -> VouchRequest:
2447
+ """Issue an out-of-band vouch request. v0.1 sandbox auto-grants."""
2448
+ self._require_cap("request_vouch")
2449
+ if self._world:
2450
+ self._world.reputation.vouch(mentor, self.keypair.kid, room, strength=2.0)
2451
+ tal = getattr(self._world, "tal", None)
2452
+ if tal is not None and tal.get_room(room) is not None:
2453
+ tal.vouch(
2454
+ mentor,
2455
+ self.keypair.kid,
2456
+ room_id=room,
2457
+ topic=room,
2458
+ now_hlc_ms=self._hlc.logical_ms,
2459
+ )
2460
+ return VouchRequest(requester=self.keypair.kid, mentor=mentor, room=room)
2461
+
2462
+ # ---- sandbox helpers ------------------------------------------------
2463
+
2464
+ def advance_time(self, delta: timedelta) -> None:
2465
+ """Decouple wall-clock from HLC for ramp/dispute TTL tests."""
2466
+ self._hlc = HLC(
2467
+ logical_ms=self._hlc.logical_ms + int(delta.total_seconds() * 1000),
2468
+ counter=0,
2469
+ node_id=self._hlc.node_id,
2470
+ )
2471
+ self._ramp_start_ms -= int(delta.total_seconds() * 1000)
2472
+
2473
+ def pause_ramp(self) -> None:
2474
+ self._ramp_paused = True
2475
+
2476
+ def resume_ramp(self) -> None:
2477
+ self._ramp_paused = False
2478
+
2479
+ async def next_dispute(self, target_kid: Kid, *, timeout: float = 5.0) -> Dispute:
2480
+ """Test helper: wait for the next dispute against `target_kid`."""
2481
+ if not self._world:
2482
+ raise RuntimeError(
2483
+ "next_dispute() requires a world.\n"
2484
+ " Hint: build the agent via HyperMindAgent.sandbox() or "
2485
+ "HyperMindAgent.testbed(n=…)."
2486
+ )
2487
+ deadline = time.time() + timeout
2488
+ while True:
2489
+ for d in self._world.disputes.values():
2490
+ if d.target_kid == target_kid:
2491
+ return d
2492
+ remaining = deadline - time.time()
2493
+ if remaining <= 0:
2494
+ break
2495
+ self._world.dispute_event.clear()
2496
+ # Re-check after clearing to avoid a race where the dispute
2497
+ # arrived between the scan above and the clear here.
2498
+ for d in self._world.disputes.values():
2499
+ if d.target_kid == target_kid:
2500
+ return d
2501
+ try:
2502
+ await asyncio.wait_for(self._world.dispute_event.wait(), timeout=remaining)
2503
+ except TimeoutError:
2504
+ break
2505
+ raise TimeoutError(f"no dispute against {target_kid.hex()[:12]}... within {timeout}s")
2506
+
2507
+ # ---- task delegation (spec §4.4) -----------------------------------
2508
+
2509
+ async def submit_task(
2510
+ self,
2511
+ target_kid: bytes,
2512
+ goal: str,
2513
+ payload: dict | None = None,
2514
+ *,
2515
+ topic: str = "tasks",
2516
+ timeout: float = 30.0,
2517
+ ) -> TaskHandle: # noqa: F821
2518
+ """Delegate a task to another agent. Returns a TaskHandle to await the result.
2519
+
2520
+ The task is gossiped as a TASK_SUBMIT claim on `topic`. The assignee
2521
+ should call `complete_task(task_id, result)` when done; this agent
2522
+ subscribes for TASK_COMPLETE claims on the same topic and resolves the
2523
+ handle's future automatically via `on_task_complete`.
2524
+ """
2525
+ import uuid
2526
+
2527
+ from hypermind.tasks import TaskHandle
2528
+
2529
+ task_id = uuid.uuid4().hex
2530
+ # Ensure the task topic is accessible (join it lazily so callers don't
2531
+ # need to pre-join "tasks").
2532
+ if topic not in self.rooms:
2533
+ self.rooms.append(topic)
2534
+ if self._world is not None:
2535
+ self._world.rooms.setdefault(topic, {"min_rep": 0.0, "members": set()})[
2536
+ "members"
2537
+ ].add(self.keypair.kid)
2538
+
2539
+ handle = TaskHandle(
2540
+ task_id=task_id,
2541
+ submitter_kid=self.keypair.kid,
2542
+ assignee_kid=target_kid,
2543
+ topic=topic,
2544
+ )
2545
+
2546
+ if not hasattr(self, "_pending_tasks"):
2547
+ self._pending_tasks: dict[str, TaskHandle] = {}
2548
+ self._pending_tasks[task_id] = handle
2549
+
2550
+ await self.publish_claim(
2551
+ {
2552
+ "task_id": task_id,
2553
+ "goal": goal,
2554
+ "payload": payload or {},
2555
+ "assignee": target_kid.hex(),
2556
+ },
2557
+ claim_type="task_submit",
2558
+ uncertainty=Uncertainty.beta(1, 1),
2559
+ topic=topic,
2560
+ )
2561
+ return handle
2562
+
2563
+ async def complete_task(
2564
+ self,
2565
+ task_id: str,
2566
+ result: Any,
2567
+ *,
2568
+ topic: str = "tasks",
2569
+ ) -> None:
2570
+ """Mark a task as complete. Called by the assignee agent.
2571
+
2572
+ Publishes a TASK_COMPLETE claim on `topic`. The submitter's
2573
+ `on_task_complete` subscription will resolve the TaskHandle future.
2574
+ """
2575
+ if topic not in self.rooms:
2576
+ self.rooms.append(topic)
2577
+ if self._world is not None:
2578
+ self._world.rooms.setdefault(topic, {"min_rep": 0.0, "members": set()})[
2579
+ "members"
2580
+ ].add(self.keypair.kid)
2581
+
2582
+ await self.publish_claim(
2583
+ {"task_id": task_id, "result": result, "status": "complete"},
2584
+ claim_type="task_complete",
2585
+ uncertainty=Uncertainty.beta(9, 1),
2586
+ topic=topic,
2587
+ )
2588
+
2589
+ async def on_task_complete(
2590
+ self,
2591
+ *,
2592
+ topic: str = "tasks",
2593
+ ) -> Subscription:
2594
+ """Subscribe to TASK_COMPLETE claims on `topic` and resolve pending TaskHandles.
2595
+
2596
+ Returns the Subscription so the caller can unsubscribe when done.
2597
+ Call this once after `submit_task` to wire up the result delivery loop.
2598
+ """
2599
+ from hypermind.tasks import TaskHandle # noqa: F401 (used in type comment)
2600
+
2601
+ agent_self = self
2602
+
2603
+ async def _on_claim(event: ClaimEvent) -> None:
2604
+ try:
2605
+ decoded = cbor2.loads(event.statement.payload)
2606
+ except Exception:
2607
+ return
2608
+ if decoded.get("claim_type") != "task_complete":
2609
+ return
2610
+ # publish_claim wraps the caller's dict under "payload"; unwrap it.
2611
+ inner = decoded.get("payload", {})
2612
+ task_id = inner.get("task_id")
2613
+ result_val = inner.get("result")
2614
+ if task_id is None:
2615
+ return
2616
+ pending = getattr(agent_self, "_pending_tasks", {})
2617
+ handle = pending.get(task_id)
2618
+ if handle is not None:
2619
+ handle._resolve(result_val)
2620
+
2621
+ return await self.subscribe(topic, _on_claim)
2622
+
2623
+ # ---- internal helpers ----------------------------------------------
2624
+
2625
+ def _require_open(self) -> None:
2626
+ if self._closed:
2627
+ raise RuntimeError(
2628
+ "agent is closed.\n"
2629
+ " Hint: this agent has already been closed (you exited the "
2630
+ "`async with` block or called .close() explicitly). Build a "
2631
+ "fresh agent to continue."
2632
+ )
2633
+
2634
+ def _enforce_ramp(self) -> None:
2635
+ if self._ramp_paused:
2636
+ raise RampError(
2637
+ "namespace ramp is paused; publish capabilities are gated until "
2638
+ "RAMP_RESUME is signed by ≥3 founding cohort.\n"
2639
+ " Hint: in tests, call agent.resume_ramp() to clear the pause.",
2640
+ state="paused",
2641
+ )
2642
+ if self._observed_outcomes < 200:
2643
+ raise RampError(
2644
+ f"30-day ramp incomplete ({self._observed_outcomes}/200 outcomes).\n"
2645
+ f" Hint: in tests, build the agent with "
2646
+ f"`HyperMindAgent.testbed(..., ramp_complete=True)` (default), or "
2647
+ f"call `await agent.why_blocked('publish_claim')` for the full "
2648
+ f"BlockReason with rule_id and spec_ref.",
2649
+ state="pending",
2650
+ )
2651
+
2652
+ def _require_cap(
2653
+ self,
2654
+ action: str,
2655
+ *,
2656
+ topic: str | None = None,
2657
+ tool: str | None = None,
2658
+ ) -> None:
2659
+ """Enforce cap_token constraints for the given action (P1-01).
2660
+
2661
+ - Returns immediately when no cap_token is set (unconstrained agent).
2662
+ - Calls ``cap_token.authorize(...)`` which raises ``AuthError`` on
2663
+ caveat failure — topic mismatch, expiry, tool mismatch, etc.
2664
+ - After scope checks, enforces any ``rate`` caveat via ``TokenBucket``
2665
+ (P1-03 / HM-RATE-CAP-001). Raises ``RateLimitError`` if exhausted.
2666
+ """
2667
+ if self.cap_token is None:
2668
+ return
2669
+
2670
+ self.cap_token.authorize(
2671
+ action=action,
2672
+ topic=topic or "",
2673
+ tool=tool or "",
2674
+ revocation_store=self._world.revocation_store if self._world else None,
2675
+ hlc=(self._hlc.logical_ms, self._hlc.counter),
2676
+ )
2677
+
2678
+ # P1-03: enforce rate caveat if present on this token.
2679
+
2680
+ rate_spec: str | None = None
2681
+ for cav in self.cap_token.caveats:
2682
+ if cav.kind == "rate":
2683
+ rate_spec = str(cav.value)
2684
+ break # first rate caveat wins (most restrictive after attenuation)
2685
+
2686
+ if rate_spec is not None:
2687
+ # Key: root_kid (stable across attenuations) + action so that different
2688
+ # actions share per-action buckets, not one global bucket.
2689
+ bucket_key = f"{self.cap_token.root_issuer.hex()}:{action}"
2690
+ if bucket_key not in self._rate_buckets:
2691
+ self._rate_buckets[bucket_key] = TokenBucket(rate_spec)
2692
+ bucket: TokenBucket = self._rate_buckets[bucket_key]
2693
+ if not bucket.consume():
2694
+ raise RateLimitError(
2695
+ f"rate caveat exhausted for action={action!r} "
2696
+ f"(limit={rate_spec}); try again later",
2697
+ telemetry={"rule_id": "HM-RATE-CAP-001", "action": action, "limit": rate_spec},
2698
+ )
2699
+
2700
+ def _select_confirmers(self, topic: str, *, exclude: bytes) -> list[bytes]:
2701
+ """Pick ≥3 confirmer keys (quorum size for sandbox)."""
2702
+ if not self._world:
2703
+ # Single-process unit-test mode: synthesize ephemeral kids.
2704
+ return [secrets.token_bytes(32) for _ in range(3)]
2705
+ peers = [k for k in self._world.agents.keys() if k != exclude]
2706
+ if len(peers) >= 3:
2707
+ return peers[:3]
2708
+ # Pad with witness keys to satisfy ≥3 confirmer_set requirement.
2709
+ peers.extend([w.kid for w in self._world.ts.witness_keypairs])
2710
+ return peers[: max(3, len(peers))]
2711
+
2712
+ def _encode_envelope(
2713
+ self,
2714
+ statement: SignedStatement,
2715
+ *,
2716
+ cosigs: list[WitnessCosignature] | None = None,
2717
+ ) -> bytes:
2718
+ # I6: include tree_size so receivers can reconstruct
2719
+ # WitnessCosignature and verify inline. Pre-I6 envelopes that
2720
+ # omit "size" remain decodable — the receiver-side verifier
2721
+ # treats absence as opt-out (see _verify_envelope_cosigs).
2722
+ return cbor2.dumps(
2723
+ {
2724
+ "statement": statement.to_bytes(),
2725
+ "cosigs": [
2726
+ {
2727
+ "witness": c.witness_kid,
2728
+ "sth": c.sth_root,
2729
+ "size": c.tree_size,
2730
+ "sig": c.signature,
2731
+ }
2732
+ for c in (cosigs or [])
2733
+ ],
2734
+ },
2735
+ canonical=True,
2736
+ )
2737
+
2738
+ def _block(self, rule_id: str, spec_ref: str, msg: str) -> AuthError:
2739
+ err = AuthError(
2740
+ msg,
2741
+ failed_caveat=rule_id,
2742
+ telemetry={"rule_id": rule_id, "spec_ref": spec_ref},
2743
+ )
2744
+ return err
2745
+
2746
+
2747
+ # -----------------------------------------------------------------------
2748
+ # helpers / aux types
2749
+ # -----------------------------------------------------------------------
2750
+
2751
+
2752
+ @dataclass
2753
+ class ClaimEvent:
2754
+ """Delivered to subscribe() handlers."""
2755
+
2756
+ statement: SignedStatement
2757
+ raw: dict[str, Any]
2758
+
2759
+
2760
+ def _default_posterior_for(reputation_score: float) -> Uncertainty:
2761
+ """Toy posterior: agents with higher rep produce confidently-positive
2762
+ Beta posteriors; lower rep produces near-uniform."""
2763
+ alpha = 1.0 + 9.0 * max(reputation_score, 0.0)
2764
+ beta = 1.0 + 9.0 * max(1 - reputation_score, 0.0)
2765
+ return Uncertainty.beta(alpha, beta)
2766
+
2767
+
2768
+ def _hash_topic_scope(scope: str) -> bytes:
2769
+ """Bond topic-scope hash (TAL-ESCROW-EXCLUSIVE)."""
2770
+ return blake3_32(scope.encode("utf-8"))
2771
+
2772
+
2773
+ import contextlib # noqa: E402 (placed near factories for grouping)
2774
+ import inspect # noqa: E402
2775
+
2776
+
2777
+ async def _invoke_dispute_callback(
2778
+ cb: Callable[[Dispute], Any],
2779
+ dispute: Dispute,
2780
+ *,
2781
+ recorder: Any,
2782
+ ) -> None:
2783
+ """Invoke a user-supplied on_dispute callback safely.
2784
+
2785
+ Accepts both sync and async callbacks. Exceptions are NOT silently
2786
+ swallowed — they're recorded via `hypermind_handler_errors_total`
2787
+ so a misbehaving callback is auditable instead of invisible.
2788
+ """
2789
+ try:
2790
+ result = cb(dispute)
2791
+ if inspect.isawaitable(result):
2792
+ await result
2793
+ except Exception as e:
2794
+ try:
2795
+ recorder.incr(
2796
+ "hypermind_handler_errors_total",
2797
+ kind=type(e).__name__,
2798
+ where="on_dispute",
2799
+ )
2800
+ recorder.emit(
2801
+ namespace="",
2802
+ agent_id="",
2803
+ event_type="dispute_callback_error",
2804
+ severity="error",
2805
+ error=type(e).__name__,
2806
+ message=str(e),
2807
+ )
2808
+ except Exception: # pragma: no cover
2809
+ pass
2810
+
2811
+
2812
+ @contextlib.asynccontextmanager
2813
+ async def testbed_cohort(
2814
+ n: int = 2,
2815
+ *,
2816
+ room: str = "default",
2817
+ namespace: str = "sandbox",
2818
+ ramp_complete: bool = True,
2819
+ recorder: Any = None,
2820
+ ) -> AsyncIterator[list[HyperMindAgent]]:
2821
+ """Async context manager — yield a list of N agents and clean up
2822
+ them all on exit. The recommended factory for multi-agent demos.
2823
+
2824
+ ``recorder`` (I0): optional pre-scoped Recorder. When supplied, every
2825
+ agent's events carry that recorder's ``namespace_id`` so callers like
2826
+ the SimLab runner can isolate events per sim_id without post-hoc
2827
+ filtering. Default ``None`` preserves the legacy lazy-init behaviour
2828
+ keyed on the agent world's namespace_id.
2829
+
2830
+ Example:
2831
+ async with testbed_cohort(3, room="ai-safety") as (alice, bob, carol):
2832
+ await alice.publish_claim(...)
2833
+ ...
2834
+ # all three agents are closed automatically
2835
+ """
2836
+ agents = HyperMindAgent.testbed(
2837
+ agents=n,
2838
+ room=room,
2839
+ namespace=namespace,
2840
+ ramp_complete=ramp_complete,
2841
+ recorder=recorder,
2842
+ )
2843
+ try:
2844
+ yield agents
2845
+ finally:
2846
+ for a in agents:
2847
+ await a.close()
2848
+
2849
+
2850
+ def _parse_hlc_for_filter(s: str | None) -> tuple[int, int]:
2851
+ """Parse `HLC(<logical_ms>.<counter>@<node>)` into (l, c) for ordering.
2852
+
2853
+ Falls back to (0, 0) on absence/parse-failure so unparseable HLCs sort
2854
+ before any well-formed one. Used by `audit_export(since_hlc=...)` to
2855
+ avoid the lexicographic-string-comparison bug (Finding #8 from
2856
+ Cycle-1 data review).
2857
+ """
2858
+ if not s:
2859
+ return (0, 0)
2860
+ try:
2861
+ # Accept "HLC(123.4@…)" or "123.4" forms.
2862
+ body = s
2863
+ if body.startswith("HLC("):
2864
+ body = body[4:].split("@", 1)[0].rstrip(")")
2865
+ if "." in body:
2866
+ l_str, c_str = body.split(".", 1)
2867
+ else:
2868
+ l_str, c_str = body, "0"
2869
+ return (int(float(l_str)), int(float(c_str)))
2870
+ except (ValueError, IndexError):
2871
+ return (0, 0)
2872
+
2873
+
2874
+ def _jsonable(obj: Any) -> Any:
2875
+ """Recursively convert bytes → hex so json.dumps doesn't fail on
2876
+ fields the recorder may emit (kid, agent_id are already hex strings,
2877
+ but custom telemetry may carry raw bytes)."""
2878
+ if isinstance(obj, bytes):
2879
+ return obj.hex()
2880
+ if isinstance(obj, dict):
2881
+ return {k: _jsonable(v) for k, v in obj.items()}
2882
+ if isinstance(obj, (list, tuple)):
2883
+ return [_jsonable(v) for v in obj]
2884
+ return obj
2885
+
2886
+
2887
+ def _deep_snapshot(obj: Any) -> Any:
2888
+ """Deep-copy primitive containers so a checkpoint cannot be
2889
+ retroactively mutated by the caller after the await point."""
2890
+ if isinstance(obj, dict):
2891
+ return {k: _deep_snapshot(v) for k, v in obj.items()}
2892
+ if isinstance(obj, list):
2893
+ return [_deep_snapshot(v) for v in obj]
2894
+ if isinstance(obj, tuple):
2895
+ return tuple(_deep_snapshot(v) for v in obj)
2896
+ return obj