All posts

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.”

The engine is now open source. It lives in a Swift package called TrainingPlanKit — the same code the app ships, with the iOS and watch layers and the localization stripped off. Everything below is in there; the snippets are lightly trimmed for reading, and links point at the file they came from.

Short article. Lots of code. No marketing.

What “load” is

Everything below is denominated in load, so start there. Load is a single number that stands in for how much a workout costs you — roughly intensity times duration, the same idea as TSS in TrainingPeaks or Training Load in Garmin. Every workout in the catalog carries one, computed up front from its intervals:

public struct Workout {
    public let duration: Int64       // seconds
    public let trainingLoad: Int64   // the single effort number
    public let intervals: [WorkoutInterval]
    // ...
}

Why one number and not just minutes? Because minutes lie. An easy run and a hard interval session can take the same wall-clock time and cost the body completely different amounts. The catalog bears that out — here are three sessions of roughly equal length:

Easy Run                 45 min   load  3,238
Threshold (2 × 12min)    40 min   load  6,739
Time Trial (25min)       45 min   load 12,434

Same time on your feet, four times the cost. That is the whole point of a load metric: it lets the engine compare a sprint session to a long aerobic plod on one axis. When you read “target load 13,600” below, picture roughly a week’s worth of those numbers added up. The bars in the charts are load, not distance.

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.

PhaseMultiplierWhat changes
BASE1.0×Aerobic foundation. Easy runs + one long run.
SPEED1.35×Intervals appear. Threshold work appears.
PEAK1.7×Race-specific. Highest combined volume + intensity.
TAPER1.19× → 0.85×Volume drops. Intensity preserved.

Got extra weeks? They go to PEAK. That’s the phase where the biggest adaptations happen.

Bar chart of weekly load for an 18-week intermediate marathon plan: rising bars within each phase, deload dips, a lower restart at each phase entry, and a steep drop into taper and race week.
The shape all of this produces — per-week load for an 18-week intermediate marathon plan, straight from the CLI. Multipliers push each phase’s envelope up; deloads (▾) notch it back down; the entry ramp starts each phase below where the last one ended.

Your baseline load

You pick a level and a distance at plan creation. Every plan declares its own starting load in a config struct — there is no per-level switch in the generator any more, each plan just carries its own number with the level-and-distance shaping already folded in. Beginner sits flat at 4,500 across every distance; the fitter tiers vary by race:

Level5K10KHalfMarathon
Beginner4,5004,5004,5004,500
Intermediate9,20011,0408,00010,000
Advanced16,10019,32021,00017,500

“Load units” are arbitrary internal numbers — what matters is the ratios. An advanced runner does roughly 3.6× to 4.7× the weekly work of a beginner at the same distance. The numbers don’t climb monotonically with race length: an advanced half plan carries more load than an advanced marathon, because the half is run harder per minute and the marathon spends its volume on slower miles. Load is effort, not kilometres. And baseline means week one, not the plan: longer races get longer plans, and a longer ramp starts lower. The intermediate half (14 weeks) begins gentler than the intermediate 10K (9 weeks) — and peaks far above it. (The whole table is in the per-tier config files — one struct per plan, all the shaping visible in one place.)

Quick worked example. Intermediate runner, 16-week half-marathon plan, baseline 8,000:

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

That table is the phase skeleton. The real per-week number has one more term: within a phase, load also climbs across the weeks. It is not flat, it ramps.

How a week’s target gets built

One function, calculateWeeklyTargetsV3, produces the target load and target duration for every week. Stripped to its spine it is three multipliers:

load = baseLoad × phaseBoost × progressionFactor

//  baseLoad         your plan's starting number (table above)
//  phaseBoost       1.0 / 1.35 / 1.7 — which phase you're in
//  progressionFactor where you are *inside* that phase

The first two we have. The third is the wave. As you move through a phase, phaseProgression runs from 0 to 1, and the engine turns that into a small upward push:

let progressionFactor =
    1.0 + (phaseProgression
           × increasePercent / 100
           × progressionAmplifier)   // progressionAmplifier = 5.0

increasePercent is the plan’s declared week-to-week ramp — an intermediate half carries 17–26%, the engine uses the midpoint. The amplifier of 5 is the piece that makes it visible: a ~21% midpoint over a 5-week phase, spread linearly, would barely register week to week, so the factor stretches it into a real climb from the phase floor up to roughly +20% by the last full week. That is why each phase in the chart is a rising staircase, not a flat block.

Two clamps sit on top of all this, and they are the reason a faithful textbook number never quite lands raw. First, the phase-end deload: once you cross 80% of the way through a phase, the closing week is notched back ~25% so you enter the next phase fresh rather than maximally fatigued. Second, an ACWR cap — acute-to-chronic workload ratio, the injury-risk number the literature keeps pointing at. No single week may exceed 1.25× the largest of the last three weeks’ volume; if the formula asks for more, the week is scaled down to the cap and its load comes with it:

