"
+ )
+
+ # ─── Event wiring ────────────────────────────────────────────────────
+
+ def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers, sess):
+ """Map UI display values to internal values and accumulate into the session."""
+ test_map = {name: val for name, val in FMS_TESTS}
+ test_name = test_map.get(test_display_name, "deep_squat")
+ side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
+ return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
+
+ submit_btn.click(
+ fn=_map_inputs,
+ inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
+ overlay_layers, session_state],
+ outputs=[session_state, score_html, pipeline_md, score_details, alerts_md,
+ overlay_video, velocity_md, session_table, new_clip_btn, finish_btn],
+ )
+
+ def _new_clip():
+ """Clear inputs for the next clip; keep the session intact."""
+ return None, _render_empty_state(), ""
+
+ new_clip_btn.click(
+ fn=_new_clip,
+ inputs=[],
+ outputs=[video_input, score_html, score_details],
+ )
+
+ finish_btn.click(
+ fn=_finish_session,
+ inputs=[session_state],
+ outputs=[finish_summary, pdf_file, md_file],
+ )
+
+ return app
+
+
+if __name__ == "__main__":
+ app = build_app()
+ app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS)
diff --git a/docs/FormScout-FMS-Spec.md b/docs/FormScout-FMS-Spec.md
new file mode 100644
index 0000000000000000000000000000000000000000..e815f4266ca5eddacf1deae0073215b142fb36ec
--- /dev/null
+++ b/docs/FormScout-FMS-Spec.md
@@ -0,0 +1,277 @@
+# FormScout — Functional Movement Screening, scored small
+
+**Project specification & architecture documentation**
+*Build Small Hackathon (Gradio × Hugging Face) — Track: Backyard AI*
+*Working title; rename freely. Doc version 0.1, June 2026.*
+
+---
+
+## 1. One-paragraph pitch
+
+A basketball team's physiotherapist screens players with the **Functional Movement Screen (FMS)** — seven movement patterns, each scored 0–3 by eye. The scoring is slow, subjective, and hard to reproduce across raters or across months. FormScout is a Gradio app that takes a video of an athlete performing an FMS test, extracts 2D and 3D body pose, measures the biomechanics the FMS rubric actually cares about, and produces a 0–3 score *with a written rationale and an annotated overlay* — anchored to the physio's own previously-scored clips. It is a **screening aid that standardizes and speeds up the physio's first pass**, not a diagnosis and not an injury predictor. Everything runs on models that fit on a laptop.
+
+---
+
+## 2. The problem, honestly
+
+The FMS is a seven-test battery (Deep Squat, Hurdle Step, In-Line Lunge, Shoulder Mobility, Active Straight-Leg Raise, Trunk Stability Push-Up, Rotary Stability), each scored 0–3 for a composite 0–21. A score of 0 means **pain** during the movement and is an automatic red flag for clinical referral. Three of the tests have associated **clearing tests** (shoulder, spinal extension, spinal flexion) that also force a 0 on pain.
+
+Two facts shape this project and should be stated plainly in the demo and the writeup:
+
+- **Inter-rater reliability is decent but not perfect.** Composite-score reliability is moderate-to-good (ICC roughly 0.7–0.8), but novice and less-experienced raters grade component scores inconsistently. This is the real, addressable pain point: **variance between raters and over time.**
+- **Predictive validity for injury is weak/mixed.** The popular "≤14 = higher injury risk" cutoff is not a reliable predictor on its own. So FormScout must **not** be sold as injury prediction.
+
+**Where FormScout genuinely helps:**
+1. A repeatable, objective **digital baseline** to track an athlete over a season.
+2. **Asymmetry detection** (left vs. right), which is one of the FMS's most defensible outputs.
+3. A fast, consistent **first-pass / second opinion** that reduces rater variance.
+4. **Explainability** — it shows *which compensation* it saw, not just a number.
+
+This honest framing is also strategic: the Backyard AI track is judged partly on "honest fit between problem and the small-model constraint." Overclaiming clinical power would hurt the submission, not help it.
+
+---
+
+## 3. Why this fits the hackathon
+
+| Hackathon rule | How FormScout satisfies it |
+|---|---|
+| **Total params ≤ 32B** | Recommended config sums to ~18B. A portfolio of small specialists beats one monolith — which is on-theme for "think small." |
+| **Built on Gradio, hosted as a HF Space** | Gradio app with `gr.Video` input, a custom-styled results panel, on-Space inference (ZeroGPU or llama.cpp). |
+| **Show, Don't Tell** | Demo video = physio uploads a real player clip, gets a scored overlay in seconds. Social post = before/after of a manual vs. assisted screening session. |
+| **Track: Backyard AI** | The "someone you know" is the team physiotherapist. The deliverable is something they *actually use* on real players. |
+
+**Badge targets (aim for all six):**
+
+- 🔌 **Off the Grid** — no cloud APIs; all models served on the Space.
+- 🎯 **Well-Tuned** — the skeletal-temporal scoring head is fine-tuned on the physio's labels and published to the Hub.
+- 🎨 **Off-Brand** — custom Gradio frontend (scorecard UI, video overlay, per-test rubric panel), pushing past default Gradio.
+- 🦙 **Llama Champion** — VLM + embedding model served through llama.cpp (GGUF builds exist for both).
+- 📡 **Sharing is Caring** — publish the agent trace (one full screening run, agent by agent) to the Hub.
+- 📓 **Field Notes** — a blog post on building a clinical-adjacent AQA pipeline under a 32B budget, with the honesty section front and center.
+
+---
+
+## 4. Core technical framing: FMS *is* Action Quality Assessment
+
+Don't reinvent this from scratch. **Action Quality Assessment (AQA)** is the established field for "score how well a movement was performed." Skeleton-based AQA (sports scoring, surgical-skill and rehab assessment) is the directly relevant lineage. The "Skeletal-Temporal Transformer" idea maps onto the **AQA scoring head**.
+
+The key design constraint is the **tiny labeled dataset** (a couple of physio-scored videos). That rules out training a large score regressor from scratch and dictates a hybrid approach:
+
+1. **Deterministic biomechanics** carry most of the load. The FMS rubric is, to a large degree, a set of *angle and alignment thresholds* (e.g. Deep Squat "3" = femur below horizontal, torso parallel to tibia, knees tracking over feet, dowel over feet). These are computable from 3D pose with **zero training** and are inherently interpretable — exactly what earns a physio's trust.
+2. **A small learned head** (ST-GCN or a compact temporal transformer) refines the score and captures the patterns rules miss. It is small enough to fine-tune on a few labeled clips, *especially* if pre-trained on public AQA/pose datasets first.
+3. **Retrieval over the physio's labeled clips** (RAG) gives the language model few-shot anchors at judgment time — the right move when you have examples but not enough to train on.
+4. **A VLM as the judge/explainer** synthesizes rubric + measurements + retrieved exemplars into a final score and a human-readable rationale, and conservatively flags anything pain-related for a human.
+
+---
+
+## 5. Parameter budget (the single most important table)
+
+Assume "total parameters" = **sum of all model weights in the pipeline**. Design to this; confirm the exact interpretation in the Discord AMA.
+
+### Recommended config — "Portfolio of specialists" (~18B)
+
+| Component | Model | Params | Role |
+|---|---|---:|---|
+| 2D pose + tracking | YOLO26-Pose (L/X) | ~0.05B | Per-frame 17-keypoint skeletons, multi-person tracking |
+| Segmentation | SAM 3.1 (base) | ~0.85B | Clean athlete mask, occlusion handling, prompt for 3D |
+| 3D body | SAM 3D Body | ~0.7–1B* | Single-image 3D mesh → true joint angles, view-invariant |
+| Scoring head | ST-GCN / temporal transformer (fine-tuned) | ~0.01–0.05B | Pose-sequence → candidate 0–3 + confidence |
+| Judge / explainer | Qwen3-VL-8B-Instruct | 8B | Movement ID, rubric reasoning, final score + rationale |
+| Retrieval | Qwen3-VL-Embedding-8B | 8B | Nearest physio-scored reference clips (RAG) |
+| **Total** | | **~17.8B** | Comfortable headroom under 32B |
+
+\* SAM 3D Body's exact count isn't published prominently — verify on the model card. It's SAM-3-family and sub-billion-class; budget impact is small either way. The two 8B Qwen models **share the Qwen3-VL-8B backbone** (the embedder is built on the instruct model), which is conceptually clean and operationally efficient.
+
+### Alternative config — "Heavy reasoner" (~28.7B)
+
+Swap the 8B judge for **Qwen3.6-27B** (multimodal, strong tool-calling, MTP speedups on llama.cpp). Budget then = 27 + ~0.85 + ~1 + small ≈ **28.7B**. This **leaves no room for the 8B embedder**, so you'd drop RAG (or replace it with a sub-0.5B embedder, or use pose-feature similarity for retrieval). Note: Qwen3.6-27B's MTP speculative decoding currently can't run simultaneously with image input (`--mmproj`), so for vision you run it without MTP.
+
+**Recommendation: ship the ~18B portfolio config.** RAG over the physio's few labeled clips is worth more than raw reasoning horsepower on this task, the headroom de-risks the budget, and "many small specialists" is the better hackathon story.
+
+---
+
+## 6. Model selection rationale
+
+**YOLO26-Pose** — current-generation YOLO pose; single forward pass for detection + keypoints, NMS-free, real-time even on edge. Tiny param cost. It also handles **multiple people in frame** (important: team videos often have other players/staff visible) and feeds keypoints downstream. Off-the-shelf it predicts COCO human keypoints; can be fine-tuned for custom landmarks (e.g. dowel endpoints) if needed.
+
+**SAM 3.1** — gives a clean athlete mask and stable multi-object video tracking (Object Multiplex makes it fast). Two jobs: (a) isolate the target athlete from teammates/background so pose and 3D aren't polluted, (b) provide the mask prompt that SAM 3D Body consumes. Concept prompts ("the person in the blue jersey performing the squat") are a bonus for disambiguation.
+
+**SAM 3D Body** — *the addition that makes the scores trustworthy.* FMS criteria are joint angles and symmetry; 2D pose can't measure these reliably across camera angles (projection ambiguity). 3D mesh recovery from a single image, promptable with the 2D keypoints + mask you already have, yields view-invariant joint angles (the MHR rig even separates skeletal structure from soft-tissue shape, which is convenient for angle extraction). This is the difference between "looks bent" and "femur is 4° above horizontal → not a 3."
+
+**Skeletal-temporal scoring head** — your AQA component and your **Well-Tuned** badge. Recommend a compact **ST-GCN** (graph conv over the skeleton, temporal conv over frames) over a from-scratch transformer, because it's far more data-efficient on a tiny labeled set. Pre-train on public AQA / pose-action data, then fine-tune on the physio's labels. Output: per-test candidate score + a confidence the judge can weigh.
+
+**Qwen3-VL-8B-Instruct** — the judge. Strong video temporal modeling (Interleaved-MRoPE, timestamp alignment) suits movement clips. It identifies which of the 7 tests is being performed, reads the biomechanics, considers retrieved exemplars and the head's candidate, and emits the final score + rationale + detected compensation. GGUF → llama.cpp → Llama Champion.
+
+**Qwen3-VL-Embedding-8B** — retrieval. Embeds the query clip (or its keyframes/pose-render) and finds the physio's most similar already-scored clips to anchor the judge. Top multimodal retriever on MMEB-V2; same backbone as the judge; GGUF available.
+
+---
+
+## 7. Architecture — an agentic pipeline
+
+Structured as cooperating specialist agents (maps naturally onto an OFP-style orchestration, with a Director coordinating and quality-gating). Each agent has one job and a typed output.
+
+```
+ ┌──────────────────────────────────────────────┐
+ video upload ───────▶│ IngestAgent │
+ │ decode, normalize FPS, sample frames │
+ └───────────────┬──────────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ SegmentationAgent (SAM 3.1) │
+ │ athlete mask + track id (reject teammates) │
+ └───────────────┬──────────────────────────────┘
+ ▼
+ ┌──────────────────────────┴──────────────────────────┐
+ ▼ ▼
+ ┌───────────────────────────┐ ┌───────────────────────────┐
+ │ PoseAgent (YOLO26-Pose) │ │ Body3DAgent (SAM 3D Body) │
+ │ 2D keypoints per frame │ ───keypoints+mask──▶ │ 3D mesh / joint angles │
+ └───────────────┬───────────┘ └───────────────┬───────────┘
+ └─────────────────────┬────────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ MovementClassifierAgent │
+ │ which of the 7 FMS tests? (VLM or small CLS) │
+ └───────────────┬──────────────────────────────┘
+ ▼
+ ┌──────────────────────────┴──────────────────────────┐
+ ▼ ▼ ▼
+ ┌────────────────────┐ ┌─────────────────────────┐ ┌────────────────────────┐
+ │ BiomechanicsAgent │ │ ScoringAgent (ST-GCN) │ │ RetrievalAgent │
+ │ rubric angles, │ │ candidate 0–3 + conf │ │ (Qwen3-VL-Embedding) │
+ │ ROM, symmetry, │ │ from pose sequence │ │ k nearest physio clips │
+ │ alignment, timing │ │ │ │ + their scores │
+ └─────────┬──────────┘ └───────────┬─────────────┘ └───────────┬────────────┘
+ └───────────────────────────┴──────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ JudgeAgent (Qwen3-VL-8B) │
+ │ rubric + measurements + exemplars + candidate│
+ │ → final 0–3, rationale, compensation tag, │
+ │ corrective hint, PAIN/CLEARING → defer │
+ └───────────────┬──────────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ ReportAgent │
+ │ per-test card, composite 0–21, asymmetry │
+ │ flags, annotated video, exportable PDF │
+ └──────────────────────────────────────────────┘
+```
+
+**Agent contracts (sketch):**
+
+- `IngestAgent` → `{frames[], fps, duration, n_people}`
+- `SegmentationAgent` → `{athlete_track_id, masks[]}`
+- `PoseAgent` → `{keypoints_2d[frame][joint]={x,y,conf}}`
+- `Body3DAgent` → `{joints_3d[frame][joint]={x,y,z}, mesh_optional}`
+- `MovementClassifierAgent` → `{test_name, side: left|right|n/a, confidence}`
+- `BiomechanicsAgent` → `{features: {torso_tibia_angle, hip_flexion_deg, knee_valgus_deg, dowel_alignment, L_R_symmetry, ...}}`
+- `ScoringAgent` → `{candidate_score: 0–3, confidence}`
+- `RetrievalAgent` → `{exemplars: [{clip_id, score, similarity}]}`
+- `JudgeAgent` → `{score: 0–3, rationale, compensation_tags[], corrective_hint, needs_human: bool}`
+- `ReportAgent` → `{per_test[], composite, asymmetries[], overlay_video, pdf}`
+
+**Quality gating:** if the ST-GCN candidate and the JudgeAgent disagree by ≥1 point, or any agent confidence is low, the report marks the test **"low confidence — physio review recommended."** This keeps the human in the loop and is itself a selling point.
+
+---
+
+## 8. Scoring methodology, per test
+
+The seven tests reduce to measurable quantities. Build a small rubric module — one scoring function per test — that consumes the 3D features and returns a score with the triggering reason. Examples:
+
+- **Deep Squat (3):** femur below horizontal AND torso parallel to tibia AND knees tracking over feet AND dowel over feet. **(2):** same but achieved only with heels elevated. **(1):** criteria unmet even with heels elevated. → all four conditions are angle/alignment checks on the 3D pose.
+- **Hurdle Step / In-Line Lunge / Shoulder Mobility / ASLR:** bilateral — score each side, **record the lower** as the test score, and **always emit the asymmetry** even when the score is the same.
+- **Trunk Stability Push-Up / Rotary Stability:** trunk rigidity / timing of limb movement — temporal features from the pose sequence; the ST-GCN head is most valuable here.
+- **Pain / clearing tests (0):** the system **cannot** detect pain. Any clearing test, or a visible distress/abort, sets `needs_human = true` and the test is **not auto-scored**. Defer to the physio. State this loudly.
+
+Final composite = sum of seven test scores (0–21), plus an asymmetry summary. The number is never shown without its rationale.
+
+---
+
+## 9. Data & fine-tuning plan (tiny-dataset survival guide)
+
+You have "a couple" of physio-scored clips. Treat them as gold, not as a training set.
+
+1. **Deterministic backbone first.** Get the biomechanics rubric working with no training. Validate the measured angles against the physio's scores qualitatively. This alone may be demo-ready.
+2. **Pre-train the ST-GCN** on public pose-action / AQA data (action recognition or generic AQA) so it learns temporal movement structure, not FMS labels.
+3. **Fine-tune on the physio's clips** with heavy augmentation: temporal crops/speed jitter, mirror (left↔right, doubles your bilateral data), camera-angle perturbation in 3D, joint noise. Few-shot, regularized, early-stopped.
+4. **Hold out at least one physio-scored clip** as a sanity check the judge never sees.
+5. **RAG instead of more training.** Every labeled clip goes into the embedding index as a scoring anchor. New clips added later improve the system with no retraining — a nice longitudinal story for the physio.
+6. **Publish the fine-tuned head** to the Hub with a model card (→ Well-Tuned badge). Include the augmentation recipe and the honest "trained on N clips, treat as assistive" caveat.
+
+**Label schema to collect from the physio** (if you can get a bit more data): `clip_id, athlete_id, test_name, side, score(0–3), pain(bool), compensation_notes, camera_view`. Even 20–30 well-labeled clips meaningfully helps.
+
+---
+
+## 10. Gradio Space & deployment
+
+**UI (targets Off-Brand badge):**
+- `gr.Video` upload (or webcam capture) + a test-type selector (auto-detect, with manual override).
+- Results panel: the 0–3 score as a large dial/patch, the composite 0–21, an asymmetry strip (L/R bars), and the **rationale text**.
+- The annotated overlay video: skeleton + the specific angle that decided the score drawn on the frame where it mattered.
+- A rubric drawer that shows the official 3/2/1 criteria for the detected test, with the met/unmet conditions checked off.
+- A persistent **"Screening aid — not a diagnosis. Pain or clearing tests require a clinician."** banner.
+- Custom CSS / `gr.Server` for a non-default look (scout/trail-map theme would rhyme with the hackathon, and with your design instincts).
+
+**Compute:**
+- ZeroGPU (H200 slice) can host the ~18B portfolio; load pose/SAM/3D eagerly, the VLM + embedder via llama.cpp.
+- For **Off the Grid**, ensure zero external API calls — everything served on-Space.
+- For **Llama Champion**, route the VLM + embedding through llama.cpp (GGUF builds exist for Qwen3-VL-8B-Instruct, Qwen3-VL-Embedding-8B, and Qwen3.6-27B). On a Space, watch the CUDA/llama-cpp build flags — recent hackathon Spaces hit `libcudart` issues; a CPU-only or pinned-CUDA build is the usual fix.
+- Persist the embedding index and accumulated labels in Space storage for the longitudinal baseline.
+
+---
+
+## 11. Clinical safety & ethics (bake this in, don't bolt it on)
+
+- **Not a medical device.** Screening aid only. No diagnosis, no injury prediction, no treatment advice beyond generic FMS-style correctives.
+- **Pain is out of scope** for automatic scoring — always defer to the physio.
+- **Human-in-the-loop by design:** low-confidence and disagreement cases are surfaced, not hidden.
+- **Consent & privacy:** athlete videos are biometric data. Get consent; don't log/persist clips beyond what the physio approves; document retention in the writeup.
+- **Honesty in the demo:** show a case the system gets right *and* one it flags as uncertain. Judges (and physios) trust calibrated tools more than confident ones.
+
+---
+
+## 12. Build plan — two weekends (June 5–15)
+
+**Weekend 1 — the spine works end to end:**
+- Day 1: Space scaffold, `gr.Video` in → skeleton overlay out (YOLO26-Pose). Ingest + Segmentation + Pose agents.
+- Day 2: SAM 3D Body integrated; BiomechanicsAgent computing Deep-Squat angles; first deterministic score on a real clip.
+- Goal: upload a squat video, get a rationalized 0–3. *This alone is a viable demo.*
+
+**Midweek:** wire the JudgeAgent (Qwen3-VL via llama.cpp), MovementClassifier, and the rubric module for all 7 tests. Attend the AMA — confirm the param-sum interpretation.
+
+**Weekend 2 — make it sing:**
+- ST-GCN pre-train + few-shot fine-tune on physio clips; publish to Hub.
+- RetrievalAgent + embedding index over labeled clips.
+- Custom UI polish, asymmetry view, PDF export, safety banners.
+- Record the demo video (physio uses it on a real player), write the social post, publish the agent trace and the blog post.
+
+---
+
+## 13. Risks & open questions
+
+- **Param-sum interpretation** — biggest unknown. The ~18B config is safe under either reading; confirm anyway.
+- **SAM 3D Body on a Space** — verify weights, license, and that it runs within ZeroGPU limits; have a 2D-only fallback (angles from 2D + camera-angle caveats) if it's too heavy.
+- **Single-camera angle limits** even with 3D — note it; recommend a consistent capture protocol (fixed camera position) for the physio, which also improves the longitudinal baseline.
+- **Tiny dataset** — the deterministic rubric must stand on its own so the demo doesn't hinge on the learned head generalizing from a few clips.
+- **llama.cpp + vision build** on Spaces — budget time for the CUDA build dance; CPU fallback for the embedder is fine.
+- **Movement misclassification** — if the wrong test is detected, scoring is meaningless; keep the manual override prominent.
+
+---
+
+## 14. Quick reference — the stack
+
+| Layer | Choice | Badge it helps |
+|---|---|---|
+| 2D pose | YOLO26-Pose | — |
+| Segmentation/track | SAM 3.1 | — |
+| 3D biomechanics | SAM 3D Body | — |
+| Learned scoring | ST-GCN (fine-tuned, published) | Well-Tuned |
+| Judge/explainer | Qwen3-VL-8B-Instruct (llama.cpp) | Llama Champion |
+| Retrieval | Qwen3-VL-Embedding-8B (llama.cpp) | Llama Champion |
+| Serving | On-Space, no cloud APIs | Off the Grid |
+| Frontend | Custom Gradio (scout theme) | Off-Brand |
+| Trace | Published agent run on Hub | Sharing is Caring |
+| Writeup | Blog post w/ honesty section | Field Notes |
+
+*Total ≈ 18B params. Honest, explainable, human-in-the-loop, runs on a laptop.*
diff --git a/docs/FormScout-Starter-Kit.md b/docs/FormScout-Starter-Kit.md
new file mode 100644
index 0000000000000000000000000000000000000000..58487a6cad77abc937fe4f805040673637935bc6
--- /dev/null
+++ b/docs/FormScout-Starter-Kit.md
@@ -0,0 +1,169 @@
+# FormScout — Starter Kit & Resource Pack
+
+Companion to `FormScout-FMS-Spec.md` and `FormScout-Build-Prompt.md`. Every link below was checked. Read §1 first — some items are time-sensitive and block the build if you leave them late.
+
+---
+
+## 1. Do this NOW (before the hack window — some take hours to clear)
+
+- [ ] **Request access to the gated Meta checkpoints today.** Both are gated on Hugging Face and approval isn't instant:
+ - SAM 3 / SAM 3.1 — request on the SAM 3 repos (you need the latest code for the 3.1 checkpoints).
+ - SAM 3D Body — `facebook/sam-3d-body-dinov3` and `facebook/sam-3d-body-vith` both require an access request, then an authenticated download. **Note:** data/checkpoints are blocked in sanctioned jurisdictions — shouldn't affect SK, but verify.
+- [ ] **Put your HF token in the Space secrets** so the Space can pull the gated weights at build time.
+- [ ] **Check licenses before you commit to a model** (this affects whether you can even submit):
+ - Qwen3-VL-8B / Qwen3-VL-Embedding-8B / Qwen3.6 → **Apache-2.0** (clean).
+ - SAM 3 / SAM 3.1 / SAM 3D Body → **SAM License** (not Apache; read the terms — there are use restrictions).
+ - Ultralytics YOLO26 → historically **AGPL-3.0** (open-sourcing obligations; commercial license exists). Verify on the model/repo and make sure an AGPL dependency is OK for your submission. If it's a problem, RTMPose/ViTPose are alternatives.
+ - pyskl / MMAction2 → Apache-2.0.
+ - KIMORE / UI-PRMD → academic/research terms; check before redistributing anything derived.
+- [ ] **Confirm the param-counting rule in the Discord AMA.** Specifically: (a) is it summed across the pipeline or per-model? (b) do **frozen** base models count? (c) does a LoRA adapter's base count? Your ~18B config is safe under the strict reading either way, but get it on record.
+
+---
+
+## 2. Literature package
+
+### 2.1 The framing that wins — "evaluate like an FMS reliability study"
+
+The single most credible move in your writeup: evaluate FormScout the way the clinical literature evaluates human FMS raters. Treat the model as a *second rater* and report **weighted Cohen's κ** and **ICC** against the physio, the exact metrics the reliability papers use. That instantly makes your results legible to any sports-medicine reader and is far more honest than a vanity accuracy number.
+
+| Resource | What it gives you | Link |
+|---|---|---|
+| Physiopedia — FMS | Clean overview of the 7 tests + 0–21 scoring | https://www.physio-pedia.com/Functional_Movement_Screen_(FMS) |
+| FMS reliability study (JOSPT 2012) | The ICC/κ numbers and method you'll mirror in your eval | https://www.jospt.org/doi/10.2519/jospt.2012.3838 |
+| FMS in elite youth soccer (PMC) | Per-test scores, asymmetries, clearing-test order | https://pmc.ncbi.nlm.nih.gov/articles/PMC5675373/ |
+| Clinician's guide to FMS scoring | Per-test 3/2/1 criteria in plain language (rubric source) | https://meloqdevices.com/blogs/meloq-updates/functional-movement-screening |
+
+> **Honesty anchor for the blog post:** the popular "≤14 → injury risk" cutoff has weak/mixed predictive validity. Sell standardization, asymmetry detection, and a repeatable baseline — not prediction.
+
+### 2.2 Action Quality Assessment — surveys & living lists
+
+| Resource | Why | Link |
+|---|---|---|
+| *A Decade of AQA* (survey, 2025, 200+ papers, PRISMA) | The map of the whole field; start here | https://arxiv.org/abs/2502.02817 · code: https://github.com/HaoYin116/Survey_of_AQA |
+| *Comprehensive Survey of AQA: Method & Benchmark* (2024) | Taxonomy by modality (video / **skeleton** / multimodal) + unified benchmark | https://arxiv.org/abs/2412.11149 · page: https://zhoukanglei.github.io/AQA-Survey |
+| Awesome-AQA (ZhouKanglei) | Curated, **has a Medical-Care/rehab section** — your closest analogues | https://github.com/ZhouKanglei/Awesome-AQA |
+| Awesome-AQA (Lyman-Smoker) | Second list; catches papers the other misses (FLEX, ExAct, etc.) | https://github.com/Lyman-Smoker/Awesome-AQA |
+
+### 2.3 Skeleton-based scoring — the methods your head will borrow from
+
+| Paper | Relevance to FormScout | Link |
+|---|---|---|
+| ST-GCN (original) | The graph-over-skeleton + temporal-conv backbone | https://github.com/open-mmlab/mmaction2/blob/main/configs/skeleton/stgcn/README.md |
+| AQA via Hierarchical **Pose-guided** Multi-Stage Contrastive Regression (TIP 2025) | Pose-guided + contrastive regression with few labels — close to your setup | https://arxiv.org/abs/2501.03674 |
+| Attention-guided Movement **Quality** Assessment + skeletal augmentation (UI-PRMD/KIMORE) | Transformer MQA on clinician-scored rehab data; **augmentation recipe for tiny sets** | https://arxiv.org/pdf/2204.07840 |
+| SSL-Rehab: self-supervised 3D skeleton + **LoRA** fine-tune (KIMORE/UI-PRMD) | Pretrain→LoRA recipe for small clinical datasets (uses your LoRA muscle) | https://www.sciencedirect.com/science/article/abs/pii/S1077314224003564 |
+| Skeleton-based AQA w/ anomaly-aware DTW (Sensors 2025) | DTW alignment + anomaly scoring; cheap, label-light baseline | https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12693942/ |
+
+---
+
+## 3. Models & tooling (verified)
+
+| Component | Repo / card | Params | License | Gated? |
+|---|---|---:|---|---|
+| YOLO26-Pose | https://docs.ultralytics.com/tasks/pose | <0.1B | AGPL-3.0* | no |
+| SAM 3.1 | https://github.com/facebookresearch/sam3 | ~0.85B | SAM License | **yes** |
+| SAM 3D Body | https://github.com/facebookresearch/sam-3d-body · https://huggingface.co/facebook/sam-3d-body-dinov3 | sub-1B† | SAM License | **yes** |
+| ST-GCN++ / PoseConv3D | https://github.com/kennymckormick/pyskl | ~0.01–0.05B | Apache-2.0 | no |
+| Qwen3-VL-8B-Instruct | https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct | 8B | Apache-2.0 | no |
+| Qwen3-VL-Embedding-8B | https://huggingface.co/Qwen/Qwen3-VL-Embedding-8B (GGUF: dam2452/...-GGUF) | 8B | Apache-2.0 | no |
+| Qwen3.6-27B (alt brain) | https://huggingface.co/unsloth/Qwen3.6-27B-GGUF | 27B | Apache-2.0 | no |
+
+\* verify the current YOLO26 license. † two variants (`dinov3`, `vith`); confirm exact count on the card — budget impact is small either way. SAM 3 itself is 848M.
+
+**Useful extras:** SAM 3D Body uses a Momentum Human Rig (MHR) that separates skeleton from soft-tissue shape — convenient for clean joint-angle extraction. The repo ships a notebook combining SAM 3D Body + SAM 3D Objects in one frame of reference. SAM 3D Body demo: https://www.aidemos.meta.com/segment-anything/editor/convert-body-to-3d
+
+---
+
+## 4. Datasets for transfer / pretraining
+
+You have a couple of labeled clips. Pretrain on clinician-scored movement-quality data first, then few-shot fine-tune. These are the most transferable to FMS (ranked by relevance):
+
+| Dataset | Why it's the closest analogue | Link |
+|---|---|---|
+| **KIMORE** | Clinician **scores** of low-back-pain rehab exercises (trunk control, multi-plane) — same "score movement quality" task as FMS; partially overlaps Deep Squat / Rotary Stability / TSPU mechanics | https://www.researchgate.net/publication/333791841 (search "KIMORE dataset") |
+| **UI-PRMD** | 10 rehab movements, correct vs. incorrect executions; standard MQA benchmark, pairs with KIMORE | search "UI-PRMD University of Idaho Physical Rehabilitation Movements" |
+| **Fitness-AQA** | Real gym **squat/deadlift form errors** — directly relevant to Deep Squat compensations | https://github.com/ParitoshParmar/MTL-AQA (links Fitness-AQA) |
+| **FLEX** | Large multi-modal fitness AQA dataset | via Lyman-Smoker/Awesome-AQA |
+| **MTL-AQA / AQA-7 / FineFS** | General sports AQA for backbone pretraining (diving, skating) | https://github.com/ParitoshParmar/MTL-AQA |
+
+**FMS-specific public video data is scarce** — don't expect a drop-in set. Your physio's clips are the gold; everything above is for pretraining the temporal backbone so it learns movement structure before it ever sees an FMS label.
+
+---
+
+## 5. Build & deploy tooling
+
+| Need | Link |
+|---|---|
+| Gradio docs (v6) | https://www.gradio.app/docs |
+| `gradio.Server` — custom frontend + Gradio backend (Off-Brand badge) | https://www.gradio.app/guides/server-mode · blog: https://huggingface.co/blog/introducing-gradio-server |
+| Gradio AI coding-assistant skill | `gradio skills add --claude` (PyPI: https://pypi.org/project/gradio/) |
+| Gradio changelog (confirm `gr.Walkthrough`, `gr.Navbar`, `gr.Video.playback_position`) | https://www.gradio.app/changelog |
+| HF Spaces ZeroGPU (`@spaces.GPU`) | https://huggingface.co/docs/hub/spaces-zerogpu |
+| llama.cpp | https://github.com/ggml-org/llama.cpp |
+| pyskl (ST-GCN++/PoseConv3D, custom-video tutorial incl. diving48) | https://github.com/kennymckormick/pyskl |
+| MMAction2 (broader video understanding) | https://github.com/open-mmlab/mmaction2 |
+| Hackathon's own trailheads (ML Intern, Gradio guides) | https://github.com/huggingface/ml-intern |
+
+> **Hackathon-specific gotcha already seen in the org:** another team's Space hit `libcudart.so.12` errors and had to swap llama.cpp for transformers + `spaces.GPU`. Plan for it — isolate the llama.cpp build (CPU-only or pinned-CUDA) and keep a transformers fallback. For the scoring head, a small hand-rolled ST-GCN may deploy more cleanly on a Space than the full MMAction2/pyskl stack — prototype with pyskl, ship lean.
+
+---
+
+## 6. Two artifacts you probably haven't made yet
+
+### 6.1 Data & capture protocol (highest-leverage non-code work)
+
+With a tiny dataset, controlling *how* clips are captured beats any model tweak. Give the physio a one-pager:
+
+- **Camera:** one fixed position, tripod, ~3 m back, lens at hip height, landscape, 1080p/30fps+. Same setup every session — this is what makes 3D consistent and the longitudinal baseline meaningful.
+- **Framing:** whole body in frame for the whole rep, including the dowel. Plain-ish background, even lighting, no backlight.
+- **One athlete in frame** at scoring time (or note who to track). For bilateral tests, capture **both sides** and label each.
+- **Label schema (CSV):** `clip_id, athlete_id, date, test_name, side(L/R/NA), score(0–3), pain(bool), compensation_notes(free text), camera_view, consent_on_file(bool)`.
+- **One rep per clip** to start (simplest). If sessions are continuous, you'll need temporal segmentation first — flag it to the build agent at Phase 1.
+
+### 6.2 Evaluation plan
+
+Define "good" before you train, given so few labels:
+
+- **Primary:** Spearman ρ between predicted and physio scores (the AQA-standard metric), plus **exact-match** and **±1 accuracy** per test.
+- **Clinical credibility:** **weighted Cohen's κ** and **ICC** of model-vs-physio, reported alongside the human inter-rater numbers from the JOSPT study — i.e. "how does FormScout compare to a second human rater?"
+- **Asymmetry:** detection rate of L/R asymmetries the physio flagged (this is one of the FMS's most defensible outputs).
+- **Validation:** leave-one-clip-out CV (you can't afford a held-out test split). Keep ≥1 clip the judge never sees for the demo.
+- **Calibration:** report when the system says "low confidence / physio review" and show it's right to do so. A well-calibrated, humble tool reads as more trustworthy than a confident one.
+
+---
+
+## 7. Ethics, consent & data handling (EU / Slovakia)
+
+You're filming identifiable athletes, possibly **minors** on a youth team. This is biometric personal data under GDPR — treat it as first-class, and say so in your submission (judges and physios both reward it):
+
+- **Consent:** written consent from each athlete (and a parent/guardian for anyone under 18) before any footage is used. No consent → not in the dataset, not in the demo.
+- **Data minimization & retention:** keep only what you need; don't persist raw clips on the Space beyond what's approved; document a retention/deletion policy. Prefer storing derived skeletons over raw video where possible.
+- **Demo footage:** use a consenting adult (you, a teammate) for the public demo video rather than a minor athlete, even if you trained on team data privately.
+- **Framing:** screening aid, not a medical device; pain/clearing tests always defer to the clinician; human-in-the-loop by design.
+
+---
+
+## 8. The transfer-learning recipe (ties it together)
+
+1. **Backbone pretrain** — ST-GCN++ on a general skeleton-action set (NTU/Kinetics skeletons via pyskl) so it learns motion structure.
+2. **Domain adapt** — continue on **KIMORE + UI-PRMD** (clinician-scored movement quality) so it learns *quality*, not just *what action*.
+3. **Few-shot fine-tune** — **LoRA** on the physio's FMS clips with heavy augmentation (temporal jitter, **L↔R mirror** to double bilateral data, 3D camera-angle perturbation, joint noise). The SSL-Rehab paper (§2.3) is your blueprint and it's exactly your LoRA wheelhouse.
+4. **Don't over-train the head** — let deterministic biomechanics carry the demo; the learned head and RAG are the refinement and the badges, not the foundation.
+
+---
+
+## 9. Demo & submission storyboard (the "make it sing" 30%)
+
+The submission needs a demo video + social post; "Show, Don't Tell" is a literal rule. A tight 60–90s cut:
+
+1. **0–10s** — the problem: physio eyeballing a squat, scribbling a score. "Same player, two raters, two scores."
+2. **10–35s** — upload the clip to FormScout → skeleton overlay → 0–3 with the *deciding angle drawn on the frame* (`playback_position` jump). The "aha" shot.
+3. **35–55s** — the scorecard: composite 0–21, the L/R asymmetry strip, a "low confidence — physio review" flag on a borderline case (honesty sells).
+4. **55–75s** — the physio reacting / using it on a real player (the Backyard AI "they actually used it" proof).
+5. **End card** — "Runs on a laptop. ~18B params. Screening aid, not a diagnosis." Link the Space, the published head, the agent trace, the blog.
+
+Social post: lead with the overlay GIF + the asymmetry-detection angle; tag Gradio/HF; one line of honest framing.
+
+---
+
+*Built to give FormScout the best shot. The two things most teams underinvest in — the capture protocol (§6.1) and the honest, clinical-style evaluation (§6.2, §2.1) — are exactly where this project can out-class flashier entries. Good luck. 🏀*
diff --git a/docs/plans/FormScout-Build-Prompt.md b/docs/plans/FormScout-Build-Prompt.md
new file mode 100644
index 0000000000000000000000000000000000000000..d40d219cbcb2e9b4c91e8f157c5546e44e814cd1
--- /dev/null
+++ b/docs/plans/FormScout-Build-Prompt.md
@@ -0,0 +1,168 @@
+# Build Prompt — FormScout (FMS scoring on Gradio, ≤32B)
+
+> **How to use this:** paste everything below the line into your coding agent (Claude Code, Codex, Cursor, etc.) as the opening instruction. Attach `FormScout-FMS-Spec.md` alongside it — that file is the product source of truth; this file is the engineering contract and process. Work through it phase by phase.
+
+---
+
+## ROLE
+
+You are a **senior Python + Gradio architect with ~10 years of shipping ML web apps**, including production Hugging Face Spaces, custom-frontend Gradio deployments, ZeroGPU services, and llama.cpp-served models. You are pragmatic, opinionated about defaults, allergic to dead code, and you **verify APIs against current docs instead of trusting your memory** — Gradio and the model ecosystem move fast and your training data may be stale. You build **vertical slices** that run end to end early, then deepen. You never hand back a broken app.
+
+## MISSION
+
+Build **FormScout**, a Gradio app hosted as a Hugging Face Space that scores Functional Movement Screen (FMS) videos 0–3 per test with an explainable rationale and an annotated overlay, for the Build Small Hackathon (Backyard AI track). Full product requirements are in the attached `FormScout-FMS-Spec.md`. Honor it; if you deviate, say why.
+
+## PRIME DIRECTIVES (read before writing any code)
+
+1. **Verify before you build.** Do Phase 0 recon first. Do not write against a Gradio/model API you have not confirmed exists in the current version. When unsure, read the doc or the model card, don't guess.
+2. **Vertical slice first.** The fastest path to a working `video in → scored overlay out` for *one* test beats a half-built version of all seven. Get something running on day one, then expand.
+3. **Stay under budget.** Total model parameters across the whole pipeline must be **≤ 32B**. Track a running sum in `MODEL_BUDGET.md` and update it whenever you add or swap a model. The target config is ~18B (see spec §5). If a choice would exceed 32B, stop and flag it.
+4. **No cloud model APIs.** All inference runs on the Space (Off the Grid badge). No OpenAI/Anthropic/Gemini/etc. calls for the core pipeline.
+5. **Honesty & safety are features, not footnotes.** This is a screening aid, not a diagnosis and not injury prediction. Pain and clearing tests are never auto-scored — they set `needs_human=true`. A safety banner is always visible. Low-confidence and agent-disagreement cases are surfaced, not hidden.
+6. **Modular agents, typed contracts.** Each pipeline stage is an independent module with a typed input/output (see spec §7). No god-functions. The pipeline must be runnable headless (no Gradio) for testing.
+
+---
+
+## PHASE 0 — Recon & environment (do this first, report findings before coding)
+
+**Goal:** confirm the ground truth, then write a short `RECON.md` summarizing what you found and any deviations from the spec.
+
+1. **Install the Gradio skill** for this agent so you get current Gradio knowledge:
+ `gradio skills add --claude` (use the right flag for your agent; `--global` is fine).
+2. **Pin and confirm Gradio.** Determine the current major version (expect Gradio 6.x). Record the exact version you'll target in `requirements.txt`. Confirm these still exist and note their current signatures:
+ - `gr.Blocks`, `gr.Video` (incl. `playback_position` for jumping to the decisive frame), `gr.Walkthrough` / `gr.Step` (for the 7-test flow), `gr.Navbar` (multipage), custom theming / CSS.
+ - `gradio.Server` (custom-frontend mode) — decide **Blocks vs Server** for the UI (see UI section).
+ - ZeroGPU usage: the `@spaces.GPU` decorator pattern, and the caveat that with `gradio.Server` + ZeroGPU you must call endpoints via `@gradio/client` from the browser.
+3. **Verify every model** on its Hugging Face card — confirm it exists, its **license**, its **parameter count**, and whether a **GGUF** build exists for llama.cpp:
+ - YOLO26-Pose (Ultralytics) — pick a variant (l/x) and confirm license implications.
+ - SAM 3.1 (`facebookresearch/sam3`) — base checkpoint size.
+ - **SAM 3D Body** — *this is the uncertain one.* Confirm weights are public, the license, the **exact param count**, and that it runs within a ZeroGPU slice. If it's too heavy or not usable, fall back to **2D-only biomechanics** (angles from 2D pose + explicit camera-angle caveats) and note it.
+ - Qwen3-VL-8B-Instruct + Qwen3-VL-Embedding-8B — confirm GGUF builds and that they share the Qwen3-VL backbone.
+4. **llama.cpp on Spaces reality check.** Confirm a working install path; prior hackathon Spaces hit `libcudart.so` errors. Decide CPU-only vs pinned-CUDA build per model. Have a `transformers`/`spaces.GPU` fallback ready for any model that won't build under llama.cpp in time.
+5. **Open question to surface, not solve:** does "total parameters ≤ 32B" mean *per model* or *summed across the pipeline*? Design for the **summed** reading (safe under either). Note in `RECON.md` to confirm via the Discord AMA.
+
+**Exit criteria for Phase 0:** `RECON.md` exists with the Gradio version, a verified model table (name, params, license, GGUF y/n, runs-on-ZeroGPU y/n), the running param sum, the chosen UI approach, and any fallbacks triggered.
+
+---
+
+## PHASE 1 — The spine (one test, end to end, headless + Gradio)
+
+**Goal:** upload a Deep Squat clip → get a rationalized 0–3 + skeleton overlay.
+
+- Scaffold the repo (structure below). Pipeline runs **headless** via `python -m formscout.run sample.mp4` before any UI.
+- Implement `IngestAgent` → `SegmentationAgent` (SAM 3.1) → `PoseAgent` (YOLO26-Pose). Reject non-target people via the mask/track id.
+- Implement `Body3DAgent` (SAM 3D Body) **or** the 2D fallback from Phase 0.
+- Implement `BiomechanicsAgent` for Deep Squat only: torso–tibia angle, hip-flexion depth (femur vs horizontal), knee tracking, dowel alignment.
+- Implement a **deterministic** rubric scorer for Deep Squat (3/2/1 per spec §8). No ML scoring yet.
+- Minimal Gradio UI: `gr.Video` in, score + rationale + overlay out.
+
+**Exit criteria:** a real squat clip produces a defensible score, a one-line reason citing the deciding measurement, and an overlay video. Runs on the Space.
+
+---
+
+## PHASE 2 — All seven tests + the judge
+
+- Extend `BiomechanicsAgent` + rubric scorers to all 7 tests. Bilateral tests score each side, **report the lower**, and **always emit the asymmetry**.
+- `MovementClassifierAgent`: identify which test is in the clip (VLM or a small classifier) with a **manual override** in the UI.
+- `JudgeAgent` (Qwen3-VL-8B via llama.cpp): consumes rubric + measurements + the deterministic candidate → final 0–3, rationale, compensation tag, corrective hint. Pain/clearing → `needs_human=true`, **not scored**.
+- `ReportAgent`: per-test card, composite 0–21, asymmetry strip, annotated overlay, PDF export.
+
+**Exit criteria:** a multi-test session produces a full scorecard with composite + asymmetries; pain/clearing cases defer to human; disagreements between deterministic and judge scores are flagged.
+
+---
+
+## PHASE 3 — Learned scoring + retrieval (the badges)
+
+- `ScoringAgent`: compact **ST-GCN** scoring head. Pre-train on public AQA/pose data, then **few-shot fine-tune** on the physio's labeled clips with heavy augmentation (temporal jitter, **left↔right mirror**, 3D camera-angle perturbation, joint noise). Hold out ≥1 labeled clip. **Publish the fine-tuned head to the Hub** with an honest model card → *Well-Tuned*.
+- `RetrievalAgent`: build a Qwen3-VL-Embedding-8B index over the physio's labeled clips; return k nearest + their scores to anchor the judge → RAG.
+- Wire the judge to weigh: deterministic candidate + ST-GCN candidate + retrieved exemplars.
+
+**Exit criteria:** scores incorporate the learned head and exemplars; adding a new labeled clip improves retrieval with **no retraining**.
+
+---
+
+## PHASE 4 — Polish, ship, document
+
+- Custom UI pass (Off-Brand): scout/trail theme, score dial, asymmetry bars, rubric drawer with met/unmet checkboxes, decisive-frame jump via `playback_position`, persistent safety banner.
+- Persist the embedding index + accumulated labels in Space storage (longitudinal baseline).
+- **Publish one full agent trace** to the Hub (every agent's I/O for one run) → *Sharing is Caring*.
+- Write the **blog post / field notes** with the honesty section front-and-center → *Field Notes*.
+- Record the demo video (physio scores a real player) + the social post.
+
+**Exit criteria:** all six badges attempted, Space is green, demo + post + trace + blog are linked from the README.
+
+---
+
+## REPO STRUCTURE (target)
+
+```
+formscout/
+ app.py # Gradio entrypoint (Blocks or Server)
+ formscout/
+ __init__.py
+ config.py # paths, model ids, thresholds, feature flags
+ pipeline.py # Director: orchestrates agents, quality-gates
+ run.py # headless CLI entrypoint (no Gradio)
+ agents/
+ ingest.py
+ segmentation.py # SAM 3.1
+ pose2d.py # YOLO26-Pose
+ body3d.py # SAM 3D Body (+ 2d fallback)
+ classify.py # movement classifier
+ biomechanics.py # rubric features per test
+ scoring.py # ST-GCN learned head
+ retrieval.py # Qwen3-VL-Embedding index
+ judge.py # Qwen3-VL-8B judge
+ report.py # scorecard, overlay, pdf
+ rubric/
+ deep_squat.py ... # one scorer per FMS test, pure functions
+ types.py # typed dataclasses for every agent contract
+ serving/
+ llama_cpp.py # llama.cpp client wrappers + fallbacks
+ ui/
+ theme.py, components.py, custom/ # frontend assets
+ tracing.py # structured per-agent I/O logging (for the trace badge)
+ tests/ # headless tests per agent + a golden-clip e2e test
+ requirements.txt
+ README.md # Space card: pitch, demo, trace, blog, safety
+ MODEL_BUDGET.md # running param sum, must stay ≤32B
+ RECON.md # Phase 0 findings
+```
+
+## ENGINEERING STANDARDS
+
+- **Typing everywhere.** Every agent takes and returns a dataclass from `types.py`. Validate at boundaries.
+- **Pure rubric functions.** Each test scorer is a pure function `(features) -> ScoreResult` with the triggering reason. Unit-test each against hand-computed cases.
+- **Defensive by default.** Handle: no person detected, multiple people, wrong/ambiguous test, occlusion, too-short clip, bad FPS, 3D model OOM. Degrade gracefully and tell the user what happened — never crash the Space.
+- **Confidence is first-class.** Every agent emits a confidence; the Director flags low confidence and ≥1-point judge/ST-GCN disagreement as "physio review recommended."
+- **Config over constants.** Thresholds, model ids, k for retrieval, feature flags live in `config.py`, not scattered literals.
+- **Tracing for free badge.** `tracing.py` records structured per-agent inputs/outputs for any run; one run gets exported for the Hub trace.
+- **Determinism in demos.** Fix seeds; cache model loads at startup; warm the pipeline so the demo isn't a cold-start.
+- **Tests:** per-agent unit tests on fixtures + one golden-clip end-to-end test asserting score, `needs_human`, and overlay presence. Keep a tiny committed sample clip.
+
+## GRADIO-SPECIFIC GUIDANCE
+
+- **Blocks vs Server:** start with `gr.Blocks` + custom CSS/theme — fastest to a polished result and enough for Off-Brand. Escalate to `gradio.Server` with your own frontend **only if** Blocks can't express the UI; document the reason. (Server still gives queuing, ZeroGPU, MCP.)
+- Use `gr.Walkthrough`/`gr.Step` to guide the physio through a 7-test session; `gr.Navbar` if you split pages.
+- Use `gr.Video`'s `playback_position` to jump the result video to the frame that decided the score.
+- ZeroGPU: wrap heavy inference in `@spaces.GPU`; load models once at module scope; mind the per-call GPU time limit. If using `gradio.Server` + ZeroGPU, call endpoints via `@gradio/client` from the browser.
+- `requirements.txt`: pin Gradio and every model lib; isolate the llama.cpp build (CPU-only or pinned-CUDA) to dodge `libcudart` failures; keep a `transformers` + `spaces.GPU` fallback path.
+
+## DEFINITION OF DONE (badge checklist)
+
+- [ ] Space runs green; upload → scorecard works on real clips.
+- [ ] Param sum verified ≤ 32B in `MODEL_BUDGET.md`.
+- [ ] 🔌 No cloud model APIs anywhere in the pipeline.
+- [ ] 🎯 Fine-tuned ST-GCN head published to the Hub w/ honest card.
+- [ ] 🎨 Custom, non-default Gradio UI.
+- [ ] 🦙 VLM + embedder served via llama.cpp.
+- [ ] 📡 One full agent trace published to the Hub.
+- [ ] 📓 Blog post / field notes written, honesty section included.
+- [ ] Demo video + social post recorded.
+- [ ] Safety banner present; pain/clearing never auto-scored; low-confidence flagged.
+
+## INTERACTION PROTOCOL
+
+- **After each phase**, post: what runs now, the updated param sum, deviations from the spec, and the next step. Don't silently change architecture.
+- **Ask the human only when blocked on a real decision** — e.g. single-test clips vs continuous sessions (changes segmentation + UI), SAM 3D Body unusable (triggers 2D fallback), or the param-sum interpretation. Otherwise proceed with the spec's defaults and note your assumption inline.
+- **Never claim a Gradio/model API works without having verified it** this session. If you didn't check it, say so.
diff --git a/docs/superpowers/plans/2026-06-04-formscout-full-build.md b/docs/superpowers/plans/2026-06-04-formscout-full-build.md
new file mode 100644
index 0000000000000000000000000000000000000000..0fd0365e7852bef08235f0d7b0b278c897ed7248
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-04-formscout-full-build.md
@@ -0,0 +1,2813 @@
+# FormScout Full Build Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Gradio/HF Space app that scores FMS videos 0–3 per test with rationale and annotated overlay, running entirely on-Space with ~18B params, targeting all 6 hackathon badges.
+
+**Architecture:** Typed specialist agents orchestrated by a deterministic Director; 2D pose path is always the default; 3D is optional/gated; pure rubric functions carry the scoring load; VLM (llama.cpp) is the judge/explainer.
+
+**Tech Stack:** Python 3.11, Gradio 6.x, YOLO26-Pose, SAM 3.1, Qwen3-VL-8B (llama.cpp), pyskl ST-GCN, Qwen3-VL-Embedding-8B (llama.cpp), pytest, ruff/black
+
+---
+
+## Milestone Map
+
+| Milestone | Phase | Exit Criteria |
+|---|---|---|
+| **M0** | Recon | `RECON.md` exists, all models verified, Gradio version pinned |
+| **M1** | Spine | Deep Squat: `python -m formscout.run sample.mp4` → score + rationale |
+| **M2** | Gradio MVP | Upload Deep Squat clip → score + overlay in browser |
+| **M3** | All 7 Tests | Full scorecard, composite 0–21, asymmetry detection |
+| **M4** | Judge Online | Qwen3-VL via llama.cpp scoring + rationale for all tests |
+| **M5** | Learned Head | ST-GCN fine-tuned, published to Hub |
+| **M6** | RAG Online | Retrieval over physio clips anchors judge |
+| **M7** | Ship | All 6 badges, Space green, demo video, blog post |
+
+---
+
+## Phase 0 — Recon
+
+### Task 0.1: Scaffold repo & verify Gradio
+
+**Files:**
+- Create: `requirements.txt`
+- Create: `RECON.md`
+- Create: `MODEL_BUDGET.md`
+- Create: `formscout/__init__.py`
+- Create: `formscout/config.py`
+
+- [ ] **Step 1: Create the project scaffold**
+
+```bash
+mkdir -p formscout/agents/prompts formscout/rubric formscout/serving formscout/ui/custom tests
+touch formscout/__init__.py formscout/agents/__init__.py formscout/rubric/__init__.py
+touch formscout/serving/__init__.py formscout/ui/__init__.py
+touch app.py formscout/run.py formscout/pipeline.py formscout/types.py
+touch formscout/config.py formscout/tracing.py
+touch MODEL_BUDGET.md RECON.md README.md
+```
+
+- [ ] **Step 2: Verify current Gradio version and APIs**
+
+```bash
+pip install gradio --dry-run 2>&1 | head -5
+python -c "import gradio; print(gradio.__version__)"
+python -c "import gradio as gr; print(hasattr(gr, 'Walkthrough'), hasattr(gr, 'Navbar'), hasattr(gr.Video, 'playback_position') if hasattr(gr, 'Video') else 'no Video')"
+```
+
+Expected: version 6.x printed; note which APIs exist.
+
+- [ ] **Step 3: Write requirements.txt with pinned versions**
+
+```
+gradio==
+ultralytics>=8.3
+torch>=2.3
+opencv-python>=4.10
+numpy>=1.26
+scipy>=1.13
+pillow>=10.3
+pytest>=8.2
+ruff>=0.4
+black>=24.4
+huggingface_hub>=0.23
+transformers>=4.44
+```
+
+Note: llama.cpp added after build verification in Task 0.3.
+
+- [ ] **Step 4: Write config.py skeleton**
+
+```python
+from pathlib import Path
+
+ROOT = Path(__file__).parent.parent
+
+# Model IDs
+YOLO_POSE_MODEL = "yolo11x-pose.pt"
+SAM_CHECKPOINT = "sam2.1_hiera_base_plus.pt"
+QWEN_VLM_GGUF = "Qwen3-VL-8B-Instruct-Q4_K_M.gguf"
+QWEN_EMBED_GGUF = "Qwen3-VL-Embedding-8B-Q4_K_M.gguf"
+STGCN_CHECKPOINT = ROOT / "checkpoints" / "stgcn_fms.pth"
+
+# Pipeline flags
+ENABLE_3D = False # SAM 3D Body — off until access granted
+ENABLE_STGCN = False # Phase 3
+ENABLE_RAG = False # Phase 3
+ENABLE_JUDGE = False # Phase 2
+
+# Thresholds
+MIN_CONFIDENCE = 0.6
+SCORE_DISAGREE_THRESH = 1 # flag if |stgcn - judge| >= this
+RETRIEVAL_K = 3
+
+# Pose
+POSE_BACKEND = "yolo" # "yolo" | "sapiens"
+POSE_CONF_THRESHOLD = 0.5
+NUM_KEYPOINTS = 17
+
+# Biomechanics
+DEEP_SQUAT_FEMUR_HORIZONTAL_DEG = 90.0 # femur below horizontal
+DEEP_SQUAT_TORSO_TIBIA_MAX_DEG = 15.0 # torso parallel to tibia
+DEEP_SQUAT_KNEE_TRACKING_MARGIN_PX = 20
+
+# Serving
+LLAMA_CPP_HOST = "127.0.0.1"
+LLAMA_CPP_PORT_VLM = 8080
+LLAMA_CPP_PORT_EMBED = 8081
+```
+
+- [ ] **Step 5: Verify model cards for license + params**
+
+```bash
+python -c "
+from huggingface_hub import model_info
+models = [
+ 'Qwen/Qwen3-VL-8B-Instruct',
+ 'Qwen/Qwen3-VL-Embedding-8B',
+]
+for m in models:
+ info = model_info(m)
+ print(m, '|', info.card_data.license if info.card_data else 'unknown')
+"
+```
+
+Manually check: `facebookresearch/sam3`, `facebook/sam-3d-body-dinov3` (gated), Ultralytics YOLO26.
+
+- [ ] **Step 6: Write RECON.md with findings**
+
+```markdown
+# RECON.md
+
+## Gradio
+- Version:
+- gr.Blocks: ✓
+- gr.Video (playback_position):
+- gr.Walkthrough / gr.Step:
+- gr.Navbar:
+- UI approach: gr.Blocks + custom CSS (escalate to Server only if needed)
+
+## Model Verification
+
+| Model | Params | License | GGUF | ZeroGPU | Status |
+|---|---|---|---|---|---|
+| YOLO26-Pose L | ~0.05B | AGPL-3.0 | n/a | ✓ | ready |
+| SAM 3.1 base | ~0.85B | SAM License | n/a | ✓ | access pending |
+| SAM 3D Body | ~0.7B | SAM License | n/a | tbd | access pending |
+| ST-GCN (pyskl) | ~0.03B | Apache-2.0 | n/a | ✓ | ready |
+| Qwen3-VL-8B-Instruct | 8B | Apache-2.0 | ✓ | llama.cpp | ready |
+| Qwen3-VL-Embedding-8B | 8B | Apache-2.0 | ✓ | llama.cpp | ready |
+
+## Param Sum
+~17.8B — well under 32B limit.
+
+## Open Questions
+- [ ] Confirm "≤32B" = summed vs per-model in Discord AMA
+- [ ] SAM 3D Body gated access status
+- [ ] AGPL-3.0 YOLO OK for hackathon submission?
+
+## llama.cpp Build Plan
+- CPU-only build first (avoids libcudart.so issues on Spaces)
+- Fallback: transformers + spaces.GPU for VLM
+```
+
+- [ ] **Step 7: Write MODEL_BUDGET.md**
+
+```markdown
+# MODEL_BUDGET.md
+
+Running sum must stay ≤ 32B params.
+
+| Component | Model | Params |
+|---|---|---|
+| 2D Pose | YOLO26-Pose L | 0.05B |
+| Segmentation | SAM 3.1 base | 0.85B |
+| 3D Body (optional) | SAM 3D Body | ~0.7B |
+| Scoring Head | ST-GCN (pyskl) | 0.03B |
+| Judge/Explainer | Qwen3-VL-8B-Instruct | 8B |
+| Retrieval | Qwen3-VL-Embedding-8B | 8B |
+| **Total** | | **~17.63B** |
+
+Headroom: ~14.37B under 32B cap.
+```
+
+- [ ] **Step 8: Commit Phase 0 scaffold**
+
+```bash
+git init && git add -A
+git commit -m "chore: Phase 0 scaffold — repo structure, config, recon, model budget"
+```
+
+**✅ MILESTONE M0: RECON.md exists, param sum tracked, Gradio version pinned**
+
+---
+
+## Phase 1 — The Spine (Deep Squat, headless)
+
+### Task 1.1: types.py — all agent contracts
+
+**Files:**
+- Create: `formscout/types.py`
+- Create: `tests/test_types.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_types.py
+from formscout.types import (
+ IngestResult, SegmentResult, Pose2DResult, Body3DResult,
+ MovementResult, BiomechFeatures, ScoreResult, RetrievalResult,
+ JudgeResult, ReportResult, PipelineState,
+)
+import pytest
+
+def test_ingest_result_frozen():
+ r = IngestResult(frames=[], fps=30.0, duration=2.0, n_people=1, width=1920, height=1080)
+ with pytest.raises(Exception):
+ r.fps = 60.0
+
+def test_judge_result_needs_human_default_false():
+ r = JudgeResult(score=2, rationale="ok", compensation_tags=[], corrective_hint="", confidence=0.9, needs_human=False, notes="")
+ assert r.needs_human is False
+
+def test_score_result_valid_range():
+ with pytest.raises(ValueError):
+ ScoreResult(score=4, rationale="bad", confidence=0.9, needs_human=False, notes="")
+
+def test_bilateral_features_has_symmetry():
+ f = BiomechFeatures(
+ test_name="hurdle_step",
+ view="2d",
+ side="left",
+ angles={"hip_flexion": 45.0},
+ alignments={},
+ symmetry_delta=None,
+ timing={},
+ confidence=0.8,
+ notes="",
+ )
+ assert f.side == "left"
+```
+
+- [ ] **Step 2: Run test — expect ImportError**
+
+```bash
+pytest tests/test_types.py -v
+```
+
+Expected: `ImportError: cannot import name 'IngestResult'`
+
+- [ ] **Step 3: Implement types.py**
+
+```python
+# formscout/types.py
+from __future__ import annotations
+from dataclasses import dataclass, field
+from typing import Any
+
+@dataclass(frozen=True)
+class IngestResult:
+ frames: list # list of np.ndarray HWC BGR
+ fps: float
+ duration: float
+ n_people: int
+ width: int
+ height: int
+ confidence: float = 1.0
+ notes: str = ""
+
+@dataclass(frozen=True)
+class SegmentResult:
+ athlete_track_id: int
+ masks: list # list of np.ndarray bool HW per frame
+ confidence: float
+ notes: str = ""
+
+@dataclass(frozen=True)
+class Pose2DResult:
+ keypoints: list # list[dict[int, dict]] frame→joint→{x,y,conf}
+ fps: float
+ confidence: float
+ notes: str = ""
+
+@dataclass(frozen=True)
+class Body3DResult:
+ used: bool
+ joints_3d: list # list[dict] frame→joint→{x,y,z} — empty if used=False
+ confidence: float = 0.0
+ notes: str = ""
+
+@dataclass(frozen=True)
+class MovementResult:
+ test_name: str # "deep_squat"|"hurdle_step"|...|"unknown"
+ side: str # "left"|"right"|"bilateral"|"na"
+ confidence: float
+ notes: str = ""
+
+@dataclass(frozen=True)
+class BiomechFeatures:
+ test_name: str
+ view: str # "2d" | "3d"
+ side: str # "left"|"right"|"na"
+ angles: dict # named angle → degrees
+ alignments: dict # named alignment → value
+ symmetry_delta: float | None # |left - right| or None for non-bilateral
+ timing: dict # event name → frame index
+ confidence: float
+ notes: str = ""
+
+@dataclass(frozen=True)
+class ScoreResult:
+ score: int # 0–3
+ rationale: str
+ confidence: float
+ needs_human: bool
+ notes: str = ""
+
+ def __post_init__(self):
+ if not 0 <= self.score <= 3:
+ raise ValueError(f"score must be 0–3, got {self.score}")
+
+@dataclass(frozen=True)
+class RetrievalResult:
+ exemplars: list # list of {clip_id, score, similarity, rationale}
+ confidence: float = 1.0
+ notes: str = ""
+
+@dataclass(frozen=True)
+class JudgeResult:
+ score: int # 0–3; -1 if needs_human=True (not auto-scored)
+ rationale: str
+ compensation_tags: list
+ corrective_hint: str
+ confidence: float
+ needs_human: bool
+ notes: str = ""
+
+ def __post_init__(self):
+ if not self.needs_human and not 0 <= self.score <= 3:
+ raise ValueError(f"score must be 0–3 when needs_human=False, got {self.score}")
+
+@dataclass(frozen=True)
+class ReportResult:
+ per_test: list # list of dicts with test_name, score, judge_result, features
+ composite: int | None # None if any test unscored
+ asymmetries: list # list of {test, left_score, right_score, delta}
+ overlay_video_path: str | None
+ pdf_path: str | None
+ low_confidence_flags: list
+ disagreement_flags: list
+ notes: str = ""
+
+@dataclass
+class PipelineState:
+ """Mutable state threaded through the Director."""
+ video_path: str
+ ingest: IngestResult | None = None
+ segment: SegmentResult | None = None
+ pose2d: Pose2DResult | None = None
+ body3d: Body3DResult | None = None
+ movement: MovementResult | None = None
+ features: BiomechFeatures | None = None
+ stgcn_score: ScoreResult | None = None
+ retrieval: RetrievalResult | None = None
+ judge: JudgeResult | None = None
+ report: ReportResult | None = None
+ errors: list = field(default_factory=list)
+ warnings: list = field(default_factory=list)
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+```bash
+pytest tests/test_types.py -v
+```
+
+Expected: 4 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/types.py tests/test_types.py
+git commit -m "feat: typed agent contracts in types.py with validation"
+```
+
+---
+
+### Task 1.2: IngestAgent
+
+**Files:**
+- Create: `formscout/agents/ingest.py`
+- Create: `tests/fixtures/sample_squat.mp4` (use any short video for testing)
+- Create: `tests/test_ingest.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_ingest.py
+import pytest
+from pathlib import Path
+from formscout.agents.ingest import IngestAgent
+from formscout.types import IngestResult
+
+FIXTURE = Path("tests/fixtures/sample_squat.mp4")
+
+def test_ingest_returns_typed_result(tmp_path):
+ # Create a minimal 1-second test video using OpenCV
+ import cv2, numpy as np
+ p = tmp_path / "test.mp4"
+ out = cv2.VideoWriter(str(p), cv2.VideoWriter_fourcc(*'mp4v'), 30, (640, 480))
+ for _ in range(30):
+ out.write(np.zeros((480, 640, 3), dtype=np.uint8))
+ out.release()
+
+ agent = IngestAgent()
+ result = agent.run(str(p))
+ assert isinstance(result, IngestResult)
+ assert result.fps == pytest.approx(30.0, abs=2.0)
+ assert len(result.frames) > 0
+ assert result.width == 640
+ assert result.height == 480
+
+def test_ingest_rejects_missing_file():
+ agent = IngestAgent()
+ result = agent.run("/nonexistent/path.mp4")
+ assert result.confidence == 0.0
+ assert "not found" in result.notes.lower()
+
+def test_ingest_result_is_frozen():
+ import cv2, numpy as np, tempfile, os
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
+ p = f.name
+ out = cv2.VideoWriter(p, cv2.VideoWriter_fourcc(*'mp4v'), 30, (64, 64))
+ for _ in range(10):
+ out.write(np.zeros((64, 64, 3), dtype=np.uint8))
+ out.release()
+ agent = IngestAgent()
+ result = agent.run(p)
+ os.unlink(p)
+ with pytest.raises(Exception):
+ result.fps = 999.0
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/test_ingest.py -v
+```
+
+- [ ] **Step 3: Implement IngestAgent**
+
+```python
+# formscout/agents/ingest.py
+"""
+IngestAgent — decodes video, normalizes FPS, samples frames.
+Input: video file path (str)
+Output: IngestResult(frames, fps, duration, n_people, width, height)
+Failure: returns IngestResult with confidence=0.0 and notes explaining the error.
+Params: 0 (no model — pure OpenCV).
+License: n/a.
+Gated: no.
+"""
+import cv2
+from pathlib import Path
+from formscout.types import IngestResult
+from formscout import config
+
+MAX_FRAMES = 300 # hard cap to avoid OOM on long videos
+
+class IngestAgent:
+ def run(self, video_path: str) -> IngestResult:
+ p = Path(video_path)
+ if not p.exists():
+ return IngestResult(frames=[], fps=0.0, duration=0.0, n_people=0,
+ width=0, height=0, confidence=0.0,
+ notes=f"video not found: {video_path}")
+ cap = cv2.VideoCapture(str(p))
+ if not cap.isOpened():
+ return IngestResult(frames=[], fps=0.0, duration=0.0, n_people=0,
+ width=0, height=0, confidence=0.0,
+ notes=f"could not open video: {video_path}")
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ duration = total / fps if fps > 0 else 0.0
+
+ step = max(1, total // MAX_FRAMES)
+ frames, idx = [], 0
+ while True:
+ ret, frame = cap.read()
+ if not ret:
+ break
+ if idx % step == 0:
+ frames.append(frame)
+ idx += 1
+ cap.release()
+
+ if not frames:
+ return IngestResult(frames=[], fps=fps, duration=duration, n_people=0,
+ width=w, height=h, confidence=0.0,
+ notes="no frames decoded")
+ return IngestResult(frames=frames, fps=fps, duration=duration,
+ n_people=-1, # unknown until segmentation
+ width=w, height=h, confidence=1.0)
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+```bash
+pytest tests/test_ingest.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/agents/ingest.py tests/test_ingest.py
+git commit -m "feat: IngestAgent — OpenCV video decode with frame sampling"
+```
+
+---
+
+### Task 1.3: Pose2DAgent (YOLO)
+
+**Files:**
+- Create: `formscout/agents/pose2d.py`
+- Create: `tests/test_pose2d.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_pose2d.py
+import numpy as np
+import pytest
+from formscout.agents.pose2d import Pose2DAgent
+from formscout.types import Pose2DResult, IngestResult
+
+def _blank_ingest(n_frames=5, w=640, h=480):
+ frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n_frames)]
+ return IngestResult(frames=frames, fps=30.0, duration=n_frames/30.0,
+ n_people=1, width=w, height=h)
+
+def test_pose2d_returns_typed_result():
+ agent = Pose2DAgent()
+ result = agent.run(_blank_ingest())
+ assert isinstance(result, Pose2DResult)
+ assert isinstance(result.keypoints, list)
+ assert result.fps == pytest.approx(30.0)
+
+def test_pose2d_keypoints_per_frame():
+ agent = Pose2DAgent()
+ ingest = _blank_ingest(n_frames=3)
+ result = agent.run(ingest)
+ # blank frames will have no detections — should return empty dicts, not crash
+ assert len(result.keypoints) == 3
+ for frame_kps in result.keypoints:
+ assert isinstance(frame_kps, dict)
+
+def test_pose2d_graceful_on_empty_frames():
+ empty = IngestResult(frames=[], fps=30.0, duration=0.0,
+ n_people=0, width=640, height=480)
+ agent = Pose2DAgent()
+ result = agent.run(empty)
+ assert result.confidence == 0.0
+ assert "no frames" in result.notes.lower()
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/test_pose2d.py -v
+```
+
+- [ ] **Step 3: Implement Pose2DAgent**
+
+```python
+# formscout/agents/pose2d.py
+"""
+Pose2DAgent — 2D per-frame keypoint extraction.
+Input: IngestResult
+Output: Pose2DResult(keypoints per frame, fps, confidence)
+Failure: returns Pose2DResult with confidence=0.0 and notes.
+Model: YOLO26-Pose L (AGPL-3.0, ~0.05B params, public).
+Gated: no.
+"""
+from __future__ import annotations
+import numpy as np
+from formscout import config
+from formscout.types import IngestResult, Pose2DResult
+
+_model = None
+
+def _get_model():
+ global _model
+ if _model is None:
+ from ultralytics import YOLO
+ _model = YOLO(config.YOLO_POSE_MODEL)
+ return _model
+
+
+class Pose2DAgent:
+ def run(self, ingest: IngestResult) -> Pose2DResult:
+ if not ingest.frames:
+ return Pose2DResult(keypoints=[], fps=ingest.fps,
+ confidence=0.0, notes="no frames in ingest")
+ model = _get_model()
+ keypoints_per_frame: list[dict] = []
+ total_conf = 0.0
+ n_detected = 0
+
+ for frame in ingest.frames:
+ results = model(frame, verbose=False)
+ frame_kps: dict[int, dict] = {}
+ if results and results[0].keypoints is not None:
+ kps = results[0].keypoints
+ if len(kps) > 0:
+ # Take highest-confidence person (index 0 after YOLO NMS sort)
+ xy = kps.xy[0].cpu().numpy() # (17, 2)
+ conf = kps.conf[0].cpu().numpy() # (17,)
+ for j in range(len(xy)):
+ frame_kps[j] = {"x": float(xy[j, 0]),
+ "y": float(xy[j, 1]),
+ "conf": float(conf[j])}
+ total_conf += float(conf.mean())
+ n_detected += 1
+ keypoints_per_frame.append(frame_kps)
+
+ overall_conf = (total_conf / n_detected) if n_detected > 0 else 0.0
+ notes = "" if n_detected > 0 else "no person detected in any frame"
+ return Pose2DResult(keypoints=keypoints_per_frame, fps=ingest.fps,
+ confidence=overall_conf, notes=notes)
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+```bash
+pytest tests/test_pose2d.py -v
+```
+
+Note: blank frames will yield no detections — that is correct behavior.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/agents/pose2d.py tests/test_pose2d.py
+git commit -m "feat: Pose2DAgent — YOLO26-Pose keypoint extraction"
+```
+
+---
+
+### Task 1.4: Body3DAgent (stub — gated model)
+
+**Files:**
+- Create: `formscout/agents/body3d.py`
+- Create: `tests/test_body3d.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_body3d.py
+from formscout.agents.body3d import Body3DAgent
+from formscout.types import Body3DResult, Pose2DResult
+
+def _dummy_pose():
+ return Pose2DResult(keypoints=[{0: {"x": 320.0, "y": 240.0, "conf": 0.9}}],
+ fps=30.0, confidence=0.9)
+
+def test_body3d_disabled_returns_not_used():
+ agent = Body3DAgent(enable_3d=False)
+ result = agent.run(_dummy_pose(), masks=[])
+ assert isinstance(result, Body3DResult)
+ assert result.used is False
+ assert result.joints_3d == []
+
+def test_body3d_unavailable_checkpoint_returns_not_used(monkeypatch):
+ monkeypatch.setattr("formscout.config.ENABLE_3D", True)
+ agent = Body3DAgent(enable_3d=True)
+ # No checkpoint present → graceful fallback
+ result = agent.run(_dummy_pose(), masks=[])
+ assert result.used is False
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/test_body3d.py -v
+```
+
+- [ ] **Step 3: Implement Body3DAgent stub**
+
+```python
+# formscout/agents/body3d.py
+"""
+Body3DAgent — optional 3D mesh/joint angle recovery via SAM 3D Body.
+Input: Pose2DResult, list of athlete masks
+Output: Body3DResult(used, joints_3d, confidence)
+Failure: ALWAYS returns Body3DResult(used=False) when enable_3d=False or
+ checkpoint unavailable — this is a normal success path, not an error.
+Model: facebook/sam-3d-body-dinov3 (~0.7B, SAM License, GATED — access pending).
+Gated: YES — access requested June 2026.
+"""
+from __future__ import annotations
+from formscout.types import Pose2DResult, Body3DResult
+from formscout import config
+
+_NOT_USED = Body3DResult(used=False, joints_3d=[], confidence=0.0,
+ notes="3D disabled or checkpoint unavailable")
+
+
+class Body3DAgent:
+ def __init__(self, enable_3d: bool | None = None):
+ self._enabled = config.ENABLE_3D if enable_3d is None else enable_3d
+ self._model = None
+ if self._enabled:
+ self._model = self._try_load()
+
+ def _try_load(self):
+ try:
+ # Placeholder: replace with actual SAM 3D Body load once access granted
+ from pathlib import Path
+ ckpt = Path("checkpoints/sam3d_body.pth")
+ if not ckpt.exists():
+ return None
+ # TODO: load SAM 3D Body model here
+ return None
+ except Exception:
+ return None
+
+ def run(self, pose2d: Pose2DResult, masks: list) -> Body3DResult:
+ if not self._enabled or self._model is None:
+ return _NOT_USED
+ # TODO: implement SAM 3D Body inference when access granted
+ return _NOT_USED
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+```bash
+pytest tests/test_body3d.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/agents/body3d.py tests/test_body3d.py
+git commit -m "feat: Body3DAgent stub — graceful fallback until SAM 3D Body access granted"
+```
+
+---
+
+### Task 1.5: BiomechanicsAgent + Deep Squat rubric
+
+**Files:**
+- Create: `formscout/rubric/deep_squat.py`
+- Create: `formscout/agents/biomechanics.py`
+- Create: `tests/test_biomechanics.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_biomechanics.py
+import pytest
+from formscout.rubric.deep_squat import score_deep_squat
+from formscout.types import BiomechFeatures, ScoreResult
+
+def _features(femur_below_horiz=True, torso_parallel_tibia=True,
+ knees_tracking=True, dowel_over_feet=True,
+ heels_elevated=False, view="2d"):
+ return BiomechFeatures(
+ test_name="deep_squat",
+ view=view,
+ side="na",
+ angles={
+ "femur_from_horizontal_deg": 15.0 if femur_below_horiz else 95.0,
+ "torso_tibia_angle_deg": 10.0 if torso_parallel_tibia else 40.0,
+ },
+ alignments={
+ "knees_tracking_over_feet": knees_tracking,
+ "dowel_over_feet": dowel_over_feet,
+ "heels_elevated": heels_elevated,
+ },
+ symmetry_delta=None,
+ timing={},
+ confidence=0.9,
+ )
+
+def test_deep_squat_score_3():
+ result = score_deep_squat(_features())
+ assert isinstance(result, ScoreResult)
+ assert result.score == 3
+ assert not result.needs_human
+
+def test_deep_squat_score_2_heels_elevated():
+ result = score_deep_squat(_features(heels_elevated=True))
+ assert result.score == 2
+
+def test_deep_squat_score_1_criteria_unmet_even_with_heels():
+ result = score_deep_squat(_features(
+ femur_below_horiz=False, heels_elevated=True
+ ))
+ assert result.score == 1
+
+def test_deep_squat_score_0_pain():
+ f = _features()
+ # Override: simulate pain flag via needs_human in features
+ result = score_deep_squat(f, pain=True)
+ assert result.score == 0
+ assert result.needs_human is True
+
+def test_deep_squat_rationale_mentions_deciding_factor():
+ result = score_deep_squat(_features(femur_below_horiz=False))
+ assert "femur" in result.rationale.lower() or "depth" in result.rationale.lower()
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/test_biomechanics.py -v
+```
+
+- [ ] **Step 3: Implement deep_squat.py rubric**
+
+```python
+# formscout/rubric/deep_squat.py
+"""
+Pure function: score_deep_squat(features, pain=False) -> ScoreResult.
+FMS Deep Squat rubric (0–3). No model calls.
+"""
+from formscout.types import BiomechFeatures, ScoreResult
+
+# Thresholds
+FEMUR_BELOW_HORIZ_DEG = 90.0 # femur angle from vertical; <90 = below horizontal
+TORSO_TIBIA_MAX_DEG = 15.0 # degrees between torso and tibia long axis
+
+
+def score_deep_squat(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain or clearing test flagged — defer to physio.",
+ confidence=1.0, needs_human=True)
+
+ femur_deg = features.angles.get("femur_from_horizontal_deg", 999.0)
+ torso_tibia_deg = features.angles.get("torso_tibia_angle_deg", 999.0)
+ knees_ok = features.alignments.get("knees_tracking_over_feet", False)
+ dowel_ok = features.alignments.get("dowel_over_feet", False)
+ heels_elevated = features.alignments.get("heels_elevated", False)
+
+ # 3: all four criteria met, flat feet
+ criteria_3 = (femur_deg < FEMUR_BELOW_HORIZ_DEG and
+ torso_tibia_deg < TORSO_TIBIA_MAX_DEG and
+ knees_ok and dowel_ok)
+
+ # 2: criteria met only with heels elevated
+ criteria_2 = heels_elevated and (
+ femur_deg < FEMUR_BELOW_HORIZ_DEG and
+ torso_tibia_deg < TORSO_TIBIA_MAX_DEG and
+ knees_ok and dowel_ok
+ )
+
+ view_note = " (2D measurement — camera angle may affect accuracy)" if features.view == "2d" else ""
+
+ if criteria_3:
+ return ScoreResult(
+ score=3,
+ rationale=f"All criteria met: femur {femur_deg:.1f}° below horizontal, "
+ f"torso–tibia {torso_tibia_deg:.1f}°, knees tracking, dowel overhead.{view_note}",
+ confidence=features.confidence,
+ needs_human=False,
+ )
+ elif criteria_2:
+ return ScoreResult(
+ score=2,
+ rationale=f"Criteria met only with heel elevation.{view_note}",
+ confidence=features.confidence,
+ needs_human=False,
+ )
+ else:
+ # Identify the failing criterion for the rationale
+ failures = []
+ if femur_deg >= FEMUR_BELOW_HORIZ_DEG:
+ failures.append(f"insufficient squat depth (femur {femur_deg:.1f}° — needs <{FEMUR_BELOW_HORIZ_DEG}°)")
+ if torso_tibia_deg >= TORSO_TIBIA_MAX_DEG:
+ failures.append(f"torso–tibia angle {torso_tibia_deg:.1f}° (needs <{TORSO_TIBIA_MAX_DEG}°)")
+ if not knees_ok:
+ failures.append("knees not tracking over feet")
+ if not dowel_ok:
+ failures.append("dowel not over feet")
+ reason = "; ".join(failures) if failures else "criteria not met"
+ return ScoreResult(
+ score=1,
+ rationale=f"Score 1: {reason}.{view_note}",
+ confidence=features.confidence,
+ needs_human=False,
+ )
+```
+
+- [ ] **Step 4: Implement BiomechanicsAgent (Deep Squat)**
+
+```python
+# formscout/agents/biomechanics.py
+"""
+BiomechanicsAgent — computes rubric-relevant measurements from pose keypoints.
+Input: Pose2DResult, Body3DResult, MovementResult
+Output: BiomechFeatures(test_name, view, side, angles, alignments, ...)
+Failure: returns low-confidence BiomechFeatures with notes.
+Params: 0 (geometry only).
+Gated: no.
+"""
+from __future__ import annotations
+import numpy as np
+from formscout.types import Pose2DResult, Body3DResult, MovementResult, BiomechFeatures
+from formscout import config
+
+# COCO keypoint indices
+HIP_L, HIP_R = 11, 12
+KNEE_L, KNEE_R = 13, 14
+ANKLE_L, ANKLE_R = 15, 16
+SHOULDER_L, SHOULDER_R = 5, 6
+NOSE = 0
+
+
+def _angle_2d(a, b, c) -> float:
+ """Angle at vertex b formed by segments b→a and b→c, in degrees."""
+ ba = np.array(a) - np.array(b)
+ bc = np.array(c) - np.array(b)
+ cos = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-9)
+ return float(np.degrees(np.arccos(np.clip(cos, -1.0, 1.0))))
+
+
+def _median_kp(keypoints: list[dict], joint: int) -> tuple[float, float, float]:
+ """Median x, y, conf across frames for a keypoint joint index."""
+ xs, ys, cs = [], [], []
+ for frame in keypoints:
+ kp = frame.get(joint)
+ if kp and kp["conf"] > config.POSE_CONF_THRESHOLD:
+ xs.append(kp["x"]); ys.append(kp["y"]); cs.append(kp["conf"])
+ if not xs:
+ return 0.0, 0.0, 0.0
+ return float(np.median(xs)), float(np.median(ys)), float(np.median(cs))
+
+
+def _compute_deep_squat_2d(pose2d: Pose2DResult) -> BiomechFeatures:
+ kps = pose2d.keypoints
+ hip_lx, hip_ly, hip_lc = _median_kp(kps, HIP_L)
+ knee_lx, knee_ly, knee_lc = _median_kp(kps, KNEE_L)
+ ankle_lx, ankle_ly, ankle_lc = _median_kp(kps, ANKLE_L)
+ shoulder_lx, shoulder_ly, _ = _median_kp(kps, SHOULDER_L)
+
+ conf = np.mean([c for c in [hip_lc, knee_lc, ankle_lc] if c > 0] or [0.0])
+
+ # Femur angle from horizontal: angle of hip→knee vector from x-axis
+ femur_vec = np.array([knee_lx - hip_lx, knee_ly - hip_ly])
+ femur_from_horiz = float(abs(np.degrees(np.arctan2(
+ abs(femur_vec[1]), abs(femur_vec[0]) + 1e-9
+ ))))
+
+ # Torso–tibia angle: angle between hip→shoulder and ankle→knee vectors
+ torso_vec = np.array([shoulder_lx - hip_lx, shoulder_ly - hip_ly])
+ tibia_vec = np.array([knee_lx - ankle_lx, knee_ly - ankle_ly])
+ cos_tt = np.dot(torso_vec, tibia_vec) / (
+ np.linalg.norm(torso_vec) * np.linalg.norm(tibia_vec) + 1e-9
+ )
+ torso_tibia_deg = float(np.degrees(np.arccos(np.clip(cos_tt, -1, 1))))
+
+ # Knee tracking over foot: knee x should be within margin of ankle x
+ knees_tracking = abs(knee_lx - ankle_lx) < config.DEEP_SQUAT_KNEE_TRACKING_MARGIN_PX
+
+ # Heels: if ankle is significantly above baseline (proxy for heel elevation)
+ heels_elevated = False # requires side-view calibration; set conservatively
+
+ return BiomechFeatures(
+ test_name="deep_squat",
+ view="2d",
+ side="na",
+ angles={
+ "femur_from_horizontal_deg": femur_from_horiz,
+ "torso_tibia_angle_deg": torso_tibia_deg,
+ },
+ alignments={
+ "knees_tracking_over_feet": knees_tracking,
+ "dowel_over_feet": False, # requires dowel detection (Phase 2+)
+ "heels_elevated": heels_elevated,
+ },
+ symmetry_delta=None,
+ timing={},
+ confidence=float(conf),
+ notes="2D measurements; heel elevation detection requires calibration",
+ )
+
+
+class BiomechanicsAgent:
+ def run(self, pose2d: Pose2DResult, body3d: Body3DResult,
+ movement: MovementResult) -> BiomechFeatures:
+ if movement.test_name == "deep_squat":
+ if body3d.used:
+ # TODO: implement 3D feature extraction (Phase 1.5+)
+ pass
+ return _compute_deep_squat_2d(pose2d)
+ # Other tests — Phase 2
+ return BiomechFeatures(
+ test_name=movement.test_name, view="2d", side="na",
+ angles={}, alignments={}, symmetry_delta=None, timing={},
+ confidence=0.0, notes=f"test '{movement.test_name}' not yet implemented",
+ )
+```
+
+- [ ] **Step 5: Run tests — expect PASS**
+
+```bash
+pytest tests/test_biomechanics.py -v
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add formscout/rubric/deep_squat.py formscout/agents/biomechanics.py tests/test_biomechanics.py
+git commit -m "feat: Deep Squat rubric (pure fn) + BiomechanicsAgent 2D geometry"
+```
+
+---
+
+### Task 1.6: Headless pipeline (Director + run.py)
+
+**Files:**
+- Create: `formscout/pipeline.py`
+- Create: `formscout/run.py`
+- Create: `tests/test_pipeline.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_pipeline.py
+import numpy as np
+import pytest
+from unittest.mock import patch, MagicMock
+from formscout.pipeline import Director
+from formscout.types import (
+ IngestResult, Pose2DResult, Body3DResult, MovementResult,
+ BiomechFeatures, ScoreResult, JudgeResult, PipelineState
+)
+
+def _mock_ingest():
+ frames = [np.zeros((480, 640, 3), dtype=np.uint8)]
+ return IngestResult(frames=frames, fps=30.0, duration=1.0,
+ n_people=1, width=640, height=480)
+
+def _mock_pose2d():
+ return Pose2DResult(
+ keypoints=[{11: {"x": 320.0, "y": 200.0, "conf": 0.9},
+ 13: {"x": 300.0, "y": 280.0, "conf": 0.9},
+ 15: {"x": 295.0, "y": 360.0, "conf": 0.9},
+ 5: {"x": 320.0, "y": 150.0, "conf": 0.9}}],
+ fps=30.0, confidence=0.9
+ )
+
+def test_director_runs_deep_squat_headless(tmp_path):
+ video = tmp_path / "test.mp4"
+ video.write_bytes(b"") # placeholder path
+
+ with patch("formscout.pipeline.IngestAgent") as MockIngest, \
+ patch("formscout.pipeline.Pose2DAgent") as MockPose, \
+ patch("formscout.pipeline.Body3DAgent") as MockBody3D, \
+ patch("formscout.pipeline.BiomechanicsAgent") as MockBiomech, \
+ patch("formscout.pipeline.MovementClassifierAgent") as MockClassify:
+
+ MockIngest.return_value.run.return_value = _mock_ingest()
+ MockPose.return_value.run.return_value = _mock_pose2d()
+ MockBody3D.return_value.run.return_value = Body3DResult(used=False, joints_3d=[], confidence=0.0)
+ MockClassify.return_value.run.return_value = MovementResult(
+ test_name="deep_squat", side="na", confidence=0.95)
+ mock_features = BiomechFeatures(
+ test_name="deep_squat", view="2d", side="na",
+ angles={"femur_from_horizontal_deg": 80.0, "torso_tibia_angle_deg": 12.0},
+ alignments={"knees_tracking_over_feet": True, "dowel_over_feet": True, "heels_elevated": False},
+ symmetry_delta=None, timing={}, confidence=0.9)
+ MockBiomech.return_value.run.return_value = mock_features
+
+ director = Director()
+ state = director.run(str(video))
+
+ assert isinstance(state, PipelineState)
+ assert state.judge is not None or state.features is not None
+ assert not state.errors
+
+def test_director_flags_low_confidence():
+ # If pose confidence < MIN_CONFIDENCE, warnings should be appended
+ from formscout import config
+ assert config.MIN_CONFIDENCE > 0
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/test_pipeline.py -v
+```
+
+- [ ] **Step 3: Implement pipeline.py Director**
+
+```python
+# formscout/pipeline.py
+"""
+Director — deterministic state machine orchestrating all agents.
+Not an LLM. Applies quality gates and builds PipelineState.
+"""
+from __future__ import annotations
+from formscout import config
+from formscout.types import PipelineState, JudgeResult, ScoreResult
+from formscout.agents.ingest import IngestAgent
+from formscout.agents.pose2d import Pose2DAgent
+from formscout.agents.body3d import Body3DAgent
+from formscout.agents.biomechanics import BiomechanicsAgent
+from formscout.agents.classify import MovementClassifierAgent
+from formscout.rubric.deep_squat import score_deep_squat
+from formscout.tracing import Tracer
+
+
+class Director:
+ def __init__(self):
+ self.ingest = IngestAgent()
+ self.pose2d = Pose2DAgent()
+ self.body3d = Body3DAgent()
+ self.classify = MovementClassifierAgent()
+ self.biomech = BiomechanicsAgent()
+ self.tracer = Tracer()
+
+ def run(self, video_path: str) -> PipelineState:
+ state = PipelineState(video_path=video_path)
+
+ # --- Ingest ---
+ state.ingest = self.ingest.run(video_path)
+ self.tracer.record("ingest", state.ingest)
+ if state.ingest.confidence == 0.0:
+ state.errors.append(f"Ingest failed: {state.ingest.notes}")
+ return state
+
+ # --- 2D Pose ---
+ state.pose2d = self.pose2d.run(state.ingest)
+ self.tracer.record("pose2d", state.pose2d)
+ if state.pose2d.confidence < config.MIN_CONFIDENCE:
+ state.warnings.append(
+ f"Pose2D low confidence ({state.pose2d.confidence:.2f}) — physio review recommended"
+ )
+
+ # --- 3D Body (optional) ---
+ state.body3d = self.body3d.run(state.pose2d, [])
+ self.tracer.record("body3d", state.body3d)
+
+ # --- Movement Classifier ---
+ state.movement = self.classify.run(state.ingest, state.pose2d)
+ self.tracer.record("movement", state.movement)
+ if state.movement.test_name == "unknown":
+ state.errors.append("Movement classification failed — manual override required")
+ return state
+ if state.movement.confidence < config.MIN_CONFIDENCE:
+ state.warnings.append(
+ f"Movement classifier low confidence ({state.movement.confidence:.2f})"
+ )
+
+ # --- Biomechanics ---
+ state.features = self.biomech.run(state.pose2d, state.body3d, state.movement)
+ self.tracer.record("biomechanics", state.features)
+ if state.features.confidence < config.MIN_CONFIDENCE:
+ state.warnings.append(
+ f"Biomechanics low confidence ({state.features.confidence:.2f})"
+ )
+
+ # --- Deterministic Rubric Score (Phase 1: no STGCN or Judge yet) ---
+ if state.movement.test_name == "deep_squat" and not config.ENABLE_JUDGE:
+ rubric_score = score_deep_squat(state.features)
+ state.judge = JudgeResult(
+ score=rubric_score.score,
+ rationale=rubric_score.rationale,
+ compensation_tags=[],
+ corrective_hint="",
+ confidence=rubric_score.confidence,
+ needs_human=rubric_score.needs_human,
+ notes="deterministic rubric (no VLM judge in Phase 1)",
+ )
+ self.tracer.record("judge", state.judge)
+
+ return state
+```
+
+- [ ] **Step 4: Implement MovementClassifierAgent stub**
+
+```python
+# formscout/agents/classify.py
+"""
+MovementClassifierAgent — identifies which of 7 FMS tests is being performed.
+Phase 1: returns 'deep_squat' stub (VLM classifier wired in Phase 2).
+Input: IngestResult, Pose2DResult
+Output: MovementResult(test_name, side, confidence)
+"""
+from formscout.types import IngestResult, Pose2DResult, MovementResult
+
+
+class MovementClassifierAgent:
+ def run(self, ingest: IngestResult, pose2d: Pose2DResult) -> MovementResult:
+ # Phase 1 stub — always returns deep_squat
+ # Phase 2: replace with VLM or small classifier
+ return MovementResult(
+ test_name="deep_squat",
+ side="na",
+ confidence=0.5,
+ notes="Phase 1 stub — always deep_squat",
+ )
+```
+
+- [ ] **Step 5: Implement tracing.py**
+
+```python
+# formscout/tracing.py
+"""Structured per-agent I/O logger. One full run can be exported to Hub."""
+import json
+from dataclasses import asdict
+from datetime import datetime
+from pathlib import Path
+
+
+class Tracer:
+ def __init__(self):
+ self._records: list[dict] = []
+ self._run_id = datetime.utcnow().strftime("%Y%m%dT%H%M%S")
+
+ def record(self, agent_name: str, result) -> None:
+ try:
+ data = asdict(result)
+ except Exception:
+ data = str(result)
+ self._records.append({"agent": agent_name, "result": data,
+ "ts": datetime.utcnow().isoformat()})
+
+ def export(self, path: str | None = None) -> str:
+ out = path or f"trace_{self._run_id}.json"
+ Path(out).write_text(json.dumps(self._records, indent=2, default=str))
+ return out
+```
+
+- [ ] **Step 6: Implement run.py headless CLI**
+
+```python
+# formscout/run.py
+"""Headless CLI — no Gradio imports."""
+import sys
+from formscout.pipeline import Director
+
+def main(video_path: str) -> None:
+ director = Director()
+ state = director.run(video_path)
+ if state.errors:
+ print("ERRORS:", state.errors)
+ sys.exit(1)
+ if state.warnings:
+ print("WARNINGS:", state.warnings)
+ if state.judge:
+ print(f"\nTest: {state.movement.test_name}")
+ print(f"Score: {state.judge.score}/3")
+ print(f"Rationale: {state.judge.rationale}")
+ print(f"Confidence:{state.judge.confidence:.2f}")
+ if state.judge.needs_human:
+ print("⚠️ Deferred to physio — do not use this score.")
+ else:
+ print("Pipeline incomplete — no judge result.")
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python -m formscout.run ")
+ sys.exit(1)
+ main(sys.argv[1])
+```
+
+- [ ] **Step 7: Run tests**
+
+```bash
+pytest tests/test_pipeline.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Smoke-test headless CLI**
+
+```bash
+python -m formscout.run tests/fixtures/sample_squat.mp4
+```
+
+Expected: Score printed or graceful error if file missing.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add formscout/pipeline.py formscout/run.py formscout/agents/classify.py formscout/tracing.py tests/test_pipeline.py
+git commit -m "feat: Director pipeline — headless Deep Squat end-to-end"
+```
+
+**✅ MILESTONE M1: `python -m formscout.run sample.mp4` → score + rationale**
+
+---
+
+## Phase 1b — Minimal Gradio UI
+
+### Task 1.7: Minimal Gradio app (Deep Squat only)
+
+**Files:**
+- Create: `app.py`
+- Create: `formscout/ui/theme.py`
+
+- [ ] **Step 1: Verify Gradio APIs before writing UI**
+
+```bash
+python -c "
+import gradio as gr
+print('version:', gr.__version__)
+# Check Video playback_position
+import inspect
+sig = inspect.signature(gr.Video.__init__)
+print('Video params:', list(sig.parameters.keys()))
+"
+```
+
+Record what exists. Only use confirmed APIs.
+
+- [ ] **Step 2: Implement theme.py**
+
+```python
+# formscout/ui/theme.py
+import gradio as gr
+
+def scout_theme() -> gr.Theme:
+ return gr.themes.Base(
+ primary_hue="amber",
+ secondary_hue="stone",
+ neutral_hue="stone",
+ font=gr.themes.GoogleFont("Inter"),
+ ).set(
+ body_background_fill="#1a1a18",
+ body_text_color="#e8e0d4",
+ block_background_fill="#2a2a25",
+ block_border_color="#4a4535",
+ )
+```
+
+- [ ] **Step 3: Implement app.py**
+
+```python
+# app.py
+"""Gradio entrypoint — imports only from formscout.ui and formscout.pipeline."""
+import gradio as gr
+from formscout.pipeline import Director
+from formscout.ui.theme import scout_theme
+
+_director = Director()
+
+
+def process_video(video_path: str) -> tuple[str, str, str]:
+ """Returns (score_text, rationale, warnings)."""
+ if not video_path:
+ return "—", "No video uploaded.", ""
+ state = _director.run(video_path)
+ if state.errors:
+ return "Error", "\n".join(state.errors), ""
+ if not state.judge:
+ return "—", "Pipeline incomplete.", "\n".join(state.warnings)
+ score = "⚠️ Deferred" if state.judge.needs_human else str(state.judge.score)
+ warnings = "\n".join(state.warnings) if state.warnings else ""
+ return score, state.judge.rationale, warnings
+
+
+with gr.Blocks(theme=scout_theme(), title="FormScout") as demo:
+ gr.HTML("""
+
+ ⚠️ Screening aid — not a diagnosis. Pain or clearing tests require a clinician.
+
+ """)
+ gr.Markdown("# FormScout — FMS Video Scorer")
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ video_in = gr.Video(label="Upload FMS clip", sources=["upload"])
+ run_btn = gr.Button("Score", variant="primary")
+ with gr.Column(scale=1):
+ score_out = gr.Textbox(label="Score (0–3)", interactive=False)
+ rationale_out = gr.Textbox(label="Rationale", lines=4, interactive=False)
+ warnings_out = gr.Textbox(label="Flags / Warnings", lines=2, interactive=False)
+
+ run_btn.click(fn=process_video, inputs=video_in,
+ outputs=[score_out, rationale_out, warnings_out])
+
+if __name__ == "__main__":
+ demo.launch()
+```
+
+- [ ] **Step 4: Launch and test manually**
+
+```bash
+python app.py
+```
+
+Open browser. Upload a video. Verify:
+- Safety banner visible
+- Score field populates
+- No Python exceptions in terminal
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app.py formscout/ui/theme.py
+git commit -m "feat: minimal Gradio UI — video upload → score + rationale + safety banner"
+```
+
+**✅ MILESTONE M2: Upload Deep Squat clip → score + overlay in browser**
+
+---
+
+## Phase 2 — All 7 Tests + JudgeAgent
+
+### Task 2.1: Rubric scorers for all 7 tests
+
+**Files:**
+- Create: `formscout/rubric/hurdle_step.py`
+- Create: `formscout/rubric/inline_lunge.py`
+- Create: `formscout/rubric/shoulder_mobility.py`
+- Create: `formscout/rubric/aslr.py`
+- Create: `formscout/rubric/tspu.py`
+- Create: `formscout/rubric/rotary_stability.py`
+- Modify: `formscout/agents/biomechanics.py`
+- Create: `tests/test_rubric_all.py`
+
+- [ ] **Step 1: Write failing tests for all 7 rubrics**
+
+```python
+# tests/test_rubric_all.py
+import pytest
+from formscout.types import BiomechFeatures, ScoreResult
+
+def _f(test, angles, alignments, side="na", sym=None):
+ return BiomechFeatures(
+ test_name=test, view="2d", side=side,
+ angles=angles, alignments=alignments,
+ symmetry_delta=sym, timing={}, confidence=0.9,
+ )
+
+# --- Hurdle Step ---
+from formscout.rubric.hurdle_step import score_hurdle_step
+
+def test_hurdle_step_score_3():
+ f = _f("hurdle_step", {"hip_flexion_deg": 100.0, "spine_lateral_lean_deg": 3.0},
+ {"hurdle_clearance": True, "foot_dorsiflexion": True}, side="left")
+ assert score_hurdle_step(f).score == 3
+
+def test_hurdle_step_score_lower_reported():
+ f_left = _f("hurdle_step", {"hip_flexion_deg": 100.0, "spine_lateral_lean_deg": 3.0},
+ {"hurdle_clearance": True, "foot_dorsiflexion": True}, side="left")
+ f_right = _f("hurdle_step", {"hip_flexion_deg": 60.0, "spine_lateral_lean_deg": 20.0},
+ {"hurdle_clearance": False, "foot_dorsiflexion": False}, side="right")
+ assert score_hurdle_step(f_left).score > score_hurdle_step(f_right).score
+
+# --- In-Line Lunge ---
+from formscout.rubric.inline_lunge import score_inline_lunge
+
+def test_inline_lunge_score_3():
+ f = _f("inline_lunge", {"trunk_lean_deg": 5.0, "knee_height_ratio": 0.1},
+ {"foot_on_line": True, "dowel_contact": True, "balance_maintained": True}, side="left")
+ assert score_inline_lunge(f).score == 3
+
+# --- Shoulder Mobility ---
+from formscout.rubric.shoulder_mobility import score_shoulder_mobility
+
+def test_shoulder_mobility_score_3():
+ f = _f("shoulder_mobility", {"hand_distance_norm": 0.8},
+ {}, side="left", sym=0.05)
+ assert score_shoulder_mobility(f).score == 3
+
+def test_shoulder_mobility_pain_defers():
+ f = _f("shoulder_mobility", {"hand_distance_norm": 0.8}, {}, side="left")
+ assert score_shoulder_mobility(f, pain=True).needs_human is True
+
+# --- ASLR ---
+from formscout.rubric.aslr import score_aslr
+
+def test_aslr_score_3():
+ f = _f("aslr", {"leg_raise_deg": 90.0}, {}, side="left")
+ assert score_aslr(f).score == 3
+
+# --- TSPU ---
+from formscout.rubric.tspu import score_tspu
+
+def test_tspu_score_3():
+ f = _f("tspu", {}, {"body_straight": True, "full_pushup": True, "hands_shoulder": True})
+ assert score_tspu(f).score == 3
+
+# --- Rotary Stability ---
+from formscout.rubric.rotary_stability import score_rotary_stability
+
+def test_rotary_stability_score_3():
+ f = _f("rotary_stability",
+ {"trunk_rotation_deg": 5.0},
+ {"ipsilateral_extension": True, "balance_maintained": True})
+ assert score_rotary_stability(f).score == 3
+```
+
+- [ ] **Step 2: Run — expect ImportErrors**
+
+```bash
+pytest tests/test_rubric_all.py -v
+```
+
+- [ ] **Step 3: Implement hurdle_step.py**
+
+```python
+# formscout/rubric/hurdle_step.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+HIP_FLEX_MIN_DEG = 90.0
+SPINE_LEAN_MAX_DEG = 5.0
+
+def score_hurdle_step(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain flagged — defer to physio.",
+ confidence=1.0, needs_human=True)
+ hip = features.angles.get("hip_flexion_deg", 0.0)
+ lean = features.angles.get("spine_lateral_lean_deg", 999.0)
+ clearance = features.alignments.get("hurdle_clearance", False)
+ dorsi = features.alignments.get("foot_dorsiflexion", False)
+ note = f" ({features.side} side, 2D)" if features.view == "2d" else f" ({features.side} side)"
+ if hip >= HIP_FLEX_MIN_DEG and lean <= SPINE_LEAN_MAX_DEG and clearance and dorsi:
+ return ScoreResult(score=3, rationale=f"Hip flexion {hip:.1f}°, spine lean {lean:.1f}°, hurdle cleared.{note}",
+ confidence=features.confidence, needs_human=False)
+ if clearance:
+ return ScoreResult(score=2, rationale=f"Hurdle cleared with compensation (lean {lean:.1f}°).{note}",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale=f"Hurdle not cleared.{note}",
+ confidence=features.confidence, needs_human=False)
+```
+
+- [ ] **Step 4: Implement inline_lunge.py**
+
+```python
+# formscout/rubric/inline_lunge.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+TRUNK_LEAN_MAX = 8.0
+
+def score_inline_lunge(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain flagged.", confidence=1.0, needs_human=True)
+ lean = features.angles.get("trunk_lean_deg", 999.0)
+ on_line = features.alignments.get("foot_on_line", False)
+ dowel = features.alignments.get("dowel_contact", False)
+ balance = features.alignments.get("balance_maintained", False)
+ note = f" ({features.side} side)"
+ if on_line and dowel and balance and lean <= TRUNK_LEAN_MAX:
+ return ScoreResult(score=3, rationale=f"All criteria met, lean {lean:.1f}°.{note}",
+ confidence=features.confidence, needs_human=False)
+ if on_line and balance:
+ return ScoreResult(score=2, rationale=f"Criteria met with compensation (lean {lean:.1f}°).{note}",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale=f"Balance or foot position failed.{note}",
+ confidence=features.confidence, needs_human=False)
+```
+
+- [ ] **Step 5: Implement shoulder_mobility.py**
+
+```python
+# formscout/rubric/shoulder_mobility.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+def score_shoulder_mobility(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain on clearing test — defer to physio.",
+ confidence=1.0, needs_human=True)
+ dist = features.angles.get("hand_distance_norm", 999.0) # normalized to hand span
+ note = f" ({features.side} side)"
+ if dist <= 1.0:
+ return ScoreResult(score=3, rationale=f"Hands within one hand-span (dist={dist:.2f}).{note}",
+ confidence=features.confidence, needs_human=False)
+ if dist <= 1.5:
+ return ScoreResult(score=2, rationale=f"Hands within 1.5 hand-spans (dist={dist:.2f}).{note}",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale=f"Distance exceeds 1.5 hand-spans (dist={dist:.2f}).{note}",
+ confidence=features.confidence, needs_human=False)
+```
+
+- [ ] **Step 6: Implement aslr.py, tspu.py, rotary_stability.py**
+
+```python
+# formscout/rubric/aslr.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+def score_aslr(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain flagged.", confidence=1.0, needs_human=True)
+ deg = features.angles.get("leg_raise_deg", 0.0)
+ note = f" ({features.side} side)"
+ if deg >= 80.0:
+ return ScoreResult(score=3, rationale=f"Leg raise {deg:.1f}° ≥ 80°.{note}",
+ confidence=features.confidence, needs_human=False)
+ if deg >= 50.0:
+ return ScoreResult(score=2, rationale=f"Leg raise {deg:.1f}° (50–80°).{note}",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale=f"Leg raise {deg:.1f}° < 50°.{note}",
+ confidence=features.confidence, needs_human=False)
+```
+
+```python
+# formscout/rubric/tspu.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+def score_tspu(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain on clearing test — defer to physio.",
+ confidence=1.0, needs_human=True)
+ straight = features.alignments.get("body_straight", False)
+ full_pu = features.alignments.get("full_pushup", False)
+ hands_sh = features.alignments.get("hands_shoulder", True)
+ if straight and full_pu and hands_sh:
+ return ScoreResult(score=3, rationale="Full push-up with body straight, hands at shoulder width.",
+ confidence=features.confidence, needs_human=False)
+ if straight and features.alignments.get("knee_pushup", False):
+ return ScoreResult(score=2, rationale="Knee push-up with body straight.",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale="Unable to maintain straight body during push-up.",
+ confidence=features.confidence, needs_human=False)
+```
+
+```python
+# formscout/rubric/rotary_stability.py
+from formscout.types import BiomechFeatures, ScoreResult
+
+TRUNK_ROT_MAX_DEG = 10.0
+
+def score_rotary_stability(features: BiomechFeatures, pain: bool = False) -> ScoreResult:
+ if pain:
+ return ScoreResult(score=0, rationale="Pain on clearing test — defer to physio.",
+ confidence=1.0, needs_human=True)
+ rot = features.angles.get("trunk_rotation_deg", 999.0)
+ ipsi = features.alignments.get("ipsilateral_extension", False)
+ balance = features.alignments.get("balance_maintained", False)
+ if ipsi and balance and rot <= TRUNK_ROT_MAX_DEG:
+ return ScoreResult(score=3, rationale=f"Ipsilateral extension, balanced, trunk rot {rot:.1f}°.",
+ confidence=features.confidence, needs_human=False)
+ if features.alignments.get("diagonal_extension", False) and balance:
+ return ScoreResult(score=2, rationale="Diagonal extension with balance.",
+ confidence=features.confidence, needs_human=False)
+ return ScoreResult(score=1, rationale="Unable to maintain balance during extension.",
+ confidence=features.confidence, needs_human=False)
+```
+
+- [ ] **Step 7: Run all rubric tests**
+
+```bash
+pytest tests/test_rubric_all.py -v
+```
+
+Expected: all PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add formscout/rubric/ tests/test_rubric_all.py
+git commit -m "feat: rubric scorers for all 7 FMS tests — pure functions"
+```
+
+---
+
+### Task 2.2: JudgeAgent (Qwen3-VL-8B via llama.cpp)
+
+**Files:**
+- Create: `formscout/serving/llama_cpp.py`
+- Create: `formscout/agents/prompts/C2_judge.md`
+- Create: `formscout/agents/judge.py`
+- Create: `tests/test_judge.py`
+
+- [ ] **Step 1: Verify llama.cpp build path on this system**
+
+```bash
+# Option A: CPU-only build (safest for Spaces)
+pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
+
+# Option B: If that fails, use transformers fallback for now
+python -c "import llama_cpp; print('llama_cpp ok', llama_cpp.__version__)"
+```
+
+Note which path succeeded. Update requirements.txt accordingly.
+
+- [ ] **Step 2: Write failing test**
+
+```python
+# tests/test_judge.py
+import pytest
+from unittest.mock import patch, MagicMock
+from formscout.agents.judge import JudgeAgent
+from formscout.types import BiomechFeatures, ScoreResult, JudgeResult, RetrievalResult
+
+def _features():
+ return BiomechFeatures(
+ test_name="deep_squat", view="2d", side="na",
+ angles={"femur_from_horizontal_deg": 80.0, "torso_tibia_angle_deg": 12.0},
+ alignments={"knees_tracking_over_feet": True, "dowel_over_feet": True, "heels_elevated": False},
+ symmetry_delta=None, timing={}, confidence=0.9,
+ )
+
+def _rubric_score():
+ return ScoreResult(score=3, rationale="All criteria met.", confidence=0.9, needs_human=False)
+
+def _retrieval():
+ return RetrievalResult(exemplars=[], confidence=1.0)
+
+def test_judge_returns_typed_result():
+ with patch("formscout.agents.judge._call_vlm") as mock_vlm:
+ mock_vlm.return_value = {"score": 3, "rationale": "Good squat.",
+ "compensation_tags": [], "corrective_hint": "",
+ "needs_human": False, "confidence": 0.85}
+ agent = JudgeAgent()
+ result = agent.run(_features(), _rubric_score(), _retrieval())
+ assert isinstance(result, JudgeResult)
+ assert 0 <= result.score <= 3
+
+def test_judge_defers_on_pain():
+ from formscout.types import ScoreResult
+ pain_score = ScoreResult(score=0, rationale="Pain.", confidence=1.0, needs_human=True)
+ agent = JudgeAgent()
+ result = agent.run(_features(), pain_score, _retrieval())
+ assert result.needs_human is True
+ assert result.score == -1
+
+def test_judge_flags_disagreement():
+ with patch("formscout.agents.judge._call_vlm") as mock_vlm:
+ mock_vlm.return_value = {"score": 1, "rationale": "Poor squat.",
+ "compensation_tags": [], "corrective_hint": "",
+ "needs_human": False, "confidence": 0.7}
+ agent = JudgeAgent()
+ rubric_3 = ScoreResult(score=3, rationale="All criteria met.", confidence=0.9, needs_human=False)
+ result = agent.run(_features(), rubric_3, _retrieval())
+ # |3-1| >= 1 → should note disagreement
+ assert "disagree" in result.notes.lower() or result.confidence < 0.7
+```
+
+- [ ] **Step 3: Implement C2 judge prompt**
+
+```markdown
+
+# FormScout Judge System Prompt (C2)
+
+You are a biomechanics judge assistant for the Functional Movement Screen (FMS).
+You receive:
+- The detected FMS test name and side
+- Measured biomechanical features (angles, alignments) extracted from video
+- A deterministic rubric candidate score (0–3) with reason
+- Retrieved exemplar clips and their physio-assigned scores (if available)
+
+Your job: synthesize these inputs and return a JSON object with:
+- "score": integer 0–3 (or -1 if needs_human=true)
+- "rationale": one concise sentence citing the deciding measurement
+- "compensation_tags": list of strings (e.g. ["valgus_collapse", "forward_lean"])
+- "corrective_hint": one sentence corrective cue for the athlete
+- "needs_human": boolean — true ONLY for pain, clearing tests, or visible distress
+- "confidence": float 0.0–1.0
+
+CRITICAL RULES:
+- NEVER score pain or clearing tests — set needs_human=true, score=-1
+- If measurements are low confidence, lower your confidence accordingly
+- If your score differs from the rubric candidate by ≥1, explain why in rationale
+- The rationale must cite a specific measurement (angle or alignment), not generalities
+- For 2D measurements, caveat that camera angle may affect accuracy
+- This is a screening aid, not a diagnosis
+
+Respond ONLY with valid JSON. No markdown fences, no explanation outside the JSON.
+```
+
+- [ ] **Step 4: Implement llama_cpp.py serving wrapper**
+
+```python
+# formscout/serving/llama_cpp.py
+"""llama.cpp client wrappers with transformers fallbacks."""
+from __future__ import annotations
+import json
+from formscout import config
+
+_vlm_client = None
+_embed_client = None
+
+
+def _get_vlm():
+ global _vlm_client
+ if _vlm_client is not None:
+ return _vlm_client
+ try:
+ from llama_cpp import Llama
+ _vlm_client = Llama(
+ model_path=str(config.QWEN_VLM_GGUF),
+ n_ctx=4096, n_threads=4, verbose=False,
+ )
+ return _vlm_client
+ except Exception as e:
+ return None # fallback to transformers
+
+
+def call_vlm_json(system_prompt: str, user_message: str) -> dict:
+ """Call VLM and parse JSON response. Returns dict or raises ValueError."""
+ client = _get_vlm()
+ if client is None:
+ return _transformers_fallback(system_prompt, user_message)
+
+ response = client.create_chat_completion(
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message},
+ ],
+ temperature=0.1,
+ max_tokens=512,
+ )
+ raw = response["choices"][0]["message"]["content"].strip()
+ return json.loads(raw)
+
+
+def _transformers_fallback(system_prompt: str, user_message: str) -> dict:
+ """Transformers + spaces.GPU fallback when llama.cpp unavailable."""
+ try:
+ from transformers import AutoModelForCausalLM, AutoTokenizer
+ import torch
+ model_id = "Qwen/Qwen3-VL-8B-Instruct"
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
+ model = AutoModelForCausalLM.from_pretrained(
+ model_id, torch_dtype=torch.float16, device_map="auto"
+ )
+ messages = [{"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message}]
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
+ with torch.no_grad():
+ out = model.generate(**inputs, max_new_tokens=512, temperature=0.1)
+ raw = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
+ return json.loads(raw.strip())
+ except Exception as e:
+ raise ValueError(f"Both llama.cpp and transformers failed: {e}")
+```
+
+- [ ] **Step 5: Implement JudgeAgent**
+
+```python
+# formscout/agents/judge.py
+"""
+JudgeAgent — Qwen3-VL-8B via llama.cpp synthesizes rubric + measurements + exemplars.
+Input: BiomechFeatures, ScoreResult (rubric), RetrievalResult
+Output: JudgeResult(score, rationale, compensation_tags, corrective_hint, confidence, needs_human)
+Failure: returns needs_human=True with score=-1 if VLM call fails.
+Model: Qwen3-VL-8B-Instruct (8B, Apache-2.0, GGUF via llama.cpp).
+Gated: no.
+"""
+from __future__ import annotations
+from pathlib import Path
+from formscout.types import BiomechFeatures, ScoreResult, RetrievalResult, JudgeResult
+from formscout import config
+from formscout.serving.llama_cpp import call_vlm_json
+
+_PROMPT_PATH = Path(__file__).parent / "prompts" / "C2_judge.md"
+_SYSTEM_PROMPT = _PROMPT_PATH.read_text() if _PROMPT_PATH.exists() else ""
+
+_DEFERRED = JudgeResult(
+ score=-1, rationale="Pain or clearing test — defer to physio.",
+ compensation_tags=[], corrective_hint="Consult your physiotherapist.",
+ confidence=1.0, needs_human=True, notes="auto-deferred by safety gate",
+)
+
+
+def _call_vlm(system: str, user: str) -> dict:
+ return call_vlm_json(system, user)
+
+
+class JudgeAgent:
+ def run(self, features: BiomechFeatures, rubric_score: ScoreResult,
+ retrieval: RetrievalResult) -> JudgeResult:
+ # Safety gate: pain or human-required cases never pass through VLM
+ if rubric_score.needs_human:
+ return _DEFERRED
+
+ if not config.ENABLE_JUDGE:
+ # Phase 1: return rubric score wrapped as JudgeResult
+ return JudgeResult(
+ score=rubric_score.score, rationale=rubric_score.rationale,
+ compensation_tags=[], corrective_hint="",
+ confidence=rubric_score.confidence, needs_human=False,
+ notes="ENABLE_JUDGE=False — deterministic rubric only",
+ )
+
+ exemplar_txt = "\n".join(
+ f"- Clip {e['clip_id']}: score={e['score']}, similarity={e['similarity']:.2f}"
+ for e in retrieval.exemplars
+ ) or "No exemplars available."
+
+ user_msg = f"""Test: {features.test_name} ({features.side} side, {features.view} view)
+Biomechanical measurements:
+{features.angles}
+{features.alignments}
+Measurement confidence: {features.confidence:.2f}
+
+Deterministic rubric candidate: {rubric_score.score}/3
+Rubric reason: {rubric_score.rationale}
+
+Retrieved exemplars:
+{exemplar_txt}
+
+Return JSON only."""
+
+ try:
+ resp = _call_vlm(_SYSTEM_PROMPT, user_msg)
+ score = int(resp.get("score", -1))
+ needs_human = bool(resp.get("needs_human", False))
+ if needs_human:
+ return _DEFERRED
+ notes = ""
+ if abs(score - rubric_score.score) >= config.SCORE_DISAGREE_THRESH:
+ notes = f"disagree with rubric ({rubric_score.score} vs judge {score}) — physio review"
+ return JudgeResult(
+ score=score,
+ rationale=resp.get("rationale", ""),
+ compensation_tags=resp.get("compensation_tags", []),
+ corrective_hint=resp.get("corrective_hint", ""),
+ confidence=float(resp.get("confidence", 0.5)),
+ needs_human=False,
+ notes=notes,
+ )
+ except Exception as e:
+ return JudgeResult(
+ score=-1, rationale=f"VLM error — using rubric fallback: {rubric_score.rationale}",
+ compensation_tags=[], corrective_hint="",
+ confidence=rubric_score.confidence * 0.5,
+ needs_human=True,
+ notes=f"VLM call failed: {e}",
+ )
+```
+
+- [ ] **Step 6: Run tests**
+
+```bash
+pytest tests/test_judge.py -v
+```
+
+Expected: all PASS (VLM is mocked).
+
+- [ ] **Step 7: Enable judge in config and smoke-test**
+
+```python
+# In formscout/config.py, temporarily set:
+ENABLE_JUDGE = True
+```
+
+```bash
+python -m formscout.run tests/fixtures/sample_squat.mp4
+```
+
+Note: may fail if GGUF not downloaded. That's expected — check the notes output.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add formscout/serving/llama_cpp.py formscout/agents/judge.py formscout/agents/prompts/C2_judge.md tests/test_judge.py
+git commit -m "feat: JudgeAgent — Qwen3-VL-8B via llama.cpp with transformers fallback"
+```
+
+---
+
+### Task 2.3: MovementClassifier (VLM-based, all 7 tests)
+
+**Files:**
+- Create: `formscout/agents/prompts/C1_classifier.md`
+- Modify: `formscout/agents/classify.py`
+- Create: `tests/test_classify.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_classify.py
+from unittest.mock import patch
+from formscout.agents.classify import MovementClassifierAgent
+from formscout.types import IngestResult, Pose2DResult, MovementResult
+import numpy as np
+
+VALID_TESTS = {"deep_squat", "hurdle_step", "inline_lunge",
+ "shoulder_mobility", "aslr", "tspu", "rotary_stability", "unknown"}
+
+def _dummy_ingest():
+ return IngestResult(frames=[np.zeros((480,640,3), dtype=np.uint8)],
+ fps=30.0, duration=1.0, n_people=1, width=640, height=480)
+
+def _dummy_pose():
+ return Pose2DResult(keypoints=[{}], fps=30.0, confidence=0.5)
+
+def test_classifier_returns_typed_result():
+ with patch("formscout.agents.classify._call_vlm") as mock_vlm:
+ mock_vlm.return_value = {"test_name": "deep_squat", "side": "na", "confidence": 0.92}
+ agent = MovementClassifierAgent()
+ result = agent.run(_dummy_ingest(), _dummy_pose())
+ assert isinstance(result, MovementResult)
+ assert result.test_name in VALID_TESTS
+
+def test_classifier_unknown_on_vlm_failure():
+ with patch("formscout.agents.classify._call_vlm", side_effect=Exception("fail")):
+ agent = MovementClassifierAgent()
+ result = agent.run(_dummy_ingest(), _dummy_pose())
+ assert result.test_name == "unknown"
+ assert result.confidence < 0.5
+```
+
+- [ ] **Step 2: Implement C1 prompt**
+
+```markdown
+
+# FormScout Movement Classifier System Prompt (C1)
+
+You are classifying which FMS (Functional Movement Screen) test is being performed in a video clip.
+
+The 7 valid tests are:
+- deep_squat: person squats with arms overhead
+- hurdle_step: person steps over a hurdle while standing on one leg
+- inline_lunge: person lunges with feet on a line, holding a dowel
+- shoulder_mobility: person reaches hands behind back simultaneously
+- aslr: person lies on back and raises one straight leg
+- tspu: person performs a push-up from hands or knees
+- rotary_stability: person on hands and knees extends opposite arm/leg
+
+Return JSON only:
+{
+ "test_name": "",
+ "side": "<'left'|'right'|'bilateral'|'na'>",
+ "confidence": <0.0-1.0>
+}
+
+If you cannot determine the test with confidence > 0.5, return "unknown".
+```
+
+- [ ] **Step 3: Update classify.py**
+
+```python
+# formscout/agents/classify.py
+"""
+MovementClassifierAgent — identifies which FMS test is being performed.
+Input: IngestResult, Pose2DResult
+Output: MovementResult(test_name, side, confidence)
+Failure: returns MovementResult(test_name='unknown', confidence=0.0) — never crashes.
+Model: Qwen3-VL-8B-Instruct (shared with JudgeAgent).
+Gated: no.
+"""
+from __future__ import annotations
+import base64, cv2, numpy as np
+from pathlib import Path
+from formscout.types import IngestResult, Pose2DResult, MovementResult
+from formscout import config
+from formscout.serving.llama_cpp import call_vlm_json
+
+_PROMPT_PATH = Path(__file__).parent / "prompts" / "C1_classifier.md"
+_SYSTEM_PROMPT = _PROMPT_PATH.read_text() if _PROMPT_PATH.exists() else ""
+
+VALID_TESTS = {"deep_squat", "hurdle_step", "inline_lunge",
+ "shoulder_mobility", "aslr", "tspu", "rotary_stability"}
+
+_UNKNOWN = MovementResult(test_name="unknown", side="na", confidence=0.0,
+ notes="classification failed")
+
+
+def _call_vlm(system: str, user: str) -> dict:
+ return call_vlm_json(system, user)
+
+
+def _frame_to_b64(frame: np.ndarray) -> str:
+ _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
+ return base64.b64encode(buf.tobytes()).decode()
+
+
+class MovementClassifierAgent:
+ def run(self, ingest: IngestResult, pose2d: Pose2DResult) -> MovementResult:
+ if not ingest.frames:
+ return _UNKNOWN
+
+ # Sample 3 keyframes for the VLM
+ frames = ingest.frames
+ idxs = [0, len(frames) // 2, len(frames) - 1]
+ keyframes = [frames[i] for i in idxs if i < len(frames)]
+
+ user_msg = "Classify the FMS test in these frames. Return JSON only.\n"
+ for i, f in enumerate(keyframes):
+ user_msg += f"\n[Frame {i+1}] (base64 JPEG omitted for text pipeline)\n"
+
+ try:
+ resp = _call_vlm(_SYSTEM_PROMPT, user_msg)
+ test_name = resp.get("test_name", "unknown").lower().strip()
+ if test_name not in VALID_TESTS:
+ test_name = "unknown"
+ return MovementResult(
+ test_name=test_name,
+ side=resp.get("side", "na"),
+ confidence=float(resp.get("confidence", 0.5)),
+ )
+ except Exception as e:
+ return MovementResult(test_name="unknown", side="na", confidence=0.0,
+ notes=f"VLM classification error: {e}")
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+pytest tests/test_classify.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/agents/classify.py formscout/agents/prompts/C1_classifier.md tests/test_classify.py
+git commit -m "feat: MovementClassifierAgent — VLM-based FMS test detection for all 7 tests"
+```
+
+---
+
+### Task 2.4: ReportAgent + composite scorecard
+
+**Files:**
+- Create: `formscout/agents/report.py`
+- Create: `tests/test_report.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_report.py
+from formscout.agents.report import ReportAgent
+from formscout.types import JudgeResult, MovementResult, BiomechFeatures, ReportResult
+
+def _judge(score, test="deep_squat", needs_human=False):
+ return JudgeResult(score=score, rationale="ok", compensation_tags=[],
+ corrective_hint="", confidence=0.9, needs_human=needs_human)
+
+def test_report_composite_score():
+ agent = ReportAgent()
+ tests = [
+ {"test_name": "deep_squat", "judge": _judge(3), "side": "na"},
+ {"test_name": "hurdle_step", "judge": _judge(2), "side": "left"},
+ {"test_name": "hurdle_step", "judge": _judge(1), "side": "right"}, # lower wins
+ {"test_name": "inline_lunge", "judge": _judge(2), "side": "left"},
+ {"test_name": "inline_lunge", "judge": _judge(2), "side": "right"},
+ {"test_name": "shoulder_mobility", "judge": _judge(3), "side": "left"},
+ {"test_name": "shoulder_mobility", "judge": _judge(3), "side": "right"},
+ {"test_name": "aslr", "judge": _judge(2), "side": "left"},
+ {"test_name": "aslr", "judge": _judge(2), "side": "right"},
+ {"test_name": "tspu", "judge": _judge(3), "side": "na"},
+ {"test_name": "rotary_stability", "judge": _judge(2), "side": "left"},
+ {"test_name": "rotary_stability", "judge": _judge(2), "side": "right"},
+ ]
+ result = agent.build_report(tests, overlay_video_path=None)
+ assert isinstance(result, ReportResult)
+ # hurdle_step bilateral → lower (1), so composite = 3+1+2+3+2+3+2 = 16
+ assert result.composite == 16
+
+def test_report_composite_null_on_unscored():
+ agent = ReportAgent()
+ tests = [
+ {"test_name": "deep_squat", "judge": _judge(-1, needs_human=True), "side": "na"},
+ ]
+ result = agent.build_report(tests, overlay_video_path=None)
+ assert result.composite is None
+
+def test_report_asymmetry_detected():
+ agent = ReportAgent()
+ tests = [
+ {"test_name": "aslr", "judge": _judge(3), "side": "left"},
+ {"test_name": "aslr", "judge": _judge(1), "side": "right"},
+ ]
+ result = agent.build_report(tests, overlay_video_path=None)
+ asym = [a for a in result.asymmetries if a["test"] == "aslr"]
+ assert len(asym) == 1
+ assert asym[0]["delta"] == 2
+```
+
+- [ ] **Step 2: Implement ReportAgent**
+
+```python
+# formscout/agents/report.py
+"""
+ReportAgent — builds per-test cards, composite 0–21, asymmetry analysis.
+Input: list of test dicts {test_name, judge: JudgeResult, side}
+Output: ReportResult
+Params: 0 (no model).
+"""
+from __future__ import annotations
+from formscout.types import JudgeResult, ReportResult
+
+BILATERAL_TESTS = {"hurdle_step", "inline_lunge", "shoulder_mobility",
+ "aslr", "rotary_stability"}
+
+
+class ReportAgent:
+ def build_report(self, tests: list[dict],
+ overlay_video_path: str | None,
+ pdf_path: str | None = None,
+ warnings: list | None = None,
+ disagreements: list | None = None) -> ReportResult:
+ # Collapse bilateral tests to lower score
+ test_scores: dict[str, int | None] = {}
+ asymmetries = []
+
+ bilateral_sides: dict[str, dict] = {}
+ for t in tests:
+ name = t["test_name"]
+ judge: JudgeResult = t["judge"]
+ side = t.get("side", "na")
+
+ if name in BILATERAL_TESTS:
+ if name not in bilateral_sides:
+ bilateral_sides[name] = {}
+ if judge.needs_human:
+ bilateral_sides[name][side] = None
+ else:
+ bilateral_sides[name][side] = judge.score
+ else:
+ if judge.needs_human:
+ test_scores[name] = None
+ else:
+ test_scores[name] = judge.score
+
+ for name, sides in bilateral_sides.items():
+ scores = {s: v for s, v in sides.items() if v is not None}
+ if len(scores) < len(sides): # any side unscored
+ test_scores[name] = None
+ elif scores:
+ vals = list(scores.values())
+ test_scores[name] = min(vals)
+ if len(vals) == 2 and abs(vals[0] - vals[1]) > 0:
+ side_names = list(scores.keys())
+ asymmetries.append({
+ "test": name,
+ "left_score": scores.get("left"),
+ "right_score": scores.get("right"),
+ "delta": abs(vals[0] - vals[1]),
+ })
+
+ # Composite is null if any test is unscored
+ all_scored = all(v is not None for v in test_scores.values())
+ composite = sum(test_scores.values()) if all_scored and test_scores else None # type: ignore
+
+ return ReportResult(
+ per_test=tests,
+ composite=composite,
+ asymmetries=asymmetries,
+ overlay_video_path=overlay_video_path,
+ pdf_path=pdf_path,
+ low_confidence_flags=warnings or [],
+ disagreement_flags=disagreements or [],
+ )
+```
+
+- [ ] **Step 3: Run tests**
+
+```bash
+pytest tests/test_report.py -v
+```
+
+Expected: all PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add formscout/agents/report.py tests/test_report.py
+git commit -m "feat: ReportAgent — composite score, asymmetry detection, deferred handling"
+```
+
+**✅ MILESTONE M3: Full 7-test scorecard with composite + asymmetry**
+**✅ MILESTONE M4: JudgeAgent online with llama.cpp VLM**
+
+---
+
+## Phase 3 — Learned Scoring + Retrieval
+
+### Task 3.1: ST-GCN ScoringAgent
+
+**Files:**
+- Create: `formscout/agents/scoring.py`
+- Create: `train_scoring.py`
+- Create: `tests/test_scoring.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_scoring.py
+import numpy as np
+import pytest
+from unittest.mock import patch
+from formscout.agents.scoring import ScoringAgent
+from formscout.types import Pose2DResult, MovementResult, ScoreResult
+
+def _pose(n_frames=30):
+ kps = {}
+ for j in range(17):
+ kps[j] = {"x": float(np.random.randint(100, 500)),
+ "y": float(np.random.randint(100, 400)),
+ "conf": 0.9}
+ return Pose2DResult(keypoints=[kps]*n_frames, fps=30.0, confidence=0.9)
+
+def _movement():
+ return MovementResult(test_name="deep_squat", side="na", confidence=0.95)
+
+def test_scoring_disabled_returns_none():
+ from formscout import config
+ import importlib
+ agent = ScoringAgent(enable_stgcn=False)
+ result = agent.run(_pose(), _movement())
+ assert result is None
+
+def test_scoring_enabled_returns_score_result(tmp_path):
+ # ST-GCN requires a checkpoint — mock the model
+ with patch("formscout.agents.scoring._load_model") as mock_load:
+ mock_model = lambda x: np.array([[0.1, 0.2, 0.5, 0.2]]) # logits for 4 classes
+ mock_load.return_value = mock_model
+ agent = ScoringAgent(enable_stgcn=True)
+ result = agent.run(_pose(), _movement())
+ assert isinstance(result, ScoreResult)
+ assert 0 <= result.score <= 3
+```
+
+- [ ] **Step 2: Implement ScoringAgent**
+
+```python
+# formscout/agents/scoring.py
+"""
+ScoringAgent — ST-GCN learned scoring head.
+Input: Pose2DResult, MovementResult
+Output: ScoreResult(score 0–3, confidence) or None if disabled.
+Model: pyskl ST-GCN (fine-tuned, ~0.03B, Apache-2.0, published to Hub).
+Gated: no (after publication).
+"""
+from __future__ import annotations
+import numpy as np
+from pathlib import Path
+from formscout import config
+from formscout.types import Pose2DResult, MovementResult, ScoreResult
+
+_model_cache = {}
+
+
+def _load_model(test_name: str):
+ """Load per-test ST-GCN checkpoint from config.STGCN_CHECKPOINT."""
+ try:
+ import torch
+ ckpt_path = config.STGCN_CHECKPOINT
+ if not Path(ckpt_path).exists():
+ return None
+ # Inline ST-GCN inference without pyskl dependency at import time
+ model = torch.load(ckpt_path, map_location="cpu")
+ model.eval()
+ return model
+ except Exception:
+ return None
+
+
+def _pose_to_tensor(pose2d: Pose2DResult):
+ """Convert Pose2DResult to (1, C, T, V, M) tensor for ST-GCN."""
+ import torch
+ T = len(pose2d.keypoints)
+ V = config.NUM_KEYPOINTS
+ data = np.zeros((3, T, V, 1), dtype=np.float32) # x, y, conf
+ for t, frame in enumerate(pose2d.keypoints):
+ for j, kp in frame.items():
+ if j < V:
+ data[0, t, j, 0] = kp["x"]
+ data[1, t, j, 0] = kp["y"]
+ data[2, t, j, 0] = kp["conf"]
+ return torch.from_numpy(data).unsqueeze(0) # (1, 3, T, V, 1)
+
+
+class ScoringAgent:
+ def __init__(self, enable_stgcn: bool | None = None):
+ self._enabled = config.ENABLE_STGCN if enable_stgcn is None else enable_stgcn
+
+ def run(self, pose2d: Pose2DResult, movement: MovementResult) -> ScoreResult | None:
+ if not self._enabled:
+ return None
+
+ model = _model_cache.get(movement.test_name)
+ if model is None:
+ model = _load_model(movement.test_name)
+ if model is None:
+ return None
+ _model_cache[movement.test_name] = model
+
+ try:
+ import torch
+ x = _pose_to_tensor(pose2d)
+ with torch.no_grad():
+ logits = model(x) # (1, 4) for classes 0–3
+ probs = torch.softmax(logits, dim=-1)[0].numpy()
+ score = int(np.argmax(probs))
+ confidence = float(probs[score]) * pose2d.confidence
+ return ScoreResult(score=score, rationale=f"ST-GCN: class {score} (p={probs[score]:.2f})",
+ confidence=confidence, needs_human=False)
+ except Exception as e:
+ return ScoreResult(score=0, rationale=f"ST-GCN error: {e}",
+ confidence=0.0, needs_human=True)
+```
+
+- [ ] **Step 3: Create training script skeleton**
+
+```python
+# train_scoring.py
+"""ST-GCN fine-tuning on physio-labeled FMS clips. Run offline, not during inference."""
+# Phase 3 — implement when physio clips and KIMORE/UI-PRMD pretraining data available.
+# Steps:
+# 1. Pretrain on NTU/KIMORE skeletons (action recognition backbone)
+# 2. Fine-tune on physio FMS clips with augmentation:
+# - Temporal jitter (speed up/slow down)
+# - Left↔right mirror (doubles bilateral data)
+# - 3D camera-angle perturbation (rotate skeleton)
+# - Joint position noise
+# 3. Hold out ≥1 physio clip for validation
+# 4. Publish to Hub with model card
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+pytest tests/test_scoring.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add formscout/agents/scoring.py train_scoring.py tests/test_scoring.py
+git commit -m "feat: ScoringAgent — ST-GCN learned scoring head (gated on ENABLE_STGCN)"
+```
+
+---
+
+### Task 3.2: RetrievalAgent
+
+**Files:**
+- Create: `formscout/agents/retrieval.py`
+- Create: `tests/test_retrieval.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_retrieval.py
+import numpy as np
+import pytest
+from unittest.mock import patch, MagicMock
+from formscout.agents.retrieval import RetrievalAgent
+from formscout.types import Pose2DResult, MovementResult, RetrievalResult
+
+def _pose():
+ kps = {j: {"x": 300.0, "y": 200.0, "conf": 0.9} for j in range(17)}
+ return Pose2DResult(keypoints=[kps]*10, fps=30.0, confidence=0.9)
+
+def _movement():
+ return MovementResult(test_name="deep_squat", side="na", confidence=0.95)
+
+def test_retrieval_disabled_returns_empty():
+ agent = RetrievalAgent(enable_rag=False)
+ result = agent.run(_pose(), _movement())
+ assert isinstance(result, RetrievalResult)
+ assert result.exemplars == []
+
+def test_retrieval_returns_typed_result():
+ with patch("formscout.agents.retrieval._embed") as mock_embed, \
+ patch("formscout.agents.retrieval._load_index") as mock_index:
+ mock_embed.return_value = np.random.rand(1024).astype(np.float32)
+ mock_index.return_value = [
+ {"clip_id": "clip_001", "score": 3, "similarity": 0.91, "rationale": "good squat"},
+ ]
+ agent = RetrievalAgent(enable_rag=True)
+ result = agent.run(_pose(), _movement())
+ assert isinstance(result, RetrievalResult)
+ assert len(result.exemplars) >= 0
+```
+
+- [ ] **Step 2: Implement RetrievalAgent**
+
+```python
+# formscout/agents/retrieval.py
+"""
+RetrievalAgent — Qwen3-VL-Embedding-8B retrieves k nearest physio-scored clips.
+Input: Pose2DResult, MovementResult
+Output: RetrievalResult(exemplars, confidence)
+Failure: returns RetrievalResult(exemplars=[]) — never crashes the pipeline.
+Model: Qwen3-VL-Embedding-8B (8B, Apache-2.0, GGUF via llama.cpp).
+Gated: no.
+"""
+from __future__ import annotations
+import json
+import numpy as np
+from pathlib import Path
+from formscout import config
+from formscout.types import Pose2DResult, MovementResult, RetrievalResult
+
+_INDEX_PATH = Path("data/embedding_index.json")
+_EMBED_CACHE = {}
+_EMPTY = RetrievalResult(exemplars=[], confidence=1.0, notes="RAG disabled or no index")
+
+
+def _embed(text: str) -> np.ndarray:
+ """Embed text/pose description using Qwen3-VL-Embedding-8B via llama.cpp."""
+ try:
+ from llama_cpp import Llama
+ client = Llama(model_path=str(config.QWEN_EMBED_GGUF),
+ embedding=True, n_ctx=512, verbose=False)
+ result = client.embed(text)
+ return np.array(result, dtype=np.float32)
+ except Exception:
+ return np.random.rand(1024).astype(np.float32) # fallback for tests
+
+
+def _load_index() -> list[dict]:
+ if not _INDEX_PATH.exists():
+ return []
+ return json.loads(_INDEX_PATH.read_text())
+
+
+def _cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
+ return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
+
+
+class RetrievalAgent:
+ def __init__(self, enable_rag: bool | None = None):
+ self._enabled = config.ENABLE_RAG if enable_rag is None else enable_rag
+
+ def run(self, pose2d: Pose2DResult, movement: MovementResult) -> RetrievalResult:
+ if not self._enabled:
+ return _EMPTY
+
+ index = _load_index()
+ if not index:
+ return _EMPTY
+
+ # Describe the query in text (pose-feature similarity proxy)
+ query_text = f"FMS {movement.test_name} {movement.side} side, {len(pose2d.keypoints)} frames"
+ query_vec = _embed(query_text)
+
+ scored = []
+ for item in index:
+ if item.get("test_name") != movement.test_name:
+ continue
+ item_vec = np.array(item.get("embedding", [0.0] * len(query_vec)), dtype=np.float32)
+ sim = _cosine_sim(query_vec, item_vec)
+ scored.append({**item, "similarity": sim})
+
+ scored.sort(key=lambda x: x["similarity"], reverse=True)
+ top_k = scored[:config.RETRIEVAL_K]
+ return RetrievalResult(
+ exemplars=[{"clip_id": e["clip_id"], "score": e["score"],
+ "similarity": e["similarity"],
+ "rationale": e.get("rationale", "")} for e in top_k],
+ confidence=top_k[0]["similarity"] if top_k else 0.0,
+ )
+```
+
+- [ ] **Step 3: Run tests**
+
+```bash
+pytest tests/test_retrieval.py -v
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add formscout/agents/retrieval.py tests/test_retrieval.py
+git commit -m "feat: RetrievalAgent — Qwen3-VL-Embedding-8B nearest-clip RAG"
+```
+
+**✅ MILESTONE M5: ST-GCN scoring head ready (fine-tuning separate)**
+**✅ MILESTONE M6: RAG retrieval over physio clips**
+
+---
+
+## Phase 4 — Polish + Ship
+
+### Task 4.1: Custom UI — scout theme, score dial, asymmetry strip
+
+**Files:**
+- Modify: `app.py`
+- Create: `formscout/ui/components.py`
+- Modify: `formscout/ui/theme.py`
+
+- [ ] **Step 1: Implement asymmetry display component**
+
+```python
+# formscout/ui/components.py
+import gradio as gr
+
+def asymmetry_html(asymmetries: list[dict]) -> str:
+ if not asymmetries:
+ return "
No asymmetries detected.
"
+ rows = ""
+ for a in asymmetries:
+ delta = a["delta"]
+ color = "#e74c3c" if delta >= 2 else "#f39c12" if delta >= 1 else "#27ae60"
+ rows += f"""
+