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.
@@ -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()
@@ -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)
@@ -0,0 +1,5 @@
1
+ """Implicant CLI (Typer)."""
2
+
3
+ from .main import app
4
+
5
+ __all__ = ["app"]
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