// cap volume at 1.25× the recent 3-week max, scaling load with the cut
let capDur = recentDur.max()! * 1.25
if rawTargets.duration > capDur {
    let scale = capDur / rawTargets.duration
    targets = WeeklyTargets(load: rawTargets.load * scale,
                            duration: capDur, ...)
}

The ramp wants to push; the cap refuses to let any one week spike more than a quarter above where you have actually been. A coach does this by feel. The engine does it with one comparison against a three-week rolling max.

Duration is computed the same way in the same breath — same phaseBoost × progressionFactor, applied to a starting weekly minutes figure. Load tells the engine how hard the week is; duration tells it how long. Both targets go to the workout picker together, which is where the two are reconciled into actual sessions.

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) * ramp

Week 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 plans

Build 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.

The long run is the plan’s single biggest stressor, so it gets its own handling. It climbs through the build, drops about a fifth on every deload (▾), then unwinds through the taper. The longest run of the whole plan sits in PEAK — not, as it once did for slower runners, two weeks before the race.

Bar chart of the long-run duration each week of an 18-week intermediate marathon plan: it ramps up through base, speed and peak to about 163 minutes, dips roughly 20% on the four deload weeks (marked), and falls to 60 minutes through the two taper weeks before race week.
The long run, week by week, same plan. Cut the stressor, not the total load — a deload (▾) takes ~20% off the long run and leaves the rest of the week mostly where it was.

How a single workout gets picked

This is the part that turns numbers into a calendar. The week now has a slot — a target load and a target duration. The pool is a list of real workouts already filtered to ones that belong in this phase, at this level (a beginner’s pool has had the Zone 5 sessions removed before this point; the picker never has to know about level). The job: choose the workout that fits the slot best.

The scorer is selectWorkoutByTargetV3. One thing to flip in your head first — here a lower score wins. Score is error-from-target, and we want the least error. It starts as the distance to the slot on the two axes that matter:

let loadDiff     = abs(workoutLoad - targetLoad) / targetLoad
let durationDiff = abs(workoutDuration - targetDuration) / targetDuration

// lower is better. load matters a bit more than duration.
var score = loadDiff × 0.3 + durationDiff × 0.2

Both terms are relative error, so a workout 10% off on load costs the same whether the slot is small or huge. Load is weighted a little heavier than duration (0.3 vs 0.2) because hitting the right effort matters more than hitting the right clock time — a 45-minute session at the wrong intensity is a worse substitute than a 40-minute one at the right intensity.

If that were the whole story you would get the same closest-match workout week after week, because the closest match does not change week to week. So the score also carries memory — penalties that push the picker off the obvious repeat:

// seen this exact workout already this phase? small nudge per use.
score += usedIds[w.key, default: 0] × 0.05

// same workout as LAST week? strong shove away.
if w.key   == prev.key   { score += 2.0 }   // sameWorkoutPenalty
// same title, different duration? still feels repetitive — shove.
if w.title == prev.title { score += 0.8 }   // sameTitlePenalty

Those anti-repetition penalties are deliberately large enough to dominate the match terms. A workout 2.0 worse on score has to be a far tighter target fit to win — which is exactly the trade we want: week-to-week novelty beats a marginally closer number. The usage term is a counter, not a flag, so the fourth time a hill session comes up its penalty has quietly quadrupled and something else gets a turn.

One more nudge, for the quality sessions specifically. When this week’s candidate is the same type as last week’s — another threshold, another interval set — the scorer looks at the rest intervals and reads them as progression. On a build week it rewards equal-or-shorter recoveries (denser, harder); on a deload week it rewards equal-or-longer ones (more forgiving). The plan sharpens in the right direction without anyone hand-writing “make week 6 harder than week 4”.

Lowest score wins, gets marked used, and the picker moves to the next slot. The whole thing is about forty lines. The full weighting table and the scorer live in PlanGeneratorV3.swift — almost everything interesting about how a plan feels is in those constants, and most of them were set by generating a plan, reading it, and adjusting one number.

Where the easy/hard split comes from

You may have noticed nobody told the engine to make 80% of running easy and 20% hard — the famous polarized ratio. It is never written down as a rule. It falls out of the parts above.

A plan declares a rough type budget — about 40% easy, 30% long run, 20% intervals, 10% other quality — so only a minority of sessions are hard to begin with. Then the load math does the rest. The weekly load and duration targets are sized so that hitting them mostly requires aerobic running; the hard sessions are short, so they spend a lot of the week’s intensity budget while spending little of its time. Add it up by minutes and the easy/hard split lands near 80/20 on its own. The engine is not enforcing a famous number. It is enforcing load, and the number is what load looks like when you do it sanely.

