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 +98 -0
- perathos/bundle.py +347 -0
- perathos/calibration.py +134 -0
- perathos/canonical.py +92 -0
- perathos/cli.py +622 -0
- perathos/engines.py +371 -0
- perathos/inspector.py +323 -0
- perathos/keytrust.py +165 -0
- perathos/pipeline.py +132 -0
- perathos/signing.py +161 -0
- perathos/storage.py +599 -0
- perathos/verdict.py +77 -0
- perathos/verifiers.py +220 -0
- perathos/verifiers_nli.py +134 -0
- perathos-0.2.0.dist-info/METADATA +25 -0
- perathos-0.2.0.dist-info/RECORD +19 -0
- perathos-0.2.0.dist-info/WHEEL +5 -0
- perathos-0.2.0.dist-info/entry_points.txt +2 -0
- perathos-0.2.0.dist-info/top_level.txt +1 -0
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}"
|
perathos/calibration.py
ADDED
|
@@ -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")
|