I stopped letting the LLM pick sets, reps and rest. A 240 line pure function does it now.
We build an AI fitness coach. For a long time the workout generator was what most of them still are: a big prompt, a JSON schema, and a hope that the model had read enough training literature.
It produced valid JSON every time. It also produced 3 sets of 5 on a leg press for a beginner who had told us she had a knee problem, and 31 weekly sets of chest for someone whose goal was general fitness. Schema valid. Structurally fine. Bad programming.
The fix was to stop asking the model for numbers at all.
### What the model is allowed to decide now
Before any prompt is rendered, a pure function computes a `TrainingPrescription` from the user's goal, experience level, weekly frequency and health screening. The model receives that prescription as a constraint and picks exercises inside it. It never picks the numbers.
The prescription is not a vibe. It is this:
```
rep ranges compound and accessory, per training intent
RIR target beginner 3-4, intermediate 2-3, advanced 1-3
rest strength 150-300s, hypertrophy compound 90-180s,
isolation 60-120s, metabolic 45-90s
weekly sets beginner 10-12, intermediate 12-16, advanced 14-20 per muscle
progression linear / double progression / autoregulation by level
deload every 6 weeks, every 4 in medically flagged mode
```
Frequency nudges the envelope rather than replacing it. Training 5 or more days a week raises the weekly set ceiling by 2, capped at 22. Training 2 days or fewer lowers it by 2. The floor never drops below 6 sets per muscle no matter what else is applied, because below that you are not training the muscle, you are visiting it.
Goal maps to a training intent, and intent is what actually drives the numbers:
```
strength_power -> STRENGTH, and reps scale with experience:
beginner 5-8, intermediate 3-6, advanced 2-5
toning -> HYPERTROPHY_ISOLATION, 10-15 compound, 12-20 accessory
lose_weight -> METABOLIC, 10-15 reps, 45-90s rest
build_muscle -> HYPERTROPHY_COMPOUND, 6-10 compound, 8-15 accessory
```
"Toning" mapping to isolation hypertrophy is a product decision, not a physiological claim. Users ask for toning and mean something real, and the honest translation of it is higher rep isolation work on top of the same compounds everyone else gets.
### Two adjustments that are code, not prompt
**Low readiness.** When someone reports being sick or under recovered, weekly volume is multiplied by 0.7 and the RIR floor is raised to at least 2-3. That is a 30% volume cut computed in code. A model asked politely to "reduce volume a bit" reduces it a bit differently every time.
**Health screening.** If the PAR-Q flags anything and the user acknowledges the medical disclaimer, generation continues in safe mode: progression is forced to linear, volume is scaled to 80%, deload cadence drops from 6 weeks to 4, and RIR is raised to at least 2-3. A cardiac flag additionally raises RIR to 3-4 and downgrades a strength intent to hypertrophy with a rep floor of 8-12. A joint flag raises RIR to 3-4 and tags the plan so exercise selection avoids heavy axial loading and impact.
If the PAR-Q is flagged and the user has **not** acknowledged the disclaimer, generation is blocked entirely. Not degraded. Blocked.
### The part I would tell my past self
The thing that made this work was not a better prompt. It was accepting that the LLM is good at one job here, choosing sensible exercises for a given slot given a catalog and a set of constraints, and bad at another, holding a numeric policy consistent across seven days and fourteen muscle groups.
So we gave it the first job and took away the second. Everything numeric is a pure, unit tested function with no framework dependencies, which means the entire training policy can be tested without a database, a network call or a model.
Whatever you are building, the question worth asking is: which decisions in my pipeline have a correct answer that I could write down? Those should not be in the prompt.
---
*I build Vires, an AI training app. iOS is live, Android is in the pipeline. Happy to go deeper on any part of this in the comments, including the parts that still do not work.*