File size: 13,034 Bytes
5e65d06
a0540e9
7006f4b
 
628bf77
 
a0540e9
 
628bf77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0540e9
 
 
628bf77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0540e9
 
 
628bf77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0540e9
 
 
628bf77
 
 
 
 
 
 
 
 
a0540e9
 
 
628bf77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e65d06
 
a0540e9
628bf77
 
 
a0540e9
 
 
628bf77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7006f4b
628bf77
7006f4b
 
628bf77
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# Confidence Threshold: Apply Only at Analysis and Generation

Status: **Implemented and synced. Final public submission is under
`build-small-hackathon/Snap2Sim` as of June 15, 2026.**
Supersedes the prior implemented version of this file (commit `a0540e9`,
"Add confidence threshold control").
Author of spec: codebase review pass, June 14, 2026.

> **Why this supersedes the prior spec.** The slider already exists, but it was
> built as a *client-authoritative, live re-render* control: moving it instantly
> recomputes the render mode from cached analysis in the browser, and the server
> never sees the chosen threshold. The user has reversed all three of those
> decisions. The threshold must now be **applied only when analysis and
> generation run**, enforced **server-side**, and the slider must be usable
> **before the first upload**.

## Findings β€” prior behavior before this re-spec

A confidence-threshold slider already existed and worked, but not the way the
user wanted in this re-spec.

1. **Slider UI exists.** `index.html:593-597` β€” `#confidenceThreshold`
   (`type="range"`, `min=0 max=1 step=0.05 value=0.5`), with a live `#thresholdValue`
   label. It carries the `disabled` attribute and is only enabled after a run.

2. **Slider applies live, client-side, from cache.** The `input` handler at
   `index.html:722-725` calls `updateConfidenceThreshold()` then
   `scheduleThresholdRender()` (`index.html:1279-1286`), which debounces 90 ms and
   re-runs `renderScenePayload(window.lastScenePayload)` against the **cached**
   analysis. No network call β€” but it re-renders on *every drag*, decoupled from
   any analysis/generation step.

3. **Threshold is applied client-side only.** `chooseRenderMode()`
   (`index.html:821-833`) compares `analysis.confidence` against the JS
   `confidenceThreshold` var. It **ignores** the `render_mode` the server already
   returned in the payload.

4. **The server never receives the threshold.** `/generate_scene`
   (`app.py:101-108` β†’ `_generate_scene` at `app.py:116-117` β†’
   `InferenceClient.generate_scene` at `backend.py:47-51`) calls
   `select_render_mode(valid_analysis)` (`schema.py:219-236`), which always uses
   the hardcoded `DEFAULT_CONFIDENCE_THRESHOLD = 0.5` (`schema.py:216`). The
   server's `render_mode` is computed with `0.5` regardless of the slider.

5. **Slider availability is gated on a completed run.** It is `disabled` in markup
   (`index.html:595`), disabled in `resetScene()` (`index.html:769`), and
   `setBusy()` (`index.html:1263`) keeps it disabled whenever
   `!window.lastScenePayload`. So it cannot be set before the first analysis.

**Conclusion:** the threshold is currently a *live, browser-only* control that
never reaches the analysis/generation pipeline β€” the opposite of "only used upon
analysis and generation." This spec changes it to a value that is captured and
enforced **at generation time, server-side**, and that only takes effect on the
next run.

## Product decisions (confirmed with user, June 14, 2026)

1. **Apply timing: next run only.** Moving the slider does **nothing
   immediately** β€” no live re-render of cached analysis. The new threshold is
   captured and applied only the next time analysis + generation runs (next
   upload / re-run). Remove the live client-side re-render entirely.
2. **Enforcement: server pipeline.** The slider value is sent to
   `/generate_scene`, and the server's `select_render_mode()` uses the user's
   threshold instead of the hardcoded `0.5`. The server's returned `render_mode`
   becomes the source of truth; the client trusts it.
3. **Pre-run state: enabled up front.** The slider is usable before the first
   upload so the chosen threshold is in effect for the very first
   analysis/generation.

Unchanged from prior spec (still true):
- **Downgrade chain stays:** below threshold, skip the 3D Three.js render and
  fall back to `annotate` (annotated source photo), then `unavailable`. The
  slider only moves the cutoff; no new hard-block state.
- **No re-running model inference on slider move** (analysis is the expensive
  Modal GPU call; it is never re-triggered by the slider).

## Implementation plan (for Codex)

