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,254 @@
|
|
|
1
|
+
"""Namespace-level policy primitives.
|
|
2
|
+
|
|
3
|
+
Each namespace has a single root-signed ``NamespacePolicy`` that
|
|
4
|
+
declares the federation rules every member MUST honour. v0.7 adds
|
|
5
|
+
``min_signature_alg`` — the explicit pin that lets a namespace REFUSE
|
|
6
|
+
classical-only Ed25519 statements once the federation is ready to be
|
|
7
|
+
"PQ-only".
|
|
8
|
+
|
|
9
|
+
Existing pq-related controls:
|
|
10
|
+
|
|
11
|
+
* ``pq_required`` (per-statement, in the protected header) is the
|
|
12
|
+
per-message gate that says "this statement was signed with a PQ-hybrid
|
|
13
|
+
key". Verifiers MAY require it on individual records.
|
|
14
|
+
* ``min_signature_alg`` (this module) is the per-namespace floor; a
|
|
15
|
+
statement whose ``alg`` does not meet the floor is rejected with
|
|
16
|
+
``rule_id="HM-MIN-ALG-001"`` regardless of its ``pq_required`` flag.
|
|
17
|
+
|
|
18
|
+
Forward-compatibility: future PQ-only algorithms (e.g. SLH-DSA-only
|
|
19
|
+
profiles) extend ``MIN_ALG_ORDERING`` without breaking existing pins.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
from hypermind.crypto.signing import ALG_ED25519, ALG_MLDSA65_ED25519
|
|
28
|
+
from hypermind.errors import BlockReason
|
|
29
|
+
from hypermind.rule_ids import MIN_ALG, SPEC_REFS
|
|
30
|
+
|
|
31
|
+
#: Allowed `by` kinds for ACL entries (spec §25.4).
|
|
32
|
+
ACLPrincipalKind = Literal["agent_id", "org_did", "room_id", "topic_glob"]
|
|
33
|
+
_VALID_ACL_PRINCIPAL_KINDS: frozenset[str] = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"agent_id",
|
|
36
|
+
"org_did",
|
|
37
|
+
"room_id",
|
|
38
|
+
"topic_glob",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
#: String ids the policy uses (rather than COSE ints) so the policy is
|
|
43
|
+
#: human-readable when serialized to YAML / JSON.
|
|
44
|
+
ALG_NAME_ED25519 = "ed25519"
|
|
45
|
+
ALG_NAME_COMPOSITE = "composite-mldsa65-ed25519"
|
|
46
|
+
|
|
47
|
+
#: Map between policy-string ids and COSE-int ids.
|
|
48
|
+
ALG_NAME_TO_COSE: dict[str, int] = {
|
|
49
|
+
ALG_NAME_ED25519: ALG_ED25519,
|
|
50
|
+
ALG_NAME_COMPOSITE: ALG_MLDSA65_ED25519,
|
|
51
|
+
}
|
|
52
|
+
COSE_TO_ALG_NAME: dict[int, str] = {v: k for k, v in ALG_NAME_TO_COSE.items()}
|
|
53
|
+
|
|
54
|
+
#: Total ordering of supported sig algs from weakest to strongest. Larger
|
|
55
|
+
#: index ⇒ stricter (composite ≥ ed25519). New PQ-only algs are appended.
|
|
56
|
+
MIN_ALG_ORDERING: tuple[str, ...] = (ALG_NAME_ED25519, ALG_NAME_COMPOSITE)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _alg_rank(name: str) -> int:
|
|
60
|
+
try:
|
|
61
|
+
return MIN_ALG_ORDERING.index(name)
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"unknown signature alg id {name!r}; supported: {MIN_ALG_ORDERING!r}"
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class ACLEntry:
|
|
70
|
+
"""A single allow/deny ACL entry (spec §25.4).
|
|
71
|
+
|
|
72
|
+
``by`` selects the principal-kind matcher: ``agent_id`` (lowercase
|
|
73
|
+
hex kid), ``org_did`` (string DID), ``room_id`` (lowercase hex), or
|
|
74
|
+
``topic_glob`` (path-style glob with ``*`` and ``**``; spec §25.4
|
|
75
|
+
grammar).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
by: ACLPrincipalKind
|
|
79
|
+
value: str
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if self.by not in _VALID_ACL_PRINCIPAL_KINDS:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"ACLEntry.by must be one of {sorted(_VALID_ACL_PRINCIPAL_KINDS)!r}; "
|
|
85
|
+
f"got {self.by!r}"
|
|
86
|
+
)
|
|
87
|
+
if not isinstance(self.value, str):
|
|
88
|
+
raise ValueError("ACLEntry.value must be a string")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _glob_match(glob: str, path: str) -> bool:
|
|
92
|
+
"""Spec §25.4 path-style glob: only ``*`` (single segment) and ``**``
|
|
93
|
+
(zero or more segments) are permitted. Case-sensitive. Empty glob
|
|
94
|
+
matches nothing; ``**`` matches everything.
|
|
95
|
+
"""
|
|
96
|
+
if glob == "":
|
|
97
|
+
return False
|
|
98
|
+
if glob == "**":
|
|
99
|
+
return True
|
|
100
|
+
g_parts = glob.split("/")
|
|
101
|
+
p_parts = path.split("/")
|
|
102
|
+
return _glob_match_parts(g_parts, p_parts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _glob_match_parts(g: list[str], p: list[str]) -> bool:
|
|
106
|
+
# Recursive descent. Forbid backslash escapes and any metachar other
|
|
107
|
+
# than * / **.
|
|
108
|
+
if not g:
|
|
109
|
+
return not p
|
|
110
|
+
head = g[0]
|
|
111
|
+
if head == "**":
|
|
112
|
+
# Match zero or more segments.
|
|
113
|
+
if len(g) == 1:
|
|
114
|
+
return True
|
|
115
|
+
for i in range(0, len(p) + 1):
|
|
116
|
+
if _glob_match_parts(g[1:], p[i:]):
|
|
117
|
+
return True
|
|
118
|
+
return False
|
|
119
|
+
if not p:
|
|
120
|
+
return False
|
|
121
|
+
if head == "*":
|
|
122
|
+
return _glob_match_parts(g[1:], p[1:])
|
|
123
|
+
# Literal segment — backslash escapes are forbidden; any other use
|
|
124
|
+
# of `*` inside a segment (e.g. `foo*`) is also forbidden by §25.4.
|
|
125
|
+
if "\\" in head or "*" in head:
|
|
126
|
+
return False
|
|
127
|
+
if head != p[0]:
|
|
128
|
+
return False
|
|
129
|
+
return _glob_match_parts(g[1:], p[1:])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class NamespacePolicy:
|
|
134
|
+
"""Root-signed policy for a namespace.
|
|
135
|
+
|
|
136
|
+
For v0.7, only the ``min_signature_alg`` field is normative. The
|
|
137
|
+
other fields stub out the surface that ROADMAP §0.7 will populate
|
|
138
|
+
(panel-quorum sizes, ramp lengths, witness cohort, etc.); they
|
|
139
|
+
default to None so existing tests don't need to set them.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
namespace_id: bytes
|
|
143
|
+
#: One of `MIN_ALG_ORDERING`. Statements whose alg is below this
|
|
144
|
+
#: floor are rejected with rule_id `HM-MIN-ALG-001`.
|
|
145
|
+
min_signature_alg: str = ALG_NAME_ED25519
|
|
146
|
+
#: Per-namespace pq_required flag — when True, every statement must
|
|
147
|
+
#: also carry `pq_required=True` in its protected header. (Already
|
|
148
|
+
#: partially supported via the wire-level pq_required bit; pinning
|
|
149
|
+
#: the alg floor here gives operators an upgrade path.)
|
|
150
|
+
pq_required: bool = False
|
|
151
|
+
#: Optional human-readable name (debug/UX only).
|
|
152
|
+
label: str | None = None
|
|
153
|
+
#: ACL allow-entries (spec §25.4). Empty tuple = no explicit allow rules
|
|
154
|
+
#: (closed-by-default still applies).
|
|
155
|
+
allow: tuple[ACLEntry, ...] = ()
|
|
156
|
+
#: ACL deny-entries (spec §25.4). Deny precedence applies.
|
|
157
|
+
deny: tuple[ACLEntry, ...] = ()
|
|
158
|
+
|
|
159
|
+
def __post_init__(self) -> None:
|
|
160
|
+
if not isinstance(self.namespace_id, (bytes, bytearray)) or len(self.namespace_id) != 16:
|
|
161
|
+
raise ValueError("NamespacePolicy.namespace_id must be 16 bytes")
|
|
162
|
+
if self.min_signature_alg not in MIN_ALG_ORDERING:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"min_signature_alg must be one of {MIN_ALG_ORDERING!r}; "
|
|
165
|
+
f"got {self.min_signature_alg!r}"
|
|
166
|
+
)
|
|
167
|
+
# Coerce list ⇒ tuple so frozen dataclass equality works.
|
|
168
|
+
if isinstance(self.allow, list):
|
|
169
|
+
object.__setattr__(self, "allow", tuple(self.allow))
|
|
170
|
+
if isinstance(self.deny, list):
|
|
171
|
+
object.__setattr__(self, "deny", tuple(self.deny))
|
|
172
|
+
for e in (*self.allow, *self.deny):
|
|
173
|
+
if not isinstance(e, ACLEntry):
|
|
174
|
+
raise ValueError("NamespacePolicy.allow/deny entries must be ACLEntry instances")
|
|
175
|
+
|
|
176
|
+
# -- ACL evaluation (spec §25.4) ---------------------------------- #
|
|
177
|
+
|
|
178
|
+
def evaluate(self, action_ctx: dict[str, Any]) -> Literal["allow", "deny"]:
|
|
179
|
+
"""Evaluate ACL against an action context (spec §25.4).
|
|
180
|
+
|
|
181
|
+
``action_ctx`` is a dict that may contain any of:
|
|
182
|
+
|
|
183
|
+
- ``agent_id``: lowercase hex of the operating kid.
|
|
184
|
+
- ``org_did``: org_did string for the operating kid.
|
|
185
|
+
- ``room_id``: lowercase hex of the operation's Room.
|
|
186
|
+
- ``topic``: topic identifier (matched against ``topic_glob`` rules).
|
|
187
|
+
|
|
188
|
+
Returns ``"deny"`` whenever a deny rule matches (deny precedence),
|
|
189
|
+
``"allow"`` if an allow rule matches and no deny matched, and
|
|
190
|
+
``"deny"`` by default if neither matched (closed-by-default).
|
|
191
|
+
"""
|
|
192
|
+
# Deny precedence: evaluate deny first.
|
|
193
|
+
for entry in self.deny:
|
|
194
|
+
if self._matches(entry, action_ctx):
|
|
195
|
+
return "deny"
|
|
196
|
+
for entry in self.allow:
|
|
197
|
+
if self._matches(entry, action_ctx):
|
|
198
|
+
return "allow"
|
|
199
|
+
return "deny"
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _matches(entry: ACLEntry, ctx: dict[str, Any]) -> bool:
|
|
203
|
+
if entry.by == "agent_id":
|
|
204
|
+
v = ctx.get("agent_id")
|
|
205
|
+
return isinstance(v, str) and v.lower() == entry.value.lower()
|
|
206
|
+
if entry.by == "org_did":
|
|
207
|
+
v = ctx.get("org_did")
|
|
208
|
+
return isinstance(v, str) and v == entry.value
|
|
209
|
+
if entry.by == "room_id":
|
|
210
|
+
v = ctx.get("room_id")
|
|
211
|
+
return isinstance(v, str) and v.lower() == entry.value.lower()
|
|
212
|
+
if entry.by == "topic_glob":
|
|
213
|
+
v = ctx.get("topic")
|
|
214
|
+
if not isinstance(v, str):
|
|
215
|
+
return False
|
|
216
|
+
return _glob_match(entry.value, v)
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
# -- gating ------------------------------------------------------- #
|
|
220
|
+
|
|
221
|
+
def alg_meets_floor(self, alg_cose: int) -> bool:
|
|
222
|
+
"""True iff the COSE alg id meets `min_signature_alg`."""
|
|
223
|
+
name = COSE_TO_ALG_NAME.get(alg_cose)
|
|
224
|
+
if name is None:
|
|
225
|
+
# Unknown alg cannot meet a known floor.
|
|
226
|
+
return False
|
|
227
|
+
return _alg_rank(name) >= _alg_rank(self.min_signature_alg)
|
|
228
|
+
|
|
229
|
+
def block_reason_for_alg(self, alg_cose: int) -> BlockReason:
|
|
230
|
+
"""Build the canonical BlockReason for a sub-floor signature."""
|
|
231
|
+
observed = COSE_TO_ALG_NAME.get(alg_cose, f"alg={alg_cose}")
|
|
232
|
+
return BlockReason(
|
|
233
|
+
rule_id=MIN_ALG,
|
|
234
|
+
spec_ref=SPEC_REFS[MIN_ALG],
|
|
235
|
+
hint=(
|
|
236
|
+
f"namespace pins min_signature_alg={self.min_signature_alg!r} "
|
|
237
|
+
f"but the statement is signed with {observed!r}; "
|
|
238
|
+
"rotate the issuer to a key that meets the floor."
|
|
239
|
+
),
|
|
240
|
+
current_value=observed,
|
|
241
|
+
required_value=self.min_signature_alg,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
__all__ = [
|
|
246
|
+
"ALG_NAME_COMPOSITE",
|
|
247
|
+
"ALG_NAME_ED25519",
|
|
248
|
+
"ALG_NAME_TO_COSE",
|
|
249
|
+
"COSE_TO_ALG_NAME",
|
|
250
|
+
"MIN_ALG_ORDERING",
|
|
251
|
+
"ACLEntry",
|
|
252
|
+
"ACLPrincipalKind",
|
|
253
|
+
"NamespacePolicy",
|
|
254
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Observability primitives.
|
|
2
|
+
|
|
3
|
+
The SDK emits structured logs in the shape mandated by spec/13-observability.md.
|
|
4
|
+
OpenTelemetry tracing and Prometheus metrics are optional; the
|
|
5
|
+
in-process recorder always works for tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hypermind.observability.cost import (
|
|
9
|
+
PRICE_TABLE,
|
|
10
|
+
PRICE_TABLE_VERSION,
|
|
11
|
+
TokenCostTracker,
|
|
12
|
+
cost_usd,
|
|
13
|
+
)
|
|
14
|
+
from hypermind.observability.otel_export import OtelExporter
|
|
15
|
+
from hypermind.observability.recorder import Recorder, get_recorder
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PRICE_TABLE",
|
|
19
|
+
"PRICE_TABLE_VERSION",
|
|
20
|
+
"OtelExporter",
|
|
21
|
+
"Recorder",
|
|
22
|
+
"TokenCostTracker",
|
|
23
|
+
"cost_usd",
|
|
24
|
+
"get_recorder",
|
|
25
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Minimal ASGI 3 app exposing `/healthz` and `/metrics`.
|
|
2
|
+
|
|
3
|
+
Designed to be zero-dependency: the SDK already exposes the bounded
|
|
4
|
+
`Recorder` and the `wire_version` / `protocol_version` constants, so
|
|
5
|
+
production operators can mount this app under any ASGI server
|
|
6
|
+
(uvicorn, hypercorn, daphne) and get health + Prometheus metrics
|
|
7
|
+
without pulling in `prometheus-client`.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from hypermind.observability.asgi import asgi_app
|
|
12
|
+
|
|
13
|
+
app = asgi_app() # uses the global Recorder
|
|
14
|
+
# uvicorn.run(app, host="0.0.0.0", port=9100)
|
|
15
|
+
|
|
16
|
+
# Or with a scoped Recorder:
|
|
17
|
+
rec = Recorder.scoped("did:web:my-namespace")
|
|
18
|
+
app = asgi_app(recorder=rec)
|
|
19
|
+
|
|
20
|
+
The exposition format follows the Prometheus text 0.0.4 spec
|
|
21
|
+
(https://prometheus.io/docs/instrumenting/exposition_formats/) — one
|
|
22
|
+
line per labelled counter, plus `# HELP` / `# TYPE` headers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Awaitable, Callable
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from hypermind.observability.recorder import Recorder, get_recorder
|
|
33
|
+
|
|
34
|
+
# ASGI-3 callable type
|
|
35
|
+
_Send = Callable[[dict[str, Any]], Awaitable[None]]
|
|
36
|
+
_Receive = Callable[[], Awaitable[dict[str, Any]]]
|
|
37
|
+
_Scope = dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Prometheus metric names: [a-zA-Z_:][a-zA-Z0-9_:]*
|
|
41
|
+
_METRIC_NAME_RE = re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$")
|
|
42
|
+
_LABEL_RE = re.compile(r"^([^{}=,]+)\{(.*)\}$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_metric_key(key: str) -> tuple[str, dict[str, str]]:
|
|
46
|
+
"""Split ``"name{label=value,...}"`` → ``("name", {"label": "value"})``."""
|
|
47
|
+
m = _LABEL_RE.match(key)
|
|
48
|
+
if not m:
|
|
49
|
+
return key, {}
|
|
50
|
+
name = m.group(1)
|
|
51
|
+
body = m.group(2)
|
|
52
|
+
labels: dict[str, str] = {}
|
|
53
|
+
for part in body.split(","):
|
|
54
|
+
if "=" not in part:
|
|
55
|
+
continue
|
|
56
|
+
k, v = part.split("=", 1)
|
|
57
|
+
labels[k.strip()] = v.strip()
|
|
58
|
+
return name, labels
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _escape_label_value(v: str) -> str:
|
|
62
|
+
return v.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_prometheus(recorder: Recorder) -> bytes:
|
|
66
|
+
"""Render the Recorder's counters in Prometheus text 0.0.4 format."""
|
|
67
|
+
snapshot = recorder.get_metrics()
|
|
68
|
+
counters: dict[str, float] = snapshot["counters"]
|
|
69
|
+
|
|
70
|
+
# Group by metric name so we can emit one HELP/TYPE per family.
|
|
71
|
+
grouped: dict[str, list[tuple[dict[str, str], float]]] = {}
|
|
72
|
+
for key, value in counters.items():
|
|
73
|
+
name, labels = _parse_metric_key(key)
|
|
74
|
+
if not _METRIC_NAME_RE.match(name):
|
|
75
|
+
# Skip names that would corrupt the exposition format.
|
|
76
|
+
continue
|
|
77
|
+
grouped.setdefault(name, []).append((labels, value))
|
|
78
|
+
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
for name in sorted(grouped):
|
|
81
|
+
lines.append(f"# HELP {name} HyperMind counter (auto-generated)")
|
|
82
|
+
lines.append(f"# TYPE {name} counter")
|
|
83
|
+
for labels, value in grouped[name]:
|
|
84
|
+
if labels:
|
|
85
|
+
label_str = ",".join(
|
|
86
|
+
f'{k}="{_escape_label_value(v)}"' for k, v in sorted(labels.items())
|
|
87
|
+
)
|
|
88
|
+
lines.append(f"{name}{{{label_str}}} {value}")
|
|
89
|
+
else:
|
|
90
|
+
lines.append(f"{name} {value}")
|
|
91
|
+
|
|
92
|
+
# Recorder-internal gauges — useful for ops to detect log-buffer
|
|
93
|
+
# saturation in long-running processes.
|
|
94
|
+
lines.append("# HELP hypermind_records_total Records currently in the bounded ring buffer")
|
|
95
|
+
lines.append("# TYPE hypermind_records_total gauge")
|
|
96
|
+
lines.append(f"hypermind_records_total {snapshot['records_total']}")
|
|
97
|
+
lines.append(
|
|
98
|
+
"# HELP hypermind_records_dropped_total Records dropped from the bounded ring buffer"
|
|
99
|
+
)
|
|
100
|
+
lines.append("# TYPE hypermind_records_dropped_total counter")
|
|
101
|
+
lines.append(f"hypermind_records_dropped_total {snapshot['records_dropped_total']}")
|
|
102
|
+
|
|
103
|
+
body = "\n".join(lines) + "\n"
|
|
104
|
+
return body.encode("utf-8")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _healthz_body() -> bytes:
|
|
108
|
+
# Late import — avoids a hard cycle if wire.py imports observability.
|
|
109
|
+
from hypermind.wire import PROTOCOL_VERSION, WIRE_VERSION
|
|
110
|
+
|
|
111
|
+
payload = {
|
|
112
|
+
"status": "ok",
|
|
113
|
+
"wire_version": WIRE_VERSION,
|
|
114
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
115
|
+
}
|
|
116
|
+
return json.dumps(payload).encode("utf-8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _send_response(
|
|
120
|
+
send: _Send,
|
|
121
|
+
*,
|
|
122
|
+
status: int,
|
|
123
|
+
body: bytes,
|
|
124
|
+
content_type: str,
|
|
125
|
+
) -> None:
|
|
126
|
+
await send(
|
|
127
|
+
{
|
|
128
|
+
"type": "http.response.start",
|
|
129
|
+
"status": status,
|
|
130
|
+
"headers": [
|
|
131
|
+
(b"content-type", content_type.encode("ascii")),
|
|
132
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def asgi_app(
|
|
140
|
+
recorder: Recorder | None = None,
|
|
141
|
+
) -> Callable[[_Scope, _Receive, _Send], Awaitable[None]]:
|
|
142
|
+
"""Return an ASGI 3 application exposing `/healthz` and `/metrics`.
|
|
143
|
+
|
|
144
|
+
Both endpoints accept GET and HEAD; everything else gets 405.
|
|
145
|
+
Unknown paths get 404.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
rec = recorder if recorder is not None else get_recorder()
|
|
149
|
+
|
|
150
|
+
async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
|
|
151
|
+
if scope["type"] == "lifespan":
|
|
152
|
+
# Drain the lifespan loop so uvicorn's startup completes.
|
|
153
|
+
while True:
|
|
154
|
+
message = await receive()
|
|
155
|
+
if message["type"] == "lifespan.startup":
|
|
156
|
+
await send({"type": "lifespan.startup.complete"})
|
|
157
|
+
elif message["type"] == "lifespan.shutdown":
|
|
158
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
159
|
+
return
|
|
160
|
+
# unreachable — `return` above ends the lifespan task
|
|
161
|
+
|
|
162
|
+
if scope["type"] != "http":
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
method: str = scope.get("method", "GET").upper()
|
|
166
|
+
path: str = scope.get("path", "/")
|
|
167
|
+
|
|
168
|
+
if path == "/healthz":
|
|
169
|
+
if method not in ("GET", "HEAD"):
|
|
170
|
+
await _send_response(
|
|
171
|
+
send,
|
|
172
|
+
status=405,
|
|
173
|
+
body=b"method not allowed",
|
|
174
|
+
content_type="text/plain; charset=utf-8",
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
body = _healthz_body() if method == "GET" else b""
|
|
178
|
+
await _send_response(
|
|
179
|
+
send,
|
|
180
|
+
status=200,
|
|
181
|
+
body=body,
|
|
182
|
+
content_type="application/json",
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if path == "/metrics":
|
|
187
|
+
if method not in ("GET", "HEAD"):
|
|
188
|
+
await _send_response(
|
|
189
|
+
send,
|
|
190
|
+
status=405,
|
|
191
|
+
body=b"method not allowed",
|
|
192
|
+
content_type="text/plain; charset=utf-8",
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
body = _render_prometheus(rec) if method == "GET" else b""
|
|
196
|
+
await _send_response(
|
|
197
|
+
send,
|
|
198
|
+
status=200,
|
|
199
|
+
body=body,
|
|
200
|
+
content_type="text/plain; version=0.0.4; charset=utf-8",
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
await _send_response(
|
|
205
|
+
send,
|
|
206
|
+
status=404,
|
|
207
|
+
body=b"not found",
|
|
208
|
+
content_type="text/plain; charset=utf-8",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return app
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = ["asgi_app"]
|