By Dan · February 15, 2026
Under the Hood: How Run Plan Actually Builds Your Plan
This is the under-the-hood article: the dials, the numbers, and the actual formulas the engine evaluates when it says “Tuesday: 6×800m.”
Short article. Lots of code. No marketing.
The shape of a plan
Every plan is four phases plus race day:
BASE → SPEED → PEAK → TAPER → RACE 25% 35% 30% 10% ← half-marathon shape
A marathon tilts the same skeleton harder into PEAK — about 40% of the plan — because a marathon runs on race-specific volume. A 5K leans into SPEED instead. The percentages above are the half-marathon shape; the phases never change, only the weighting.
Each phase has a load multiplier on top of your baseline. Multiply the baseline by the multiplier → that’s the target weekly load for that phase.
| Phase | Multiplier | What changes |
|---|---|---|
| BASE | 1.0× | Aerobic foundation. Easy runs + one long run. |
| SPEED | 1.35× | Intervals appear. Threshold work appears. |
| PEAK | 1.7× | Race-specific. Highest combined volume + intensity. |
| TAPER | 1.19× → 0.85× | Volume drops. Intensity preserved. |
Got extra weeks? They go to PEAK. That’s the phase where the biggest adaptations happen.
Your baseline load
You pick a level at plan creation. The engine reads off a table:
| Level | Weekly load (units) |
|---|---|
| Beginner | 4,500 |
| Intermediate | 8,000 |
| Advanced | 14,000 |
“Load units” are arbitrary internal numbers — what matters is the ratios. An intermediate runner does ~1.8× the weekly work of a beginner. An advanced runner ~3.1×.
Quick worked example. Intermediate runner, 16-week half-marathon plan:
BASE weeks (4): 8,000 × 1.0 = 8,000 units/wk SPEED weeks (5): 8,000 × 1.35 = 10,800 units/wk PEAK weeks (5): 8,000 × 1.7 = 13,600 units/wk TAPER weeks (2): ramp down from ~9,500 → 6,800, race week last
Within each phase, weekly load also climbs ~15-26% across the phase weeks. Not flat. Wave shape. (Then recovery weeks notch it back down — more on that in a sec.)
Phase transitions don’t slam
Old version of the engine did this:
Week 4 (last BASE): 8,000 units ← end of BASE Week 5 (first SPEED): 10,800 units ← +35% overnight. ouch.
That’s a step change of 35% in a single week. Real bodies don’t love that. We watched a couple of runners injure themselves at phase transitions in v1.6.
Now we ramp:
// Smooth phase transitions
let multiplierTarget = phase.loadMultiplier // e.g. 1.35 for SPEED
let weekInPhase = currentWeek - phaseStart
let ramp: Double
switch weekInPhase {
case 0: ramp = 0.5 // 50% of the increase in week 1
case 1: ramp = 0.75 // 75% in week 2
default: ramp = 1.0 // full multiplier from week 3 onward
}
let effectiveMultiplier = previousPhase.multiplier
+ (multiplierTarget - previousPhase.multiplier) * rampWeek 1 of a new phase: half the bump. Week 2: three-quarters. Week 3 onward: full.
Same total work over the phase. Way less injury risk.
Recovery weeks aren’t a chore
Every 3rd week of a build phase — BASE included — the engine drops the load by 15% (25% on Pro plans). Same training. Less stress. Body catches up.
let isRecoveryWeek = weekInPhase % 3 == 2 // weeks 3, 6, 9 of a phase
&& phaseDuration >= 4 // short phases skip it
let weeklyLoad = baseload
* effectiveMultiplier
* (isRecoveryWeek ? 0.85 : 1.0) // 0.75 on Pro plansBuild two weeks, absorb on the third. Repeat. Adaptation happens during the recovery, not during the load. (Say it twice. It’s counterintuitive.)
Plus a phase-end deload: when you cross 80% through any phase, the engine notches load back another ~25% for the closing week. Lets you transition into the next phase fresh.
How a single workout gets picked
OK this is the fun part.
Given a target load + target duration for the week, plus a phase + your level, the engine scores every workout in the catalog against the slot and picks the highest score. Roughly:
func score(workout: Workout, slot: WeeklySlot) -> Double {
var score = 100.0
// Closer to the target load = higher score
let loadDiff = abs(workout.load - slot.targetLoad) / slot.targetLoad
score -= loadDiff * 50
// Closer to the target duration = higher score
let durationDiff = abs(workout.duration - slot.targetDuration) / slot.targetDuration
score -= durationDiff * 30
// Wrong phase? big penalty
if !workout.allowedPhases.contains(slot.phase) {
score -= 1000 // effectively excluded
}
// Used in the previous week? penalty (variety)
if slot.previousWeekIncluded(workout) {
score -= 100
}
// Beginner + Zone 5? excluded entirely
if slot.level == .beginner && workout.peakZone == .z5 {
score -= 1000
}
return score
}Pick the highest scorer. Move to the next slot. Repeat.
Almost everything interesting about the engine is in those weights. Tuning the duplicate penalty too high → boring plans full of variety the runner doesn’t want. Too low → same workout three weeks in a row. (We started at -100. Tried -200. Reverted. Landed at -100.)
The surprise week
Monotony is the enemy. Every plan has 1-4 surprise weeks scheduled into SPEED/PEAK:
- 5K plan: 1 surprise week.
- 10K plan: 2.
- Half marathon: 3.
- Marathon: 4.
What happens: a scheduled threshold run gets replaced with a progression run. Slightly easier. Different shape. The brain notices. The body gets a tiny break. Adherence goes up.
if plan.surpriseWeeks.contains(currentWeek) {
// Swap one threshold workout for a progression run
if let thresholdIndex = weekWorkouts.firstIndex(where: { $0.type == .threshold }) {
weekWorkouts[thresholdIndex] = pickProgression(for: slot)
}
}Not random. Strategically placed during the high-stress phases where burnout risk is highest.
What changes by level
The engine doesn’t just rescale the load. Three things change with level:
Beginner — Zone 5 workouts excluded entirely (too intense, injury risk). Recovery intervals are longer (~75s vs. ~45s for intermediate). Gentler week-to-week progression (18-26% across a phase vs. up to 26% for advanced). Long runs progress from 60min → 120min over the plan.
Intermediate — full intensity spectrum. Standard recovery intervals. Mix of progression runs and easy runs as filler.
Advanced — full spectrum. Shorter recovery intervals (the engine wants you to build resilience under fatigue). Multiple quality sessions per week. More aggressive phase ramps.
How the code actually got written
We didn’t write the engine alone. The first year was a lot of copy-pasting between ChatGPT and Claude chat windows — long context, “here’s the engine, what’s wrong with this scoring loop?” sessions. Then Claude Code arrived and became the CLI workflow. Briefly tried Antigravity (rough). A few months on Codex which was genuinely great. Currently back on Claude Code most days.
Most of the engine refactors in this article — phase transitions, recovery cadence, the surprise-week mechanic — came out of “OK Claude, think out loud with me about why this transition is jagged” sessions. Three approaches proposed, one picked, push back when the proposal was wrong (it often was). It feels like pairing with a colleague who has read every Swift blog post on the internet and forgotten which one said what. Net very positive, but it took a year of practice to know when to trust which output.
A worked plan
Concrete: 16-week intermediate half-marathon plan, top to bottom.
Phase split: BASE 4 → SPEED 5 → PEAK 5 → TAPER 2 Base load: 8,000 units/wk Peak load: 13,600 units/wk (week 13) Recovery wks: 3, 7, 12 — plus a deload closing each phase Long runs: 12 of them, 60min → 110min progressive Quality: 24 interval/threshold sessions total Easy/recovery: 28 easy runs as filler Catalog draw: ~50 distinct workouts (no repeats inside ~3 weeks)
That’s everything the engine knows about you: race distance, weeks-to-race, level, training days per week, HR vs pace mode. From those five inputs the engine produces a calendar with specific workouts on specific days, deterministically. Same inputs tomorrow = same plan.
(That last sentence is the one most other apps cannot make.)
What’s next
Things on the engine roadmap:
- Adaptive load. Right now the engine uses your initial level forever. We’re building a Sunday-evening review that reads HealthKit completion and bumps next week up or down. Local. Nothing leaves the device.
- Time trials. Periodic 3K/5K all-out efforts that auto-recalibrate your pace zones mid-plan.
- Readiness signal. HRV + resting HR + sleep → traffic light. Green push. Yellow ease. Red maybe swap quality for easy.
Each of these is a few weeks of evening work + a CLI audit pass. They’ll get their own articles when they ship.
Run Plan is an indie iOS + Apple Watch training planner built by a 2-person team in Amsterdam. No accounts, no ads, no subscription. Your data stays on your device.