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/__init__.py +34 -0
- implicant/__main__.py +5 -0
- implicant/app/__init__.py +8 -0
- implicant/app/__main__.py +61 -0
- implicant/app/catalog.py +32 -0
- implicant/app/credentials.py +40 -0
- implicant/app/main_window.py +258 -0
- implicant/app/messages.py +145 -0
- implicant/app/preflight.py +73 -0
- implicant/app/wizard.py +71 -0
- implicant/app/worker.py +72 -0
- implicant/binarize.py +169 -0
- implicant/cli/__init__.py +5 -0
- implicant/cli/main.py +133 -0
- implicant/client.py +212 -0
- implicant/config.py +46 -0
- implicant/envelope.py +76 -0
- implicant/keys.py +172 -0
- implicant/manifest.py +115 -0
- implicant/postprocess.py +73 -0
- implicant/transport/__init__.py +24 -0
- implicant/transport/base.py +52 -0
- implicant/transport/errors.py +58 -0
- implicant/transport/http.py +139 -0
- implicant/transport/mock.py +107 -0
- implicant-0.2.0.dist-info/METADATA +85 -0
- implicant-0.2.0.dist-info/RECORD +30 -0
- implicant-0.2.0.dist-info/WHEEL +4 -0
- implicant-0.2.0.dist-info/entry_points.txt +3 -0
- implicant-0.2.0.dist-info/licenses/LICENSE +201 -0
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"])
|
implicant/postprocess.py
ADDED
|
@@ -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
|
+
)
|