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,173 @@
1
+ """Sigstore Rekor read-only client + downgrade-mode submitter.
2
+
3
+ ROADMAP §0.3.1 (read), §0.4.3 (write).
4
+
5
+ The client is intentionally minimal: it speaks the Rekor v1 REST API
6
+ and handles transparency-log entry lookup + RFC 6962 inclusion-proof
7
+ verification. Any heavier integration (key trust roots, fulcio cert
8
+ chain validation) is deferred to v0.7 alongside the rest of the
9
+ sigstore trust-bundle work.
10
+
11
+ ``httpx`` is a soft dependency — imports are lazy so the SDK still
12
+ loads in environments that haven't installed the ``[rekor]`` extra.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from hypermind.knowledge.transparency import (
23
+ PortableInclusionProof,
24
+ verify_inclusion,
25
+ )
26
+
27
+
28
+ class RekorUnavailableError(RuntimeError):
29
+ """Raised when ``httpx`` is not installed."""
30
+
31
+
32
+ def _load_httpx() -> Any:
33
+ try:
34
+ import httpx
35
+ except ImportError as e: # pragma: no cover - depends on env
36
+ raise RekorUnavailableError(
37
+ "Rekor integration requires `httpx`. "
38
+ "Install with `pip install httpx` or `pip install 'hypermind[rekor]'`."
39
+ ) from e
40
+ return httpx
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RekorEntry:
45
+ """A single Rekor log entry, normalised across upstream API shapes."""
46
+
47
+ uuid: str
48
+ log_index: int
49
+ integrated_time: int
50
+ log_id: str
51
+ body: bytes # base64-decoded body (the canonical entry blob)
52
+ inclusion_proof: PortableInclusionProof | None
53
+ root_hash: bytes | None
54
+ tree_size: int | None
55
+
56
+
57
+ @dataclass
58
+ class RekorClient:
59
+ """Read-only Sigstore Rekor client.
60
+
61
+ Construct with the public Rekor URL or a self-hosted instance::
62
+
63
+ client = RekorClient("https://rekor.sigstore.dev")
64
+ entry = client.lookup("e3b0c44...uuid...")
65
+ assert client.verify_inclusion(entry)
66
+ """
67
+
68
+ base_url: str = "https://rekor.sigstore.dev"
69
+ timeout: float = 5.0
70
+ transport: Any = None # httpx.BaseTransport — for tests
71
+
72
+ def _client(self) -> Any:
73
+ httpx = _load_httpx()
74
+ kwargs: dict[str, Any] = {"base_url": self.base_url, "timeout": self.timeout}
75
+ if self.transport is not None:
76
+ kwargs["transport"] = self.transport
77
+ return httpx.Client(**kwargs)
78
+
79
+ # ---- read paths --------------------------------------------------
80
+
81
+ def lookup(self, uuid: str) -> RekorEntry:
82
+ """Fetch and parse a single Rekor entry by UUID."""
83
+ with self._client() as c:
84
+ resp = c.get(f"/api/v1/log/entries/{uuid}")
85
+ resp.raise_for_status()
86
+ body = resp.json()
87
+ return _parse_entry(body)
88
+
89
+ def verify_inclusion(self, entry: RekorEntry) -> bool:
90
+ """Verify the entry's inclusion proof against its embedded root.
91
+
92
+ Returns False (not raises) on missing or malformed proof so
93
+ callers can treat unverifiable entries as untrusted.
94
+ """
95
+ if entry.inclusion_proof is None or entry.root_hash is None:
96
+ return False
97
+ leaf_hash = hashlib.sha256(b"\x00" + entry.body).digest()
98
+ return verify_inclusion(leaf_hash, entry.inclusion_proof, entry.root_hash)
99
+
100
+ # ---- write paths (ROADMAP §0.4.3) --------------------------------
101
+
102
+ def submit_downgrade(self, signed_statement: Any) -> str:
103
+ """Submit a HyperMind ``SignedStatement`` to Rekor as cross-anchor
104
+ evidence.
105
+
106
+ Used when an operator wants Sigstore-anchored proof that a
107
+ statement existed at the submission time. Returns the Rekor UUID
108
+ of the new entry. The "downgrade" terminology reflects that the
109
+ HyperMind log is the canonical primary source — Rekor is a
110
+ secondary anchor used for cross-ecosystem auditability.
111
+ """
112
+ try:
113
+ payload = signed_statement.to_bytes()
114
+ except AttributeError:
115
+ payload = bytes(signed_statement)
116
+ body_b64 = base64.b64encode(payload).decode("ascii")
117
+ proposed = {
118
+ "kind": "hypermind",
119
+ "apiVersion": "0.0.1",
120
+ "spec": {"content": body_b64},
121
+ }
122
+ with self._client() as c:
123
+ resp = c.post("/api/v1/log/entries", json=proposed)
124
+ resp.raise_for_status()
125
+ data = resp.json()
126
+ # Rekor returns {uuid: entry_dict}
127
+ if isinstance(data, dict) and len(data) == 1:
128
+ return next(iter(data.keys()))
129
+ raise RuntimeError(f"unexpected Rekor response: {data!r}")
130
+
131
+
132
+ def _parse_entry(body: dict[str, Any]) -> RekorEntry:
133
+ """Normalise a Rekor v1 entries response into a :class:`RekorEntry`.
134
+
135
+ Rekor returns ``{uuid: {body, integratedTime, logIndex, ...}}``; we
136
+ accept either the wrapped form or a single-entry dict.
137
+ """
138
+ if not isinstance(body, dict):
139
+ raise ValueError(f"unexpected entry shape: {type(body)}")
140
+ if len(body) == 1 and isinstance(next(iter(body.values())), dict):
141
+ uuid, raw = next(iter(body.items()))
142
+ else:
143
+ uuid = body.get("uuid", "")
144
+ raw = body
145
+ inc = raw.get("verification", {}).get("inclusionProof", {}) or {}
146
+ proof: PortableInclusionProof | None = None
147
+ root_hash: bytes | None = None
148
+ tree_size: int | None = None
149
+ if inc:
150
+ try:
151
+ audit_path = tuple(bytes.fromhex(h) for h in inc.get("hashes", []))
152
+ leaf_index = int(inc["logIndex"])
153
+ tree_size = int(inc["treeSize"])
154
+ root_hash = bytes.fromhex(inc["rootHash"])
155
+ proof = PortableInclusionProof(
156
+ audit_path=audit_path,
157
+ leaf_index=leaf_index,
158
+ tree_size=tree_size,
159
+ )
160
+ except (KeyError, ValueError):
161
+ proof = None
162
+ body_b64 = raw.get("body", "")
163
+ body_bytes = base64.b64decode(body_b64) if body_b64 else b""
164
+ return RekorEntry(
165
+ uuid=uuid,
166
+ log_index=int(raw.get("logIndex", 0)),
167
+ integrated_time=int(raw.get("integratedTime", 0)),
168
+ log_id=str(raw.get("logID", "")),
169
+ body=body_bytes,
170
+ inclusion_proof=proof,
171
+ root_hash=root_hash,
172
+ tree_size=tree_size,
173
+ )
@@ -0,0 +1,364 @@
1
+ """Reputation kernel.
2
+
3
+ v0.1 ships per-(agent, topic) reputation with smooth slashing and lazy
4
+ decay. v0.2 will swap the elicitation/aggregation rule for Correlated
5
+ Agreement peer-prediction (Wei-Chen Cycle 2 §1) without touching this
6
+ interface.
7
+
8
+ Reputation is stored as a moving Beta posterior:
9
+ - α = correct outcomes + 1 (Laplace prior)
10
+ - β = incorrect outcomes + 1
11
+ - mean = α / (α + β) ∈ (0, 1)
12
+
13
+ Slashing: α' = α · (1 - λ), β' = β + μ on a confirmed bad outcome
14
+ Decay: half-life H_T (default 180d) on the (α + β - 2) above-prior mass
15
+
16
+ Wire-version-0 normative defaults (closes spec/06-conformance.md gap #13):
17
+ λ = 0.02, μ = 0.015, threshold = 0.5, H_T = 180 days
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import math
24
+ import time
25
+ from collections import deque
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING
28
+
29
+ if TYPE_CHECKING:
30
+ from hypermind.transport.bus import GossipBus
31
+
32
+ LAMBDA_DEFAULT = 0.02
33
+ MU_DEFAULT = 0.015
34
+ THRESHOLD_DEFAULT = 0.5
35
+ H_T_DEFAULT_DAYS = 180
36
+ STALENESS_WINDOW_MS_DEFAULT = 90 * 24 * 60 * 60 * 1000 # 90 days
37
+ LAMBDA_STALENESS_DEFAULT = 0.005
38
+
39
+
40
+ @dataclass
41
+ class ReputationSnapshot:
42
+ agent_id: bytes
43
+ topic: str
44
+ score: float
45
+ n_observations: int
46
+ last_updated_ms: int
47
+ momentum_z: float = 0.0 # A-MOMENTUM-DETECTION
48
+
49
+
50
+ @dataclass
51
+ class _BetaPosterior:
52
+ alpha: float = 1.0
53
+ beta: float = 1.0
54
+ last_update_ms: int = field(default_factory=lambda: int(time.time() * 1000))
55
+ last_score: float = 0.5
56
+ score_history: deque = field(default_factory=lambda: deque(maxlen=200))
57
+
58
+
59
+ class ReputationKernel:
60
+ """Per-(agent, topic) reputation with bounded slashing + lazy decay."""
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ lam: float = LAMBDA_DEFAULT,
66
+ mu: float = MU_DEFAULT,
67
+ threshold: float = THRESHOLD_DEFAULT,
68
+ half_life_days: int = H_T_DEFAULT_DAYS,
69
+ staleness_window_ms: int = STALENESS_WINDOW_MS_DEFAULT,
70
+ lambda_staleness: float = LAMBDA_STALENESS_DEFAULT,
71
+ ) -> None:
72
+ self.lam = lam
73
+ self.mu = mu
74
+ self.threshold = threshold
75
+ self.half_life_days = half_life_days
76
+ self.staleness_window_ms = staleness_window_ms
77
+ self.lambda_staleness = lambda_staleness
78
+ self._posteriors: dict[tuple[bytes, str], _BetaPosterior] = {}
79
+ # v0.6 — closed-loop calibration. Lazy-init to avoid pulling
80
+ # eval/ at import time of this file.
81
+ self._brier_tracker = None # type: ignore[assignment]
82
+ # P2-16: optional bus attachment for KNOWS_DELTA broadcasting.
83
+ self._bus: GossipBus | None = None
84
+ self._bus_issuer_kid: bytes = b""
85
+ self._bus_namespace_id: str = ""
86
+
87
+ @property
88
+ def brier_tracker(self):
89
+ """Per-(agent, topic) Brier-score tracker.
90
+
91
+ Lazy-instantiated on first access. Lives for the kernel's
92
+ lifetime; mirrors to disk via ``ReputationKernel.snapshot()``
93
+ future work in the persistence layer (v0.6.5).
94
+ """
95
+ if self._brier_tracker is None:
96
+ from hypermind.eval.calibration import BrierScoreTracker
97
+
98
+ self._brier_tracker = BrierScoreTracker()
99
+ return self._brier_tracker
100
+
101
+ def calibration_adjustment(self, agent_id: bytes, topic: str) -> float:
102
+ """Return the multiplicative ``effective_n`` shrinkage for this agent.
103
+
104
+ Returns 1.0 (no-op) when there are too few oracle resolutions to
105
+ compute a meaningful Brier score. Otherwise ∈ [0.5, 2.0].
106
+ """
107
+ return self.brier_tracker.adjustment(agent_id, topic)
108
+
109
+ # P2-16: bus attachment --------------------------------------------------
110
+
111
+ def attach_bus(
112
+ self,
113
+ bus: GossipBus,
114
+ *,
115
+ issuer_kid: bytes,
116
+ namespace_id: str,
117
+ ) -> None:
118
+ """Attach a GossipBus so reputation deltas are broadcast as KNOWS_DELTA.
119
+
120
+ Call once after constructing the kernel. ``issuer_kid`` must be the
121
+ 32-byte Ed25519 kid of the agent/namespace that owns this kernel
122
+ (used to sign outbound KNOWS_DELTA messages).
123
+
124
+ Note: publication requires a signing keypair to produce verifiable
125
+ messages. For lightweight delta broadcast (no signature), pass an
126
+ unsigned message. Full signed broadcast requires ``attach_bus_with_keypair``.
127
+ """
128
+ self._bus = bus
129
+ self._bus_issuer_kid = bytes(issuer_kid)
130
+ self._bus_namespace_id = namespace_id
131
+
132
+ def attach_bus_with_keypair(
133
+ self,
134
+ bus: GossipBus,
135
+ keypair: object, # Keypair — avoid import cycle; duck-typed
136
+ *,
137
+ namespace_id: str,
138
+ ) -> None:
139
+ """Attach a GossipBus with a signing keypair for signed KNOWS_DELTA.
140
+
141
+ ``keypair`` must be a :class:`~hypermind.crypto.signing.Keypair`
142
+ instance. Use this variant when you want fully verified broadcasts.
143
+ """
144
+ self._bus = bus
145
+ self._bus_issuer_kid = bytes(keypair.kid) # type: ignore[union-attr]
146
+ self._bus_namespace_id = namespace_id
147
+ self._bus_keypair = keypair # type: ignore[assignment]
148
+
149
+ def _schedule_knows_delta_broadcast(
150
+ self,
151
+ agent_id: bytes,
152
+ topic: str,
153
+ alpha_delta: float,
154
+ beta_delta: float,
155
+ evidence_kid: bytes | None = None,
156
+ ) -> None:
157
+ """Fire-and-forget KNOWS_DELTA broadcast after a reputation update.
158
+
159
+ Uses ``asyncio.create_task`` so synchronous callers (``record_outcome``,
160
+ ``vouch``) can trigger the async broadcast without blocking. Silently
161
+ suppresses errors when no event loop is running (e.g. in synchronous
162
+ test contexts that don't need the broadcast).
163
+ """
164
+ if self._bus is None:
165
+ return
166
+ keypair = getattr(self, "_bus_keypair", None)
167
+ if keypair is None:
168
+ return # unsigned broadcasts not implemented — skip
169
+ bus = self._bus
170
+ namespace_id = self._bus_namespace_id
171
+ hlc = int(time.time() * 1000)
172
+
173
+ async def _broadcast() -> None:
174
+ from hypermind.transport.knows_delta import (
175
+ KnowsDeltaBucket,
176
+ KnowsDeltaMessage,
177
+ publish_knows_delta,
178
+ )
179
+
180
+ bucket = KnowsDeltaBucket(
181
+ topic_id=topic,
182
+ subject_kid=bytes(agent_id),
183
+ alpha_delta=alpha_delta,
184
+ beta_delta=beta_delta,
185
+ evidence_kid=evidence_kid,
186
+ )
187
+ msg = KnowsDeltaMessage.create(
188
+ keypair=keypair,
189
+ namespace_id=namespace_id,
190
+ hlc=hlc,
191
+ buckets=[bucket],
192
+ )
193
+ try:
194
+ await publish_knows_delta(msg, bus)
195
+ except Exception:
196
+ pass # best-effort
197
+
198
+ try:
199
+ loop = asyncio.get_running_loop()
200
+ _task = loop.create_task(_broadcast()) # fire-and-forget; reference kept to avoid GC
201
+ del _task # suppress RUF006: intentionally not awaited
202
+ except RuntimeError:
203
+ pass # no running event loop — skip broadcast
204
+
205
+ @staticmethod
206
+ def _staleness_factor(
207
+ evidence_hlc_ms: int,
208
+ now_hlc_ms: int,
209
+ *,
210
+ staleness_window_ms: int = STALENESS_WINDOW_MS_DEFAULT,
211
+ lambda_staleness: float = LAMBDA_STALENESS_DEFAULT,
212
+ ) -> float:
213
+ """Return a weight in (0, 1] based on how old the evidence is.
214
+
215
+ Evidence within ``staleness_window_ms`` of now has full weight 1.0.
216
+ Evidence older than the window decays exponentially with rate
217
+ ``lambda_staleness`` per day.
218
+ """
219
+ age_ms = now_hlc_ms - evidence_hlc_ms
220
+ if age_ms <= staleness_window_ms:
221
+ return 1.0
222
+ excess_days = (age_ms - staleness_window_ms) / 86_400_000
223
+ return math.exp(-lambda_staleness * excess_days)
224
+
225
+ def _get(self, agent_id: bytes, topic: str) -> _BetaPosterior:
226
+ key = (bytes(agent_id), topic)
227
+ if key not in self._posteriors:
228
+ self._posteriors[key] = _BetaPosterior()
229
+ return self._posteriors[key]
230
+
231
+ def _decay(self, p: _BetaPosterior, *, now_ms: int) -> None:
232
+ elapsed_days = (now_ms - p.last_update_ms) / (1000 * 60 * 60 * 24)
233
+ if elapsed_days <= 0:
234
+ return
235
+ decay = math.exp(-math.log(2) * elapsed_days / self.half_life_days)
236
+ # Decay only the "above-prior" mass; preserve the Laplace prior.
237
+ p.alpha = 1.0 + (p.alpha - 1.0) * decay
238
+ p.beta = 1.0 + (p.beta - 1.0) * decay
239
+ p.last_update_ms = now_ms
240
+
241
+ def record_outcome(
242
+ self,
243
+ agent_id: bytes,
244
+ topic: str,
245
+ *,
246
+ correct: bool,
247
+ weight: float = 1.0,
248
+ evidence_hlc_ms: int | None = None,
249
+ ) -> ReputationSnapshot:
250
+ """Update reputation given a confirmed outcome.
251
+
252
+ ``evidence_hlc_ms`` is the HLC timestamp of the original claim (the
253
+ evidence age). When provided, the effective weight is multiplied by
254
+ ``_staleness_factor`` so stale evidence has less influence than fresh
255
+ evidence. When absent, the current time is used (full weight).
256
+ """
257
+ now_ms = int(time.time() * 1000)
258
+ if evidence_hlc_ms is not None:
259
+ stale_w = self._staleness_factor(
260
+ evidence_hlc_ms,
261
+ now_ms,
262
+ staleness_window_ms=self.staleness_window_ms,
263
+ lambda_staleness=self.lambda_staleness,
264
+ )
265
+ weight = weight * stale_w
266
+ p = self._get(agent_id, topic)
267
+ self._decay(p, now_ms=now_ms)
268
+ if correct:
269
+ p.alpha += weight
270
+ else:
271
+ # Smooth slashing: shave a fraction of α, increment β.
272
+ p.alpha = max(1.0, p.alpha * (1 - self.lam))
273
+ p.beta += self.mu * weight + weight # weighted bad outcome
274
+ # Push to score_history HERE — once per real observation.
275
+ # snapshot() must remain a pure read so consult/discover_mentors
276
+ # don't bias momentum_z by calling it n·log(n) times in panel
277
+ # selection (closes self-heal Cycle-3 Finding #16).
278
+ score = p.alpha / (p.alpha + p.beta)
279
+ p.score_history.append(score)
280
+ # P2-16: broadcast the applied delta to peers (fire-and-forget).
281
+ alpha_delta = weight if correct else 0.0
282
+ beta_delta = 0.0 if correct else self.mu * weight + weight
283
+ self._schedule_knows_delta_broadcast(agent_id, topic, alpha_delta, beta_delta)
284
+ return self.snapshot(agent_id, topic)
285
+
286
+ def vouch(self, voucher: bytes, vouchee: bytes, topic: str, *, strength: float = 1.0) -> None:
287
+ """Boost vouchee's reputation by a fraction of voucher's confidence.
288
+
289
+ Cap (closes self-heal Cycle-1 Finding #4): a single vouch may
290
+ contribute at most 5% of the VOUCHER's own α — so a high-rep
291
+ agent cannot transfer their full reputation to a brand-new
292
+ cohort in one move (ring-laundering defence). The pre-cap line
293
+ `min(α, α * 1.05)` was a no-op (always returns α since 1.05>1).
294
+ """
295
+ voucher_p = self._get(voucher, topic)
296
+ self._decay(voucher_p, now_ms=int(time.time() * 1000))
297
+ boost = strength * (self.snapshot(voucher, topic).score - self.threshold)
298
+ if boost <= 0:
299
+ return
300
+ # Hard cap: 5% of the voucher's above-prior mass.
301
+ cap = 0.05 * max(0.0, voucher_p.alpha - 1.0)
302
+ boost = min(boost, cap) if cap > 0 else 0.0
303
+ if boost <= 0:
304
+ return
305
+ p = self._get(vouchee, topic)
306
+ self._decay(p, now_ms=int(time.time() * 1000))
307
+ p.alpha += boost
308
+
309
+ def snapshot(self, agent_id: bytes, topic: str) -> ReputationSnapshot:
310
+ now_ms = int(time.time() * 1000)
311
+ p = self._get(agent_id, topic)
312
+ # Compute decayed values on a scratch copy — do NOT mutate p so
313
+ # concurrent reads don't race on last_update_ms (closes #53).
314
+ elapsed_days = (now_ms - p.last_update_ms) / (1000 * 60 * 60 * 24)
315
+ if elapsed_days > 0:
316
+ decay = math.exp(-math.log(2) * elapsed_days / self.half_life_days)
317
+ alpha = 1.0 + (p.alpha - 1.0) * decay
318
+ beta = 1.0 + (p.beta - 1.0) * decay
319
+ else:
320
+ alpha, beta = p.alpha, p.beta
321
+ score = alpha / (alpha + beta)
322
+ # Note: score_history is appended in record_outcome (the actual
323
+ # observation point), NOT here. snapshot() is a pure read for
324
+ # panel selection / discover_mentors / consult routing — appending
325
+ # on every read would n·log(n)-bias momentum_z.
326
+ z = 0.0
327
+ if len(p.score_history) >= 5:
328
+ mean = sum(p.score_history) / len(p.score_history)
329
+ var = sum((s - mean) ** 2 for s in p.score_history) / len(p.score_history)
330
+ std = math.sqrt(var) or 1e-6
331
+ z = (score - mean) / std
332
+ return ReputationSnapshot(
333
+ agent_id=bytes(agent_id),
334
+ topic=topic,
335
+ score=score,
336
+ n_observations=int(alpha + beta - 2),
337
+ last_updated_ms=p.last_update_ms,
338
+ momentum_z=z,
339
+ )
340
+
341
+ def above_threshold(self, agent_id: bytes, topic: str) -> bool:
342
+ return self.snapshot(agent_id, topic).score >= self.threshold
343
+
344
+ def update_with_correlated_agreement(
345
+ self,
346
+ reports: list,
347
+ *,
348
+ topic: str,
349
+ alpha: float = 1.0,
350
+ threshold: float = 0.0,
351
+ ) -> dict[bytes, float]:
352
+ """Apply Bayesian-Truth-Serum / correlated-agreement scoring.
353
+
354
+ See :mod:`hypermind.knowledge.correlated_agreement` (ROADMAP §0.2.2).
355
+ Returns the raw score map (reporter_id → score) so callers can
356
+ inspect calibration without a second computation.
357
+ """
358
+ from hypermind.knowledge.correlated_agreement import (
359
+ update_kernel_with_reports,
360
+ )
361
+
362
+ return update_kernel_with_reports(
363
+ self, reports, topic=topic, alpha=alpha, threshold=threshold
364
+ )