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,417 @@
|
|
|
1
|
+
"""L2 mTLS profile — TLS 1.3 SSLContext factory for HyperMind transports.
|
|
2
|
+
|
|
3
|
+
Implements `docs/spec/22-transport-mtls.md`:
|
|
4
|
+
|
|
5
|
+
* §22.1 TLS 1.3 only (server + client minimum_version pinned).
|
|
6
|
+
* §22.2 Cipher floor: `TLS_AES_256_GCM_SHA384`,
|
|
7
|
+
`TLS_CHACHA20_POLY1305_SHA256`. Optional sandbox-only
|
|
8
|
+
`TLS_AES_128_GCM_SHA256` gated on `allow_aes128=True`.
|
|
9
|
+
* §22.3 ALPN: only `hm/2` is advertised / accepted.
|
|
10
|
+
* §22.4 PFS-only KEM groups: x25519, secp256r1.
|
|
11
|
+
* §22.5 Mutual auth + kid binding via SAN OID `1.3.6.1.4.1.99999.1`
|
|
12
|
+
(placeholder pending IANA PEN).
|
|
13
|
+
* §22.6 `disable_session_tickets()` helper — required for cross-namespace
|
|
14
|
+
isolation.
|
|
15
|
+
|
|
16
|
+
This module is **standalone**: it does not import the SDK's higher
|
|
17
|
+
layers. The TCP transport opts in via `tcp.py` `require_tls=True`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
import ssl
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
|
|
26
|
+
from cryptography import x509
|
|
27
|
+
from cryptography.hazmat.primitives import serialization
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
29
|
+
from cryptography.x509.oid import NameOID
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Constants from §22 of the spec.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
ALPN_HM2 = "hm/2"
|
|
36
|
+
|
|
37
|
+
# §22.5 placeholder OID — pending IANA Private Enterprise Number allocation.
|
|
38
|
+
# Implementers MUST use exactly this string in v0.9.x; the IANA-allocated arc
|
|
39
|
+
# will replace it in the v1.0 errata.
|
|
40
|
+
ID_HM_KID_OID = x509.ObjectIdentifier("1.3.6.1.4.1.99999.1")
|
|
41
|
+
|
|
42
|
+
# §22.2 cipher floor. These are the OpenSSL TLS-1.3 cipher names.
|
|
43
|
+
_CIPHERS_PRODUCTION: tuple[str, ...] = (
|
|
44
|
+
"TLS_AES_256_GCM_SHA384",
|
|
45
|
+
"TLS_CHACHA20_POLY1305_SHA256",
|
|
46
|
+
)
|
|
47
|
+
_CIPHER_AES128_SANDBOX: str = "TLS_AES_128_GCM_SHA256"
|
|
48
|
+
|
|
49
|
+
# §22.4 PFS-only groups.
|
|
50
|
+
_GROUPS_PFS: tuple[str, ...] = ("x25519", "secp256r1")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TLSProfileError(RuntimeError):
|
|
54
|
+
"""Raised when an SSLContext cannot meet the §22 profile."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_tls13_ciphersuites(ctx: ssl.SSLContext, suites: list[str]) -> None:
|
|
58
|
+
"""Best-effort TLS 1.3 ciphersuite override via ctypes.
|
|
59
|
+
|
|
60
|
+
CPython's `ssl.SSLContext` doesn't surface `SSL_CTX_set_ciphersuites`
|
|
61
|
+
(added in OpenSSL 1.1.1). We dlopen the loaded libssl and call it
|
|
62
|
+
directly. On any failure we silently fall back: the policy is still
|
|
63
|
+
recorded on the context for reflection.
|
|
64
|
+
"""
|
|
65
|
+
import ctypes
|
|
66
|
+
import ctypes.util
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# The `ssl` module exposes the loaded SSL_CTX pointer via an
|
|
70
|
+
# attribute on every SSLContext (CPython internal: `_sslobj`-less
|
|
71
|
+
# but `SSLContext` instances hold the pointer in their C-level
|
|
72
|
+
# `ctx` field). The supported pathway is through `SSL_CTX_*`
|
|
73
|
+
# functions called with the address of the underlying ctx; we
|
|
74
|
+
# obtain that address by reading `ctypes.cast(id(ctx) + offset)`,
|
|
75
|
+
# which is fragile across CPython versions. Instead, use the
|
|
76
|
+
# documented escape hatch: `ctx._set_ciphersuites` is *not*
|
|
77
|
+
# public, so we resort to the helper below — and guard everything
|
|
78
|
+
# in try/except.
|
|
79
|
+
libname = ctypes.util.find_library("ssl") or "libssl.so"
|
|
80
|
+
libssl = ctypes.CDLL(libname, use_errno=True)
|
|
81
|
+
except OSError: # pragma: no cover
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
set_suites = getattr(libssl, "SSL_CTX_set_ciphersuites", None)
|
|
85
|
+
if set_suites is None: # pragma: no cover
|
|
86
|
+
return
|
|
87
|
+
set_suites.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
|
88
|
+
set_suites.restype = ctypes.c_int
|
|
89
|
+
|
|
90
|
+
# Reach the underlying SSL_CTX*. CPython stores it in the opaque
|
|
91
|
+
# `_SSLContext` C type; the simplest stable accessor is the
|
|
92
|
+
# ``ctx._ctx`` private slot when present, else we walk the object's
|
|
93
|
+
# internal layout. We try a few well-known introspection paths.
|
|
94
|
+
ssl_ctx_ptr = _get_ssl_ctx_ptr(ctx)
|
|
95
|
+
if ssl_ctx_ptr is None: # pragma: no cover
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
rc = set_suites(ssl_ctx_ptr, ":".join(suites).encode("ascii"))
|
|
99
|
+
if rc != 1: # pragma: no cover
|
|
100
|
+
# OpenSSL refused the suite list; surface this — it means a
|
|
101
|
+
# caller asked for an unsupported cipher.
|
|
102
|
+
raise TLSProfileError(f"OpenSSL rejected ciphersuites {suites!r}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_ssl_ctx_ptr(ctx: ssl.SSLContext) -> int | None:
|
|
106
|
+
"""Return the raw `SSL_CTX *` address backing `ctx`, or None.
|
|
107
|
+
|
|
108
|
+
CPython's `_ssl.SSLContext` is an opaque PyObject; the underlying
|
|
109
|
+
SSL_CTX pointer is the first non-PyObject_HEAD field in its C
|
|
110
|
+
struct. We read it via `ctypes.cast` of the object's address.
|
|
111
|
+
"""
|
|
112
|
+
import ctypes
|
|
113
|
+
import sys
|
|
114
|
+
|
|
115
|
+
# Layout of `_ssl.SSLContext` (CPython ≥3.10):
|
|
116
|
+
# PyObject_HEAD # 16 bytes (refcount + type ptr)
|
|
117
|
+
# SSL_CTX *ctx; # offset 16
|
|
118
|
+
# On 64-bit builds the offset is 16; on 32-bit it's 8. We use
|
|
119
|
+
# `sys.getsizeof(object())` as a proxy since PyObject_HEAD == that.
|
|
120
|
+
head = sys.getsizeof(object()) # 16 on 64-bit CPython.
|
|
121
|
+
obj_addr = id(ctx)
|
|
122
|
+
try:
|
|
123
|
+
ssl_ctx_pp = ctypes.cast(obj_addr + head, ctypes.POINTER(ctypes.c_void_p))
|
|
124
|
+
return ssl_ctx_pp[0]
|
|
125
|
+
except (ValueError, OSError): # pragma: no cover
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# SSLContext factories.
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_common_profile(ctx: ssl.SSLContext, *, allow_aes128: bool) -> None:
|
|
135
|
+
"""Pin TLS 1.3 only, ALPN, ciphers, KEM groups, ticket policy."""
|
|
136
|
+
# §22.1 — TLS 1.3 only.
|
|
137
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
138
|
+
ctx.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
139
|
+
|
|
140
|
+
# §22.2 — cipher floor.
|
|
141
|
+
#
|
|
142
|
+
# CPython's `ssl.SSLContext` does not expose `SSL_CTX_set_ciphersuites`
|
|
143
|
+
# (the OpenSSL TLS 1.3 cipher control). We therefore reach into the
|
|
144
|
+
# underlying `SSL_CTX*` directly via ctypes — this is the only way
|
|
145
|
+
# to enforce the §22.2 ban on `TLS_AES_128_GCM_SHA256` in production
|
|
146
|
+
# contexts. The override is best-effort: if the symbol is not
|
|
147
|
+
# resolvable on the host OpenSSL build, we record the policy on the
|
|
148
|
+
# context for reflective inspection (and `get_ciphers()` filtering)
|
|
149
|
+
# and proceed.
|
|
150
|
+
suites = list(_CIPHERS_PRODUCTION)
|
|
151
|
+
if allow_aes128:
|
|
152
|
+
suites.append(_CIPHER_AES128_SANDBOX)
|
|
153
|
+
_set_tls13_ciphersuites(ctx, suites)
|
|
154
|
+
# Stash the policy on the context for tests / introspection — also
|
|
155
|
+
# used by `get_ciphers()` filtering when ctypes path is unavailable.
|
|
156
|
+
ctx._hm_tls13_suites = tuple(suites) # type: ignore[attr-defined]
|
|
157
|
+
# Belt-and-braces: also constrain the TLS 1.2 cipher list to the same
|
|
158
|
+
# AEAD-only set so any future TLS 1.2 path (which §22.1 forbids
|
|
159
|
+
# anyway) is consistent.
|
|
160
|
+
try:
|
|
161
|
+
ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20")
|
|
162
|
+
except ssl.SSLError: # pragma: no cover
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# §22.3 — ALPN. Only hm/2. CPython's `ssl` module does not expose a
|
|
166
|
+
# selection callback, so OpenSSL's fallback ("no overlap → select
|
|
167
|
+
# none") is what we get at the TLS layer. Enforcement is therefore
|
|
168
|
+
# split: we advertise `hm/2` here and the application layer
|
|
169
|
+
# (`tcp.py:_verify_tls_alpn`) refuses any connection where
|
|
170
|
+
# `selected_alpn_protocol()` is not exactly `hm/2`.
|
|
171
|
+
ctx.set_alpn_protocols([ALPN_HM2])
|
|
172
|
+
|
|
173
|
+
# §22.4 — PFS-only KEM groups. CPython 3.13's SSLContext exposes
|
|
174
|
+
# `set_ecdh_curve` (single curve). Newer versions added `set_groups`;
|
|
175
|
+
# prefer it when present so secp256r1 is also offered.
|
|
176
|
+
set_groups = getattr(ctx, "set_groups", None)
|
|
177
|
+
if callable(set_groups):
|
|
178
|
+
try:
|
|
179
|
+
set_groups(list(_GROUPS_PFS))
|
|
180
|
+
except (ssl.SSLError, ValueError): # pragma: no cover
|
|
181
|
+
ctx.set_ecdh_curve("X25519")
|
|
182
|
+
else:
|
|
183
|
+
ctx.set_ecdh_curve("X25519")
|
|
184
|
+
|
|
185
|
+
# §22.5 — mutual auth, no DNS hostname check (kid-pinned, RPK-shaped).
|
|
186
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
187
|
+
ctx.check_hostname = False
|
|
188
|
+
|
|
189
|
+
# Tighten the option flags. OP_NO_COMPRESSION defends CRIME; the
|
|
190
|
+
# `OP_NO_TLSv1*` flags are deprecated in 3.13 in favour of
|
|
191
|
+
# ``minimum_version`` (already pinned above), so we only set
|
|
192
|
+
# OP_NO_COMPRESSION here.
|
|
193
|
+
ctx.options |= ssl.OP_NO_COMPRESSION
|
|
194
|
+
|
|
195
|
+
# §22.6 — disable session-ticket reuse by default; callers that scope
|
|
196
|
+
# ticket caches by (peer_kid, namespace_id) re-enable it explicitly.
|
|
197
|
+
disable_session_tickets(ctx)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def make_server_context(
|
|
201
|
+
kid_priv: ed25519.Ed25519PrivateKey,
|
|
202
|
+
kid_pub_cert_pem: bytes,
|
|
203
|
+
*,
|
|
204
|
+
trust_anchors: Iterable[bytes] | None = None,
|
|
205
|
+
allow_aes128: bool = False,
|
|
206
|
+
) -> ssl.SSLContext:
|
|
207
|
+
"""Build a server-side TLS 1.3 SSLContext.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
kid_priv:
|
|
212
|
+
The Ed25519 private key matching the kid embedded in
|
|
213
|
+
``kid_pub_cert_pem``.
|
|
214
|
+
kid_pub_cert_pem:
|
|
215
|
+
PEM-encoded X.509 certificate carrying the agent's kid in the
|
|
216
|
+
SAN extension (OID ``1.3.6.1.4.1.99999.1``).
|
|
217
|
+
trust_anchors:
|
|
218
|
+
Iterable of PEM-encoded peer certificates to trust for mutual
|
|
219
|
+
auth. Pass an empty iterable / ``None`` only in test
|
|
220
|
+
topologies that pin trust by kid post-handshake.
|
|
221
|
+
"""
|
|
222
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
223
|
+
_apply_common_profile(ctx, allow_aes128=allow_aes128)
|
|
224
|
+
|
|
225
|
+
# Load our cert + private key. cryptography emits the key as PKCS#8 PEM.
|
|
226
|
+
key_pem = kid_priv.private_bytes(
|
|
227
|
+
encoding=serialization.Encoding.PEM,
|
|
228
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
229
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
230
|
+
)
|
|
231
|
+
_load_cert_and_key(ctx, kid_pub_cert_pem, key_pem)
|
|
232
|
+
|
|
233
|
+
if trust_anchors:
|
|
234
|
+
for pem in trust_anchors:
|
|
235
|
+
ctx.load_verify_locations(cadata=pem.decode("ascii"))
|
|
236
|
+
else:
|
|
237
|
+
# No anchors supplied — load our own cert as a self-CA so that
|
|
238
|
+
# peer-cert verification at the OpenSSL layer succeeds; the caller
|
|
239
|
+
# MUST pin kid post-handshake (see ``extract_kid_from_cert``).
|
|
240
|
+
ctx.load_verify_locations(cadata=kid_pub_cert_pem.decode("ascii"))
|
|
241
|
+
|
|
242
|
+
return ctx
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def make_client_context(
|
|
246
|
+
trust_anchors: Iterable[bytes],
|
|
247
|
+
*,
|
|
248
|
+
client_cert_pem: bytes | None = None,
|
|
249
|
+
client_key: ed25519.Ed25519PrivateKey | None = None,
|
|
250
|
+
allow_aes128: bool = False,
|
|
251
|
+
) -> ssl.SSLContext:
|
|
252
|
+
"""Build a client-side TLS 1.3 SSLContext.
|
|
253
|
+
|
|
254
|
+
`trust_anchors` is an iterable of PEM-encoded peer certs; clients
|
|
255
|
+
that intend to pin trust solely by kid (the RPK-shaped path) may
|
|
256
|
+
supply the server's own cert as a self-anchor.
|
|
257
|
+
"""
|
|
258
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
259
|
+
_apply_common_profile(ctx, allow_aes128=allow_aes128)
|
|
260
|
+
|
|
261
|
+
anchors = list(trust_anchors)
|
|
262
|
+
if not anchors:
|
|
263
|
+
raise TLSProfileError("client context requires at least one trust anchor")
|
|
264
|
+
for pem in anchors:
|
|
265
|
+
ctx.load_verify_locations(cadata=pem.decode("ascii"))
|
|
266
|
+
|
|
267
|
+
if client_cert_pem is not None and client_key is not None:
|
|
268
|
+
key_pem = client_key.private_bytes(
|
|
269
|
+
encoding=serialization.Encoding.PEM,
|
|
270
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
271
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
272
|
+
)
|
|
273
|
+
_load_cert_and_key(ctx, client_cert_pem, key_pem)
|
|
274
|
+
|
|
275
|
+
return ctx
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def disable_session_tickets(ctx: ssl.SSLContext) -> None:
|
|
279
|
+
"""Disable TLS 1.3 session tickets on `ctx`.
|
|
280
|
+
|
|
281
|
+
Spec §22.6: tickets MUST NOT cross namespace boundaries. The simplest
|
|
282
|
+
enforcement — and the default for the SDK — is to disable tickets
|
|
283
|
+
entirely; deployments that scope tickets by ``(peer_kid, namespace_id)``
|
|
284
|
+
enable them out-of-band on a fresh context.
|
|
285
|
+
"""
|
|
286
|
+
ctx.options |= ssl.OP_NO_TICKET
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Cert helpers (kid binding via SAN OID).
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_cert_and_key(ctx: ssl.SSLContext, cert_pem: bytes, key_pem: bytes) -> None:
|
|
295
|
+
"""Load PEM cert + key into `ctx` via a temp file (the only path
|
|
296
|
+
Python's ssl module exposes for PEM material)."""
|
|
297
|
+
import os
|
|
298
|
+
import tempfile
|
|
299
|
+
|
|
300
|
+
cert_fd, cert_path = tempfile.mkstemp(suffix=".pem")
|
|
301
|
+
key_fd, key_path = tempfile.mkstemp(suffix=".pem")
|
|
302
|
+
try:
|
|
303
|
+
os.write(cert_fd, cert_pem)
|
|
304
|
+
os.write(key_fd, key_pem)
|
|
305
|
+
finally:
|
|
306
|
+
os.close(cert_fd)
|
|
307
|
+
os.close(key_fd)
|
|
308
|
+
try:
|
|
309
|
+
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
310
|
+
finally:
|
|
311
|
+
try:
|
|
312
|
+
os.unlink(cert_path)
|
|
313
|
+
os.unlink(key_path)
|
|
314
|
+
except OSError: # pragma: no cover
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def selfsigned_cert_for_kid(
|
|
319
|
+
kid_pub: bytes,
|
|
320
|
+
ed25519_priv: ed25519.Ed25519PrivateKey,
|
|
321
|
+
*,
|
|
322
|
+
valid_days: int = 1,
|
|
323
|
+
) -> bytes:
|
|
324
|
+
"""Emit a minimal self-signed X.509 cert binding `kid_pub` via the
|
|
325
|
+
`id-hm-kid` SAN OID. Returns PEM bytes.
|
|
326
|
+
|
|
327
|
+
The cert's `SubjectPublicKeyInfo` is the Ed25519 public half of the
|
|
328
|
+
kid (so the cert *is* an RPK in X.509 clothing). The 32-byte raw kid
|
|
329
|
+
is also placed in a SAN ``otherName`` with OID ``1.3.6.1.4.1.99999.1``
|
|
330
|
+
for callers that prefer to read the kid out of the SAN block.
|
|
331
|
+
"""
|
|
332
|
+
if len(kid_pub) != 32:
|
|
333
|
+
raise ValueError(f"kid_pub must be 32 bytes, got {len(kid_pub)}")
|
|
334
|
+
|
|
335
|
+
pub = ed25519_priv.public_key()
|
|
336
|
+
pub_bytes = pub.public_bytes(
|
|
337
|
+
encoding=serialization.Encoding.Raw,
|
|
338
|
+
format=serialization.PublicFormat.Raw,
|
|
339
|
+
)
|
|
340
|
+
if pub_bytes != kid_pub:
|
|
341
|
+
raise ValueError("kid_pub does not match the public key of ed25519_priv")
|
|
342
|
+
|
|
343
|
+
subject = issuer = x509.Name(
|
|
344
|
+
[x509.NameAttribute(NameOID.COMMON_NAME, f"hm-kid-{kid_pub.hex()[:16]}")]
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
now = _dt.datetime.now(_dt.UTC)
|
|
348
|
+
san = x509.SubjectAlternativeName([x509.OtherName(ID_HM_KID_OID, _der_octet_string(kid_pub))])
|
|
349
|
+
|
|
350
|
+
cert = (
|
|
351
|
+
x509.CertificateBuilder()
|
|
352
|
+
.subject_name(subject)
|
|
353
|
+
.issuer_name(issuer)
|
|
354
|
+
.public_key(pub)
|
|
355
|
+
.serial_number(x509.random_serial_number())
|
|
356
|
+
.not_valid_before(now - _dt.timedelta(minutes=1))
|
|
357
|
+
.not_valid_after(now + _dt.timedelta(days=valid_days))
|
|
358
|
+
.add_extension(san, critical=False)
|
|
359
|
+
.add_extension(
|
|
360
|
+
x509.BasicConstraints(ca=True, path_length=None),
|
|
361
|
+
critical=True,
|
|
362
|
+
)
|
|
363
|
+
.sign(private_key=ed25519_priv, algorithm=None)
|
|
364
|
+
)
|
|
365
|
+
return cert.public_bytes(serialization.Encoding.PEM)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _der_octet_string(value: bytes) -> bytes:
|
|
369
|
+
"""Wrap `value` as a DER OCTET STRING (tag 0x04)."""
|
|
370
|
+
if len(value) < 0x80:
|
|
371
|
+
return bytes([0x04, len(value)]) + value
|
|
372
|
+
# 32-byte kid is always short-form; longer values not expected here.
|
|
373
|
+
raise ValueError("kid value too large for short-form DER") # pragma: no cover
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def extract_kid_from_cert(cert_pem: bytes) -> bytes:
|
|
377
|
+
"""Return the 32-byte kid embedded via SAN OID `id-hm-kid`.
|
|
378
|
+
|
|
379
|
+
Falls back to the certificate's Ed25519 SubjectPublicKey when the
|
|
380
|
+
SAN extension is absent; raises `TLSProfileError` if neither path
|
|
381
|
+
yields a 32-byte kid.
|
|
382
|
+
"""
|
|
383
|
+
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
384
|
+
try:
|
|
385
|
+
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
|
|
386
|
+
except x509.ExtensionNotFound:
|
|
387
|
+
san = None
|
|
388
|
+
|
|
389
|
+
if san is not None:
|
|
390
|
+
for general_name in san:
|
|
391
|
+
if isinstance(general_name, x509.OtherName) and general_name.type_id == ID_HM_KID_OID:
|
|
392
|
+
raw = general_name.value
|
|
393
|
+
# Strip the DER OCTET STRING wrapper if present.
|
|
394
|
+
if len(raw) == 34 and raw[0] == 0x04 and raw[1] == 32:
|
|
395
|
+
return bytes(raw[2:])
|
|
396
|
+
if len(raw) == 32:
|
|
397
|
+
return bytes(raw)
|
|
398
|
+
raise TLSProfileError(f"id-hm-kid SAN value has unexpected length {len(raw)}")
|
|
399
|
+
|
|
400
|
+
# Fallback: derive kid from the SPKI (RPK shape). Only valid for
|
|
401
|
+
# Ed25519 SPKIs — anything else means the kid binding is missing.
|
|
402
|
+
pub = cert.public_key()
|
|
403
|
+
if isinstance(pub, ed25519.Ed25519PublicKey):
|
|
404
|
+
return pub.public_bytes(
|
|
405
|
+
encoding=serialization.Encoding.Raw,
|
|
406
|
+
format=serialization.PublicFormat.Raw,
|
|
407
|
+
)
|
|
408
|
+
raise TLSProfileError("certificate does not carry an id-hm-kid SAN or Ed25519 SPKI")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def ed25519_priv_from_secret(secret_bytes: bytes) -> ed25519.Ed25519PrivateKey:
|
|
412
|
+
"""Adapter: construct a `cryptography` Ed25519 private key from the
|
|
413
|
+
raw 32-byte secret stored on `Keypair.secret_bytes` (or its
|
|
414
|
+
composite-key prefix). Used by the TCP transport when wrapping a
|
|
415
|
+
socket with TLS.
|
|
416
|
+
"""
|
|
417
|
+
return ed25519.Ed25519PrivateKey.from_private_bytes(secret_bytes[:32])
|
hypermind/types.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Public type re-exports — canonical location as of v0.5.
|
|
2
|
+
|
|
3
|
+
Per ROADMAP §0.5.4–0.5.8 (WS-F DX cleanup), the public surface is
|
|
4
|
+
consolidating around three namespaces:
|
|
5
|
+
|
|
6
|
+
- ``hypermind`` — top-level (``HyperMindAgent``, ``Claim``, ``Dispute``,
|
|
7
|
+
``Capability``, ``Caveat``, ``Blocked``, ``BlockReason``,
|
|
8
|
+
``__version__``, ``open``).
|
|
9
|
+
- ``hypermind.types`` — every supporting dataclass / Literal / Protocol
|
|
10
|
+
needed to type-annotate calls into the SDK (this module).
|
|
11
|
+
- ``hypermind.testing`` — fixtures and cohort factories used by tests
|
|
12
|
+
(``testbed_cohort`` lives here as of v0.5).
|
|
13
|
+
|
|
14
|
+
The root re-exports retained for v0.5/v0.6 fire ``PendingDeprecationWarning``
|
|
15
|
+
on first attribute lookup; they will become ``DeprecationWarning`` in
|
|
16
|
+
v0.7 and are scheduled for removal in v1.0. New code SHOULD import from
|
|
17
|
+
this module directly:
|
|
18
|
+
|
|
19
|
+
.. code-block:: python
|
|
20
|
+
|
|
21
|
+
from hypermind.types import ClaimType, RouteStrategy, Subscription
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from hypermind.agent import (
|
|
27
|
+
BlockReason,
|
|
28
|
+
ClaimEvent,
|
|
29
|
+
ClaimType,
|
|
30
|
+
VouchRequest,
|
|
31
|
+
)
|
|
32
|
+
from hypermind.consult import (
|
|
33
|
+
ArgumentDAG,
|
|
34
|
+
ArgumentEdge,
|
|
35
|
+
CalibTruthSnapshot,
|
|
36
|
+
PanelMember,
|
|
37
|
+
RouteStrategy,
|
|
38
|
+
)
|
|
39
|
+
from hypermind.dispute import DisputeStatus
|
|
40
|
+
from hypermind.pin_policy import PinPolicy
|
|
41
|
+
from hypermind.transport.bus import Subscription
|
|
42
|
+
from hypermind.wire import Kid, SignedStatement
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ArgumentDAG",
|
|
46
|
+
"ArgumentEdge",
|
|
47
|
+
"BlockReason",
|
|
48
|
+
"CalibTruthSnapshot",
|
|
49
|
+
"ClaimEvent",
|
|
50
|
+
"ClaimType",
|
|
51
|
+
"DisputeStatus",
|
|
52
|
+
"Kid",
|
|
53
|
+
"PanelMember",
|
|
54
|
+
"PinPolicy",
|
|
55
|
+
"RouteStrategy",
|
|
56
|
+
"SignedStatement",
|
|
57
|
+
"Subscription",
|
|
58
|
+
"VouchRequest",
|
|
59
|
+
]
|
hypermind/uncertainty.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""First-class `Uncertainty` type — RECEIPT.uncertainty triple semantics.
|
|
2
|
+
|
|
3
|
+
Wei-Chen Cycle 2 §5: encodes first- and second-order uncertainty so the
|
|
4
|
+
v0.2 Correlated-Agreement peer-prediction layer can score reports
|
|
5
|
+
without forklift. v0.1 emits the field; v0.2 scores it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Uncertainty:
|
|
17
|
+
"""Posterior uncertainty over a claim's truth value.
|
|
18
|
+
|
|
19
|
+
`epistemic_var` (model uncertainty, BALD-style) is what drives
|
|
20
|
+
information-gain routing in v0.2. `aleatoric_var` (irreducible
|
|
21
|
+
noise) bounds CA payment — claims with high aleatoric var yield
|
|
22
|
+
bounded peer-prediction reward to prevent reward-hacking.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
kind: Literal["beta", "dirichlet", "gauss"]
|
|
26
|
+
params: tuple[float, ...]
|
|
27
|
+
epistemic_var: float = 0.0
|
|
28
|
+
aleatoric_var: float = 0.0
|
|
29
|
+
n_effective: float = 1.0
|
|
30
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.kind == "beta" and len(self.params) != 2:
|
|
34
|
+
raise ValueError("beta uncertainty needs (alpha, beta)")
|
|
35
|
+
if self.kind == "gauss" and len(self.params) != 2:
|
|
36
|
+
raise ValueError("gauss uncertainty needs (mu, sigma_squared)")
|
|
37
|
+
if self.kind == "dirichlet" and len(self.params) < 2:
|
|
38
|
+
raise ValueError("dirichlet uncertainty needs ≥2 alphas")
|
|
39
|
+
if self.epistemic_var < 0 or self.aleatoric_var < 0:
|
|
40
|
+
raise ValueError("variances must be non-negative")
|
|
41
|
+
if self.n_effective <= 0:
|
|
42
|
+
raise ValueError("n_effective must be positive")
|
|
43
|
+
|
|
44
|
+
# ---- constructors ----------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def beta(cls, alpha: float, beta: float, *, n_eff: float | None = None) -> Uncertainty:
|
|
48
|
+
if alpha <= 0 or beta <= 0:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"beta params must be positive (got alpha={alpha}, beta={beta}).\n"
|
|
51
|
+
f" Hint: use Uncertainty.unknown() if you have no prior, or "
|
|
52
|
+
f"alpha=successes+1, beta=failures+1 for Laplace-smoothed counts."
|
|
53
|
+
)
|
|
54
|
+
n = n_eff if n_eff is not None else alpha + beta
|
|
55
|
+
var = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1))
|
|
56
|
+
return cls(
|
|
57
|
+
kind="beta",
|
|
58
|
+
params=(float(alpha), float(beta)),
|
|
59
|
+
epistemic_var=var,
|
|
60
|
+
aleatoric_var=0.0,
|
|
61
|
+
n_effective=float(n),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def unknown(cls) -> Uncertainty:
|
|
66
|
+
"""The 'I have no prior' default — Beta(1, 1), uniform on [0, 1].
|
|
67
|
+
|
|
68
|
+
Use this when publishing a claim before any evidence has been
|
|
69
|
+
gathered. The reputation kernel treats this as a non-informative
|
|
70
|
+
report; oracle resolution will move the posterior decisively.
|
|
71
|
+
"""
|
|
72
|
+
return cls.beta(1, 1)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def point(cls, p: float, *, n_eff: float = 1.0) -> Uncertainty:
|
|
76
|
+
"""A near-delta posterior at probability `p` ∈ [0, 1].
|
|
77
|
+
|
|
78
|
+
Use this for confident-but-fallible point claims. Internally
|
|
79
|
+
encodes as Beta(α, β) with α + β = n_eff and mean ≈ p.
|
|
80
|
+
"""
|
|
81
|
+
if not 0.0 <= p <= 1.0:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"point probability must be in [0, 1] (got {p}).\n"
|
|
84
|
+
f" Hint: Uncertainty.point(p) takes a probability, not log-odds."
|
|
85
|
+
)
|
|
86
|
+
# Tiny offsets keep params strictly positive while the mean stays at p.
|
|
87
|
+
alpha = max(p * n_eff, 1e-3)
|
|
88
|
+
beta = max((1 - p) * n_eff, 1e-3)
|
|
89
|
+
return cls.beta(alpha, beta, n_eff=n_eff)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def dirichlet(cls, alphas: tuple[float, ...]) -> Uncertainty:
|
|
93
|
+
alphas = tuple(float(a) for a in alphas)
|
|
94
|
+
if any(a <= 0 for a in alphas):
|
|
95
|
+
raise ValueError("dirichlet alphas must be positive")
|
|
96
|
+
a0 = sum(alphas)
|
|
97
|
+
# variance of marginal = α_i * (a0 - α_i) / (a0² * (a0 + 1))
|
|
98
|
+
var = sum(a * (a0 - a) / (a0**2 * (a0 + 1)) for a in alphas) / len(alphas)
|
|
99
|
+
return cls(
|
|
100
|
+
kind="dirichlet",
|
|
101
|
+
params=alphas,
|
|
102
|
+
epistemic_var=var,
|
|
103
|
+
aleatoric_var=0.0,
|
|
104
|
+
n_effective=a0,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def gauss(cls, mu: float, sigma2: float, *, n_eff: float = 1.0) -> Uncertainty:
|
|
109
|
+
if sigma2 < 0:
|
|
110
|
+
raise ValueError("gauss variance must be non-negative")
|
|
111
|
+
return cls(
|
|
112
|
+
kind="gauss",
|
|
113
|
+
params=(float(mu), float(sigma2)),
|
|
114
|
+
epistemic_var=float(sigma2),
|
|
115
|
+
aleatoric_var=0.0,
|
|
116
|
+
n_effective=float(n_eff),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_logits(
|
|
121
|
+
cls,
|
|
122
|
+
logits: list[float],
|
|
123
|
+
*,
|
|
124
|
+
temperature: float = 1.0,
|
|
125
|
+
deep_ensemble: list[list[float]] | None = None,
|
|
126
|
+
) -> Uncertainty:
|
|
127
|
+
"""Convert classifier logits to a Dirichlet posterior.
|
|
128
|
+
|
|
129
|
+
If `deep_ensemble` is provided, epistemic_var is the disagreement
|
|
130
|
+
across ensemble members (BALD-style); otherwise epistemic_var is
|
|
131
|
+
the marginal variance and aleatoric_var is set to 0.
|
|
132
|
+
"""
|
|
133
|
+
scaled = [logit / temperature for logit in logits]
|
|
134
|
+
m = max(scaled)
|
|
135
|
+
exps = [math.exp(x - m) for x in scaled]
|
|
136
|
+
z = sum(exps)
|
|
137
|
+
probs = [e / z for e in exps]
|
|
138
|
+
# Pseudo-counts: one effective observation per logit dimension.
|
|
139
|
+
alphas = tuple(max(p * len(probs) + 1.0, 0.5) for p in probs)
|
|
140
|
+
u = cls.dirichlet(alphas)
|
|
141
|
+
if deep_ensemble:
|
|
142
|
+
ens_probs = []
|
|
143
|
+
for ens_logits in deep_ensemble:
|
|
144
|
+
m2 = max(ens_logits)
|
|
145
|
+
exps2 = [math.exp(x - m2) for x in ens_logits]
|
|
146
|
+
z2 = sum(exps2)
|
|
147
|
+
ens_probs.append([e / z2 for e in exps2])
|
|
148
|
+
mean_probs = [
|
|
149
|
+
sum(ep[i] for ep in ens_probs) / len(ens_probs) for i in range(len(probs))
|
|
150
|
+
]
|
|
151
|
+
ens_var = sum(
|
|
152
|
+
sum((ep[i] - mean_probs[i]) ** 2 for ep in ens_probs) / len(ens_probs)
|
|
153
|
+
for i in range(len(probs))
|
|
154
|
+
) / len(probs)
|
|
155
|
+
u = cls(
|
|
156
|
+
kind=u.kind,
|
|
157
|
+
params=u.params,
|
|
158
|
+
epistemic_var=ens_var,
|
|
159
|
+
aleatoric_var=u.epistemic_var,
|
|
160
|
+
n_effective=u.n_effective,
|
|
161
|
+
)
|
|
162
|
+
return u
|
|
163
|
+
|
|
164
|
+
# ---- scoring ---------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def mean(self) -> float:
|
|
167
|
+
if self.kind == "beta":
|
|
168
|
+
a, b = self.params
|
|
169
|
+
return a / (a + b)
|
|
170
|
+
if self.kind == "gauss":
|
|
171
|
+
return self.params[0]
|
|
172
|
+
if self.kind == "dirichlet":
|
|
173
|
+
a0 = sum(self.params)
|
|
174
|
+
# Mean of last dimension as the "positive" class (convention).
|
|
175
|
+
return self.params[-1] / a0
|
|
176
|
+
raise ValueError(f"unknown kind {self.kind}")
|
|
177
|
+
|
|
178
|
+
def brier(self, outcome: int) -> float:
|
|
179
|
+
"""Brier score against a binary outcome (lower = better)."""
|
|
180
|
+
if outcome not in (0, 1):
|
|
181
|
+
raise ValueError("brier outcome must be 0 or 1")
|
|
182
|
+
return (self.mean() - outcome) ** 2
|
|
183
|
+
|
|
184
|
+
def log_score(self, outcome: int) -> float:
|
|
185
|
+
"""Log-loss against a binary outcome (lower = better)."""
|
|
186
|
+
p = self.mean()
|
|
187
|
+
# avoid log(0)
|
|
188
|
+
p = min(max(p, 1e-9), 1 - 1e-9)
|
|
189
|
+
return -math.log(p if outcome == 1 else 1 - p)
|
|
190
|
+
|
|
191
|
+
# ---- wire serialization ----------------------------------------------
|
|
192
|
+
|
|
193
|
+
def __repr__(self) -> str:
|
|
194
|
+
if self.kind == "beta":
|
|
195
|
+
a, b = self.params
|
|
196
|
+
return f"Uncertainty.beta(α={a:g}, β={b:g}, mean={self.mean():.3f}, var={self.epistemic_var:.4f})"
|
|
197
|
+
if self.kind == "gauss":
|
|
198
|
+
mu, s2 = self.params
|
|
199
|
+
return f"Uncertainty.gauss(μ={mu:g}, σ²={s2:.4f}, n_eff={self.n_effective:g})"
|
|
200
|
+
if self.kind == "dirichlet":
|
|
201
|
+
return f"Uncertainty.dirichlet(α={tuple(round(p, 3) for p in self.params)}, n_eff={self.n_effective:g})"
|
|
202
|
+
return f"Uncertainty(kind={self.kind!r}, params={self.params!r})"
|
|
203
|
+
|
|
204
|
+
def to_cbor(self) -> dict:
|
|
205
|
+
"""COSE protected header label -65608 payload."""
|
|
206
|
+
return {
|
|
207
|
+
"kind": self.kind,
|
|
208
|
+
"params": list(self.params),
|
|
209
|
+
"epistemic_var": self.epistemic_var,
|
|
210
|
+
"aleatoric_var": self.aleatoric_var,
|
|
211
|
+
"n_effective": self.n_effective,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_cbor(cls, m: dict) -> Uncertainty:
|
|
216
|
+
return cls(
|
|
217
|
+
kind=m["kind"],
|
|
218
|
+
params=tuple(m["params"]),
|
|
219
|
+
epistemic_var=float(m.get("epistemic_var", 0.0)),
|
|
220
|
+
aleatoric_var=float(m.get("aleatoric_var", 0.0)),
|
|
221
|
+
n_effective=float(m.get("n_effective", 1.0)),
|
|
222
|
+
)
|