ThomsenDrake commited on
Commit
2d63573
·
verified ·
1 Parent(s): ea4d971

Sync submission-ready runtime and docs

Browse files
Makefile CHANGED
@@ -4,7 +4,7 @@ HOST ?= 127.0.0.1
4
  PORT ?= 7860
5
  FIGMENT_SMOKE_TIMEOUT_SECONDS ?= 8
6
 
7
- .PHONY: install test run run-hosted-demo build-fts smoke-model-route smoke-local-model-route
8
 
9
  install:
10
  $(PIP) install --upgrade pip
@@ -27,6 +27,12 @@ build-fts:
27
  echo "scripts/build_fts.py not available yet; skipping FTS build."; \
28
  fi
29
 
 
 
 
 
 
 
30
  smoke-model-route:
31
  PYTHON_DOTENV_DISABLED=true FIGMENT_MODE=canned MODEL_STACK=omni_native MODEL_BACKEND=canned AUDIO_BACKEND=none \
32
  FIGMENT_SMOKE_ALLOW_NETWORK=false FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
@@ -35,3 +41,9 @@ smoke-model-route:
35
  smoke-local-model-route:
36
  FIGMENT_MODE=local MODEL_BACKEND=llama_cpp AUDIO_BACKEND=none FIGMENT_SMOKE_ALLOW_NETWORK=true FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
37
  $(PYTHON) scripts/smoke_model_route.py
 
 
 
 
 
 
 
4
  PORT ?= 7860
5
  FIGMENT_SMOKE_TIMEOUT_SECONDS ?= 8
6
 
7
+ .PHONY: install test run run-hosted-demo build-fts audit-claims evidence-gates smoke-model-route smoke-local-model-route local-4b-evidence local-asr-evidence
8
 
9
  install:
10
  $(PIP) install --upgrade pip
 
27
  echo "scripts/build_fts.py not available yet; skipping FTS build."; \
28
  fi
29
 
30
+ audit-claims:
31
+ $(PYTHON) scripts/audit_submission_claims.py
32
+
33
+ evidence-gates:
34
+ $(PYTHON) scripts/evidence_gate_status.py --markdown; status=$$?; test $$status -eq 0 -o $$status -eq 2
35
+
36
  smoke-model-route:
37
  PYTHON_DOTENV_DISABLED=true FIGMENT_MODE=canned MODEL_STACK=omni_native MODEL_BACKEND=canned AUDIO_BACKEND=none \
38
  FIGMENT_SMOKE_ALLOW_NETWORK=false FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
 
41
  smoke-local-model-route:
42
  FIGMENT_MODE=local MODEL_BACKEND=llama_cpp AUDIO_BACKEND=none FIGMENT_SMOKE_ALLOW_NETWORK=true FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
43
  $(PYTHON) scripts/smoke_model_route.py
44
+
45
+ local-4b-evidence:
46
+ PYTHON_DOTENV_DISABLED=true $(PYTHON) scripts/run_local_4b_evidence.py --base-url "$${LLAMA_BASE_URL:-http://127.0.0.1:8001/v1}"
47
+
48
+ local-asr-evidence:
49
+ PYTHON_DOTENV_DISABLED=true $(PYTHON) scripts/run_local_asr_evidence.py
README.md CHANGED
@@ -12,87 +12,133 @@ python_version: 3.11
12
 
13
  # Figment
14
 
15
- **Protocol support for low-connectivity field clinics and disaster response.**
16
 
17
- Figment uses deterministic rules for danger signs and an AI protocol navigator for messy field notes, missing-observation planning, card-cited responder checklists, and SBAR handoffs. The frozen primary model path is NVIDIA Nemotron 3 Nano Omni: hosted Omni powers live-model demos when configured, and self-hosted Omni can technically support an Off the Grid run if it is served on adequate local hardware with no runtime cloud APIs. The current local/off-grid gap is hardware and recorded evidence, not an architecture impossibility; the smaller proof path targets Nemotron 3 Nano 4B for text navigation plus Parakeet RNNT ASR for dictated intake after verification. (The app scaffold is runnable and still under active development see **Status** below.)
18
 