Changes span the browser (`index.html`) and the server
(`app.py`, `snap2sim/backend.py`, `snap2sim/schema.py`).

### 1. Server: accept and enforce a threshold at generation

- `snap2sim/schema.py` β€” `select_render_mode(analysis, threshold=DEFAULT_CONFIDENCE_THRESHOLD)`:
  - Add a `threshold` parameter, defaulting to `DEFAULT_CONFIDENCE_THRESHOLD`.
  - Coerce/clamp: if `threshold` is not a number, fall back to the default; clamp
    into `[0.0, 1.0]`.
  - Replace the hardcoded `DEFAULT_CONFIDENCE_THRESHOLD` in the `low_confidence`
    line (`schema.py:229`) with the (clamped) `threshold`.
- `snap2sim/backend.py` β€” `InferenceClient.generate_scene(analysis, threshold=None)`
  (`backend.py:47`):
  - Accept an optional `threshold`; when `None`/invalid use
    `DEFAULT_CONFIDENCE_THRESHOLD`.
  - Pass it through to `select_render_mode(valid_analysis, threshold)`.
- `app.py`:
  - `/generate_scene` HTTP route (`app.py:106-108`): read
    `payload.get("confidence_threshold")` and pass to `_generate_scene`.
  - `@app.api(name="generate_scene")` (`app.py:101-103`): add an optional
    `confidence_threshold` parameter (default keeps the existing `/run_pipeline`
    Gradio API backward compatible).
  - `_generate_scene(analysis, threshold)` (`app.py:116-117`): forward the
    threshold to `InferenceClient(...).generate_scene(analysis, threshold)`.
  - Validate at the boundary: coerce to `float`, clamp `[0, 1]`, default on
    missing/invalid. Do not raise on a bad threshold β€” fall back to the default so
    a malformed client value can't break generation.

Result: `/generate_scene` returns a `render_mode` computed with the user's
threshold. Existing callers that omit `confidence_threshold` still get the `0.5`
default β€” backward compatible.

### 2. Client: send the threshold at generation, stop live re-render

- **Capture and send at run time.** In `runPipeline()` (`index.html:727-759`),
  include the current threshold in the generate call:
  `postJson("/generate_scene", { analysis, confidence_threshold: confidenceThreshold })`
  (`index.html:749`). The value is read at the moment of the call, so later slider
  drags don't affect the in-flight run.
- **Trust the server's render mode.** Change `renderScenePayload()`
  (`index.html:805-819`) / `chooseRenderMode()` (`index.html:821-833`) so the
  decision uses `payload.render_mode` as the primary choice, with the existing
  capability guards (`hasUsableGeometry`, `hasAnnotations`) only to *downgrade*
  when data is missing β€” never to upgrade past what the server allowed. Remove the
  client-side `confidence vs confidenceThreshold` comparison (the server now owns
  that). `chooseRenderMode` should take the payload (or render_mode) rather than
  recomputing from confidence.
- **Remove the live re-render.** Delete `scheduleThresholdRender()`
  (`index.html:1279-1286`) and the `thresholdRenderTimer` state
  (`index.html:675`). The slider `input` handler (`index.html:722-725`) should
  now only call `updateConfidenceThreshold()` β€” update the `confidenceThreshold`
  var and the `#thresholdValue` label. No render, no network call.

### 3. Client: enable the slider up front

- Remove the `disabled` attribute from the markup (`index.html:595`).
- In `resetScene()` (`index.html:761-776`), stop disabling the slider
  (`index.html:769`) β€” it should remain available between runs.
- In `setBusy()` (`index.html:1259-1264`), disable the slider **only while a
  request is in flight** (`active`), not based on `window.lastScenePayload`
  (`index.html:1263`). This lets the user set the threshold before the first
  upload and adjust it between runs, while preventing edits mid-request.
- Keep the default at `0.5` / `50%` so first-run behavior is unchanged when the
  user never touches the slider.

### 4. Optional: reflect "applies on next run" in the UI

