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