Four stacked bars, one per phase, showing the share of weekly training time by workout type. Base is mostly easy running and the long run; speed adds a large tempo and intervals block; peak is dominated by the long run with a marathon-pace block and strides; taper is mostly easy running.
That budget seen as time, phase by phase. Intervals arrive in SPEED; marathon pace only shows up in PEAK; the long run is the longest single thing you do, so it dominates the week; the taper keeps a little of everything and mostly just gets shorter. Note this is workout type, not intensity — most long-run minutes are easy-effort, which is how the split above still lands at 80/20.

Variety, and the recovery-week reshape

Monotony is its own failure mode — a plan you can predict is a plan you stop reading. The duplicate and usage penalties above are the first defense: they keep the same session from landing two and three weeks running, and they rotate the pool so a 16-week plan draws on most of its catalog rather than parking on one cheap hill workout.

The second is the recovery week, and it does more than drop the load number. A coach on a down week removes real work, not just a percentage. So on every recovery week the engine reshapes the week itself — applyDeloadReshaping — and what it does depends on how full the week is:

// recovery week, build phase only
if week.count >= 5 {
    // busy week: drop the biggest easy filler — a real rest day appears,
    // long run and quality untouched.
    drop(largestAerobicFill)
} else {
    // leaner week: keep the days, kill the intensity. swap the single
    // heaviest quality session for a progression run (or an easy run)
    // of similar length.
    replace(heaviestQuality, with: nearbyProgressionOrEasy)
}

On a five- or six-day week, taking a day off is the recovery, so the engine removes the largest easy filler and leaves you a genuine rest day — never the long run, never the quality. On a leaner three- or four-day week there is no spare day to give back, so it keeps the days and pulls the teeth instead: the hardest session of the week becomes a progression run at roughly the same duration, a real but gentler stimulus. Either way the down week looks different from the weeks around it, which is the point — the body gets the break and the calendar stops being a copy of last week.

(An earlier version of the engine had a separate “surprise week” that swapped a threshold for a progression run on a fixed schedule. It is gone — the duplicate penalty and the recovery reshape cover the same ground without a second mechanism bolted on beside the deload.)

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.

Pro — a different animal. Six days a week, built on the Pfitzinger 18/70–18/85 shell by name, anchored to a recent race result instead of a goal time. Medium-long runs midweek, threshold work nearly every week, the long run carrying marathon-pace segments. Baseline load 20,000 — about 4.4× a beginner’s.

Same engine, same four phases, the whole way up. Here’s one race — a marathon, 22 weeks — built at all four levels and laid over each other:

Four overlaid lines of weekly running time for a 22-week marathon at Beginner, Intermediate, Advanced and Pro. All share one build-to-peak-then-three-week-taper shape; the peaks scale from roughly five hours (Beginner) to nine (Pro).
One engine, one shape, four levels — the same marathon from Beginner to Pro. Build, peak, three-week taper, scaled from a ~5-hour peak week to a ~9-hour one.

One thing the chart gives away: Pro’s peak is capped. Left alone, the 18/85 math wants ten-hour weeks, and a faithful engine would hand them over. We hold it near nine. A ten-hour week breaks more amateurs than it builds, and the person who picks a sub-3 plan is still an amateur with a job — the textbook doesn’t know that. We do.

The textbook plan and the real-life plan

Everything up to here is the textbook. Higdon’s long runs, Pfitzinger’s mesocycles, Daniels’ paces — the plan a good coach would actually write. Five or six days a week. It is correct.

It is also more than a lot of people can give. A 10K runner with two kids and a commute does not have five running days. Hand them the five-day plan anyway and they fall off it in week three — and feel bad about it. Guilt is a terrible retention strategy and a worse training one. Nobody got fitter from a plan they stopped opening.

So the engine ships two of every non-Pro plan. The textbook set is the one above. The accessible set is the same race on fewer days, with the beginner tiers turned gentler — a deliberate product call, shown as its own card, never the silent default. Pick the one that fits the life you actually have. Both are real plans.

Weekly-load bars for a 9-week intermediate 10K plan. Solid bars are the 3-day accessible plan; a dashed outline above each shows the 5-day textbook plan. The empty caps are the load the accessible plan skips — about the top third.
Same race, two plans. The 10K intermediate accessible plan (solid) keeps the textbook plan’s (outline) whole build-peak-taper shape on three days instead of five — about 69% of the load. The empty caps are exactly what it leaves out.

The hard part isn’t cutting load — it’s cutting the right load. A three-day week can’t hold two hard sessions and still leave a genuinely easy day; do it anyway and you’ve built a harder plan, not a lighter one. So the accessible plans cap quality at one session a week and spend the freed days on easy aerobic running. Fewer days, same shape, the intensity that’s left pointed at the sessions that matter most. This is the one place the product overrules the textbook on purpose.

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 deload reshaping — 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.