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.
Files changed (86) hide show
  1. implicant-0.7.2/README.md → implicant-0.7.5/PKG-INFO +44 -0
  2. implicant-0.7.2/PKG-INFO → implicant-0.7.5/README.md +17 -22
  3. {implicant-0.7.2 → implicant-0.7.5}/pyproject.toml +24 -1
  4. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/cli/main.py +15 -4
  5. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/copy.py +264 -0
  6. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/server.py +34 -1
  7. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/app.css +17 -0
  8. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/app.js +17 -2
  9. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/index.html +84 -2
  10. implicant-0.7.5/src/implicant/vision/__init__.py +28 -0
  11. implicant-0.7.5/src/implicant/vision/assets/README.txt +10 -0
  12. implicant-0.7.5/src/implicant/vision/assets/cbm_efficientnet_b0_celeba.pt +0 -0
  13. implicant-0.7.5/src/implicant/vision/assets/concepts.json +42 -0
  14. implicant-0.7.5/src/implicant/vision/assets/face_detection_yunet_2023mar.onnx +0 -0
  15. implicant-0.7.5/src/implicant/vision/assets/gallery/carla.jpg +0 -0
  16. implicant-0.7.5/src/implicant/vision/assets/gallery/famous.jpg +0 -0
  17. implicant-0.7.5/src/implicant/vision/assets/gallery/haaland.jpg +0 -0
  18. implicant-0.7.5/src/implicant/vision/assets/gallery/harry.jpg +0 -0
  19. implicant-0.7.5/src/implicant/vision/assets/gallery/mbappe.jpg +0 -0
  20. implicant-0.7.5/src/implicant/vision/assets/gallery/pam.jpg +0 -0
  21. implicant-0.7.5/src/implicant/vision/assets/gallery/strangerthings.jpg +0 -0
  22. implicant-0.7.5/src/implicant/vision/assets/rules_accurate.json +326 -0
  23. implicant-0.7.5/src/implicant/vision/assets/rules_compact.json +186 -0
  24. implicant-0.7.5/src/implicant/vision/engine.py +186 -0
  25. implicant-0.7.5/src/implicant/vision/routes.py +122 -0
  26. implicant-0.7.5/src/implicant/vision/rules.py +178 -0
  27. implicant-0.7.5/src/implicant/vision/service.py +399 -0
  28. implicant-0.7.5/src/implicant/vision/static/vision.css +120 -0
  29. implicant-0.7.5/src/implicant/vision/static/vision.js +525 -0
  30. implicant-0.7.2/docker/Dockerfile +0 -28
  31. {implicant-0.7.2 → implicant-0.7.5}/.dockerignore +0 -0
  32. {implicant-0.7.2 → implicant-0.7.5}/.github/workflows/publish-pypi.yml +0 -0
  33. {implicant-0.7.2 → implicant-0.7.5}/.github/workflows/release.yml +0 -0
  34. {implicant-0.7.2 → implicant-0.7.5}/.gitignore +0 -0
  35. {implicant-0.7.2 → implicant-0.7.5}/LICENSE +0 -0
  36. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/__init__.py +0 -0
  37. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/binarize.py +0 -0
  38. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/cli/__init__.py +0 -0
  39. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/client.py +0 -0
  40. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/config.py +0 -0
  41. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/README.txt +0 -0
  42. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L6-3631b8781e3a/public_key.bin +0 -0
  43. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L6-3631b8781e3a/secret_key.bin +0 -0
  44. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L7-a394f2ee7032/public_key.bin +0 -0
  45. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L7-a394f2ee7032/secret_key.bin +0 -0
  46. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L8-5f657f27f77c/public_key.bin +0 -0
  47. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/demo_keys/bgv-n16384-L8-5f657f27f77c/secret_key.bin +0 -0
  48. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/envelope.py +0 -0
  49. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/keys.py +0 -0
  50. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/manifest.py +0 -0
  51. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/models.py +0 -0
  52. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/postprocess.py +0 -0
  53. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/bankfraud.json +0 -0
  54. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/cancer.json +0 -0
  55. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/credit.json +0 -0
  56. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/diabetes.json +0 -0
  57. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/samples/heartdisease.json +0 -0
  58. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/__init__.py +0 -0
  59. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/base.py +0 -0
  60. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/errors.py +0 -0
  61. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/http.py +0 -0
  62. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/transport/mock.py +0 -0
  63. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/README.md +0 -0
  64. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/__init__.py +0 -0
  65. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/ceremony_meta.json +0 -0
  66. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/events.py +0 -0
  67. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/service.py +0 -0
  68. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_fire.png +0 -0
  69. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_idle.png +0 -0
  70. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/frieza_point.png +0 -0
  71. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_charge.png +0 -0
  72. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_fire.png +0 -0
  73. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fanart/goku_stance.png +0 -0
  74. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/FONTS.md +0 -0
  75. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Bold.woff2 +0 -0
  76. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Medium.woff2 +0 -0
  77. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/IBMPlexMono-Regular.woff2 +0 -0
  78. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/ibm-plex-mono/OFL.txt +0 -0
  79. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/inter/Inter-Bold.woff2 +0 -0
  80. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/fonts/inter/OFL.txt +0 -0
  81. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/logo.png +0 -0
  82. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/bankfraud.png +0 -0
  83. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/cancer.png +0 -0
  84. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/credit.png +0 -0
  85. {implicant-0.7.2 → implicant-0.7.5}/src/implicant/ui/static/models/diabetes.png +0 -0
  86. {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.2"
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, *, token: str | None = None, public_demo: bool = False
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