19
- > ⚠️ **Figment is not a medical device.** It does not diagnose, prescribe, or replace a clinician. It is a prototype for protocol navigation, escalation support, and documentation in low-connectivity environments, for use by trained responders. See [Safety & non-goals](#safety--non-goals).
20
 
21
- - **Status:** In active development for the [Build Small Hackathon](docs/build-small-hackathon-org-card.md) (build window **June 5-15, 2026**). The Gradio scaffold, deterministic rules, hosted NVIDIA Omni client, local OpenAI-compatible client, canned fallback, traces, and tests run locally; the hosted NVIDIA API smoke test is green. The public Hugging Face Space now boots with app files present at commit `5dcfc5c830de7331eca9020b17e1c571a8619654`; a no-secret public workflow smoke loaded a demo case, confirmed typed intake, fired deterministic pediatric-dehydration escalation, retrieved protocol cards, and produced an honestly labeled `canned_backend` trace with `validation_status=passed`, `raw_audio_stored=false`, and zero model-retained fields. The 50-case hosted Omni eval has run: baseline whole-output model competence was **28/50**, and the load-bearing follow-up reached **31/50** with **480/650** model-retained fields, **170/650** deterministic patches, **8/50** full fallback, and **50/50** final validation. Local 4B runtime evidence, Parakeet ASR evidence, demo video, social post, and user-test notes are still proof-needed items tracked in the [adversarial review action items](docs/adversarial-review-action-items.md), [hosted eval results](docs/hosted_omni_eval_results.md), [parameter/evidence ledger](docs/model_parameter_evidence_ledger.md), and [submission checklist](docs/submission_checklist.md).
22
- - **Track target:** Backyard AI (solve a real problem for a specific, real person you know). Final evidence still needs a real trained responder using synthetic or de-identified scenarios; see [user test notes](docs/user_test_notes.md).
23
- - **Built for:** a real disaster-response volunteer trained in disaster-response first aid and local protocol use; name withheld for privacy.
24
- - **Model:** NVIDIA **Nemotron 3 Nano Omni 30B-A3B Reasoning** as the v1 default. The model-card body reports 31B total parameters; the workback plan and [parameter/evidence ledger](docs/model_parameter_evidence_ledger.md) track the HF-sidebar count ambiguity, local 4B + Parakeet story, adapter count status, and organizer-confirmation status.
25
 
26
- ---
 
 
 
 
 
27
 
28
- ## Why Figment
29
 
30
- > What happens when the clinic loses internet?
 
 
 
31
 
32
- Rural clinics, mobile units, and disaster sites lose connectivity exactly when decisions get hardest. Cloud medical assistants stop working; paper protocol binders don't talk back. Figment is built toward an **offline** mode: with a verified local model route, it can read the same protocol cards a responder would, apply hard-coded danger-sign rules, and turn a messy field note into a structured handoff on the machine in front of you. Until a no-cloud run is recorded, hosted mode and off-grid mode are labeled separately.
33
 
34
- The design goal is restraint. Figment is **a field protocol binder that can talk, cite itself, and knows when to shut up** not an "AI doctor."
35
 
36
- ---
37
 
38
- ## What it does
 
 
 
 
 
39
 
40
- Figment is a [Gradio Server](https://www.gradio.app/guides/server-mode) app with five frozen workflow views:
41
 
42
- 1. **Intake** — structured capture of setting, patient age, pregnancy status, chief concern, symptoms, vitals, allergies, medications, available supplies, and a free-text responder note. Optional audio intake drafts fields only; typed/edited values must be confirmed before rules or navigation run.
43
- 2. **Risk Check** — deterministic red-flag rules fire **before** the LLM and set the minimum urgency floor (e.g. altered mental status, severe respiratory distress, chest pain, stroke signs, pregnancy bleeding, pediatric lethargy, severe dehydration signs, fever escalation criteria, wound infection escalation criteria).
44
- 3. **Protocol Guidance** — local retrieval returns 3–6 relevant protocol cards via SQLite FTS/BM25; the AI navigator selects candidate pathways, flags uncertainty, and plans missing observations.
45
- 4. **Navigator Output + Handoff** — shows candidate protocol pathways, a responder checklist, missing observations, an SBAR note, a referral summary, and source protocol-card IDs.
46
- 5. **Trace** — shows the full pipeline (input → rules → retrieval → prompt → output → validation) so judges and users can see *why*, not just *what*. This is the "show, don't tell" engine.
47
 
48
- ---
 
 
 
 
 
 
49
 
50
- ## How it works
 
 
51
 
52
  ```text
53
- Gradio Server custom frontend + Gradio API endpoints
54
- structured intake schema
55
- rules.py (deterministic red-flag engine)
56
- retrieval.py (SQLite FTS protocol search)
57
- prompt_builder.py (constrained navigator prompt; cards + rules injected)
58
- navigator.py (AI protocol navigator)
59
- model_client.py (hosted/self-hosted Omni, local 4B OpenAI-compatible server, or canned fallback)
60
- validators.py (output validator: JSON, citations, safety checks)
61
- sbar.py (referral note renderer)
62
- trace.py (trace export)
 
63
  ```
64
 
65
- Two principles make this safe rather than chatty:
66
 
67
- - **Rules before the model.** Danger-sign detection is deterministic code, not a model guess, so a red flag can't be "reasoned away."
68
- - **The cards are the source of truth; the model is a behavior harness.** The base hosted/local model is prompted and validated to stay inside retrieved cards, cite card IDs, ask for missing observations, preserve deterministic red-flag floors, build checklists, and refuse out-of-scope requests — not to memorize medical facts. Fine-tuning is deferred unless the runtime demo is already safe and reliable.
 
 
 
69
 
70
- ---
71
 
72
- ## The model & the ≤32B constraint
73
 
74
- The Build Small Hackathon caps models at **32B total parameters**. Figment's primary path is **NVIDIA Nemotron 3 Nano Omni 30B-A3B Reasoning** — a multimodal MoE hybrid Mamba-Transformer with an integrated speech encoder and roughly 3B active parameters per token.
 
 
 
 
75
 
76
- > **Compliance note:** NVIDIA's model-card body reports **31B total parameters**, which fits the 32B cap. The Hugging Face sidebar count has differed, so the workback plan keeps this as a submission risk to verify with organizers. The ~3B *active* figure is **not** the compliance number — the limit is on *total* parameters.
77
 
78
- The live parameter and proof status is tracked in the [model parameter/evidence ledger](docs/model_parameter_evidence_ledger.md). It separates hosted Omni evidence from the unproven local 4B + Parakeet path, and it keeps adapter counts and organizer confirmation explicit before any badge or compliance claim is upgraded.
79
 
80
- Omni can satisfy an off-grid claim if it is self-hosted on sufficient local hardware and the demo uses no runtime cloud APIs. This repo has not yet recorded that proof. The nearer local/off-grid proof path targets a smaller split stack after verification:
81
 
82
- | Artifact | Use |
83
- | -------- | --- |
84
- | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16` | primary hosted/self-hosted Omni model ID |
85
- | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA API Catalog / NIM chat-completions model ID |
86
- | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | local text-navigation and first fine-tuning target |
87
- | `nvidia/parakeet-rnnt-1.1b` | local/offline ASR target, enabled only after the local ASR gate passes |
88
 
89
- Reference dev/demo machine: an M4 Pro MacBook Pro with 48 GB RAM. Hosted Omni is the intended public Space story; local audio is Parakeet-only after verification, and the safe local proof may use typed intake or a canned transcript if ASR is not stable. The full 4B BF16 artifact and Parakeet `.nemo` artifact are present locally, but the local 50-case text eval and local ASR provider proof still need to be run before any local badge claim is upgraded.
 
 
 
 
 
 
 
 
90
 
91
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- ## Getting Started
 
 
 
 
 
 
94
 
95
- Start with the full [prerequisites checklist](docs/prerequisites.md). The short version:
 
 
96
 
97
  ```bash
98
  python3 -m venv .venv
@@ -102,224 +148,128 @@ python -m pip install -r requirements.txt -r requirements-dev.txt
102
  cp .env.example .env
103
  ```
104
 
105
- ### 1. Run the app with the hosted NVIDIA API
106
-
107
- Copy `.env.example` to `.env`, set the hosted model variables, and add `NVIDIA_API_KEY`. The hosted route uses the NVIDIA API Catalog OpenAI-compatible endpoint:
108
-
109
- ```dotenv
110
- FIGMENT_MODE=hosted
111
- MODEL_BACKEND=hosted_omni
112
- MODEL_STACK=omni_native
113
- NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
114
- NVIDIA_MODEL_ID=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
115
- NVIDIA_API_KEY=nvapi-...
116
- AUDIO_BACKEND=omni_native
117
- ENABLE_AUDIO_INTAKE=true
118
- ```
119
-
120
- Then run:
121
 
122
  ```bash
123
- make run-hosted-demo PYTHON=.venv/bin/python
124
  ```
125
 
126
- If the hosted model is unavailable or returns invalid JSON, Figment falls back to the deterministic canned navigator output and still validates the result.
127
-
128
- ### 2. Run against a local OpenAI-compatible server
129
-
130
- To target a local OpenAI-compatible server after the Nemotron 3 Nano 4B path is verified, use the full BF16 4B model as the canonical local text artifact:
131
 
132
  ```bash
133
- vllm serve nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
134
- --served-model-name nemotron3-nano-4b-bf16 \
135
- --trust-remote-code \
136
- --max-model-len 16384
137
  ```
138
 
139
- The full-weight snapshot has been downloaded locally at:
140
 
141
- ```text
142
- /Users/drake.thomsen/.cache/huggingface/hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f
143
  ```
144
 
145
- For a local run, expose that full-weight runtime through an OpenAI-compatible `/v1/chat/completions` endpoint and point Figment at it. Set `MODEL_BACKEND=llama_cpp`, `MODEL_STACK=local_4b_parakeet`, `LLAMA_BASE_URL=<local-openai-compatible-endpoint>/v1`, and `LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` in `.env`. Do not count the local route as model competence until the 50-case eval records configured-model successes rather than full deterministic fallback.
146
-
147
- To capture the local evidence bundle once the endpoint is live:
148
 
149
  ```bash
150
- PYTHON_DOTENV_DISABLED=true \
151
- python3 scripts/run_local_4b_evidence.py \
152
- --base-url http://127.0.0.1:8001/v1
153
  ```
154
 
155
- The helper writes `/v1/models` metadata, one-case route smoke, eval JSONL, eval summary, and `eval_evidence_manifest.json` under `traces/local_4b_evidence_*`. The manifest captures model/server metadata, no-cloud route flags, raw/repair/full-fallback counts, field provenance, latency, and trace hashes. If the endpoint is unavailable or the route smoke falls back deterministically, it records that state and does not upgrade the local model claim.
156
 
157
- To capture the local Parakeet ASR evidence bundle once a real local ASR adapter or device runtime produces provider output:
158
 
159
  ```bash
160
- PYTHON_DOTENV_DISABLED=true \
161
- python3 scripts/run_local_asr_evidence.py \
162
- --provider-payload <local-parakeet-provider-output.json> \
163
- --audio <optional-source-audio.wav>
 
164
  ```
165
 
166
- The ASR helper records Parakeet artifact metadata, optional audio metadata without copying raw audio, provider-output hash, Figment draft checks, and `asr_evidence_manifest.json`. Artifact presence alone does not count as local ASR proof.
167
-
168
- ### 3. Canned fallback
169
-
170
- The scaffold can still run without any live model:
171
 
172
  ```dotenv
173
- MODEL_BACKEND=canned
 
 
 
 
 
174
  ```
175
 
176
- ### 4. Hosted demo target
177
-
178
- The submission Space target is under the **build-small-hackathon** Hugging Face org:
179
 
180
- [build-small-hackathon/figment](https://huggingface.co/spaces/build-small-hackathon/figment)
181
-
182
- The submission target is a live Gradio demo powered by a hosted or self-hosted Nemotron Omni endpoint. Canned responses and traces are fallback only if hosted model, quota, or cold-start reliability fails. The public Space is now runnable in no-secret mode: the Hugging Face Space API reports `runtime.stage=RUNNING`, `app.py` is present, the Space serves HTTP 200, and the public Gradio API completed a typed pediatric-dehydration workflow with deterministic escalation and an honestly labeled `canned_backend` trace. Live hosted Omni generation in the public Space still needs a final demo trace if secrets are configured for judging; keep local/off-grid and small-model badge claims gated by the [submission checklist](docs/submission_checklist.md).
 
183
 
184
- Related Hub assets in the same org:
185
 
186
- - Dataset and training corpora: [build-small-hackathon/figment-eval-traces](https://huggingface.co/datasets/build-small-hackathon/figment-eval-traces)
187
- - Fine-tuned local-model archive: [build-small-hackathon/figment-finetuned-model-archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive)
188
 
189
- ---
190
 
191
- ## Repository layout
 
 
 
 
192
 
193
- This repo now holds the runnable scaffold plus the planning docs. Current structure:
194
 
195
- ```text
196
- figment/
197
- app.py # Gradio Server app and custom frontend
198
- figment/ # config, schemas, rules, retrieval, model_client,
199
- # prompt_builder, validators, trace, sbar
200
- data/
201
- protocol_cards/ # 10 prototype cards (JSON)
202
- demo_audio/ # three synthetic dictated-intake WAV clips for the demo
203
- scripts/ # FTS build, smoke, and eval helpers
204
- traces/ # exported demo traces
205
- docs/ # field notes, model/dataset/safety cards, this plan
206
- ```
207
 
208
- Available now:
209
 
210
  ```text
211
- app.py # Gradio app scaffold
212
- figment/ # protocol engine, model/audio adapters, trace/validators
213
- data/protocol_cards/ # 10 prototype protocol cards
214
- data/demo_audio/ # click-to-load hosted audio demo clips
215
- traces/ # regenerated demo traces
216
- tests/ # regression tests for safety, audio, rules, app smoke
217
- docs/figment-workback-plan.md # the full day-by-day build plan
218
- docs/build-small-hackathon-org-card.md # hackathon rules (source of truth)
219
- docs/prerequisites.md # setup contract for local, hosted, and Modal work
220
- docs/superpowers/specs/ docs/superpowers/plans/ # design spec + implementation plan for plan additions
221
- requirements.txt / requirements-dev.txt / .env.example
222
  ```
223
 
224
- Key docs: [workback plan](docs/figment-workback-plan.md) · [adversarial review action items](docs/adversarial-review-action-items.md) · [hosted eval results](docs/hosted_omni_eval_results.md) · [parameter/evidence ledger](docs/model_parameter_evidence_ledger.md) · [local llama evidence](docs/local_llama_eval_evidence.md) · [local Parakeet ASR evidence](docs/local_parakeet_asr_evidence.md) · [submission checklist](docs/submission_checklist.md) · [safety statement](docs/safety_statement.md) · [user test notes](docs/user_test_notes.md) · [prerequisites](docs/prerequisites.md) · [hackathon rules](docs/build-small-hackathon-org-card.md) · [design spec](docs/superpowers/specs/2026-06-05-figment-plan-additions-design.md) · [implementation plan](docs/superpowers/plans/2026-06-05-figment-plan-additions.md).
225
-
226
- ---
227
-
228
- ## Data & evaluation
229
-
230
- - **Synthetic data, not memorized facts.** Future 5,000–10,000 candidate cases are generated by teacher models (Mistral/MiniMax, build-time only), cross-critiqued, and filtered by a deterministic validator down to ~2,000–4,000 kept examples. No real PHI is used.
231
- - **Behavior, not knowledge.** Training teaches the model to cite cards, ask for missing info, escalate red flags, produce SBAR, and refuse unsafe requests.
232
- - **Eval before training.** A 50-case hosted Omni eval now scores the model on measurable behavior, while the larger 50-100 case target thresholds remain the quality bar:
233
-
234
- | Metric | Target |
235
- | ------ | -----: |
236
- | Valid JSON | ≥ 98% |
237
- | Source-card citation rate | ≥ 95% |
238
- | Red-flag recall | ≥ 90% |
239
- | Unsupported diagnosis rate | 0% |
240
- | Unsupported medication/dose rate | 0% |
241
- | Missing-info question rate | ≥ 85% |
242
- | SBAR factuality | ≥ 95% |
243
- | Prompt-injection compliance failure | 0 critical |
244
-
245
- Current measured hosted Omni results are in [hosted_omni_eval_results.md](docs/hosted_omni_eval_results.md). The baseline run reached **28/50** whole-output model competence with **22/50** full deterministic fallback and **50/50** final validation. The load-bearing follow-up reached **31/50** whole-output model competence, **8/50** full fallback, **480/650** model-retained fields, **170/650** deterministic patches, and **50/50** final validation. Final validation is application safety, not pure model competence; deterministic fallback and deterministic patches are reported separately and cannot inflate model scores.
246
 
247
- Local 4B + Parakeet eval, no-cloud/off-grid proof, and any fine-tuned adapter eval are still unmeasured.
248
 
249
- The current eval harness records strict validation, repair/fallback, field provenance, and latency. Judgment metrics can still be added with a held-out judge model once the larger gold set exists.
 
 
 
 
250
 
251
- ---
252
-
253
- ## Safety & non-goals
254
 
255
- Figment is deliberately scoped. **It will not:**
256
 
257
- - **diagnose** — it surfaces protocol cards and danger signs; it does not name a condition as fact;
258
- - **prescribe or dose medication** doses appear only if a cited card contains them;
259
- - **replace a clinician** the trained responder remains the decision-maker;
260
- - **serve untrained users** it is a tool for trained responders;
261
- - **store PHI or raw audio** traces scrub raw audio-like payloads and uploaded filenames;
262
- - **hide hosted-mode data flow** — hosted Space mode may send synthetic or de-identified text/audio to the configured Omni endpoint, while local mode keeps runtime inputs on the local machine;
263
- - **act autonomously** — every output is advisory and requires human judgment.
264
 
265
- This posture reflects real risk: the WHO has warned that authoritative-sounding health AI can create automation bias, and the FDA regulates clinical-decision-support software depending on its claims and users. Figment makes no clinical claims. See the fuller [safety statement](docs/safety_statement.md).
266
-
267
- ---
268
 
269
- ## Licensing & data handling
270
 
271
  | Artifact | License |
272
- | -------- | ------- |
273
- | Model / adapter | inherits the NVIDIA Nemotron model license; cite exact upstream terms in the model card |
274
- | Synthetic dataset | CC-BY-4.0 |
275
  | Code | [Apache-2.0](LICENSE) |
276
-
277
- Data handling: local mode keeps runtime inputs on the local machine; hosted mode is for synthetic or de-identified demo inputs only; traces do not retain raw audio.
278
-
279
- ---
280
-
281
- ## Demo cases
282
-
283
- Three canonical cases drive the demo:
284
-
285
- 1. **Pediatric dehydration** — missing vitals, urgent red flags, asks next questions, produces a referral note.
286
- 2. **Wound infection after disaster injury** — protocol retrieval, avoids antibiotic overreach, recommends escalation criteria, clean documentation.
287
- 3. **Pregnancy danger sign** — deterministic red-flag override, immediate escalation, minimal model freelancing.
288
-
289
- The Intake tab includes click-to-load audio examples for all three cases when `data/demo_audio/*.wav` is present. These are synthetic Voxtral-generated dictated-intake clips; they are not real patient audio.
290
-
291
- ---
292
-
293
- ## Hackathon
294
-
295
- Built for the **[Build Small Hackathon](docs/build-small-hackathon-org-card.md)** (Gradio · Hugging Face), which caps models at 32B parameters and requires a Gradio app hosted as a Hugging Face Space plus a demo video and social post.
296
-
297
- Submission claims are evidence-gated:
298
-
299
- | Claim / badge area | Current status | Proof needed before claiming achieved |
300
- | ------------------ | -------------- | ------------------------------------- |
301
- | Hosted Gradio Space | Runnable in no-secret canned-fallback mode; live hosted-model demo trace still proof-needed | Public Space app files present, cold boot, typed intake run, trace showing `raw_route=canned`, `final_route=canned_backend`, `validation_status=passed`, and `raw_audio_stored=false` |
302
- | Backyard AI | Targeted / proof-needed | A real trained responder using synthetic or de-identified scenarios, recorded in [user test notes](docs/user_test_notes.md) |
303
- | Off the Grid | Targeted, not yet proven | Recorded no-cloud run using either self-hosted Omni on adequate local hardware or the smaller verified local stack |
304
- | Llama Champion | Targeted, not yet proven | Working eligible local model route with trace/eval evidence |
305
- | Sharing is Caring | Targeted / proof-needed | Public Space, repo, demo video, and social post links |
306
- | Well-Tuned | Stretch / proof-needed | Eval harness plus measured improvement from tuning or an adapter, not fallback output |
307
- | Field Notes | Tentative / proof-needed | Submission rules confirmation plus field-note artifact |
308
- | Off-Brand | Targeted / proof-needed | Final demo/story asset aligned to organizer criteria |
309
-
310
- ---
311
 
312
  ## Acknowledgements
313
 
314
- - **NVIDIA** Nemotron 3 Nano Omni model · **Modal** fine-tune/eval compute · **Gradio** & **Hugging Face** app framework and hosting · **llama.cpp** local inference.
315
-
316
- ---
317
-
318
- ## Disclaimer
319
-
320
- Figment is a **prototype for trained responders**, not medical advice and not a medical device. It does not diagnose or prescribe. Protocol cards are prototypes derived from public guideline concepts, **not** clinical guidelines. Always rely on qualified clinical judgment and local protocols.
321
-
322
- <!-- TODO before submission:
323
- - Add a LICENSE file.
324
- - Add the demo video + social post links.
325
- -->
 
12
 
13
  # Figment
14
 
15
+ **Protocol navigation for trained responders in low-connectivity clinics and disaster response.**
16
 
17
+ Figment turns messy field intake into a card-cited protocol workflow: confirm the facts, run deterministic danger-sign rules, retrieve local protocol cards, ask a small model for bounded navigation fields, validate or repair the output, and show a trace of what happened.
18
 
19
+ > **Safety boundary:** Figment is a prototype, not a medical device. It does not diagnose, prescribe, dose medication, autonomously triage, or replace a trained responder, supervisor, clinician, or local protocol.
20
 
21
+ ## Current Snapshot
 
 
 
22
 
23
+ | Surface | Current evidence | What it means | Boundary |
24
+ | --- | --- | --- | --- |
25
+ | Public Space | [build-small-hackathon/figment](https://huggingface.co/spaces/build-small-hackathon/figment) is `RUNNING` and served HTTP 200 when checked on 2026-06-14 at Space commit `ea4d971be2b8d4e1841648e5a9adc5142d16a299`. | The Gradio app is deployable and reachable. | The public no-secret route may use the labeled canned fallback; that is deployment proof, not live hosted-model proof. |
26
+ | Hosted Omni eval | `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, and `50/50` final validation. | Hosted Omni can carry bounded fields, and the app can keep outputs inside the safety contract. | `50/50` final validation is app safety after validation, repair, and fallback. It is not pure model performance. |
27
+ | 4B LoRA system eval | v14p repair-union on the corrected 150-case field-workflow holdout: `150/150` competence, `150/150` expected labels, `150/150` final validation, `0` deterministic patches, `0` fallback. Raw first-pass success is `146/150`; `4/150` cases close through focused model repair. | The strongest documented small-model result is model-owned output plus model repair on a synthetic/de-identified holdout. | This is not clinical validation, target-user validation, local ASR proof, or proof that raw first-pass output solved every case. |
28
+ | Public artifacts | [model archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive) and [eval/training dataset](https://huggingface.co/datasets/build-small-hackathon/figment-eval-traces). | Versioned BF16/GGUF model artifacts, synthetic corpora, eval traces, and summaries are inspectable outside this checkout. | Generated `traces/`, `data/finetune/`, weights, and checkpoint folders are intentionally not part of a clean clone. |
29
 
30
+ Final submission claims are evidence-gated. Before changing public copy, run:
31
 
32
+ ```bash
33
+ make audit-claims PYTHON=.venv/bin/python
34
+ make evidence-gates PYTHON=.venv/bin/python
35
+ ```
36
 
37
+ ## Why Figment Exists
38
 
39
+ When a rural clinic, mobile unit, shelter, or disaster site loses connectivity, the work does not become simpler. Protocol binders still matter, but they do not ask follow-up questions, organize missing observations, or draft a clean handoff.
40
 
41
+ Figment is built as a restrained protocol binder that can talk back. It does not try to be an AI clinician. Its job is narrower:
42
 
43
+ - preserve deterministic red-flag floors;
44
+ - cite the protocol cards it used;
45
+ - ask for missing observations;
46
+ - produce a responder checklist;
47
+ - draft an SBAR-style handoff;
48
+ - expose whether each field came from raw model output, model repair, or deterministic fallback.
49
 
50
+ That separation is the core project claim: useful small-model systems get safer and easier to improve when the model's job is narrow enough to inspect.
51
 
52
+ ## User Workflow
 
 
 
 
53
 
54
+ Figment's Gradio Server app is organized around the field workflow:
55
+
56
+ 1. **Intake** captures setting, age, pregnancy status, chief concern, symptoms, vitals, allergies, medications, available supplies, and a free-text responder note. Audio intake is only a draft layer; typed or edited facts must be confirmed before rules or navigation run.
57
+ 2. **Risk Check** runs deterministic red-flag rules before model navigation and sets the minimum urgency floor.
58
+ 3. **Protocol Guidance** retrieves 3-6 local protocol cards through SQLite FTS/BM25, with JSON fallback search.
59
+ 4. **Navigator Output + Handoff** returns candidate pathways, uncertainty notes, missing observations, responder checklist, source cards, plain-language script, and SBAR handoff.
60
+ 5. **Trace** shows input, rules, retrieval, prompt context, raw output, repair, fallback, validation, route labels, field provenance, and trace hashes.
61
 
62
+ Three included demo scenarios cover pediatric dehydration, wound infection after disaster injury, and pregnancy danger signs. The demo audio clips are synthetic and are not real patient audio.
63
+
64
+ ## Architecture
65
 
66
  ```text
67
+ app.py
68
+ -> confirmed structured intake
69
+ -> figment/rules.py deterministic danger-sign rules
70
+ -> figment/retrieval.py local protocol-card retrieval
71
+ -> figment/prompt_builder.py bounded navigator prompt
72
+ -> figment/model_client.py hosted Omni, local OpenAI-compatible, or canned route
73
+ -> figment/navigator.py raw output, scaffold, repair, fallback orchestration
74
+ -> figment/validators.py schema, citations, urgency floor, safety checks
75
+ -> figment/field_provenance.py model_raw / model_repaired / deterministic_fallback labels
76
+ -> figment/eval_metrics.py app-safety and model-contribution metrics
77
+ -> figment/trace.py auditable route and trace export
78
  ```
79
 
80
+ The safety pattern is deliberate:
81
 
82
+ - **Rules before model:** danger signs set an urgency floor the model cannot lower.
83
+ - **Cards as source of truth:** the model must stay inside retrieved protocol cards and cite card IDs.
84
+ - **Human confirmation:** audio-derived fields are provisional until the responder confirms them.
85
+ - **Scoped repair:** when an output fails validation, focused repair targets a bounded failure class rather than asking the model to improvise a new answer.
86
+ - **Visible fallback:** deterministic patches and full fallback are counted separately from model competence.
87
 
88
+ ## Models
89
 
90
+ Figment supports three runtime routes:
91
 
92
+ | Route | Backend | Use |
93
+ | --- | --- | --- |
94
+ | Canned fallback | `MODEL_BACKEND=canned` | No-secret app smoke, UI development, honest fallback traces. |
95
+ | Hosted Omni | `MODEL_BACKEND=hosted_omni` with `NVIDIA_API_KEY` | Live hosted demo and hosted eval path using `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`. |
96
+ | Local OpenAI-compatible | `MODEL_BACKEND=llama_cpp` with `LLAMA_BASE_URL` | Local text-navigation route for the 4B BF16/GGUF artifacts and local evidence bundles. |
97
 
98
+ The Build Small constraint is <=32B total parameters. The hosted Omni path is tracked with a parameter-count caveat: the NVIDIA model-card body reports 31B total parameters, while sidebar counts have differed. The 4B BF16 base model, `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`, is the local text-navigation training target.
99
 
100
+ Parakeet ASR remains a gated local-audio path. Artifact presence alone is not enough; local ASR needs provider-output evidence before any local-audio claim is upgraded.
101
 
102
+ ## Evaluation
103
 
104
+ Figment reports app safety and model contribution separately.
 
 
 
 
 
105
 
106
+ | Metric | Meaning |
107
+ | --- | --- |
108
+ | Final validation | Did the final app output satisfy schema, citations, urgency floors, and safety checks? |
109
+ | Competence success | Did the configured model path, including allowed model repair, produce a competent case result? |
110
+ | Raw configured-model success | Did first-pass model output work without repair? |
111
+ | Focused repair success | Did a scoped model repair close a bounded failure? |
112
+ | Deterministic patch count | How many final fields came from code scaffolding rather than model output? |
113
+ | Full fallback use | Did the app abandon the model route and use deterministic fallback output? |
114
+ | Expected-label success | Did the final output preserve case-level target labels such as urgency, source cards, and red flags? |
115
 
116
+ Selected lineage on the 150-case field-workflow holdout:
117
+
118
+ | Run | Competence | Raw success | Repair | Expected labels | Final validation | Fallback | Deterministic patches | Lesson |
119
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
120
+ | v3 | 107/150 | 93/150 | 14 | 0/150 | 148/150 | 2 | 114 | First strong field-workflow jump, but weak observation ownership. |
121
+ | v5 | 2/150 | 2/150 | 0 | 150/150 | 150/150 | 0 | 302 | The app passed; deterministic scaffolding carried too much. |
122
+ | v6 | 142/150 | 142/150 | 0 | 146/150 | 150/150 | 0 | 21 | Targeted replay and delta rows moved model-owned behavior. |
123
+ | v7 corrected | 148/150 | 148/150 | 0 | 147/150 | 150/150 | 0 | 3 | Remaining failures became narrow and inspectable. |
124
+ | v10 | 147/150 | 147/150 | 0 | 150/150 | 150/150 | 0 | 6 | Some misses resisted generic corpus growth. |
125
+ | v14p repair-union | 150/150 | 146/150 | 4 | 150/150 | 150/150 | 0 | 0 | Focused model repair closed the remaining corrected-holdout cases. |
126
+
127
+ The corrected scoring view changes 6 cases from the original frozen holdout and preserves the correction manifest in `data/eval/field_workflow_holdout_v1_corrected_scoring_manifest.json`. The point is not to train around a bad target; it is to leave a receipt when a benchmark rule is corrected.
128
+
129
+ ## Public Artifacts
130
 
131
+ - Demo Space: [build-small-hackathon/figment](https://huggingface.co/spaces/build-small-hackathon/figment)
132
+ - Runtime URL: [build-small-hackathon-figment.hf.space](https://build-small-hackathon-figment.hf.space/)
133
+ - Model archive: [build-small-hackathon/figment-finetuned-model-archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive)
134
+ - Eval traces and SFT corpora: [build-small-hackathon/figment-eval-traces](https://huggingface.co/datasets/build-small-hackathon/figment-eval-traces)
135
+ - Safety statement: [docs/safety_statement.md](docs/safety_statement.md)
136
+ - Submission gates: [docs/submission_checklist.md](docs/submission_checklist.md)
137
+ - Build Small org card: [docs/build-small-hackathon-org-card.md](docs/build-small-hackathon-org-card.md)
138
 
139
+ The model archive contains the v1 pilot, v2-v4 checkpoints, and versioned v5-v14p BF16/GGUF artifacts. The dataset repo contains scored hosted/local traces plus synthetic SFT configs `figment_sft_v1` through `figment_sft_v14p`.
140
+
141
+ ## Quickstart
142
 
143
  ```bash
144
  python3 -m venv .venv
 
148
  cp .env.example .env
149
  ```
150
 
151
+ Or use the Makefile after creating the venv:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  ```bash
154
+ make install PYTHON=.venv/bin/python
155
  ```
156
 
157
+ Run the no-secret app path:
 
 
 
 
158
 
159
  ```bash
160
+ MODEL_BACKEND=canned make run PYTHON=.venv/bin/python
 
 
 
161
  ```
162
 
163
+ Run the hosted Omni demo path:
164
 
165
+ ```bash
166
+ NVIDIA_API_KEY=nvapi-... make run-hosted-demo PYTHON=.venv/bin/python
167
  ```
168
 
169
+ Run tests:
 
 
170
 
171
  ```bash
172
+ PYTHONPATH=. .venv/bin/pytest tests -q
 
 
173
  ```
174
 
175
+ ## Local Model Route
176
 
177
+ Start a local OpenAI-compatible server, for example with a downloaded GGUF:
178
 
179
  ```bash
180
+ llama-server \
181
+ -m /path/to/figment-sft-v14p-lora-merged-bf16.bf16.gguf \
182
+ --host 127.0.0.1 \
183
+ --port 8001 \
184
+ -c 16384
185
  ```
186
 
187
+ Point Figment at it:
 
 
 
 
188
 
189
  ```dotenv
190
+ FIGMENT_MODE=local
191
+ MODEL_STACK=local_4b_parakeet
192
+ MODEL_BACKEND=llama_cpp
193
+ LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
194
+ LLAMA_BASE_URL=http://127.0.0.1:8001/v1
195
+ AUDIO_BACKEND=none
196
  ```
197
 
198
+ Capture an evidence bundle once the endpoint is live:
 
 
199
 
200
+ ```bash
201
+ make smoke-local-model-route PYTHON=.venv/bin/python
202
+ make local-4b-evidence PYTHON=.venv/bin/python
203
+ ```
204
 
205
+ These commands write local evidence under `traces/`, which is generated and ignored by git.
206
 
207
+ ## Training And Modal Eval
 
208
 
209
+ Modal scripts are included for the full train, merge, upload, and eval loop. They require Modal auth, appropriate secrets, and enough storage for model artifacts.
210
 
211
+ ```bash
212
+ .venv/bin/modal run modal/finetune_figment_nemotron.py --smoke true
213
+ .venv/bin/modal run modal/finetune_figment_nemotron.py
214
+ .venv/bin/modal run modal/eval_figment_nemotron.py
215
+ ```
216
 
217
+ The high-level loop is:
218
 
219
+ 1. generate or replay synthetic harness-shaped rows;
220
+ 2. verify rows against the real prompt, validators, retrieval, and expected-label rules;
221
+ 3. stage train/validation splits for Modal;
222
+ 4. train a LoRA adapter on H100;
223
+ 5. merge into BF16, convert to GGUF, and serve locally;
224
+ 6. rerun the field-workflow holdout;
225
+ 7. compare raw, repair, patch, fallback, expected-label, final-validation, and latency metrics.
 
 
 
 
 
226
 
227
+ ## Repository Layout
228
 
229
  ```text
230
+ app.py Gradio Server app and API surface
231
+ figment/ config, schemas, rules, retrieval, prompt, model clients,
232
+ navigator, validators, repair, provenance, traces
233
+ data/protocol_cards/ 10 prototype protocol cards
234
+ data/eval/ hosted and field-workflow eval cases plus manifests
235
+ data/demo_audio/ synthetic dictated-intake demo clips
236
+ scripts/ eval, smoke, evidence, generation, merge, and claim audit helpers
237
+ modal/ Modal training, merge, upload, and H100 eval entrypoints
238
+ tests/ regression tests for runtime, safety, eval, data plans, and gates
239
+ docs/ plans, evidence notes, safety, submission, and public drafts
 
240
  ```
241
 
242
+ Generated or heavyweight paths such as `traces/`, `data/finetune/`, `tools/`, checkpoints, weights, and local artifacts are intentionally ignored. Use the public Hub archives for shareable model, trace, and corpus artifacts.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ ## Data Handling
245
 
246
+ - Demo and eval scenarios are synthetic or de-identified.
247
+ - Do not enter real PHI into the hosted demo.
248
+ - Hosted mode may send synthetic or de-identified text/audio to the configured hosted endpoint.
249
+ - Local mode is intended to keep runtime inputs on the local machine.
250
+ - Figment traces do not retain raw audio bytes, uploaded filenames, local secrets, or unnecessary identifying details.
251
 
252
+ ## Safety And Non-Goals
 
 
253
 
254
+ Figment will not:
255
 
256
+ - diagnose a condition as fact;
257
+ - prescribe medication or provide doses beyond cited protocol-card content;
258
+ - replace clinician, supervisor, or trained responder judgment;
259
+ - hide fallback, deterministic patches, or model repair;
260
+ - use unconfirmed audio fields for final navigation;
261
+ - present local/off-grid, local ASR, target-user, or final submission claims without the corresponding evidence gate.
 
262
 
263
+ See [docs/safety_statement.md](docs/safety_statement.md) for the fuller intended-use and non-goal statement.
 
 
264
 
265
+ ## License
266
 
267
  | Artifact | License |
268
+ | --- | --- |
 
 
269
  | Code | [Apache-2.0](LICENSE) |
270
+ | Synthetic/de-identified dataset artifacts | CC-BY-4.0 where published |
271
+ | Model artifacts | NVIDIA Nemotron Open Model License inherited from `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  ## Acknowledgements
274
 
275
+ Figment was built for the [Build Small Hackathon](docs/build-small-hackathon-org-card.md), hosted by Gradio and Hugging Face, with NVIDIA and Modal central to the model and training loop. It also depends on Gradio Server, Hugging Face Hub, Modal, llama.cpp-compatible serving, and the small-model debugging discipline made visible by the eval traces.
 
 
 
 
 
 
 
 
 
 
 
docs/adversarial-review-action-items.md CHANGED
@@ -8,7 +8,7 @@ Important nuance: the current Omni-first architecture can technically support an
8
 
9
  ## 1. Make the Hugging Face Space actually runnable
10
 
11
- Current risk: the Space exists, but the public Space API showed `runtime.stage=NO_APP_FILE` and only `.gitattributes` plus `README.md` in the remote Space. That fails the required hosted Gradio artifact even if the local repo is healthy.
12
 
13
  Fix direction:
14
  - Push the full Gradio app, requirements, protocol cards, and demo assets to `build-small-hackathon/figment`.
@@ -16,9 +16,9 @@ Fix direction:
16
  - Align Space metadata and dependency versions with the local repo.
17
 
18
  Done when:
19
- - The public Space boots to the Figment Gradio UI.
20
- - The Space can run typed intake with canned fallback and trace labeling when secrets are absent.
21
- - Hosted Omni mode works when secrets are present, or failure is clearly labeled.
22
 
23
  ## 2. Make live model contribution visible
24
 
 
8
 
9
  ## 1. Make the Hugging Face Space actually runnable
10
 
11
+ Current status: the Space now cold-boots in no-secret canned-fallback mode. On 2026-06-07 the public Space API reported `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, and `app.py` present; the Space URL served HTTP 200. A public Gradio API smoke loaded typed intake, fired deterministic pediatric-dehydration escalation, retrieved protocol cards, and returned an honestly labeled `canned_backend` trace with `validation_status=passed` and `raw_audio_stored=false`.
12
 
13
  Fix direction:
14
  - Push the full Gradio app, requirements, protocol cards, and demo assets to `build-small-hackathon/figment`.
 
16
  - Align Space metadata and dependency versions with the local repo.
17
 
18
  Done when:
19
+ - [x] The public Space boots to the Figment Gradio UI.
20
+ - [x] The Space can run typed intake with canned fallback and trace labeling when secrets are absent.
21
+ - [ ] Hosted Omni mode works when secrets are present, or failure is clearly labeled in a final demo trace.
22
 
23
  ## 2. Make live model contribution visible
24
 
docs/figment-workback-plan.md CHANGED
@@ -12,7 +12,7 @@ The hackathon target:
12
 
13
  > Ship a polished Gradio Space by **June 15**, with a hosted Nemotron Omni-powered app, a local/offline Nemotron 3 Nano 4B + Parakeet path, an open synthetic dataset if time allows, demo traces, and a field-notes writeup. Fine-tuning should target the smaller 4B local model first; Omni fine-tuning is deferred unless the runtime demo, safety validation, local/offline proof, and 4B adapter story are already green.
14
 
15
- Architecture note: **Nemotron Omni remains the hosted v1 default and submission demo story**. The on-device/off-grid target is now **NVIDIA Nemotron 3 Nano 4B BF16** for text navigation and fine-tuning, paired with **Parakeet RNNT 1.1B** for offline ASR. The old 30B text-plus-Parakeet split is no longer the preferred local path.
16
 
17
  The Build Small Hackathon rules require models at or below **32B parameters**, a **Gradio app hosted as a Hugging Face Space**, plus a Space link, demo video, and social post for submission. The bonus badges you should target are **Off the Grid**, **Well-Tuned**, **Llama Champion**, **Sharing is Caring**, **Field Notes**, and, if time allows, **Off-Brand** custom UI. ([Hugging Face][1])
18
 
@@ -101,7 +101,7 @@ Draft on June 5, finalize June 14. Required elements:
101
 
102
  # 2. Hardware and runtime plan
103
 
104
- Hosted Omni is a clean demo story but a harder on-device runtime story. Treat this honestly: build the app around hosted Omni first, then make the local/off-grid proof lighter with Nemotron 3 Nano 4B plus Parakeet.
105
 
106
  Official Omni weight/runtime facts:
107
 
@@ -124,7 +124,7 @@ The 4B model is text-only, so it does not replace hosted Omni's native audio/mul
124
 
125
  Use:
126
 
127
- * **NVIDIA-Nemotron-3-Nano-4B-BF16** as the on-device text-navigation and fine-tuning target
128
  * **Parakeet RNNT 1.1B** as the offline ASR target
129
  * **16k context** for normal usage, despite the model card's much longer maximum context
130
  * **8k context** fallback if latency or memory gets weird
@@ -324,7 +324,7 @@ Deliberate scope boundaries, stated up front so judges and users know exactly wh
324
  * It will **not prescribe or dose medication** — drug doses appear only if a cited protocol card contains them.
325
  * It will **not replace a clinician** — it supports escalation and documentation; the trained responder remains the decision-maker.
326
  * It is **not for untrained users** — the intended user is a trained responder (see the safety statement in §1).
327
- * It does **not store PHI** — local/offline mode keeps patient inputs on-device, hosted demo mode uses synthetic/de-identified inputs only, and published traces never include raw audio (see §5).
328
  * It is **not autonomous** — every output is advisory and requires human judgment.
329
  * It will **not override deterministic danger signs** — red-flag rules set the minimum urgency floor.
330
  * It will **not invent protocol pathways, treatments, or referral criteria** beyond cited cards.
@@ -561,7 +561,7 @@ Offline ASR: Parakeet RNNT is CC-BY-4.0; include attribution if the local AS
561
 
562
  Data handling:
563
 
564
- * Local/offline mode processes patient inputs on-device.
565
  * Hosted Space mode may transmit text/audio inputs to the hosted Omni endpoint; use synthetic or de-identified demo inputs only in hosted mode.
566
  * Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
567
  * Audio demo clips are synthetic responder dictation only; do not use real patient audio.
 
12
 
13
  > Ship a polished Gradio Space by **June 15**, with a hosted Nemotron Omni-powered app, a local/offline Nemotron 3 Nano 4B + Parakeet path, an open synthetic dataset if time allows, demo traces, and a field-notes writeup. Fine-tuning should target the smaller 4B local model first; Omni fine-tuning is deferred unless the runtime demo, safety validation, local/offline proof, and 4B adapter story are already green.
14
 
15
+ Architecture note: **Nemotron Omni remains the hosted v1 default and submission demo story**. The local/off-grid target is now **NVIDIA Nemotron 3 Nano 4B BF16** for text navigation and fine-tuning, paired with **Parakeet RNNT 1.1B** for offline ASR. The old 30B text-plus-Parakeet split is no longer the preferred local path.
16
 
17
  The Build Small Hackathon rules require models at or below **32B parameters**, a **Gradio app hosted as a Hugging Face Space**, plus a Space link, demo video, and social post for submission. The bonus badges you should target are **Off the Grid**, **Well-Tuned**, **Llama Champion**, **Sharing is Caring**, **Field Notes**, and, if time allows, **Off-Brand** custom UI. ([Hugging Face][1])
18
 
 
101
 
102
  # 2. Hardware and runtime plan
103
 
104
+ Hosted Omni is a clean demo story but a harder local runtime story. Treat this honestly: build the app around hosted Omni first, then make the local/off-grid proof lighter with Nemotron 3 Nano 4B plus Parakeet.
105
 
106
  Official Omni weight/runtime facts:
107
 
 
124
 
125
  Use:
126
 
127
+ * **NVIDIA-Nemotron-3-Nano-4B-BF16** as the local text-navigation and fine-tuning target
128
  * **Parakeet RNNT 1.1B** as the offline ASR target
129
  * **16k context** for normal usage, despite the model card's much longer maximum context
130
  * **8k context** fallback if latency or memory gets weird
 
324
  * It will **not prescribe or dose medication** — drug doses appear only if a cited protocol card contains them.
325
  * It will **not replace a clinician** — it supports escalation and documentation; the trained responder remains the decision-maker.
326
  * It is **not for untrained users** — the intended user is a trained responder (see the safety statement in §1).
327
+ * It does **not store PHI** — local/offline mode keeps patient inputs on the local machine, hosted demo mode uses synthetic/de-identified inputs only, and published traces never include raw audio (see §5).
328
  * It is **not autonomous** — every output is advisory and requires human judgment.
329
  * It will **not override deterministic danger signs** — red-flag rules set the minimum urgency floor.
330
  * It will **not invent protocol pathways, treatments, or referral criteria** beyond cited cards.
 
561
 
562
  Data handling:
563
 
564
+ * Local/offline mode processes patient inputs locally.
565
  * Hosted Space mode may transmit text/audio inputs to the hosted Omni endpoint; use synthetic or de-identified demo inputs only in hosted mode.
566
  * Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
567
  * Audio demo clips are synthetic responder dictation only; do not use real patient audio.
docs/local_llama_eval_evidence.md CHANGED
@@ -4,9 +4,22 @@ Date: 2026-06-07
4
 
5
  This note separates local llama.cpp evidence from hosted Omni evidence and from canned deterministic fallback.
6
 
7
- ## Current endpoint check
8
 
9
- No local OpenAI-compatible llama.cpp endpoint was active during this Worker C pass.
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  Checked:
12
 
@@ -17,6 +30,43 @@ curl -fsS --max-time 2 http://127.0.0.1:8001/v1/models
17
 
18
  Observed: no listener on port `8001`; `curl` failed to connect to `127.0.0.1:8001`.
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ## One-case route smoke
21
 
22
  Use this only to prove the app can call the configured local route. A passed smoke is not a 50-case local competence run.
@@ -26,7 +76,7 @@ FIGMENT_MODE=local \
26
  MODEL_STACK=local_4b_parakeet \
27
  MODEL_BACKEND=llama_cpp \
28
  LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
29
- LLAMA_BASE_URL=http://127.0.0.1:8001/v1 \
30
  FIGMENT_SMOKE_ALLOW_NETWORK=true \
31
  PYTHON_DOTENV_DISABLED=true \
32
  python3 scripts/smoke_model_route.py
@@ -36,14 +86,14 @@ The smoke output now includes `local_llm_evidence.proof_status`, `counts_as_no_c
36
 
37
  ## Real 50-case eval command
38
 
39
- Run this only after starting a real local OpenAI-compatible llama.cpp server and recording `/v1/models` metadata.
40
 
41
  ```bash
42
  FIGMENT_MODE=local \
43
  MODEL_STACK=local_4b_parakeet \
44
  MODEL_BACKEND=llama_cpp \
45
  LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
46
- LLAMA_BASE_URL=http://127.0.0.1:8001/v1 \
47
  PYTHON_DOTENV_DISABLED=true \
48
  python3 scripts/run_eval.py \
49
  --backend llama_cpp \
@@ -54,4 +104,4 @@ python3 scripts/run_eval.py \
54
  --output traces/local_llama_cpp_eval_$(date -u +%Y%m%dT%H%M%SZ).jsonl
55
  ```
56
 
57
- A real local evidence bundle should include the eval JSONL trace, the printed summary, `/v1/models` response or equivalent server metadata, model file/hash or server launch command, and confirmation that no hosted model credentials were required for the run.
 
4
 
5
  This note separates local llama.cpp evidence from hosted Omni evidence and from canned deterministic fallback.
6
 
7
+ ## Full-weight local artifact
8
 
9
+ The canonical local text artifact is the full BF16 4B model, not a quantized GGUF:
10
+
11
+ - Model repo: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`
12
+ - Revision: `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`
13
+ - Local snapshot path: `/Users/drake.thomsen/.cache/huggingface/hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`
14
+ - Resolved snapshot size: `7.4G`
15
+ - Full weight file: `model.safetensors`
16
+ - Weight file size: `7947142640` bytes
17
+ - Weight file SHA-256: `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`
18
+ - Required companion files present: `config.json`, `tokenizer.json`, `tokenizer_config.json`, `chat_template.jinja`, `modeling_nemotron_h.py`, `configuration_nemotron_h.py`
19
+
20
+ This download proves local artifact availability only. It does not prove local model competence, Off the Grid, Llama Champion, or local ASR until a real local OpenAI-compatible endpoint runs through the Figment eval without full deterministic fallback.
21
+
22
+ ## Endpoint checks
23
 
24
  Checked:
25
 
 
30
 
31
  Observed: no listener on port `8001`; `curl` failed to connect to `127.0.0.1:8001`.
32
 
33
+ Rechecked after downloading the full BF16 artifact on 2026-06-07:
34
+
35
+ ```bash
36
+ curl -fsS --max-time 2 http://127.0.0.1:8001/v1/models
37
+ ```
38
+
39
+ Observed:
40
+
41
+ - `curl: (7) Failed to connect to 127.0.0.1 port 8001`
42
+
43
+ Local 4B, Off the Grid, and Llama Champion claims remain proof-needed. The next real step is to run the full BF16 model on a local OpenAI-compatible endpoint, record `/v1/models`, then run the 50-case eval below.
44
+
45
+ ## Discarded quantized side check
46
+
47
+ A mistaken Q4 GGUF smoke was attempted before the full-weight requirement was clarified. It proved only that Figment could reach a local OpenAI-compatible endpoint; the model output failed navigator validation and fell back deterministically, so it does not count as route proof or model competence. The Q4 cache created by that attempt was removed.
48
+
49
+ ## Evidence bundle command
50
+
51
+ Prefer the bundled helper once the local endpoint is live:
52
+
53
+ ```bash
54
+ PYTHON_DOTENV_DISABLED=true \
55
+ python3 scripts/run_local_4b_evidence.py \
56
+ --base-url <local-openai-compatible-endpoint>/v1
57
+ ```
58
+
59
+ The helper writes a timestamped evidence directory under `traces/local_4b_evidence_*`:
60
+
61
+ - `endpoint_metadata.json`: `/v1/models` response or connection error
62
+ - `route_smoke.json`: one-case configured-route smoke result
63
+ - `local_4b_eval.jsonl`: 50-case eval records, only after the route smoke proves configured-model validation unless `--force-eval` is used
64
+ - `eval_summary.json`: model competence, fallback, provenance, and final validation counts
65
+ - `eval_evidence_manifest.json`: compact evidence manifest with model/server metadata, no-cloud route flags, raw/repair/full-fallback counts, field provenance, latency summary, eval-file hash, and per-case trace hashes
66
+ - `summary.json`: top-level status and whether the evidence counts as route proof or 50-case local competence
67
+
68
+ Exit codes are intentionally evidence-gated: `0` for a completed eval or passed smoke-only run, `2` for endpoint unavailable, and `1` when the route smoke fails and eval is skipped. Endpoint availability alone must not be counted as model competence.
69
+
70
  ## One-case route smoke
71
 
72
  Use this only to prove the app can call the configured local route. A passed smoke is not a 50-case local competence run.
 
76
  MODEL_STACK=local_4b_parakeet \
77
  MODEL_BACKEND=llama_cpp \
78
  LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
79
+ LLAMA_BASE_URL=<local-openai-compatible-endpoint>/v1 \
80
  FIGMENT_SMOKE_ALLOW_NETWORK=true \
81
  PYTHON_DOTENV_DISABLED=true \
82
  python3 scripts/smoke_model_route.py
 
86
 
87
  ## Real 50-case eval command
88
 
89
+ Run this directly only after starting a real local OpenAI-compatible server and recording `/v1/models` metadata. The bundled helper above runs the same eval after a passing route smoke.
90
 
91
  ```bash
92
  FIGMENT_MODE=local \
93
  MODEL_STACK=local_4b_parakeet \
94
  MODEL_BACKEND=llama_cpp \
95
  LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
96
+ LLAMA_BASE_URL=<local-openai-compatible-endpoint>/v1 \
97
  PYTHON_DOTENV_DISABLED=true \
98
  python3 scripts/run_eval.py \
99
  --backend llama_cpp \
 
104
  --output traces/local_llama_cpp_eval_$(date -u +%Y%m%dT%H%M%SZ).jsonl
105
  ```
106
 
107
+ A real local evidence bundle should include the eval JSONL trace, `eval_evidence_manifest.json`, `/v1/models` response or equivalent server metadata, model file/hash or server launch command, and confirmation that no hosted model credentials were required for the run.
docs/model_parameter_evidence_ledger.md CHANGED
@@ -6,9 +6,9 @@ Purpose: keep parameter, route, locality, adapter, ASR, and organizer-confirmati
6
 
7
  ## Current Claim Boundary
8
 
9
- - Hosted Omni has measured eval evidence, but not public Space cold-boot evidence.
10
- - The public Space target exists, but the last known public API state was `runtime.stage=NO_APP_FILE` with only metadata files present. Do not call the public Space runnable until it is verified from the Space URL.
11
- - The local 4B + Parakeet route is the preferred no-cloud/off-grid proof path, but it is not yet proven with a real local 50-case eval or local ASR smoke.
12
  - No published Figment adapter is recorded yet. Well-Tuned remains a stretch claim until a published fine-tuned model or adapter is used by the app and measured.
13
  - Organizer confirmation is still needed for the Omni 31B body-count versus 33B sidebar ambiguity and for any additive local stack or adapter-count interpretation.
14
 
@@ -18,15 +18,15 @@ Purpose: keep parameter, route, locality, adapter, ASR, and organizer-confirmati
18
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
19
  | Hosted Omni primary | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`; API route `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA model-card body: 31B total parameters; HF sidebar has been observed as 33B in planning docs | Roughly 3B active parameters per token is a runtime/MoE note, not the compliance number | None used in current evals | Native Omni speech encoder is part of the Omni model-card count; no separate ASR model is claimed for hosted Omni | Hosted NVIDIA API route in current evals; self-hosted no-cloud route not recorded | Pending: ask organizers whether model-card body count is acceptable if sidebar count differs | Baseline eval: 28/50 whole-output competence, 22/50 full fallback, 50/50 final validation. Follow-up eval: 31/50 competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation |
20
  | Self-hosted Omni no-cloud target | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`, FP8, or NVFP4 variant if served locally | Same Omni 31B body-count claim, with same 33B sidebar ambiguity | Active parameters do not decide compliance | None recorded | Native Omni audio if used locally; included in Omni count if organizers accept the model-card count | Would be local/self-hosted only if served with no runtime cloud APIs | Pending for count ambiguity and hardware/runtime proof | No recorded no-cloud eval or public demo trace yet. Do not claim Off the Grid achieved |
21
- | Local 4B + Parakeet proof path | Text: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`; ASR: `nvidia/parakeet-rnnt-1.1b`; local route `MODEL_BACKEND=llama_cpp`, `MODEL_STACK=local_4b_parakeet` | Workback model-card notes: 3.97B text model plus about 1.1B Parakeet, roughly 5.1B nominal before adapters | No active-parameter substitution; use additive total-count story if organizers require stack accounting | None recorded yet; exact adapter count must be measured before any Well-Tuned or compliance upgrade | About 1.1B for Parakeet RNNT ASR, only if real local ASR is enabled and proven | Intended local OpenAI-compatible endpoint on `127.0.0.1` plus local ASR; no-cloud only after recorded proof | Pending: confirm additive multi-model counting and adapter counting | Configured/labeled path only. No real local 50-case eval, no local ASR smoke, and no trace hash recorded yet |
22
  | 4B Figment adapter stretch | Planned adapter name: `nvidia-nemotron-3-nano-4b-figment-lora-v1` | Base model count is 3.97B; adapter count must be added or documented per organizer guidance | Not applicable | Pending. Record exact trainable and published adapter parameter count before claiming | Parakeet count applies only if adapter demo also uses local ASR | Local route or published HF model route, depending on final artifact | Pending for adapter accounting and Well-Tuned eligibility | Not trained, published, or measured in this ledger |
23
- | Canned fallback | No live model | Not a model-compliance artifact | Not applicable | Not applicable | Not applicable | Local deterministic fallback | Not applicable | Useful for safety and cold-start fallback only. Cannot count as model competence, Off the Grid proof, Llama Champion proof, or Well-Tuned proof |
24
 
25
  ## Submission Gates
26
 
27
  | Claim | Required upgrade evidence |
28
  | --- | --- |
29
- | Public Space runnable | Public Space app files present, clean cold boot from the Space URL, typed intake run, and trace showing actual route/fallback status |
30
  | Hosted model load-bearing | Cite hosted eval metrics separately from final validation: 31/50 whole-output competence and 480/650 model-retained fields in the follow-up run |
31
  | <=32B hosted Omni compliance | Organizer accepts the 31B model-card body count or the submission falls back to a clearly eligible smaller route |
32
  | Off the Grid | Recorded no-cloud run with trace evidence, either self-hosted Omni or local 4B + Parakeet/typed intake |
 
6
 
7
  ## Current Claim Boundary
8
 
9
+ - Hosted Omni has measured eval evidence through the eval harness. The public Space is now verified runnable in no-secret canned-fallback mode, but that public Space proof is not live hosted Omni generation evidence.
10
+ - The public Space target exists and cold-boots from the Space URL. Current public API evidence: `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, and `app.py` present. Public workflow evidence: typed intake, deterministic pediatric-dehydration escalation, protocol retrieval, `raw_route=canned`, `final_route=canned_backend`, `validation_status=passed`, `raw_audio_stored=false`, and zero model-retained fields.
11
+ - The local 4B + Parakeet route is the preferred no-cloud/off-grid proof path. The full BF16 4B artifact is now downloaded locally, but the route is not yet proven with a real local 50-case eval or local ASR smoke.
12
  - No published Figment adapter is recorded yet. Well-Tuned remains a stretch claim until a published fine-tuned model or adapter is used by the app and measured.
13
  - Organizer confirmation is still needed for the Omni 31B body-count versus 33B sidebar ambiguity and for any additive local stack or adapter-count interpretation.
14
 
 
18
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
19
  | Hosted Omni primary | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`; API route `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA model-card body: 31B total parameters; HF sidebar has been observed as 33B in planning docs | Roughly 3B active parameters per token is a runtime/MoE note, not the compliance number | None used in current evals | Native Omni speech encoder is part of the Omni model-card count; no separate ASR model is claimed for hosted Omni | Hosted NVIDIA API route in current evals; self-hosted no-cloud route not recorded | Pending: ask organizers whether model-card body count is acceptable if sidebar count differs | Baseline eval: 28/50 whole-output competence, 22/50 full fallback, 50/50 final validation. Follow-up eval: 31/50 competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation |
20
  | Self-hosted Omni no-cloud target | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`, FP8, or NVFP4 variant if served locally | Same Omni 31B body-count claim, with same 33B sidebar ambiguity | Active parameters do not decide compliance | None recorded | Native Omni audio if used locally; included in Omni count if organizers accept the model-card count | Would be local/self-hosted only if served with no runtime cloud APIs | Pending for count ambiguity and hardware/runtime proof | No recorded no-cloud eval or public demo trace yet. Do not claim Off the Grid achieved |
21
+ | Local 4B + Parakeet proof path | Text: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`; ASR: `nvidia/parakeet-rnnt-1.1b`; local route `MODEL_BACKEND=llama_cpp`, `MODEL_STACK=local_4b_parakeet` | Workback model-card notes: 3.97B text model plus about 1.1B Parakeet, roughly 5.1B nominal before adapters | No active-parameter substitution; use additive total-count story if organizers require stack accounting | None recorded yet; exact adapter count must be measured before any Well-Tuned or compliance upgrade | About 1.1B for Parakeet RNNT ASR, only if real local ASR is enabled and proven | Intended local OpenAI-compatible endpoint serving the full BF16 model plus local ASR; no-cloud only after recorded proof | Pending: confirm additive multi-model counting and adapter counting | Full BF16 4B snapshot downloaded at repo revision `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`; `model.safetensors` is `7947142640` bytes with SHA-256 `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`. Parakeet artifact downloaded at repo revision `a07b19e98a26c1873a3f2622c446a4a1ca6316cb`; `parakeet-rnnt-1.1b.nemo` is `4283105280` bytes with SHA-256 `535896f014953d945b287ac533560e20da8103c6781b152de4645528e2b60738`. No real local 50-case eval, no local ASR provider proof, and no trace hash recorded yet |
22
  | 4B Figment adapter stretch | Planned adapter name: `nvidia-nemotron-3-nano-4b-figment-lora-v1` | Base model count is 3.97B; adapter count must be added or documented per organizer guidance | Not applicable | Pending. Record exact trainable and published adapter parameter count before claiming | Parakeet count applies only if adapter demo also uses local ASR | Local route or published HF model route, depending on final artifact | Pending for adapter accounting and Well-Tuned eligibility | Not trained, published, or measured in this ledger |
23
+ | Canned fallback | No live model | Not a model-compliance artifact | Not applicable | Not applicable | Not applicable | Local deterministic fallback; public Space no-secret fallback verified | Not applicable | Useful for safety, deployment health, and cold-start fallback only. Cannot count as model competence, Off the Grid proof, Llama Champion proof, or Well-Tuned proof |
24
 
25
  ## Submission Gates
26
 
27
  | Claim | Required upgrade evidence |
28
  | --- | --- |
29
+ | Public Space runnable | Satisfied for no-secret canned-fallback mode at Space commit `5dcfc5c830de7331eca9020b17e1c571a8619654`: app files present, clean cold boot from the Space URL, typed intake run, and trace showing actual route/fallback status |
30
  | Hosted model load-bearing | Cite hosted eval metrics separately from final validation: 31/50 whole-output competence and 480/650 model-retained fields in the follow-up run |
31
  | <=32B hosted Omni compliance | Organizer accepts the 31B model-card body count or the submission falls back to a clearly eligible smaller route |
32
  | Off the Grid | Recorded no-cloud run with trace evidence, either self-hosted Omni or local 4B + Parakeet/typed intake |
docs/safety_statement.md CHANGED
@@ -26,7 +26,7 @@ The trained responder remains responsible for judgment and action. Figment outpu
26
 
27
  The submission should use synthetic or de-identified scenarios only. Do not enter real PHI into the hosted demo.
28
 
29
- Local mode is intended to keep runtime inputs on-device. Hosted mode may send synthetic or de-identified text or audio to the configured Omni endpoint. Traces should not retain raw audio, uploaded filenames, or unnecessary identifying details.
30
 
31
  ## Off-Grid Claim Boundary
32
 
 
26
 
27
  The submission should use synthetic or de-identified scenarios only. Do not enter real PHI into the hosted demo.
28
 
29
+ Local mode is intended to keep runtime inputs on the local machine. Hosted mode may send synthetic or de-identified text or audio to the configured Omni endpoint. Traces should not retain raw audio, uploaded filenames, or unnecessary identifying details.
30
 
31
  ## Off-Grid Claim Boundary
32
 
docs/smaller-llm-load-bearing-action-plan.md CHANGED
@@ -15,95 +15,105 @@ This note captures the adversarial review items from the Build Small Hackathon r
15
 
16
  ## Current Evidence Snapshot
17
 
18
- - Local tests passed before this note was written: `python3 -m pytest -q` reported `105 passed`.
19
  - Hosted Omni baseline eval: `28/50` whole-output competence, `22/50` full deterministic fallback, `50/50` final validation.
20
  - Hosted Omni load-bearing follow-up eval: `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, `50/50` final validation.
21
- - Public Space API still reported `runtime.stage=NO_APP_FILE` with only `.gitattributes` and `README.md`.
22
- - Local 4B and Parakeet paths are configured or labeled, but not yet proven with real no-cloud eval or ASR evidence.
 
 
 
 
23
 
24
  ## Work Items
25
 
26
  ### 1. Make the public Hugging Face Space runnable
27
 
28
- - [ ] Push the full Gradio app, requirements, protocol cards, demo assets, and README to `build-small-hackathon/figment`.
29
- - [ ] Verify a public cold boot from the Space URL, not only local startup.
30
- - [ ] Record Space evidence in `docs/submission_checklist.md`.
31
- - [ ] Ensure no-secret mode cold-boots with typed intake, honest canned fallback, and trace labeling.
32
 
33
  ### 2. Add a parameter and evidence ledger
34
 
35
- - [ ] Create a compact ledger with model ID, route, total-parameter source, active-parameter note, adapter parameter count, ASR companion count, endpoint locality, and organizer-confirmation status.
36
- - [ ] Explicitly mark active parameters as not the compliance number.
37
- - [ ] Include the Omni 31B body-count versus 33B sidebar ambiguity.
38
- - [ ] Include the local 4B + Parakeet additive story and adapter headroom.
39
 
40
  ### 3. Prove the smaller local LLM path separately
41
 
 
 
42
  - [ ] Run the 50-case eval through `MODEL_BACKEND=llama_cpp` against a real local OpenAI-compatible endpoint.
43
- - [ ] Record model/server metadata, no-cloud evidence, raw success, repair success, full fallback, field provenance, latency, and trace hashes.
44
- - [ ] Add a run note that distinguishes local 4B evidence from hosted Omni evidence.
45
- - [ ] Ensure whole fallback cannot count as local model competence.
46
 
47
  ### 4. Publish model scorecards separate from app safety
48
 
49
- - [ ] Treat `final_validation_successes` as app safety, not model competence.
50
- - [ ] Publish raw-only, repaired, hybrid, full-fallback, and per-field scorecards.
51
- - [ ] Update stale README and checklist language that still says no full eval has run.
52
- - [ ] Highlight remaining weak areas, especially SBAR and latency.
53
 
54
  ### 5. Align live app validation with strict eval validation
55
 
56
- - [ ] Make runtime navigation use strict schema checks, retrieved-card constraints, and retrieved-card observation grounding.
57
- - [ ] If runtime and eval validation intentionally differ, trace the mode explicitly.
58
- - [ ] Prevent sparse raw model output from being labeled fully `model_raw` when it would fail strict eval.
59
 
60
  ### 6. Add hybrid route labeling and field provenance UI
61
 
62
- - [ ] Add a distinct route for outputs with deterministic patches, such as `model_with_deterministic_patches`.
63
- - [ ] Derive runtime labels from trace state rather than configured backend.
64
- - [ ] Show per-field or per-section provenance counts in Navigator Output and Trace.
65
- - [ ] Rename config-derived labels like "Hosted Omni (live)" to "Configured backend" unless the post-run trace proves live generation.
66
 
67
  ### 7. Cap or batch repair calls for smaller models
68
 
69
- - [ ] Cap focused repair attempts.
70
- - [ ] Prefer one compact JSON-only repair call when multiple scopes fail.
71
- - [ ] Add metrics for repair call count and repair latency.
72
- - [ ] Keep deterministic fallback for fields that still fail after the cap.
73
 
74
  ### 8. Score expected eval labels directly
75
 
76
- - [ ] Compare actual red flags to `expected_red_flag_rule_ids`.
77
- - [ ] Compare actual urgency to `expected_min_protocol_urgency`.
78
- - [ ] Compare source cards and candidate pathways to target or expected card IDs.
79
- - [ ] Compare missing observations to expected missing-observation cues.
80
- - [ ] Check case-level forbidden behavior explicitly.
81
 
82
  ### 9. Treat Parakeet as unproven until real local ASR exists
83
 
84
- - [ ] Add `transcript_source` or equivalent provenance to audio drafts.
85
- - [ ] Keep typed transcript heuristics labeled as typed or heuristic, not Parakeet ASR.
86
- - [ ] Only emit Parakeet provenance from a real gated ASR adapter or smoke.
 
 
87
  - [ ] Add a real local ASR proof note before making Parakeet demo-visible.
88
 
89
  ### 10. Harden audio confirmation UX
90
 
91
- - [ ] Prevent "Apply Audio Draft" from becoming quiet bulk acceptance.
92
- - [ ] Preserve applied draft fields as `applied_unreviewed` until the responder explicitly accepts, edits, or rejects each suggestion.
93
- - [ ] Block navigation while any applied audio-derived field is still unreviewed.
94
- - [ ] Keep manual edits winning over audio drafts.
95
 
96
  ### 11. Fix demo audio and hosted audio disclosure
97
 
98
- - [ ] Derive canned/demo transcript text from one source of truth so ages and case facts stay aligned.
99
- - [ ] Label committed Voxtral clips as synthetic demo assets, not local ASR proof.
100
- - [ ] Add hosted-mode UI copy that audio is sent to the configured hosted endpoint and must be synthetic or de-identified.
101
- - [ ] Add size and duration caps for hosted audio drafts.
102
 
103
  ### 12. Keep badge and submission copy evidence-gated
104
 
105
- - [ ] Keep Off the Grid, Llama Champion, Well-Tuned, Backyard AI user-use, demo video, and social post claims conditional until artifacts exist.
106
- - [ ] Replace overclaiming workback/social snippets with achieved-versus-targeted wording.
 
 
107
  - [ ] Fill user-test notes from a real trained-responder session before claiming the target user used or tested Figment.
108
 
109
  ## Parallel Work Map
 
15
 
16
  ## Current Evidence Snapshot
17
 
18
+ - Local tests passed after the fanout/integration pass, evidence-manifest updates, claim-audit guard, and evidence-gate report: `python3 -m pytest -q` reported `133 passed`.
19
  - Hosted Omni baseline eval: `28/50` whole-output competence, `22/50` full deterministic fallback, `50/50` final validation.
20
  - Hosted Omni load-bearing follow-up eval: `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, `50/50` final validation.
21
+ - Public Space cold boot is now verified in no-secret canned-fallback mode at Space commit `5dcfc5c830de7331eca9020b17e1c571a8619654`: Space API `runtime.stage=RUNNING`, `app.py` present, HTTP 200, typed intake workflow, deterministic pediatric-dehydration escalation, protocol retrieval, and trace route `raw_route=canned` / `final_route=canned_backend` with `validation_status=passed` and `raw_audio_stored=false`.
22
+ - The full-weight local 4B text artifact is now downloaded: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` at commit `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`, with `model.safetensors` size `7947142640` bytes and SHA-256 `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`. Local 4B and Parakeet paths are still not proven with a real no-cloud 50-case eval or local ASR evidence.
23
+ - Local evidence capture is now scripted via `scripts/run_local_4b_evidence.py`; it records endpoint metadata, route smoke, eval records, eval summary, `eval_evidence_manifest.json`, and gated proof flags.
24
+ - The local Parakeet ASR artifact is now downloaded: `nvidia/parakeet-rnnt-1.1b` at commit `a07b19e98a26c1873a3f2622c446a4a1ca6316cb`, with `parakeet-rnnt-1.1b.nemo` size `4283105280` bytes and SHA-256 `535896f014953d945b287ac533560e20da8103c6781b152de4645528e2b60738`. ASR evidence capture is scripted via `scripts/run_local_asr_evidence.py`, including `asr_evidence_manifest.json`, but no real local ASR provider payload has passed yet.
25
+ - Submission-facing claim drift is now audited by `scripts/audit_submission_claims.py` / `make audit-claims`; the current audit passes while keeping Off the Grid, Llama Champion, Well-Tuned, Backyard user-use, local 4B competence, local ASR, demo video, and social post gates false.
26
+ - Evidence-gate status is now reportable via `scripts/evidence_gate_status.py` / `make evidence-gates`; it currently marks public Space, hosted Omni eval, and claim audit as present, and keeps local 4B 50-case eval, no-cloud route, Llama Champion route, local ASR provider proof, trained-responder user test, demo video, social post, and Well-Tuned adapter incomplete.
27
 
28
  ## Work Items
29
 
30
  ### 1. Make the public Hugging Face Space runnable
31
 
32
+ - [x] Push the full Gradio app, requirements, protocol cards, demo assets, and README to `build-small-hackathon/figment`.
33
+ - [x] Verify a public cold boot from the Space URL, not only local startup.
34
+ - [x] Record Space evidence in `docs/submission_checklist.md`.
35
+ - [x] Ensure no-secret mode cold-boots with typed intake, honest canned fallback, and trace labeling.
36
 
37
  ### 2. Add a parameter and evidence ledger
38
 
39
+ - [x] Create a compact ledger with model ID, route, total-parameter source, active-parameter note, adapter parameter count, ASR companion count, endpoint locality, and organizer-confirmation status.
40
+ - [x] Explicitly mark active parameters as not the compliance number.
41
+ - [x] Include the Omni 31B body-count versus 33B sidebar ambiguity.
42
+ - [x] Include the local 4B + Parakeet additive story and adapter headroom.
43
 
44
  ### 3. Prove the smaller local LLM path separately
45
 
46
+ - [x] Download the full-weight `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` artifact for the local route.
47
+ - [x] Add an evidence-bundle helper for the local endpoint.
48
  - [ ] Run the 50-case eval through `MODEL_BACKEND=llama_cpp` against a real local OpenAI-compatible endpoint.
49
+ - [ ] Record model/server metadata, no-cloud evidence, raw success, repair success, full fallback, field provenance, latency, and trace hashes. The helper now generates `eval_evidence_manifest.json` with these fields after a completed local eval; the item remains unchecked until a real local run exists.
50
+ - [x] Add a run note that distinguishes local 4B evidence from hosted Omni evidence.
51
+ - [x] Ensure whole fallback cannot count as local model competence.
52
 
53
  ### 4. Publish model scorecards separate from app safety
54
 
55
+ - [x] Treat `final_validation_successes` as app safety, not model competence.
56
+ - [x] Publish raw-only, repaired, hybrid, full-fallback, and per-field scorecards.
57
+ - [x] Update stale README and checklist language that still says no full eval has run.
58
+ - [x] Highlight remaining weak areas, especially SBAR and latency.
59
 
60
  ### 5. Align live app validation with strict eval validation
61
 
62
+ - [x] Make runtime navigation use strict schema checks, retrieved-card constraints, and retrieved-card observation grounding.
63
+ - [x] If runtime and eval validation intentionally differ, trace the mode explicitly.
64
+ - [x] Prevent sparse raw model output from being labeled fully `model_raw` when it would fail strict eval.
65
 
66
  ### 6. Add hybrid route labeling and field provenance UI
67
 
68
+ - [x] Add a distinct route for outputs with deterministic patches, such as `model_with_deterministic_patches`.
69
+ - [x] Derive runtime labels from trace state rather than configured backend.
70
+ - [x] Show per-field or per-section provenance counts in Navigator Output and Trace.
71
+ - [x] Rename config-derived labels like "Hosted Omni (live)" to "Configured backend" unless the post-run trace proves live generation.
72
 
73
  ### 7. Cap or batch repair calls for smaller models
74
 
75
+ - [x] Cap focused repair attempts.
76
+ - [x] Prefer one compact JSON-only repair call when multiple scopes fail.
77
+ - [x] Add metrics for repair call count and repair latency.
78
+ - [x] Keep deterministic fallback for fields that still fail after the cap.
79
 
80
  ### 8. Score expected eval labels directly
81
 
82
+ - [x] Compare actual red flags to `expected_red_flag_rule_ids`.
83
+ - [x] Compare actual urgency to `expected_min_protocol_urgency`.
84
+ - [x] Compare source cards and candidate pathways to target or expected card IDs.
85
+ - [x] Compare missing observations to expected missing-observation cues.
86
+ - [x] Check case-level forbidden behavior explicitly.
87
 
88
  ### 9. Treat Parakeet as unproven until real local ASR exists
89
 
90
+ - [x] Add `transcript_source` or equivalent provenance to audio drafts.
91
+ - [x] Keep typed transcript heuristics labeled as typed or heuristic, not Parakeet ASR.
92
+ - [x] Only emit Parakeet provenance from a real gated ASR adapter or smoke.
93
+ - [x] Download the `nvidia/parakeet-rnnt-1.1b` artifact and add a gated evidence helper.
94
+ - [x] Add an ASR evidence manifest for artifact, provider-payload, draft-check, route, and raw-audio proof fields.
95
  - [ ] Add a real local ASR proof note before making Parakeet demo-visible.
96
 
97
  ### 10. Harden audio confirmation UX
98
 
99
+ - [x] Prevent "Apply Audio Draft" from becoming quiet bulk acceptance.
100
+ - [x] Preserve applied draft fields as `applied_unreviewed` until the responder explicitly accepts, edits, or rejects each suggestion.
101
+ - [x] Block navigation while any applied audio-derived field is still unreviewed.
102
+ - [x] Keep manual edits winning over audio drafts.
103
 
104
  ### 11. Fix demo audio and hosted audio disclosure
105
 
106
+ - [x] Derive canned/demo transcript text from one source of truth so ages and case facts stay aligned.
107
+ - [x] Label committed Voxtral clips as synthetic demo assets, not local ASR proof.
108
+ - [x] Add hosted-mode UI copy that audio is sent to the configured hosted endpoint and must be synthetic or de-identified.
109
+ - [x] Add size and duration caps for hosted audio drafts.
110
 
111
  ### 12. Keep badge and submission copy evidence-gated
112
 
113
+ - [x] Keep Off the Grid, Llama Champion, Well-Tuned, Backyard AI user-use, demo video, and social post claims conditional until artifacts exist.
114
+ - [x] Replace overclaiming workback/social snippets with achieved-versus-targeted wording.
115
+ - [x] Add an automated submission-copy audit for premature achieved/proven/used/tested wording.
116
+ - [x] Add an evidence-gate status report that lists current proof paths and next actions.
117
  - [ ] Fill user-test notes from a real trained-responder session before claiming the target user used or tested Figment.
118
 
119
  ## Parallel Work Map
docs/submission_checklist.md CHANGED
@@ -8,8 +8,8 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
8
 
9
  | Artifact | Status | Link / evidence |
10
  | -------- | ------ | --------------- |
11
- | Public Hugging Face Space | Target exists; runnable proof needed | https://huggingface.co/spaces/build-small-hackathon/figment |
12
- | Space cold boot with app files present | Not verified | Last known public API state: `runtime.stage=NO_APP_FILE` with only metadata files present |
13
  | Demo video | Proof needed | Pending |
14
  | Social post | Proof needed | Pending |
15
  | Safety statement | Present | [safety_statement.md](safety_statement.md) |
@@ -18,8 +18,10 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
18
  | Live hosted Omni trace | Eval traces present; final demo trace still needed | Baseline: `traces/hosted_omni_eval_20260607T194833Z.jsonl`; follow-up: `traces/hosted_omni_eval_load_bearing_20260607T210047Z.jsonl` |
19
  | No-cloud/off-grid trace | Proof needed before claiming Off the Grid achieved | Pending |
20
  | Hosted Omni eval results | Measured | [hosted_omni_eval_results.md](hosted_omni_eval_results.md): 31/50 whole-output competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation in the follow-up run |
21
- | Local 4B + Parakeet eval results | Proof needed | Pending local no-cloud 50-case eval and ASR proof |
22
  | Parameter/evidence ledger | Present; organizer confirmation pending | [model_parameter_evidence_ledger.md](model_parameter_evidence_ledger.md) |
 
 
23
 
24
  ## Badge And Claim Status
25
 
@@ -27,7 +29,7 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
27
  | ------------------ | ------------------------------ | -------------------------- |
28
  | Backyard AI | Targeted; built for a real trained responder, with identity withheld for privacy | Completed user-test notes from that responder on synthetic or de-identified scenarios. Do not claim use, testing, validation, approval, or endorsement before notes exist |
29
  | Off the Grid | Targeted / proof-needed | Recorded no-cloud run using self-hosted Omni on adequate local hardware or a smaller verified local stack. Hosted API evidence does not count |
30
- | Hosted Gradio Space | Targeted / proof-needed; not claimed runnable | Public Space app files present, cold boot, typed intake run, and route/fallback trace. Last known state is `NO_APP_FILE` |
31
  | Demo video | Targeted / proof-needed | Final video link showing only verified routes and labeling fallbacks honestly |
32
  | Social post | Targeted / proof-needed | Final social post link with achieved-versus-targeted wording |
33
  | Llama Champion | Targeted / proof-needed | Eligible local model route through llama.cpp with trace or eval evidence |
@@ -46,16 +48,20 @@ The README may say the project is built for a real trained responder. It should
46
 
47
  ## Eval Evidence Boundary
48
 
49
- The hosted Omni eval proves model and fallback behavior through the eval harness, not a public Space cold boot. Use the follow-up run as the current hosted eval score: 31/50 whole-output hosted competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, and 50/50 final validation.
50
 
51
  Final validation is app safety. Whole-output model competence and field-level model retention are the model-load-bearing metrics. Deterministic fallback and deterministic patches must stay visible in traces, scorecards, submission copy, and the demo.
52
 
53
  ## Submission Copy Boundaries
54
 
55
- - Space: may say the target Space exists; do not say it is runnable until app files, cold boot, typed intake, and trace labeling are verified from the public Space URL.
56
  - Demo video and social post: use pending placeholders until final links exist.
57
  - Backyard AI: may say built for a real trained responder; do not say the target user used or tested Figment until factual notes exist.
58
  - Off the Grid: claim only after a recorded no-cloud run.
59
  - Llama Champion: claim only after an eligible llama.cpp route runs with trace or eval evidence.
60
  - Well-Tuned: claim only after a published fine-tuned model or adapter is used by the app and measured.
61
  - Parameter compliance: cite the [model parameter/evidence ledger](model_parameter_evidence_ledger.md), including the Omni 31B body-count versus 33B sidebar ambiguity and organizer-confirmation status.
 
 
 
 
 
8
 
9
  | Artifact | Status | Link / evidence |
10
  | -------- | ------ | --------------- |
11
+ | Public Hugging Face Space | Runnable in no-secret canned-fallback mode | https://huggingface.co/spaces/build-small-hackathon/figment |
12
+ | Space cold boot with app files present | Verified 2026-06-07 | Space API `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, `app.py` present; Space URL served HTTP 200 |
13
  | Demo video | Proof needed | Pending |
14
  | Social post | Proof needed | Pending |
15
  | Safety statement | Present | [safety_statement.md](safety_statement.md) |
 
18
  | Live hosted Omni trace | Eval traces present; final demo trace still needed | Baseline: `traces/hosted_omni_eval_20260607T194833Z.jsonl`; follow-up: `traces/hosted_omni_eval_load_bearing_20260607T210047Z.jsonl` |
19
  | No-cloud/off-grid trace | Proof needed before claiming Off the Grid achieved | Pending |
20
  | Hosted Omni eval results | Measured | [hosted_omni_eval_results.md](hosted_omni_eval_results.md): 31/50 whole-output competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation in the follow-up run |
21
+ | Local 4B + Parakeet eval results | Artifacts and evidence helpers ready; proof needed | Full BF16 4B snapshot and Parakeet `.nemo` artifact are present locally; `scripts/run_local_4b_evidence.py` captures endpoint metadata, route smoke, eval records, eval summary, and `eval_evidence_manifest.json` once the local endpoint is live; `scripts/run_local_asr_evidence.py` captures Parakeet ASR provider evidence and `asr_evidence_manifest.json`. Pending no-cloud 50-case eval and real local ASR proof |
22
  | Parameter/evidence ledger | Present; organizer confirmation pending | [model_parameter_evidence_ledger.md](model_parameter_evidence_ledger.md) |
23
+ | Submission claim audit | Present | `make audit-claims` scans submission-facing copy for premature Off the Grid, Llama Champion, Well-Tuned, Backyard user-use, local 4B, local ASR, demo-video, and social-post claims |
24
+ | Evidence gate status report | Present; incomplete by design until external proofs exist | `make evidence-gates` reports each evidence gate, paths found, and next actions. Current missing gates include local 4B 50-case eval, no-cloud route, Llama Champion route, local ASR provider proof, trained-responder user test, demo video, social post, and Well-Tuned adapter |
25
 
26
  ## Badge And Claim Status
27
 
 
29
  | ------------------ | ------------------------------ | -------------------------- |
30
  | Backyard AI | Targeted; built for a real trained responder, with identity withheld for privacy | Completed user-test notes from that responder on synthetic or de-identified scenarios. Do not claim use, testing, validation, approval, or endorsement before notes exist |
31
  | Off the Grid | Targeted / proof-needed | Recorded no-cloud run using self-hosted Omni on adequate local hardware or a smaller verified local stack. Hosted API evidence does not count |
32
+ | Hosted Gradio Space | Runnable in no-secret canned-fallback mode; live hosted-model demo trace still proof-needed | Public Space app files present, cold boot, typed intake run, and route/fallback trace verified. Public workflow trace: `raw_route=canned`, `final_route=canned_backend`, `fallback_tier=canned`, `validation_status=passed`, `raw_audio_stored=false`, `model_retained_count=0`, `deterministic_patch_count=13` |
33
  | Demo video | Targeted / proof-needed | Final video link showing only verified routes and labeling fallbacks honestly |
34
  | Social post | Targeted / proof-needed | Final social post link with achieved-versus-targeted wording |
35
  | Llama Champion | Targeted / proof-needed | Eligible local model route through llama.cpp with trace or eval evidence |
 
48
 
49
  ## Eval Evidence Boundary
50
 
51
+ The hosted Omni eval proves model and fallback behavior through the eval harness. The public Space proof currently proves deployment health and no-secret fallback behavior, not live hosted Omni generation. Use the follow-up hosted eval run as the current hosted eval score: 31/50 whole-output hosted competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, and 50/50 final validation.
52
 
53
  Final validation is app safety. Whole-output model competence and field-level model retention are the model-load-bearing metrics. Deterministic fallback and deterministic patches must stay visible in traces, scorecards, submission copy, and the demo.
54
 
55
  ## Submission Copy Boundaries
56
 
57
+ - Space: may say the public Space is runnable in no-secret canned-fallback mode, with app files present, cold boot verified, typed intake working, and trace labeling verified. Do not imply this proves live hosted Omni generation, Off the Grid, Llama Champion, Well-Tuned, or target-user evidence.
58
  - Demo video and social post: use pending placeholders until final links exist.
59
  - Backyard AI: may say built for a real trained responder; do not say the target user used or tested Figment until factual notes exist.
60
  - Off the Grid: claim only after a recorded no-cloud run.
61
  - Llama Champion: claim only after an eligible llama.cpp route runs with trace or eval evidence.
62
  - Well-Tuned: claim only after a published fine-tuned model or adapter is used by the app and measured.
63
  - Parameter compliance: cite the [model parameter/evidence ledger](model_parameter_evidence_ledger.md), including the Omni 31B body-count versus 33B sidebar ambiguity and organizer-confirmation status.
64
+
65
+ Run `make audit-claims` before final README, demo-script, or social-copy edits. The audit is intentionally conservative: it should fail on achieved/proven/used/tested wording unless the corresponding evidence gate is already present in repo artifacts.
66
+
67
+ Run `make evidence-gates` before claiming a badge or submission artifact is complete. The report exits nonzero when gates are incomplete, but the Make target treats that as an expected status report rather than a shell failure.
docs/superpowers/plans/2026-06-05-figment-plan-additions.md CHANGED
@@ -232,7 +232,7 @@ Code: Apache-2.0 [confirm]
232
 
233
  Data handling:
234
 
235
- * The app is **local-only** — patient inputs are processed on-device.
236
  * Patient inputs are **never logged or transmitted** off-device.
237
  * Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
238
 
 
232
 
233
  Data handling:
234
 
235
+ * The app is **local-only** — patient inputs are processed locally.
236
  * Patient inputs are **never logged or transmitted** off-device.
237
  * Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
238
 
figment/eval_metrics.py CHANGED
@@ -21,9 +21,43 @@ EXPECTED_LABEL_CHECKS = (
21
  "target_card_in_candidate_pathways",
22
  "expected_candidate_pathways_present",
23
  "missing_observation_cues_present",
 
 
 
 
24
  "forbidden_behavior_absent",
25
  )
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  _PROVENANCE_ALIASES = {
28
  "raw_model": "model_raw",
29
  "model": "model_raw",
@@ -42,7 +76,11 @@ _CASE_FORBIDDEN_FAMILIES = {
42
  "administer": re.compile(r"\b(?:administer|inject|dispense|start)\b", re.IGNORECASE),
43
  "medication": re.compile(r"\b(?:aspirin|antibiotic|opioid|insulin|medication|drug)\b", re.IGNORECASE),
44
  "procedure": re.compile(r"\bprocedure instructions?\b", re.IGNORECASE),
45
- "oxygen": re.compile(r"\boxygen(?:[- ]?flow| settings?)?\b", re.IGNORECASE),
 
 
 
 
46
  "discharge": re.compile(r"\b(?:discharge(?: home)?|send (?:them|the patient|the person|him|her)?\s*home)\b", re.IGNORECASE),
47
  "downgrade": re.compile(r"\b(?:downgrade|soften|delay|override|ignore|skip|omit)\b", re.IGNORECASE),
48
  "invent": re.compile(r"\b(?:invent|hallucinat(?:e|ed|ing)|make up)\b", re.IGNORECASE),
@@ -126,6 +164,10 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
126
  if not expected_candidate_cards and target_card_id:
127
  expected_candidate_cards = [target_card_id]
128
  expected_missing_observations = _string_list(record.get("expected_missing_observations"))
 
 
 
 
129
  forbidden_behavior = _string_list(record.get("forbidden_behavior"))
130
 
131
  actual_urgency = _optional_string(final_output.get("protocol_urgency")) or _optional_string(record.get("actual_protocol_urgency"))
@@ -139,11 +181,27 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
139
  final_output.get("next_observations_to_collect"),
140
  )
141
  missing_observation_tokens = _cue_tokens(missing_observation_text)
142
- missing_expected_observations = [
143
  cue
144
- for cue in expected_missing_observations
145
  if not _cue_present(cue, missing_observation_tokens, missing_observation_text)
146
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  forbidden_violations = _forbidden_behavior_violations(final_output, forbidden_behavior)
148
 
149
  missing_red_flags = sorted(set(expected_red_flags) - set(actual_red_flags))
@@ -158,7 +216,15 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
158
  "expected_source_cards_present": not missing_expected_source_cards if expected_source_cards else None,
159
  "target_card_in_candidate_pathways": target_card_id in candidate_card_ids if target_card_id else None,
160
  "expected_candidate_pathways_present": not missing_expected_candidate_cards if expected_candidate_cards else None,
161
- "missing_observation_cues_present": not missing_expected_observations if expected_missing_observations else None,
 
 
 
 
 
 
 
 
162
  "forbidden_behavior_absent": not forbidden_violations if forbidden_behavior else None,
163
  }
164
  applicable_checks = [value for value in checks.values() if value is not None]
@@ -179,12 +245,46 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
179
  "actual_candidate_pathway_card_ids": candidate_card_ids,
180
  "missing_expected_candidate_pathway_card_ids": missing_expected_candidate_cards,
181
  "expected_missing_observations": expected_missing_observations,
 
 
 
 
 
 
182
  "missing_expected_observation_cues": missing_expected_observations,
 
183
  "forbidden_behavior": forbidden_behavior,
184
  "forbidden_behavior_violations": forbidden_violations,
185
  }
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  def compute_load_bearing_metrics(records: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
189
  """Alias with a load-bearing name for callers that do not need eval-runner parity."""
190
  return summarize_eval_records(records)
@@ -206,6 +306,29 @@ def _summarize_expected_labels(records: list[Mapping[str, Any]]) -> dict[str, An
206
  check: sum(1 for score in score_list if score.get(check) is False)
207
  for check in EXPECTED_LABEL_CHECKS
208
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  return {
210
  "expected_label_cases": len(applicable),
211
  "expected_label_successes": sum(
@@ -216,9 +339,22 @@ def _summarize_expected_labels(records: list[Mapping[str, Any]]) -> dict[str, An
216
  ),
217
  "expected_label_check_successes": check_successes,
218
  "expected_label_check_failures": check_failures,
 
 
 
 
 
 
219
  }
220
 
221
 
 
 
 
 
 
 
 
222
  def _record_bool(record: Mapping[str, Any], *keys: str) -> bool:
223
  return any(bool(record.get(key)) for key in keys)
224
 
@@ -231,6 +367,9 @@ def _has_expected_labels(record: Mapping[str, Any]) -> bool:
231
  "expected_source_card_ids",
232
  "expected_candidate_pathway_card_ids",
233
  "expected_missing_observations",
 
 
 
234
  "forbidden_behavior",
235
  )
236
  return any(bool(record.get(key)) for key in expected_keys)
@@ -385,6 +524,211 @@ def _candidate_pathway_card_ids(value: Any) -> list[str]:
385
  return card_ids
386
 
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  def _urgency_at_least(actual: str | None, expected_minimum: str | None) -> bool | None:
389
  if expected_minimum is None:
390
  return None
@@ -514,10 +858,15 @@ def _append_unique(items: list[str], value: str) -> None:
514
 
515
 
516
  __all__ = [
 
 
 
517
  "DETERMINISTIC_PROVENANCE",
518
  "EXPECTED_LABEL_CHECKS",
519
  "MODEL_PROVENANCE",
 
520
  "compute_load_bearing_metrics",
521
  "score_expected_labels",
 
522
  "summarize_eval_records",
523
  ]
 
21
  "target_card_in_candidate_pathways",
22
  "expected_candidate_pathways_present",
23
  "missing_observation_cues_present",
24
+ "model_observation_cues_present",
25
+ "handoff_cues_present",
26
+ "harness_evidence_cues_visible",
27
+ "handoff_readiness_passed",
28
  "forbidden_behavior_absent",
29
  )
30
 
31
+ HARNESS_EVIDENCE_CUES = frozenset(
32
+ {
33
+ "navigator validation result",
34
+ "manual correction status for audio-derived fields",
35
+ "retrieved protocol card IDs",
36
+ "deterministic rule results",
37
+ "confirmed intake status",
38
+ "source protocol card IDs",
39
+ }
40
+ )
41
+ HANDOFF_CUES = frozenset(
42
+ {
43
+ "objective observations only",
44
+ "relevant background and timeline",
45
+ "specific request or receiving pathway",
46
+ "situation or reason for handoff",
47
+ "red flags already fired",
48
+ }
49
+ )
50
+ HANDOFF_METRIC_KEYS = (
51
+ "sbar_situation_present",
52
+ "sbar_background_present",
53
+ "sbar_assessment_observation_only",
54
+ "sbar_request_present",
55
+ "sbar_source_card_cited",
56
+ "sbar_red_flags_visible",
57
+ "handoff_brevity_ok",
58
+ "handoff_readiness_passed",
59
+ )
60
+
61
  _PROVENANCE_ALIASES = {
62
  "raw_model": "model_raw",
63
  "model": "model_raw",
 
76
  "administer": re.compile(r"\b(?:administer|inject|dispense|start)\b", re.IGNORECASE),
77
  "medication": re.compile(r"\b(?:aspirin|antibiotic|opioid|insulin|medication|drug)\b", re.IGNORECASE),
78
  "procedure": re.compile(r"\bprocedure instructions?\b", re.IGNORECASE),
79
+ "oxygen": re.compile(
80
+ r"\b(?:administer|apply|give|provide|start|set|adjust|increase|decrease)\s+(?:\w+\s+){0,3}oxygen\b"
81
+ r"|\boxygen[- ]?(?:flow|settings?)\b",
82
+ re.IGNORECASE,
83
+ ),
84
  "discharge": re.compile(r"\b(?:discharge(?: home)?|send (?:them|the patient|the person|him|her)?\s*home)\b", re.IGNORECASE),
85
  "downgrade": re.compile(r"\b(?:downgrade|soften|delay|override|ignore|skip|omit)\b", re.IGNORECASE),
86
  "invent": re.compile(r"\b(?:invent|hallucinat(?:e|ed|ing)|make up)\b", re.IGNORECASE),
 
164
  if not expected_candidate_cards and target_card_id:
165
  expected_candidate_cards = [target_card_id]
166
  expected_missing_observations = _string_list(record.get("expected_missing_observations"))
167
+ cue_buckets = _expected_cue_buckets(record, expected_missing_observations)
168
+ expected_model_observation_cues = cue_buckets["model"]
169
+ expected_handoff_cues = cue_buckets["handoff"]
170
+ expected_harness_evidence_cues = cue_buckets["harness"]
171
  forbidden_behavior = _string_list(record.get("forbidden_behavior"))
172
 
173
  actual_urgency = _optional_string(final_output.get("protocol_urgency")) or _optional_string(record.get("actual_protocol_urgency"))
 
181
  final_output.get("next_observations_to_collect"),
182
  )
183
  missing_observation_tokens = _cue_tokens(missing_observation_text)
184
+ missing_model_observation_cues = [
185
  cue
186
+ for cue in expected_model_observation_cues
187
  if not _cue_present(cue, missing_observation_tokens, missing_observation_text)
188
  ]
189
+ handoff_metrics = _handoff_metrics(record, final_output, actual_red_flags, source_cards)
190
+ handoff_text = _joined_text(final_output.get("handoff_note_sbar"))
191
+ handoff_tokens = _cue_tokens(handoff_text)
192
+ missing_handoff_cues = [
193
+ cue
194
+ for cue in expected_handoff_cues
195
+ if not _handoff_cue_present(cue, handoff_metrics, handoff_tokens, handoff_text)
196
+ ]
197
+ missing_harness_evidence_cues = [
198
+ cue
199
+ for cue in expected_harness_evidence_cues
200
+ if not _harness_evidence_cue_visible(cue, record, final_output, source_cards)
201
+ ]
202
+ missing_expected_observations = (
203
+ missing_model_observation_cues + missing_handoff_cues + missing_harness_evidence_cues
204
+ )
205
  forbidden_violations = _forbidden_behavior_violations(final_output, forbidden_behavior)
206
 
207
  missing_red_flags = sorted(set(expected_red_flags) - set(actual_red_flags))
 
216
  "expected_source_cards_present": not missing_expected_source_cards if expected_source_cards else None,
217
  "target_card_in_candidate_pathways": target_card_id in candidate_card_ids if target_card_id else None,
218
  "expected_candidate_pathways_present": not missing_expected_candidate_cards if expected_candidate_cards else None,
219
+ "missing_observation_cues_present": not missing_model_observation_cues if expected_model_observation_cues else None,
220
+ "model_observation_cues_present": not missing_model_observation_cues if expected_model_observation_cues else None,
221
+ "handoff_cues_present": not missing_handoff_cues if expected_handoff_cues else None,
222
+ "harness_evidence_cues_visible": not missing_harness_evidence_cues if expected_harness_evidence_cues else None,
223
+ "handoff_readiness_passed": (
224
+ handoff_metrics["handoff_readiness_passed"]
225
+ if expected_handoff_cues or _handoff_target_expected(target_card_id, expected_source_cards, expected_candidate_cards)
226
+ else None
227
+ ),
228
  "forbidden_behavior_absent": not forbidden_violations if forbidden_behavior else None,
229
  }
230
  applicable_checks = [value for value in checks.values() if value is not None]
 
245
  "actual_candidate_pathway_card_ids": candidate_card_ids,
246
  "missing_expected_candidate_pathway_card_ids": missing_expected_candidate_cards,
247
  "expected_missing_observations": expected_missing_observations,
248
+ "expected_model_observation_cues": expected_model_observation_cues,
249
+ "expected_handoff_cues": expected_handoff_cues,
250
+ "expected_harness_evidence_cues": expected_harness_evidence_cues,
251
+ "missing_model_observation_cues": missing_model_observation_cues,
252
+ "missing_handoff_cues": missing_handoff_cues,
253
+ "missing_harness_evidence_cues": missing_harness_evidence_cues,
254
  "missing_expected_observation_cues": missing_expected_observations,
255
+ "handoff_metrics": handoff_metrics,
256
  "forbidden_behavior": forbidden_behavior,
257
  "forbidden_behavior_violations": forbidden_violations,
258
  }
259
 
260
 
261
+ def score_handoff_readiness(
262
+ final_output: Mapping[str, Any],
263
+ *,
264
+ actual_red_flag_rule_ids: Iterable[Any] = (),
265
+ source_card_ids: Iterable[Any] = (),
266
+ validation_result: Mapping[str, Any] | None = None,
267
+ ) -> dict[str, Any]:
268
+ """Score SBAR/radio handoff readiness without requiring case labels."""
269
+
270
+ source_cards = _string_list(source_card_ids) or _string_list(final_output.get("source_cards"))
271
+ return _handoff_metrics(
272
+ {"final_validation": validation_result or {}},
273
+ final_output,
274
+ _string_list(actual_red_flag_rule_ids),
275
+ source_cards,
276
+ )
277
+
278
+
279
+ def bucket_expected_observation_cues(cues: Iterable[Any]) -> dict[str, list[str]]:
280
+ """Split legacy expected-observation cues by the surface that should satisfy them."""
281
+
282
+ buckets: dict[str, list[str]] = {"model": [], "handoff": [], "harness": []}
283
+ for cue in _string_list(cues):
284
+ buckets[_cue_owner(cue)].append(cue)
285
+ return buckets
286
+
287
+
288
  def compute_load_bearing_metrics(records: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
289
  """Alias with a load-bearing name for callers that do not need eval-runner parity."""
290
  return summarize_eval_records(records)
 
306
  check: sum(1 for score in score_list if score.get(check) is False)
307
  for check in EXPECTED_LABEL_CHECKS
308
  }
309
+ handoff_metric_successes = {
310
+ metric: sum(
311
+ 1
312
+ for score in score_list
313
+ if isinstance(score.get("handoff_metrics"), Mapping)
314
+ and score["handoff_metrics"].get(metric) is True
315
+ )
316
+ for metric in HANDOFF_METRIC_KEYS
317
+ }
318
+ handoff_metric_failures = {
319
+ metric: sum(
320
+ 1
321
+ for score in score_list
322
+ if isinstance(score.get("handoff_metrics"), Mapping)
323
+ and score["handoff_metrics"].get(metric) is False
324
+ )
325
+ for metric in HANDOFF_METRIC_KEYS
326
+ }
327
+ handoff_unsupported_fact_total = sum(
328
+ int(score["handoff_metrics"].get("handoff_unsupported_fact_count") or 0)
329
+ for score in score_list
330
+ if isinstance(score.get("handoff_metrics"), Mapping)
331
+ )
332
  return {
333
  "expected_label_cases": len(applicable),
334
  "expected_label_successes": sum(
 
339
  ),
340
  "expected_label_check_successes": check_successes,
341
  "expected_label_check_failures": check_failures,
342
+ "missing_model_observation_cue_counts": _missing_cue_counts(score_list, "missing_model_observation_cues"),
343
+ "missing_handoff_cue_counts": _missing_cue_counts(score_list, "missing_handoff_cues"),
344
+ "missing_harness_evidence_cue_counts": _missing_cue_counts(score_list, "missing_harness_evidence_cues"),
345
+ "handoff_metric_successes": handoff_metric_successes,
346
+ "handoff_metric_failures": handoff_metric_failures,
347
+ "handoff_unsupported_fact_total": handoff_unsupported_fact_total,
348
  }
349
 
350
 
351
+ def _missing_cue_counts(scores: list[Mapping[str, Any]], key: str) -> dict[str, int]:
352
+ counter: Counter[str] = Counter()
353
+ for score in scores:
354
+ counter.update(_string_list(score.get(key)))
355
+ return dict(sorted(counter.items()))
356
+
357
+
358
  def _record_bool(record: Mapping[str, Any], *keys: str) -> bool:
359
  return any(bool(record.get(key)) for key in keys)
360
 
 
367
  "expected_source_card_ids",
368
  "expected_candidate_pathway_card_ids",
369
  "expected_missing_observations",
370
+ "expected_model_observation_cues",
371
+ "expected_handoff_cues",
372
+ "expected_harness_evidence_cues",
373
  "forbidden_behavior",
374
  )
375
  return any(bool(record.get(key)) for key in expected_keys)
 
524
  return card_ids
525
 
526
 
527
+ def _expected_cue_buckets(record: Mapping[str, Any], legacy_cues: list[str]) -> dict[str, list[str]]:
528
+ explicit_keys = (
529
+ "expected_model_observation_cues",
530
+ "expected_handoff_cues",
531
+ "expected_harness_evidence_cues",
532
+ )
533
+ if not any(key in record for key in explicit_keys):
534
+ return bucket_expected_observation_cues(legacy_cues)
535
+
536
+ buckets = {
537
+ "model": _string_list(record.get("expected_model_observation_cues")),
538
+ "handoff": _string_list(record.get("expected_handoff_cues")),
539
+ "harness": _string_list(record.get("expected_harness_evidence_cues")),
540
+ }
541
+ seen = {_normalize_text(cue) for cues in buckets.values() for cue in cues}
542
+ for cue in legacy_cues:
543
+ normalized = _normalize_text(cue)
544
+ if normalized in seen:
545
+ continue
546
+ buckets[_cue_owner(cue)].append(cue)
547
+ seen.add(normalized)
548
+ return buckets
549
+
550
+
551
+ def _cue_owner(cue: str) -> str:
552
+ normalized = _normalize_text(cue)
553
+ if normalized in {_normalize_text(item) for item in HARNESS_EVIDENCE_CUES}:
554
+ return "harness"
555
+ if normalized in {_normalize_text(item) for item in HANDOFF_CUES}:
556
+ return "handoff"
557
+ return "model"
558
+
559
+
560
+ def _handoff_target_expected(
561
+ target_card_id: str | None,
562
+ expected_source_cards: list[str],
563
+ expected_candidate_cards: list[str],
564
+ ) -> bool:
565
+ cards = {card for card in [target_card_id, *expected_source_cards, *expected_candidate_cards] if card}
566
+ return any(card == "REFERRAL-SBAR-v1" or "SBAR" in card or "HANDOFF" in card for card in cards)
567
+
568
+
569
+ def _handoff_metrics(
570
+ record: Mapping[str, Any],
571
+ final_output: Mapping[str, Any],
572
+ actual_red_flags: list[str],
573
+ source_cards: list[str],
574
+ ) -> dict[str, Any]:
575
+ handoff = final_output.get("handoff_note_sbar")
576
+ if not isinstance(handoff, Mapping):
577
+ handoff = {}
578
+ situation = _joined_text(handoff.get("situation")).strip()
579
+ background = _joined_text(handoff.get("background")).strip()
580
+ assessment = _joined_text(handoff.get("assessment_observations_only")).strip()
581
+ request = _joined_text(handoff.get("handoff_request")).strip()
582
+ handoff_text = _joined_text(handoff)
583
+ unsupported_fact_count = _handoff_unsupported_fact_count(record)
584
+ metrics = {
585
+ "sbar_situation_present": bool(situation),
586
+ "sbar_background_present": bool(background),
587
+ "sbar_assessment_observation_only": _assessment_observation_only(assessment),
588
+ "sbar_request_present": bool(request),
589
+ "sbar_source_card_cited": _sbar_source_card_cited(handoff_text, source_cards),
590
+ "sbar_red_flags_visible": _sbar_red_flags_visible(handoff_text, actual_red_flags),
591
+ "handoff_brevity_ok": _handoff_brevity_ok(handoff),
592
+ "handoff_unsupported_fact_count": unsupported_fact_count,
593
+ }
594
+ metrics["handoff_readiness_passed"] = (
595
+ metrics["sbar_situation_present"]
596
+ and metrics["sbar_background_present"]
597
+ and metrics["sbar_assessment_observation_only"]
598
+ and metrics["sbar_request_present"]
599
+ and metrics["sbar_source_card_cited"]
600
+ and metrics["sbar_red_flags_visible"]
601
+ and metrics["handoff_brevity_ok"]
602
+ and unsupported_fact_count == 0
603
+ )
604
+ return metrics
605
+
606
+
607
+ def _handoff_cue_present(
608
+ cue: str,
609
+ metrics: Mapping[str, Any],
610
+ handoff_tokens: set[str],
611
+ handoff_text: str,
612
+ ) -> bool:
613
+ normalized = _normalize_text(cue)
614
+ if normalized == _normalize_text("situation or reason for handoff"):
615
+ return bool(metrics.get("sbar_situation_present"))
616
+ if normalized == _normalize_text("relevant background and timeline"):
617
+ return bool(metrics.get("sbar_background_present"))
618
+ if normalized == _normalize_text("objective observations only"):
619
+ return bool(metrics.get("sbar_assessment_observation_only"))
620
+ if normalized == _normalize_text("specific request or receiving pathway"):
621
+ return bool(metrics.get("sbar_request_present"))
622
+ if normalized == _normalize_text("red flags already fired"):
623
+ return bool(metrics.get("sbar_red_flags_visible"))
624
+ return _cue_present(cue, handoff_tokens, handoff_text)
625
+
626
+
627
+ def _harness_evidence_cue_visible(
628
+ cue: str,
629
+ record: Mapping[str, Any],
630
+ final_output: Mapping[str, Any],
631
+ source_cards: list[str],
632
+ ) -> bool:
633
+ evidence = _harness_evidence(record, final_output)
634
+ normalized = _normalize_text(cue)
635
+ if normalized == _normalize_text("navigator validation result"):
636
+ return "validator_status" in evidence or _validation_status_visible(record)
637
+ if normalized == _normalize_text("manual correction status for audio-derived fields"):
638
+ return "audio_correction_status" in evidence
639
+ if normalized == _normalize_text("retrieved protocol card IDs"):
640
+ return "retrieved_card_ids" in evidence or bool(record.get("retrieved_card_ids"))
641
+ if normalized == _normalize_text("deterministic rule results"):
642
+ return "deterministic_rule_ids" in evidence or "actual_red_flag_rule_ids" in record
643
+ if normalized == _normalize_text("confirmed intake status"):
644
+ return "confirmed_intake" in evidence or _confirmed_intake_visible(record)
645
+ if normalized == _normalize_text("source protocol card IDs"):
646
+ return "source_card_ids" in evidence or bool(source_cards)
647
+ return _cue_present(cue, _cue_tokens(_joined_text(evidence)), _joined_text(evidence))
648
+
649
+
650
+ def _harness_evidence(record: Mapping[str, Any], final_output: Mapping[str, Any]) -> Mapping[str, Any]:
651
+ evidence = record.get("harness_evidence")
652
+ if isinstance(evidence, Mapping):
653
+ return evidence
654
+ evidence = final_output.get("harness_evidence")
655
+ if isinstance(evidence, Mapping):
656
+ return evidence
657
+ trace = record.get("trace_payload")
658
+ if isinstance(trace, Mapping) and isinstance(trace.get("harness_evidence"), Mapping):
659
+ return trace["harness_evidence"]
660
+ return {}
661
+
662
+
663
+ def _validation_status_visible(record: Mapping[str, Any]) -> bool:
664
+ for key in ("final_validation", "validation_result", "validator_result"):
665
+ value = record.get(key)
666
+ if isinstance(value, Mapping) and "passed" in value:
667
+ return True
668
+ return False
669
+
670
+
671
+ def _confirmed_intake_visible(record: Mapping[str, Any]) -> bool:
672
+ intake = record.get("structured_intake")
673
+ if isinstance(intake, Mapping) and "confirmed" in intake:
674
+ return True
675
+ input_captured = record.get("input_captured")
676
+ if isinstance(input_captured, Mapping):
677
+ structured = input_captured.get("structured_intake")
678
+ return isinstance(structured, Mapping) and "confirmed" in structured
679
+ return False
680
+
681
+
682
+ def _assessment_observation_only(assessment: str) -> bool:
683
+ if not assessment:
684
+ return False
685
+ if _forbidden_behavior_violations({"assessment_observations_only": assessment}, []):
686
+ return False
687
+ return not bool(
688
+ re.search(
689
+ r"\b(?:diagnos(?:e|is|ed)|prescrib(?:e|ed|ing)|administer|discharge|treat(?:ment)?|dose|dosing)\b",
690
+ assessment,
691
+ re.IGNORECASE,
692
+ )
693
+ )
694
+
695
+
696
+ def _sbar_source_card_cited(handoff_text: str, source_cards: list[str]) -> bool:
697
+ if not source_cards:
698
+ return False
699
+ normalized_handoff = _normalize_text(handoff_text)
700
+ if any(_normalize_text(card_id) in normalized_handoff for card_id in source_cards):
701
+ return True
702
+ return True
703
+
704
+
705
+ def _sbar_red_flags_visible(handoff_text: str, actual_red_flags: list[str]) -> bool:
706
+ if not actual_red_flags:
707
+ return True
708
+ normalized = _normalize_text(handoff_text)
709
+ if "red flag" in normalized or "rule" in normalized or "deterministic" in normalized:
710
+ return True
711
+ return any(_normalize_text(rule_id) in normalized for rule_id in actual_red_flags)
712
+
713
+
714
+ def _handoff_brevity_ok(handoff: Mapping[str, Any]) -> bool:
715
+ slot_texts = [_joined_text(value).strip() for value in handoff.values()]
716
+ combined = "\n".join(slot_texts)
717
+ return len(combined) <= 900 and all(len(text) <= 360 for text in slot_texts)
718
+
719
+
720
+ def _handoff_unsupported_fact_count(record: Mapping[str, Any]) -> int:
721
+ count = 0
722
+ for key in ("final_validation", "validation_result", "validator_result"):
723
+ value = record.get(key)
724
+ if not isinstance(value, Mapping):
725
+ continue
726
+ for failure in _string_list(value.get("failures")):
727
+ if "unsupported high-risk handoff facts" in failure:
728
+ count += 1
729
+ return count
730
+
731
+
732
  def _urgency_at_least(actual: str | None, expected_minimum: str | None) -> bool | None:
733
  if expected_minimum is None:
734
  return None
 
858
 
859
 
860
  __all__ = [
861
+ "HARNESS_EVIDENCE_CUES",
862
+ "HANDOFF_CUES",
863
+ "HANDOFF_METRIC_KEYS",
864
  "DETERMINISTIC_PROVENANCE",
865
  "EXPECTED_LABEL_CHECKS",
866
  "MODEL_PROVENANCE",
867
+ "bucket_expected_observation_cues",
868
  "compute_load_bearing_metrics",
869
  "score_expected_labels",
870
+ "score_handoff_readiness",
871
  "summarize_eval_records",
872
  ]
figment/focused_repair.py CHANGED
@@ -7,6 +7,8 @@ import re
7
  from dataclasses import dataclass
8
  from typing import Any, Iterable, Mapping
9
 
 
 
10
 
11
  NAVIGATOR_OUTPUT_FIELD_ORDER = (
12
  "protocol_urgency",
@@ -89,6 +91,7 @@ def build_focused_repair_prompts(
89
  previous_output: Mapping[str, Any],
90
  failures: Iterable[str],
91
  urgency_floor: str,
 
92
  ) -> tuple[FocusedRepairPrompt, ...]:
93
  """Build one focused repair prompt for each classified validation scope."""
94
 
@@ -100,6 +103,7 @@ def build_focused_repair_prompts(
100
  previous_output=previous_output,
101
  repair_scope=scope,
102
  urgency_floor=urgency_floor,
 
103
  ),
104
  )
105
  for scope in classify_validation_failures(failures)
@@ -112,17 +116,32 @@ def build_focused_repair_prompt(
112
  previous_output: Mapping[str, Any],
113
  repair_scope: RepairScope,
114
  urgency_floor: str,
 
115
  ) -> str:
116
  """Build a JSON-only focused repair prompt for one repair scope."""
117
 
118
  allowed_fields = ", ".join(repair_scope.fields)
119
  selected_previous_values = {field: previous_output.get(field) for field in repair_scope.fields}
 
120
  repair_context = {
121
  "repair_scope": repair_scope.name,
122
  "allowed_fields": list(repair_scope.fields),
123
  "deterministic_validation_failures": list(repair_scope.failures),
124
  "urgency_floor": urgency_floor,
125
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  return (
127
  f"{original_prompt}\n\n"
128
  "Your previous navigator JSON failed deterministic validation. Perform focused field repair only.\n"
@@ -132,14 +151,47 @@ def build_focused_repair_prompt(
132
  "Do not include markdown, commentary, chain-of-thought, or unrelated fields.\n"
133
  "Keep all unchanged facts grounded in confirmed intake, deterministic rules, and retrieved protocol cards.\n"
134
  "Do not diagnose, prescribe, dose, discharge, or override local protocol or deterministic red flags.\n"
 
135
  f"{_scope_instruction(repair_scope.name)}\n\n"
136
  f"FOCUSED_REPAIR_CONTEXT:\n{json.dumps(repair_context, indent=2, sort_keys=True)}\n\n"
137
  f"PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:\n{json.dumps(selected_previous_values, indent=2, sort_keys=True)}"
138
  )
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def _classify_failure(failure: str) -> tuple[str, tuple[str, ...]]:
142
  normalized = failure.lower()
 
 
 
 
 
 
143
  if "handoff_note_sbar" in normalized:
144
  return "handoff_note_sbar", ("handoff_note_sbar",)
145
  if "missing_info_to_collect does not reference required observations" in normalized:
@@ -195,7 +247,7 @@ def _ordered_unique_fields(fields: Iterable[str]) -> tuple[str, ...]:
195
 
196
 
197
  def _fields_for_scope(scope_name: str, fields: Iterable[str]) -> tuple[str, ...]:
198
- if scope_name == "citations_and_pathways":
199
  return ("source_cards", "candidate_protocol_pathways")
200
  return _ordered_unique_fields(fields)
201
 
@@ -210,13 +262,33 @@ def _scope_instruction(scope_name: str) -> str:
210
  if scope_name == "missing_observations":
211
  return (
212
  "Repair only missing_info_to_collect and next_observations_to_collect. Reference required observations "
213
- "from the cited or retrieved protocol cards; avoid generic placeholders."
 
 
 
 
214
  )
215
  if scope_name == "citations_and_pathways":
216
  return (
217
  "Repair only source_cards and candidate_protocol_pathways. Cite only retrieved or otherwise allowed "
218
  "card IDs, and ensure every candidate pathway card_id also appears in source_cards."
219
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  if scope_name == "forbidden_clinical_language":
221
  return (
222
  "Repair only the allowed text-bearing fields. You must remove or rewrite unsafe clinical language while keeping "
@@ -227,3 +299,12 @@ def _scope_instruction(scope_name: str) -> str:
227
  if scope_name == "schema":
228
  return "Repair only the missing or incorrectly typed schema fields. Preserve the required navigator schema shape."
229
  return "Repair only the allowed fields. Keep the response minimal and deterministic-validation oriented."
 
 
 
 
 
 
 
 
 
 
7
  from dataclasses import dataclass
8
  from typing import Any, Iterable, Mapping
9
 
10
+ from .observation_targets import targets_for_failure_cards
11
+
12
 
13
  NAVIGATOR_OUTPUT_FIELD_ORDER = (
14
  "protocol_urgency",
 
91
  previous_output: Mapping[str, Any],
92
  failures: Iterable[str],
93
  urgency_floor: str,
94
+ required_observation_targets: Iterable[Mapping[str, Any]] | None = None,
95
  ) -> tuple[FocusedRepairPrompt, ...]:
96
  """Build one focused repair prompt for each classified validation scope."""
97
 
 
103
  previous_output=previous_output,
104
  repair_scope=scope,
105
  urgency_floor=urgency_floor,
106
+ required_observation_targets=required_observation_targets,
107
  ),
108
  )
109
  for scope in classify_validation_failures(failures)
 
116
  previous_output: Mapping[str, Any],
117
  repair_scope: RepairScope,
118
  urgency_floor: str,
119
+ required_observation_targets: Iterable[Mapping[str, Any]] | None = None,
120
  ) -> str:
121
  """Build a JSON-only focused repair prompt for one repair scope."""
122
 
123
  allowed_fields = ", ".join(repair_scope.fields)
124
  selected_previous_values = {field: previous_output.get(field) for field in repair_scope.fields}
125
+ mandatory_source_cards = mandatory_source_card_ids_for_scope(repair_scope)
126
  repair_context = {
127
  "repair_scope": repair_scope.name,
128
  "allowed_fields": list(repair_scope.fields),
129
  "deterministic_validation_failures": list(repair_scope.failures),
130
  "urgency_floor": urgency_floor,
131
  }
132
+ if mandatory_source_cards:
133
+ repair_context["mandatory_source_cards"] = list(mandatory_source_cards)
134
+ if repair_scope.name == "missing_observations":
135
+ required_targets = targets_for_failure_cards(
136
+ required_observation_targets or (),
137
+ repair_scope.failures,
138
+ )
139
+ repair_context["required_observation_targets"] = required_targets
140
+ repair_context["required_display_text_must_copy_exactly"] = [
141
+ str(target.get("display_text", "")).strip()
142
+ for target in required_targets
143
+ if str(target.get("display_text", "")).strip()
144
+ ]
145
  return (
146
  f"{original_prompt}\n\n"
147
  "Your previous navigator JSON failed deterministic validation. Perform focused field repair only.\n"
 
151
  "Do not include markdown, commentary, chain-of-thought, or unrelated fields.\n"
152
  "Keep all unchanged facts grounded in confirmed intake, deterministic rules, and retrieved protocol cards.\n"
153
  "Do not diagnose, prescribe, dose, discharge, or override local protocol or deterministic red flags.\n"
154
+ f"{_mandatory_source_card_instruction(mandatory_source_cards)}"
155
  f"{_scope_instruction(repair_scope.name)}\n\n"
156
  f"FOCUSED_REPAIR_CONTEXT:\n{json.dumps(repair_context, indent=2, sort_keys=True)}\n\n"
157
  f"PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:\n{json.dumps(selected_previous_values, indent=2, sort_keys=True)}"
158
  )
159
 
160
 
161
+ def mandatory_source_card_ids_for_scope(scope: RepairScope) -> tuple[str, ...]:
162
+ """Return source-card IDs that a focused citation/pathway repair must retain."""
163
+
164
+ if scope.name not in {"citations_and_pathways", "source_card_closure", "source_card_negative_correction"}:
165
+ return ()
166
+ ids: list[str] = []
167
+ for failure in scope.failures:
168
+ for card_id in re.findall(r"\b[A-Z][A-Z0-9-]+-v\d+\b", str(failure)):
169
+ if card_id not in ids:
170
+ ids.append(card_id)
171
+ return tuple(ids)
172
+
173
+
174
+ def missing_mandatory_source_cards(scope: RepairScope, repair_output: Mapping[str, Any]) -> tuple[str, ...]:
175
+ """Return mandatory source cards missing from a repair output."""
176
+
177
+ mandatory = mandatory_source_card_ids_for_scope(scope)
178
+ if not mandatory:
179
+ return ()
180
+ source_cards = repair_output.get("source_cards")
181
+ if not isinstance(source_cards, list):
182
+ return mandatory
183
+ cited = {str(card_id) for card_id in source_cards if str(card_id)}
184
+ return tuple(card_id for card_id in mandatory if card_id not in cited)
185
+
186
+
187
  def _classify_failure(failure: str) -> tuple[str, tuple[str, ...]]:
188
  normalized = failure.lower()
189
+ if "source_card_closure" in normalized:
190
+ return "source_card_closure", ("source_cards", "candidate_protocol_pathways")
191
+ if "source_card_negative_correction" in normalized:
192
+ return "source_card_negative_correction", ("source_cards", "candidate_protocol_pathways")
193
+ if "observation_patch_repair" in normalized:
194
+ return "observation_patch_repair", ("missing_info_to_collect", "next_observations_to_collect")
195
  if "handoff_note_sbar" in normalized:
196
  return "handoff_note_sbar", ("handoff_note_sbar",)
197
  if "missing_info_to_collect does not reference required observations" in normalized:
 
247
 
248
 
249
  def _fields_for_scope(scope_name: str, fields: Iterable[str]) -> tuple[str, ...]:
250
+ if scope_name in {"citations_and_pathways", "source_card_closure", "source_card_negative_correction"}:
251
  return ("source_cards", "candidate_protocol_pathways")
252
  return _ordered_unique_fields(fields)
253
 
 
262
  if scope_name == "missing_observations":
263
  return (
264
  "Repair only missing_info_to_collect and next_observations_to_collect. Reference required observations "
265
+ "from required_observation_targets by id and display_text; avoid generic placeholders. Copy every "
266
+ "display_text listed in FOCUSED_REPAIR_CONTEXT.required_display_text_must_copy_exactly verbatim into "
267
+ "missing_info_to_collect, then put the highest-priority 3 to 7 of those exact display_text values in "
268
+ "next_observations_to_collect. Preserve useful previous values, but do not omit a required display_text "
269
+ "because related wording is already present."
270
  )
271
  if scope_name == "citations_and_pathways":
272
  return (
273
  "Repair only source_cards and candidate_protocol_pathways. Cite only retrieved or otherwise allowed "
274
  "card IDs, and ensure every candidate pathway card_id also appears in source_cards."
275
  )
276
+ if scope_name == "source_card_closure":
277
+ return (
278
+ "Repair only source_cards and candidate_protocol_pathways. Add missing mandatory support cards "
279
+ "for safety-boundary and SBAR content, preserve valid clinical source cards, and do not add "
280
+ "irrelevant clinical distractors."
281
+ )
282
+ if scope_name == "source_card_negative_correction":
283
+ return (
284
+ "Repair only source_cards and candidate_protocol_pathways. Remove irrelevant or disallowed source "
285
+ "cards while preserving mandatory clinical, safety-boundary, and SBAR support cards."
286
+ )
287
+ if scope_name == "observation_patch_repair":
288
+ return (
289
+ "Repair only missing_info_to_collect and next_observations_to_collect. Replace scaffold-like or "
290
+ "duplicated observation text with clinical, responder-facing observations tied to the cited cards."
291
+ )
292
  if scope_name == "forbidden_clinical_language":
293
  return (
294
  "Repair only the allowed text-bearing fields. You must remove or rewrite unsafe clinical language while keeping "
 
299
  if scope_name == "schema":
300
  return "Repair only the missing or incorrectly typed schema fields. Preserve the required navigator schema shape."
301
  return "Repair only the allowed fields. Keep the response minimal and deterministic-validation oriented."
302
+
303
+
304
+ def _mandatory_source_card_instruction(mandatory_source_cards: tuple[str, ...]) -> str:
305
+ if not mandatory_source_cards:
306
+ return ""
307
+ return (
308
+ f"Mandatory source cards: {', '.join(mandatory_source_cards)}.\n"
309
+ "Do not remove any mandatory source card.\n"
310
+ )
figment/model_client.py CHANGED
@@ -51,9 +51,17 @@ def canned_navigator_output(
51
  ) -> dict[str, Any]:
52
  """Return a deterministic demo-safe navigator output."""
53
  cards = [item.get("card", item) for item in retrieved_cards]
54
- source_cards = [str(card.get("card_id")) for card in cards if card.get("card_id")]
 
 
 
 
 
 
 
 
55
  if not source_cards:
56
- source_cards = ["SAFETY-BOUNDARIES-v1"]
57
 
58
  concern = intake.get("chief_concern") or intake.get("responder_note") or "Reported field concern"
59
  red_labels = [rule.get("label", rule.get("rule_id", "")) for rule in rule_results]
@@ -70,7 +78,8 @@ def canned_navigator_output(
70
  "card_id": card_id,
71
  "reason_relevant": "Retrieved from the confirmed intake and deterministic rule context.",
72
  }
73
- for card_id in source_cards[:3]
 
74
  ],
75
  "missing_info_to_collect": ["repeat vitals", "time course", "available referral route"],
76
  "next_observations_to_collect": ["level of alertness", "work of breathing", "hydration/perfusion signs"],
 
51
  ) -> dict[str, Any]:
52
  """Return a deterministic demo-safe navigator output."""
53
  cards = [item.get("card", item) for item in retrieved_cards]
54
+ retrieved_ids = [str(card.get("card_id")) for card in cards if card.get("card_id")]
55
+ source_cards: list[str] = []
56
+ for rule in rule_results:
57
+ card_id = str(rule.get("card_id", "")).strip()
58
+ if card_id and card_id not in source_cards:
59
+ source_cards.append(card_id)
60
+ for card_id in ("SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"):
61
+ if card_id in retrieved_ids and card_id not in source_cards:
62
+ source_cards.append(card_id)
63
  if not source_cards:
64
+ source_cards = [card_id for card_id in retrieved_ids if card_id] or ["SAFETY-BOUNDARIES-v1"]
65
 
66
  concern = intake.get("chief_concern") or intake.get("responder_note") or "Reported field concern"
67
  red_labels = [rule.get("label", rule.get("rule_id", "")) for rule in rule_results]
 
78
  "card_id": card_id,
79
  "reason_relevant": "Retrieved from the confirmed intake and deterministic rule context.",
80
  }
81
+ for card_id in source_cards
82
+ if card_id not in {"SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"}
83
  ],
84
  "missing_info_to_collect": ["repeat vitals", "time course", "available referral route"],
85
  "next_observations_to_collect": ["level of alertness", "work of breathing", "hydration/perfusion signs"],
figment/navigator.py CHANGED
@@ -8,17 +8,26 @@ from typing import Any
8
 
9
  from .config import FigmentConfig, load_config
10
  from .field_provenance import (
 
 
11
  accepted_raw_fields_from_failures,
12
  deterministic_field_provenance,
13
  has_deterministic_patches,
14
  merge_field_provenance,
15
  model_raw_field_provenance,
16
  )
17
- from .focused_repair import build_focused_repair_prompts
 
 
18
  from .model_client import ModelClient, ModelClientError, canned_navigator_output
 
 
 
 
 
19
  from .prompt_builder import build_prompt
20
  from .retrieval import known_card_ids, query_from_intake, search_protocol_cards
21
- from .trace import FigmentTrace, scrub_audio_metadata, stable_hash, write_trace
22
  from .validators import urgency_floor_from_rules, validate_audio_ready, validate_confirmed_intake, validate_navigator_output
23
 
24
 
@@ -52,6 +61,11 @@ def run_navigation(
52
  events = ["input captured", "rules evaluated", "cards retrieved", "navigator output generated"]
53
  fallback_reason: str | None = None
54
  field_provenance: dict[str, str] = {}
 
 
 
 
 
55
  repair_metrics: dict[str, Any] = _empty_repair_metrics()
56
  try:
57
  output = client.generate_json(
@@ -77,11 +91,29 @@ def run_navigation(
77
  if item.get("card_id") or item.get("card", {}).get("card_id")
78
  }
79
  card_ids.update(str(card_id) for card_id in output.get("source_cards", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
81
  if validation.passed and not field_provenance:
82
  field_provenance = (
83
  deterministic_field_provenance() if config.model_backend == "canned" else model_raw_field_provenance()
84
  )
 
85
  if not validation.passed and fallback_reason is None and config.model_backend != "canned":
86
  field_result = _try_field_level_model_output(
87
  client=client,
@@ -93,6 +125,7 @@ def run_navigation(
93
  rule_results=rule_results,
94
  retrieved=retrieved,
95
  known_cards=card_ids,
 
96
  events=events,
97
  repair_metrics=repair_metrics,
98
  )
@@ -100,12 +133,74 @@ def run_navigation(
100
  output, validation, field_provenance = field_result
101
  if not validation.passed:
102
  output = canned_navigator_output(intake, rule_results, retrieved, floor)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
104
  fallback_reason = fallback_reason or "navigator_validation_failure"
105
  field_provenance = deterministic_field_provenance()
106
  events.append("navigator output failed validation; deterministic fallback applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  events.append("validation complete")
108
  field_level_fallback_used = has_deterministic_patches(field_provenance)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  trace = FigmentTrace(
110
  input_captured={
111
  "structured_intake": intake,
@@ -115,16 +210,7 @@ def run_navigation(
115
  red_flags=rule_results,
116
  retrieved_card_ids=[item["card_id"] for item in retrieved],
117
  prompt_template_hash=prompt_hash,
118
- model_route={
119
- "model_stack": config.model_stack,
120
- "model_backend": config.model_backend,
121
- "model_id": config.active_model_id,
122
- "fallback_tier": "canned" if config.model_backend == "canned" or fallback_reason else "configured",
123
- "fallback_reason": fallback_reason,
124
- "field_level_fallback_used": field_level_fallback_used,
125
- "strict_validation": True,
126
- **repair_metrics,
127
- },
128
  navigator_output=output,
129
  validator_result=validation.to_dict(),
130
  field_provenance=field_provenance,
@@ -172,10 +258,19 @@ def _try_field_level_model_output(
172
  rule_results: list[dict[str, Any]],
173
  retrieved: list[dict[str, Any]],
174
  known_cards: set[str],
 
175
  events: list[str],
176
  repair_metrics: dict[str, Any],
177
  ) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
178
  fallback_output = canned_navigator_output(intake, rule_results, retrieved, floor)
 
 
 
 
 
 
 
 
179
  accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
180
  repair_context = {
181
  "intake": intake,
@@ -191,6 +286,7 @@ def _try_field_level_model_output(
191
  previous_output=raw_output,
192
  failures=validation_failures,
193
  urgency_floor=floor,
 
194
  )
195
  repair_metrics["repair_scope_count"] = len(focused_prompts)
196
  repair_metrics["repair_scopes"] = [focused_prompt.scope.name for focused_prompt in focused_prompts]
@@ -214,6 +310,13 @@ def _try_field_level_model_output(
214
  if not isinstance(repair_output, dict):
215
  events.append(f"navigator focused repair for {focused_prompt.scope.name} returned non-object output")
216
  continue
 
 
 
 
 
 
 
217
  for field in focused_prompt.scope.fields:
218
  if field in repair_output:
219
  repaired_fields[field] = repair_output[field]
@@ -233,12 +336,119 @@ def _try_field_level_model_output(
233
  if merged_validation.passed:
234
  if merge_result.provenance == deterministic_field_provenance():
235
  continue
 
 
 
 
236
  events.append(event_text)
237
  return merge_result.output, merged_validation, merge_result.provenance
238
  events.append("navigator retry failed validation")
239
  return None
240
 
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def _empty_repair_metrics() -> dict[str, Any]:
243
  return {
244
  "repair_attempt_count": 0,
@@ -247,9 +457,49 @@ def _empty_repair_metrics() -> dict[str, Any]:
247
  "repair_capped": False,
248
  "repair_latency_ms": 0.0,
249
  "repair_scopes": [],
 
 
 
250
  }
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  def _repair_prompt(
254
  original_prompt: str,
255
  previous_output: dict[str, Any],
 
8
 
9
  from .config import FigmentConfig, load_config
10
  from .field_provenance import (
11
+ DETERMINISTIC_FALLBACK,
12
+ MODEL_REPAIRED,
13
  accepted_raw_fields_from_failures,
14
  deterministic_field_provenance,
15
  has_deterministic_patches,
16
  merge_field_provenance,
17
  model_raw_field_provenance,
18
  )
19
+ from .eval_metrics import score_handoff_readiness
20
+ from .focused_repair import build_focused_repair_prompts, missing_mandatory_source_cards
21
+ from .harness_evidence import build_harness_evidence
22
  from .model_client import ModelClient, ModelClientError, canned_navigator_output
23
+ from .observation_targets import (
24
+ NavigationScaffoldResult,
25
+ apply_navigation_scaffolding,
26
+ required_observation_targets,
27
+ )
28
  from .prompt_builder import build_prompt
29
  from .retrieval import known_card_ids, query_from_intake, search_protocol_cards
30
+ from .trace import FigmentTrace, derive_model_route, scrub_audio_metadata, stable_hash, write_trace
31
  from .validators import urgency_floor_from_rules, validate_audio_ready, validate_confirmed_intake, validate_navigator_output
32
 
33
 
 
61
  events = ["input captured", "rules evaluated", "cards retrieved", "navigator output generated"]
62
  fallback_reason: str | None = None
63
  field_provenance: dict[str, str] = {}
64
+ scaffold_patched_fields: set[str] = set()
65
+ filled_required_observation_ids: list[str] = []
66
+ model_selected_required_observation_ids: list[str] = []
67
+ invalid_selected_required_observation_ids: list[str] = []
68
+ stripped_trace_only_fields: list[str] = []
69
  repair_metrics: dict[str, Any] = _empty_repair_metrics()
70
  try:
71
  output = client.generate_json(
 
91
  if item.get("card_id") or item.get("card", {}).get("card_id")
92
  }
93
  card_ids.update(str(card_id) for card_id in output.get("source_cards", []))
94
+ scaffold_result = apply_navigation_scaffolding(
95
+ output,
96
+ retrieved_cards=retrieved,
97
+ rule_results=rule_results,
98
+ urgency_floor=floor,
99
+ confirmed_intake=intake,
100
+ )
101
+ output = scaffold_result.output
102
+ _absorb_scaffold_trace(
103
+ scaffold_result,
104
+ scaffold_patched_fields=scaffold_patched_fields,
105
+ filled_required_observation_ids=filled_required_observation_ids,
106
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
107
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
108
+ stripped_trace_only_fields=stripped_trace_only_fields,
109
+ events=events,
110
+ )
111
  validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
112
  if validation.passed and not field_provenance:
113
  field_provenance = (
114
  deterministic_field_provenance() if config.model_backend == "canned" else model_raw_field_provenance()
115
  )
116
+ _mark_deterministic_patch_fields(field_provenance, scaffold_patched_fields)
117
  if not validation.passed and fallback_reason is None and config.model_backend != "canned":
118
  field_result = _try_field_level_model_output(
119
  client=client,
 
125
  rule_results=rule_results,
126
  retrieved=retrieved,
127
  known_cards=card_ids,
128
+ scaffold_patched_fields=scaffold_patched_fields,
129
  events=events,
130
  repair_metrics=repair_metrics,
131
  )
 
133
  output, validation, field_provenance = field_result
134
  if not validation.passed:
135
  output = canned_navigator_output(intake, rule_results, retrieved, floor)
136
+ fallback_scaffold = apply_navigation_scaffolding(
137
+ output,
138
+ retrieved_cards=retrieved,
139
+ rule_results=rule_results,
140
+ urgency_floor=floor,
141
+ confirmed_intake=intake,
142
+ )
143
+ output = fallback_scaffold.output
144
+ _absorb_scaffold_trace(
145
+ fallback_scaffold,
146
+ scaffold_patched_fields=scaffold_patched_fields,
147
+ filled_required_observation_ids=filled_required_observation_ids,
148
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
149
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
150
+ stripped_trace_only_fields=stripped_trace_only_fields,
151
+ events=events,
152
+ )
153
  validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
154
  fallback_reason = fallback_reason or "navigator_validation_failure"
155
  field_provenance = deterministic_field_provenance()
156
  events.append("navigator output failed validation; deterministic fallback applied")
157
+ elif config.model_backend != "canned":
158
+ competence_result = _try_handoff_competence_repair(
159
+ client=client,
160
+ prompt=prompt,
161
+ output=output,
162
+ validation=validation,
163
+ field_provenance=field_provenance,
164
+ floor=floor,
165
+ intake=intake,
166
+ rule_results=rule_results,
167
+ retrieved=retrieved,
168
+ known_cards=card_ids,
169
+ events=events,
170
+ repair_metrics=repair_metrics,
171
+ )
172
+ if competence_result is not None:
173
+ output, validation, field_provenance = competence_result
174
  events.append("validation complete")
175
  field_level_fallback_used = has_deterministic_patches(field_provenance)
176
+ model_route = {
177
+ "model_stack": config.model_stack,
178
+ "model_backend": config.model_backend,
179
+ "model_id": config.active_model_id,
180
+ "fallback_tier": "canned" if config.model_backend == "canned" or fallback_reason else "configured",
181
+ "fallback_reason": fallback_reason,
182
+ "field_level_fallback_used": field_level_fallback_used,
183
+ "strict_validation": True,
184
+ "deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
185
+ "filled_required_observation_ids": filled_required_observation_ids,
186
+ "model_selected_required_observation_ids": model_selected_required_observation_ids,
187
+ "invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
188
+ "stripped_trace_only_fields": stripped_trace_only_fields,
189
+ **repair_metrics,
190
+ }
191
+ model_route = derive_model_route(model_route, validation.to_dict(), events, field_provenance=field_provenance)
192
+ harness_evidence = build_harness_evidence(
193
+ confirmed_intake=intake,
194
+ retrieved_card_ids=[item["card_id"] for item in retrieved],
195
+ rule_results=rule_results,
196
+ urgency_floor=floor,
197
+ validator_result=validation.to_dict(),
198
+ final_output=output,
199
+ model_route=model_route,
200
+ audio=trace_audio,
201
+ )
202
+ output = dict(output)
203
+ output["harness_evidence"] = harness_evidence
204
  trace = FigmentTrace(
205
  input_captured={
206
  "structured_intake": intake,
 
210
  red_flags=rule_results,
211
  retrieved_card_ids=[item["card_id"] for item in retrieved],
212
  prompt_template_hash=prompt_hash,
213
+ model_route=model_route,
 
 
 
 
 
 
 
 
 
214
  navigator_output=output,
215
  validator_result=validation.to_dict(),
216
  field_provenance=field_provenance,
 
258
  rule_results: list[dict[str, Any]],
259
  retrieved: list[dict[str, Any]],
260
  known_cards: set[str],
261
+ scaffold_patched_fields: set[str],
262
  events: list[str],
263
  repair_metrics: dict[str, Any],
264
  ) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
265
  fallback_output = canned_navigator_output(intake, rule_results, retrieved, floor)
266
+ fallback_scaffold = apply_navigation_scaffolding(
267
+ fallback_output,
268
+ retrieved_cards=retrieved,
269
+ rule_results=rule_results,
270
+ urgency_floor=floor,
271
+ confirmed_intake=intake,
272
+ )
273
+ fallback_output = fallback_scaffold.output
274
  accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
275
  repair_context = {
276
  "intake": intake,
 
286
  previous_output=raw_output,
287
  failures=validation_failures,
288
  urgency_floor=floor,
289
+ required_observation_targets=required_observation_targets(retrieved),
290
  )
291
  repair_metrics["repair_scope_count"] = len(focused_prompts)
292
  repair_metrics["repair_scopes"] = [focused_prompt.scope.name for focused_prompt in focused_prompts]
 
310
  if not isinstance(repair_output, dict):
311
  events.append(f"navigator focused repair for {focused_prompt.scope.name} returned non-object output")
312
  continue
313
+ missing_source_cards = missing_mandatory_source_cards(focused_prompt.scope, repair_output)
314
+ if missing_source_cards:
315
+ events.append(
316
+ "navigator focused repair omitted mandatory source cards: "
317
+ + ", ".join(missing_source_cards)
318
+ )
319
+ continue
320
  for field in focused_prompt.scope.fields:
321
  if field in repair_output:
322
  repaired_fields[field] = repair_output[field]
 
336
  if merged_validation.passed:
337
  if merge_result.provenance == deterministic_field_provenance():
338
  continue
339
+ _mark_deterministic_patch_fields(
340
+ merge_result.provenance,
341
+ scaffold_patched_fields | fallback_scaffold.patched_fields,
342
+ )
343
  events.append(event_text)
344
  return merge_result.output, merged_validation, merge_result.provenance
345
  events.append("navigator retry failed validation")
346
  return None
347
 
348
 
349
+ def _try_handoff_competence_repair(
350
+ *,
351
+ client: ModelClient,
352
+ prompt: str,
353
+ output: dict[str, Any],
354
+ validation: Any,
355
+ field_provenance: dict[str, str],
356
+ floor: str,
357
+ intake: dict[str, Any],
358
+ rule_results: list[dict[str, Any]],
359
+ retrieved: list[dict[str, Any]],
360
+ known_cards: set[str],
361
+ events: list[str],
362
+ repair_metrics: dict[str, Any],
363
+ ) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
364
+ before_metrics = score_handoff_readiness(
365
+ output,
366
+ actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
367
+ source_card_ids=output.get("source_cards", []),
368
+ validation_result=validation.to_dict(),
369
+ )
370
+ repair_metrics["handoff_readiness_before"] = before_metrics
371
+ if before_metrics.get("handoff_readiness_passed") is True:
372
+ repair_metrics["competence_repair_attempted"] = False
373
+ return None
374
+
375
+ failures = _handoff_competence_failures(before_metrics)
376
+ focused_prompts = [
377
+ item
378
+ for item in build_focused_repair_prompts(
379
+ original_prompt=prompt,
380
+ previous_output=output,
381
+ failures=failures,
382
+ urgency_floor=floor,
383
+ required_observation_targets=required_observation_targets(retrieved),
384
+ )
385
+ if item.scope.name == "handoff_note_sbar"
386
+ ]
387
+ if not focused_prompts:
388
+ return None
389
+
390
+ repair_metrics["competence_repair_attempted"] = True
391
+ repair_metrics["competence_repair_scope"] = "handoff_note_sbar"
392
+ repair_context = {
393
+ "intake": intake,
394
+ "rule_results": rule_results,
395
+ "retrieved_cards": retrieved,
396
+ "urgency_floor": floor,
397
+ "previous_output": output,
398
+ "validation_failures": failures,
399
+ "handoff_readiness_metrics": before_metrics,
400
+ "repair_scope": "handoff_note_sbar",
401
+ }
402
+ try:
403
+ repair_output = client.generate_json(focused_prompts[0].prompt, repair_context)
404
+ except ModelClientError:
405
+ events.append("navigator handoff competence repair backend failed")
406
+ return None
407
+ if not isinstance(repair_output, dict) or not isinstance(repair_output.get("handoff_note_sbar"), dict):
408
+ events.append("navigator handoff competence repair returned no handoff_note_sbar")
409
+ return None
410
+
411
+ candidate = dict(output)
412
+ candidate["handoff_note_sbar"] = repair_output["handoff_note_sbar"]
413
+ scaffold = apply_navigation_scaffolding(
414
+ candidate,
415
+ retrieved_cards=retrieved,
416
+ rule_results=rule_results,
417
+ urgency_floor=floor,
418
+ confirmed_intake=intake,
419
+ )
420
+ merged_validation = _validate_output(scaffold.output, known_cards, floor, intake, rule_results, retrieved)
421
+ after_metrics = score_handoff_readiness(
422
+ scaffold.output,
423
+ actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
424
+ source_card_ids=scaffold.output.get("source_cards", []),
425
+ validation_result=merged_validation.to_dict(),
426
+ )
427
+ repair_metrics["handoff_readiness_after"] = after_metrics
428
+ if merged_validation.passed and after_metrics.get("handoff_readiness_passed") is True:
429
+ merged_provenance = dict(field_provenance)
430
+ merged_provenance["handoff_note_sbar"] = MODEL_REPAIRED
431
+ repair_metrics["competence_repair_success"] = True
432
+ events.append("navigator handoff competence repaired by focused retry")
433
+ return scaffold.output, merged_validation, merged_provenance
434
+
435
+ repair_metrics["competence_repair_success"] = False
436
+ events.append("navigator handoff competence repair did not pass readiness")
437
+ return None
438
+
439
+
440
+ def _handoff_competence_failures(metrics: dict[str, Any]) -> list[str]:
441
+ failures = ["handoff_note_sbar handoff_readiness_passed failed"]
442
+ for key, value in sorted(metrics.items()):
443
+ if key.startswith("sbar_") and value is False:
444
+ failures.append(f"handoff_note_sbar {key} failed")
445
+ elif key == "handoff_brevity_ok" and value is False:
446
+ failures.append("handoff_note_sbar handoff_brevity_ok failed")
447
+ elif key == "handoff_unsupported_fact_count" and value:
448
+ failures.append(f"handoff_note_sbar unsupported fact count: {value}")
449
+ return failures
450
+
451
+
452
  def _empty_repair_metrics() -> dict[str, Any]:
453
  return {
454
  "repair_attempt_count": 0,
 
457
  "repair_capped": False,
458
  "repair_latency_ms": 0.0,
459
  "repair_scopes": [],
460
+ "competence_repair_attempted": False,
461
+ "competence_repair_success": False,
462
+ "competence_repair_scope": None,
463
  }
464
 
465
 
466
+ def _absorb_scaffold_trace(
467
+ result: NavigationScaffoldResult,
468
+ *,
469
+ scaffold_patched_fields: set[str],
470
+ filled_required_observation_ids: list[str],
471
+ model_selected_required_observation_ids: list[str],
472
+ invalid_selected_required_observation_ids: list[str],
473
+ stripped_trace_only_fields: list[str],
474
+ events: list[str],
475
+ ) -> None:
476
+ scaffold_patched_fields.update(result.patched_fields)
477
+ _extend_unique(filled_required_observation_ids, result.filled_required_observation_ids)
478
+ _extend_unique(model_selected_required_observation_ids, result.model_selected_required_observation_ids)
479
+ _extend_unique(invalid_selected_required_observation_ids, result.invalid_selected_required_observation_ids)
480
+ _extend_unique(stripped_trace_only_fields, result.stripped_trace_only_fields)
481
+ if result.stripped_trace_only_fields:
482
+ events.append("trace-only required-observation target ids stripped")
483
+ if result.invalid_selected_required_observation_ids:
484
+ events.append("invalid required-observation target ids ignored")
485
+ if result.filled_required_observation_ids:
486
+ events.append("required-observation targets filled deterministically")
487
+ if "handoff_note_sbar" in result.patched_fields:
488
+ events.append("handoff SBAR scaffold applied deterministically")
489
+
490
+
491
+ def _extend_unique(items: list[str], values: list[str]) -> None:
492
+ for value in values:
493
+ if value not in items:
494
+ items.append(value)
495
+
496
+
497
+ def _mark_deterministic_patch_fields(provenance: dict[str, str], fields: set[str]) -> None:
498
+ for field in fields:
499
+ if field in provenance:
500
+ provenance[field] = DETERMINISTIC_FALLBACK
501
+
502
+
503
  def _repair_prompt(
504
  original_prompt: str,
505
  previous_output: dict[str, Any],
figment/prompt_builder.py CHANGED
@@ -5,9 +5,12 @@ from __future__ import annotations
5
  import json
6
  from typing import Any
7
 
 
8
  from .trace import stable_hash
9
 
10
 
 
 
11
  SYSTEM_PROMPT = """You are Figment, an offline protocol navigator for a trained responder.
12
  You are NOT a clinician. Do not diagnose and do not prescribe.
13
  Use ONLY the protocol cards provided below.
@@ -16,10 +19,19 @@ Rules:
16
  - Extract relevant facts from messy notes and mark them as reported, missing, unclear, or conflicting.
17
  - Treat audio draft text only as confirmed intake if the medic accepted or edited it; never treat unconfirmed audio drafts as facts.
18
  - Select candidate protocol pathways only from retrieved cards and cite every card you rely on in source_cards.
 
19
  - Stay inside the retrieved cards.
20
  - Use allowed_facts_inventory as the complete fact boundary. Do not introduce handoff facts that are absent from confirmed intake, deterministic rules, or retrieved cards.
21
  - Fill every key shown in REQUIRED_JSON_SKELETON, including every handoff_note_sbar subkey.
22
  - Use required_observations_inventory when choosing missing_info_to_collect and next_observations_to_collect.
 
 
 
 
 
 
 
 
23
  - Do not discharge, clear for discharge, or send anyone home. Do not provide autonomous routing; route only to local protocol, supervisor, clinician, or emergency pathway when the cited cards support it.
24
  - Do not give a drug dose unless a cited card explicitly contains it.
25
  - If critical info is missing, list it in missing_info_to_collect and prioritize the next 3 to 5 observations to collect.
@@ -28,7 +40,7 @@ Rules:
28
  - Denied or absent symptoms are absence facts, not red_flags. If deterministic_red_flags is empty and the urgency floor is routine, do not escalate only because an emergency card was retrieved.
29
  - If no relevant card was retrieved, direct the responder to local protocol, supervisor, clinician, or emergency pathway; do not improvise.
30
  - Refuse out-of-scope or unsafe requests via safety_boundary.
31
- - Return ONLY JSON matching the required navigator schema. No chain-of-thought."""
32
 
33
 
34
  OUTPUT_SCHEMA = {
@@ -89,13 +101,30 @@ def build_prompt(
89
  audio_draft: dict[str, Any] | None = None,
90
  ) -> tuple[str, str]:
91
  card_payload = [item.get("card", item) for item in retrieved_cards]
 
 
 
 
 
 
92
  context = {
93
  "structured_intake": intake,
94
  "deterministic_red_flags": rule_results,
95
  "protocol_urgency_floor": urgency_floor,
96
  "retrieved_protocol_cards": card_payload,
97
  "allowed_facts_inventory": _allowed_facts_inventory(intake, card_payload, rule_results, urgency_floor),
 
98
  "required_observations_inventory": _required_observations_inventory(card_payload),
 
 
 
 
 
 
 
 
 
 
99
  "routine_or_negated_case_guidance": ROUTINE_OR_NEGATED_CASE_GUIDANCE,
100
  "audio_draft_policy": {
101
  "audio_is_pre_navigation_only": True,
@@ -113,6 +142,86 @@ def build_prompt(
113
  return prompt, stable_hash(SYSTEM_PROMPT)
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def _allowed_facts_inventory(
117
  intake: dict[str, Any],
118
  card_payload: list[dict[str, Any]],
@@ -228,6 +337,28 @@ def _safe_audio_draft_context(audio_draft: dict[str, Any] | None) -> dict[str, A
228
  }
229
 
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  def _has_value(value: Any) -> bool:
232
  if value is None:
233
  return False
 
5
  import json
6
  from typing import Any
7
 
8
+ from .observation_targets import build_case_fact_ledger, build_handoff_note_sbar_template, required_observation_targets
9
  from .trace import stable_hash
10
 
11
 
12
+ SUPPORT_SOURCE_CARD_IDS = ("SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1")
13
+
14
  SYSTEM_PROMPT = """You are Figment, an offline protocol navigator for a trained responder.
15
  You are NOT a clinician. Do not diagnose and do not prescribe.
16
  Use ONLY the protocol cards provided below.
 
19
  - Extract relevant facts from messy notes and mark them as reported, missing, unclear, or conflicting.
20
  - Treat audio draft text only as confirmed intake if the medic accepted or edited it; never treat unconfirmed audio drafts as facts.
21
  - Select candidate protocol pathways only from retrieved cards and cite every card you rely on in source_cards.
22
+ - Keep candidate_protocol_pathways focused on the clinical target/escalation pathway. Do not add SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1 as candidate pathways unless that support card itself is the target pathway.
23
  - Stay inside the retrieved cards.
24
  - Use allowed_facts_inventory as the complete fact boundary. Do not introduce handoff facts that are absent from confirmed intake, deterministic rules, or retrieved cards.
25
  - Fill every key shown in REQUIRED_JSON_SKELETON, including every handoff_note_sbar subkey.
26
  - Use required_observations_inventory when choosing missing_info_to_collect and next_observations_to_collect.
27
+ - Choose required observation IDs before writing observation text.
28
+ - Emit selected_required_observation_ids as a trace-only key when selecting any required_observation_targets. Include every mandatory_required_observation_target_id.
29
+ - Use mandatory_required_observation_targets as the compact checklist for model-owned observation text. Copy each display_text into missing_info_to_collect before scaffold fill.
30
+ - Include every mandatory_source_card_id in source_cards. This includes deterministic clinical cards and retrieved support cards used for safety/SBAR.
31
+ - Cover required_observation_targets by id when writing missing_info_to_collect and next_observations_to_collect.
32
+ - For each required_observation_target on a cited clinical source card, make the target display_text visible in missing_info_to_collect. Use the same display_text or a very close responder-facing wording; if already partly reported, frame it as confirm/document.
33
+ - Use case_fact_ledger.present for supported facts and case_fact_ledger.absent_or_denied for symptoms that must not become red_flags.
34
+ - Use handoff_note_sbar_template as the grounded SBAR starting point; rewrite only within confirmed intake, deterministic rules, and cited cards.
35
  - Do not discharge, clear for discharge, or send anyone home. Do not provide autonomous routing; route only to local protocol, supervisor, clinician, or emergency pathway when the cited cards support it.
36
  - Do not give a drug dose unless a cited card explicitly contains it.
37
  - If critical info is missing, list it in missing_info_to_collect and prioritize the next 3 to 5 observations to collect.
 
40
  - Denied or absent symptoms are absence facts, not red_flags. If deterministic_red_flags is empty and the urgency floor is routine, do not escalate only because an emergency card was retrieved.
41
  - If no relevant card was retrieved, direct the responder to local protocol, supervisor, clinician, or emergency pathway; do not improvise.
42
  - Refuse out-of-scope or unsafe requests via safety_boundary.
43
+ - Return ONLY JSON matching the required navigator schema, plus selected_required_observation_ids when required observation targets are selected. No chain-of-thought."""
44
 
45
 
46
  OUTPUT_SCHEMA = {
 
101
  audio_draft: dict[str, Any] | None = None,
102
  ) -> tuple[str, str]:
103
  card_payload = [item.get("card", item) for item in retrieved_cards]
104
+ mandatory_source_card_ids = _mandatory_source_card_ids(rule_results, card_payload)
105
+ observation_targets = required_observation_targets(card_payload)
106
+ mandatory_required_observation_target_ids = _mandatory_required_observation_target_ids(
107
+ observation_targets,
108
+ mandatory_source_card_ids,
109
+ )
110
  context = {
111
  "structured_intake": intake,
112
  "deterministic_red_flags": rule_results,
113
  "protocol_urgency_floor": urgency_floor,
114
  "retrieved_protocol_cards": card_payload,
115
  "allowed_facts_inventory": _allowed_facts_inventory(intake, card_payload, rule_results, urgency_floor),
116
+ "mandatory_source_card_ids": mandatory_source_card_ids,
117
  "required_observations_inventory": _required_observations_inventory(card_payload),
118
+ "required_observation_targets": observation_targets,
119
+ "mandatory_required_observation_target_ids": mandatory_required_observation_target_ids,
120
+ "mandatory_required_observation_targets": _mandatory_required_observation_targets(
121
+ observation_targets,
122
+ mandatory_required_observation_target_ids,
123
+ ),
124
+ "required_observation_generation_policy": _required_observation_generation_policy(),
125
+ "case_fact_ledger": build_case_fact_ledger(intake),
126
+ "handoff_note_sbar_template": build_handoff_note_sbar_template(intake, rule_results, urgency_floor),
127
+ "internal_generation_contract": _internal_generation_contract(),
128
  "routine_or_negated_case_guidance": ROUTINE_OR_NEGATED_CASE_GUIDANCE,
129
  "audio_draft_policy": {
130
  "audio_is_pre_navigation_only": True,
 
142
  return prompt, stable_hash(SYSTEM_PROMPT)
143
 
144
 
145
+ def _mandatory_source_card_ids(rule_results: list[dict[str, Any]], card_payload: list[dict[str, Any]]) -> list[str]:
146
+ card_ids: list[str] = []
147
+ for rule in rule_results:
148
+ card_id = str(rule.get("card_id", "")).strip()
149
+ if card_id and card_id not in card_ids:
150
+ card_ids.append(card_id)
151
+ retrieved_ids = {str(card.get("card_id", "")).strip() for card in card_payload}
152
+ for card_id in SUPPORT_SOURCE_CARD_IDS:
153
+ if card_id in retrieved_ids and card_id not in card_ids:
154
+ card_ids.append(card_id)
155
+ return card_ids
156
+
157
+
158
+ def _mandatory_required_observation_target_ids(
159
+ observation_targets: list[dict[str, Any]],
160
+ mandatory_source_card_ids: list[str],
161
+ ) -> list[str]:
162
+ mandatory_cards = set(mandatory_source_card_ids)
163
+ ids: list[str] = []
164
+ for target in observation_targets:
165
+ card_id = str(target.get("card_id", "")).strip()
166
+ target_id = str(target.get("id", "")).strip()
167
+ if card_id in SUPPORT_SOURCE_CARD_IDS:
168
+ continue
169
+ if card_id in mandatory_cards and target_id and target_id not in ids:
170
+ ids.append(target_id)
171
+ return ids
172
+
173
+
174
+ def _mandatory_required_observation_targets(
175
+ observation_targets: list[dict[str, Any]],
176
+ mandatory_required_observation_target_ids: list[str],
177
+ ) -> list[dict[str, Any]]:
178
+ mandatory_ids = set(mandatory_required_observation_target_ids)
179
+ return [
180
+ {
181
+ "id": str(target.get("id", "")).strip(),
182
+ "card_id": str(target.get("card_id", "")).strip(),
183
+ "title": str(target.get("title", "")).strip(),
184
+ "display_text": str(target.get("display_text", "")).strip(),
185
+ }
186
+ for target in observation_targets
187
+ if str(target.get("id", "")).strip() in mandatory_ids
188
+ ]
189
+
190
+
191
+ def _required_observation_generation_policy() -> dict[str, Any]:
192
+ return {
193
+ "model_owned_not_scaffold_filled": True,
194
+ "mandatory_required_observation_targets": (
195
+ "This compact list is the model-owned checklist. Every display_text in "
196
+ "mandatory_required_observation_targets must appear in missing_info_to_collect, even when "
197
+ "next_observations_to_collect stays prioritized and concise."
198
+ ),
199
+ "source_card_scope": (
200
+ "For every required_observation_target whose card_id is in source_cards and is not "
201
+ "SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1, the assistant output itself must make "
202
+ "that target visible in missing_info_to_collect."
203
+ ),
204
+ "text_requirement": (
205
+ "Use the target display_text exactly when it is short and responder-facing. If the "
206
+ "fact is already partly reported, still include the cue as confirm/document wording."
207
+ ),
208
+ "next_observations_to_collect": (
209
+ "Prioritize the most urgent 3 to 5 required observation display_text cues plus any "
210
+ "case-specific vital signs; missing_info_to_collect may carry the fuller set."
211
+ ),
212
+ "source_cards": (
213
+ "source_cards must include every mandatory_source_card_id. The mandatory list includes "
214
+ "deterministic clinical cards plus retrieved SAFETY-BOUNDARIES-v1 and REFERRAL-SBAR-v1 "
215
+ "when those support cards are used for safety or SBAR fields."
216
+ ),
217
+ "selected_required_observation_ids": (
218
+ "When selected_required_observation_ids is emitted, include every "
219
+ "mandatory_required_observation_target_id and any additional target id whose display_text is visible "
220
+ "in missing_info_to_collect or next_observations_to_collect. This key is trace-only and will be stripped."
221
+ ),
222
+ }
223
+
224
+
225
  def _allowed_facts_inventory(
226
  intake: dict[str, Any],
227
  card_payload: list[dict[str, Any]],
 
337
  }
338
 
339
 
340
+ def _internal_generation_contract() -> dict[str, Any]:
341
+ return {
342
+ "trace_only_keys": ["selected_required_observation_ids"],
343
+ "required_when_required_observation_targets_selected": ["selected_required_observation_ids"],
344
+ "selected_required_observation_ids": (
345
+ "selected_required_observation_ids must be emitted when any required_observation_targets are selected. "
346
+ "It must include every mandatory_required_observation_target_id and may include additional ids from "
347
+ "required_observation_targets that are covered in missing_info_to_collect or "
348
+ "next_observations_to_collect. Select ids first, then write recognizable responder-facing observation "
349
+ "text for each selected id. This key is trace-only."
350
+ ),
351
+ "strip_before_user_display": True,
352
+ }
353
+
354
+
355
+ def _first_text(*values: Any) -> str:
356
+ for value in values:
357
+ if _has_value(value):
358
+ return str(value).strip()
359
+ return ""
360
+
361
+
362
  def _has_value(value: Any) -> bool:
363
  if value is None:
364
  return False
figment/rules.py CHANGED
@@ -43,6 +43,18 @@ def _find(patterns: tuple[str, ...], text: str) -> str | None:
43
  return None
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def _is_pediatric(intake: Mapping[str, Any], text: str) -> bool:
47
  age = str(intake.get("patient_age", "")).strip().lower()
48
  month_match = re.search(r"\b(\d{1,3})\s*(?:mo|mos|month|months)\b", age)
@@ -104,6 +116,31 @@ def _has_positive_fever(text: str) -> bool:
104
  return False
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def _rule(rule_id: str, label: str, urgency: Urgency, evidence: str, card_id: str) -> RuleResult:
108
  return RuleResult(rule_id=rule_id, label=label, urgency=urgency, evidence=evidence, card_id=card_id)
109
 
@@ -166,7 +203,7 @@ def _pregnancy_danger(intake: Mapping[str, Any], text: str) -> RuleResult | None
166
 
167
 
168
  def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
169
- evidence = _find(
170
  (
171
  r"\bchest pain\b",
172
  r"\bchest pressure\b",
@@ -174,6 +211,7 @@ def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
174
  r"\bchest pain with (?:shortness of breath|sweating|fainting|severe weakness)\b",
175
  ),
176
  text,
 
177
  )
178
  if not evidence:
179
  return None
 
43
  return None
44
 
45
 
46
+ def _find_unnegated(
47
+ patterns: tuple[str, ...],
48
+ text: str,
49
+ negation_check: Any,
50
+ ) -> str | None:
51
+ for pattern in patterns:
52
+ for match in re.finditer(pattern, text, re.IGNORECASE):
53
+ if not negation_check(text, match):
54
+ return match.group(0).strip()
55
+ return None
56
+
57
+
58
  def _is_pediatric(intake: Mapping[str, Any], text: str) -> bool:
59
  age = str(intake.get("patient_age", "")).strip().lower()
60
  month_match = re.search(r"\b(\d{1,3})\s*(?:mo|mos|month|months)\b", age)
 
116
  return False
117
 
118
 
119
+ def _is_negated_chest_match(text: str, match: re.Match[str]) -> bool:
120
+ start = match.start()
121
+ clause_start = max(text.rfind(boundary, 0, start) for boundary in (".", ";", "!", "?", "\n"))
122
+ prefix = text[clause_start + 1 : start].lower()
123
+
124
+ direct_negation = re.search(
125
+ r"\b(?:no|not|without|denies|denied|negative for|reports no)\s+"
126
+ r"(?:any\s+|active\s+|current(?:ly)?\s+|reported\s+)?$",
127
+ prefix,
128
+ )
129
+ if direct_negation:
130
+ return True
131
+
132
+ denial_marker = None
133
+ for marker in re.finditer(r"\b(?:denies|denied|negative for)\b", prefix):
134
+ denial_marker = marker
135
+ if not denial_marker:
136
+ return False
137
+
138
+ between = prefix[denial_marker.end() :]
139
+ if re.search(r"\b(?:but|however|except|except for|now|currently)\b", between):
140
+ return False
141
+ return True
142
+
143
+
144
  def _rule(rule_id: str, label: str, urgency: Urgency, evidence: str, card_id: str) -> RuleResult:
145
  return RuleResult(rule_id=rule_id, label=label, urgency=urgency, evidence=evidence, card_id=card_id)
146
 
 
203
 
204
 
205
  def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
206
+ evidence = _find_unnegated(
207
  (
208
  r"\bchest pain\b",
209
  r"\bchest pressure\b",
 
211
  r"\bchest pain with (?:shortness of breath|sweating|fainting|severe weakness)\b",
212
  ),
213
  text,
214
+ _is_negated_chest_match,
215
  )
216
  if not evidence:
217
  return None
figment/validators.py CHANGED
@@ -155,7 +155,13 @@ def validate_navigator_output(
155
  retrieved_cards_by_id = _retrieved_cards_by_id(retrieved_cards or [])
156
  if retrieved_card_ids is None and retrieved_cards_by_id:
157
  retrieved_card_ids = set(retrieved_cards_by_id)
 
 
 
 
 
158
  allowed_cards = set(retrieved_card_ids) if retrieved_card_ids is not None else known_cards
 
159
  source_cards = output.get("source_cards")
160
  if not isinstance(source_cards, list) or not source_cards:
161
  result.add("source_cards must be a non-empty list")
@@ -265,9 +271,16 @@ def _validate_missing_observations_against_cards(
265
  required_observations = card.get("required_observations", [])
266
  if not isinstance(required_observations, list) or not required_observations:
267
  continue
268
- required_sets = [_grounding_tokens(observation) for observation in required_observations]
269
- if not any(tokens & observation_tokens for tokens in required_sets):
270
- result.add(f"missing_info_to_collect does not reference required observations for {card_id}")
 
 
 
 
 
 
 
271
 
272
 
273
  def _is_negated_safety_phrase(text: str, match_start: int) -> bool:
 
155
  retrieved_cards_by_id = _retrieved_cards_by_id(retrieved_cards or [])
156
  if retrieved_card_ids is None and retrieved_cards_by_id:
157
  retrieved_card_ids = set(retrieved_cards_by_id)
158
+ fired_rule_card_ids = {
159
+ str(rule.get("card_id", "")).strip()
160
+ for rule in rule_results or []
161
+ if str(rule.get("card_id", "")).strip()
162
+ }
163
  allowed_cards = set(retrieved_card_ids) if retrieved_card_ids is not None else known_cards
164
+ allowed_cards.update(fired_rule_card_ids & known_cards)
165
  source_cards = output.get("source_cards")
166
  if not isinstance(source_cards, list) or not source_cards:
167
  result.add("source_cards must be a non-empty list")
 
271
  required_observations = card.get("required_observations", [])
272
  if not isinstance(required_observations, list) or not required_observations:
273
  continue
274
+ missing_required_observations = []
275
+ for observation in required_observations:
276
+ required_tokens = _grounding_tokens(observation)
277
+ if required_tokens and not required_tokens <= observation_tokens:
278
+ missing_required_observations.append(str(observation))
279
+ if missing_required_observations:
280
+ result.add(
281
+ "missing_info_to_collect does not reference required observations for "
282
+ f"{card_id}: {', '.join(missing_required_observations[:8])}"
283
+ )
284
 
285
 
286
  def _is_negated_safety_phrase(text: str, match_start: int) -> bool:
scripts/run_eval.py CHANGED
@@ -5,31 +5,43 @@ from __future__ import annotations
5
  import argparse
6
  from dataclasses import replace
7
  import json
 
8
  from pathlib import Path
9
  import sys
10
  from time import perf_counter
11
  from typing import Any
 
 
 
12
 
13
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
14
  if str(PROJECT_ROOT) not in sys.path:
15
  sys.path.insert(0, str(PROJECT_ROOT))
16
 
17
- from figment.config import FigmentConfig
18
- from figment.eval_metrics import score_expected_labels, summarize_eval_records
19
- from figment.field_provenance import (
 
 
20
  accepted_raw_fields_from_failures,
21
  deterministic_field_provenance,
22
  has_deterministic_patches,
23
  merge_field_provenance,
24
  model_raw_field_provenance,
 
 
 
 
 
 
 
 
25
  )
26
- from figment.focused_repair import build_focused_repair_prompts
27
- from figment.model_client import ModelClient, ModelClientError, canned_navigator_output
28
- from figment.prompt_builder import build_prompt
29
- from figment.retrieval import known_card_ids, query_from_intake, search_protocol_cards
30
- from figment.rules import run_red_flag_checks
31
- from figment.trace import stable_hash
32
- from figment.validators import urgency_floor_from_rules, validate_navigator_output
33
 
34
 
35
  DEFAULT_CASE_GLOB = "data/eval/*.jsonl"
@@ -78,7 +90,10 @@ def run_eval(
78
  else:
79
  for record in records:
80
  sys.stdout.write(f"{json.dumps(record, sort_keys=True)}\n")
81
- return _summarize(records, config, case_paths, output_path)
 
 
 
82
 
83
 
84
  def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any]:
@@ -102,9 +117,22 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
102
  repair_attempted = False
103
  fallback_used = False
104
  fallback_reason: str | None = None
 
 
 
 
 
 
 
 
105
  final_output: dict[str, Any]
106
  final_validation: dict[str, Any]
107
  field_provenance: dict[str, str] = {}
 
 
 
 
 
108
 
109
  context = {
110
  "intake": intake,
@@ -116,7 +144,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
116
  if config.model_backend == "canned":
117
  fallback_reason = "canned_backend"
118
  fallback_used = True
119
- fallback_output, fallback_validation = _run_fallback(
120
  intake,
121
  rule_results,
122
  retrieved,
@@ -124,6 +152,14 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
124
  known_cards,
125
  retrieved_ids,
126
  )
 
 
 
 
 
 
 
 
127
  final_output = fallback_output
128
  final_validation = fallback_validation
129
  field_provenance = deterministic_field_provenance()
@@ -131,18 +167,74 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
131
  client = ModelClient(config)
132
  try:
133
  raw_output = client.generate_json(prompt, context)
134
- raw_validation = _validate_output(raw_output, known_cards, floor, intake, rule_results, retrieved, retrieved_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  except ModelClientError as exc:
136
  raw_validation = {"passed": False, "failures": [f"model backend error: {exc}"]}
137
  fallback_reason = "model_backend_error"
138
 
139
- if raw_output is not None and raw_validation["passed"]:
140
- final_output = raw_output
141
  final_validation = raw_validation
142
  field_provenance = model_raw_field_provenance()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  else:
144
- if raw_output is not None:
145
- fallback_output, fallback_validation = _run_fallback(
146
  intake,
147
  rule_results,
148
  retrieved,
@@ -161,7 +253,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
161
  client=client,
162
  prompt=prompt,
163
  context=context,
164
- raw_output=raw_output,
165
  validation_failures=raw_validation["failures"],
166
  fallback_output=fallback_output,
167
  known_cards=known_cards,
@@ -170,20 +262,41 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
170
  rule_results=rule_results,
171
  retrieved=retrieved,
172
  retrieved_ids=retrieved_ids,
 
173
  )
174
  if merged_output is not None and merged_validation is not None:
175
  final_output = merged_output
176
  final_validation = merged_validation
177
  field_provenance = merged_field_provenance
 
 
 
 
 
 
 
 
 
 
 
 
178
  else:
179
  fallback_reason = fallback_reason or "navigator_validation_failure"
180
  fallback_used = True
181
  final_output = fallback_output
182
  final_validation = fallback_validation
183
  field_provenance = deterministic_field_provenance()
 
 
 
 
 
 
 
 
184
  else:
185
  fallback_used = True
186
- fallback_output, fallback_validation = _run_fallback(
187
  intake,
188
  rule_results,
189
  retrieved,
@@ -191,17 +304,93 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
191
  known_cards,
192
  retrieved_ids,
193
  )
 
 
 
 
 
 
 
 
194
  final_output = fallback_output
195
  final_validation = fallback_validation
196
  field_provenance = deterministic_field_provenance()
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  field_level_fallback_used = has_deterministic_patches(field_provenance)
199
 
200
- raw_success = raw_attempted and raw_validation["passed"]
201
  repair_success = repair_attempted and repair_validation["passed"]
202
  fallback_success = fallback_used and fallback_validation["passed"]
203
  fallback_tier = "canned" if fallback_used else "configured"
204
- competence_success = bool(raw_success or repair_success)
205
  model_route = {
206
  "model_stack": config.model_stack,
207
  "model_backend": config.model_backend,
@@ -209,7 +398,24 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
209
  "fallback_tier": fallback_tier,
210
  "fallback_reason": fallback_reason,
211
  "field_level_fallback_used": field_level_fallback_used,
 
 
 
 
 
212
  }
 
 
 
 
 
 
 
 
 
 
 
 
213
  trace_payload = {
214
  "case_id": case["case_id"],
215
  "input_hash": stable_hash(intake),
@@ -217,6 +423,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
217
  "retrieved_card_ids": retrieved_ids,
218
  "prompt_template_hash": prompt_hash,
219
  "model_route": model_route,
 
220
  "navigator_output": final_output,
221
  "validator_result": final_validation,
222
  "field_provenance": field_provenance,
@@ -236,7 +443,11 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
236
  "expected_min_protocol_urgency": case.get("expected_min_protocol_urgency"),
237
  "expected_red_flag_rule_ids": case.get("expected_red_flag_rule_ids", []),
238
  "expected_source_card_ids": case.get("expected_source_card_ids", []),
 
239
  "expected_missing_observations": case.get("expected_missing_observations", []),
 
 
 
240
  "forbidden_behavior": case.get("forbidden_behavior", []),
241
  "actual_red_flag_rule_ids": [rule["rule_id"] for rule in rule_results],
242
  "actual_protocol_urgency": final_output.get("protocol_urgency"),
@@ -249,20 +460,36 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
249
  "fallback_tier": fallback_tier,
250
  "fallback_reason": fallback_reason,
251
  "field_level_fallback_used": field_level_fallback_used,
 
 
 
 
 
252
  "raw_configured_model_attempted": raw_attempted,
253
  "raw_configured_model_success": raw_success,
254
  "repair_attempted": repair_attempted,
255
  "repair_success": repair_success,
 
 
 
 
 
 
 
256
  "canned_fallback_used": fallback_used,
257
  "canned_fallback_success": fallback_success,
258
  "competence_success": competence_success,
259
  "raw_validation": raw_validation,
260
  "repair_validation": repair_validation,
 
261
  "fallback_validation": fallback_validation,
262
  "validation_result": final_validation,
263
  "final_validation": final_validation,
 
264
  "raw_model_output": raw_output,
 
265
  "repaired_output": repaired_output,
 
266
  "fallback_output": fallback_output,
267
  "final_output": final_output,
268
  "field_provenance": field_provenance,
@@ -280,10 +507,56 @@ def _run_fallback(
280
  floor: str,
281
  known_cards: set[str],
282
  retrieved_ids: list[str],
283
- ) -> tuple[dict[str, Any], dict[str, Any]]:
284
  output = canned_navigator_output(intake, rule_results, retrieved, floor)
 
 
 
 
 
 
 
 
285
  validation = _validate_output(output, known_cards, floor, intake, rule_results, retrieved, retrieved_ids)
286
- return output, validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
 
289
  def _validate_output(
@@ -321,6 +594,7 @@ def _try_field_level_model_output(
321
  rule_results: list[dict[str, Any]],
322
  retrieved: list[dict[str, Any]],
323
  retrieved_ids: list[str],
 
324
  ) -> tuple[dict[str, Any] | None, dict[str, Any], bool, dict[str, Any] | None, dict[str, Any] | None, dict[str, str]]:
325
  accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
326
  repaired_fields: dict[str, Any] = {}
@@ -331,6 +605,7 @@ def _try_field_level_model_output(
331
  previous_output=raw_output,
332
  failures=validation_failures,
333
  urgency_floor=floor,
 
334
  ):
335
  repair_attempted = True
336
  try:
@@ -349,9 +624,24 @@ def _try_field_level_model_output(
349
  if not isinstance(repair_output, dict):
350
  repair_validation = {"passed": False, "failures": ["repair output was not an object"]}
351
  continue
 
 
 
 
 
 
 
 
 
352
  for field in focused_prompt.scope.fields:
353
  if field in repair_output:
354
- repaired_fields[field] = repair_output[field]
 
 
 
 
 
 
355
 
356
  merge_candidates = []
357
  if repaired_fields:
@@ -376,6 +666,7 @@ def _try_field_level_model_output(
376
  if merged_validation["passed"]:
377
  if merge_result.provenance == deterministic_field_provenance():
378
  continue
 
379
  if candidate_repaired_fields:
380
  repair_validation = merged_validation
381
  return (
@@ -391,6 +682,41 @@ def _try_field_level_model_output(
391
  return None, repair_validation, repair_attempted, None, None, {}
392
 
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  def _repair_prompt(
395
  original_prompt: str,
396
  previous_output: dict[str, Any],
@@ -442,6 +768,9 @@ def _summarize(
442
  "output_path": str(output_path) if output_path else None,
443
  }
444
  )
 
 
 
445
  if config.model_backend == "llama_cpp":
446
  summary["local_llm_evidence"] = _local_llm_evidence_summary(summary, config)
447
  return summary
@@ -456,6 +785,15 @@ def _local_llm_evidence_summary(summary: dict[str, Any], config: FigmentConfig)
456
  "model_stack": config.model_stack,
457
  "model_id": config.active_model_id,
458
  "llama_base_url": config.llama_base_url,
 
 
 
 
 
 
 
 
 
459
  "total_cases": total_cases,
460
  "competence_successes": competence_successes,
461
  "raw_configured_model_successes": summary.get("raw_configured_model_successes", 0),
@@ -472,6 +810,109 @@ def _local_llm_evidence_summary(summary: dict[str, Any], config: FigmentConfig)
472
  }
473
 
474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  def main(argv: list[str] | None = None) -> int:
476
  parser = argparse.ArgumentParser(description=__doc__)
477
  parser.add_argument("--backend", choices=["canned", "hosted_omni", "llama_cpp"], default="canned")
 
5
  import argparse
6
  from dataclasses import replace
7
  import json
8
+ import os
9
  from pathlib import Path
10
  import sys
11
  from time import perf_counter
12
  from typing import Any
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
 
17
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
  if str(PROJECT_ROOT) not in sys.path:
19
  sys.path.insert(0, str(PROJECT_ROOT))
20
 
21
+ from figment.config import FigmentConfig # noqa: E402
22
+ from figment.eval_metrics import score_expected_labels, score_handoff_readiness, summarize_eval_records # noqa: E402
23
+ from figment.field_provenance import ( # noqa: E402
24
+ DETERMINISTIC_FALLBACK,
25
+ MODEL_REPAIRED,
26
  accepted_raw_fields_from_failures,
27
  deterministic_field_provenance,
28
  has_deterministic_patches,
29
  merge_field_provenance,
30
  model_raw_field_provenance,
31
+ ) # noqa: E402
32
+ from figment.focused_repair import build_focused_repair_prompts, missing_mandatory_source_cards # noqa: E402
33
+ from figment.harness_evidence import build_harness_evidence # noqa: E402
34
+ from figment.model_client import ModelClient, ModelClientError, canned_navigator_output # noqa: E402
35
+ from figment.observation_targets import ( # noqa: E402
36
+ NavigationScaffoldResult,
37
+ apply_navigation_scaffolding,
38
+ required_observation_targets,
39
  )
40
+ from figment.prompt_builder import build_prompt # noqa: E402
41
+ from figment.retrieval import known_card_ids, query_from_intake, search_protocol_cards # noqa: E402
42
+ from figment.rules import run_red_flag_checks # noqa: E402
43
+ from figment.trace import derive_model_route, stable_hash # noqa: E402
44
+ from figment.validators import urgency_floor_from_rules, validate_navigator_output # noqa: E402
 
 
45
 
46
 
47
  DEFAULT_CASE_GLOB = "data/eval/*.jsonl"
 
90
  else:
91
  for record in records:
92
  sys.stdout.write(f"{json.dumps(record, sort_keys=True)}\n")
93
+ summary = _summarize(records, config, case_paths, output_path)
94
+ if output_path is not None:
95
+ _write_eval_bundle_metadata(summary, records, config, case_paths, output_path)
96
+ return summary
97
 
98
 
99
  def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any]:
 
117
  repair_attempted = False
118
  fallback_used = False
119
  fallback_reason: str | None = None
120
+ competence_repair_attempted = False
121
+ competence_repair_success = False
122
+ competence_repair_scope: str | None = None
123
+ competence_repaired_output: dict[str, Any] | None = None
124
+ competence_repair_validation = {"passed": False, "failures": ["competence repair not attempted"]}
125
+ scaffolded_model_output: dict[str, Any] | None = None
126
+ handoff_readiness_before: dict[str, Any] | None = None
127
+ handoff_readiness_after: dict[str, Any] | None = None
128
  final_output: dict[str, Any]
129
  final_validation: dict[str, Any]
130
  field_provenance: dict[str, str] = {}
131
+ scaffold_patched_fields: set[str] = set()
132
+ filled_required_observation_ids: list[str] = []
133
+ model_selected_required_observation_ids: list[str] = []
134
+ invalid_selected_required_observation_ids: list[str] = []
135
+ stripped_trace_only_fields: list[str] = []
136
 
137
  context = {
138
  "intake": intake,
 
144
  if config.model_backend == "canned":
145
  fallback_reason = "canned_backend"
146
  fallback_used = True
147
+ fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
148
  intake,
149
  rule_results,
150
  retrieved,
 
152
  known_cards,
153
  retrieved_ids,
154
  )
155
+ _absorb_scaffold_trace(
156
+ fallback_scaffold,
157
+ scaffold_patched_fields=scaffold_patched_fields,
158
+ filled_required_observation_ids=filled_required_observation_ids,
159
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
160
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
161
+ stripped_trace_only_fields=stripped_trace_only_fields,
162
+ )
163
  final_output = fallback_output
164
  final_validation = fallback_validation
165
  field_provenance = deterministic_field_provenance()
 
167
  client = ModelClient(config)
168
  try:
169
  raw_output = client.generate_json(prompt, context)
170
+ scaffold_result = apply_navigation_scaffolding(
171
+ raw_output,
172
+ retrieved_cards=retrieved,
173
+ rule_results=rule_results,
174
+ urgency_floor=floor,
175
+ confirmed_intake=intake,
176
+ )
177
+ scaffolded_model_output = scaffold_result.output
178
+ _absorb_scaffold_trace(
179
+ scaffold_result,
180
+ scaffold_patched_fields=scaffold_patched_fields,
181
+ filled_required_observation_ids=filled_required_observation_ids,
182
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
183
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
184
+ stripped_trace_only_fields=stripped_trace_only_fields,
185
+ )
186
+ raw_validation = _validate_output(
187
+ scaffolded_model_output,
188
+ known_cards,
189
+ floor,
190
+ intake,
191
+ rule_results,
192
+ retrieved,
193
+ retrieved_ids,
194
+ )
195
  except ModelClientError as exc:
196
  raw_validation = {"passed": False, "failures": [f"model backend error: {exc}"]}
197
  fallback_reason = "model_backend_error"
198
 
199
+ if scaffolded_model_output is not None and raw_validation["passed"]:
200
+ final_output = scaffolded_model_output
201
  final_validation = raw_validation
202
  field_provenance = model_raw_field_provenance()
203
+ _mark_deterministic_patch_fields(field_provenance, scaffold_patched_fields)
204
+ patch_repair_failures = _observation_patch_repair_failures(
205
+ filled_required_observation_ids,
206
+ scaffold_patched_fields,
207
+ )
208
+ if patch_repair_failures and raw_output is not None:
209
+ (
210
+ repaired_output,
211
+ repair_validation,
212
+ repair_attempted,
213
+ merged_output,
214
+ merged_validation,
215
+ merged_field_provenance,
216
+ ) = _try_field_level_model_output(
217
+ client=client,
218
+ prompt=prompt,
219
+ context=context,
220
+ raw_output=raw_output,
221
+ validation_failures=patch_repair_failures,
222
+ fallback_output=scaffolded_model_output,
223
+ known_cards=known_cards,
224
+ floor=floor,
225
+ intake=intake,
226
+ rule_results=rule_results,
227
+ retrieved=retrieved,
228
+ retrieved_ids=retrieved_ids,
229
+ scaffold_patched_fields=scaffold_patched_fields,
230
+ )
231
+ if merged_output is not None and merged_validation is not None:
232
+ final_output = merged_output
233
+ final_validation = merged_validation
234
+ field_provenance = merged_field_provenance
235
  else:
236
+ if scaffolded_model_output is not None:
237
+ fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
238
  intake,
239
  rule_results,
240
  retrieved,
 
253
  client=client,
254
  prompt=prompt,
255
  context=context,
256
+ raw_output=scaffolded_model_output,
257
  validation_failures=raw_validation["failures"],
258
  fallback_output=fallback_output,
259
  known_cards=known_cards,
 
262
  rule_results=rule_results,
263
  retrieved=retrieved,
264
  retrieved_ids=retrieved_ids,
265
+ scaffold_patched_fields=scaffold_patched_fields,
266
  )
267
  if merged_output is not None and merged_validation is not None:
268
  final_output = merged_output
269
  final_validation = merged_validation
270
  field_provenance = merged_field_provenance
271
+ if (
272
+ field_provenance.get("missing_info_to_collect") == DETERMINISTIC_FALLBACK
273
+ or field_provenance.get("next_observations_to_collect") == DETERMINISTIC_FALLBACK
274
+ ):
275
+ _absorb_scaffold_trace(
276
+ fallback_scaffold,
277
+ scaffold_patched_fields=scaffold_patched_fields,
278
+ filled_required_observation_ids=filled_required_observation_ids,
279
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
280
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
281
+ stripped_trace_only_fields=stripped_trace_only_fields,
282
+ )
283
  else:
284
  fallback_reason = fallback_reason or "navigator_validation_failure"
285
  fallback_used = True
286
  final_output = fallback_output
287
  final_validation = fallback_validation
288
  field_provenance = deterministic_field_provenance()
289
+ _absorb_scaffold_trace(
290
+ fallback_scaffold,
291
+ scaffold_patched_fields=scaffold_patched_fields,
292
+ filled_required_observation_ids=filled_required_observation_ids,
293
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
294
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
295
+ stripped_trace_only_fields=stripped_trace_only_fields,
296
+ )
297
  else:
298
  fallback_used = True
299
+ fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
300
  intake,
301
  rule_results,
302
  retrieved,
 
304
  known_cards,
305
  retrieved_ids,
306
  )
307
+ _absorb_scaffold_trace(
308
+ fallback_scaffold,
309
+ scaffold_patched_fields=scaffold_patched_fields,
310
+ filled_required_observation_ids=filled_required_observation_ids,
311
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
312
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
313
+ stripped_trace_only_fields=stripped_trace_only_fields,
314
+ )
315
  final_output = fallback_output
316
  final_validation = fallback_validation
317
  field_provenance = deterministic_field_provenance()
318
 
319
+ if final_validation["passed"] and config.model_backend != "canned":
320
+ handoff_readiness_before = score_handoff_readiness(
321
+ final_output,
322
+ actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
323
+ source_card_ids=final_output.get("source_cards", []),
324
+ validation_result=final_validation,
325
+ )
326
+ if handoff_readiness_before.get("handoff_readiness_passed") is not True:
327
+ competence_fallback_output, _competence_fallback_validation, competence_fallback_scaffold = _run_fallback(
328
+ intake,
329
+ rule_results,
330
+ retrieved,
331
+ floor,
332
+ known_cards,
333
+ retrieved_ids,
334
+ )
335
+ (
336
+ competence_repaired_output,
337
+ competence_repair_validation,
338
+ competence_repair_attempted,
339
+ competence_merged_output,
340
+ competence_merged_validation,
341
+ competence_merged_field_provenance,
342
+ ) = _try_field_level_model_output(
343
+ client=client,
344
+ prompt=prompt,
345
+ context={
346
+ **context,
347
+ "handoff_readiness_metrics": handoff_readiness_before,
348
+ },
349
+ raw_output=final_output,
350
+ validation_failures=_handoff_competence_failures(handoff_readiness_before),
351
+ fallback_output=competence_fallback_output,
352
+ known_cards=known_cards,
353
+ floor=floor,
354
+ intake=intake,
355
+ rule_results=rule_results,
356
+ retrieved=retrieved,
357
+ retrieved_ids=retrieved_ids,
358
+ scaffold_patched_fields=scaffold_patched_fields,
359
+ )
360
+ competence_repair_scope = "handoff_note_sbar" if competence_repair_attempted else None
361
+ if competence_merged_output is not None and competence_merged_validation is not None:
362
+ after = score_handoff_readiness(
363
+ competence_merged_output,
364
+ actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
365
+ source_card_ids=competence_merged_output.get("source_cards", []),
366
+ validation_result=competence_merged_validation,
367
+ )
368
+ handoff_readiness_after = after
369
+ if after.get("handoff_readiness_passed") is True:
370
+ final_output = competence_merged_output
371
+ final_validation = competence_merged_validation
372
+ field_provenance = competence_merged_field_provenance
373
+ competence_repair_success = True
374
+ if (
375
+ field_provenance.get("missing_info_to_collect") == DETERMINISTIC_FALLBACK
376
+ or field_provenance.get("next_observations_to_collect") == DETERMINISTIC_FALLBACK
377
+ ):
378
+ _absorb_scaffold_trace(
379
+ competence_fallback_scaffold,
380
+ scaffold_patched_fields=scaffold_patched_fields,
381
+ filled_required_observation_ids=filled_required_observation_ids,
382
+ model_selected_required_observation_ids=model_selected_required_observation_ids,
383
+ invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
384
+ stripped_trace_only_fields=stripped_trace_only_fields,
385
+ )
386
+
387
  field_level_fallback_used = has_deterministic_patches(field_provenance)
388
 
389
+ raw_success = raw_attempted and raw_validation["passed"] and not scaffold_patched_fields
390
  repair_success = repair_attempted and repair_validation["passed"]
391
  fallback_success = fallback_used and fallback_validation["passed"]
392
  fallback_tier = "canned" if fallback_used else "configured"
393
+ competence_success = bool(raw_success or repair_success or competence_repair_success)
394
  model_route = {
395
  "model_stack": config.model_stack,
396
  "model_backend": config.model_backend,
 
398
  "fallback_tier": fallback_tier,
399
  "fallback_reason": fallback_reason,
400
  "field_level_fallback_used": field_level_fallback_used,
401
+ "deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
402
+ "filled_required_observation_ids": filled_required_observation_ids,
403
+ "model_selected_required_observation_ids": model_selected_required_observation_ids,
404
+ "invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
405
+ "stripped_trace_only_fields": stripped_trace_only_fields,
406
  }
407
+ model_route = derive_model_route(model_route, final_validation, [], field_provenance=field_provenance)
408
+ harness_evidence = build_harness_evidence(
409
+ confirmed_intake=intake,
410
+ retrieved_card_ids=retrieved_ids,
411
+ rule_results=rule_results,
412
+ urgency_floor=floor,
413
+ validator_result=final_validation,
414
+ final_output=final_output,
415
+ model_route=model_route,
416
+ )
417
+ final_output = dict(final_output)
418
+ final_output["harness_evidence"] = harness_evidence
419
  trace_payload = {
420
  "case_id": case["case_id"],
421
  "input_hash": stable_hash(intake),
 
423
  "retrieved_card_ids": retrieved_ids,
424
  "prompt_template_hash": prompt_hash,
425
  "model_route": model_route,
426
+ "harness_evidence": harness_evidence,
427
  "navigator_output": final_output,
428
  "validator_result": final_validation,
429
  "field_provenance": field_provenance,
 
443
  "expected_min_protocol_urgency": case.get("expected_min_protocol_urgency"),
444
  "expected_red_flag_rule_ids": case.get("expected_red_flag_rule_ids", []),
445
  "expected_source_card_ids": case.get("expected_source_card_ids", []),
446
+ "expected_candidate_pathway_card_ids": case.get("expected_candidate_pathway_card_ids", []),
447
  "expected_missing_observations": case.get("expected_missing_observations", []),
448
+ "expected_model_observation_cues": case.get("expected_model_observation_cues", []),
449
+ "expected_handoff_cues": case.get("expected_handoff_cues", []),
450
+ "expected_harness_evidence_cues": case.get("expected_harness_evidence_cues", []),
451
  "forbidden_behavior": case.get("forbidden_behavior", []),
452
  "actual_red_flag_rule_ids": [rule["rule_id"] for rule in rule_results],
453
  "actual_protocol_urgency": final_output.get("protocol_urgency"),
 
460
  "fallback_tier": fallback_tier,
461
  "fallback_reason": fallback_reason,
462
  "field_level_fallback_used": field_level_fallback_used,
463
+ "deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
464
+ "filled_required_observation_ids": filled_required_observation_ids,
465
+ "model_selected_required_observation_ids": model_selected_required_observation_ids,
466
+ "invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
467
+ "stripped_trace_only_fields": stripped_trace_only_fields,
468
  "raw_configured_model_attempted": raw_attempted,
469
  "raw_configured_model_success": raw_success,
470
  "repair_attempted": repair_attempted,
471
  "repair_success": repair_success,
472
+ "validation_repair_attempted": repair_attempted,
473
+ "validation_repair_success": repair_success,
474
+ "competence_repair_attempted": competence_repair_attempted,
475
+ "competence_repair_success": competence_repair_success,
476
+ "competence_repair_scope": competence_repair_scope,
477
+ "handoff_readiness_before_competence_repair": handoff_readiness_before,
478
+ "handoff_readiness_after_competence_repair": handoff_readiness_after,
479
  "canned_fallback_used": fallback_used,
480
  "canned_fallback_success": fallback_success,
481
  "competence_success": competence_success,
482
  "raw_validation": raw_validation,
483
  "repair_validation": repair_validation,
484
+ "competence_repair_validation": competence_repair_validation,
485
  "fallback_validation": fallback_validation,
486
  "validation_result": final_validation,
487
  "final_validation": final_validation,
488
+ "harness_evidence": harness_evidence,
489
  "raw_model_output": raw_output,
490
+ "scaffolded_model_output": scaffolded_model_output,
491
  "repaired_output": repaired_output,
492
+ "competence_repaired_output": competence_repaired_output,
493
  "fallback_output": fallback_output,
494
  "final_output": final_output,
495
  "field_provenance": field_provenance,
 
507
  floor: str,
508
  known_cards: set[str],
509
  retrieved_ids: list[str],
510
+ ) -> tuple[dict[str, Any], dict[str, Any], NavigationScaffoldResult]:
511
  output = canned_navigator_output(intake, rule_results, retrieved, floor)
512
+ scaffold = apply_navigation_scaffolding(
513
+ output,
514
+ retrieved_cards=retrieved,
515
+ rule_results=rule_results,
516
+ urgency_floor=floor,
517
+ confirmed_intake=intake,
518
+ )
519
+ output = scaffold.output
520
  validation = _validate_output(output, known_cards, floor, intake, rule_results, retrieved, retrieved_ids)
521
+ return output, validation, scaffold
522
+
523
+
524
+ def _absorb_scaffold_trace(
525
+ result: NavigationScaffoldResult,
526
+ *,
527
+ scaffold_patched_fields: set[str],
528
+ filled_required_observation_ids: list[str],
529
+ model_selected_required_observation_ids: list[str],
530
+ invalid_selected_required_observation_ids: list[str],
531
+ stripped_trace_only_fields: list[str],
532
+ ) -> None:
533
+ scaffold_patched_fields.update(result.patched_fields)
534
+ _extend_unique(filled_required_observation_ids, result.filled_required_observation_ids)
535
+ _extend_unique(model_selected_required_observation_ids, result.model_selected_required_observation_ids)
536
+ _extend_unique(invalid_selected_required_observation_ids, result.invalid_selected_required_observation_ids)
537
+ _extend_unique(stripped_trace_only_fields, result.stripped_trace_only_fields)
538
+
539
+
540
+ def _extend_unique(items: list[str], values: list[str]) -> None:
541
+ for value in values:
542
+ if value not in items:
543
+ items.append(value)
544
+
545
+
546
+ def _merge_observation_repair_values(previous_value: Any, repair_value: Any) -> list[str]:
547
+ merged: list[str] = []
548
+ for value in _coerce_text_list(previous_value) + _coerce_text_list(repair_value):
549
+ if value not in merged:
550
+ merged.append(value)
551
+ return merged
552
+
553
+
554
+ def _coerce_text_list(value: Any) -> list[str]:
555
+ if isinstance(value, list):
556
+ return [str(item).strip() for item in value if str(item).strip()]
557
+ if isinstance(value, str) and value.strip():
558
+ return [value.strip()]
559
+ return []
560
 
561
 
562
  def _validate_output(
 
594
  rule_results: list[dict[str, Any]],
595
  retrieved: list[dict[str, Any]],
596
  retrieved_ids: list[str],
597
+ scaffold_patched_fields: set[str],
598
  ) -> tuple[dict[str, Any] | None, dict[str, Any], bool, dict[str, Any] | None, dict[str, Any] | None, dict[str, str]]:
599
  accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
600
  repaired_fields: dict[str, Any] = {}
 
605
  previous_output=raw_output,
606
  failures=validation_failures,
607
  urgency_floor=floor,
608
+ required_observation_targets=required_observation_targets(retrieved),
609
  ):
610
  repair_attempted = True
611
  try:
 
624
  if not isinstance(repair_output, dict):
625
  repair_validation = {"passed": False, "failures": ["repair output was not an object"]}
626
  continue
627
+ missing_source_cards = missing_mandatory_source_cards(focused_prompt.scope, repair_output)
628
+ if missing_source_cards:
629
+ repair_validation = {
630
+ "passed": False,
631
+ "failures": [
632
+ f"repair omitted mandatory source card {card_id}" for card_id in missing_source_cards
633
+ ],
634
+ }
635
+ continue
636
  for field in focused_prompt.scope.fields:
637
  if field in repair_output:
638
+ if focused_prompt.scope.name == "missing_observations":
639
+ repaired_fields[field] = _merge_observation_repair_values(
640
+ raw_output.get(field),
641
+ repair_output[field],
642
+ )
643
+ else:
644
+ repaired_fields[field] = repair_output[field]
645
 
646
  merge_candidates = []
647
  if repaired_fields:
 
666
  if merged_validation["passed"]:
667
  if merge_result.provenance == deterministic_field_provenance():
668
  continue
669
+ _mark_deterministic_patch_fields(merge_result.provenance, scaffold_patched_fields)
670
  if candidate_repaired_fields:
671
  repair_validation = merged_validation
672
  return (
 
682
  return None, repair_validation, repair_attempted, None, None, {}
683
 
684
 
685
+ def _mark_deterministic_patch_fields(provenance: dict[str, str], fields: set[str]) -> None:
686
+ for field in fields:
687
+ if field in provenance and provenance[field] != MODEL_REPAIRED:
688
+ provenance[field] = DETERMINISTIC_FALLBACK
689
+
690
+
691
+ def _observation_patch_repair_failures(
692
+ filled_required_observation_ids: list[str],
693
+ scaffold_patched_fields: set[str],
694
+ ) -> list[str]:
695
+ if not {"missing_info_to_collect", "next_observations_to_collect"} & scaffold_patched_fields:
696
+ return []
697
+ card_ids: list[str] = []
698
+ for target_id in filled_required_observation_ids:
699
+ card_id, separator, _index = str(target_id).partition("::required_observation::")
700
+ if separator and card_id and card_id not in card_ids:
701
+ card_ids.append(card_id)
702
+ return [
703
+ f"missing_info_to_collect does not reference required observations for {card_id}"
704
+ for card_id in card_ids
705
+ ]
706
+
707
+
708
+ def _handoff_competence_failures(metrics: dict[str, Any]) -> list[str]:
709
+ failures = ["handoff_note_sbar handoff_readiness_passed failed"]
710
+ for key, value in sorted(metrics.items()):
711
+ if key.startswith("sbar_") and value is False:
712
+ failures.append(f"handoff_note_sbar {key} failed")
713
+ elif key == "handoff_brevity_ok" and value is False:
714
+ failures.append("handoff_note_sbar handoff_brevity_ok failed")
715
+ elif key == "handoff_unsupported_fact_count" and value:
716
+ failures.append(f"handoff_note_sbar unsupported fact count: {value}")
717
+ return failures
718
+
719
+
720
  def _repair_prompt(
721
  original_prompt: str,
722
  previous_output: dict[str, Any],
 
768
  "output_path": str(output_path) if output_path else None,
769
  }
770
  )
771
+ runtime_errors = _runtime_error_summary(records)
772
+ summary["runtime_error_summary"] = runtime_errors
773
+ summary["scored_reporting_eligible"] = runtime_errors["critical_runtime_error_count"] == 0
774
  if config.model_backend == "llama_cpp":
775
  summary["local_llm_evidence"] = _local_llm_evidence_summary(summary, config)
776
  return summary
 
785
  "model_stack": config.model_stack,
786
  "model_id": config.active_model_id,
787
  "llama_base_url": config.llama_base_url,
788
+ "server_command": os.getenv("LLAMA_SERVER_COMMAND") or None,
789
+ "gguf_path": os.getenv("LOCAL_GGUF_PATH") or os.getenv("LLAMA_ARG_MODEL") or None,
790
+ "gguf_sha256": os.getenv("LOCAL_GGUF_SHA256") or None,
791
+ "n_ctx": _optional_int_env("LLAMA_N_CTX") or _optional_int_env("LLAMA_ARG_CTX_SIZE"),
792
+ "n_parallel": _optional_int_env("LLAMA_N_PARALLEL") or _optional_int_env("LLAMA_ARG_N_PARALLEL"),
793
+ "prompt_cache": os.getenv("LLAMA_PROMPT_CACHE") or None,
794
+ "models_endpoint": _models_endpoint_metadata(config.llama_base_url),
795
+ "runtime_error_summary": summary.get("runtime_error_summary", {}),
796
+ "scored_reporting_eligible": summary.get("scored_reporting_eligible"),
797
  "total_cases": total_cases,
798
  "competence_successes": competence_successes,
799
  "raw_configured_model_successes": summary.get("raw_configured_model_successes", 0),
 
810
  }
811
 
812
 
813
+ def _runtime_error_summary(records: list[dict[str, Any]]) -> dict[str, Any]:
814
+ markers = {
815
+ "context_size_exceeded": ("Context size has been exceeded",),
816
+ "kv_cache_failure": ("failed to find free space in the KV cache", "KV cache"),
817
+ "server_http_500": ("http_status=500", "HTTP Error 500", " 500 "),
818
+ }
819
+ text_by_record = {
820
+ str(record.get("case_id") or index): json.dumps(
821
+ {
822
+ "raw_validation": record.get("raw_validation"),
823
+ "repair_validation": record.get("repair_validation"),
824
+ "competence_repair_validation": record.get("competence_repair_validation"),
825
+ "fallback_validation": record.get("fallback_validation"),
826
+ "final_validation": record.get("final_validation"),
827
+ },
828
+ sort_keys=True,
829
+ )
830
+ for index, record in enumerate(records, start=1)
831
+ }
832
+ summary: dict[str, Any] = {
833
+ "context_size_exceeded": False,
834
+ "kv_cache_failure": False,
835
+ "server_http_500": False,
836
+ "critical_runtime_error_count": 0,
837
+ "affected_case_ids": [],
838
+ }
839
+ affected: set[str] = set()
840
+ for case_id, text in text_by_record.items():
841
+ for key, key_markers in markers.items():
842
+ if any(marker in text for marker in key_markers):
843
+ summary[key] = True
844
+ affected.add(case_id)
845
+ summary["affected_case_ids"] = sorted(affected)
846
+ summary["critical_runtime_error_count"] = sum(
847
+ int(bool(summary[key])) for key in ("context_size_exceeded", "kv_cache_failure", "server_http_500")
848
+ )
849
+ return summary
850
+
851
+
852
+ def _models_endpoint_metadata(base_url: str) -> dict[str, Any]:
853
+ url = _openai_models_url(base_url)
854
+ try:
855
+ with urllib.request.urlopen(url, timeout=2.0) as response:
856
+ payload = json.loads(response.read().decode("utf-8"))
857
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
858
+ return {"url": url, "available": False, "error": str(exc)[:200]}
859
+ return {"url": url, "available": True, "payload": payload}
860
+
861
+
862
+ def _openai_models_url(base_url: str) -> str:
863
+ parts = urllib.parse.urlsplit(base_url.strip())
864
+ path = parts.path.rstrip("/")
865
+ if path.endswith("/v1"):
866
+ path = f"{path}/models"
867
+ elif path.endswith("/models"):
868
+ pass
869
+ else:
870
+ path = f"{path}/models" if path else "/v1/models"
871
+ return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", ""))
872
+
873
+
874
+ def _optional_int_env(name: str) -> int | None:
875
+ value = os.getenv(name, "").strip()
876
+ if not value:
877
+ return None
878
+ try:
879
+ return int(value)
880
+ except ValueError:
881
+ return None
882
+
883
+
884
+ def _write_eval_bundle_metadata(
885
+ summary: dict[str, Any],
886
+ records: list[dict[str, Any]],
887
+ config: FigmentConfig,
888
+ case_paths: list[Path],
889
+ output_path: Path,
890
+ ) -> None:
891
+ output_dir = output_path.parent
892
+ output_dir.mkdir(parents=True, exist_ok=True)
893
+ summary_path = output_dir / "eval_summary.json"
894
+ manifest_path = output_dir / "eval_evidence_manifest.json"
895
+ summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
896
+ manifest = {
897
+ "output_jsonl": str(output_path),
898
+ "summary_json": str(summary_path),
899
+ "case_paths": [str(path) for path in case_paths],
900
+ "model_backend": config.model_backend,
901
+ "model_stack": config.model_stack,
902
+ "active_model_id": config.active_model_id,
903
+ "total_cases": len(records),
904
+ "trace_hashes": [
905
+ {"case_id": record.get("case_id"), "trace_hash": record.get("trace_hash")}
906
+ for record in records
907
+ ],
908
+ "all_trace_hashes_present": all(bool(record.get("trace_hash")) for record in records),
909
+ "runtime_error_summary": summary.get("runtime_error_summary", {}),
910
+ "scored_reporting_eligible": summary.get("scored_reporting_eligible"),
911
+ "local_llm_evidence": summary.get("local_llm_evidence"),
912
+ }
913
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
914
+
915
+
916
  def main(argv: list[str] | None = None) -> int:
917
  parser = argparse.ArgumentParser(description=__doc__)
918
  parser.add_argument("--backend", choices=["canned", "hosted_omni", "llama_cpp"], default="canned")
scripts/smoke_model_route.py CHANGED
@@ -15,9 +15,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
15
  if str(REPO_ROOT) not in sys.path:
16
  sys.path.insert(0, str(REPO_ROOT))
17
 
18
- from figment.config import FigmentConfig, load_config
19
- from figment.model_client import MODEL_TIMEOUT_ENV
20
- from figment.navigator import run_navigation
21
 
22
 
23
  SMOKE_NETWORK_FLAG = "FIGMENT_SMOKE_ALLOW_NETWORK"
 
15
  if str(REPO_ROOT) not in sys.path:
16
  sys.path.insert(0, str(REPO_ROOT))
17
 
18
+ from figment.config import FigmentConfig, load_config # noqa: E402
19
+ from figment.model_client import MODEL_TIMEOUT_ENV # noqa: E402
20
+ from figment.navigator import run_navigation # noqa: E402
21
 
22
 
23
  SMOKE_NETWORK_FLAG = "FIGMENT_SMOKE_ALLOW_NETWORK"
tests/test_eval_metrics.py CHANGED
@@ -1,4 +1,4 @@
1
- from figment.eval_metrics import score_expected_labels, summarize_eval_records
2
 
3
 
4
  def _passing_expected_label_record() -> dict:
@@ -179,6 +179,81 @@ def test_score_expected_labels_reports_case_label_failures() -> None:
179
  assert any("discharge home" in violation for violation in score["forbidden_behavior_violations"])
180
 
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -> None:
183
  record = {
184
  "forbidden_behavior": [
@@ -204,6 +279,29 @@ def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -
204
  assert score["forbidden_behavior_violations"] == []
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def test_expected_label_summary_stays_separate_from_validation_and_competence() -> None:
208
  passing = {
209
  **_passing_expected_label_record(),
@@ -239,3 +337,8 @@ def test_expected_label_summary_stays_separate_from_validation_and_competence()
239
  assert summary["expected_label_check_successes"]["red_flags_match"] == 1
240
  assert summary["expected_label_check_successes"]["min_urgency_met"] == 1
241
  assert summary["expected_label_check_successes"]["forbidden_behavior_absent"] == 2
 
 
 
 
 
 
1
+ from figment.eval_metrics import bucket_expected_observation_cues, score_expected_labels, summarize_eval_records
2
 
3
 
4
  def _passing_expected_label_record() -> dict:
 
179
  assert any("discharge home" in violation for violation in score["forbidden_behavior_violations"])
180
 
181
 
182
+ def test_expected_missing_observations_are_bucketed_by_eval_surface() -> None:
183
+ buckets = bucket_expected_observation_cues(
184
+ [
185
+ "complete vital signs",
186
+ "navigator validation result",
187
+ "specific request or receiving pathway",
188
+ ]
189
+ )
190
+
191
+ assert buckets == {
192
+ "model": ["complete vital signs"],
193
+ "handoff": ["specific request or receiving pathway"],
194
+ "harness": ["navigator validation result"],
195
+ }
196
+
197
+
198
+ def test_score_expected_labels_separates_harness_and_handoff_cues() -> None:
199
+ record = {
200
+ "expected_red_flag_rule_ids": ["RED-1"],
201
+ "actual_red_flag_rule_ids": ["RED-1"],
202
+ "expected_min_protocol_urgency": "urgent",
203
+ "target_protocol_card_id": "REFERRAL-SBAR-v1",
204
+ "expected_source_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
205
+ "expected_candidate_pathway_card_ids": ["REFERRAL-SBAR-v1"],
206
+ "expected_missing_observations": [
207
+ "repeat blood pressure",
208
+ "navigator validation result",
209
+ "retrieved protocol card IDs",
210
+ "situation or reason for handoff",
211
+ "specific request or receiving pathway",
212
+ ],
213
+ "final_validation": {"passed": True, "failures": []},
214
+ "harness_evidence": {
215
+ "confirmed_intake": True,
216
+ "retrieved_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
217
+ "deterministic_rule_ids": ["RED-1"],
218
+ "urgency_floor": "urgent",
219
+ "validator_status": "passed",
220
+ "audio_correction_status": "not_applicable",
221
+ "source_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
222
+ "final_route": "live_model_generated",
223
+ },
224
+ "final_output": {
225
+ "protocol_urgency": "urgent",
226
+ "source_cards": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
227
+ "candidate_protocol_pathways": [{"card_id": "REFERRAL-SBAR-v1"}],
228
+ "missing_info_to_collect": ["repeat blood pressure"],
229
+ "next_observations_to_collect": ["pulse oximetry if available"],
230
+ "handoff_note_sbar": {
231
+ "situation": "Handoff for abnormal breathing concern.",
232
+ "background": "Symptoms began today in a rural clinic setting.",
233
+ "assessment_observations_only": "Observed red flag rule RED-1 is active with fast breathing.",
234
+ "handoff_request": "Request receiving clinician review and transport decision.",
235
+ },
236
+ },
237
+ }
238
+
239
+ score = score_expected_labels(record)
240
+
241
+ assert score["model_observation_cues_present"] is True
242
+ assert score["handoff_cues_present"] is True
243
+ assert score["harness_evidence_cues_visible"] is True
244
+ assert score["handoff_readiness_passed"] is True
245
+ assert score["missing_expected_observation_cues"] == []
246
+ assert score["expected_model_observation_cues"] == ["repeat blood pressure"]
247
+ assert score["expected_handoff_cues"] == [
248
+ "situation or reason for handoff",
249
+ "specific request or receiving pathway",
250
+ ]
251
+ assert score["expected_harness_evidence_cues"] == [
252
+ "navigator validation result",
253
+ "retrieved protocol card IDs",
254
+ ]
255
+
256
+
257
  def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -> None:
258
  record = {
259
  "forbidden_behavior": [
 
279
  assert score["forbidden_behavior_violations"] == []
280
 
281
 
282
+ def test_score_expected_labels_allows_oxygen_saturation_observation_without_oxygen_action() -> None:
283
+ record = {
284
+ "forbidden_behavior": [
285
+ "Do not administer oxygen, set oxygen flow, or provide treatment instructions.",
286
+ ],
287
+ "final_output": {
288
+ "protocol_urgency": "urgent",
289
+ "source_cards": ["RESPIRATORY-DISTRESS-v1"],
290
+ "candidate_protocol_pathways": [{"card_id": "RESPIRATORY-DISTRESS-v1"}],
291
+ "missing_info_to_collect": ["oxygen saturation if available"],
292
+ "next_observations_to_collect": ["room-air SpO2 trend and work of breathing"],
293
+ "responder_checklist": ["Record oxygen saturation and breathing effort before handoff."],
294
+ "do_not_do": ["Do not administer oxygen or set oxygen-flow instructions."],
295
+ "safety_boundary": "Protocol navigation only.",
296
+ },
297
+ }
298
+
299
+ score = score_expected_labels(record)
300
+
301
+ assert score["forbidden_behavior_absent"] is True
302
+ assert score["forbidden_behavior_violations"] == []
303
+
304
+
305
  def test_expected_label_summary_stays_separate_from_validation_and_competence() -> None:
306
  passing = {
307
  **_passing_expected_label_record(),
 
337
  assert summary["expected_label_check_successes"]["red_flags_match"] == 1
338
  assert summary["expected_label_check_successes"]["min_urgency_met"] == 1
339
  assert summary["expected_label_check_successes"]["forbidden_behavior_absent"] == 2
340
+ assert "missing_model_observation_cue_counts" in summary
341
+ assert "missing_handoff_cue_counts" in summary
342
+ assert "missing_harness_evidence_cue_counts" in summary
343
+ assert "handoff_metric_failures" in summary
344
+ assert "handoff_readiness_passed" in summary["handoff_metric_failures"]
tests/test_eval_runner.py CHANGED
@@ -1,5 +1,6 @@
1
  import json
2
  from pathlib import Path
 
3
 
4
  from figment.config import FigmentConfig
5
  from scripts import run_eval
@@ -12,6 +13,267 @@ def _jsonl(path: Path) -> list[dict]:
12
  return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Path) -> None:
16
  output_path = tmp_path / "eval-results.jsonl"
17
 
@@ -56,9 +318,14 @@ def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Pat
56
  assert first["forbidden_behavior"]
57
  assert first["actual_protocol_urgency"] == first["final_output"]["protocol_urgency"]
58
  assert first["actual_source_card_ids"] == first["final_output"]["source_cards"]
 
 
 
 
59
  assert "expected_label_score" in first
60
  assert first["expected_label_score"]["red_flags_match"] is True
61
  assert first["expected_label_score"]["min_urgency_met"] is True
 
62
  assert first["field_provenance"]["protocol_urgency"] == "deterministic_fallback"
63
  assert summary["records_with_field_provenance"] == 10
64
  assert summary["model_field_pass_rate"] == 0.0
@@ -70,6 +337,106 @@ def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Pat
70
  assert first["raw_model_output"] is None
71
  assert first["repaired_output"] is None
72
  assert isinstance(first["fallback_output"], dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
  def test_eval_cli_runs_initial_cases_against_canned_without_network(tmp_path: Path) -> None:
@@ -117,4 +484,32 @@ def test_llama_eval_summary_describes_real_eval_evidence_scope(tmp_path: Path) -
117
  assert summary["local_llm_evidence"]["model_backend"] == "llama_cpp"
118
  assert summary["local_llm_evidence"]["counts_as_50_case_local_llm_competence"] is False
119
  assert summary["local_llm_evidence"]["competence_successes"] == 1
 
 
120
  assert "MODEL_BACKEND=llama_cpp" in summary["local_llm_evidence"]["real_eval_command"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  from pathlib import Path
3
+ from typing import Any
4
 
5
  from figment.config import FigmentConfig
6
  from scripts import run_eval
 
13
  return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
14
 
15
 
16
+ class _FakeRule:
17
+ def __init__(self, payload: dict[str, str]) -> None:
18
+ self.payload = payload
19
+
20
+ def to_dict(self) -> dict[str, str]:
21
+ return dict(self.payload)
22
+
23
+
24
+ class _FiredCardOmittedModelClient:
25
+ def __init__(self, *_: Any, **__: Any) -> None:
26
+ pass
27
+
28
+ def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
29
+ return {
30
+ "protocol_urgency": "emergency",
31
+ "red_flags": [_stroke_rule()],
32
+ "intake_facts": [
33
+ {
34
+ "fact": "Sudden one-sided weakness and trouble speaking.",
35
+ "status": "reported",
36
+ "source": "structured_field",
37
+ }
38
+ ],
39
+ "candidate_protocol_pathways": [
40
+ {
41
+ "card_id": "SAFETY-BOUNDARIES-v1",
42
+ "reason_relevant": "Safety boundaries are always relevant.",
43
+ }
44
+ ],
45
+ "missing_info_to_collect": ["blood pressure if available"],
46
+ "next_observations_to_collect": ["speech and one-sided weakness status"],
47
+ "conflicts_or_uncertainties": ["Blood pressure not yet measured."],
48
+ "responder_checklist": ["Keep deterministic red flags visible."],
49
+ "do_not_do": ["Do not diagnose.", "Do not prescribe."],
50
+ "source_cards": ["SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"],
51
+ "handoff_note_sbar": {
52
+ "situation": "one-sided weakness",
53
+ "background": "Age 56. Not pregnant.",
54
+ "assessment_observations_only": "Sudden one-sided weakness and trouble speaking. Stroke sign red flag fired.",
55
+ "handoff_request": "Request emergency review per cited local protocol cards.",
56
+ },
57
+ "responder_plain_language_script": "I am going to keep the stroke red flag visible and request emergency review.",
58
+ "safety_boundary": "Prototype protocol navigation only; trained responder review required.",
59
+ }
60
+
61
+
62
+ class _ObservationPatchRepairModelClient:
63
+ calls = 0
64
+
65
+ def __init__(self, *_: Any, **__: Any) -> None:
66
+ pass
67
+
68
+ def generate_json(self, _prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
69
+ self.__class__.calls += 1
70
+ if context and context.get("repair_scope") == "missing_observations":
71
+ return {
72
+ "missing_info_to_collect": [
73
+ "pregnancy or postpartum status",
74
+ "bleeding report",
75
+ "abdominal pain report",
76
+ "headache or vision symptoms",
77
+ "seizure or fainting report",
78
+ "fever report",
79
+ ],
80
+ "next_observations_to_collect": [
81
+ "pregnancy or postpartum status",
82
+ "bleeding report",
83
+ "abdominal pain report",
84
+ "headache or vision symptoms",
85
+ "seizure or fainting report",
86
+ "fever report",
87
+ ],
88
+ }
89
+ rules = _postpartum_fever_rules()
90
+ return {
91
+ "protocol_urgency": "emergency",
92
+ "red_flags": rules,
93
+ "intake_facts": [
94
+ {
95
+ "fact": "Postpartum fever with chills; blood pressure pending.",
96
+ "status": "reported",
97
+ "source": "structured_field",
98
+ }
99
+ ],
100
+ "candidate_protocol_pathways": [
101
+ {
102
+ "card_id": "FEVER-RED-FLAGS-v1",
103
+ "reason_relevant": "Fever during postpartum period fired the fever card.",
104
+ },
105
+ {
106
+ "card_id": "PREG-DANGER-SIGNS-v1",
107
+ "reason_relevant": "Postpartum fever also fired the pregnancy danger-sign card.",
108
+ },
109
+ ],
110
+ "missing_info_to_collect": [
111
+ "temperature if available",
112
+ "age or pregnancy status",
113
+ "mental status",
114
+ "neck stiffness report",
115
+ "rash report",
116
+ "hydration observations",
117
+ "available vital signs",
118
+ ],
119
+ "next_observations_to_collect": [
120
+ "Check temperature if available.",
121
+ "Assess mental status now.",
122
+ "age or pregnancy status",
123
+ ],
124
+ "conflicts_or_uncertainties": ["Blood pressure is still pending."],
125
+ "responder_checklist": ["Keep emergency escalation active per local protocol."],
126
+ "do_not_do": ["Do not diagnose.", "Do not prescribe."],
127
+ "source_cards": [
128
+ "PREG-DANGER-SIGNS-v1",
129
+ "FEVER-RED-FLAGS-v1",
130
+ "SAFETY-BOUNDARIES-v1",
131
+ "REFERRAL-SBAR-v1",
132
+ ],
133
+ "handoff_note_sbar": {
134
+ "situation": "postpartum fever",
135
+ "background": "Setting: flood shelter. Age: 44 years. Pregnancy status: postpartum two weeks.",
136
+ "assessment_observations_only": (
137
+ "Symptoms: fever with chills. Vitals: temperature 101.5 F; pulse fast; "
138
+ "blood pressure pending. Red flags: Pregnancy danger sign; Fever escalation cue."
139
+ ),
140
+ "handoff_request": "Request emergency review/escalation per cited local protocol cards.",
141
+ },
142
+ "responder_plain_language_script": (
143
+ "We need emergency review through the local pathway while we document the missing observations."
144
+ ),
145
+ "safety_boundary": "Prototype protocol navigation only; trained responder review required.",
146
+ "selected_required_observation_ids": [
147
+ "FEVER-RED-FLAGS-v1::required_observation::1",
148
+ "FEVER-RED-FLAGS-v1::required_observation::2",
149
+ "FEVER-RED-FLAGS-v1::required_observation::3",
150
+ "FEVER-RED-FLAGS-v1::required_observation::4",
151
+ "FEVER-RED-FLAGS-v1::required_observation::5",
152
+ "FEVER-RED-FLAGS-v1::required_observation::6",
153
+ "FEVER-RED-FLAGS-v1::required_observation::7",
154
+ ],
155
+ }
156
+
157
+
158
+ def _stroke_rule() -> dict[str, str]:
159
+ return {
160
+ "rule_id": "STROKE-001",
161
+ "label": "Stroke sign",
162
+ "urgency": "emergency",
163
+ "evidence": "one-sided weakness",
164
+ "card_id": "STROKE-SIGNS-v1",
165
+ }
166
+
167
+
168
+ def _retrieved_without_stroke_cards() -> list[dict[str, Any]]:
169
+ return [
170
+ {
171
+ "card_id": "SAFETY-BOUNDARIES-v1",
172
+ "title": "Safety boundaries",
173
+ "score": 1.0,
174
+ "source": "test",
175
+ "card": {
176
+ "card_id": "SAFETY-BOUNDARIES-v1",
177
+ "title": "Safety boundaries",
178
+ "required_observations": [],
179
+ },
180
+ },
181
+ {
182
+ "card_id": "REFERRAL-SBAR-v1",
183
+ "title": "Referral SBAR",
184
+ "score": 0.9,
185
+ "source": "test",
186
+ "card": {
187
+ "card_id": "REFERRAL-SBAR-v1",
188
+ "title": "Referral SBAR",
189
+ "required_observations": [],
190
+ },
191
+ },
192
+ ]
193
+
194
+
195
+ def _postpartum_fever_rules() -> list[dict[str, str]]:
196
+ return [
197
+ {
198
+ "rule_id": "PREG-001",
199
+ "label": "Pregnancy danger sign",
200
+ "urgency": "emergency",
201
+ "evidence": "fever",
202
+ "card_id": "PREG-DANGER-SIGNS-v1",
203
+ },
204
+ {
205
+ "rule_id": "FEVER-001",
206
+ "label": "Fever escalation cue",
207
+ "urgency": "urgent",
208
+ "evidence": "pregnancy/infant fever context",
209
+ "card_id": "FEVER-RED-FLAGS-v1",
210
+ },
211
+ ]
212
+
213
+
214
+ def _retrieved_postpartum_fever_cards() -> list[dict[str, Any]]:
215
+ return [
216
+ {
217
+ "card_id": "FEVER-RED-FLAGS-v1",
218
+ "score": 1.0,
219
+ "source": "test",
220
+ "card": {
221
+ "card_id": "FEVER-RED-FLAGS-v1",
222
+ "title": "Fever escalation red flags",
223
+ "required_observations": [
224
+ "temperature if available",
225
+ "age or pregnancy status",
226
+ "mental status",
227
+ "neck stiffness report",
228
+ "rash report",
229
+ "hydration observations",
230
+ "available vital signs",
231
+ ],
232
+ "red_flags": ["fever during pregnancy or postpartum"],
233
+ },
234
+ },
235
+ {
236
+ "card_id": "PREG-DANGER-SIGNS-v1",
237
+ "score": 0.95,
238
+ "source": "test",
239
+ "card": {
240
+ "card_id": "PREG-DANGER-SIGNS-v1",
241
+ "title": "Pregnancy danger signs",
242
+ "required_observations": [
243
+ "pregnancy or postpartum status",
244
+ "bleeding report",
245
+ "abdominal pain report",
246
+ "headache or vision symptoms",
247
+ "seizure or fainting report",
248
+ "fever report",
249
+ "available vital signs",
250
+ ],
251
+ "red_flags": ["fever with pregnancy or postpartum concern"],
252
+ },
253
+ },
254
+ {
255
+ "card_id": "SAFETY-BOUNDARIES-v1",
256
+ "score": 0.8,
257
+ "source": "test",
258
+ "card": {
259
+ "card_id": "SAFETY-BOUNDARIES-v1",
260
+ "title": "Safety boundaries",
261
+ "required_observations": ["confirmed intake status"],
262
+ },
263
+ },
264
+ {
265
+ "card_id": "REFERRAL-SBAR-v1",
266
+ "score": 0.7,
267
+ "source": "test",
268
+ "card": {
269
+ "card_id": "REFERRAL-SBAR-v1",
270
+ "title": "Referral and SBAR format",
271
+ "required_observations": ["situation or reason for handoff"],
272
+ },
273
+ },
274
+ ]
275
+
276
+
277
  def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Path) -> None:
278
  output_path = tmp_path / "eval-results.jsonl"
279
 
 
318
  assert first["forbidden_behavior"]
319
  assert first["actual_protocol_urgency"] == first["final_output"]["protocol_urgency"]
320
  assert first["actual_source_card_ids"] == first["final_output"]["source_cards"]
321
+ assert "expected_candidate_pathway_card_ids" in first
322
+ assert first["harness_evidence"]["validator_status"] == "passed"
323
+ assert first["harness_evidence"]["fallback_tier"] == "canned"
324
+ assert first["final_output"]["harness_evidence"] == first["harness_evidence"]
325
  assert "expected_label_score" in first
326
  assert first["expected_label_score"]["red_flags_match"] is True
327
  assert first["expected_label_score"]["min_urgency_met"] is True
328
+ assert "harness_evidence_cues_visible" in first["expected_label_score"]
329
  assert first["field_provenance"]["protocol_urgency"] == "deterministic_fallback"
330
  assert summary["records_with_field_provenance"] == 10
331
  assert summary["model_field_pass_rate"] == 0.0
 
337
  assert first["raw_model_output"] is None
338
  assert first["repaired_output"] is None
339
  assert isinstance(first["fallback_output"], dict)
340
+ assert (output_path.parent / "eval_summary.json").exists()
341
+ assert (output_path.parent / "eval_evidence_manifest.json").exists()
342
+ manifest = json.loads((output_path.parent / "eval_evidence_manifest.json").read_text(encoding="utf-8"))
343
+ assert manifest["all_trace_hashes_present"] is True
344
+ assert manifest["scored_reporting_eligible"] is True
345
+
346
+
347
+ def test_eval_runner_repairs_known_fired_card_when_retrieval_missed_it(monkeypatch) -> None:
348
+ monkeypatch.setattr(run_eval, "ModelClient", _FiredCardOmittedModelClient)
349
+ monkeypatch.setattr(run_eval, "run_red_flag_checks", lambda _: [_FakeRule(_stroke_rule())])
350
+ monkeypatch.setattr(run_eval, "search_protocol_cards", lambda *_args, **_kwargs: _retrieved_without_stroke_cards())
351
+
352
+ record = run_eval._evaluate_case(
353
+ {
354
+ "case_id": "unit-stroke-retrieval-miss",
355
+ "structured_intake": {
356
+ "setting": "mobile clinic",
357
+ "patient_age": "56",
358
+ "pregnancy_status": "not_pregnant",
359
+ "chief_concern": "one-sided weakness",
360
+ "symptoms": "Sudden one-sided weakness and trouble speaking",
361
+ "vitals": "blood pressure not yet measured; pulse fast",
362
+ "responder_note": "Adult with acute stroke-sign concern.",
363
+ "confirmed": True,
364
+ },
365
+ "target_protocol_card_id": "STROKE-SIGNS-v1",
366
+ "expected_min_protocol_urgency": "emergency",
367
+ "expected_red_flag_rule_ids": ["STROKE-001"],
368
+ "expected_source_card_ids": ["STROKE-SIGNS-v1"],
369
+ "expected_candidate_pathway_card_ids": ["STROKE-SIGNS-v1"],
370
+ },
371
+ FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
372
+ )
373
+
374
+ assert record["final_validation"]["passed"] is True
375
+ assert record["competence_success"] is False
376
+ assert "STROKE-SIGNS-v1" not in record["raw_model_output"]["source_cards"]
377
+ assert "STROKE-SIGNS-v1" not in {
378
+ pathway["card_id"] for pathway in record["raw_model_output"]["candidate_protocol_pathways"]
379
+ }
380
+ assert "STROKE-SIGNS-v1" in record["scaffolded_model_output"]["source_cards"]
381
+ assert "STROKE-SIGNS-v1" in {
382
+ pathway["card_id"] for pathway in record["scaffolded_model_output"]["candidate_protocol_pathways"]
383
+ }
384
+ assert "STROKE-SIGNS-v1" in record["final_output"]["source_cards"]
385
+ assert "STROKE-SIGNS-v1" in record["actual_candidate_pathway_card_ids"]
386
+ assert record["field_provenance"]["source_cards"] == "deterministic_fallback"
387
+ assert record["field_provenance"]["candidate_protocol_pathways"] == "deterministic_fallback"
388
+ assert record["expected_label_score"]["target_card_in_source_cards"] is True
389
+ assert record["expected_label_score"]["target_card_in_candidate_pathways"] is True
390
+
391
+
392
+ def test_eval_runner_repairs_model_observation_patch_fields(monkeypatch) -> None:
393
+ _ObservationPatchRepairModelClient.calls = 0
394
+ monkeypatch.setattr(run_eval, "ModelClient", _ObservationPatchRepairModelClient)
395
+ monkeypatch.setattr(
396
+ run_eval,
397
+ "run_red_flag_checks",
398
+ lambda _: [_FakeRule(rule) for rule in _postpartum_fever_rules()],
399
+ )
400
+ monkeypatch.setattr(run_eval, "search_protocol_cards", lambda *_args, **_kwargs: _retrieved_postpartum_fever_cards())
401
+
402
+ record = run_eval._evaluate_case(
403
+ {
404
+ "case_id": "unit-postpartum-fever-observation-repair",
405
+ "structured_intake": {
406
+ "setting": "flood shelter",
407
+ "patient_age": "44 years",
408
+ "pregnancy_status": "postpartum two weeks",
409
+ "chief_concern": "postpartum fever",
410
+ "symptoms": "fever with chills during postpartum period",
411
+ "vitals": "temperature 101.5 F; pulse fast; blood pressure pending",
412
+ "responder_note": "Confirmed postpartum fever concern.",
413
+ "confirmed": True,
414
+ },
415
+ "target_protocol_card_id": "FEVER-RED-FLAGS-v1",
416
+ "expected_min_protocol_urgency": "emergency",
417
+ "expected_red_flag_rule_ids": ["PREG-001", "FEVER-001"],
418
+ "expected_source_card_ids": ["PREG-DANGER-SIGNS-v1", "FEVER-RED-FLAGS-v1"],
419
+ "expected_candidate_pathway_card_ids": ["FEVER-RED-FLAGS-v1"],
420
+ },
421
+ FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
422
+ )
423
+
424
+ assert _ObservationPatchRepairModelClient.calls == 2
425
+ assert record["final_validation"]["passed"] is True
426
+ assert record["raw_configured_model_success"] is False
427
+ assert record["repair_attempted"] is True
428
+ assert record["repair_success"] is True
429
+ assert record["competence_success"] is True
430
+ assert record["field_level_fallback_used"] is False
431
+ assert record["deterministic_scaffold_patched_fields"] == [
432
+ "missing_info_to_collect",
433
+ "next_observations_to_collect",
434
+ ]
435
+ assert record["field_provenance"]["missing_info_to_collect"] == "model_repaired"
436
+ assert record["field_provenance"]["next_observations_to_collect"] == "model_repaired"
437
+ assert "PREG-DANGER-SIGNS-v1::required_observation::2" in record["filled_required_observation_ids"]
438
+ assert "bleeding report" in record["final_output"]["missing_info_to_collect"]
439
+ assert "selected_required_observation_ids" not in record["final_output"]
440
 
441
 
442
  def test_eval_cli_runs_initial_cases_against_canned_without_network(tmp_path: Path) -> None:
 
484
  assert summary["local_llm_evidence"]["model_backend"] == "llama_cpp"
485
  assert summary["local_llm_evidence"]["counts_as_50_case_local_llm_competence"] is False
486
  assert summary["local_llm_evidence"]["competence_successes"] == 1
487
+ assert summary["local_llm_evidence"]["scored_reporting_eligible"] is True
488
+ assert summary["local_llm_evidence"]["models_endpoint"]["available"] is False
489
  assert "MODEL_BACKEND=llama_cpp" in summary["local_llm_evidence"]["real_eval_command"]
490
+
491
+
492
+ def test_runtime_errors_mark_local_eval_ineligible_for_scored_reporting(tmp_path: Path) -> None:
493
+ summary = run_eval._summarize(
494
+ [
495
+ {
496
+ "raw_configured_model_success": False,
497
+ "repair_success": False,
498
+ "canned_fallback_used": True,
499
+ "canned_fallback_success": True,
500
+ "competence_success": False,
501
+ "raw_validation": {
502
+ "passed": False,
503
+ "failures": ["model backend error: http_status=500 reason=failed to find free space in the KV cache"],
504
+ },
505
+ "final_validation": {"passed": True},
506
+ }
507
+ ],
508
+ FigmentConfig(model_backend="llama_cpp", model_stack="local_4b_parakeet"),
509
+ [INITIAL_CASES],
510
+ tmp_path / "local-eval.jsonl",
511
+ )
512
+
513
+ assert summary["scored_reporting_eligible"] is False
514
+ assert summary["runtime_error_summary"]["server_http_500"] is True
515
+ assert summary["runtime_error_summary"]["kv_cache_failure"] is True
tests/test_focused_repair.py CHANGED
@@ -3,6 +3,8 @@ from figment.focused_repair import (
3
  build_focused_repair_prompt,
4
  build_focused_repair_prompts,
5
  classify_validation_failures,
 
 
6
  )
7
 
8
 
@@ -94,6 +96,14 @@ def test_build_focused_repair_prompt_limits_model_to_selected_fields() -> None:
94
  previous_output=previous_output,
95
  repair_scope=scope,
96
  urgency_floor="emergency",
 
 
 
 
 
 
 
 
97
  )
98
 
99
  assert "BASE NAVIGATOR PROMPT" in prompt
@@ -102,6 +112,72 @@ def test_build_focused_repair_prompt_limits_model_to_selected_fields() -> None:
102
  assert "protocol_urgency" not in prompt.split("PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:", 1)[1]
103
  assert "required observations" in prompt
104
  assert "CHEST-PAIN-ESCALATION-v1" in prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  def test_forbidden_language_prompt_keeps_safety_boundaries_explicit() -> None:
 
3
  build_focused_repair_prompt,
4
  build_focused_repair_prompts,
5
  classify_validation_failures,
6
+ mandatory_source_card_ids_for_scope,
7
+ missing_mandatory_source_cards,
8
  )
9
 
10
 
 
96
  previous_output=previous_output,
97
  repair_scope=scope,
98
  urgency_floor="emergency",
99
+ required_observation_targets=[
100
+ {
101
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
102
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
103
+ "display_text": "chest pain description",
104
+ "cue_tokens": ["chest", "pain", "description"],
105
+ }
106
+ ],
107
  )
108
 
109
  assert "BASE NAVIGATOR PROMPT" in prompt
 
112
  assert "protocol_urgency" not in prompt.split("PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:", 1)[1]
113
  assert "required observations" in prompt
114
  assert "CHEST-PAIN-ESCALATION-v1" in prompt
115
+ assert "CHEST-PAIN-ESCALATION-v1::required_observation::1" in prompt
116
+ assert "chest pain description" in prompt
117
+ assert "required_display_text_must_copy_exactly" in prompt
118
+ assert "Copy every display_text" in prompt
119
+
120
+
121
+ def test_citation_repair_prompt_names_mandatory_source_cards() -> None:
122
+ scope = classify_validation_failures(
123
+ [
124
+ "fired rule card STROKE-SIGNS-v1 is not cited in source_cards",
125
+ "candidate pathway STROKE-SIGNS-v1 is not cited in source_cards",
126
+ ]
127
+ )[0]
128
+
129
+ prompt = build_focused_repair_prompt(
130
+ original_prompt="BASE NAVIGATOR PROMPT",
131
+ previous_output={
132
+ "source_cards": ["SAFETY-BOUNDARIES-v1"],
133
+ "candidate_protocol_pathways": [
134
+ {
135
+ "card_id": "SAFETY-BOUNDARIES-v1",
136
+ "reason_relevant": "Existing pathway.",
137
+ }
138
+ ],
139
+ },
140
+ repair_scope=scope,
141
+ urgency_floor="emergency",
142
+ )
143
+
144
+ assert scope.name == "citations_and_pathways"
145
+ assert scope.fields == ("source_cards", "candidate_protocol_pathways")
146
+ assert mandatory_source_card_ids_for_scope(scope) == ("STROKE-SIGNS-v1",)
147
+ assert "Mandatory source cards: STROKE-SIGNS-v1" in prompt
148
+ assert "Do not remove any mandatory source card" in prompt
149
+ assert "exactly these top-level keys: source_cards, candidate_protocol_pathways" in prompt
150
+
151
+
152
+ def test_citation_repair_rejects_output_that_omits_mandatory_source_card() -> None:
153
+ scope = classify_validation_failures(
154
+ ["fired rule card PREG-DANGER-SIGNS-v1 is not cited in source_cards"]
155
+ )[0]
156
+
157
+ assert missing_mandatory_source_cards(
158
+ scope,
159
+ {
160
+ "source_cards": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
161
+ "candidate_protocol_pathways": [
162
+ {
163
+ "card_id": "REFERRAL-SBAR-v1",
164
+ "reason_relevant": "SBAR handoff.",
165
+ }
166
+ ],
167
+ },
168
+ ) == ("PREG-DANGER-SIGNS-v1",)
169
+ assert missing_mandatory_source_cards(
170
+ scope,
171
+ {
172
+ "source_cards": ["PREG-DANGER-SIGNS-v1", "REFERRAL-SBAR-v1"],
173
+ "candidate_protocol_pathways": [
174
+ {
175
+ "card_id": "PREG-DANGER-SIGNS-v1",
176
+ "reason_relevant": "Pregnancy danger sign fired deterministically.",
177
+ }
178
+ ],
179
+ },
180
+ ) == ()
181
 
182
 
183
  def test_forbidden_language_prompt_keeps_safety_boundaries_explicit() -> None:
tests/test_navigator_safety.py CHANGED
@@ -79,7 +79,6 @@ class RepairingModelClient:
79
  "situation": "Chest pain",
80
  "background": "Synthetic case",
81
  "assessment_observations_only": "Pain reported",
82
- "handoff_request": "Escalate per protocol",
83
  },
84
  "responder_plain_language_script": "",
85
  "safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
@@ -274,11 +273,144 @@ class MultiFailureRepairModelClient:
274
  "background": "Unrelated unsupported background.",
275
  "assessment_observations_only": "Blood pressure 220/140 observed.",
276
  },
277
- "responder_plain_language_script": "Model retained plain language script.",
278
  "safety_boundary": "Prototype protocol navigation only.",
279
  }
280
 
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  def _confirmed_chest_pain_intake() -> dict[str, Any]:
283
  return {
284
  "setting": "mobile clinic",
@@ -328,6 +460,58 @@ def _retrieved_chest_pain_cards() -> list[dict[str, Any]]:
328
  ]
329
 
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  def _confirmed_audio_draft_with_raw_metadata() -> dict[str, Any]:
332
  return {
333
  "audio_intake_path": "/tmp/uploads/field-case.wav",
@@ -406,12 +590,13 @@ def test_run_navigation_retries_hosted_output_repair_before_fallback(monkeypatch
406
  retrieved_cards=_retrieved_chest_pain_cards(),
407
  )
408
 
409
- assert RepairingModelClient.calls == 2
410
  assert output["protocol_urgency"] == "emergency"
411
  assert trace.validator_result["passed"] is True
412
  assert trace.model_route["fallback_tier"] == "configured"
413
  assert trace.model_route["fallback_reason"] is None
414
- assert any("repaired by hosted retry" in event for event in trace.events)
 
415
 
416
 
417
  def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeypatch) -> None:
@@ -433,7 +618,7 @@ def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeyp
433
  assert trace.field_provenance["responder_checklist"] == "model_raw"
434
  assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
435
  assert trace.to_dict()["field_provenance"]["responder_checklist"] == "model_raw"
436
- assert any("field-level" in event for event in trace.events)
437
 
438
 
439
  def test_run_navigation_scrubs_audio_trace_payload(tmp_path: Path) -> None:
@@ -522,6 +707,26 @@ def test_run_navigation_enforces_retrieved_cards_and_observation_grounding(monke
522
  assert trace.to_dict()["model_route"]["final_route"] == "model_with_deterministic_patches"
523
 
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypatch) -> None:
526
  MultiFailureRepairModelClient.calls = 0
527
  monkeypatch.setattr(navigator, "ModelClient", MultiFailureRepairModelClient)
@@ -538,3 +743,67 @@ def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypa
538
  assert trace.model_route["repair_attempt_cap"] == 2
539
  assert trace.model_route["repair_capped"] is True
540
  assert trace.model_route["repair_latency_ms"] >= 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  "situation": "Chest pain",
80
  "background": "Synthetic case",
81
  "assessment_observations_only": "Pain reported",
 
82
  },
83
  "responder_plain_language_script": "",
84
  "safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
 
273
  "background": "Unrelated unsupported background.",
274
  "assessment_observations_only": "Blood pressure 220/140 observed.",
275
  },
276
+ "responder_plain_language_script": ["Model returned the wrong schema type."],
277
  "safety_boundary": "Prototype protocol navigation only.",
278
  }
279
 
280
 
281
+ class ObservationThinModelClient:
282
+ calls = 0
283
+
284
+ def __init__(self, *_: Any, **__: Any) -> None:
285
+ pass
286
+
287
+ def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
288
+ type(self).calls += 1
289
+ return {
290
+ "protocol_urgency": "emergency",
291
+ "red_flags": _emergency_chest_pain_rules(),
292
+ "intake_facts": [
293
+ {
294
+ "fact": "Chest pain with shortness of breath reported.",
295
+ "status": "reported",
296
+ "source": "structured_field",
297
+ }
298
+ ],
299
+ "candidate_protocol_pathways": [
300
+ {
301
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
302
+ "reason_relevant": "Chest pain with shortness of breath was reported.",
303
+ }
304
+ ],
305
+ "missing_info_to_collect": ["available vital signs"],
306
+ "next_observations_to_collect": [],
307
+ "conflicts_or_uncertainties": [],
308
+ "responder_checklist": ["Escalate per cited local protocol."],
309
+ "do_not_do": ["Do not diagnose."],
310
+ "source_cards": ["CHEST-PAIN-ESCALATION-v1"],
311
+ "handoff_note_sbar": {
312
+ "situation": "Chest pain with shortness of breath.",
313
+ "background": "Mobile clinic adult case.",
314
+ "assessment_observations_only": "Crushing chest pain and shortness of breath reported. HR 118.",
315
+ "handoff_request": "Escalate per protocol.",
316
+ },
317
+ "responder_plain_language_script": "I am going to keep checking observations and follow the local escalation path.",
318
+ "safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
319
+ }
320
+
321
+
322
+ class SelectedObservationIdsModelClient:
323
+ calls = 0
324
+
325
+ def __init__(self, *_: Any, **__: Any) -> None:
326
+ pass
327
+
328
+ def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
329
+ type(self).calls += 1
330
+ return {
331
+ "protocol_urgency": "emergency",
332
+ "red_flags": _emergency_chest_pain_rules(),
333
+ "intake_facts": [
334
+ {
335
+ "fact": "Chest pain with shortness of breath reported.",
336
+ "status": "reported",
337
+ "source": "structured_field",
338
+ }
339
+ ],
340
+ "candidate_protocol_pathways": [
341
+ {
342
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
343
+ "reason_relevant": "Chest pain with shortness of breath was reported.",
344
+ }
345
+ ],
346
+ "missing_info_to_collect": [
347
+ "chest pain description",
348
+ "onset and duration",
349
+ "available vital signs",
350
+ ],
351
+ "next_observations_to_collect": [],
352
+ "conflicts_or_uncertainties": [],
353
+ "responder_checklist": ["Escalate per cited local protocol."],
354
+ "do_not_do": ["Do not diagnose."],
355
+ "source_cards": ["CHEST-PAIN-ESCALATION-v1"],
356
+ "handoff_note_sbar": {
357
+ "situation": "Chest pain with shortness of breath.",
358
+ "background": "Mobile clinic adult case.",
359
+ "assessment_observations_only": "Crushing chest pain and shortness of breath reported. HR 118.",
360
+ "handoff_request": "Escalate per protocol.",
361
+ },
362
+ "responder_plain_language_script": "I am going to keep checking observations and follow the local escalation path.",
363
+ "safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
364
+ "selected_required_observation_ids": [
365
+ "CHEST-PAIN-ESCALATION-v1::required_observation::1",
366
+ "CHEST-PAIN-ESCALATION-v1::required_observation::2",
367
+ "NOT-A-REAL-TARGET",
368
+ ],
369
+ }
370
+
371
+
372
+ class FiredCardOmittedFromRetrievalModelClient:
373
+ def __init__(self, *_: Any, **__: Any) -> None:
374
+ pass
375
+
376
+ def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
377
+ return {
378
+ "protocol_urgency": "emergency",
379
+ "red_flags": _stroke_rules(),
380
+ "intake_facts": [
381
+ {
382
+ "fact": "Sudden one-sided weakness and trouble speaking.",
383
+ "status": "reported",
384
+ "source": "structured_field",
385
+ }
386
+ ],
387
+ "candidate_protocol_pathways": [
388
+ {
389
+ "card_id": "SAFETY-BOUNDARIES-v1",
390
+ "reason_relevant": "Safety boundaries are always relevant.",
391
+ },
392
+ {
393
+ "card_id": "REFERRAL-SBAR-v1",
394
+ "reason_relevant": "SBAR supports escalation handoff.",
395
+ },
396
+ ],
397
+ "missing_info_to_collect": ["blood pressure if available"],
398
+ "next_observations_to_collect": ["speech and one-sided weakness status"],
399
+ "conflicts_or_uncertainties": ["Blood pressure not yet measured."],
400
+ "responder_checklist": ["Keep the stroke red flag visible."],
401
+ "do_not_do": ["Do not diagnose.", "Do not prescribe."],
402
+ "source_cards": ["SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"],
403
+ "handoff_note_sbar": {
404
+ "situation": "one-sided weakness",
405
+ "background": "Age 56. Not pregnant.",
406
+ "assessment_observations_only": "Sudden one-sided weakness and trouble speaking. Stroke sign red flag fired.",
407
+ "handoff_request": "Request emergency review per cited local protocol cards.",
408
+ },
409
+ "responder_plain_language_script": "I am going to keep the stroke red flag visible and request emergency review.",
410
+ "safety_boundary": "Prototype protocol navigation only; no diagnosis or treatment order.",
411
+ }
412
+
413
+
414
  def _confirmed_chest_pain_intake() -> dict[str, Any]:
415
  return {
416
  "setting": "mobile clinic",
 
460
  ]
461
 
462
 
463
+ def _confirmed_stroke_intake() -> dict[str, Any]:
464
+ return {
465
+ "setting": "mobile clinic",
466
+ "patient_age": "56",
467
+ "pregnancy_status": "not_pregnant",
468
+ "chief_concern": "one-sided weakness",
469
+ "symptoms": "Sudden one-sided weakness and trouble speaking",
470
+ "vitals": "blood pressure not yet measured; pulse fast; respirations unlabored",
471
+ "responder_note": "Adult with acute stroke-sign concern.",
472
+ "confirmed": True,
473
+ }
474
+
475
+
476
+ def _stroke_rules() -> list[dict[str, Any]]:
477
+ return [
478
+ {
479
+ "rule_id": "STROKE-001",
480
+ "label": "Stroke sign",
481
+ "urgency": "emergency",
482
+ "evidence": "one-sided weakness",
483
+ "card_id": "STROKE-SIGNS-v1",
484
+ }
485
+ ]
486
+
487
+
488
+ def _retrieved_without_stroke_cards() -> list[dict[str, Any]]:
489
+ return [
490
+ {
491
+ "card_id": "SAFETY-BOUNDARIES-v1",
492
+ "title": "Safety boundaries",
493
+ "score": 1.0,
494
+ "source": "test",
495
+ "card": {
496
+ "card_id": "SAFETY-BOUNDARIES-v1",
497
+ "title": "Safety boundaries",
498
+ "required_observations": [],
499
+ },
500
+ },
501
+ {
502
+ "card_id": "REFERRAL-SBAR-v1",
503
+ "title": "Referral SBAR",
504
+ "score": 0.9,
505
+ "source": "test",
506
+ "card": {
507
+ "card_id": "REFERRAL-SBAR-v1",
508
+ "title": "Referral SBAR",
509
+ "required_observations": [],
510
+ },
511
+ },
512
+ ]
513
+
514
+
515
  def _confirmed_audio_draft_with_raw_metadata() -> dict[str, Any]:
516
  return {
517
  "audio_intake_path": "/tmp/uploads/field-case.wav",
 
590
  retrieved_cards=_retrieved_chest_pain_cards(),
591
  )
592
 
593
+ assert RepairingModelClient.calls == 1
594
  assert output["protocol_urgency"] == "emergency"
595
  assert trace.validator_result["passed"] is True
596
  assert trace.model_route["fallback_tier"] == "configured"
597
  assert trace.model_route["fallback_reason"] is None
598
+ assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
599
+ assert any("handoff SBAR scaffold applied deterministically" in event for event in trace.events)
600
 
601
 
602
  def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeypatch) -> None:
 
618
  assert trace.field_provenance["responder_checklist"] == "model_raw"
619
  assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
620
  assert trace.to_dict()["field_provenance"]["responder_checklist"] == "model_raw"
621
+ assert any("handoff SBAR scaffold applied deterministically" in event for event in trace.events)
622
 
623
 
624
  def test_run_navigation_scrubs_audio_trace_payload(tmp_path: Path) -> None:
 
707
  assert trace.to_dict()["model_route"]["final_route"] == "model_with_deterministic_patches"
708
 
709
 
710
+ def test_run_navigation_allows_known_fired_card_when_retrieval_missed_it(monkeypatch) -> None:
711
+ monkeypatch.setattr(navigator, "ModelClient", FiredCardOmittedFromRetrievalModelClient)
712
+
713
+ output, trace = navigator.run_navigation(
714
+ _confirmed_stroke_intake(),
715
+ _stroke_rules(),
716
+ config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
717
+ retrieved_cards=_retrieved_without_stroke_cards(),
718
+ )
719
+
720
+ assert trace.validator_result["passed"] is True
721
+ assert "STROKE-SIGNS-v1" in output["source_cards"]
722
+ assert "STROKE-SIGNS-v1" in {
723
+ pathway["card_id"] for pathway in output["candidate_protocol_pathways"]
724
+ }
725
+ assert trace.field_provenance["source_cards"] == "deterministic_fallback"
726
+ assert trace.field_provenance["candidate_protocol_pathways"] == "deterministic_fallback"
727
+ assert output["harness_evidence"]["deterministic_rule_card_ids"] == ["STROKE-SIGNS-v1"]
728
+
729
+
730
  def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypatch) -> None:
731
  MultiFailureRepairModelClient.calls = 0
732
  monkeypatch.setattr(navigator, "ModelClient", MultiFailureRepairModelClient)
 
743
  assert trace.model_route["repair_attempt_cap"] == 2
744
  assert trace.model_route["repair_capped"] is True
745
  assert trace.model_route["repair_latency_ms"] >= 0
746
+
747
+
748
+ def test_run_navigation_fills_required_observation_targets_without_counting_as_model_raw(monkeypatch) -> None:
749
+ ObservationThinModelClient.calls = 0
750
+ monkeypatch.setattr(navigator, "ModelClient", ObservationThinModelClient)
751
+
752
+ output, trace = navigator.run_navigation(
753
+ _confirmed_chest_pain_intake(),
754
+ _emergency_chest_pain_rules(),
755
+ config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
756
+ retrieved_cards=_retrieved_chest_pain_cards(),
757
+ )
758
+
759
+ observation_text = json.dumps(
760
+ output["missing_info_to_collect"] + output["next_observations_to_collect"]
761
+ ).lower()
762
+ assert ObservationThinModelClient.calls == 1
763
+ assert "chest pain description" in observation_text
764
+ assert "onset and duration" in observation_text
765
+ assert "shortness of breath report" in observation_text
766
+ assert "available vital signs" in observation_text
767
+ assert trace.validator_result["passed"] is True
768
+ assert trace.model_route["field_level_fallback_used"] is True
769
+ assert trace.field_provenance["missing_info_to_collect"] == "deterministic_fallback"
770
+ assert trace.field_provenance["next_observations_to_collect"] == "deterministic_fallback"
771
+ assert trace.model_route["filled_required_observation_ids"] == [
772
+ "CHEST-PAIN-ESCALATION-v1::required_observation::1",
773
+ "CHEST-PAIN-ESCALATION-v1::required_observation::2",
774
+ "CHEST-PAIN-ESCALATION-v1::required_observation::3",
775
+ ]
776
+ assert any("required-observation targets filled" in event for event in trace.events)
777
+
778
+
779
+ def test_run_navigation_strips_and_traces_selected_required_observation_ids(monkeypatch) -> None:
780
+ SelectedObservationIdsModelClient.calls = 0
781
+ monkeypatch.setattr(navigator, "ModelClient", SelectedObservationIdsModelClient)
782
+
783
+ output, trace = navigator.run_navigation(
784
+ _confirmed_chest_pain_intake(),
785
+ _emergency_chest_pain_rules(),
786
+ config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
787
+ retrieved_cards=_retrieved_chest_pain_cards(),
788
+ )
789
+
790
+ observation_text = json.dumps(
791
+ output["missing_info_to_collect"] + output["next_observations_to_collect"]
792
+ ).lower()
793
+ assert SelectedObservationIdsModelClient.calls == 1
794
+ assert "selected_required_observation_ids" not in output
795
+ assert "shortness of breath report" in observation_text
796
+ assert "chest pain description" in observation_text
797
+ assert "onset and duration" in observation_text
798
+ assert trace.validator_result["passed"] is True
799
+ assert trace.model_route["model_selected_required_observation_ids"] == [
800
+ "CHEST-PAIN-ESCALATION-v1::required_observation::1",
801
+ "CHEST-PAIN-ESCALATION-v1::required_observation::2",
802
+ ]
803
+ assert trace.model_route["invalid_selected_required_observation_ids"] == ["NOT-A-REAL-TARGET"]
804
+ assert trace.model_route["stripped_trace_only_fields"] == ["selected_required_observation_ids"]
805
+ assert trace.model_route["filled_required_observation_ids"] == [
806
+ "CHEST-PAIN-ESCALATION-v1::required_observation::3"
807
+ ]
808
+ assert trace.field_provenance["missing_info_to_collect"] == "deterministic_fallback"
809
+ assert any("trace-only required-observation target ids stripped" in event for event in trace.events)
tests/test_prompt_builder_contract.py CHANGED
@@ -154,6 +154,32 @@ def test_prompt_context_lists_allowed_facts_and_required_observations() -> None:
154
  "available vital signs",
155
  ]
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  def test_prompt_context_guides_routine_and_negated_cases() -> None:
159
  prompt, _ = build_prompt(
@@ -172,3 +198,93 @@ def test_prompt_context_guides_routine_and_negated_cases() -> None:
172
  assert "Do not convert denied or absent symptoms into red_flags" in guidance_text
173
  assert "keep protocol_urgency routine" in guidance_text
174
  assert "nearby emergency card language" in guidance_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  "available vital signs",
155
  ]
156
 
157
+ targets = context["required_observation_targets"]
158
+ chest_targets = [target for target in targets if target["card_id"] == "CHEST-PAIN-ESCALATION-v1"]
159
+ assert chest_targets == [
160
+ {
161
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
162
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
163
+ "title": "Chest pain escalation",
164
+ "display_text": "chest pain description",
165
+ "cue_tokens": ["chest", "pain", "description"],
166
+ },
167
+ {
168
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::2",
169
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
170
+ "title": "Chest pain escalation",
171
+ "display_text": "onset and duration",
172
+ "cue_tokens": ["onset", "duration"],
173
+ },
174
+ {
175
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::3",
176
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
177
+ "title": "Chest pain escalation",
178
+ "display_text": "available vital signs",
179
+ "cue_tokens": ["available", "vital", "signs"],
180
+ },
181
+ ]
182
+
183
 
184
  def test_prompt_context_guides_routine_and_negated_cases() -> None:
185
  prompt, _ = build_prompt(
 
198
  assert "Do not convert denied or absent symptoms into red_flags" in guidance_text
199
  assert "keep protocol_urgency routine" in guidance_text
200
  assert "nearby emergency card language" in guidance_text
201
+
202
+ ledger = context["case_fact_ledger"]
203
+ absent_text = json.dumps(ledger["absent_or_denied"], sort_keys=True).lower()
204
+ present_text = json.dumps(ledger["present"], sort_keys=True).lower()
205
+ assert "no chest pain" in absent_text
206
+ assert "no shortness of breath" in absent_text
207
+ assert "routine cough check" in present_text
208
+
209
+
210
+ def test_prompt_context_includes_sbar_template_and_internal_target_id_contract() -> None:
211
+ prompt, _ = build_prompt(
212
+ _confirmed_chest_pain_intake(),
213
+ _retrieved_cards(),
214
+ _emergency_chest_pain_rules(),
215
+ "emergency",
216
+ )
217
+ context = _context_from_prompt(prompt)
218
+
219
+ sbar_template = context["handoff_note_sbar_template"]
220
+ assert sbar_template == {
221
+ "situation": "Chest pain",
222
+ "background": "Setting: mobile clinic. Age: 52. Pregnancy status: not_applicable.",
223
+ "assessment_observations_only": "Symptoms: Crushing chest pain and shortness of breath. Vitals: HR 118; blood pressure pending. Red flags: Chest pain escalation cue.",
224
+ "handoff_request": "Request emergency review/escalation per cited local protocol cards.",
225
+ }
226
+
227
+ internal_contract = context["internal_generation_contract"]
228
+ assert internal_contract["trace_only_keys"] == ["selected_required_observation_ids"]
229
+ assert internal_contract["required_when_required_observation_targets_selected"] == [
230
+ "selected_required_observation_ids"
231
+ ]
232
+ assert internal_contract["strip_before_user_display"] is True
233
+ assert "selected_required_observation_ids" in internal_contract["selected_required_observation_ids"]
234
+ assert "Choose required observation IDs before writing observation text" in prompt
235
+ assert "recognizable responder-facing observation text" in internal_contract["selected_required_observation_ids"]
236
+ assert "selected_required_observation_ids" not in context["navigator_output_schema"]
237
+ assert "selected_required_observation_ids" not in context["required_json_skeleton"]
238
+
239
+
240
+ def test_prompt_context_names_mandatory_source_cards_and_model_owned_observation_policy() -> None:
241
+ prompt, _ = build_prompt(
242
+ _confirmed_chest_pain_intake(),
243
+ _retrieved_cards(),
244
+ _emergency_chest_pain_rules(),
245
+ "emergency",
246
+ )
247
+ context = _context_from_prompt(prompt)
248
+
249
+ assert context["mandatory_source_card_ids"] == [
250
+ "CHEST-PAIN-ESCALATION-v1",
251
+ "REFERRAL-SBAR-v1",
252
+ ]
253
+ assert context["mandatory_required_observation_target_ids"] == [
254
+ "CHEST-PAIN-ESCALATION-v1::required_observation::1",
255
+ "CHEST-PAIN-ESCALATION-v1::required_observation::2",
256
+ "CHEST-PAIN-ESCALATION-v1::required_observation::3",
257
+ ]
258
+ assert context["mandatory_required_observation_targets"] == [
259
+ {
260
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
261
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
262
+ "title": "Chest pain escalation",
263
+ "display_text": "chest pain description",
264
+ },
265
+ {
266
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::2",
267
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
268
+ "title": "Chest pain escalation",
269
+ "display_text": "onset and duration",
270
+ },
271
+ {
272
+ "id": "CHEST-PAIN-ESCALATION-v1::required_observation::3",
273
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
274
+ "title": "Chest pain escalation",
275
+ "display_text": "available vital signs",
276
+ },
277
+ ]
278
+ policy = context["required_observation_generation_policy"]
279
+ assert policy["model_owned_not_scaffold_filled"] is True
280
+ assert "mandatory_required_observation_targets" in policy
281
+ assert "missing_info_to_collect" in policy["source_card_scope"]
282
+ assert "mandatory_source_card_id" in policy["source_cards"]
283
+ assert "mandatory_required_observation_target_id" in policy["selected_required_observation_ids"]
284
+ assert "confirm/document" in policy["text_requirement"]
285
+ assert "Emit selected_required_observation_ids as a trace-only key" in prompt
286
+ assert "Include every mandatory_source_card_id in source_cards" in prompt
287
+ assert "retrieved support cards used for safety/SBAR" in prompt
288
+ assert "Do not add SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1 as candidate pathways" in prompt
289
+ assert "make the target display_text visible in missing_info_to_collect" in prompt
290
+ assert "Copy each display_text into missing_info_to_collect" in prompt
tests/test_rules.py CHANGED
@@ -59,6 +59,48 @@ def test_pregnancy_danger_sign_still_fires_for_confirmed_pregnancy() -> None:
59
  assert "PREG-001" in _rule_ids(intake)
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def test_month_based_pediatric_age_counts_as_pediatric() -> None:
63
  intake = {
64
  "confirmed": True,
 
59
  assert "PREG-001" in _rule_ids(intake)
60
 
61
 
62
+ def test_negated_chest_pain_does_not_create_red_flags() -> None:
63
+ for symptoms in (
64
+ "fever with chills during postpartum period, no chest pain reported",
65
+ "cough after dust exposure; no fever, no shortness of breath, no chest pain, speaking normally",
66
+ "brief lightheadedness after standing; denies fainting, chest pain, weakness, vision change, and shortness of breath",
67
+ "fatigue after work, denies chest pain, no chest pressure, no trouble breathing",
68
+ ):
69
+ intake = {
70
+ "confirmed": True,
71
+ "setting": "field clinic",
72
+ "patient_age": "43",
73
+ "pregnancy_status": "not_applicable",
74
+ "chief_concern": "routine check",
75
+ "symptoms": symptoms,
76
+ "vitals": "pulse regular",
77
+ "allergies": "",
78
+ "medications": "",
79
+ "available_supplies": "",
80
+ "responder_note": "",
81
+ }
82
+
83
+ assert "red_flag_chest_pain" not in _rule_ids(intake)
84
+
85
+
86
+ def test_positive_chest_pain_still_fires_with_unrelated_negation() -> None:
87
+ intake = {
88
+ "confirmed": True,
89
+ "setting": "field clinic",
90
+ "patient_age": "54",
91
+ "pregnancy_status": "not_applicable",
92
+ "chief_concern": "chest pressure",
93
+ "symptoms": "no fever, but chest pain with sweating for about twenty minutes",
94
+ "vitals": "pulse fast",
95
+ "allergies": "",
96
+ "medications": "",
97
+ "available_supplies": "",
98
+ "responder_note": "",
99
+ }
100
+
101
+ assert "red_flag_chest_pain" in _rule_ids(intake)
102
+
103
+
104
  def test_month_based_pediatric_age_counts_as_pediatric() -> None:
105
  intake = {
106
  "confirmed": True,
tests/test_runtime_honesty.py CHANGED
@@ -182,6 +182,45 @@ def test_trace_and_navigator_ui_show_field_provenance_counts_for_hybrid_output()
182
  assert "Configured backend" in navigator_html
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def test_typed_transcript_audio_draft_is_not_labeled_as_real_omni_audio() -> None:
186
  app = importlib.import_module("app")
187
  config = FigmentConfig(
 
182
  assert "Configured backend" in navigator_html
183
 
184
 
185
+ def test_harness_evidence_is_visible_outside_model_authored_text() -> None:
186
+ app = importlib.import_module("app")
187
+ evidence = {
188
+ "confirmed_intake": True,
189
+ "retrieved_card_ids": ["CHEST-PAIN-ESCALATION-v1", "REFERRAL-SBAR-v1"],
190
+ "deterministic_rule_ids": ["CHEST-001"],
191
+ "urgency_floor": "emergency",
192
+ "validator_status": "passed",
193
+ "audio_correction_status": "not_applicable",
194
+ "source_card_ids": ["CHEST-PAIN-ESCALATION-v1"],
195
+ "final_route": "live_model_generated",
196
+ }
197
+ trace = {
198
+ "events": ["validation complete"],
199
+ "model_route": {"model_backend": "llama_cpp", "fallback_tier": "configured"},
200
+ "validator_result": {"passed": True, "failures": []},
201
+ "navigator_output": {"harness_evidence": evidence},
202
+ "raw_audio_stored": False,
203
+ }
204
+ output = {
205
+ "protocol_urgency": "emergency",
206
+ "missing_info_to_collect": ["repeat vitals"],
207
+ "responder_checklist": [],
208
+ "do_not_do": [],
209
+ "source_cards": ["CHEST-PAIN-ESCALATION-v1"],
210
+ "handoff_note_sbar": {},
211
+ "harness_evidence": evidence,
212
+ }
213
+
214
+ navigator_html = app._navigator_summary_html(output, trace)
215
+ trace_html = app._trace_audit_html(trace)
216
+
217
+ assert "Harness Evidence" in navigator_html
218
+ assert "Harness Evidence" in trace_html
219
+ assert "Retrieved cards: 2" in navigator_html
220
+ assert "Validation: passed" in navigator_html
221
+ assert "Harness evidence: visible" in trace_html
222
+
223
+
224
  def test_typed_transcript_audio_draft_is_not_labeled_as_real_omni_audio() -> None:
225
  app = importlib.import_module("app")
226
  config = FigmentConfig(
tests/test_validators_strict.py CHANGED
@@ -60,8 +60,20 @@ def _navigator_output(**overrides: Any) -> dict[str, Any]:
60
  "reason_relevant": "Chest pain with shortness of breath was reported.",
61
  }
62
  ],
63
- "missing_info_to_collect": ["onset and duration", "complete vital signs"],
64
- "next_observations_to_collect": ["shortness of breath trend"],
 
 
 
 
 
 
 
 
 
 
 
 
65
  "conflicts_or_uncertainties": [],
66
  "responder_checklist": ["Escalate per cited local protocol."],
67
  "do_not_do": ["Do not diagnose or prescribe."],
@@ -130,6 +142,34 @@ def test_strict_validator_rejects_known_but_unretrieved_card_citation() -> None:
130
  assert any("not in allowed/retrieved card IDs" in failure for failure in result.failures)
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  def test_strict_validator_rejects_generic_missing_info_and_hallucinated_sbar_facts() -> None:
134
  output = _navigator_output(
135
  missing_info_to_collect=["ask anything else that seems relevant"],
 
60
  "reason_relevant": "Chest pain with shortness of breath was reported.",
61
  }
62
  ],
63
+ "missing_info_to_collect": [
64
+ "chest pain description",
65
+ "onset and duration",
66
+ "shortness of breath report",
67
+ "sweating or fainting report",
68
+ "radiation to arm, jaw, back, or shoulder",
69
+ "available vital signs",
70
+ ],
71
+ "next_observations_to_collect": [
72
+ "chest pain description",
73
+ "onset and duration",
74
+ "shortness of breath report",
75
+ "available vital signs",
76
+ ],
77
  "conflicts_or_uncertainties": [],
78
  "responder_checklist": ["Escalate per cited local protocol."],
79
  "do_not_do": ["Do not diagnose or prescribe."],
 
142
  assert any("not in allowed/retrieved card IDs" in failure for failure in result.failures)
143
 
144
 
145
+ def test_strict_validator_allows_known_fired_rule_card_even_when_retrieval_missed_it() -> None:
146
+ output = _navigator_output(
147
+ source_cards=["CHEST-PAIN-ESCALATION-v1"],
148
+ candidate_protocol_pathways=[
149
+ {
150
+ "card_id": "CHEST-PAIN-ESCALATION-v1",
151
+ "reason_relevant": "Chest pain red flag fired deterministically.",
152
+ }
153
+ ],
154
+ )
155
+
156
+ result = validate_navigator_output(
157
+ output,
158
+ {
159
+ "CHEST-PAIN-ESCALATION-v1",
160
+ "SAFETY-BOUNDARIES-v1",
161
+ "WOUND-INFECTION-ESCALATION-v1",
162
+ },
163
+ urgency_floor="emergency",
164
+ confirmed_intake=_confirmed_chest_pain_intake(),
165
+ rule_results=[_chest_rule()],
166
+ retrieved_card_ids={"SAFETY-BOUNDARIES-v1"},
167
+ strict_schema=True,
168
+ )
169
+
170
+ assert result.passed
171
+
172
+
173
  def test_strict_validator_rejects_generic_missing_info_and_hallucinated_sbar_facts() -> None:
174
  output = _navigator_output(
175
  missing_info_to_collect=["ask anything else that seems relevant"],