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

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project overview

FormScout is a Gradio app (Hugging Face Space) that scores Functional Movement Screen (FMS) videos 0–3 per test with a written rationale and an annotated overlay. It is a **screening aid** β€” not a diagnosis, not an injury predictor. Built for the Build Small Hackathon (Backyard AI track). Full product spec is in `docs/FormScout-FMS-Spec.md`; the engineering contract is in `docs/plans/FormScout-Build-Prompt.md`.

**Current status:** Phase 2 complete. All 7 FMS test rubric scorers, JudgeAgent, MovementClassifierAgent, ReportAgent, PoseVisualizer (overlay video), and a user-selectable pose-model registry are implemented and tested (86/87 passing). Phase 3 is next (ST-GCN fine-tune + RAG retrieval).

## Common commands

```bash
# Run the Gradio app locally
python3 app.py

# Headless pipeline test (no Gradio)
python3 -m formscout.run sample.mp4

# Run all tests
pytest tests/

# Run a single test file or test
pytest tests/test_phase2.py
pytest tests/test_biomechanics.py::TestBiomechanicsAgent::test_deep_squat_score

# Lint / format
ruff check . && ruff format .

# Start the local VLM judge server (llama.cpp, port 8080)
./scripts/serve_judge.sh

# Push source tree to the HF model repo + Space (PRs; message from last commit)
./scripts/hf_upload.sh

# Run Svelte component tests (when frontend work is added)
npx vitest run
```

## Architecture

The pipeline is a sequence of **typed specialist agents**. Each agent accepts and returns a frozen dataclass from `formscout/types.py`. The Director in `formscout/pipeline.py` orchestrates them as a deterministic state machine (not an LLM).

### Agent pipeline

```
IngestAgent β†’ Pose2DAgent β†’ [Body3DAgent β€” optional]
β†’ MovementClassifierAgent β†’ BiomechanicsAgent
β†’ rubric/score_test() β†’ JudgeAgent β†’ ReportAgent
```

The **Director** (`pipeline.py`) owns the flow. `app.py` creates one `Director()` instance and calls `director.run(video_path, test_name, side, model_key)` per submission. The Gradio UI passes `test_name` directly (from dropdown), bypassing the classifier; `model_key` selects the pose backend from `config.POSE_MODELS`.

`PoseVisualizer` (`formscout/agents/visualizer.py`) renders the annotated overlay video (skeleton, trails, velocity arrows) from `IngestResult` + `Pose2DResult`. It is called from `app.py` after the pipeline run β€” it is a UI-layer component, not a Director stage. It returns `None` on failure, never raises.

### The tiering rule (most important invariant)

**The 2D path is the default and must stand alone as a complete, functional pipeline.** `Body3DAgent` is only activated when `config.ENABLE_3D == True` AND the checkpoint loads successfully. If 3D is off or fails, `Body3DResult(used=False, ...)` is returned β€” this is a normal success path, not an error. `BiomechFeatures.view` is `"2d"` or `"3d"` so the `JudgeAgent` can caveat its rationale appropriately. Never put `Body3DAgent` on the critical path.

### Feature flags in `config.py` and their current state

| Flag | Default | Meaning |
|------|---------|---------|
| `ENABLE_JUDGE` | `True` | Judge/Classifier call Qwen3-VL via llama-server; graceful rubric fallback when the server is down |
| `ENABLE_3D` | `False` | When False, Body3DAgent returns `used=False` immediately |
| `ENABLE_STGCN` | `False` | Phase 3 β€” ST-GCN learned scoring head |
| `ENABLE_RAG` | `False` | Phase 3 β€” RetrievalAgent exemplar lookup |

All model IDs, thresholds, k-values, and feature flags live in `config.py` β€” never scattered literals.

### Judge backend selection (local vs Space)

`config.resolve_judge_backend()` picks the VLM backend via `FORMSCOUT_JUDGE_BACKEND` (`llama_cpp` | `transformers` | `auto`). `auto` (default) uses **llama-server locally** and the **in-process transformers backend on a Space** (detected via `SPACE_ID`). `JudgeAgent` gets its client from `serving.get_vlm_client()`.

- **`llama_cpp`** β€” `LlamaCppClient` β†’ llama-server at `127.0.0.1:8080` (start with `scripts/serve_judge.sh`). The local path; works perfectly.
- **`transformers`** β€” `TransformersVLMClient` loads Qwen3-VL-8B via transformers, GPU-wrapped with `spaces.GPU` (ZeroGPU). Lazy model load, cached per process. On any load/inference failure it returns `{"fallback": True}` and the Judge falls back to the rubric. **Needs validation on real ZeroGPU hardware** β€” not exercised in CPU tests.

### Fallback chain (important for local dev and Spaces)

1. `ENABLE_JUDGE=False` β†’ JudgeAgent returns rubric score wrapped as JudgeResult (no VLM needed)
2. `ENABLE_JUDGE=True` + selected backend unavailable / transformers load fails β†’ same rubric fallback, logs a warning
3. `ENABLE_JUDGE=True` + backend available β†’ calls Qwen3-VL-8B-Instruct (llama-server locally, transformers/ZeroGPU on a Space)