Because the slider no longer re-renders live, consider a subtle affordance so the
change isn't silent β€” e.g. update the label to hint the value applies to the next
analysis (tooltip or helper text). Low priority; keep it lightweight and
accessible (don't regress the existing `aria-live` label).

## Out of scope / explicitly NOT doing

- **No live re-render from cached analysis** (decision #1 β€” this is the behavior
  being removed).
- **No re-running model inference (`/analyze_image`) when the slider moves**
  (analysis is the expensive Modal GPU call).
- **No hard-block "confidence too low" state** β€” the `three -> annotate ->
  unavailable` downgrade chain stays.
- **No model-authored HTML/JS/markup injection.** Rendering stays deterministic
  Three.js from validated JSON (`SECURITY.md` Agent Guidance). The threshold is a
  non-credential UX/quality control; sending it to a same-origin endpoint is fine.

## Verification checklist

- **Server, default:** `/generate_scene` without `confidence_threshold` returns
  the same `render_mode` as today (regression β€” `0.5` default). Add/extend a unit
  check: `select_render_mode(analysis, 0.9)` downgrades a `0.7`-confidence
  geometry payload to `annotate`/`unavailable`; `select_render_mode(analysis, 0.1)`
  keeps it `three`.
- **Server, clamp/coerce:** out-of-range (`-1`, `5`) and non-numeric thresholds
  fall back/clamp without raising; `/generate_scene` still returns a valid payload.
- **Client, next-run-only:** moving the slider after a run does **not** trigger
  any network request and does **not** change the current render (confirm via
  devtools Network + visual). The new value only takes effect after the next
  upload / re-run.
- **Client, enforced server-side:** raising the threshold above the analysis's
  confidence and re-running downgrades the 3D cutaway to annotated photo / then
  unavailable; lowering it and re-running promotes back to 3D when geometry exists.
- **Client, pre-run:** the slider is interactive before the first upload, disabled
  only while a request is in flight, and re-enabled afterward.
- **Accessibility/layout (regression):** slider keyboard-operable, `#thresholdValue`
  announced, does not block canvas OrbitControls (the pointer-events bug fixed in
  the `docs/reviews/interaction-and-fallback-review.md` pass), and no mobile
  horizontal overflow.
- `INFERENCE_BACKEND=local` sample mode still renders the example analysis with
  the slider present and the threshold honored at generation.
- Existing local checks pass: schema/parser checks and FastAPI `TestClient` for
  `/`, `/analyze_image`, `/generate_scene`.

## Touch points (file/line reference)

- `snap2sim/schema.py:219-236` `select_render_mode()` β€” add `threshold` param,
  clamp, use it instead of the hardcoded default at `schema.py:229`.
- `snap2sim/backend.py:47-51` `generate_scene()` β€” accept + forward `threshold`.
- `app.py:101-108` `generate_scene_api` / `generate_scene_http` β€” accept
  `confidence_threshold`; `app.py:116-117` `_generate_scene` β€” forward it; clamp
  at the boundary.
- `index.html:595` slider markup β€” remove `disabled`.
- `index.html:722-725` slider `input` handler β€” drop the live re-render call.
- `index.html:749` `/generate_scene` call β€” send `confidence_threshold`.
- `index.html:761-776` `resetScene()` / `index.html:1259-1264` `setBusy()` β€”
  enable slider up front, disable only while busy.
- `index.html:805-833` `renderScenePayload()` / `chooseRenderMode()` β€” trust
  server `render_mode`; drop client-side threshold comparison.
- `index.html:675` `thresholdRenderTimer` + `index.html:1279-1286`
  `scheduleThresholdRender()` β€” remove.

## Implementation result

- Implemented in commit `a6f63e9` (`Enforce confidence threshold during generation`).
- The slider is enabled before the first upload and disabled only while a request
  is in flight.
- The browser sends `confidence_threshold` only with `/generate_scene`; slider
  movement does not call `/analyze_image`, call `/generate_scene`, or re-render
  cached analysis.
- `/generate_scene` clamps/coerces the threshold server-side and returns the
  authoritative `render_mode`; the browser only downgrades when geometry or
  annotation data is missing.
- Local verification passed for default/high/low/malformed/clamped thresholds,
  FastAPI `TestClient`, next-run-only browser behavior, high-threshold
  downgrade, low-threshold promotion, keyboard slider operation, mobile
  no-overflow layout, and canvas pointer targeting.
- GitHub Actions sync run `27515950105` deployed commit `a6f63e9` to the then-private
  Hugging Face Space. The Space reported SHA
  `a6f63e9a0b76315bb223a09a71f4c027a29877fb`.
- Authenticated Space verification passed: the root served the updated
  shell, a synthetic image returned `optical sight` at `0.7` confidence with 3
  parts, high threshold returned `photo` / `annotate`, low threshold returned
  `three` / `three`, and no HTML field was present.