OCR, retrieval, a code verifier, and a LoRA-distilled Qwen2.5-3B

Guardrails and distillation, benchmarked

A pacing plan that has to pass six checks in code before you see it

Photograph a race course map and state a goal. Local OCR and Claude turn the photo into a course profile, retrieval adds pacing research from 14 knowledge cards, and Claude writes a km-by-km plan. verifier.py then checks the plan for coverage, split arithmetic, hill plausibility, fade, heat and citations, and any failure goes back to the model as a plain-English sentence. I also distilled the task into a 4-bit Qwen2.5-3B with LoRA, trained on a laptop from 1,321 verifier-passed teacher plans. On 70 held-out scenarios the student formats almost every plan correctly and then gets the arithmetic wrong.

75.7%

of the Claude teacher's zero-shot plans pass every check on the 70 held-out scenarios.

55.7% to 95.7%

Parse success for Qwen2.5-3B before and after LoRA fine-tuning.

0.0%

Full verifier pass rate for the LoRA student: 82.9% of its plans miss the 90-second sum tolerance.

96.0%

of teacher generations accepted while building the training set: 1,458 of 1,519 attempts.

At a glance

Task
Write a km-by-km pacing plan for a course and goal that passes six checks in code
Eval set
70 held-out scenarios, split by scenario id (seed 42, 90/5/5) with a leakage check; every system ran on all 70
Baseline
Claude teacher, zero-shot: 75.7% of plans pass every check. Base Qwen2.5-3B: 55.7% parse, 0.0% pass
Result
LoRA student: 95.7% parse, 0.0% pass every check. A blind judge preferred the teacher on 56 of 60 pairs, with 4 ties
Cost per plan
$0 per call on the laptop; the Claude teacher $0.117 API-equivalent ($117.47 per 1,000), from 20 held-out scenarios rerun on 2026-11-05 with the benchmark's prompt and CLI flags (eval/claude_cost/)
Latency
Means only: 53.7 s per plan for the student (model reloaded every call), 157.7 s for the teacher. Per-plan times were not saved, so there is no median or p95
Main failure
Arithmetic: 82.9% of student plans miss the 90-second sum tolerance and 91.4% fail the fade check

The pipeline

Photo to verified plan

  1. Read the map. src/vision.py runs Tesseract OCR on the photo, then claude -p parses the text into a CourseProfile: distance, hill segments with trend and severity, and a confidence. A photo that isn't a course map gets a refusal.
  2. Retrieve. 14 knowledge cards on pacing research (negative-split rarity, hill effort, the heat tax, the wall) plus findings digests, embedded locally with sentence-transformers.
  3. Pack the prompt. A priority-based ContextBudgeter fits the course, the runner brief and the retrieved cards into a token budget and reports each section's share. Teacher and student get the same assembled prompt.
  4. Generate. Claude, or the local LoRA model, writes a structured PacingPlan: per-km target paces, predicted finish, fade allowance, heat adjustment, cautions, citations.
  5. Verify and retry. verify() returns a list of violation sentences. An empty list passes. Otherwise the sentences go into the next prompt, up to two retries.

The verifier

Six rules, with the tolerances in the code

01 Coverage
One split per km, plus the partial final km for distances like 21.1 or 42.2.
02 Sum
Splits add up to the goal within 90 seconds, or to the plan's own predicted finish if it flags the goal as unrealistic.
03 Plausibility
No split more than 12% slower than the mean pace, or 20% on a steep climb; on a downhill no split more than 20% faster.
04 Fade
Second half 0 to 8% slower than the first. A negative split is allowed only with a caution citing the 2.5% base rate. The plan's fade_allowance_pct must match its own splits within 0.5 points.
05 Heat
At 18°C or above, the per-km heat adjustment must fall between 0.75 and 4.5 seconds per degree over 15°C, a band derived from my Boston heat-tax finding of about 1 minute per °F.
06 Citations
At least one citation, and only ids that were retrieved for that prompt.

Earlier descriptions of this project counted five checks. The citation rule lives in the same function and is scored in the benchmark, so it is listed here as the sixth.

The benchmark

Base against LoRA against teacher

All three systems ran zero-shot, with no retries and identical prompts, on 70 held-out scenarios split by scenario id (seed 42, 90/5/5). The numbers come from eval/benchmark.md.

SystemParse successVerifier passLatency, s/plan
Qwen2.5-3B base55.7%0.0%17.5
Qwen2.5-3B + LoRA95.7%0.0%53.7
Claude teacher90.0%75.7%157.7

