hypermind 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Gossip bus interface + in-process implementation.
|
|
2
|
+
|
|
3
|
+
Real deployments use libp2p-gossipsub v1.2 with reputation-weighted
|
|
4
|
+
peer-scoring (P5) and topic-scoped meshes. The in-process bus mimics
|
|
5
|
+
the topic-fan-out semantics so agents can run end-to-end tests without
|
|
6
|
+
a real network. The interface is intentionally small so swapping in
|
|
7
|
+
libp2p later is a one-file change.
|
|
8
|
+
|
|
9
|
+
T5-03 — credit-based backpressure: each subscriber may advertise a
|
|
10
|
+
per-(peer_kid, topic_id) credit window via :meth:`InProcessBus.set_credit`.
|
|
11
|
+
Publishers that overrun the window get :class:`BackpressureExceeded`
|
|
12
|
+
instead of an unbounded internal queue. The token-bucket implementation
|
|
13
|
+
is reused from :mod:`hypermind.capability` (the same one rate-caveats
|
|
14
|
+
already use, so we avoid two parallel rate machineries).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Protocol
|
|
24
|
+
|
|
25
|
+
from hypermind.capability import TokenBucket
|
|
26
|
+
from hypermind.errors import HyperMindError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackpressureExceeded(HyperMindError):
|
|
30
|
+
"""Publisher exceeded a subscriber's advertised credit window.
|
|
31
|
+
|
|
32
|
+
The bus refuses to enqueue further messages until the subscriber's
|
|
33
|
+
bucket refills. Carries ``peer_kid`` and ``topic_id`` so callers can
|
|
34
|
+
back off per-peer rather than throttling the whole namespace.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, peer_kid: str, topic_id: str) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.peer_kid = peer_kid
|
|
40
|
+
self.topic_id = topic_id
|
|
41
|
+
self.telemetry["peer_kid"] = peer_kid
|
|
42
|
+
self.telemetry["topic_id"] = topic_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_credit() -> int:
|
|
46
|
+
"""Default per-(peer, topic) capacity. Override via env var.
|
|
47
|
+
|
|
48
|
+
1024 messages / 1s burst is the v0.10.1 default — enough headroom
|
|
49
|
+
for steady-state gossip but small enough that runaway publishers
|
|
50
|
+
surface immediately in tests.
|
|
51
|
+
"""
|
|
52
|
+
raw = os.environ.get("HYPERMIND_BUS_CREDIT_DEFAULT", "1024")
|
|
53
|
+
try:
|
|
54
|
+
v = int(raw)
|
|
55
|
+
return v if v > 0 else 1024
|
|
56
|
+
except ValueError:
|
|
57
|
+
return 1024
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Subscription:
|
|
62
|
+
topic: str
|
|
63
|
+
_cancel: Callable[[], None]
|
|
64
|
+
|
|
65
|
+
def unsubscribe(self) -> None:
|
|
66
|
+
self._cancel()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Handler = Callable[[bytes], Awaitable[None]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GossipBus(Protocol):
|
|
73
|
+
async def publish(self, topic: str, message: bytes) -> None: ...
|
|
74
|
+
async def subscribe(self, topic: str, handler: Handler) -> Subscription: ...
|
|
75
|
+
async def close(self) -> None: ...
|
|
76
|
+
def set_credit(self, peer_kid: str, topic_id: str, capacity: int) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class InProcessBus:
|
|
81
|
+
"""An asyncio-native fan-out bus.
|
|
82
|
+
|
|
83
|
+
All published messages are delivered to every subscriber on the
|
|
84
|
+
same topic (pure broadcast). Eventual-consistency / partition
|
|
85
|
+
behaviour is not modelled at this layer — see `partition.py`.
|
|
86
|
+
|
|
87
|
+
Backpressure: callers may publish with a ``peer_kid`` keyword to
|
|
88
|
+
pin the (peer, topic) credit window. Plain ``publish(topic, msg)``
|
|
89
|
+
treats the publisher as anonymous (peer_kid="") which still shares
|
|
90
|
+
a bucket so misconfigured callers can't escape backpressure.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
_subscribers: dict[str, list[Handler]] = field(default_factory=dict)
|
|
94
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
95
|
+
_closed: bool = False
|
|
96
|
+
# Credit windows: (peer_kid, topic_id) -> TokenBucket. Lazily created
|
|
97
|
+
# on first publish; advertised capacity overrides the default.
|
|
98
|
+
_buckets: dict[tuple[str, str], TokenBucket] = field(default_factory=dict)
|
|
99
|
+
_capacities: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
100
|
+
# Internal "queue" — used only as a depth gauge for tests. The
|
|
101
|
+
# in-process bus delivers synchronously inside `publish`, so the
|
|
102
|
+
# queue length is bounded by len(subscribers) at any instant; we
|
|
103
|
+
# track it explicitly so backpressure tests can assert on it.
|
|
104
|
+
_queue: list[tuple[str, str, bytes]] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
def set_credit(self, peer_kid: str, topic_id: str, capacity: int) -> None:
|
|
107
|
+
"""Advertise this subscriber's max consumption rate.
|
|
108
|
+
|
|
109
|
+
``capacity`` is messages/second burst. The bucket is reset so a
|
|
110
|
+
previously-throttled publisher gets a clean slate after the
|
|
111
|
+
subscriber re-advertises.
|
|
112
|
+
"""
|
|
113
|
+
if capacity <= 0:
|
|
114
|
+
raise ValueError(f"capacity must be > 0, got {capacity}")
|
|
115
|
+
key = (peer_kid, topic_id)
|
|
116
|
+
self._capacities[key] = capacity
|
|
117
|
+
self._buckets[key] = TokenBucket(f"{capacity}/sec")
|
|
118
|
+
|
|
119
|
+
def _bucket_for(self, peer_kid: str, topic_id: str) -> TokenBucket:
|
|
120
|
+
key = (peer_kid, topic_id)
|
|
121
|
+
if key not in self._buckets:
|
|
122
|
+
cap = self._capacities.get(key, _default_credit())
|
|
123
|
+
self._capacities[key] = cap
|
|
124
|
+
self._buckets[key] = TokenBucket(f"{cap}/sec")
|
|
125
|
+
return self._buckets[key]
|
|
126
|
+
|
|
127
|
+
async def publish(
|
|
128
|
+
self,
|
|
129
|
+
topic: str,
|
|
130
|
+
message: bytes,
|
|
131
|
+
*,
|
|
132
|
+
peer_kid: str = "",
|
|
133
|
+
) -> None:
|
|
134
|
+
if self._closed:
|
|
135
|
+
raise RuntimeError("bus is closed")
|
|
136
|
+
|
|
137
|
+
bucket = self._bucket_for(peer_kid, topic)
|
|
138
|
+
if not bucket.consume():
|
|
139
|
+
raise BackpressureExceeded(
|
|
140
|
+
f"backpressure exceeded for peer={peer_kid!r} topic={topic!r}",
|
|
141
|
+
peer_kid=peer_kid,
|
|
142
|
+
topic_id=topic,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async with self._lock:
|
|
146
|
+
handlers = list(self._subscribers.get(topic, ()))
|
|
147
|
+
# Track in-flight depth for test observability. Bounded by the
|
|
148
|
+
# bucket's capacity per peer; we cap the gauge at sum-of-capacities
|
|
149
|
+
# so a long-running bus does not leak memory in `_queue`.
|
|
150
|
+
self._queue.append((peer_kid, topic, message))
|
|
151
|
+
max_depth = sum(self._capacities.values()) or _default_credit()
|
|
152
|
+
if len(self._queue) > max_depth:
|
|
153
|
+
# Drop oldest gauge entries — this is just an observability
|
|
154
|
+
# buffer, not a real delivery queue.
|
|
155
|
+
del self._queue[: len(self._queue) - max_depth]
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# Run handlers concurrently; isolation prevents one bad
|
|
159
|
+
# handler from blocking the whole fan-out.
|
|
160
|
+
await asyncio.gather(
|
|
161
|
+
*(_safe_invoke(h, message) for h in handlers),
|
|
162
|
+
return_exceptions=True,
|
|
163
|
+
)
|
|
164
|
+
finally:
|
|
165
|
+
try:
|
|
166
|
+
self._queue.remove((peer_kid, topic, message))
|
|
167
|
+
except ValueError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
async def subscribe(self, topic: str, handler: Handler) -> Subscription:
|
|
171
|
+
if self._closed:
|
|
172
|
+
raise RuntimeError("bus is closed")
|
|
173
|
+
async with self._lock:
|
|
174
|
+
self._subscribers.setdefault(topic, []).append(handler)
|
|
175
|
+
|
|
176
|
+
def _cancel() -> None:
|
|
177
|
+
try:
|
|
178
|
+
self._subscribers[topic].remove(handler)
|
|
179
|
+
except (ValueError, KeyError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
return Subscription(topic=topic, _cancel=_cancel)
|
|
183
|
+
|
|
184
|
+
async def close(self) -> None:
|
|
185
|
+
async with self._lock:
|
|
186
|
+
self._subscribers.clear()
|
|
187
|
+
self._closed = True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _safe_invoke(handler: Handler, message: bytes) -> None:
|
|
191
|
+
try:
|
|
192
|
+
await handler(message)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
# Surface to the global recorder so a misbehaving handler is
|
|
195
|
+
# auditable rather than silent (closes self-heal Cycle-1
|
|
196
|
+
# Finding #9 from data review). One bad handler doesn't break
|
|
197
|
+
# the fan-out; the exception is caught.
|
|
198
|
+
try:
|
|
199
|
+
from hypermind.observability.recorder import get_recorder
|
|
200
|
+
|
|
201
|
+
get_recorder().incr(
|
|
202
|
+
"hypermind_handler_errors_total",
|
|
203
|
+
kind=type(e).__name__,
|
|
204
|
+
)
|
|
205
|
+
except Exception: # pragma: no cover (recorder always present)
|
|
206
|
+
pass
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""P2-16: KNOWS_DELTA gossip message type.
|
|
2
|
+
|
|
3
|
+
A lightweight reputation-delta broadcast that lets agents efficiently
|
|
4
|
+
synchronise knowledge of each other's reputations without replaying the
|
|
5
|
+
full claim + outcome history. Each message is signed (domain tag
|
|
6
|
+
DomainTag.KNOWS_DELTA = 0x18) and carries one or more per-topic deltas.
|
|
7
|
+
|
|
8
|
+
Wire shape (deterministic CBOR):
|
|
9
|
+
{
|
|
10
|
+
"issuer_kid": <32 bytes>,
|
|
11
|
+
"namespace_id": <str>,
|
|
12
|
+
"hlc": <int>, # issuer's HLC logical_ms at emission
|
|
13
|
+
"buckets": [
|
|
14
|
+
{
|
|
15
|
+
"topic_id": <str>,
|
|
16
|
+
"subject_kid": <32 bytes>,
|
|
17
|
+
"alpha_delta": <float>,
|
|
18
|
+
"beta_delta": <float>,
|
|
19
|
+
"evidence_kid": <bytes|None>, # optional claim that drove the delta
|
|
20
|
+
},
|
|
21
|
+
...
|
|
22
|
+
],
|
|
23
|
+
"sig": <bytes>, # Ed25519 over domain_tag || canonical_cbor(body)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Publishing: call ``publish_knows_delta(delta, bus, topic=KNOWS_DELTA_BUS_TOPIC)``
|
|
27
|
+
Subscribing: call ``on_knows_delta(bus, handler, topic=KNOWS_DELTA_BUS_TOPIC)``
|
|
28
|
+
|
|
29
|
+
The bus topic is a fixed string so all agents in a namespace share a
|
|
30
|
+
single gossip channel for reputation deltas.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Awaitable, Callable
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import cbor2
|
|
40
|
+
|
|
41
|
+
from hypermind.crypto.domain import DomainTag
|
|
42
|
+
from hypermind.crypto.signing import Keypair, verify_ed25519
|
|
43
|
+
from hypermind.transport.bus import GossipBus, Subscription
|
|
44
|
+
|
|
45
|
+
# Fixed gossip bus topic for KNOWS_DELTA messages.
|
|
46
|
+
KNOWS_DELTA_BUS_TOPIC = "hm/knows_delta/v1"
|
|
47
|
+
|
|
48
|
+
# Domain-separation tag prefix for the KNOWS_DELTA signature preimage.
|
|
49
|
+
_DOMAIN_BYTE = bytes([int(DomainTag.KNOWS_DELTA)])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class KnowsDeltaBucket:
|
|
54
|
+
"""One per-topic reputation delta inside a KnowsDeltaMessage."""
|
|
55
|
+
|
|
56
|
+
topic_id: str
|
|
57
|
+
subject_kid: bytes
|
|
58
|
+
alpha_delta: float
|
|
59
|
+
beta_delta: float
|
|
60
|
+
evidence_kid: bytes | None = None
|
|
61
|
+
|
|
62
|
+
def to_cbor_dict(self) -> dict[str, Any]:
|
|
63
|
+
d: dict[str, Any] = {
|
|
64
|
+
"topic_id": self.topic_id,
|
|
65
|
+
"subject_kid": bytes(self.subject_kid),
|
|
66
|
+
"alpha_delta": self.alpha_delta,
|
|
67
|
+
"beta_delta": self.beta_delta,
|
|
68
|
+
}
|
|
69
|
+
if self.evidence_kid is not None:
|
|
70
|
+
d["evidence_kid"] = bytes(self.evidence_kid)
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_cbor_dict(cls, d: dict[str, Any]) -> KnowsDeltaBucket:
|
|
75
|
+
return cls(
|
|
76
|
+
topic_id=str(d["topic_id"]),
|
|
77
|
+
subject_kid=bytes(d["subject_kid"]),
|
|
78
|
+
alpha_delta=float(d["alpha_delta"]),
|
|
79
|
+
beta_delta=float(d["beta_delta"]),
|
|
80
|
+
evidence_kid=bytes(d["evidence_kid"]) if d.get("evidence_kid") else None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class KnowsDeltaMessage:
|
|
86
|
+
"""A signed reputation-delta gossip message (P2-16 / DomainTag.KNOWS_DELTA).
|
|
87
|
+
|
|
88
|
+
Carries one or more ``KnowsDeltaBucket`` entries — each records the
|
|
89
|
+
raw α/β increment applied by the issuer to a specific (topic, agent).
|
|
90
|
+
Recipients MAY apply these deltas to their own reputation kernel or
|
|
91
|
+
use them as a signal for peer learning.
|
|
92
|
+
|
|
93
|
+
The message is signed with ``issuer_keypair`` at construction time
|
|
94
|
+
via :meth:`create`. Parse with :meth:`from_bytes`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
issuer_kid: bytes
|
|
98
|
+
namespace_id: str
|
|
99
|
+
hlc: int
|
|
100
|
+
buckets: list[KnowsDeltaBucket] = field(default_factory=list)
|
|
101
|
+
sig: bytes = b""
|
|
102
|
+
|
|
103
|
+
def _body_bytes(self) -> bytes:
|
|
104
|
+
body = {
|
|
105
|
+
"issuer_kid": bytes(self.issuer_kid),
|
|
106
|
+
"namespace_id": self.namespace_id,
|
|
107
|
+
"hlc": int(self.hlc),
|
|
108
|
+
"buckets": [b.to_cbor_dict() for b in self.buckets],
|
|
109
|
+
}
|
|
110
|
+
return cbor2.dumps(body, canonical=True)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def create(
|
|
114
|
+
cls,
|
|
115
|
+
*,
|
|
116
|
+
keypair: Keypair,
|
|
117
|
+
namespace_id: str,
|
|
118
|
+
hlc: int,
|
|
119
|
+
buckets: list[KnowsDeltaBucket],
|
|
120
|
+
) -> KnowsDeltaMessage:
|
|
121
|
+
"""Build and sign a KnowsDeltaMessage."""
|
|
122
|
+
msg = cls(
|
|
123
|
+
issuer_kid=keypair.kid,
|
|
124
|
+
namespace_id=namespace_id,
|
|
125
|
+
hlc=hlc,
|
|
126
|
+
buckets=list(buckets),
|
|
127
|
+
)
|
|
128
|
+
preimage = _DOMAIN_BYTE + msg._body_bytes()
|
|
129
|
+
msg.sig = keypair.sign(preimage)
|
|
130
|
+
return msg
|
|
131
|
+
|
|
132
|
+
def verify(self) -> bool:
|
|
133
|
+
"""Verify the issuer's Ed25519 signature."""
|
|
134
|
+
if len(self.issuer_kid) != 32:
|
|
135
|
+
return False
|
|
136
|
+
preimage = _DOMAIN_BYTE + self._body_bytes()
|
|
137
|
+
return verify_ed25519(self.issuer_kid, self.sig, preimage)
|
|
138
|
+
|
|
139
|
+
def to_bytes(self) -> bytes:
|
|
140
|
+
"""Serialise as deterministic CBOR for bus transport."""
|
|
141
|
+
payload = {
|
|
142
|
+
"issuer_kid": bytes(self.issuer_kid),
|
|
143
|
+
"namespace_id": self.namespace_id,
|
|
144
|
+
"hlc": int(self.hlc),
|
|
145
|
+
"buckets": [b.to_cbor_dict() for b in self.buckets],
|
|
146
|
+
"sig": bytes(self.sig),
|
|
147
|
+
}
|
|
148
|
+
return cbor2.dumps(payload, canonical=True)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_bytes(cls, raw: bytes) -> KnowsDeltaMessage:
|
|
152
|
+
"""Parse a KnowsDeltaMessage from CBOR bytes."""
|
|
153
|
+
try:
|
|
154
|
+
d = cbor2.loads(raw)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise ValueError(f"KnowsDeltaMessage: invalid CBOR: {exc}") from exc
|
|
157
|
+
if not isinstance(d, dict):
|
|
158
|
+
raise ValueError("KnowsDeltaMessage: expected CBOR map")
|
|
159
|
+
try:
|
|
160
|
+
buckets = [KnowsDeltaBucket.from_cbor_dict(b) for b in d.get("buckets", [])]
|
|
161
|
+
return cls(
|
|
162
|
+
issuer_kid=bytes(d["issuer_kid"]),
|
|
163
|
+
namespace_id=str(d["namespace_id"]),
|
|
164
|
+
hlc=int(d["hlc"]),
|
|
165
|
+
buckets=buckets,
|
|
166
|
+
sig=bytes(d.get("sig", b"")),
|
|
167
|
+
)
|
|
168
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
169
|
+
raise ValueError(f"KnowsDeltaMessage: malformed fields: {exc}") from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def publish_knows_delta(
|
|
173
|
+
delta: KnowsDeltaMessage,
|
|
174
|
+
bus: GossipBus,
|
|
175
|
+
*,
|
|
176
|
+
topic: str = KNOWS_DELTA_BUS_TOPIC,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Publish a signed KnowsDeltaMessage to the gossip bus."""
|
|
179
|
+
await bus.publish(topic, delta.to_bytes())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def on_knows_delta(
|
|
183
|
+
bus: GossipBus,
|
|
184
|
+
handler: Callable[[KnowsDeltaMessage], Awaitable[None]],
|
|
185
|
+
*,
|
|
186
|
+
topic: str = KNOWS_DELTA_BUS_TOPIC,
|
|
187
|
+
) -> Subscription:
|
|
188
|
+
"""Subscribe to KnowsDelta messages on the bus.
|
|
189
|
+
|
|
190
|
+
``handler`` is called with each decoded and signature-verified
|
|
191
|
+
``KnowsDeltaMessage``. Messages that fail signature verification
|
|
192
|
+
are silently dropped (logged via the recorder if available).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
async def _decode_and_call(raw: bytes) -> None:
|
|
196
|
+
try:
|
|
197
|
+
msg = KnowsDeltaMessage.from_bytes(raw)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return # malformed — drop
|
|
200
|
+
if not msg.verify():
|
|
201
|
+
return # bad signature — drop
|
|
202
|
+
await handler(msg)
|
|
203
|
+
|
|
204
|
+
return await bus.subscribe(topic, _decode_and_call)
|