Plan · 2026-05-21

Cycle → Continuous Mode in the Planner Engine

Proposed  ·  Owner: Preben  ·  Source markdown: plans/2026-05-21-cycle-to-continuous-engine.md
Contents
  1. Problem
  2. Goals
  3. Non-goals
  4. Three-regime model
  5. Engine specification
  6. Where the code lives
  7. Implementation steps
  8. Migration & compat
  9. Testing
  10. Open questions
  11. Acceptance criteria

1 — Problem

Our planner currently has two volume-progression mechanisms that don’t compose:

  1. Discrete J-H-R cycle (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).
  2. Continuous EWMA-headroom (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.

2 — Goals

  1. One mental model. Three regimes — linear / cycle / continuous — chosen automatically by current chronic km. User sees one cap value; the regime label tells them which math is running.
  2. Cross-platform. Same recommendation surface in tren_web and the planned iOS app. The engine writes a single regime-aware result to plan_states; both clients read it.
  3. Backwards compatible. Don’t break existing cycle behaviour for the band where it’s validated (30-150 km/wk). The change is adding the linear and continuous bands cleanly; the cycle middle band is mostly preserved (with the small recalibration from published-plan-benchmarks § 4.5.1).
  4. Minimum moving parts. New code = one orchestrator file in tren_web/src/domain/ + one mirror in tren_domain/. No new database fields. No new API endpoints. No new UI routes.

3 — Non-goals (deferred to separate plans)

Each is its own follow-up. This plan is just the three-regime orchestrator.

4 — The Three-Regime Model

chronic < 30 km/wk → LINEAR (additive +0.6–0.8 km/wk floor) 30 ≤ chronic < 150 → CYCLE (existing J-H-R, with § 4.5.1 cutback recalibration) chronic ≥ 150 km/wk → CONTINUOUS (existing EWMA-headroom)
< 30 km/wk
Linear

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.

30 – 150 km/wk
Cycle

Build / Hold / Reduce on 4-week cadence. Existing volume-cycle.ts logic with recalibrated cutback magnitudes (10/15/20 % instead of 30/28/25 %).

≥ 150 km/wk
Continuous

Elites don’t use weekly cutbacks. Kipchoge “only slight variation.” Norwegian macro-cycle deload replaces microcycle. Continuous EWMA at half cycle rate.

Why these thresholds

Hysteresis

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.

Default ceiling

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.

5 — Engine specification

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.

Linear regime (chronic < 30 km/wk)

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, /* … */ };
}

Cycle regime (30 ≤ chronic < 150 km/wk)

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

Continuous regime (chronic ≥ 150 km/wk)

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.

Build / consolidate macrocycle (cycle + continuous regimes)

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):

When macro_block_type === 'consolidate':

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

6 — Where the code lives

tren_web (TypeScript — source of truth for UI)

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 (Python — shared engine math)

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

# 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'),
)

tren_api

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
}

iOS app (tren_app)

No iOS-specific code in this plan. The iOS client fetches /api/athlete/config and renders vTargetCurrentWeekKm + volumeMode + volumePhaseLabel the 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.

7 — Implementation steps

Step order is meant to be reviewable in isolation — each step ships its own PR.

  1. TypeScript orchestrator + tests

    • Create tren_web/src/domain/volume-regime.ts
    • Unit tests: linear regime math, cycle delegation, continuous delegation, hysteresis, ceiling enforcement
    • Doesn’t touch UI yet
  2. Python mirror + tests

    • Create tren_domain/src/tren_domain/volume_regime.py
    • Mirror unit tests; ensure numerical agreement with TS at sample points
    • Doesn’t touch the engine yet
  3. Recalibrate cycle cutbacks (small, separable)

    • tren_web/src/domain/volume-cycle.ts: update CYCLE_PARAMS per benchmarks § 4.5.1
    • tren_domain/src/tren_domain/volume_cycle.py: mirror
    • Smallest, lowest-risk change. Ship first as proof-of-life.
  4. Wire orchestrator into the engine

    • tren_plan_engine_v2/src/engine.py: call compute_volume_regime after the existing logic
    • Persist to plan_states.state.volume_regime (JSONB)
    • No client-facing change yet
  5. Surface in the API response

    • tren_api/src/routes/athlete/config.ts: include volumeMode, volumePhaseLabel, volumeReasoning
    • Update tren_web/src/infrastructure/api/planner.api.ts typedef
  6. Web UI: replace cycle/continuous switch with mode label

    • tren_web/src/components/planner/PlannerLeftPanel.tsx: render volumeMode + label
    • Remove the “two caps” display; show only the active mode’s cap
    • Add a tooltip explaining the regime
  7. Backfill + migration

    • Existing users’ plan_states.state doesn’t have volume_regime — the engine writes it on first run after deployment
    • Optional: one-shot backfill script that runs the engine for all users
  8. iOS integration check

    • Confirm iOS app reads the new fields from /api/athlete/config and renders them
    • No code change needed if iOS already displays vTargetCurrentWeekKm — the value is now mode-aware on the server

8 — Migration & backward compatibility

9 — Testing approach

Boundary tests

Numerical tests

Cross-platform parity

Run the TS orchestrator and the Python mirror against the same inputs; assert numerical agreement (within 1e-6).

Acceptance tests (against the benchmark library)

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.

10 — Open questions

Should the regime change be visible or invisible to the user?

What happens during the consolidate macroblock?

Should the lower bound be 30 km/wk or higher (say 40)?

Should the ceiling be hard or soft?

11 — Acceptance criteria

12 — References

Back to: Volume Progression archive  ·  Source: plans/2026-05-21-cycle-to-continuous-engine.md