The pass rate alone hides where the student fails.

The LoRA student fails on arithmetic: sum 82.9%, fade 91.4%

Share of the 70 held-out plans failing each check. Bars show the selected system; the tick is the Claude teacher.

From eval/benchmark_raw.json (violation_rates). Base plans fail at parse or coverage, so the verifier returns early and the later checks never run on them; a zero there means "not reached".

The LoRA student is close to the teacher on structure, coverage, hill plausibility, heat and citations; where it falls down is the two checks that need exact arithmetic across 42 numbers. A typical student plan misses the goal by 430 to 730 seconds, and its fade_allowance_pct often disagrees with its own splits in sign, for example reporting 4.5 while the splits compute −5.1%. In a blind, position-swapped LLM-judge comparison on 60 pairs, the teacher was preferred 93.3% of the time, the student never, with 6.7% ties.

Two plans, one scenario

Where the student's arithmetic goes wrong

The archived outputs keep each plan's cumulative time per km, so the teacher's and the student's plans for the same held-out scenario can be laid over each other. A plan on target reaches the goal time at the finish. Pick a scenario; the default is one where the student is far off.

Cumulative time by km, teacher against student

The goal is the time named in the teacher's strategy summary. Lines stop where the archive was cut at 4,000 characters, not where the plans ended.

Parsed from eval/raw_outputs_teacher.json and eval/raw_outputs_lora.json (the cumulative field of each split) for the 31 held-out scenarios where the teacher's summary names a goal and both archives keep at least 20 splits. Judge verdicts from eval/judge_outputs.json.

The fine-tune

Four training attempts on a 16 GB Mac

Training data: 1,458 accepted scenarios across 6 course profiles, 14 goal times, 6 temperatures and 3 experience levels, split into 1,321 train, 67 validation and 70 test, with a leakage check. Training used mlx_lm LoRA on mlx-community/Qwen2.5-3B-Instruct-4bit, 16 layers, batch size 1, gradient accumulation 4, learning rate 5e-5, gradient checkpointing.

Sequence length
The planned 1,536 tokens was too short. The longest training example measured 4,197 tokens with the Qwen tokenizer, so the budget became 4,608, asserted before training.
Silent failure
Without gradient checkpointing, memory overflowed and training printed "Train loss 0.000, Trained Tokens 0" every report instead of crashing. A 10-iteration repro confirmed it; checkpointing brought peak memory to about 13 GB.
Divergence
Loss fell from 1.666 to 0.256 over 280 iterations, then climbed to 1.651 within three reports. Halving the learning rate and adding accumulation delayed it but didn't stop it.
Clipping
train_clipped.py reuses mlx_lm.lora's loop and adds clip_grad_norm, since the CLI has no clipping flag. It diverged again; gradient norms stayed at 0.18 to 0.35, under the 1.0 threshold, so clipping never engaged.
Decision
Ship the best checkpoint, about 400 clean iterations at validation loss 0.226, and leave the next hypothesis (a constant learning rate with no decay) for later.
Training and validation loss for the published LoRA model over 400 iterations; validation loss falls from about 1.67 to about 0.23 by iteration 100 and stays flat.
Loss for the published checkpoint. The divergence runs are in eval/loss_curve_divergence_incident.png and training/*.log.

Limitations

What this doesn't show

The student model is not usable on its own yet: no held-out plan passed every check. It would need either a decaying learning-rate schedule and more training, or the arithmetic moved out of the model, for example by having it emit relative effort and computing the splits in code.

The 70 test scenarios come from the same six synthetic course profiles as the training data, so the benchmark doesn't test unseen course shapes or photographed maps. The LLM judge is Claude, which is also the teacher. And the archived raw outputs in eval/raw_outputs_*.json were truncated at 4,000 characters when saved; scoring ran on the full text before truncation, so the metrics stand, but the archive is incomplete.

Plans are pacing guidance for adult runners, not medical advice. The app runs on your own machine; there is no hosted demo.

Stack

What it runs on

Vision
Tesseract OCR plus claude -p for structured parsing; no vision API key
Retrieval
sentence-transformers embeddings, priority-based context budgeting
Generation
Claude as teacher; Qwen2.5-3B-Instruct 4-bit with a LoRA adapter as student
Training
MLX (mlx_lm) on Apple silicon, custom clipped training loop
Guardrails
Pydantic schemas, verifier.py, bounded retry loop with violation feedback
Interface
Gradio app: photo, brief, plan, verification, text-to-speech
Tests
tests/test_verifier.py

The student learned the format and not the arithmetic