hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,234 @@
1
+ """Active learning — pick the *next best question* to ask the swarm.
2
+
3
+ Information-gain planning, curriculum scheduling, anti-herding.
4
+
5
+ Spec: docs/spec/20-swarm-intelligence.md §20.3.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Information-gain planner
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CandidateQuestion:
21
+ qid: str
22
+ topic: str
23
+ expected_posterior: float # current swarm posterior estimate
24
+ cost: float = 1.0 # relative cost to consult (time, $, etc.)
25
+
26
+
27
+ @dataclass
28
+ class InformationGainPlanner:
29
+ """Pick the question that maximally narrows expected entropy.
30
+
31
+ Score = (current_entropy − expected_post_entropy) / cost.
32
+ The "expected post-entropy" is approximated by assuming the consult
33
+ will sharpen the posterior toward 0 or 1 with probability equal to
34
+ the current estimate.
35
+ """
36
+
37
+ sharpening: float = 0.5 # how much a consult shrinks distance to 0/1
38
+
39
+ def score_question(self, q: CandidateQuestion) -> float:
40
+ h_before = _bernoulli_entropy(q.expected_posterior)
41
+ # Two outcomes weighted by their current probability.
42
+ p = q.expected_posterior
43
+ post_yes = p + (1 - p) * self.sharpening
44
+ post_no = p * (1 - self.sharpening)
45
+ h_after = p * _bernoulli_entropy(post_yes) + (1 - p) * _bernoulli_entropy(post_no)
46
+ gain = max(0.0, h_before - h_after)
47
+ return gain / max(q.cost, 1e-6)
48
+
49
+ def pick(self, candidates: Sequence[CandidateQuestion]) -> CandidateQuestion | None:
50
+ if not candidates:
51
+ return None
52
+ ranked = sorted(candidates, key=self.score_question, reverse=True)
53
+ return ranked[0]
54
+
55
+ def rank(self, candidates: Sequence[CandidateQuestion]) -> list[CandidateQuestion]:
56
+ return sorted(candidates, key=self.score_question, reverse=True)
57
+
58
+ # ---- T4-06 multi-step planner --------------------------------------
59
+
60
+ def plan_multi_step(
61
+ self,
62
+ candidates: Sequence[CandidateQuestion],
63
+ *,
64
+ horizon: int = 3,
65
+ beam_width: int = 4,
66
+ ) -> list[CandidateQuestion]:
67
+ """Beam search over question sequences.
68
+
69
+ Returns up to ``horizon`` distinct candidates whose chained
70
+ sharpening maximises total expected entropy reduction. Each step
71
+ propagates the *post-yes* / *post-no* posterior estimates so the
72
+ next step's expected information gain is computed against the
73
+ sharpened estimate rather than the original prior.
74
+
75
+ For ``horizon=1`` the result is identical to ``[pick(candidates)]``.
76
+ """
77
+ if horizon <= 0 or not candidates:
78
+ return []
79
+ # Each beam state: (cumulative_gain, [chosen], remaining_candidates,
80
+ # rolling posterior estimate per qid).
81
+ Beam = tuple[float, list[CandidateQuestion], list[CandidateQuestion]]
82
+ beams: list[Beam] = [(0.0, [], list(candidates))]
83
+ for _ in range(horizon):
84
+ next_beams: list[Beam] = []
85
+ for cum_gain, chosen, remaining in beams:
86
+ if not remaining:
87
+ next_beams.append((cum_gain, chosen, remaining))
88
+ continue
89
+ # Score every remaining candidate against the rolling state.
90
+ # Sharpen estimates of *other* candidates by this candidate's
91
+ # sharpening factor so beam search sees diminishing returns.
92
+ for q in remaining:
93
+ gain = self.score_question(q)
94
+ new_chosen = [*chosen, q]
95
+ sharpened: list[CandidateQuestion] = []
96
+ for r in remaining:
97
+ if r.qid == q.qid:
98
+ continue
99
+ # Move r's expected_posterior closer to the boundary
100
+ # determined by q's expected_posterior — shared topic
101
+ # questions interfere; cross-topic ones do not.
102
+ if r.topic == q.topic:
103
+ new_p = r.expected_posterior * (
104
+ 1 - self.sharpening * 0.5
105
+ ) + q.expected_posterior * (self.sharpening * 0.5)
106
+ else:
107
+ new_p = r.expected_posterior
108
+ sharpened.append(
109
+ CandidateQuestion(
110
+ qid=r.qid,
111
+ topic=r.topic,
112
+ expected_posterior=new_p,
113
+ cost=r.cost,
114
+ )
115
+ )
116
+ next_beams.append((cum_gain + gain, new_chosen, sharpened))
117
+ # Prune to top beam_width by cumulative gain.
118
+ next_beams.sort(key=lambda b: b[0], reverse=True)
119
+ beams = next_beams[:beam_width]
120
+ if not beams:
121
+ break
122
+ if not beams:
123
+ return []
124
+ best = max(beams, key=lambda b: b[0])
125
+ return best[1]
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Curriculum scheduler
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class AgentSnapshot:
135
+ kid: bytes
136
+ brier: float # lower = better calibrated
137
+ n_observations: int
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class GradedQuestion:
142
+ qid: str
143
+ difficulty: float # 0 = trivial, 1 = hardest
144
+
145
+
146
+ @dataclass
147
+ class CurriculumScheduler:
148
+ """Match agents to questions by difficulty.
149
+
150
+ Higher-Brier (worse) agents get easier questions; lower-Brier (better)
151
+ agents get harder ones. This is the active-learning mirror of the
152
+ classroom: practise on what you can almost do.
153
+ """
154
+
155
+ target_match_strength: float = 1.0 # higher → tighter matching
156
+
157
+ def assign(
158
+ self,
159
+ agents: Sequence[AgentSnapshot],
160
+ questions: Sequence[GradedQuestion],
161
+ ) -> dict[bytes, list[str]]:
162
+ if not agents or not questions:
163
+ return {}
164
+ # Normalise difficulty and brier into [0, 1].
165
+ max_b = max((a.brier for a in agents), default=1.0) or 1.0
166
+ max_d = max((q.difficulty for q in questions), default=1.0) or 1.0
167
+ out: dict[bytes, list[str]] = {bytes(a.kid): [] for a in agents}
168
+ for q in questions:
169
+ target = q.difficulty / max_d
170
+ best: tuple[float, AgentSnapshot] | None = None
171
+ for a in agents:
172
+ # better calibration (low brier) → assign harder questions.
173
+ ideal = 1.0 - (a.brier / max_b)
174
+ gap = abs(ideal - target)
175
+ if best is None or gap < best[0]:
176
+ best = (gap, a)
177
+ if best is not None:
178
+ out[bytes(best[1].kid)].append(q.qid)
179
+ return out
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Anti-herding trigger
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ @dataclass
188
+ class AntiHerdingTrigger:
189
+ """Surface minority signal when consensus is suspiciously tight.
190
+
191
+ If the panel posterior stdev drops below ``stdev_floor`` AND no
192
+ DissentRecord has been observed in the last ``window`` ticks, the
193
+ trigger asks the *most-disagreeing* candidate available — explicitly
194
+ seeking out a hold-out posterior to break the herd.
195
+ """
196
+
197
+ stdev_floor: float = 0.05
198
+ window_ticks: int = 16
199
+
200
+ def should_trigger(
201
+ self,
202
+ *,
203
+ recent_posteriors: Sequence[float],
204
+ ticks_since_last_dissent: int,
205
+ ) -> bool:
206
+ if len(recent_posteriors) < 3:
207
+ return False
208
+ mean = sum(recent_posteriors) / len(recent_posteriors)
209
+ var = sum((p - mean) ** 2 for p in recent_posteriors) / len(recent_posteriors)
210
+ std = math.sqrt(var)
211
+ return std < self.stdev_floor and ticks_since_last_dissent >= self.window_ticks
212
+
213
+ def pick_outlier(
214
+ self,
215
+ candidates: Sequence[tuple[bytes, float]],
216
+ *,
217
+ consensus_posterior: float,
218
+ ) -> bytes | None:
219
+ """From `(kid, posterior)` candidates, pick the one furthest from consensus."""
220
+ if not candidates:
221
+ return None
222
+ ranked = sorted(candidates, key=lambda kv: abs(kv[1] - consensus_posterior), reverse=True)
223
+ return bytes(ranked[0][0])
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def _bernoulli_entropy(p: float) -> float:
232
+ eps = 1e-9
233
+ p = min(max(p, eps), 1.0 - eps)
234
+ return -(p * math.log(p, 2) + (1 - p) * math.log(1 - p, 2))
@@ -0,0 +1,369 @@
1
+ """Belief state + Theory of Mind (T4 / spec §21).
2
+
3
+ A belief is a per-(subject_kid, topic, proposition_hash) Beta posterior
4
+ together with the evidence kids that justify it and the HLC at which
5
+ the belief was last revised. Belief revision follows AGM postulates:
6
+
7
+ success — after revising by ϕ, ϕ is in the belief set
8
+ inclusion — revising by ϕ never adds *unrelated* propositions
9
+ vacuity — if ¬ϕ is not in the belief set, expansion suffices
10
+ extensionality— logically equivalent ϕ, ψ produce equal revisions
11
+
12
+ A *contradiction* (revising by ¬ϕ when ϕ is currently held with high
13
+ posterior) does NOT crash — it forks the belief into the fork log
14
+ (mirroring SwarmMemory's CRDT pattern). Both branches remain queryable
15
+ via :meth:`BeliefStore.snapshot` until oracle resolution decides one.
16
+
17
+ Theory of mind layers a second BeliefStore *per peer*: "what do I think
18
+ peer P believes about topic T?" The peer's signed claims update the
19
+ projection; the agent's deliberator uses the projection to weight goals
20
+ that require peer cooperation.
21
+
22
+ Spec banner: Original. Reuses `WorldModel` Protocol from world_model.py
23
+ and the Beta-posterior shape from `knowledge/reputation.py`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import logging
30
+ import time
31
+ import uuid
32
+ from collections import deque
33
+ from dataclasses import dataclass, field
34
+ from typing import Any
35
+
36
+ import cbor2
37
+
38
+ _log = logging.getLogger(__name__)
39
+
40
+
41
+ __all__ = [
42
+ "BeliefRecord",
43
+ "BeliefRevisionError",
44
+ "BeliefStore",
45
+ "TheoryOfMind",
46
+ "proposition_hash",
47
+ ]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Records
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def proposition_hash(proposition: bytes) -> bytes:
56
+ """Domain-tagged 32-byte hash of a CBOR-canonical proposition."""
57
+ return hashlib.sha256(b"hm:belief-prop:v1\x00" + bytes(proposition)).digest()
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class BeliefRecord:
62
+ """A signed (logically; signing is the caller's responsibility) belief.
63
+
64
+ The record is a value object; signing happens at publication time
65
+ (the caller wraps a BeliefRecord into a SignedStatement when emitting
66
+ onto the wire). The MUST clauses in §21 are enforced at *publication*
67
+ boundaries — the record itself is a pure data shape.
68
+ """
69
+
70
+ subject_kid: bytes #: whose belief
71
+ topic: str
72
+ proposition: bytes #: CBOR-canonical encoding of the asserted proposition
73
+ alpha: float = 1.0 #: Beta posterior α
74
+ beta: float = 1.0 #: Beta posterior β
75
+ evidence_kids: tuple[bytes, ...] = () #: supporting claim kids
76
+ hlc_ms: int = 0 #: hybrid logical clock (logical_ms component)
77
+ revision_id: str = field(default_factory=lambda: uuid.uuid4().hex)
78
+
79
+ @property
80
+ def proposition_hash(self) -> bytes:
81
+ return proposition_hash(self.proposition)
82
+
83
+ @property
84
+ def posterior_mean(self) -> float:
85
+ s = self.alpha + self.beta
86
+ return self.alpha / s if s > 0 else 0.5
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "subject_kid": self.subject_kid.hex(),
91
+ "topic": self.topic,
92
+ "proposition_hash": self.proposition_hash.hex(),
93
+ "alpha": self.alpha,
94
+ "beta": self.beta,
95
+ "evidence_kids": [k.hex() for k in self.evidence_kids],
96
+ "hlc_ms": self.hlc_ms,
97
+ "revision_id": self.revision_id,
98
+ }
99
+
100
+
101
+ class BeliefRevisionError(ValueError):
102
+ """Raised when a revision request is malformed (e.g. no evidence)."""
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # BeliefStore
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ @dataclass
111
+ class _BeliefHistoryEntry:
112
+ record: BeliefRecord
113
+ superseded_by: str | None = None # revision_id that replaced this one
114
+
115
+
116
+ class BeliefStore:
117
+ """Per-(subject, topic) belief index with AGM revision semantics.
118
+
119
+ Indexed by ``(subject_kid, topic, proposition_hash)``. A *revision*
120
+ over (ϕ, evidence) walks the AGM postulates:
121
+
122
+ 1. Success: the resulting store contains a record asserting ϕ.
123
+ 2. Inclusion: only the (ϕ, ¬ϕ) pair is touched; other propositions
124
+ on the same topic are left intact.
125
+ 3. Vacuity: if ¬ϕ is not currently held, the revision degenerates
126
+ to expansion — the new record is appended without contraction.
127
+ 4. Extensionality: if proposition bytes are equal (modulo CBOR-
128
+ canonical encoding), revision IDs round-trip equal beliefs.
129
+
130
+ Contradiction handling: if ¬ϕ is currently held with posterior_mean
131
+ > 0.5 AND the new evidence raises ϕ's posterior_mean > 0.5, the
132
+ contradiction is recorded in :attr:`fork_log` (deque, bounded at 100)
133
+ and *both* records remain queryable until an oracle resolves which
134
+ branch survives.
135
+ """
136
+
137
+ def __init__(self) -> None:
138
+ # (subject_kid, topic, prop_hash) -> current BeliefRecord
139
+ self._beliefs: dict[tuple[bytes, str, bytes], BeliefRecord] = {}
140
+ # All historical revisions ordered by HLC for snapshot().
141
+ self._history: list[_BeliefHistoryEntry] = []
142
+ self.fork_log: deque[dict[str, Any]] = deque(maxlen=100)
143
+
144
+ # ---- queries ----------------------------------------------------------
145
+
146
+ def get(
147
+ self,
148
+ subject_kid: bytes,
149
+ topic: str,
150
+ proposition: bytes,
151
+ ) -> BeliefRecord | None:
152
+ key = (bytes(subject_kid), topic, proposition_hash(proposition))
153
+ return self._beliefs.get(key)
154
+
155
+ def all_for(self, subject_kid: bytes, topic: str | None = None) -> list[BeliefRecord]:
156
+ out: list[BeliefRecord] = []
157
+ for (kid, tpc, _), rec in self._beliefs.items():
158
+ if kid != bytes(subject_kid):
159
+ continue
160
+ if topic is not None and tpc != topic:
161
+ continue
162
+ out.append(rec)
163
+ return out
164
+
165
+ def history(self) -> list[BeliefRecord]:
166
+ return [h.record for h in self._history]
167
+
168
+ # ---- mutators ---------------------------------------------------------
169
+
170
+ def expand(self, belief: BeliefRecord) -> BeliefRecord:
171
+ """AGM expansion — add ϕ when ¬ϕ is not currently held.
172
+
173
+ Idempotent: re-adding a logically-equal proposition replaces the
174
+ record in place (extensionality). Returns the stored record.
175
+ """
176
+ if not belief.evidence_kids:
177
+ raise BeliefRevisionError(
178
+ "BeliefRecord MUST cite at least one evidence_kid; see spec §21 normative MUST."
179
+ )
180
+ key = (bytes(belief.subject_kid), belief.topic, belief.proposition_hash)
181
+ self._beliefs[key] = belief
182
+ self._history.append(_BeliefHistoryEntry(record=belief))
183
+ return belief
184
+
185
+ def contract(
186
+ self,
187
+ subject_kid: bytes,
188
+ topic: str,
189
+ proposition: bytes,
190
+ ) -> BeliefRecord | None:
191
+ """AGM contraction — drop ϕ from the belief set if held.
192
+
193
+ Returns the contracted record (now removed) or None if no such
194
+ belief existed. The history retains the contraction event.
195
+ """
196
+ key = (bytes(subject_kid), topic, proposition_hash(proposition))
197
+ existing = self._beliefs.pop(key, None)
198
+ if existing is not None:
199
+ self._history.append(
200
+ _BeliefHistoryEntry(
201
+ record=existing,
202
+ superseded_by="contracted",
203
+ )
204
+ )
205
+ return existing
206
+
207
+ def revise(self, belief: BeliefRecord, new_evidence: tuple[bytes, ...] = ()) -> BeliefRecord:
208
+ """AGM revision: contract ¬ϕ if present, then expand ϕ.
209
+
210
+ ``new_evidence`` is appended to ``belief.evidence_kids`` before
211
+ storing — the caller may pass the bare BeliefRecord and let the
212
+ store accumulate evidence kids across revisions.
213
+ """
214
+ # Build the merged evidence tuple.
215
+ merged_ev = tuple(belief.evidence_kids) + tuple(bytes(e) for e in new_evidence)
216
+ if not merged_ev:
217
+ raise BeliefRevisionError(
218
+ "revise() requires at least one evidence_kid (spec §21 MUST)."
219
+ )
220
+ merged = BeliefRecord(
221
+ subject_kid=belief.subject_kid,
222
+ topic=belief.topic,
223
+ proposition=belief.proposition,
224
+ alpha=belief.alpha,
225
+ beta=belief.beta,
226
+ evidence_kids=merged_ev,
227
+ hlc_ms=belief.hlc_ms or int(time.time() * 1000),
228
+ revision_id=belief.revision_id,
229
+ )
230
+
231
+ # Look for ¬ϕ — encoded as the "negation" of the proposition.
232
+ # We treat negation as a CBOR-canonical wrapper {"_not": proposition}.
233
+ not_prop = cbor2.dumps({"_not": belief.proposition}, canonical=True)
234
+ not_key = (
235
+ bytes(belief.subject_kid),
236
+ belief.topic,
237
+ proposition_hash(not_prop),
238
+ )
239
+ existing_neg = self._beliefs.get(not_key)
240
+ if existing_neg is not None and existing_neg.posterior_mean > 0.5:
241
+ # Contradiction: ¬ϕ currently held with majority belief.
242
+ if merged.posterior_mean > 0.5:
243
+ self.fork_log.append(
244
+ {
245
+ "store": "belief",
246
+ "subject_kid": bytes(belief.subject_kid).hex(),
247
+ "topic": belief.topic,
248
+ "hlc_ms": merged.hlc_ms,
249
+ "phi_revision": merged.revision_id,
250
+ "not_phi_revision": existing_neg.revision_id,
251
+ "reason": "AGM-CONTRADICTION-FORK",
252
+ }
253
+ )
254
+ _log.warning(
255
+ "BeliefStore fork: subject=%s topic=%s — both ϕ and ¬ϕ believed",
256
+ bytes(belief.subject_kid)[:4].hex(),
257
+ belief.topic,
258
+ )
259
+ # Both records survive; an oracle resolution will retire one.
260
+ return self.expand(merged)
261
+ # Otherwise: contract ϕ instead — ¬ϕ stronger; vacuity holds.
262
+ self.contract(belief.subject_kid, belief.topic, belief.proposition)
263
+ return existing_neg
264
+
265
+ # No conflicting ¬ϕ — vacuity: revision == expansion.
266
+ return self.expand(merged)
267
+
268
+ # ---- snapshot ---------------------------------------------------------
269
+
270
+ def snapshot(self, *, at_hlc_ms: int) -> list[BeliefRecord]:
271
+ """Return the set of beliefs that were current at ``at_hlc_ms``.
272
+
273
+ A record R is "current at H" iff R.hlc_ms ≤ H AND no later
274
+ record (with the same key) supersedes R by H. Contracted records
275
+ appear as removed.
276
+ """
277
+ # Walk history in HLC order; rebuild the live set up to at_hlc_ms.
278
+ live: dict[tuple[bytes, str, bytes], BeliefRecord] = {}
279
+ ordered = sorted(self._history, key=lambda h: h.record.hlc_ms)
280
+ for entry in ordered:
281
+ if entry.record.hlc_ms > at_hlc_ms:
282
+ break
283
+ key = (
284
+ bytes(entry.record.subject_kid),
285
+ entry.record.topic,
286
+ entry.record.proposition_hash,
287
+ )
288
+ if entry.superseded_by == "contracted":
289
+ live.pop(key, None)
290
+ else:
291
+ live[key] = entry.record
292
+ return list(live.values())
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Theory of Mind
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ class TheoryOfMind:
301
+ """Per-(self_agent, peer_kid) snapshot of *what self thinks peer believes*.
302
+
303
+ Internally holds one :class:`BeliefStore` keyed by ``peer_kid`` so a
304
+ single ToM tracker handles many peers. Updated by observations of
305
+ peer-signed claims; consumed by Deliberator scoring.
306
+ """
307
+
308
+ def __init__(self, self_kid: bytes) -> None:
309
+ self.self_kid = bytes(self_kid)
310
+ self._stores: dict[bytes, BeliefStore] = {}
311
+
312
+ def store_for(self, peer_kid: bytes) -> BeliefStore:
313
+ peer = bytes(peer_kid)
314
+ if peer not in self._stores:
315
+ self._stores[peer] = BeliefStore()
316
+ return self._stores[peer]
317
+
318
+ def update_from_observation(
319
+ self,
320
+ peer_kid: bytes,
321
+ *,
322
+ topic: str,
323
+ proposition: bytes,
324
+ evidence_kid: bytes,
325
+ posterior_mean: float,
326
+ hlc_ms: int | None = None,
327
+ n_pseudo_obs: float = 4.0,
328
+ ) -> BeliefRecord:
329
+ """Update peer model from a single observed claim.
330
+
331
+ ``posterior_mean`` is the peer's stated posterior on ϕ; we map
332
+ it onto a Beta(α, β) with effective sample size ``n_pseudo_obs``.
333
+ """
334
+ if not (0.0 <= posterior_mean <= 1.0):
335
+ raise BeliefRevisionError(f"posterior_mean must be in [0,1]; got {posterior_mean!r}")
336
+ alpha = 1.0 + n_pseudo_obs * posterior_mean
337
+ beta = 1.0 + n_pseudo_obs * (1.0 - posterior_mean)
338
+ rec = BeliefRecord(
339
+ subject_kid=bytes(peer_kid),
340
+ topic=topic,
341
+ proposition=bytes(proposition),
342
+ alpha=alpha,
343
+ beta=beta,
344
+ evidence_kids=(bytes(evidence_kid),),
345
+ hlc_ms=hlc_ms if hlc_ms is not None else int(time.time() * 1000),
346
+ )
347
+ return self.store_for(peer_kid).revise(rec)
348
+
349
+ def belief(
350
+ self,
351
+ peer_kid: bytes,
352
+ topic: str,
353
+ proposition: bytes,
354
+ ) -> BeliefRecord | None:
355
+ return self.store_for(peer_kid).get(peer_kid, topic, proposition)
356
+
357
+ def posterior_mean(
358
+ self,
359
+ peer_kid: bytes,
360
+ topic: str,
361
+ proposition: bytes,
362
+ *,
363
+ default: float = 0.5,
364
+ ) -> float:
365
+ rec = self.belief(peer_kid, topic, proposition)
366
+ return rec.posterior_mean if rec is not None else default
367
+
368
+ def peers(self) -> list[bytes]:
369
+ return list(self._stores.keys())