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/app/wizard.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""First-run onboarding dialog and the 'unsupported computer' message.
|
|
2
|
+
|
|
3
|
+
The onboarding dialog collects the access key and stores it in the OS keychain.
|
|
4
|
+
``show_unsupported`` is the friendly hard-stop for machines the engine can't run
|
|
5
|
+
on.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QDialog,
|
|
13
|
+
QLabel,
|
|
14
|
+
QLineEdit,
|
|
15
|
+
QMessageBox,
|
|
16
|
+
QPushButton,
|
|
17
|
+
QVBoxLayout,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from . import messages
|
|
21
|
+
from .credentials import save_api_key
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .preflight import PreflightResult
|
|
25
|
+
|
|
26
|
+
__all__ = ["OnboardingWizard", "show_unsupported"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OnboardingWizard(QDialog):
|
|
30
|
+
"""Collects the access key on first run and saves it to the keychain."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.setWindowTitle(messages.WIZARD_TITLE)
|
|
35
|
+
layout = QVBoxLayout(self)
|
|
36
|
+
|
|
37
|
+
welcome = QLabel(messages.WIZARD_WELCOME)
|
|
38
|
+
welcome.setWordWrap(True)
|
|
39
|
+
layout.addWidget(welcome)
|
|
40
|
+
|
|
41
|
+
self.key_field = QLineEdit()
|
|
42
|
+
self.key_field.setEchoMode(QLineEdit.EchoMode.Password)
|
|
43
|
+
self.key_field.setPlaceholderText(messages.ACCESS_KEY_PLACEHOLDER)
|
|
44
|
+
layout.addWidget(self.key_field)
|
|
45
|
+
|
|
46
|
+
self.error_label = QLabel("")
|
|
47
|
+
self.error_label.setWordWrap(True)
|
|
48
|
+
layout.addWidget(self.error_label)
|
|
49
|
+
|
|
50
|
+
self.save_button = QPushButton(messages.SAVE_AND_CONTINUE)
|
|
51
|
+
self.save_button.clicked.connect(self.on_save)
|
|
52
|
+
layout.addWidget(self.save_button)
|
|
53
|
+
|
|
54
|
+
def on_save(self) -> None:
|
|
55
|
+
key = self.key_field.text().strip()
|
|
56
|
+
if not key:
|
|
57
|
+
self.error_label.setText(messages.ACCESS_KEY_REQUIRED)
|
|
58
|
+
return
|
|
59
|
+
save_api_key(key)
|
|
60
|
+
self.accept()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def show_unsupported(result: "PreflightResult") -> None:
|
|
64
|
+
"""Show the friendly hard-stop message for an unsupported computer."""
|
|
65
|
+
box = QMessageBox()
|
|
66
|
+
box.setWindowTitle(messages.APP_TITLE)
|
|
67
|
+
box.setText(messages.UNSUPPORTED_TEXT)
|
|
68
|
+
box.setInformativeText(
|
|
69
|
+
messages.detected_platform_text(result.os_name, result.arch)
|
|
70
|
+
)
|
|
71
|
+
box.exec()
|
implicant/app/worker.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Background tasks: run the blocking prepare/predict off the UI thread and
|
|
2
|
+
report progress via Qt signals. The GUI never calls the engine directly.
|
|
3
|
+
|
|
4
|
+
The bare ``except Exception`` is intentional: a UI worker must surface ANY
|
|
5
|
+
failure to the UI (via the ``failed`` signal) rather than crash the thread.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QRunnable, Signal
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # type-only; avoids importing the native engine at runtime
|
|
14
|
+
from implicant.client import ImplicantClient, PreparedModel
|
|
15
|
+
|
|
16
|
+
__all__ = ["WorkerSignals", "PrepareTask", "PredictTask"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkerSignals(QObject):
|
|
20
|
+
stage = Signal(str) # internal stage token
|
|
21
|
+
prepared = Signal(object) # PreparedModel
|
|
22
|
+
finished = Signal(object) # PredictResult
|
|
23
|
+
failed = Signal(object) # Exception
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PrepareTask(QRunnable):
|
|
27
|
+
"""Runs ``client.prepare(model_id)`` and emits prepared/failed."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: "ImplicantClient", model_id: str) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.signals = WorkerSignals()
|
|
32
|
+
self._client = client
|
|
33
|
+
self._model_id = model_id
|
|
34
|
+
|
|
35
|
+
def run(self) -> None:
|
|
36
|
+
try:
|
|
37
|
+
prepared = self._client.prepare(
|
|
38
|
+
self._model_id, on_stage=self.signals.stage.emit
|
|
39
|
+
)
|
|
40
|
+
self.signals.prepared.emit(prepared)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
self.signals.failed.emit(exc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PredictTask(QRunnable):
|
|
46
|
+
"""Runs ``client.predict_prepared(...)`` and emits finished/failed."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
client: "ImplicantClient",
|
|
51
|
+
prepared: "PreparedModel",
|
|
52
|
+
row: dict[str, float],
|
|
53
|
+
class_labels: tuple[str, ...],
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.signals = WorkerSignals()
|
|
57
|
+
self._client = client
|
|
58
|
+
self._prepared = prepared
|
|
59
|
+
self._row = row
|
|
60
|
+
self._class_labels = class_labels
|
|
61
|
+
|
|
62
|
+
def run(self) -> None:
|
|
63
|
+
try:
|
|
64
|
+
result = self._client.predict_prepared(
|
|
65
|
+
self._prepared,
|
|
66
|
+
self._row,
|
|
67
|
+
class_names=self._class_labels,
|
|
68
|
+
on_stage=self.signals.stage.emit,
|
|
69
|
+
)
|
|
70
|
+
self.signals.finished.emit(result)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
self.signals.failed.emit(exc)
|
implicant/binarize.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""BinarizationSpec (§4) — raw feature row → flat bit vector.
|
|
2
|
+
|
|
3
|
+
The SDK owns this stage end to end; the server only ever sees the resulting
|
|
4
|
+
bit vector through ciphertexts and never reads the spec. Two versions:
|
|
5
|
+
|
|
6
|
+
* **v1 — thermometer** (``version: 1``, ``mode: "thermometer"``): per-feature
|
|
7
|
+
uniform min/max thermometer. ``ones = floor((x-min)/(max-min)*num_bits)``,
|
|
8
|
+
clamped to ``[0, num_bits]``; ``bits = [1]*ones + [0]*(num_bits-ones)``.
|
|
9
|
+
|
|
10
|
+
* **v2 — per_feature** (``version: 2``, ``mode: "per_feature"``): per-feature
|
|
11
|
+
threshold encoding that reproduces the fitted ``TabularEncoder`` bit-for-bit.
|
|
12
|
+
The encoder standardizes (``StandardScaler`` mean/scale) then compares against
|
|
13
|
+
empirical quantile thresholds, so each feature carries ``mean``/``scale``/
|
|
14
|
+
``thresholds`` (length ``num_bits``, ascending, in standardized space):
|
|
15
|
+
``x_s = (x - mean)/scale``; bit ``b = 1 if x_s >= thresholds[b] else 0``.
|
|
16
|
+
|
|
17
|
+
Feature ``i`` occupies the contiguous slot window
|
|
18
|
+
``[Σ_{j<i} num_bits_j, Σ_{j<i} num_bits_j + num_bits_i)``; concatenated in
|
|
19
|
+
declared order they form the flat input pool fed to ``encrypt_input_bits``.
|
|
20
|
+
|
|
21
|
+
Reference: ``platform/docs/FHE_INTERFACE_SPEC.md §4``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Annotated, Any, Literal, Union
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
30
|
+
|
|
31
|
+
__all__ = ["ContinuousFeatureSpec", "ThresholdFeatureSpec", "BinarizationSpec", "encode_row"]
|
|
32
|
+
|
|
33
|
+
_SUPPORTED_VERSIONS = frozenset({1, 2})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContinuousFeatureSpec(BaseModel):
|
|
37
|
+
"""One continuous feature's thermometer encoding parameters (§4 v1)."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True)
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
kind: Literal["continuous"]
|
|
43
|
+
num_bits: int = Field(ge=1)
|
|
44
|
+
min: float
|
|
45
|
+
max: float
|
|
46
|
+
|
|
47
|
+
@model_validator(mode="after")
|
|
48
|
+
def _check_range(self) -> "ContinuousFeatureSpec":
|
|
49
|
+
if self.max <= self.min:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"feature {self.name!r}: max ({self.max}) must exceed min ({self.min})"
|
|
52
|
+
)
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ThresholdFeatureSpec(BaseModel):
|
|
57
|
+
"""One feature's per-feature threshold encoding parameters (§4 v2).
|
|
58
|
+
|
|
59
|
+
Reproduces the fitted ``TabularEncoder``: standardize then compare against
|
|
60
|
+
empirical quantile thresholds (ascending, in standardized space).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(frozen=True)
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
kind: Literal["threshold"]
|
|
67
|
+
num_bits: int = Field(ge=1)
|
|
68
|
+
mean: float
|
|
69
|
+
scale: float
|
|
70
|
+
thresholds: tuple[float, ...]
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _check_thresholds(self) -> "ThresholdFeatureSpec":
|
|
74
|
+
if len(self.thresholds) != self.num_bits:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"feature {self.name!r}: len(thresholds) ({len(self.thresholds)}) "
|
|
77
|
+
f"must equal num_bits ({self.num_bits})"
|
|
78
|
+
)
|
|
79
|
+
if self.scale == 0:
|
|
80
|
+
raise ValueError(f"feature {self.name!r}: scale must be non-zero")
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
FeatureSpecU = Annotated[
|
|
85
|
+
Union[ContinuousFeatureSpec, ThresholdFeatureSpec], Field(discriminator="kind")
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BinarizationSpec(BaseModel):
|
|
90
|
+
"""Validated §4 spec for a whole model input row (v1 thermometer or v2 per_feature)."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(frozen=True)
|
|
93
|
+
|
|
94
|
+
version: Literal[1, 2]
|
|
95
|
+
mode: Literal["thermometer", "per_feature"]
|
|
96
|
+
n_ltt_features_total: int = Field(ge=1)
|
|
97
|
+
features: tuple[FeatureSpecU, ...]
|
|
98
|
+
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def _check_version_mode(self) -> "BinarizationSpec":
|
|
101
|
+
if (self.version, self.mode) not in {(1, "thermometer"), (2, "per_feature")}:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"version {self.version} and mode {self.mode!r} are inconsistent: "
|
|
104
|
+
f"v1 ⟺ thermometer, v2 ⟺ per_feature"
|
|
105
|
+
)
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def _check_total(self) -> "BinarizationSpec":
|
|
110
|
+
summed = sum(f.num_bits for f in self.features)
|
|
111
|
+
if summed != self.n_ltt_features_total:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"n_ltt_features_total ({self.n_ltt_features_total}) must equal "
|
|
114
|
+
f"Σ num_bits ({summed})"
|
|
115
|
+
)
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(cls, spec: dict[str, Any]) -> "BinarizationSpec":
|
|
120
|
+
"""Parse + validate a fetched BinarizationSpec JSON dict.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: on unknown version/mode or inconsistent widths.
|
|
124
|
+
"""
|
|
125
|
+
version = spec.get("version")
|
|
126
|
+
if version not in _SUPPORTED_VERSIONS:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"unsupported BinarizationSpec version {version!r}; "
|
|
129
|
+
f"this SDK understands versions {sorted(_SUPPORTED_VERSIONS)}"
|
|
130
|
+
)
|
|
131
|
+
return cls.model_validate(spec)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _encode_feature(
|
|
135
|
+
value: float, feature: ContinuousFeatureSpec | ThresholdFeatureSpec
|
|
136
|
+
) -> list[int]:
|
|
137
|
+
"""Encode one value to ``feature.num_bits`` bits (§4 v1 or v2)."""
|
|
138
|
+
if isinstance(feature, ContinuousFeatureSpec):
|
|
139
|
+
frac = (value - feature.min) / (feature.max - feature.min)
|
|
140
|
+
ones = int(np.floor(frac * feature.num_bits))
|
|
141
|
+
ones = max(0, min(feature.num_bits, ones))
|
|
142
|
+
return [1] * ones + [0] * (feature.num_bits - ones)
|
|
143
|
+
# ThresholdFeatureSpec — replicate TabularEncoder's exact float64 path:
|
|
144
|
+
# StandardScaler.transform then per-threshold >=.
|
|
145
|
+
x_s = (np.float64(value) - feature.mean) / feature.scale
|
|
146
|
+
return [1 if x_s >= t else 0 for t in feature.thresholds]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def encode_row(row: dict[str, float], spec: BinarizationSpec) -> np.ndarray:
|
|
150
|
+
"""Encode one input row to the flat ``uint8`` bit vector for §3 packing.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
row: Feature name → raw numeric value. Must contain every feature
|
|
154
|
+
named in ``spec`` (extra keys are ignored).
|
|
155
|
+
spec: Validated §4 spec.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
1-D ``uint8`` array of length ``spec.n_ltt_features_total``, ready
|
|
159
|
+
to hand to ``helut.client.ClientContext.encrypt_input_bits``.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
KeyError: if ``row`` is missing a feature declared in ``spec``.
|
|
163
|
+
"""
|
|
164
|
+
bits: list[int] = []
|
|
165
|
+
for feature in spec.features:
|
|
166
|
+
if feature.name not in row:
|
|
167
|
+
raise KeyError(f"input row missing feature {feature.name!r}")
|
|
168
|
+
bits.extend(_encode_feature(float(row[feature.name]), feature))
|
|
169
|
+
return np.asarray(bits, dtype=np.uint8)
|
implicant/cli/main.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Implicant command-line interface.
|
|
2
|
+
|
|
3
|
+
Crypto params live in each model's manifest, so the CLI no longer takes
|
|
4
|
+
``--n`` / ``--num-layers``: keys are derived from the server's
|
|
5
|
+
``circuit_meta.json`` at predict time. Commands:
|
|
6
|
+
|
|
7
|
+
implicant init [--api-url URL] configure the client
|
|
8
|
+
implicant status show config + cached public keys
|
|
9
|
+
implicant predict -m MODEL -i FILE run an encrypted prediction
|
|
10
|
+
implicant keys list list cached public-key bundles
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from ..client import ImplicantClient
|
|
24
|
+
from ..config import ImplicantConfig
|
|
25
|
+
from ..transport import HttpxTransport
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="implicant",
|
|
29
|
+
help="Client SDK for the Implicant FHE inference platform.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
)
|
|
32
|
+
keys_app = typer.Typer(help="Inspect cached public-key bundles.")
|
|
33
|
+
app.add_typer(keys_app, name="keys")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_client(config: ImplicantConfig) -> ImplicantClient:
|
|
37
|
+
api_key = os.environ.get("IMPLICANT_API_KEY", "")
|
|
38
|
+
transport = HttpxTransport(base_url=config.api_url, api_key=api_key)
|
|
39
|
+
return ImplicantClient(transport=transport, key_cache_dir=config.key_cache_dir)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def init(
|
|
44
|
+
api_url: Annotated[
|
|
45
|
+
str | None, typer.Option(help="Override the configured API URL.")
|
|
46
|
+
] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Write the local configuration to ``~/.implicant/config.toml``."""
|
|
49
|
+
config = ImplicantConfig.load()
|
|
50
|
+
if api_url:
|
|
51
|
+
config = config.model_copy(update={"api_url": api_url})
|
|
52
|
+
path = config.save()
|
|
53
|
+
typer.echo(f"Wrote config to {path}")
|
|
54
|
+
typer.echo(f" api_url = {config.api_url}")
|
|
55
|
+
typer.echo(f" key_cache_dir = {config.key_cache_dir}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def status() -> None:
|
|
60
|
+
"""Show the active configuration and cached public-key bundles."""
|
|
61
|
+
config = ImplicantConfig.load()
|
|
62
|
+
typer.echo(f"api_url: {config.api_url}")
|
|
63
|
+
typer.echo(f"key_cache_dir: {config.key_cache_dir}")
|
|
64
|
+
|
|
65
|
+
cache_dir = Path(config.key_cache_dir)
|
|
66
|
+
bundles = sorted(p.name for p in cache_dir.glob("*") if p.is_dir()) if cache_dir.is_dir() else []
|
|
67
|
+
if not bundles:
|
|
68
|
+
typer.echo("No cached public-key bundles.")
|
|
69
|
+
return
|
|
70
|
+
typer.echo("Cached public-key bundles:")
|
|
71
|
+
for key_id in bundles:
|
|
72
|
+
typer.echo(f" - {key_id}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def predict(
|
|
77
|
+
model: Annotated[str, typer.Option("--model", "-m", help="Model id.")],
|
|
78
|
+
input: Annotated[
|
|
79
|
+
Path,
|
|
80
|
+
typer.Option(
|
|
81
|
+
"--input", "-i", exists=True, dir_okay=False, readable=True,
|
|
82
|
+
help="JSON object or single-row CSV of feature values.",
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Run an encrypted prediction against a model on the platform."""
|
|
87
|
+
config = ImplicantConfig.load()
|
|
88
|
+
row = _load_row(input)
|
|
89
|
+
client = _make_client(config)
|
|
90
|
+
result = client.predict(model, row)
|
|
91
|
+
typer.echo(
|
|
92
|
+
json.dumps(
|
|
93
|
+
{
|
|
94
|
+
"key_id": result.key_id,
|
|
95
|
+
"label_index": result.prediction.label_index,
|
|
96
|
+
"label": result.prediction.label,
|
|
97
|
+
"scores": list(result.prediction.scores),
|
|
98
|
+
},
|
|
99
|
+
indent=2,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@keys_app.command("list")
|
|
105
|
+
def keys_list() -> None:
|
|
106
|
+
"""List cached public-key bundles by key_id."""
|
|
107
|
+
config = ImplicantConfig.load()
|
|
108
|
+
cache_dir = Path(config.key_cache_dir)
|
|
109
|
+
if not cache_dir.is_dir():
|
|
110
|
+
return
|
|
111
|
+
for path in sorted(cache_dir.glob("*")):
|
|
112
|
+
if path.is_dir():
|
|
113
|
+
typer.echo(path.name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_row(path: Path) -> dict[str, float]:
|
|
117
|
+
text = path.read_text()
|
|
118
|
+
if path.suffix.lower() == ".json":
|
|
119
|
+
raw = json.loads(text)
|
|
120
|
+
elif path.suffix.lower() == ".csv":
|
|
121
|
+
rows = list(csv.DictReader(text.splitlines()))
|
|
122
|
+
if len(rows) != 1:
|
|
123
|
+
raise typer.BadParameter(
|
|
124
|
+
f"CSV input must contain exactly one data row, got {len(rows)}"
|
|
125
|
+
)
|
|
126
|
+
raw = rows[0]
|
|
127
|
+
else:
|
|
128
|
+
raise typer.BadParameter(f"Unsupported input format: {path.suffix}")
|
|
129
|
+
return {k: float(v) for k, v in raw.items()}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
app()
|
implicant/client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""ImplicantClient — the end-to-end client workflow facade.
|
|
2
|
+
|
|
3
|
+
One ``predict`` call walks the whole §-contract pipeline:
|
|
4
|
+
|
|
5
|
+
fetch circuit_meta (§5.1) → validate + rehydrate Params (§1)
|
|
6
|
+
→ ensure key session (keygen if needed) + register public blobs (§2)
|
|
7
|
+
→ fetch + apply BinarizationSpec (§4) → flat bit vector
|
|
8
|
+
→ encrypt → §3 request envelope → transport.predict
|
|
9
|
+
→ §3 response envelope → decrypt slots → argmax (§6 head, server-side)
|
|
10
|
+
|
|
11
|
+
Key sessions (secret-key holders) are created lazily per ``key_id`` and kept
|
|
12
|
+
in memory for the process lifetime; the on-disk cache only records which
|
|
13
|
+
public blobs were already uploaded so re-registration can be skipped.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from helut.client import decode_class_scores
|
|
24
|
+
|
|
25
|
+
from .binarize import BinarizationSpec, encode_row
|
|
26
|
+
from .envelope import pack_envelope, unpack_envelope
|
|
27
|
+
from .keys import KeySession, PublicKeyCache
|
|
28
|
+
from .manifest import CircuitMeta
|
|
29
|
+
from .postprocess import ClassPrediction, predict_label
|
|
30
|
+
from .transport import Transport
|
|
31
|
+
|
|
32
|
+
__all__ = ["ImplicantClient", "PredictResult", "PreparedModel"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class PredictResult:
|
|
37
|
+
"""Outcome of one encrypted prediction."""
|
|
38
|
+
|
|
39
|
+
prediction: ClassPrediction
|
|
40
|
+
key_id: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PreparedModel:
|
|
45
|
+
"""A model fetched and key-warmed, ready for repeated predictions.
|
|
46
|
+
|
|
47
|
+
Produced by :meth:`ImplicantClient.prepare`. ``feature_names`` is the
|
|
48
|
+
ordered list of input fields the caller should collect (e.g. to render a
|
|
49
|
+
form); the session for ``key_id`` is already generated and registered.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
model_id: str
|
|
53
|
+
meta: CircuitMeta
|
|
54
|
+
spec: BinarizationSpec
|
|
55
|
+
key_id: str
|
|
56
|
+
feature_names: tuple[str, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ImplicantClient:
|
|
60
|
+
"""Orchestrates the encrypted-inference workflow over a Transport."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, transport: Transport, key_cache_dir: str | Path) -> None:
|
|
63
|
+
self._transport = transport
|
|
64
|
+
self._cache = PublicKeyCache(Path(key_cache_dir))
|
|
65
|
+
self._sessions: dict[str, KeySession] = {}
|
|
66
|
+
|
|
67
|
+
def predict(
|
|
68
|
+
self,
|
|
69
|
+
model_id: str,
|
|
70
|
+
row: dict[str, float],
|
|
71
|
+
class_names: tuple[str, ...] | None = None,
|
|
72
|
+
*,
|
|
73
|
+
on_stage: Callable[[str], None] | None = None,
|
|
74
|
+
) -> PredictResult:
|
|
75
|
+
"""Run an encrypted prediction for one input ``row``.
|
|
76
|
+
|
|
77
|
+
Convenience wrapper: prepares the model (fetch + keygen) then runs one
|
|
78
|
+
prediction. Identical in behavior to the prior implementation.
|
|
79
|
+
"""
|
|
80
|
+
prepared = self.prepare(model_id, on_stage=on_stage)
|
|
81
|
+
return self.predict_prepared(
|
|
82
|
+
prepared, row, class_names=class_names, on_stage=on_stage
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def prepare(
|
|
86
|
+
self,
|
|
87
|
+
model_id: str,
|
|
88
|
+
*,
|
|
89
|
+
on_stage: Callable[[str], None] | None = None,
|
|
90
|
+
) -> PreparedModel:
|
|
91
|
+
"""Fetch a model's settings + spec and warm its key session.
|
|
92
|
+
|
|
93
|
+
Runs keygen + public-key registration (once per ``key_id`` per process).
|
|
94
|
+
Emits the stage tokens ``"fetching"`` then ``"keygen"`` through
|
|
95
|
+
``on_stage`` if provided. Returns a :class:`PreparedModel` the caller
|
|
96
|
+
reuses for :meth:`predict_prepared`.
|
|
97
|
+
"""
|
|
98
|
+
notify = on_stage or (lambda _s: None)
|
|
99
|
+
notify("fetching")
|
|
100
|
+
meta = CircuitMeta.from_dict(self._transport.fetch_circuit_meta(model_id))
|
|
101
|
+
spec = BinarizationSpec.from_dict(
|
|
102
|
+
self._transport.fetch_binarization_spec(meta.binarization_spec_uri)
|
|
103
|
+
)
|
|
104
|
+
notify("keygen")
|
|
105
|
+
session = self._ensure_session(meta)
|
|
106
|
+
feature_names = tuple(f.name for f in spec.features)
|
|
107
|
+
return PreparedModel(
|
|
108
|
+
model_id=model_id,
|
|
109
|
+
meta=meta,
|
|
110
|
+
spec=spec,
|
|
111
|
+
key_id=session.key_id,
|
|
112
|
+
feature_names=feature_names,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def predict_prepared(
|
|
116
|
+
self,
|
|
117
|
+
prepared: PreparedModel,
|
|
118
|
+
row: dict[str, float],
|
|
119
|
+
class_names: tuple[str, ...] | None = None,
|
|
120
|
+
*,
|
|
121
|
+
on_stage: Callable[[str], None] | None = None,
|
|
122
|
+
) -> PredictResult:
|
|
123
|
+
"""Run one prediction against an already-:meth:`prepare`d model.
|
|
124
|
+
|
|
125
|
+
Emits ``"encrypting"`` → ``"computing"`` → ``"reading"`` through
|
|
126
|
+
``on_stage`` if provided.
|
|
127
|
+
"""
|
|
128
|
+
session = self._get_session(prepared.key_id)
|
|
129
|
+
bits = encode_row(row, prepared.spec)
|
|
130
|
+
if bits.shape != (prepared.meta.input_bits,):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"encoded bit vector has {bits.shape[0]} bits, manifest "
|
|
133
|
+
f"declares input_bits={prepared.meta.input_bits}"
|
|
134
|
+
)
|
|
135
|
+
return self._predict_bits(
|
|
136
|
+
prepared.model_id, prepared.meta, session, bits, class_names,
|
|
137
|
+
on_stage=on_stage,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _predict_bits(
|
|
141
|
+
self,
|
|
142
|
+
model_id: str,
|
|
143
|
+
meta: CircuitMeta,
|
|
144
|
+
session: KeySession,
|
|
145
|
+
bits: np.ndarray,
|
|
146
|
+
class_names: tuple[str, ...] | None,
|
|
147
|
+
*,
|
|
148
|
+
on_stage: Callable[[str], None] | None = None,
|
|
149
|
+
) -> PredictResult:
|
|
150
|
+
"""Run the encrypt → evaluate → decrypt → decode path from a flat bit
|
|
151
|
+
vector. Shared by :meth:`predict_prepared` (after binarization) and the
|
|
152
|
+
gold ``X_bits`` harness (which feeds pre-encoded bits directly).
|
|
153
|
+
"""
|
|
154
|
+
notify = on_stage or (lambda _s: None)
|
|
155
|
+
notify("encrypting")
|
|
156
|
+
request = pack_envelope(session.encrypt_bits(bits))
|
|
157
|
+
|
|
158
|
+
notify("computing")
|
|
159
|
+
response = self._transport.predict(model_id, session.key_id, request)
|
|
160
|
+
notify("reading")
|
|
161
|
+
outputs = unpack_envelope(response.body)
|
|
162
|
+
if not outputs:
|
|
163
|
+
raise ValueError("server returned an empty output envelope")
|
|
164
|
+
|
|
165
|
+
if meta.variant == "B":
|
|
166
|
+
# Variant B (§3/§6): ONE output ct with class scores packed
|
|
167
|
+
# contiguously in slots [0, num_classes). The binding decrypts
|
|
168
|
+
# unsigned (§7, P14); decode_class_scores recovers the signed
|
|
169
|
+
# logits via balanced residue before threshold/argmax.
|
|
170
|
+
nc = meta.output_slot_count
|
|
171
|
+
unsigned = session.decrypt_slots(outputs[0], n=nc)
|
|
172
|
+
logits = [decode_class_scores(unsigned, nc, session.plain_modulus)]
|
|
173
|
+
else: # "A" — raw LUT bits, one ct per tower; out of SDK Phase 2 scope.
|
|
174
|
+
raise NotImplementedError(
|
|
175
|
+
"Variant A (raw LUT-bit output) decode is not implemented in "
|
|
176
|
+
"the SDK; Phase 2 tabular models use Variant B (server FHE head)."
|
|
177
|
+
)
|
|
178
|
+
prediction = predict_label(logits, class_names=class_names)
|
|
179
|
+
return PredictResult(prediction=prediction, key_id=session.key_id)
|
|
180
|
+
|
|
181
|
+
def _get_session(self, key_id: str) -> KeySession:
|
|
182
|
+
"""Return the already-warmed session for ``key_id``.
|
|
183
|
+
|
|
184
|
+
A pure in-memory lookup with no transport round trip — used by
|
|
185
|
+
:meth:`predict_prepared`, which relies on :meth:`prepare` having warmed
|
|
186
|
+
and registered the session first. Raises if it was never prepared.
|
|
187
|
+
"""
|
|
188
|
+
session = self._sessions.get(key_id)
|
|
189
|
+
if session is None:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"no prepared session for key_id {key_id!r}; call prepare() first"
|
|
192
|
+
)
|
|
193
|
+
return session
|
|
194
|
+
|
|
195
|
+
def _ensure_session(self, meta: CircuitMeta) -> KeySession:
|
|
196
|
+
"""Get-or-create the secret-key session for this manifest's params.
|
|
197
|
+
|
|
198
|
+
Keygen runs once per ``key_id`` per process. Public blobs are
|
|
199
|
+
registered with the server (and cached on disk) the first time we
|
|
200
|
+
produce them; a cache hit + server-side presence lets us skip the
|
|
201
|
+
~35 MB upload.
|
|
202
|
+
"""
|
|
203
|
+
key_id = meta.required_key_id
|
|
204
|
+
session = self._sessions.get(key_id)
|
|
205
|
+
if session is None:
|
|
206
|
+
session = KeySession.generate(meta.params)
|
|
207
|
+
self._sessions[key_id] = session
|
|
208
|
+
|
|
209
|
+
if not self._transport.has_keys(key_id):
|
|
210
|
+
self._transport.register_keys(key_id, session.public_bundle)
|
|
211
|
+
self._cache.write(key_id, session.public_bundle)
|
|
212
|
+
return session
|