Start the VLM server with `scripts/serve_judge.sh` (downloads live in `checkpoints/qwen3-vl/`, gitignored). To use a fine-tuned GGUF, set `FORMSCOUT_JUDGE_GGUF` (and `FORMSCOUT_JUDGE_MMPROJ` if it ships its own projector) β€” no code change needed. Multimodal requests go through the OpenAI-compatible `/v1/chat/completions` endpoint (the legacy `/completion` + `image_data` path does not work with modern llama-server).

This means the app is **fully functional without any GPU or llama.cpp** β€” rubric scoring is pure Python.

### Rubric scorers

Each FMS test has a pure-function scorer in `formscout/rubric/`:

```
score_deep_squat / score_hurdle_step / score_inline_lunge /
score_shoulder_mobility / score_active_slr /
score_trunk_stability_pushup / score_rotary_stability
```

All accept `BiomechFeatures` and return `ScoreResult`. Dispatch via `rubric.score_test(features)`. **Rubric functions must remain pure** β€” no model calls, no I/O.

### Bilateral tests

`hurdle_step`, `inline_lunge`, `shoulder_mobility`, `active_slr` are bilateral. `ReportAgent` groups them by test name, takes the **lower** score, and always emits the asymmetry delta even when scores are equal. `composite` is `None` when any test is unscored.

### Types contract

Every agent I/O is a frozen dataclass from `formscout/types.py`. Key types:

- `IngestResult` β€” decoded frames (np.ndarray list), fps, duration, dimensions
- `Pose2DResult` β€” per-frame keypoints as `dict[int, {x, y, conf}]` (COCO 17 joints)
- `Body3DResult` β€” optional 3D joints, always has `used: bool`
- `MovementResult` β€” `test_name` (validated enum), `side` ("left"|"right"|"na")
- `BiomechFeatures` β€” `angles: dict`, `alignments: dict`, `view: "2d"|"3d"`, `symmetry_delta`
- `ScoreResult` β€” `score: int` (0–3), `rationale`, `needs_human`
- `JudgeResult` β€” same as ScoreResult + `compensation_tags`, `corrective_hint`; `score=None` when `needs_human=True`
- `PipelineState` β€” mutable accumulator threaded through the Director

`MovementResult` and `JudgeResult` validate their fields in `__post_init__` β€” passing invalid values raises immediately.

### Pose model selection and checkpoints

`config.POSE_MODELS` is a registry of pose backends: MediaPipe (CPU-friendly), five YOLO26 sizes (n/s/m/l/x), and Sapiens2 variants (Phase 3, need the custom `sapiens` repo installed). `config.DEFAULT_POSE_MODEL` is YOLO26n. The Gradio UI exposes a dropdown built from `config.available_pose_models()` (filters to checkpoints actually present) and passes the chosen `model_key` through `Director.run` to `Pose2DAgent`. `config.YOLO_POSE_MODEL` is a backward-compat alias only.

Checkpoints are **not** committed (`checkpoints/` is gitignored). `formscout/startup.py:ensure_checkpoints()` downloads missing YOLO26/MediaPipe files from the `silas-therapy/formscout-checkpoints` HF repo once at app startup. Models load once per process and are cached β€” never inside the inference hot path.

### llama.cpp serving

`formscout/serving/llama_cpp.py` provides `LlamaCppClient` (VLM, port 8080) and `EmbeddingClient` (embeddings, port 8081). Both check `/health` before use and return safe error dicts when unavailable. Only active when the corresponding `ENABLE_*` flag is True.

### Deploying to Hugging Face

The repo deploys to both `silas-therapy/small-functional-movement-screening` (model repo) and the Space of the same name (README frontmatter is the Space config). Use `./scripts/hf_upload.sh` β€” never raw `hf upload .`: the `hf` CLI does **not** read `.hfignore`, so a raw upload hashes the entire `.venv` (~44k files) and pushes torch binaries. The script parses `.hfignore` into `--exclude` globs, preflights the file count, creates PRs on both repos, and auto-switches to `hf upload-large-folder` (resumable, but no PR / no commit message) above 500 files.

## Key constraints and invariants

- **No cloud model APIs.** All inference runs on-Space (ZeroGPU). No OpenAI/Anthropic/Gemini calls.
- **Pain is never auto-scored.** Any clearing test or visible distress sets `needs_human=True` β€” enforced in rubric functions and JudgeAgent. `JudgeResult.score` must be `None` when `needs_human=True`.
- **Quality gates (Director, never silently skip):**
  - Any agent `confidence < config.MIN_CONFIDENCE` (0.6) β†’ warn or stop
  - `|rubric.score - judge.score| >= 1` β†’ flag disagreement
  - `MovementResult.test_name == "unknown"` β†’ stop pipeline, surface manual override
  - `JudgeAgent.needs_human == True` β†’ no numeric score emitted
