implicant 0.7.2__tar.gz → 0.7.5__tar.gz
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-0.7.2/README.md → implicant-0.7.5/PKG-INFO +44 -0
- implicant-0.7.2/PKG-INFO → implicant-0.7.5/README.md +17 -22
- {implicant-0.7.2 → implicant-0.7.5}/pyproject.toml +24 -1
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/cli/main.py +15 -4
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/copy.py +264 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/server.py +34 -1
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/app.css +17 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/app.js +17 -2
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/index.html +84 -2
- implicant-0.7.5/src/implicant/vision/__init__.py +28 -0
- implicant-0.7.5/src/implicant/vision/assets/README.txt +10 -0
- implicant-0.7.5/src/implicant/vision/assets/cbm_efficientnet_b0_celeba.pt +0 -0
- implicant-0.7.5/src/implicant/vision/assets/concepts.json +42 -0
- implicant-0.7.5/src/implicant/vision/assets/face_detection_yunet_2023mar.onnx +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/carla.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/famous.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/haaland.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/harry.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/mbappe.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/pam.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/gallery/strangerthings.jpg +0 -0
- implicant-0.7.5/src/implicant/vision/assets/rules_accurate.json +326 -0
- implicant-0.7.5/src/implicant/vision/assets/rules_compact.json +186 -0
- implicant-0.7.5/src/implicant/vision/engine.py +186 -0
- implicant-0.7.5/src/implicant/vision/routes.py +122 -0
- implicant-0.7.5/src/implicant/vision/rules.py +178 -0
- implicant-0.7.5/src/implicant/vision/service.py +399 -0
- implicant-0.7.5/src/implicant/vision/static/vision.css +120 -0
- implicant-0.7.5/src/implicant/vision/static/vision.js +525 -0
- implicant-0.7.2/docker/Dockerfile +0 -28
- {implicant-0.7.2 → implicant-0.7.5}/.dockerignore +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/.github/workflows/publish-pypi.yml +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/.github/workflows/release.yml +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/.gitignore +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/LICENSE +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/__init__.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/binarize.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/cli/__init__.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/client.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/config.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/README.txt +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L6-3631b8781e3a/public_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L6-3631b8781e3a/secret_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L7-a394f2ee7032/public_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L7-a394f2ee7032/secret_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L8-5f657f27f77c/public_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L8-5f657f27f77c/secret_key.bin +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/envelope.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/keys.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/manifest.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/models.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/postprocess.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/bankfraud.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/cancer.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/credit.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/diabetes.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/heartdisease.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/__init__.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/base.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/errors.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/http.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/mock.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/README.md +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/__init__.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/ceremony_meta.json +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/events.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/service.py +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_fire.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_idle.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_point.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_charge.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_fire.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_stance.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/FONTS.md +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Bold.woff2 +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Medium.woff2 +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Regular.woff2 +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/OFL.txt +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/inter/Inter-Bold.woff2 +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/inter/OFL.txt +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/logo.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/bankfraud.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/cancer.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/credit.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/diabetes.png +0 -0
- {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/heartdisease.png +0 -0
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: implicant
|
|
3
|
+
Version: 0.7.5
|
|
4
|
+
Summary: Python client SDK for the Implicant FHE inference platform
|
|
5
|
+
Author: Implicant
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: implicant-fhe<0.6,>=0.5
|
|
11
|
+
Requires-Dist: numpy>=1.26
|
|
12
|
+
Requires-Dist: pydantic>=2.6
|
|
13
|
+
Requires-Dist: rich>=13.8
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Provides-Extra: ui
|
|
19
|
+
Requires-Dist: fastapi>=0.115; extra == 'ui'
|
|
20
|
+
Requires-Dist: uvicorn>=0.30; extra == 'ui'
|
|
21
|
+
Provides-Extra: vision
|
|
22
|
+
Requires-Dist: opencv-python-headless>=4.8; extra == 'vision'
|
|
23
|
+
Requires-Dist: pillow>=10; extra == 'vision'
|
|
24
|
+
Requires-Dist: torch>=2.2; extra == 'vision'
|
|
25
|
+
Requires-Dist: torchvision>=0.17; extra == 'vision'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
1
28
|
# Implicant SDK
|
|
2
29
|
|
|
3
30
|
Python **client** for the Implicant FHE inference platform. Encrypts one row
|
|
@@ -251,6 +278,23 @@ docker run --rm -p 8642:8642 \
|
|
|
251
278
|
# then open the printed URL — including the #token — in your browser
|
|
252
279
|
```
|
|
253
280
|
|
|
281
|
+
**Vision Rules** — a third tab with a concept-bottleneck face demo (the 38
|
|
282
|
+
CelebA attributes → a short if-then rulebook → Female/Male, every fired rule
|
|
283
|
+
spelled out in words; gallery faces, photo upload, webcam snapshot and live
|
|
284
|
+
view). It runs the model in the clear on the serving machine and needs the
|
|
285
|
+
optional `[vision]` extra (torch, torchvision, OpenCV, Pillow); without it
|
|
286
|
+
the tab shows an install hint and nothing else changes:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
pip install "implicant[ui,vision]" # macOS / Windows
|
|
290
|
+
# Linux: point pip at PyTorch's CPU index too (the CPU torch wheel is
|
|
291
|
+
# ~200 MB; PyPI's default Linux build pulls ~2 GB of CUDA):
|
|
292
|
+
PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu pip install "implicant[ui,vision]"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The Docker image installs the extra already. The camera needs a secure
|
|
296
|
+
page (`localhost` or HTTPS).
|
|
297
|
+
|
|
254
298
|
By default the page is protected by a per-launch URL token — the printed
|
|
255
299
|
`#fragment` is the credential, so keep the full URL. For a deliberately
|
|
256
300
|
public deployment behind a reverse proxy (e.g. a demo site), start with
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: implicant
|
|
3
|
-
Version: 0.7.2
|
|
4
|
-
Summary: Python client SDK for the Implicant FHE inference platform
|
|
5
|
-
Author: Implicant
|
|
6
|
-
License: Apache-2.0
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Requires-Dist: httpx>=0.27
|
|
10
|
-
Requires-Dist: implicant-fhe<0.6,>=0.5
|
|
11
|
-
Requires-Dist: numpy>=1.26
|
|
12
|
-
Requires-Dist: pydantic>=2.6
|
|
13
|
-
Requires-Dist: rich>=13.8
|
|
14
|
-
Requires-Dist: typer>=0.12
|
|
15
|
-
Provides-Extra: dev
|
|
16
|
-
Requires-Dist: build>=1; extra == 'dev'
|
|
17
|
-
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
-
Provides-Extra: ui
|
|
19
|
-
Requires-Dist: fastapi>=0.115; extra == 'ui'
|
|
20
|
-
Requires-Dist: uvicorn>=0.30; extra == 'ui'
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
1
|
# Implicant SDK
|
|
24
2
|
|
|
25
3
|
Python **client** for the Implicant FHE inference platform. Encrypts one row
|
|
@@ -273,6 +251,23 @@ docker run --rm -p 8642:8642 \
|
|
|
273
251
|
# then open the printed URL — including the #token — in your browser
|
|
274
252
|
```
|
|
275
253
|
|
|
254
|
+
**Vision Rules** — a third tab with a concept-bottleneck face demo (the 38
|
|
255
|
+
CelebA attributes → a short if-then rulebook → Female/Male, every fired rule
|
|
256
|
+
spelled out in words; gallery faces, photo upload, webcam snapshot and live
|
|
257
|
+
view). It runs the model in the clear on the serving machine and needs the
|
|
258
|
+
optional `[vision]` extra (torch, torchvision, OpenCV, Pillow); without it
|
|
259
|
+
the tab shows an install hint and nothing else changes:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
pip install "implicant[ui,vision]" # macOS / Windows
|
|
263
|
+
# Linux: point pip at PyTorch's CPU index too (the CPU torch wheel is
|
|
264
|
+
# ~200 MB; PyPI's default Linux build pulls ~2 GB of CUDA):
|
|
265
|
+
PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu pip install "implicant[ui,vision]"
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The Docker image installs the extra already. The camera needs a secure
|
|
269
|
+
page (`localhost` or HTTPS).
|
|
270
|
+
|
|
276
271
|
By default the page is protected by a per-launch URL token — the printed
|
|
277
272
|
`#fragment` is the credential, so keep the full URL. For a deliberately
|
|
278
273
|
public deployment behind a reverse proxy (e.g. a demo site), start with
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "implicant"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.5"
|
|
8
8
|
description = "Python client SDK for the Implicant FHE inference platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -56,6 +56,24 @@ ui = [
|
|
|
56
56
|
"fastapi>=0.115",
|
|
57
57
|
"uvicorn>=0.30",
|
|
58
58
|
]
|
|
59
|
+
# The Vision Rules page (`implicant ui`, third tab): a concept-bottleneck
|
|
60
|
+
# face model that runs in the clear on the serving machine. Heavy on
|
|
61
|
+
# purpose, so it is its own extra — `implicant ui` starts and every other
|
|
62
|
+
# page works without it (the tab then shows an install hint). The engine
|
|
63
|
+
# is imported lazily on the first analysis. On Linux, PyPI's torch wheel
|
|
64
|
+
# pulls CUDA (~2 GB); point pip at the CPU torch wheels at
|
|
65
|
+
# download.pytorch.org/whl/cpu ON THE PIP COMMAND LINE (as pip's extra
|
|
66
|
+
# index), e.g. `pip install -e ".[ui,vision]"` with that flag. It must
|
|
67
|
+
# never be written into this file: the release gate
|
|
68
|
+
# (scripts/check_no_server_surface.py) rejects any pip index configuration
|
|
69
|
+
# in the shipped sdist/METADATA, and pyproject.toml ships in the sdist.
|
|
70
|
+
# torch/torchvision publish cp310-cp313 wheels; cp314 lags.
|
|
71
|
+
vision = [
|
|
72
|
+
"torch>=2.2",
|
|
73
|
+
"torchvision>=0.17",
|
|
74
|
+
"opencv-python-headless>=4.8",
|
|
75
|
+
"pillow>=10",
|
|
76
|
+
]
|
|
59
77
|
|
|
60
78
|
[project.scripts]
|
|
61
79
|
implicant = "implicant.cli.main:app"
|
|
@@ -85,6 +103,11 @@ exclude = [
|
|
|
85
103
|
"docs",
|
|
86
104
|
"tests",
|
|
87
105
|
"scripts",
|
|
106
|
+
# docker/Dockerfile carries the PyTorch CPU wheel index flag for the
|
|
107
|
+
# [vision] extra; the release gate (check_no_server_surface.py) rejects
|
|
108
|
+
# any pip index configuration in the sdist, and the Dockerfile is deploy
|
|
109
|
+
# infrastructure the published sdist does not need anyway.
|
|
110
|
+
"docker",
|
|
88
111
|
"demo_handoff.zip",
|
|
89
112
|
]
|
|
90
113
|
|
|
@@ -115,8 +115,9 @@ def _import_ui():
|
|
|
115
115
|
from ..ui.events import EventBus
|
|
116
116
|
from ..ui.server import create_app
|
|
117
117
|
from ..ui.service import UiService
|
|
118
|
+
from ..vision.service import VisionService
|
|
118
119
|
|
|
119
|
-
return EventBus, UiService, create_app, uvicorn
|
|
120
|
+
return EventBus, UiService, VisionService, create_app, uvicorn
|
|
120
121
|
|
|
121
122
|
|
|
122
123
|
def _free_port(host: str = "127.0.0.1") -> int:
|
|
@@ -336,7 +337,7 @@ def ui(
|
|
|
336
337
|
)
|
|
337
338
|
raise typer.Exit(code=2)
|
|
338
339
|
try:
|
|
339
|
-
EventBus, UiService, create_app, uvicorn = _import_ui()
|
|
340
|
+
EventBus, UiService, VisionService, create_app, uvicorn = _import_ui()
|
|
340
341
|
except ModuleNotFoundError:
|
|
341
342
|
typer.echo(
|
|
342
343
|
"Error: the web UI needs the optional dependencies.\n"
|
|
@@ -373,6 +374,10 @@ def ui(
|
|
|
373
374
|
raise typer.Exit(code=2)
|
|
374
375
|
bus = EventBus()
|
|
375
376
|
service = UiService(bus)
|
|
377
|
+
# The Vision Rules tab (2026-09-04): the service is always built (it is
|
|
378
|
+
# light), the engine only loads on the first analysis; without the
|
|
379
|
+
# [vision] extra the tab shows the install hint and nothing else changes.
|
|
380
|
+
vision = VisionService()
|
|
376
381
|
if public_demo:
|
|
377
382
|
typer.echo(
|
|
378
383
|
"Warning: public demo mode — no URL token, anyone who can reach "
|
|
@@ -380,10 +385,10 @@ def ui(
|
|
|
380
385
|
err=True,
|
|
381
386
|
)
|
|
382
387
|
token = None
|
|
383
|
-
app_ = create_app(service, public_demo=True)
|
|
388
|
+
app_ = create_app(service, public_demo=True, vision=vision)
|
|
384
389
|
else:
|
|
385
390
|
token = secrets.token_urlsafe(16)
|
|
386
|
-
app_ = create_app(service, token=token)
|
|
391
|
+
app_ = create_app(service, token=token, vision=vision)
|
|
387
392
|
# `--port 0` is the conventional "pick a free port" idiom. bind(host, 0)
|
|
388
393
|
# always succeeds, so it sails through _can_bind — but uvicorn would then
|
|
389
394
|
# bind an OS-chosen ephemeral port it never announces (log_level=warning)
|
|
@@ -405,6 +410,12 @@ def ui(
|
|
|
405
410
|
)
|
|
406
411
|
raise typer.Exit(code=2)
|
|
407
412
|
typer.echo(f"Implicant UI: {url}", err=True)
|
|
413
|
+
typer.echo(
|
|
414
|
+
"Vision Rules: available"
|
|
415
|
+
if vision.available
|
|
416
|
+
else 'Vision Rules: not installed (pip install "implicant[vision]")',
|
|
417
|
+
err=True,
|
|
418
|
+
)
|
|
408
419
|
if not no_browser:
|
|
409
420
|
opened = False
|
|
410
421
|
try:
|
|
@@ -89,6 +89,7 @@ __all__ = [
|
|
|
89
89
|
"BOOT_INCOMPLETE",
|
|
90
90
|
"SERVER_GONE",
|
|
91
91
|
"SESSION_STALE",
|
|
92
|
+
"RESULT_CORE_NOTE",
|
|
92
93
|
"RESULT_LABEL_FALLBACK",
|
|
93
94
|
"REQUEST_FAILED",
|
|
94
95
|
"CIPHER_BYTES",
|
|
@@ -117,6 +118,49 @@ __all__ = [
|
|
|
117
118
|
"HISTORY_LIMIT_NOTE",
|
|
118
119
|
"HISTORY_VIEW_INPUT",
|
|
119
120
|
"HISTORY_HIDE_INPUT",
|
|
121
|
+
"VISION_INTRO_PARAS",
|
|
122
|
+
"VISION_PLAIN_NOTE",
|
|
123
|
+
"VISION_PHOTO_NOTE",
|
|
124
|
+
"VISION_RULEBOOK_LABEL",
|
|
125
|
+
"VISION_RULEBOOKS",
|
|
126
|
+
"VISION_RULEBOOK_HINT",
|
|
127
|
+
"VISION_GALLERY_TITLE",
|
|
128
|
+
"VISION_GALLERY_HINT",
|
|
129
|
+
"VISION_BUTTONS",
|
|
130
|
+
"VISION_CAMERA_HINT",
|
|
131
|
+
"VISION_CAMERA_INSECURE",
|
|
132
|
+
"VISION_CAMERA_DENIED",
|
|
133
|
+
"VISION_LIVE_WAITING",
|
|
134
|
+
"VISION_ANALYZING",
|
|
135
|
+
"VISION_NO_FACE",
|
|
136
|
+
"VISION_BAD_IMAGE",
|
|
137
|
+
"VISION_TOO_LARGE",
|
|
138
|
+
"VISION_BUSY",
|
|
139
|
+
"VISION_UNAVAILABLE",
|
|
140
|
+
"VISION_GALLERY_NOT_FOUND",
|
|
141
|
+
"VISION_LABELS",
|
|
142
|
+
"VISION_VERDICT_PROB",
|
|
143
|
+
"VISION_WHY_TITLE",
|
|
144
|
+
"VISION_WHY_HINT",
|
|
145
|
+
"VISION_WHY_NONE",
|
|
146
|
+
"VISION_RULE_ID",
|
|
147
|
+
"VISION_WEIGHT_TOWARD",
|
|
148
|
+
"VISION_LEGEND_PUSHES",
|
|
149
|
+
"VISION_TERM_OR",
|
|
150
|
+
"VISION_FOOTER",
|
|
151
|
+
"VISION_CONCEPTS_TITLE",
|
|
152
|
+
"VISION_GAUGE_TITLE",
|
|
153
|
+
"VISION_HESITANT_NOTE",
|
|
154
|
+
"VISION_THUMB_CAPTION",
|
|
155
|
+
"VISION_SOURCE_CAPTION",
|
|
156
|
+
"VISION_CONCEPT_NAMES",
|
|
157
|
+
"VISION_CONCEPT_PHRASES",
|
|
158
|
+
"VISION_EXEC_ZONE",
|
|
159
|
+
"VISION_STEP_LABELS",
|
|
160
|
+
"VISION_FEED_TITLE",
|
|
161
|
+
"VISION_FEED_EMPTY",
|
|
162
|
+
"VISION_FEED_LIMIT_NOTE",
|
|
163
|
+
"VISION_FEED_SOURCES",
|
|
120
164
|
"format_duration_ns",
|
|
121
165
|
"format_expansion_ratio",
|
|
122
166
|
"friendly_error",
|
|
@@ -142,6 +186,7 @@ NAV_BACK = "← Back"
|
|
|
142
186
|
NAV_TABS: dict[str, str] = {
|
|
143
187
|
"technology": "Technology",
|
|
144
188
|
"models": "Models",
|
|
189
|
+
"vision": "Vision Rules",
|
|
145
190
|
"contact": "Contact us",
|
|
146
191
|
"website": "implicant.ai",
|
|
147
192
|
"linkedin": "LinkedIn",
|
|
@@ -291,6 +336,8 @@ ACT_TITLES: dict[str, str] = {
|
|
|
291
336
|
"act2": "1. Generate your keys",
|
|
292
337
|
"act3": "Input data",
|
|
293
338
|
"act4": "Result",
|
|
339
|
+
"vision": "Vision Rules",
|
|
340
|
+
"vision_result": "Result",
|
|
294
341
|
}
|
|
295
342
|
|
|
296
343
|
# Display-name overrides for model slugs (owner, 2026-09-04): the page's
|
|
@@ -801,6 +848,202 @@ HISTORY_VIEW_INPUT = "View input"
|
|
|
801
848
|
HISTORY_HIDE_INPUT = "Hide input"
|
|
802
849
|
|
|
803
850
|
|
|
851
|
+
# ---- Vision Rules (the third header tab, 2026-09-04) -----------------------
|
|
852
|
+
# The concept-bottleneck FACE demo ported from get_concept_into_rules: a
|
|
853
|
+
# model whose every decision goes through 38 named CelebA attributes and a
|
|
854
|
+
# short TT-Sparse rulebook. It runs IN THE CLEAR on the machine serving the
|
|
855
|
+
# demo — the page says so plainly (VISION_PLAIN_NOTE) and never borrows the
|
|
856
|
+
# Models page's encryption vocabulary. Wording rule (§6.6 + BANNED_CLAIMS):
|
|
857
|
+
# describe what happens to a photo factually (memory only, a session
|
|
858
|
+
# thumbnail), never as a privacy promise.
|
|
859
|
+
VISION_INTRO_PARAS: tuple[str, ...] = (
|
|
860
|
+
"This face model is fully transparent. Everything it knows about a face "
|
|
861
|
+
"is its score on 38 human concepts — beard, eyeglasses, smiling, gray "
|
|
862
|
+
"hair, lipstick — and every decision is a short list of if-then rules "
|
|
863
|
+
"written over those concepts.",
|
|
864
|
+
"Pick a face and the page shows the concepts the model saw and exactly "
|
|
865
|
+
"which rules fired, in plain words. The concepts are the ones we chose "
|
|
866
|
+
"for this demonstration; a client lists the concepts that matter for "
|
|
867
|
+
"their own task, and the model and its rules are built over that list.",
|
|
868
|
+
)
|
|
869
|
+
VISION_PLAIN_NOTE = (
|
|
870
|
+
"This page runs the model in the clear on the machine serving the demo: "
|
|
871
|
+
"nothing is encrypted and no platform round trip happens. It shows the "
|
|
872
|
+
"reasoning; the Models page shows the same kind of rulebook evaluated "
|
|
873
|
+
"on encrypted data."
|
|
874
|
+
)
|
|
875
|
+
VISION_PHOTO_NOTE = (
|
|
876
|
+
"Photos and camera frames are analyzed in memory and kept only as a small "
|
|
877
|
+
"thumbnail in this session's list of analyses; nothing is written to disk."
|
|
878
|
+
)
|
|
879
|
+
VISION_RULEBOOK_LABEL = "Rulebook"
|
|
880
|
+
VISION_RULEBOOKS: dict[str, str] = {
|
|
881
|
+
"compact": "Compact — 6 rules",
|
|
882
|
+
"accurate": "Accurate — 26 rules",
|
|
883
|
+
}
|
|
884
|
+
VISION_RULEBOOK_HINT = "Fewer rules read easier; more rules are a little more precise."
|
|
885
|
+
VISION_GALLERY_TITLE = "Click a test face"
|
|
886
|
+
VISION_GALLERY_HINT = (
|
|
887
|
+
"Photos the model never saw during training — click one to see its "
|
|
888
|
+
"concept scores and exactly which rules fired."
|
|
889
|
+
)
|
|
890
|
+
VISION_BUTTONS: dict[str, str] = {
|
|
891
|
+
"upload": "Upload a photo",
|
|
892
|
+
"camera": "Use camera",
|
|
893
|
+
"camera_stop": "Stop camera",
|
|
894
|
+
"snap": "Take picture",
|
|
895
|
+
"another": "Try another face",
|
|
896
|
+
}
|
|
897
|
+
VISION_CAMERA_HINT = (
|
|
898
|
+
"Your browser owns the camera: the live view shows the face box, the "
|
|
899
|
+
"verdict and the concept gauges frame by frame. Take picture keeps one "
|
|
900
|
+
"analysis."
|
|
901
|
+
)
|
|
902
|
+
VISION_CAMERA_INSECURE = (
|
|
903
|
+
"The camera needs a secure page: open this demo at localhost or over HTTPS."
|
|
904
|
+
)
|
|
905
|
+
VISION_CAMERA_DENIED = "Camera access was refused. Allow the camera and try again."
|
|
906
|
+
VISION_LIVE_WAITING = "Looking for a face…"
|
|
907
|
+
VISION_ANALYZING = "Analyzing…"
|
|
908
|
+
VISION_NO_FACE = "No face was found, so the whole picture was scored instead."
|
|
909
|
+
VISION_BAD_IMAGE = "That file could not be read as an image. Try a JPEG or PNG."
|
|
910
|
+
VISION_TOO_LARGE = "That image is larger than 8 MB. Please use a smaller one."
|
|
911
|
+
VISION_BUSY = "Still analyzing the previous image — give it a moment."
|
|
912
|
+
VISION_UNAVAILABLE = (
|
|
913
|
+
"The vision demo is not installed on this server. Install it with: "
|
|
914
|
+
'pip install "implicant[vision]"'
|
|
915
|
+
)
|
|
916
|
+
VISION_GALLERY_NOT_FOUND = "That gallery face does not exist."
|
|
917
|
+
# Wire label -> display; index 0 = female, 1 = male (the CelebA `male`
|
|
918
|
+
# target, exactly like KNOWN_MODELS.class_names follows the training encoding).
|
|
919
|
+
VISION_LABELS: dict[str, str] = {"female": "Female", "male": "Male"}
|
|
920
|
+
VISION_VERDICT_PROB = "p({label}) {prob} from the rules"
|
|
921
|
+
VISION_WHY_TITLE = "Why {verdict}: {n_fired} of {n_total} rules fired"
|
|
922
|
+
VISION_WHY_HINT = (
|
|
923
|
+
"Each card is one fired rule and the condition this face met; its "
|
|
924
|
+
"weight joins the score. Rules that did not fire are not shown."
|
|
925
|
+
)
|
|
926
|
+
VISION_WHY_NONE = (
|
|
927
|
+
"No rule matched this face — the base score alone decides: {base} → {label}"
|
|
928
|
+
)
|
|
929
|
+
VISION_RULE_ID = "R{rank}"
|
|
930
|
+
VISION_WEIGHT_TOWARD = "{weight} → {label}"
|
|
931
|
+
VISION_LEGEND_PUSHES = "pushes {label}"
|
|
932
|
+
VISION_TERM_OR = "or"
|
|
933
|
+
VISION_FOOTER = (
|
|
934
|
+
"base {base} + {n_fired} fired rules {fired} = score {score} → "
|
|
935
|
+
"p({label}) {prob}"
|
|
936
|
+
)
|
|
937
|
+
VISION_CONCEPTS_TITLE = "{n} concept scores — everything the model knows about this face"
|
|
938
|
+
VISION_GAUGE_TITLE = "{name}: {score} (cut {cut})"
|
|
939
|
+
VISION_HESITANT_NOTE = (
|
|
940
|
+
"Filled: above its cut. Amber: within 0.1 of the cut — a hesitant score."
|
|
941
|
+
)
|
|
942
|
+
VISION_THUMB_CAPTION = "Model input — the 224 px crop the model actually saw"
|
|
943
|
+
VISION_SOURCE_CAPTION = "Your picture with the detected face"
|
|
944
|
+
# Raw CelebA attribute -> display name (chips, gauges).
|
|
945
|
+
VISION_CONCEPT_NAMES: dict[str, str] = {
|
|
946
|
+
"5_o_Clock_Shadow": "5 o'clock shadow",
|
|
947
|
+
"Arched_Eyebrows": "arched eyebrows",
|
|
948
|
+
"Attractive": "attractive",
|
|
949
|
+
"Bags_Under_Eyes": "bags under the eyes",
|
|
950
|
+
"Bald": "bald",
|
|
951
|
+
"Bangs": "bangs",
|
|
952
|
+
"Big_Lips": "big lips",
|
|
953
|
+
"Big_Nose": "big nose",
|
|
954
|
+
"Black_Hair": "black hair",
|
|
955
|
+
"Blond_Hair": "blond hair",
|
|
956
|
+
"Blurry": "blurry photo",
|
|
957
|
+
"Brown_Hair": "brown hair",
|
|
958
|
+
"Bushy_Eyebrows": "bushy eyebrows",
|
|
959
|
+
"Chubby": "chubby",
|
|
960
|
+
"Double_Chin": "double chin",
|
|
961
|
+
"Eyeglasses": "eyeglasses",
|
|
962
|
+
"Goatee": "goatee",
|
|
963
|
+
"Gray_Hair": "gray hair",
|
|
964
|
+
"Heavy_Makeup": "heavy makeup",
|
|
965
|
+
"High_Cheekbones": "high cheekbones",
|
|
966
|
+
"Mouth_Slightly_Open": "mouth slightly open",
|
|
967
|
+
"Mustache": "mustache",
|
|
968
|
+
"Narrow_Eyes": "narrow eyes",
|
|
969
|
+
"No_Beard": "no beard",
|
|
970
|
+
"Oval_Face": "oval face",
|
|
971
|
+
"Pale_Skin": "pale skin",
|
|
972
|
+
"Pointy_Nose": "pointy nose",
|
|
973
|
+
"Receding_Hairline": "receding hairline",
|
|
974
|
+
"Rosy_Cheeks": "rosy cheeks",
|
|
975
|
+
"Sideburns": "sideburns",
|
|
976
|
+
"Smiling": "smiling",
|
|
977
|
+
"Straight_Hair": "straight hair",
|
|
978
|
+
"Wavy_Hair": "wavy hair",
|
|
979
|
+
"Wearing_Earrings": "earrings",
|
|
980
|
+
"Wearing_Hat": "hat",
|
|
981
|
+
"Wearing_Lipstick": "lipstick",
|
|
982
|
+
"Wearing_Necklace": "necklace",
|
|
983
|
+
"Wearing_Necktie": "necktie",
|
|
984
|
+
}
|
|
985
|
+
# Plain words for a rule literal: (present, absent). Verbatim from the demo's
|
|
986
|
+
# overlay.py PHRASES — this is what makes a fired rule read as English.
|
|
987
|
+
VISION_CONCEPT_PHRASES: dict[str, tuple[str, str]] = {
|
|
988
|
+
"5_o_Clock_Shadow": ("5 o'clock shadow", "no 5 o'clock shadow"),
|
|
989
|
+
"Arched_Eyebrows": ("arched eyebrows", "no arched eyebrows"),
|
|
990
|
+
"Attractive": ("attractive", "not attractive"),
|
|
991
|
+
"Bags_Under_Eyes": ("bags under the eyes", "no bags under the eyes"),
|
|
992
|
+
"Bald": ("bald", "not bald"),
|
|
993
|
+
"Bangs": ("bangs", "no bangs"),
|
|
994
|
+
"Big_Lips": ("big lips", "no big lips"),
|
|
995
|
+
"Big_Nose": ("big nose", "no big nose"),
|
|
996
|
+
"Black_Hair": ("black hair", "not black hair"),
|
|
997
|
+
"Blond_Hair": ("blond hair", "not blond hair"),
|
|
998
|
+
"Blurry": ("blurry photo", "sharp photo"),
|
|
999
|
+
"Brown_Hair": ("brown hair", "not brown hair"),
|
|
1000
|
+
"Bushy_Eyebrows": ("bushy eyebrows", "no bushy eyebrows"),
|
|
1001
|
+
"Chubby": ("chubby", "not chubby"),
|
|
1002
|
+
"Double_Chin": ("double chin", "no double chin"),
|
|
1003
|
+
"Eyeglasses": ("eyeglasses", "no eyeglasses"),
|
|
1004
|
+
"Goatee": ("goatee", "no goatee"),
|
|
1005
|
+
"Gray_Hair": ("gray hair", "not gray hair"),
|
|
1006
|
+
"Heavy_Makeup": ("heavy makeup", "no heavy makeup"),
|
|
1007
|
+
"High_Cheekbones": ("high cheekbones", "no high cheekbones"),
|
|
1008
|
+
"Mouth_Slightly_Open": ("mouth slightly open", "mouth closed"),
|
|
1009
|
+
"Mustache": ("mustache", "no mustache"),
|
|
1010
|
+
"Narrow_Eyes": ("narrow eyes", "no narrow eyes"),
|
|
1011
|
+
"No_Beard": ("no beard", "beard"),
|
|
1012
|
+
"Oval_Face": ("oval face", "not an oval face"),
|
|
1013
|
+
"Pale_Skin": ("pale skin", "not pale skin"),
|
|
1014
|
+
"Pointy_Nose": ("pointy nose", "no pointy nose"),
|
|
1015
|
+
"Receding_Hairline": ("receding hairline", "no receding hairline"),
|
|
1016
|
+
"Rosy_Cheeks": ("rosy cheeks", "no rosy cheeks"),
|
|
1017
|
+
"Sideburns": ("sideburns", "no sideburns"),
|
|
1018
|
+
"Smiling": ("smiling", "not smiling"),
|
|
1019
|
+
"Straight_Hair": ("straight hair", "not straight hair"),
|
|
1020
|
+
"Wavy_Hair": ("wavy hair", "not wavy hair"),
|
|
1021
|
+
"Wearing_Earrings": ("earrings", "no earrings"),
|
|
1022
|
+
"Wearing_Hat": ("hat", "no hat"),
|
|
1023
|
+
"Wearing_Lipstick": ("lipstick", "no lipstick"),
|
|
1024
|
+
"Wearing_Necklace": ("necklace", "no necklace"),
|
|
1025
|
+
"Wearing_Necktie": ("necktie", "no necktie"),
|
|
1026
|
+
}
|
|
1027
|
+
# The page's three-step execution strip. Everything runs locally, so there is
|
|
1028
|
+
# one zone; steps go pending -> done from the RESPONSE's timings (never a
|
|
1029
|
+
# fabricated in-progress state).
|
|
1030
|
+
VISION_EXEC_ZONE = "Runs where implicant ui runs — no encryption, no platform round trip"
|
|
1031
|
+
VISION_STEP_LABELS: dict[str, str] = {
|
|
1032
|
+
"detect_pending": "Find the face",
|
|
1033
|
+
"detect_done": "Face found",
|
|
1034
|
+
"score_pending": "Score 38 concepts",
|
|
1035
|
+
"score_done": "38 concepts scored",
|
|
1036
|
+
"rules_pending": "Apply the rules",
|
|
1037
|
+
"rules_done": "Rules applied",
|
|
1038
|
+
}
|
|
1039
|
+
VISION_FEED_TITLE = "Your analyses"
|
|
1040
|
+
VISION_FEED_EMPTY = "Nothing yet — pick a face, upload a photo or take a picture."
|
|
1041
|
+
VISION_FEED_LIMIT_NOTE = "Showing the latest 20 analyses"
|
|
1042
|
+
VISION_FEED_SOURCES: dict[str, str] = {
|
|
1043
|
+
"gallery": "gallery", "upload": "upload", "camera": "camera",
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
|
|
804
1047
|
def format_duration_ns(ns: int) -> str:
|
|
805
1048
|
"""Render a raw nanosecond duration per ADD-PERF-7 (display-only rounding).
|
|
806
1049
|
|
|
@@ -838,6 +1081,19 @@ def format_expansion_ratio(payload_bytes: int, encoded_bytes: int) -> str:
|
|
|
838
1081
|
return template.replace("{ratio}", text)
|
|
839
1082
|
|
|
840
1083
|
|
|
1084
|
+
# Act-4 single-core note (owner, 2026-09-04): a small panel under the
|
|
1085
|
+
# buttons saying the demo platform evaluates on one CPU core. Wording is
|
|
1086
|
+
# deliberately number-free — the measured thread-scaling of the deployed
|
|
1087
|
+
# engine (implicant-fhe/bench/stack1_phoenix_bsgs) caps a SINGLE
|
|
1088
|
+
# inference's speedup around 2x regardless of core count, while
|
|
1089
|
+
# throughput (parallel inferences) scales with cores; "faster" and
|
|
1090
|
+
# "in parallel" are both true, a bold multiplier would not be.
|
|
1091
|
+
RESULT_CORE_NOTE = (
|
|
1092
|
+
"This demo's platform evaluates on a single CPU core. A multi-core "
|
|
1093
|
+
"server answers a single encrypted inference faster, and serves many "
|
|
1094
|
+
"inferences in parallel."
|
|
1095
|
+
)
|
|
1096
|
+
|
|
841
1097
|
# The key id is deliberately NOT shown (product owner, 2026-08-26 — same
|
|
842
1098
|
# rule as act 2: nothing hinting at the model's parameters). ADD-RECAP-1
|
|
843
1099
|
# only says key_id MAY remain as secondary detail, so dropping it complies.
|
|
@@ -888,6 +1144,14 @@ def friendly_error(exc: Exception) -> str:
|
|
|
888
1144
|
# MUST also precede the ValueError arm (NotPreparedError subclasses
|
|
889
1145
|
# it): this is a state desync, not a field the participant typed.
|
|
890
1146
|
return NOT_PREPARED
|
|
1147
|
+
try: # local import: the vision package is light (no torch at import)
|
|
1148
|
+
from ..vision.service import VisionImageError
|
|
1149
|
+
except Exception: # pragma: no cover
|
|
1150
|
+
VisionImageError = ()
|
|
1151
|
+
if VisionImageError and isinstance(exc, VisionImageError):
|
|
1152
|
+
# MUST also precede the ValueError arm (VisionImageError subclasses
|
|
1153
|
+
# it): an unreadable upload is about the file, not a form field.
|
|
1154
|
+
return VISION_BAD_IMAGE
|
|
891
1155
|
if isinstance(exc, KeyError):
|
|
892
1156
|
return "Please fill in every field before encrypting."
|
|
893
1157
|
if isinstance(exc, ValueError):
|
|
@@ -33,12 +33,19 @@ from fastapi.staticfiles import StaticFiles
|
|
|
33
33
|
from pydantic import BaseModel, field_validator
|
|
34
34
|
|
|
35
35
|
from . import copy as ui_copy
|
|
36
|
+
from ..vision.routes import add_vision_routes
|
|
37
|
+
from ..vision.service import (
|
|
38
|
+
GALLERY_DIR,
|
|
39
|
+
VisionBusyError,
|
|
40
|
+
VisionUnavailableError,
|
|
41
|
+
)
|
|
36
42
|
from .events import sse_format
|
|
37
43
|
from .service import BusyError
|
|
38
44
|
|
|
39
45
|
__all__ = ["create_app"]
|
|
40
46
|
|
|
41
47
|
_STATIC = Path(__file__).parent / "static"
|
|
48
|
+
_VISION_STATIC = Path(__file__).parent.parent / "vision" / "static"
|
|
42
49
|
_ALLOWED_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
43
50
|
_SSE_KEEPALIVE_S = 15.0
|
|
44
51
|
_SSE_POLL_S = 0.05 # imperceptible for the theater; costs no thread at all
|
|
@@ -82,10 +89,18 @@ class PredictBody(BaseModel):
|
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
def create_app(
|
|
85
|
-
service: Any,
|
|
92
|
+
service: Any,
|
|
93
|
+
*,
|
|
94
|
+
token: str | None = None,
|
|
95
|
+
public_demo: bool = False,
|
|
96
|
+
vision: Any | None = None,
|
|
86
97
|
) -> FastAPI:
|
|
87
98
|
"""Build the app around an injected service (tests pass a fake).
|
|
88
99
|
|
|
100
|
+
``vision`` is the Vision Rules service (``None`` when the ``[vision]``
|
|
101
|
+
extra is missing: the tab then shows the install hint, nothing else
|
|
102
|
+
changes).
|
|
103
|
+
|
|
89
104
|
Exactly one of the two configurations is valid: a non-empty ``token``
|
|
90
105
|
(private mode, the default everywhere) or ``public_demo=True`` with no
|
|
91
106
|
token at all. Anything else refuses to build — public mode is opt-in
|
|
@@ -102,6 +117,17 @@ def create_app(
|
|
|
102
117
|
app = FastAPI(
|
|
103
118
|
title="implicant ui", docs_url=None, redoc_url=None, openapi_url=None
|
|
104
119
|
)
|
|
120
|
+
# Longer prefixes FIRST: Starlette matches mounts in registration order,
|
|
121
|
+
# so /static would otherwise swallow /static/vision/... and 404 it. The
|
|
122
|
+
# gallery photos are ordinary static assets (unguarded, like the page).
|
|
123
|
+
app.mount(
|
|
124
|
+
"/static/vision/gallery",
|
|
125
|
+
StaticFiles(directory=GALLERY_DIR),
|
|
126
|
+
name="vision-gallery",
|
|
127
|
+
)
|
|
128
|
+
app.mount(
|
|
129
|
+
"/static/vision", StaticFiles(directory=_VISION_STATIC), name="vision-static"
|
|
130
|
+
)
|
|
105
131
|
app.mount("/static", StaticFiles(directory=_STATIC), name="static")
|
|
106
132
|
|
|
107
133
|
@app.middleware("http")
|
|
@@ -291,6 +317,11 @@ def create_app(
|
|
|
291
317
|
return fn(*args)
|
|
292
318
|
except BusyError:
|
|
293
319
|
raise HTTPException(status_code=409, detail=ui_copy.BUSY_ERROR)
|
|
320
|
+
except VisionBusyError:
|
|
321
|
+
raise HTTPException(status_code=409, detail=ui_copy.VISION_BUSY)
|
|
322
|
+
except VisionUnavailableError:
|
|
323
|
+
traceback.print_exc()
|
|
324
|
+
raise HTTPException(status_code=503, detail=ui_copy.VISION_UNAVAILABLE)
|
|
294
325
|
except ValueError as exc:
|
|
295
326
|
# str(exc) would put raw Python text on screen ("could not convert
|
|
296
327
|
# string to float: 'abc'"); spec §7 wants plain language in the
|
|
@@ -341,4 +372,6 @@ def create_app(
|
|
|
341
372
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
342
373
|
)
|
|
343
374
|
|
|
375
|
+
add_vision_routes(app, vision=vision, guard=_guard, run=_run)
|
|
376
|
+
|
|
344
377
|
return app
|
|
@@ -382,6 +382,16 @@ input.invalid { border-color: var(--err); }
|
|
|
382
382
|
.fhe-fig .cout { fill: var(--cipher); opacity: 0; filter: drop-shadow(0 0 2px var(--cipher)); }
|
|
383
383
|
.fhe-fig .res { fill: var(--result); opacity: 0; }
|
|
384
384
|
.fhe-fig .res line { stroke: var(--result); stroke-width: 3.2; stroke-linecap: round; }
|
|
385
|
+
/* Verdict glyph matches the verdict (owner's bug report 2026-09-04): the
|
|
386
|
+
cross shows only for Malignant; Benign swaps in the same-box checkmark
|
|
387
|
+
— before this, Benign rendered a green X. The .res group keeps the
|
|
388
|
+
opacity timeline; only its children trade places. */
|
|
389
|
+
.fhe-fig .res-v {
|
|
390
|
+
fill: none; stroke: var(--result); stroke-width: 3.2;
|
|
391
|
+
stroke-linecap: round; stroke-linejoin: round; display: none;
|
|
392
|
+
}
|
|
393
|
+
.fhe-fig.benign .res-x { display: none; }
|
|
394
|
+
.fhe-fig.benign .res-v { display: inline; }
|
|
385
395
|
.fhe-fig .rec { font-size: 7.5px; font-family: var(--font-display); text-anchor: middle; }
|
|
386
396
|
.fhe-fig .rec-k { fill: var(--ink); }
|
|
387
397
|
.fhe-fig .reslbl {
|
|
@@ -969,6 +979,13 @@ input.invalid { border-color: var(--err); }
|
|
|
969
979
|
from { opacity: 0; transform: translateY(14px) scale(.97); filter: blur(10px); }
|
|
970
980
|
}
|
|
971
981
|
#result-cipher { animation: rise .4s var(--ease-settle) both; animation-delay: .55s; }
|
|
982
|
+
/* Single-core note (owner, 2026-09-04): a small panel under the act-4
|
|
983
|
+
buttons, in the same card idiom as the tech-page panels. */
|
|
984
|
+
#core-note {
|
|
985
|
+
background: var(--panel); border-radius: .6rem; box-shadow: var(--ring);
|
|
986
|
+
padding: .85rem 1.1rem; margin: 1.6rem 0 0; max-width: 44rem;
|
|
987
|
+
color: var(--dim); font-size: .88rem; line-height: 1.55;
|
|
988
|
+
}
|
|
972
989
|
#stage-line { color: var(--dim); min-height: 1.5rem; }
|
|
973
990
|
/* Replayed by the stage arm on each REAL token change (the .swap class is
|
|
974
991
|
re-added after a reflow): the new label rises in; unknown tokens keep the
|