implicant 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.
implicant/config.py ADDED
@@ -0,0 +1,46 @@
1
+ """User-level SDK configuration persisted as TOML.
2
+
3
+ Crypto parameters are *not* configured here — they come from each model's
4
+ ``circuit_meta.json`` (§5.1). This config only holds client-local knobs:
5
+ where the platform lives and where to keep the public-key cache.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ _DEFAULT_HOME = Path("~/.implicant").expanduser()
16
+ _DEFAULT_PATH = _DEFAULT_HOME / "config.toml"
17
+
18
+
19
+ class ImplicantConfig(BaseModel):
20
+ """Configuration loaded from ``~/.implicant/config.toml``."""
21
+
22
+ model_config = ConfigDict(frozen=True)
23
+
24
+ api_url: str = Field(default="https://api.implicant.local")
25
+ key_cache_dir: str = Field(default=str(_DEFAULT_HOME / "keys"))
26
+
27
+ @classmethod
28
+ def load(cls, path: Path | None = None) -> "ImplicantConfig":
29
+ """Load config from ``path``, returning defaults if missing."""
30
+ target = path or _DEFAULT_PATH
31
+ if not target.is_file():
32
+ return cls()
33
+ return cls.model_validate(tomllib.loads(target.read_text()))
34
+
35
+ def dump_toml(self) -> str:
36
+ """Serialize to a minimal TOML string."""
37
+ return (
38
+ f'api_url = "{self.api_url}"\n'
39
+ f'key_cache_dir = "{self.key_cache_dir}"\n'
40
+ )
41
+
42
+ def save(self, path: Path | None = None) -> Path:
43
+ target = path or _DEFAULT_PATH
44
+ target.parent.mkdir(parents=True, exist_ok=True)
45
+ target.write_text(self.dump_toml())
46
+ return target
implicant/envelope.py ADDED
@@ -0,0 +1,76 @@
1
+ """§3 ciphertext envelope — wraps N SEAL ciphertext blobs in one body.
2
+
3
+ This is the ``application/octet-stream`` framing the SDK sends as a predict
4
+ request body and receives as the response body. It is *outer* framing: each
5
+ ``seal_bytes`` item is already a self-contained per-ciphertext blob produced
6
+ by ``ClientContext.encrypt_input_bits`` (raw ``Ciphertext::save`` with its
7
+ own SEALHeader), and the server returns items of the same shape.
8
+
9
+ Layout (§3):
10
+
11
+ magic : 8 bytes ASCII = "IMPLCTX\\0"
12
+ version : u32 little-endian = 1
13
+ count : u32 little-endian
14
+ [ length : u64 LE | seal_bytes : <length> bytes ] × count
15
+
16
+ The request ``count`` is the client-derived input-ct count
17
+ (``ceil(circuit.input_bits / (slot_count/2))``); the server re-derives and
18
+ guards it (§3, P4). The response ``count`` is the variant-determined output
19
+ shape, read from ``len(unpack_envelope(...))`` — not from the manifest
20
+ (``n_input_ciphertexts`` / ``n_output_ciphertexts`` are not manifest fields).
21
+
22
+ Reference: ``platform/docs/FHE_INTERFACE_SPEC.md §3``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import struct
28
+
29
+ __all__ = ["pack_envelope", "unpack_envelope", "EnvelopeError", "MAGIC", "VERSION"]
30
+
31
+ MAGIC = b"IMPLCTX\x00"
32
+ VERSION = 1
33
+
34
+ _HEADER = struct.Struct("<II") # version, count
35
+ _LEN = struct.Struct("<Q") # per-item length prefix
36
+
37
+
38
+ class EnvelopeError(ValueError):
39
+ """Raised when an envelope is malformed or truncated."""
40
+
41
+
42
+ def pack_envelope(ciphertexts: list[bytes]) -> bytes:
43
+ """Frame ``ciphertexts`` into a single §3 envelope body."""
44
+ out = bytearray(MAGIC)
45
+ out += _HEADER.pack(VERSION, len(ciphertexts))
46
+ for blob in ciphertexts:
47
+ out += _LEN.pack(len(blob))
48
+ out += blob
49
+ return bytes(out)
50
+
51
+
52
+ def unpack_envelope(body: bytes) -> list[bytes]:
53
+ """Parse a §3 envelope body back into its list of ciphertext blobs.
54
+
55
+ Raises:
56
+ EnvelopeError: on bad magic, unknown version, or truncation.
57
+ """
58
+ if body[:8] != MAGIC:
59
+ raise EnvelopeError("bad envelope magic")
60
+ version, count = _HEADER.unpack_from(body, 8)
61
+ if version != VERSION:
62
+ raise EnvelopeError(f"unsupported envelope version {version}")
63
+
64
+ items: list[bytes] = []
65
+ offset = 8 + _HEADER.size
66
+ for i in range(count):
67
+ if offset + _LEN.size > len(body):
68
+ raise EnvelopeError(f"truncated length prefix at item {i}")
69
+ (length,) = _LEN.unpack_from(body, offset)
70
+ offset += _LEN.size
71
+ end = offset + length
72
+ if end > len(body):
73
+ raise EnvelopeError(f"truncated ciphertext body at item {i}")
74
+ items.append(body[offset:end])
75
+ offset = end
76
+ return items
implicant/keys.py ADDED
@@ -0,0 +1,172 @@
1
+ """Key lifecycle for the SDK.
2
+
3
+ Two cohesive pieces, one domain:
4
+
5
+ - :class:`KeySession` — owns the live ``helut.client.ClientContext`` (and
6
+ therefore the secret key) for the duration of a process. Generates keys,
7
+ exposes the three public blobs, and is the only object able to encrypt /
8
+ decrypt.
9
+ - :class:`PublicKeyCache` — persists ``key_id`` + the three *public* blobs
10
+ to ``~/.implicant/keys/<key_id>/`` so the client can skip re-upload when
11
+ the server already holds them.
12
+
13
+ **No secret material is ever written to disk.** The `helut` binding exposes
14
+ no secret-key serialization (verified against the compiled module), so a
15
+ ``KeySession`` cannot be restored from disk — a fresh process must re-run
16
+ keygen before it can decrypt.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ from helut.client import (
26
+ ClientContext,
27
+ KeyBundle,
28
+ Params,
29
+ generate_keys,
30
+ key_id_from_params,
31
+ )
32
+
33
+ __all__ = ["KeySession", "PublicKeyCache"]
34
+
35
+
36
+ class KeySession:
37
+ """A live secret-key-holding context for one parameter set.
38
+
39
+ Construction runs BGV keygen (cost scales with ``poly_modulus_degree``),
40
+ so a session is created once per ``key_id`` and reused for every
41
+ encrypt/decrypt in the process. The secret key lives only inside the C++
42
+ ``ClientContext`` and is never serialized (see the module docstring).
43
+ """
44
+
45
+ def __init__(self, ctx: ClientContext, bundle: KeyBundle, key_id: str) -> None:
46
+ self._ctx = ctx
47
+ self._bundle = bundle
48
+ self._key_id = key_id
49
+
50
+ @classmethod
51
+ def generate(cls, params: Params) -> "KeySession":
52
+ """Run keygen for ``params`` and wrap the resulting context (§7)."""
53
+ ctx, bundle = generate_keys(params)
54
+ return cls(ctx, bundle, key_id_from_params(params))
55
+
56
+ @property
57
+ def key_id(self) -> str:
58
+ """Stable identifier the server indexes public blobs by (§1)."""
59
+ return self._key_id
60
+
61
+ @property
62
+ def public_bundle(self) -> KeyBundle:
63
+ """The three public blobs to upload (§2). No secret material."""
64
+ return self._bundle
65
+
66
+ def encrypt_bits(self, bits: np.ndarray) -> list[bytes]:
67
+ """Encrypt a flat bit vector into one or more input ciphertexts (§3).
68
+
69
+ Uses the chunked encoder so any length is supported; the client
70
+ derives the input-ct count from the chunking
71
+ (``ceil(len / (slot_count/2))``). Returns a one-element list when the
72
+ input fits a single row.
73
+
74
+ Args:
75
+ bits: 1-D ``uint8`` array from :func:`implicant.binarize.encode_row`.
76
+
77
+ Returns:
78
+ List of wire-format ciphertext blobs (envelope items).
79
+ """
80
+ return self._ctx.encrypt_input_bits_chunked(bits)
81
+
82
+ def decrypt_slots(self, ciphertext: bytes, n: int | None = None) -> np.ndarray:
83
+ """Decrypt one output ciphertext blob to its **unsigned** slot values.
84
+
85
+ The binding always returns ``uint64`` residues (§7, P14) — there is no
86
+ ``signed`` flag. For Variant B class scores, pass the result through
87
+ :func:`helut.client.decode_class_scores` (with :attr:`plain_modulus`)
88
+ to recover the signed logits; raw bits (Variant A) are used as-is.
89
+
90
+ Args:
91
+ ciphertext: One §3 envelope item returned by the server.
92
+ n: Truncate to the first ``n`` slots (``num_classes`` for B, the
93
+ last layer's LUT count for A).
94
+
95
+ Returns:
96
+ Slot array (``uint64`` unsigned).
97
+ """
98
+ if n is not None:
99
+ return self._ctx.decrypt_output_slots(ciphertext, n=n)
100
+ return self._ctx.decrypt_output_slots(ciphertext)
101
+
102
+ @property
103
+ def plain_modulus(self) -> int:
104
+ """The BGV plaintext modulus prime, for the Variant B signed decode.
105
+
106
+ Needed by :func:`helut.client.decode_class_scores` (balanced residue);
107
+ ``params.plain_modulus_bits`` is only the bit width, not the prime."""
108
+ return int(self._ctx.plain_modulus)
109
+
110
+
111
+ # Blob filename → KeyBundle attribute. Single source of truth for the
112
+ # on-disk layout, shared by read() and write().
113
+ _BLOBS = {
114
+ "public_key.bin": "public_key",
115
+ "relin_keys.bin": "relin_keys",
116
+ "galois_keys.bin": "galois_keys",
117
+ }
118
+
119
+
120
+ class PublicKeyCache:
121
+ """Filesystem cache of uploaded public key blobs, keyed by ``key_id``.
122
+
123
+ Layout::
124
+
125
+ <root>/<key_id>/
126
+ public_key.bin
127
+ relin_keys.bin
128
+ galois_keys.bin
129
+
130
+ Lets the client check whether the public material for a ``key_id`` was
131
+ already produced/uploaded, so a session can skip re-sending ~28 MB of
132
+ Galois keys. **Public material only** — there is intentionally no
133
+ ``secret_key.bin``. Writes are atomic (tmp file + ``os.replace``); public
134
+ blobs are not secret, so no special permissions are enforced.
135
+ """
136
+
137
+ def __init__(self, root: Path) -> None:
138
+ self._root = root
139
+
140
+ def _dir(self, key_id: str) -> Path:
141
+ return self._root / key_id
142
+
143
+ def has(self, key_id: str) -> bool:
144
+ """True iff all three public blobs are present for ``key_id``."""
145
+ d = self._dir(key_id)
146
+ return all((d / name).is_file() for name in _BLOBS)
147
+
148
+ def write(self, key_id: str, bundle: KeyBundle) -> None:
149
+ """Persist a bundle's three public blobs under ``key_id`` atomically."""
150
+ d = self._dir(key_id)
151
+ d.mkdir(parents=True, exist_ok=True)
152
+ for name, attr in _BLOBS.items():
153
+ self._write_atomic(d / name, getattr(bundle, attr))
154
+
155
+ def read(self, key_id: str) -> KeyBundle:
156
+ """Load the cached public blobs for ``key_id`` into a KeyBundle.
157
+
158
+ Raises:
159
+ FileNotFoundError: if any blob is missing.
160
+ """
161
+ d = self._dir(key_id)
162
+ return KeyBundle(
163
+ public_key=(d / "public_key.bin").read_bytes(),
164
+ relin_keys=(d / "relin_keys.bin").read_bytes(),
165
+ galois_keys=(d / "galois_keys.bin").read_bytes(),
166
+ )
167
+
168
+ @staticmethod
169
+ def _write_atomic(path: Path, data: bytes) -> None:
170
+ tmp = path.with_suffix(path.suffix + ".tmp")
171
+ tmp.write_bytes(data)
172
+ os.replace(tmp, path)
implicant/manifest.py ADDED
@@ -0,0 +1,115 @@
1
+ """``circuit_meta.json`` handling — the client's sole source of crypto truth.
2
+
3
+ The platform publishes one ``circuit_meta.json`` per model (§5.1). The SDK
4
+ fetches it, validates it, rehydrates a typed ``helut.Params`` from its
5
+ ``key_params`` block, and derives the ``required_key_id`` used for key-cache
6
+ lookup (§1). The client never *guesses* crypto params — it only reads them
7
+ from this manifest.
8
+
9
+ Validation is delegated to ``helut.io.validate_circuit_meta`` so the SDK
10
+ and the worker share one schema guard. This module adds a thin typed view
11
+ (:class:`CircuitMeta`) for ergonomic access to the envelope counts the
12
+ transport layer needs.
13
+
14
+ Reference: ``platform/docs/FHE_INTERFACE_SPEC.md §1, §5.1``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from helut.client import ( # provided by implicant-fhe
23
+ CircuitMetaError,
24
+ Params,
25
+ key_id_from_params,
26
+ validate_circuit_meta,
27
+ )
28
+
29
+ __all__ = ["CircuitMeta", "CircuitMetaError"]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class CircuitMeta:
34
+ """Typed, validated view over a fetched ``circuit_meta.json`` dict.
35
+
36
+ Holds the raw dict (the canonical artifact) plus the few fields the
37
+ SDK reads directly. ``Params`` and ``required_key_id`` are derived,
38
+ never stored redundantly on the wire.
39
+ """
40
+
41
+ raw: dict[str, Any]
42
+
43
+ @classmethod
44
+ def from_dict(cls, meta: dict[str, Any]) -> "CircuitMeta":
45
+ """Validate ``meta`` against §5.1 and wrap it.
46
+
47
+ Raises:
48
+ CircuitMetaError: if the manifest fails schema validation.
49
+ """
50
+ validate_circuit_meta(meta)
51
+ return cls(raw=meta)
52
+
53
+ @property
54
+ def params(self) -> Params:
55
+ """Rehydrate the typed crypto params from ``key_params`` (§5.1).
56
+
57
+ This is the only place the SDK builds a ``Params``; the result
58
+ feeds ``generate_keys`` and the encrypt/decrypt calls.
59
+ """
60
+ return Params.from_dict(self.raw)
61
+
62
+ @property
63
+ def required_key_id(self) -> str:
64
+ """Stable key identifier the server indexes public blobs by (§1)."""
65
+ return key_id_from_params(self.params)
66
+
67
+ @property
68
+ def input_bits(self) -> int:
69
+ """Flat select-bit vector length the client encrypts (§5.1).
70
+
71
+ The number of input ciphertexts is derived by the client
72
+ (chunking ``ceil(input_bits / (poly_modulus_degree/2))``), so it is
73
+ not carried in the manifest.
74
+ """
75
+ return int(self.raw["circuit"]["input_bits"])
76
+
77
+ @property
78
+ def variant(self) -> str:
79
+ """Circuit variant (§5.1, P7): ``"A"`` (raw LUT bits) or ``"B"``
80
+ (server-side FHE classifier head → contiguous class scores)."""
81
+ return str(self.raw["circuit"]["variant"])
82
+
83
+ @property
84
+ def output_slots_per_ciphertext(self) -> int:
85
+ """Variant-A only: meaningful raw-bit slots ``[0, K)`` per output ct.
86
+
87
+ Forbidden for Variant B under schema v2 (B uses ``num_classes`` and
88
+ contiguous scores). Prefer :attr:`output_slot_count`, which is
89
+ variant-aware; this raw accessor ``KeyError``s on a B manifest.
90
+ """
91
+ return int(self.raw["circuit"]["output_slots_per_ciphertext"])
92
+
93
+ @property
94
+ def num_classes(self) -> int | None:
95
+ """Class count when ``variant == "B"`` (required there); else None."""
96
+ v = self.raw["circuit"].get("num_classes")
97
+ return int(v) if v is not None else None
98
+
99
+ @property
100
+ def output_slot_count(self) -> int:
101
+ """Meaningful slots to decrypt per output ct, **variant-aware** (§5.1).
102
+
103
+ Variant B → ``num_classes`` (one ct, contiguous scores ``[0, C)``);
104
+ Variant A → ``output_slots_per_ciphertext`` (raw LUT bits). The
105
+ output-ciphertext *count* comes from the response envelope length,
106
+ not the manifest.
107
+ """
108
+ if self.variant == "B":
109
+ return int(self.raw["circuit"]["num_classes"])
110
+ return int(self.raw["circuit"]["output_slots_per_ciphertext"])
111
+
112
+ @property
113
+ def binarization_spec_uri(self) -> str:
114
+ """Where to fetch the §4 BinarizationSpec for this model."""
115
+ return str(self.raw["binarization_spec_uri"])
@@ -0,0 +1,73 @@
1
+ """Post-processing: decrypted signed class logits → predicted label.
2
+
3
+ Variant B (§6): the linear head (``W·bits + b``) is evaluated **under FHE**,
4
+ server-side. Each output ciphertext carries ``num_classes`` signed class
5
+ logits in slots ``[0, num_classes)`` — NOT raw LUT bits. The SDK never sees
6
+ ``W`` / ``b`` / scale / bundle; its only job is threshold (binary) or argmax
7
+ (multi-class) over the signed logits.
8
+
9
+ Decode convention matches ``implicant-fhe/src/bench_tabular.cpp``:
10
+ binary (C=1) → ``logit > 0``; multi-class (C>1) → ``argmax``.
11
+
12
+ Reference: ``platform/docs/FHE_INTERFACE_SPEC.md §6`` (changelog 2026-06-18).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ import numpy as np
19
+
20
+ __all__ = ["ClassPrediction", "predict_label"]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ClassPrediction:
25
+ """Result of decoding the decrypted class logits."""
26
+
27
+ label_index: int
28
+ scores: tuple[int, ...]
29
+ label: str | None = None
30
+
31
+
32
+ def predict_label(
33
+ logit_slots: list[np.ndarray],
34
+ class_names: tuple[str, ...] | None = None,
35
+ ) -> ClassPrediction:
36
+ """Reduce decrypted signed-logit slots to a predicted label.
37
+
38
+ Args:
39
+ logit_slots: One signed-logit array per output ciphertext. Variant B
40
+ returns a single ct, so this has length 1 and its array holds the
41
+ ``C = num_classes`` signed class logits (already balanced-residue
42
+ decoded by ``decode_class_scores``).
43
+ class_names: Optional labels; length must equal the logit count.
44
+
45
+ Returns:
46
+ The predicted class as a :class:`ClassPrediction`. Binary (``C==1``)
47
+ uses a strict ``> 0`` threshold; multi-class uses argmax.
48
+
49
+ Raises:
50
+ ValueError: on empty input or class-name count mismatch.
51
+ """
52
+ if not logit_slots:
53
+ raise ValueError("no output ciphertexts to post-process")
54
+
55
+ logits = logit_slots[0]
56
+ scores = tuple(int(v) for v in logits)
57
+
58
+ # Binary models emit a single signed logit but choose between TWO labels
59
+ # (threshold at 0); multi-class emits one logit per class.
60
+ expected_names = 2 if len(scores) == 1 else len(scores)
61
+ if class_names is not None and len(class_names) != expected_names:
62
+ raise ValueError(
63
+ f"class_names has {len(class_names)} entries but the model has "
64
+ f"{expected_names} classes"
65
+ )
66
+
67
+ if len(scores) == 1:
68
+ best = 1 if scores[0] > 0 else 0
69
+ else:
70
+ best = int(np.argmax(logits))
71
+
72
+ label = class_names[best] if class_names is not None else None
73
+ return ClassPrediction(label_index=best, scores=scores, label=label)
@@ -0,0 +1,24 @@
1
+ """Transport layer: the wire boundary between the SDK and the platform.
2
+
3
+ The byte contracts are fixed by ``FHE_INTERFACE_SPEC.md``: the three public
4
+ blobs (§2) and the §3 ciphertext envelope. The :class:`Transport` Protocol
5
+ captures those operations; :class:`HttpxTransport` implements them over the
6
+ platform REST API (``/api/v1/fhe``, routes per ``implicant/platform#361 §2``),
7
+ and :class:`MockTransport` runs the server role in-process for demos/tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .base import PredictResponse, Transport
13
+ from .errors import KeyDepthInsufficientError, TransportError
14
+ from .http import HttpxTransport
15
+ from .mock import MockTransport
16
+
17
+ __all__ = [
18
+ "Transport",
19
+ "PredictResponse",
20
+ "HttpxTransport",
21
+ "MockTransport",
22
+ "TransportError",
23
+ "KeyDepthInsufficientError",
24
+ ]
@@ -0,0 +1,52 @@
1
+ """Transport Protocol and shared response types.
2
+
3
+ The Protocol is intentionally small and byte-oriented. Each method maps to
4
+ one platform interaction the client workflow needs; the exact HTTP routes
5
+ that back them are Phase 3.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from helut.client import KeyBundle
14
+
15
+ __all__ = ["Transport", "PredictResponse"]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class PredictResponse:
20
+ """Server response to a predict call.
21
+
22
+ ``body`` is the raw §3 envelope; the client unpacks it and takes the
23
+ output-ct count from ``len(unpack_envelope(body))`` (the variant-determined
24
+ response shape), not from the manifest (§3, P4).
25
+ """
26
+
27
+ body: bytes
28
+
29
+
30
+ @runtime_checkable
31
+ class Transport(Protocol):
32
+ """Operations the client needs from the platform."""
33
+
34
+ def fetch_circuit_meta(self, model_id: str) -> dict[str, Any]:
35
+ """Fetch the model's ``circuit_meta.json`` (§5.1) as a dict."""
36
+ ...
37
+
38
+ def fetch_binarization_spec(self, uri: str) -> dict[str, Any]:
39
+ """Fetch the §4 BinarizationSpec the manifest points at."""
40
+ ...
41
+
42
+ def register_keys(self, key_id: str, bundle: KeyBundle) -> None:
43
+ """Upload the three public blobs (§2) under ``key_id``."""
44
+ ...
45
+
46
+ def has_keys(self, key_id: str) -> bool:
47
+ """Whether the server already holds public material for ``key_id``."""
48
+ ...
49
+
50
+ def predict(self, model_id: str, key_id: str, envelope: bytes) -> PredictResponse:
51
+ """Run inference: POST the §3 request envelope, get the response one."""
52
+ ...
@@ -0,0 +1,58 @@
1
+ """Transport-layer exceptions raised by :class:`HttpxTransport`.
2
+
3
+ These map platform HTTP failures (§2) to typed errors the client can branch
4
+ on, instead of leaking raw ``httpx`` status codes upward.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["TransportError", "KeyDepthInsufficientError"]
10
+
11
+
12
+ class TransportError(Exception):
13
+ """A platform request failed.
14
+
15
+ Carries the HTTP ``status_code`` and the parsed JSON ``payload`` (when the
16
+ server returned one) for callers that want to inspect the failure.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ status_code: int | None = None,
24
+ payload: dict | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.status_code = status_code
28
+ self.payload = payload
29
+
30
+
31
+ class KeyDepthInsufficientError(TransportError):
32
+ """The registered key supports fewer multiplicative levels than the model needs.
33
+
34
+ Raised on the platform's ``400 {"error": "key_depth_insufficient"}`` response
35
+ to ``predict`` (§2 / #361 §4.4 depth guard).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ key_id: str,
42
+ key_L: int | None,
43
+ required_L: int | None,
44
+ ) -> None:
45
+ self.key_id = key_id
46
+ self.key_L = key_L
47
+ self.required_L = required_L
48
+ super().__init__(
49
+ f"key_depth_insufficient: key supports L={key_L}, "
50
+ f"model needs L={required_L} (key_id={key_id})",
51
+ status_code=400,
52
+ payload={
53
+ "error": "key_depth_insufficient",
54
+ "key_id": key_id,
55
+ "key_L": key_L,
56
+ "required_L": required_L,
57
+ },
58
+ )