- **Composite is null** when any test is unscored. Never show a partial 0–21 as complete.
- **Pipeline runs headless.** No Gradio imports in any agent file.
- **Safety banner** ("Screening aid β€” not a diagnosis…") must always be visible in the UI β€” appears at top and bottom of `app.py`.

## Engineering standards

- Every agent: one public entrypoint, typed dataclass I/O from `types.py`, `confidence: float` and `notes: str` on every result.
- Models load once at module/instance init β€” never inside the inference hot path.
- Every agent module docstring states: purpose, inputs, outputs, failure behavior, model param count, license, and gated status.
- `tracing.py` records structured per-agent I/O for any run; one full run gets exported to the Hub.
- Every agent ships with a pytest in `tests/` that runs without model downloads and asserts the typed contract.

## Model stack (~17.6B total β€” stay under 32B)

| Component | Model | Params | Status |
|---|---|---|---|
| 2D pose (primary) | YOLO26-Pose n/s/m/l/x (default: n) | 0.0007–0.058B | Ready (auto-downloaded at startup) |
| 2D pose (CPU alt) | MediaPipe Pose Landmarker (full) | ~0.004B | Ready (auto-downloaded at startup) |
| 2D pose (HQ alt) | `facebook/sapiens2-pose-0.4b/0.8b/1b/5b` | 0.4–5B | Phase 3 β€” needs custom `sapiens` repo |
| Segmentation | SAM 3.1 base | ~0.85B | Access accepted |
| 3D biomechanics | `facebook/sam-3d-body-dinov3` | ~0.84B | **Access ACCEPTED Jun 4 2026** |
| Learned scoring | ST-GCN (pyskl) | ~0.03B | Phase 3 |
| Judge + Classifier | Qwen3-VL-8B-Instruct (llama.cpp) | 8B | **Online** β€” `scripts/serve_judge.sh`, ENABLE_JUDGE=True |
| Retrieval | Qwen3-VL-Embedding-8B (llama.cpp) | 8B | Phase 3 |

Track the running sum in `MODEL_BUDGET.md`. The two Qwen3-VL-8B models share a backbone.

## Gradio + Svelte UI guidance

The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py`). Custom Svelte components for score dial, asymmetry bars, rubric drawer are planned for Phase 4. Use `gradio-svelte-expert` agent for Svelte component work.

- ZeroGPU: `app.py`'s `process_video` (the Start Analysis handler) is decorated with `@spaces.GPU` (via the `gpu_task` shim, no-op off-Space) so one GPU window wraps the whole pipeline β€” pose, optional 3D, and the judge. **ZeroGPU aborts startup with "No @spaces.GPU function detected" unless a decorated function exists at import time**, so the decorator must stay at module level on a top-level function, not buried behind a lazy import. Window length is `config.ZEROGPU_DURATION` (default 120s, `FORMSCOUT_ZEROGPU_DURATION`).
- Verify Gradio APIs against current docs before use β€” pin exact versions in `requirements.txt`.

## Build phases

1. **Phase 0 β€” Recon:** βœ… Complete. See `RECON.md`.
2. **Phase 1 β€” Spine:** βœ… Complete. Deep Squat end-to-end.
3. **Phase 2 β€” All 7 tests:** βœ… Complete. Classifier, Judge, Report agents; all rubric scorers; Gradio UI.
4. **Phase 3 β€” Learned scoring + retrieval:** ST-GCN fine-tune on physio clips, publish to Hub. RetrievalAgent with embedding index.
5. **Phase 4 β€” Polish + ship:** Custom Svelte UI components, agent trace to Hub, blog post. (Overlay video done via `PoseVisualizer`; full 7-test session + PDF export done via `formscout/session.py` + `PdfReportAgent`.)

## Known issues

- `tests/test_biomechanics.py::TestBiomechanicsAgent::test_unimplemented_test_returns_low_confidence` fails: expects `"not yet implemented"` in `result.notes` but biomechanics returns empty string. Minor β€” low priority.

## Badge checklist (definition of done)

- [ ] Space runs green; upload β†’ scorecard works on real clips
- [ ] Param sum verified ≀ 32B in `MODEL_BUDGET.md`
- [ ] πŸ”Œ **Off the Grid** β€” no cloud model APIs anywhere in the pipeline
- [ ] 🎯 **Well-Tuned** β€” fine-tuned ST-GCN head published to Hub with honest model card
- [ ] 🎨 **Off-Brand** β€” custom, non-default Gradio UI (scout/trail theme)
- [ ] πŸ¦™ **Llama Champion** β€” VLM + embedder served via llama.cpp (GGUF)
- [ ] πŸ“‘ **Sharing is Caring** β€” one full agent trace (all I/O) published to Hub
- [ ] πŸ““ **Field Notes** β€” blog post written, honesty section (FMS limitations) front-and-center
- [ ] Demo video + social post recorded
- [ ] Safety banner present; pain/clearing never auto-scored; low-confidence flagged