perathos 0.2.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.
perathos/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """Perathos — cryptographic proof layer for AI outputs.
2
+
3
+ Provides a clean Python API for creating, signing, and verifying tamper-evident
4
+ VRL Proof Bundles on AI-generated content. Each bundle captures the input hash,
5
+ output hash, execution trace, and optional cryptographic signature.
6
+
7
+ Example:
8
+ >>> from perathos import prove, verify, sign
9
+ >>> bundle = prove("The capital of France is Paris.")
10
+ >>> bundle = sign(bundle) # attach Ed25519 signature
11
+ >>> valid, err = verify(bundle)
12
+ >>> print(valid) # True
13
+ """
14
+ from .bundle import create_bundle, sign_bundle, verify_bundle
15
+ from .calibration import (
16
+ BinningCalibrator,
17
+ TemperatureCalibrator,
18
+ expected_calibration_error,
19
+ )
20
+ from .engines import (
21
+ HallucinationDetector,
22
+ SchemaValidator,
23
+ SymbolicSolver,
24
+ TemporalConsistency,
25
+ all_engines,
26
+ )
27
+ from .inspector import inspect_bundle
28
+ from .keytrust import (
29
+ KmsSigner,
30
+ LocalSigner,
31
+ Signer,
32
+ TrustStore,
33
+ kid_for,
34
+ )
35
+ from .pipeline import (
36
+ MultiVerifierPipeline,
37
+ VerificationResult,
38
+ verify_output,
39
+ )
40
+ from .signing import generate_keypair, verify_signature
41
+ from .verdict import VerdictRecord
42
+ from .verifiers import (
43
+ ClaimEntailmentVerifier,
44
+ CrossExaminer,
45
+ VerificationContext,
46
+ Verifier,
47
+ default_verifiers,
48
+ )
49
+
50
+ __version__ = "0.2.0"
51
+
52
+ # Public API aliases for library users. These are the SAME function objects as
53
+ # create_bundle/sign_bundle/verify_bundle/inspect_bundle — deliberately not
54
+ # reassigning __doc__ here, because doing so would clobber the underlying
55
+ # functions' own docstrings (an alias shares the object, not a copy).
56
+ prove = create_bundle
57
+ sign = sign_bundle
58
+ verify = verify_bundle
59
+ inspect = inspect_bundle
60
+
61
+ __all__ = [
62
+ "prove",
63
+ "verify",
64
+ "sign",
65
+ "inspect",
66
+ "create_bundle",
67
+ "sign_bundle",
68
+ "verify_bundle",
69
+ "inspect_bundle",
70
+ "generate_keypair",
71
+ "verify_signature",
72
+ # key management & trust anchoring
73
+ "LocalSigner",
74
+ "KmsSigner",
75
+ "Signer",
76
+ "TrustStore",
77
+ "kid_for",
78
+ # multi-verifier pipeline
79
+ "verify_output",
80
+ "MultiVerifierPipeline",
81
+ "VerificationResult",
82
+ "VerificationContext",
83
+ "VerdictRecord",
84
+ "Verifier",
85
+ "CrossExaminer",
86
+ "ClaimEntailmentVerifier",
87
+ "default_verifiers",
88
+ # real verifier engines
89
+ "SymbolicSolver",
90
+ "HallucinationDetector",
91
+ "SchemaValidator",
92
+ "TemporalConsistency",
93
+ "all_engines",
94
+ # calibration (real-confidence honesty)
95
+ "TemperatureCalibrator",
96
+ "BinningCalibrator",
97
+ "expected_calibration_error",
98
+ ]
perathos/bundle.py ADDED
@@ -0,0 +1,347 @@
1
+ """VRL Proof Bundle creation and verification logic.
2
+
3
+ Handles the construction of tamper-evident bundles that capture the hash chain
4
+ from input/output through execution trace to the integrity root, plus optional
5
+ Ed25519 cryptographic signatures. All hashes are SHA-256 and deterministically
6
+ encoded to ensure offline verifiability.
7
+ """
8
+ import hashlib
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+
12
+ from .canonical import canonical_bytes
13
+
14
+ # Deterministic namespace: all Perathos bundle IDs live under this UUID.
15
+ # Derived itself via uuid5 so it's auditable, not magic.
16
+ PERATHOS_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://perathos.io/vrl/v1")
17
+
18
+ # The model identity descriptor — in production this would encode model weights
19
+ # fingerprint, version, and serving config.
20
+ _MODEL_DESCRIPTOR = "perathos-demo-model:sha256-deterministic:v1"
21
+
22
+
23
+ def _sha256(data: bytes) -> str:
24
+ """Compute SHA-256 digest of bytes and return as hex string.
25
+
26
+ Used internally for all hash operations in the bundle: input, output,
27
+ trace, and integrity payload. The result is always a 64-character
28
+ lowercase hex string suitable for use in bundles and hashing chains.
29
+
30
+ Args:
31
+ data: Bytes to hash.
32
+
33
+ Returns:
34
+ Lowercase hex-encoded SHA-256 digest (64 characters).
35
+
36
+ Examples:
37
+ _sha256(b"hello") → "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
38
+ _sha256(b"") → "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
39
+ """
40
+ return hashlib.sha256(data).hexdigest()
41
+
42
+
43
+ def ai_id() -> str:
44
+ """Stable AI identity: SHA-256 of the model descriptor string.
45
+
46
+ Returns the same hash across all invocations and machines, since it
47
+ derives from a fixed model descriptor string. In production, this would
48
+ reflect actual model weights fingerprint and serving configuration,
49
+ enabling verifiers to confirm which model produced a given output.
50
+
51
+ The model descriptor is a colon-separated string:
52
+ <name>:<proof_system>:<version>
53
+ Example: "openai-gpt4-serving:sha256-deterministic:v20240101"
54
+
55
+ Returns:
56
+ Lowercase hex-encoded SHA-256 (64 characters) of the model descriptor.
57
+ In production, this would reflect actual model weights and serving config.
58
+
59
+ Examples:
60
+ ai_id() → "7f9c2ba4e88fb81860ea5e70f527f31f..." (deterministic, always same)
61
+ """
62
+ return _sha256(_MODEL_DESCRIPTOR.encode("utf-8"))
63
+
64
+
65
+ BUNDLE_VERSION = "2.0"
66
+
67
+
68
+ def create_bundle(output_text: str, input_text: str = "", verification: dict | None = None,
69
+ *, model: str = "unspecified", include_answer: bool = False,
70
+ bundle_version: str = BUNDLE_VERSION) -> dict:
71
+ """Build a VRL Proof Bundle for a given AI output.
72
+
73
+ Constructs a tamper-evident bundle by hashing the input and output,
74
+ capturing execution metadata in a trace, and computing an integrity root
75
+ hash. The bundle_id is derived deterministically via UUIDv5 from the
76
+ integrity hash, so it is reconstructable offline without any state.
77
+
78
+ Args:
79
+ output_text: The AI-generated text to prove (e.g., model completion).
80
+ Must be a string (empty string is allowed).
81
+ input_text: Original prompt or request that produced output_text.
82
+ Defaults to empty string for prove-from-output-only use cases.
83
+ Must be a string.
84
+ verification: Optional verification block (verdict records + aggregate
85
+ verdict) produced by the multi-verifier pipeline. When present, its
86
+ digest is folded into integrity_hash so the verdicts are signed and
87
+ tamper-evident; stripping or altering them breaks verification.
88
+
89
+ Returns:
90
+ A dictionary containing:
91
+ - bundle_id: UUIDv5 derived from integrity_hash.
92
+ - ai_id: SHA-256 of the model descriptor.
93
+ - input_hash: SHA-256 of input_text.
94
+ - output_hash: SHA-256 of output_text.
95
+ - trace_hash: SHA-256 of execution metadata.
96
+ - integrity_hash: SHA-256 of the canonical integrity payload.
97
+ - issued_at: ISO 8601 timestamp (UTC).
98
+ - proof_system: String identifier for the hash scheme ("sha256-deterministic").
99
+ - _trace: Verbatim execution metadata dict (used by verifiers).
100
+
101
+ Raises:
102
+ ValueError: If output_text is not a string, or if input_text is not a string.
103
+ TypeError: If output_text or input_text have wrong type.
104
+ """
105
+ if not isinstance(output_text, str):
106
+ raise ValueError(
107
+ f"output_text must be a string, got {type(output_text).__name__}"
108
+ )
109
+ if not isinstance(input_text, str):
110
+ raise ValueError(
111
+ f"input_text must be a string, got {type(input_text).__name__}"
112
+ )
113
+ if verification is not None and not isinstance(verification, dict):
114
+ raise ValueError(
115
+ f"verification must be a dict or None, got {type(verification).__name__}"
116
+ )
117
+ is_v2 = bundle_version == BUNDLE_VERSION
118
+
119
+ issued_at = datetime.now(timezone.utc).isoformat()
120
+ proof_system = "sha256-deterministic"
121
+
122
+ input_hash = _sha256(input_text.encode("utf-8"))
123
+ output_hash = _sha256(output_text.encode("utf-8"))
124
+
125
+ # Trace captures execution metadata needed to reproduce / audit the run.
126
+ # Hashed separately so it can grow without breaking the integrity chain.
127
+ trace = {
128
+ "model_descriptor": _MODEL_DESCRIPTOR,
129
+ "proof_system": proof_system,
130
+ "input_length": len(input_text),
131
+ "output_length": len(output_text),
132
+ }
133
+ trace_hash = _sha256(canonical_bytes(trace))
134
+
135
+ model_ai_id = ai_id()
136
+
137
+ # Integrity payload — the tamper-evident root. Every semantically
138
+ # significant field is included; changing any one of them rotates the
139
+ # integrity_hash and therefore the bundle_id.
140
+ integrity_payload = {
141
+ "ai_id": model_ai_id,
142
+ "input_hash": input_hash,
143
+ "issued_at": issued_at,
144
+ "output_hash": output_hash,
145
+ "proof_system": proof_system,
146
+ "trace_hash": trace_hash,
147
+ }
148
+ # v2 binds the format version and the human-readable model id into the root.
149
+ if is_v2:
150
+ integrity_payload["bundle_version"] = bundle_version
151
+ integrity_payload["model"] = model
152
+ # Bind the verification block (if any) into the integrity root. Including
153
+ # verification_hash only when verification is present keeps bundles without
154
+ # verification byte-for-byte backward compatible, and any later attempt to
155
+ # add, remove, or edit the block flips this recomputation -> tamper detected.
156
+ if verification is not None:
157
+ integrity_payload["verification_hash"] = _sha256(canonical_bytes(verification))
158
+ integrity_hash = _sha256(canonical_bytes(integrity_payload))
159
+
160
+ # UUIDv5: deterministic, reconstructable from integrity_hash alone.
161
+ bundle_id = str(uuid.uuid5(PERATHOS_NAMESPACE, integrity_hash))
162
+
163
+ bundle = {
164
+ "bundle_id": bundle_id,
165
+ "proof_system": proof_system,
166
+ "issued_at": issued_at,
167
+ "ai_id": model_ai_id,
168
+ "input_hash": input_hash,
169
+ "output_hash": output_hash,
170
+ "trace_hash": trace_hash,
171
+ "integrity_hash": integrity_hash,
172
+ # _trace stored verbatim so verifiers can recompute trace_hash offline.
173
+ "_trace": trace,
174
+ }
175
+ if is_v2:
176
+ # Top-level convenience fields. `model` and `bundle_version` are bound
177
+ # into integrity_hash; `verdict` is a copy of the aggregate (cross-checked
178
+ # on verify); `answer` is bound via output_hash (cross-checked on verify).
179
+ bundle["bundle_version"] = bundle_version
180
+ bundle["model"] = model
181
+ if verification is not None:
182
+ bundle["verdict"] = verification.get("aggregate", {}).get("verdict")
183
+ if include_answer:
184
+ bundle["answer"] = output_text
185
+ if verification is not None:
186
+ # Stored verbatim so a consumer can recompute verification_hash offline.
187
+ bundle["verification"] = verification
188
+ return bundle
189
+
190
+
191
+ def sign_bundle(bundle: dict, signing_key_hex: str) -> dict:
192
+ """Attach an Ed25519 provider_signature over integrity_hash.
193
+
194
+ Computes an Ed25519 signature on the raw bytes of integrity_hash and
195
+ adds it to the bundle as a provider_signature field. Does not mutate
196
+ the input bundle; returns a new dict with the signature attached.
197
+
198
+ Args:
199
+ bundle: A bundle dict from create_bundle() (or compatible structure).
200
+ Must be a dict and must contain the 'integrity_hash' field.
201
+ signing_key_hex: 64-character hex-encoded Ed25519 signing key.
202
+ Must be a string containing exactly 64 hex characters (0-9, a-f).
203
+
204
+ Returns:
205
+ A new dict with all fields from bundle plus a "provider_signature" field:
206
+ - algorithm: "ed25519"
207
+ - verify_key: 64-character hex-encoded public key
208
+ - signature: 128-character hex-encoded signature bytes
209
+
210
+ Raises:
211
+ TypeError: If bundle is not a dict or signing_key_hex is not a string.
212
+ KeyError: If bundle does not contain the 'integrity_hash' field.
213
+ ValueError: If signing_key_hex is not a valid 64-character hex string.
214
+ """
215
+ if not isinstance(bundle, dict):
216
+ raise TypeError(f"bundle must be a dict, got {type(bundle).__name__}")
217
+ from .keytrust import LocalSigner, Signer # local import to avoid cycles
218
+ is_signer = isinstance(signing_key_hex, Signer)
219
+ if not is_signer and not isinstance(signing_key_hex, str):
220
+ raise TypeError(
221
+ f"signing_key_hex must be a string or a Signer, got {type(signing_key_hex).__name__}"
222
+ )
223
+ if "integrity_hash" not in bundle:
224
+ raise KeyError("bundle must contain 'integrity_hash' field")
225
+
226
+ if is_signer:
227
+ signer = signing_key_hex
228
+ else:
229
+ if len(signing_key_hex) != 64:
230
+ raise ValueError(
231
+ f"signing_key_hex must be exactly 64 hex characters, got {len(signing_key_hex)}"
232
+ )
233
+ if any(c not in "0123456789abcdef" for c in signing_key_hex):
234
+ raise ValueError(
235
+ f"signing_key_hex must contain only lowercase hex characters (0-9, a-f), got: {signing_key_hex}"
236
+ )
237
+ signer = LocalSigner.from_hex(signing_key_hex)
238
+
239
+ from .signing import sign_hash_with_signer
240
+ sig = sign_hash_with_signer(bundle["integrity_hash"], signer)
241
+ return {**bundle, "provider_signature": sig}
242
+
243
+
244
+ def verify_bundle(bundle: dict, expected_verify_key: str | None = None, trust_store=None) -> tuple[bool, str]:
245
+ """Recompute all hashes from stored data and check they match.
246
+
247
+ Verifies the integrity chain: _trace → trace_hash → integrity_hash → bundle_id,
248
+ and — if the bundle carries a provider_signature — that the Ed25519 signature
249
+ validates over integrity_hash. Does not re-hash the original input/output text
250
+ (those are gone); only checks that the stored hashes are self-consistent and
251
+ form a valid chain.
252
+
253
+ Args:
254
+ bundle: A bundle dict from create_bundle() (optionally with signature).
255
+ expected_verify_key: If given, a signed bundle is only accepted when its
256
+ provider_signature.verify_key equals this 64-char hex public key. This
257
+ pins provenance: the signature itself only proves *some* key signed the
258
+ hash, and verify_key lives outside integrity_hash, so without pinning an
259
+ attacker can strip the signature and re-sign with their own key.
260
+
261
+ Returns:
262
+ A tuple (valid, error_code) where:
263
+ - valid is True and error_code is "" on successful verification.
264
+ - valid is False and error_code is a string like "ERR_TRACE_HASH_MISMATCH"
265
+ on any failure. Check for 'ERR' substring to detect invalid bundles.
266
+ """
267
+ try:
268
+ trace = bundle.get("_trace")
269
+ if trace is None:
270
+ return False, "ERR_MISSING_TRACE"
271
+
272
+ # Step 1: trace integrity
273
+ if _sha256(canonical_bytes(trace)) != bundle["trace_hash"]:
274
+ return False, "ERR_TRACE_HASH_MISMATCH"
275
+
276
+ # Dual-read: a bundle with no bundle_version is a v1 bundle and verifies
277
+ # exactly as before; "2.0" adds bound fields + cross-checks. Existing v1
278
+ # bundles keep verifying unchanged.
279
+ version = bundle.get("bundle_version", "1.0")
280
+
281
+ # Step 2: integrity root
282
+ integrity_payload = {
283
+ "ai_id": bundle["ai_id"],
284
+ "input_hash": bundle["input_hash"],
285
+ "issued_at": bundle["issued_at"],
286
+ "output_hash": bundle["output_hash"],
287
+ "proof_system": bundle["proof_system"],
288
+ "trace_hash": bundle["trace_hash"],
289
+ }
290
+ if version == BUNDLE_VERSION:
291
+ integrity_payload["bundle_version"] = bundle["bundle_version"]
292
+ integrity_payload["model"] = bundle["model"]
293
+ # The verification block is bound into integrity_hash iff it is present
294
+ # (see create_bundle). Recompute symmetrically so that adding, removing,
295
+ # or editing the verdicts is detected as a hash mismatch.
296
+ verification = bundle.get("verification")
297
+ if verification is not None:
298
+ integrity_payload["verification_hash"] = _sha256(canonical_bytes(verification))
299
+ if _sha256(canonical_bytes(integrity_payload)) != bundle["integrity_hash"]:
300
+ return False, "ERR_INTEGRITY_HASH_MISMATCH"
301
+
302
+ # Step 3: bundle_id derivation
303
+ expected_id = str(uuid.uuid5(PERATHOS_NAMESPACE, bundle["integrity_hash"]))
304
+ if expected_id != bundle["bundle_id"]:
305
+ return False, "ERR_BUNDLE_ID_MISMATCH"
306
+
307
+ # Step 3b (v2): cross-check the convenience copies. The top-level verdict
308
+ # must equal the bound aggregate verdict, and a stored answer must hash to
309
+ # output_hash — so neither can be tampered without detection.
310
+ if version == BUNDLE_VERSION:
311
+ if verification is not None:
312
+ if bundle.get("verdict") != verification.get("aggregate", {}).get("verdict"):
313
+ return False, "ERR_VERDICT_MISMATCH"
314
+ if "answer" in bundle:
315
+ if _sha256(bundle["answer"].encode("utf-8")) != bundle["output_hash"]:
316
+ return False, "ERR_ANSWER_MISMATCH"
317
+
318
+ # Step 4: signature. A bundle that carries a provider_signature must NOT
319
+ # pass verification if that signature does not validate — otherwise the
320
+ # public verify() reports a forged signature as valid.
321
+ sig = bundle.get("provider_signature")
322
+ if sig is not None:
323
+ from .signing import verify_signature # local import avoids a cycle
324
+ sig_valid, sig_err = verify_signature(bundle["integrity_hash"], sig)
325
+ if not sig_valid:
326
+ return False, sig_err
327
+ # Step 5: optional signer pinning (provenance).
328
+ if expected_verify_key is not None and sig.get("verify_key") != expected_verify_key:
329
+ return False, "ERR_UNEXPECTED_SIGNER"
330
+ # Step 6: optional trust-anchor check — the signer's key must be one
331
+ # the consumer has explicitly trusted (by derived kid), not just any
332
+ # key the bundle embeds. Scales signer pinning to many keys via JWKS.
333
+ if trust_store is not None:
334
+ from .keytrust import kid_for
335
+ vk_bytes = bytes.fromhex(sig["verify_key"])
336
+ if trust_store.get(kid_for(vk_bytes)) != vk_bytes:
337
+ return False, "ERR_UNTRUSTED_SIGNER"
338
+
339
+ return True, ""
340
+
341
+ except KeyError as exc:
342
+ return False, f"ERR_MISSING_FIELD:{exc}"
343
+ except (TypeError, ValueError) as exc:
344
+ # A hostile/malformed bundle (e.g. _trace with mixed-type keys that
345
+ # json.dumps(sort_keys=True) cannot order) must return an error, not
346
+ # crash a verifier that is documented to return (False, error_code).
347
+ return False, f"ERR_MALFORMED_BUNDLE:{exc}"
@@ -0,0 +1,134 @@
1
+ """Confidence calibration for verifier scores.
2
+
3
+ A verifier's raw score (e.g. an NLI entailment probability) is NOT the same as
4
+ the probability the verdict is correct. confidence_bps makes an honesty promise
5
+ — "0.8 means ~80% right" — and that promise has to be *fit and measured*, not
6
+ assumed. This module fits a mapping from raw score -> calibrated probability
7
+ using held-out labeled (score, correct) pairs, and reports Expected Calibration
8
+ Error (ECE) so the promise is testable.
9
+
10
+ Pure-Python (math only), no heavy dependencies, so it lives in the core package.
11
+ Persist with to_dict()/from_dict() and store the calibrator alongside the
12
+ verifier version that produced the scores.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from typing import List, Sequence, Tuple
19
+
20
+ _EPS = 1e-6
21
+
22
+
23
+ def _clamp01(p: float) -> float:
24
+ return min(1.0 - _EPS, max(_EPS, p))
25
+
26
+
27
+ def expected_calibration_error(
28
+ scores: Sequence[float], labels: Sequence[int], n_bins: int = 10
29
+ ) -> float:
30
+ """Binned ECE: weighted average gap between confidence and accuracy."""
31
+ if not scores:
32
+ return 0.0
33
+ n = len(scores)
34
+ total = 0.0
35
+ for b in range(n_bins):
36
+ lo, hi = b / n_bins, (b + 1) / n_bins
37
+ idx = [i for i, s in enumerate(scores) if (s > lo or (b == 0 and s >= lo)) and s <= hi]
38
+ if not idx:
39
+ continue
40
+ conf = sum(scores[i] for i in idx) / len(idx)
41
+ acc = sum(labels[i] for i in idx) / len(idx)
42
+ total += (len(idx) / n) * abs(conf - acc)
43
+ return total
44
+
45
+
46
+ @dataclass
47
+ class TemperatureCalibrator:
48
+ """One-parameter temperature scaling on the logit of a [0,1] score.
49
+
50
+ calibrate(p) = sigmoid(logit(p) / T). T is fit by minimizing log-loss over
51
+ labeled data via a 1-D ternary search. T>1 softens overconfident scores.
52
+ """
53
+
54
+ temperature: float = 1.0
55
+
56
+ def calibrate(self, score: float) -> float:
57
+ p = _clamp01(float(score))
58
+ logit = math.log(p / (1.0 - p))
59
+ return 1.0 / (1.0 + math.exp(-logit / self.temperature))
60
+
61
+ def confidence_bps(self, score: float) -> int:
62
+ return int(round(self.calibrate(score) * 1000))
63
+
64
+ def _nll(self, scores: Sequence[float], labels: Sequence[int], t: float) -> float:
65
+ total = 0.0
66
+ for s, y in zip(scores, labels):
67
+ p = _clamp01(self.__class__(temperature=t).calibrate(s))
68
+ total += -(y * math.log(p) + (1 - y) * math.log(1.0 - p))
69
+ return total / max(1, len(scores))
70
+
71
+ def fit(self, scores: Sequence[float], labels: Sequence[int]) -> "TemperatureCalibrator":
72
+ if not scores:
73
+ return self
74
+ lo, hi = 0.05, 20.0
75
+ for _ in range(60): # ternary search converges quickly on a convex-ish NLL
76
+ m1 = lo + (hi - lo) / 3.0
77
+ m2 = hi - (hi - lo) / 3.0
78
+ if self._nll(scores, labels, m1) < self._nll(scores, labels, m2):
79
+ hi = m2
80
+ else:
81
+ lo = m1
82
+ self.temperature = (lo + hi) / 2.0
83
+ return self
84
+
85
+ def to_dict(self) -> dict:
86
+ return {"method": "temperature", "temperature": self.temperature}
87
+
88
+ @classmethod
89
+ def from_dict(cls, d: dict) -> "TemperatureCalibrator":
90
+ return cls(temperature=float(d["temperature"]))
91
+
92
+
93
+ @dataclass
94
+ class BinningCalibrator:
95
+ """Reliability-binning calibrator: map a score to the empirical accuracy of
96
+ its bin on the fit data. Isotonic-free, robust on small samples."""
97
+
98
+ n_bins: int = 10
99
+ bin_accuracy: List[float] = None # type: ignore[assignment]
100
+
101
+ def fit(self, scores: Sequence[float], labels: Sequence[int]) -> "BinningCalibrator":
102
+ acc = []
103
+ for b in range(self.n_bins):
104
+ lo, hi = b / self.n_bins, (b + 1) / self.n_bins
105
+ idx = [i for i, s in enumerate(scores) if (s > lo or (b == 0 and s >= lo)) and s <= hi]
106
+ acc.append(sum(labels[i] for i in idx) / len(idx) if idx else (b + 0.5) / self.n_bins)
107
+ self.bin_accuracy = acc
108
+ return self
109
+
110
+ def calibrate(self, score: float) -> float:
111
+ if not self.bin_accuracy:
112
+ return _clamp01(float(score))
113
+ b = min(self.n_bins - 1, max(0, int(float(score) * self.n_bins)))
114
+ return self.bin_accuracy[b]
115
+
116
+ def confidence_bps(self, score: float) -> int:
117
+ return int(round(self.calibrate(score) * 1000))
118
+
119
+ def to_dict(self) -> dict:
120
+ return {"method": "binning", "n_bins": self.n_bins, "bin_accuracy": self.bin_accuracy}
121
+
122
+ @classmethod
123
+ def from_dict(cls, d: dict) -> "BinningCalibrator":
124
+ c = cls(n_bins=int(d["n_bins"]))
125
+ c.bin_accuracy = list(d["bin_accuracy"])
126
+ return c
127
+
128
+
129
+ def reliability(scores: Sequence[float], labels: Sequence[int], n_bins: int = 10) -> Tuple[float, float]:
130
+ """Convenience: (ECE before, ECE after) temperature calibration on the data."""
131
+ before = expected_calibration_error(scores, labels, n_bins)
132
+ cal = TemperatureCalibrator().fit(scores, labels)
133
+ after = expected_calibration_error([cal.calibrate(s) for s in scores], labels, n_bins)
134
+ return before, after
perathos/canonical.py ADDED
@@ -0,0 +1,92 @@
1
+ """Deterministic canonical JSON encoding for tamper-evident hashing.
2
+
3
+ Provides a stable JSON representation where key order and formatting are
4
+ fixed, enabling reproducible hashes across independent verifiers. This is
5
+ critical for Perathos because bundles must be verifiable offline: the same
6
+ input dict must always produce identical bytes, regardless of Python version,
7
+ platform, or execution order. Without canonical encoding, an attacker could
8
+ reorder keys or change whitespace to produce a different hash while keeping
9
+ the same logical dict.
10
+
11
+ Why determinism matters:
12
+ - Bundle integrity relies on hashing dicts (trace, integrity payload).
13
+ - If two Python processes hash the same dict differently, one verifier
14
+ will reject a valid bundle (false negative) or accept a tampered one
15
+ (false positive).
16
+ - Canonical encoding ensures byte-for-byte reproducibility across all
17
+ verifiers, even offline and across languages (via JSON spec).
18
+ """
19
+ import json
20
+
21
+
22
+ def _assert_canonicalizable(obj) -> None:
23
+ """Reject inputs that would break byte-for-byte cross-language determinism.
24
+
25
+ Non-string dict keys are rejected because json coerces them to strings
26
+ ({1: 'a'} and {'1': 'a'} would collide to identical bytes, defeating
27
+ injectivity). Non-finite floats are handled separately by allow_nan=False.
28
+ """
29
+ if isinstance(obj, dict):
30
+ for k, v in obj.items():
31
+ if not isinstance(k, str):
32
+ raise ValueError(
33
+ f"canonical JSON requires string object keys, got {type(k).__name__}"
34
+ )
35
+ _assert_canonicalizable(v)
36
+ elif isinstance(obj, (list, tuple)):
37
+ for v in obj:
38
+ _assert_canonicalizable(v)
39
+
40
+
41
+ def canonical_json(obj: dict) -> str:
42
+ """Convert a dict to deterministic JSON string with sorted keys and no whitespace.
43
+
44
+ Ensures that the same Python dict always produces identical JSON bytes,
45
+ enabling reproducible hashing. Keys are sorted lexicographically, separators
46
+ are compact (no spaces), and non-ASCII characters (e.g., Japanese text) are
47
+ preserved as-is rather than escaped, improving human readability while
48
+ maintaining byte-for-byte determinism across Python versions and platforms.
49
+
50
+ Args:
51
+ obj: A dictionary to serialize (typically a bundle or trace dict).
52
+
53
+ Returns:
54
+ A JSON string with sorted keys, no whitespace, and preserved Unicode.
55
+
56
+ Examples:
57
+ Input: {'b': 2, 'a': 1}
58
+ Output: '{"a":1,"b":2}'
59
+
60
+ Input: {'name': '日本', 'count': 3}
61
+ Output: '{"count":3,"name":"日本"}'
62
+ """
63
+ _assert_canonicalizable(obj)
64
+ # allow_nan=False: NaN/Infinity are not valid JSON and are rejected by
65
+ # spec-compliant parsers (Go/JS), so they must never enter a proof bundle.
66
+ return json.dumps(
67
+ obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
68
+ )
69
+
70
+
71
+ def canonical_bytes(obj: dict) -> bytes:
72
+ """Convert a dict to deterministic JSON bytes (UTF-8 encoded).
73
+
74
+ Equivalent to canonical_json(obj).encode('utf-8'). Used internally
75
+ to hash dictionaries for bundle integrity checks and trace verification.
76
+ The bytes returned are passed directly to hashlib.sha256(), so encoding
77
+ is critical: UTF-8 is the standard for all hashing in Perathos.
78
+
79
+ Args:
80
+ obj: A dictionary to serialize.
81
+
82
+ Returns:
83
+ The canonical JSON representation encoded as UTF-8 bytes.
84
+
85
+ Examples:
86
+ Input: {'b': 2, 'a': 1}
87
+ Output: b'{"a":1,"b":2}'
88
+
89
+ Input: {'x': 1}
90
+ Output: b'{"x":1}'
91
+ """
92
+ return canonical_json(obj).encode("utf-8")