Our planner currently has two volume-progression mechanisms that don’t compose:
tren_web/src/domain/volume-cycle.ts) — Jump / Hold / Reduce phases on a 3-or-4-week cadence. Validated against Pfitz 18/55, Higdon Intermediate/Advanced, Hansons Advanced (cycle match rate within ±15 % for established 50-100 km/wk runners).tren_web/src/domain/continuous-progression.ts) — V_target = V_chronic × (1 + (CEIL − ACWR) / K_struct) × msk_coef. Already in code; smooth, no cycle. Currently displayed as a “second constraint floor” alongside the cycle.Two problems with this:
We want a single unified model: regime-switching by chronic volume, with one output shape (v_target_km, growth_rate_pct, phase_label) regardless of which regime is active.
plan_states; both clients read it.tren_web/src/domain/ + one mirror in tren_domain/. No new database fields. No new API endpoints. No new UI routes.Each is its own follow-up. This plan is just the three-regime orchestrator.
Additive +0.6–0.8 km/wk. Percentage growth underflows here. Higdon Novice 5K, Novice 10K, NHS C25K all confirm this pattern — no cutback weeks.
Build / Hold / Reduce on 4-week cadence. Existing volume-cycle.ts logic with recalibrated cutback magnitudes (10/15/20 % instead of 30/28/25 %).
Elites don’t use weekly cutbacks. Kipchoge “only slight variation.” Norwegian macro-cycle deload replaces microcycle. Continuous EWMA at half cycle rate.
Mode is sticky across the boundary to prevent flapping:
User-facing label freezes between transitions. Mode change is logged on the plan_state for audit.
TREN_DEFAULT_CEILING_KM = 180 (Norwegian double-threshold target band). User can override to 220 (Kipchoge band) or any value, but loses calibration confidence above the published-plan envelope.
A single function in shared math. Inputs and outputs:
// Input from existing state interface VolumeRegimeInput { chronicWeeklyKm: number; // 12w EWMA — primary regime driver acwrMsk: number; // 7d/28d MSK EWMA ratio tier: string; // 'new_runner' | 'developing' | … mskCoef: number; // tier × familiarity × return × age × consistency ageYears?: number; // optional, modifies K_struct injuryHistoryFlag?: boolean; // optional, modifies K_struct ceilingKm?: number; // default 180 lastModeFromState?: VolumeMode; // for hysteresis } interface VolumeRegimeResult { mode: 'linear' | 'cycle' | 'continuous' | 'ceiling'; vTargetKm: number; // recommended weekly km for next week growthRatePctPerWeek: number; phaseLabel: string; // 'building' | 'consolidating' | … reasoning: string; // one-line explanation for UI tooltip modeDetails: { linearStepKm?: number; cyclePhase?: 'build' | 'hold' | 'reduce' | 'return'; cycleWeek?: number; headroom?: number; }; }
The function is pure — no I/O, no state mutation. Returns a fresh result every call.
function computeLinear(input: VolumeRegimeInput): VolumeRegimeResult { const minStep = input.chronicWeeklyKm < 15 ? 0.8 : 0.6; const vTarget = input.chronicWeeklyKm + minStep * input.mskCoef; return { mode: 'linear', vTargetKm: vTarget, /* … */ }; }
Reuses existing volume-cycle.ts with the cutback recalibration:
// New cutback values (was 30/28/25) const CYCLE_PARAMS = { new_runner: { holdWeeks: 2, reduceCut: 0.10 }, developing: { holdWeeks: 2, reduceCut: 0.15 }, established: { holdWeeks: 2, reduceCut: 0.20 }, cross_sport: { holdWeeks: 2, reduceCut: 0.15 }, strength_athlete: { holdWeeks: 2, reduceCut: 0.15 }, }; const LOW_VOL_CYCLE = { holdWeeks: 1, reduceCut: 0.18 }; // was 0.15
const params = PROGRESSION_PARAMS[tier]; const headroom = Math.max(0, params.ceil - acwrMsk); const growthRate = (headroom / params.kStruct) * mskCoef * 0.5; const vTarget = chronic * (1 + growthRate);
The 0.5 factor matches the safety-net analysis — at the elite tail, growth slows because each percent is a bigger absolute load.
Both regimes alternate between build blocks (12 weeks of growth) and consolidate blocks (10 weeks holding the new baseline). This honors how real plans periodise — you don’t grow back-to-back forever; you do an 18-week Pfitz cycle, race, recover, then maybe build again.
The macrocycle position lives on plan_states.state.macro (new field):
macro_block_type: 'build' | 'consolidate'macro_block_week: 0..12 (build) or 0..10 (consolidate)When macro_block_type === 'consolidate':
'consolidating'This is the most important behavioural change. Without it, the engine keeps growing every 4 weeks indefinitely, producing unrealistic 100→180 km in 18 months. With it, the 100→180 ramp takes a realistic 2-3 years (matches the multi-year published consensus in high-volume case studies).
tren_web/src/domain/ ├── volume-cycle.ts # existing, RECALIBRATED (cutback %) ├── continuous-progression.ts # existing, MOSTLY UNCHANGED ├── volume-regime.ts # NEW — the orchestrator (~150 LOC) └── ewma-simulator.ts # existing, UPDATED to call volume-regime.ts
tren_domain/src/tren_domain/ ├── volume_cycle.py # existing ├── continuous_progression.py # existing ├── volume_regime.py # NEW — mirror of TS orchestrator └── tests/test_volume_regime.py # NEW
# tren_plan_engine_v2/src/engine.py from tren_domain.volume_regime import compute_volume_regime state['volume_regime'] = compute_volume_regime( chronic_weekly_km=state['chronic_weekly_km'], acwr_msk=state['acwr_msk'], tier=state['tier'], msk_coef=state['msk_coef']['msk_coef'], age_years=user.age_years, injury_history_flag=user.injury_history_flag, ceiling_km=user.volume_ceiling_km or 180, last_mode_from_state=state.get('volume_regime', {}).get('mode'), )
No new endpoints. The existing GET /api/athlete/config returns PlannerAthleteConfig. We add three fields:
interface PlannerAthleteConfig { // …existing fields… volumeMode: 'linear' | 'cycle' | 'continuous' | 'ceiling'; // NEW volumePhaseLabel: string; // NEW volumeReasoning: string; // NEW }
No iOS-specific code in this plan. The iOS client fetches
/api/athlete/configand rendersvTargetCurrentWeekKm+volumeMode+volumePhaseLabelthe same way the web does. Cross-platform consistency is achieved by computing once on the backend (tren_plan_engine_v2) and reading the result on both clients.
Step order is meant to be reviewable in isolation — each step ships its own PR.
tren_web/src/domain/volume-regime.tstren_domain/src/tren_domain/volume_regime.pytren_web/src/domain/volume-cycle.ts: update CYCLE_PARAMS per benchmarks § 4.5.1tren_domain/src/tren_domain/volume_cycle.py: mirrortren_plan_engine_v2/src/engine.py: call compute_volume_regime after the existing logicplan_states.state.volume_regime (JSONB)tren_api/src/routes/athlete/config.ts: include volumeMode, volumePhaseLabel, volumeReasoningtren_web/src/infrastructure/api/planner.api.ts typedeftren_web/src/components/planner/PlannerLeftPanel.tsx: render volumeMode + labelplan_states.state doesn’t have volume_regime — the engine writes it on first run after deployment/api/athlete/config and renders themvTargetCurrentWeekKm — the value is now mode-aware on the servercycleWeek field stays. Used only when mode is 'cycle'.establishedLevelKm field stays. Synonymous with chronicWeeklyKm for the orchestrator’s purposes; we don’t read it.vTargetCurrentWeekKm semantics: was “the continuous-progression cap.” Now: “the active regime’s recommended km for next week.” Numeric range similar; existing UI code reading this field still works.plan_states.state JSONB.Run the TS orchestrator and the Python mirror against the same inputs; assert numerical agreement (within 1e-6).
For each plan in plan-benchmarks.html, simulate the orchestrator from W1 baseline for the plan’s duration. Compare regime mix and peak km against published plan.
phaseLabel: 'consolidating' and vTargetKm ≈ chronic (~0 growth).volume_regime to plan_states.state for all usersvolumeMode / volumePhaseLabel / volumeReasoningvTargetCurrentWeekKm correctly (no iOS-specific code change needed)research/high-volume-progression.md — elite case studies + research evidenceresearch/published-plan-benchmarks.md — 10-plan library + cutback recalibrationresearch/jhhr-cycle-evidence-and-continuous-formula.md — the original cycle critiquetodo/fasterIncrease/NOTES.md — pending-decisions digest that led here
Back to: Volume Progression archive
·
Source: plans/2026-05-21-cycle-to-continuous-engine.md