Koncur Last verified 27 Jul 2026

Methodology

Every number in Koncur comes from a formula you can read. This page lists them — the constants, the boundaries, the sources — and the places the app does less than you might assume.

On 27 July 2026 every claim below was checked line-by-line against the code that is actually deployed. Where the shipped behaviour was narrower than the ambition, the claim was cut or narrowed rather than dressed up. Things we intend to build but have not built sit in On the roadmap, in the future tense, and nowhere else.

A methodology page is only worth anything if it is boring and correct. If you find a discrepancy between this page and what the app does, that is a bug in one of the two and we want to hear about it.

01 — Benchmarks & zones

Where your zones come from

Koncur does not use generic beginner/intermediate/advanced pace bands. Every intensity target is derived from one of your own benchmarks: a 5K time, an FTP, a 2K row, a max heart rate.

Running pace — Daniels VDOT from your 5K

Running zones are computed from a single input: your current 5K time in seconds. We estimate VDOT with the Daniels–Gilbert relations, then invert the oxygen-cost equation at a fixed fraction of VDOT per zone to get a velocity, and convert that to a pace.

v      = (5000 / t_sec) × 60                       // m/min
vo2    = −4.60 + 0.182258·v + 0.000104·v²
pctMax = 0.8 + 0.1894393·e^(−0.012778·t_min)
             + 0.2989558·e^(−0.1932605·t_min)

VDOT   = vo2 / pctMax

// zone velocity = inverse of the vo2 quadratic at:
Z1 Recovery              0.59 × VDOT
Z2 Easy / aerobic base   0.67 × VDOT
Z3 Tempo / threshold     0.84 × VDOT
Z4 VO₂max intervals      0.97 × VDOT
Z5 Anaerobic / sprint    1.05 × VDOT

generate-programme/zones.ts:5–38 · mobile/src/utils/clientZones.ts:47–73 — numerically identical, verified

10K estimate — Riegel, exponent 1.06

If you have given us a 5K but not a 10K, we estimate the 10K rather than leave the endurance benchmark blank. The estimate is tagged at the point of writing so it can be told apart from a time you actually ran.

T₂ = T₁ × (D₂ / D₁)^1.06

// applied as:  ten_k = round(five_k × (10/5)^1.06)
// tagged as:    ten_k_source = 'riegel'

// provenance ladder — higher never overwritten by lower
'user'      manually entered      — never overwritten
'healthkit' imported 9.5–10.5 km run
'riegel'    estimated from your 5K
null        not set

mobile/src/utils/predictRunTime.ts:11–18 · mobile/src/store/profileStore.ts:100–103, 154–208

The Riegel exponent is Riegel's own 1.06, unchanged. The same exponent is used in reverse (10K → 5K) when only a 10K is on file and a pace band is needed.

What this estimate does not do

The riegel tag is honoured in the app's own display — an estimated 10K is labelled as a Day-1 estimate on your Endurance axis. It is not read by the server when the server computes your profile, so a Riegel-derived 10K and a 10K you actually raced currently carry equal weight in programme decisions. Per-benchmark provenance on the server side is on the roadmap.

Cycling power — Coggan zones from FTP

ZoneName% FTP
Z1Recovery0 – 55%
Z2Endurance56 – 75%
Z3Tempo / sweet spot76 – 90%
Z4Threshold91 – 105%
Z5VO₂max106 – 120%
Z6Anaerobic121 – 150%

generate-programme/zones.ts:47–63 · mobile/src/utils/clientZones.ts:22–36

FTP is a number you enter. Koncur does not currently run an FTP test — there is no ramp test and no 20-minute protocol you can complete in the app, and we do not estimate FTP from ride data. Whatever you type is what the zones are built on.

The session picker uses a separate, coarser set of single-watt targets per intensity tier rather than the six-zone table: recover 55%, base 68%, threshold 98%, vo2 113% of FTP. These are midpoints for prescribing one number on a screen, not a second zone model.

design-session/logic.ts:239–244

Heart-rate zones — percentage of max HR

Heart-rate zones are a straight percentage of maximum heart rate. They are not Karvonen heart-rate-reserve zones. (Heart-rate reserve is used, but only inside the load model — see below. Do not conflate the two.)

Z1 Recovery   50–60% HRmax
Z2 Aerobic    60–70% HRmax
Z3 Tempo      70–80% HRmax
Z4 Threshold  80–90% HRmax
Z5 Max        90–100% HRmax

HRmax = entered value, else 220 − age  (floor 120, default 190)

mobile/src/constants/hrZones.ts:4–23 · aerobicTarget.ts:35–37 · liveAerobicBlocks.ts:236 — five copies, all identical

We use 220 − age rather than the Tanaka regression. Tanaka is the better estimator and we know it; the code carries 220 − age for compatibility with the stored profile fields. If you know your true max HR, entering it removes the estimate entirely and is the single highest-value number you can give us.

Erg — Concept2 watts and split

Two separate conversions, for two separate jobs.

// 1. Power ↔ split, the exact Concept2 relation
watts       = 2.80 / (sec_per_metre)³
split_500m  = 500 × ∛(2.80 / watts)

// 2. Session targets, relative to your own benchmark
ref_split = row: 2K_time / 4   |   ski: 1K_time / 2

recover    ref + 26 s
base       ref + 20 s
threshold  ref +  7 s
vo2        ref +  2 s

mobile/src/utils/pickerV3.ts:415–417 · design-session/logic.ts:199–244

There is no swim model. Koncur has no critical-swim-speed calculation and no swim pace zones — the modalities with a real zone model are run, bike, row and ski.

02 — Training load

Two channels, deliberately not fused

Heart rate describes cardiovascular cost. It describes mechanical cost badly — a heavy triple spikes HR far less than its tissue cost warrants, and a long easy ride spikes it far more. So Koncur computes two separate load channels and does not add them into one "strain" number.

Cardiac load — TRIMP

The training-impulse figure saved with every session, in the Banister family: minutes weighted by an exponential function of heart-rate reserve, with a sex-specific exponent.

HRr   = (avgHR − restHR) / (HRmax − restHR)

TRIMP = minutes × HRr × e^(b · HRr)

b = 1.92 male
    1.67 female

// as actually executed on session save:
restHR = 60   ← a constant, not your resting HR

mobile/src/store/sessionLogStore.ts:256–271, call site :887–901

Two honest caveats on this number

Resting heart rate is hardcoded to 60 bpm in the path that computes the TRIMP actually stored against your session. Your measured resting HR is not used. If your true resting HR is 45, your heart-rate reserve — and therefore your TRIMP — is systematically understated; if it is 70, overstated.

Our exponent coefficients are Morton/Banister; the leading sex constant is not applied. Published Banister TRIMP carries a leading 0.64 (male) / 0.86 (female) scale factor. Ours does not. Koncur's TRIMP values are internally consistent and all thresholds on this page are calibrated on this scale, but they are not directly comparable to Banister TRIMP figures quoted elsewhere.

The strength-session cardiac cap described below applies to the budget side only. The TRIMP saved against a completed strength session is uncapped.

On the prescription side, an expected TRIMP is computed per session from zone midpoints — Z1 0.55 · Z2 0.65 · Z3 0.75 · Z4 0.85 · Z5 0.92 HRr — and a strength session's expected cardiac cost is capped at 25% of its theoretical ceiling, because the mechanical channel carries the real cost of lifting.

mobile/src/engine/load/prescribedLoad.ts:19–25, 163–172 · constants.ts:27

Mechanical work — kilojoules

For barbell lifts and bodyweight gymnastics, we compute actual mechanical work: mass moved against gravity through a range of motion, with an eccentric multiplier. This is the channel that makes a strength session legible as load rather than as a heart-rate non-event.

mass_kg = bwFraction × bodyweight + external_load
work_J  = mass_kg × 9.81 × ROM_m × k_ecc × reps
kJ      = round(Σ work_J / 1000, 1)

k_ecc = 1.3 default, per-movement overrides
        (e.g. box jump 1.0 — no eccentric phase)

mobile/src/engine/load/movementLibrary.ts:291–305 · constants.ts:14

MovementbwFractionROM (m)
Back squat0.850.55
Deadlift1.000.55
Bench press0.00 + bar0.40
Pull-up1.000.55
Toe-to-bar0.331.05
Box jump1.000.60

mobile/src/engine/load/movementLibrary.ts:30–70 — whole-body lifted-mass fractions after Humphrey & McGill 2007; partial-body segmental masses after Plagenhoef 1983 / Winter 2009

Movements where mechanical work in this sense is not a clean fit opt out explicitly and are marked cardiacOnly: plank, echo bike, assault bike, rower, ski erg, run, bike. Their cost lives in the TRIMP channel only. An unknown movement also falls back to cardiacOnly and the session card flags partial coverage rather than inventing a number.

Where this stops

Mechanical kJ is computed correctly, stored, and shown to you. No server-side system reads it. ACWR, the strain budget, recovery scoring and programme adaptation are all driven by cardiac TRIMP alone. In practice that means a strength-only athlete generates almost no load signal for those systems — the number on your session card is real, but nothing downstream currently acts on it.

Acute:chronic workload ratio

acute   = ewma(daily TRIMP, 7d,  α = 2/(7+1)  = 0.250)
chronic = ewma(daily TRIMP, 28d, α = 2/(28+1) ≈ 0.069)
acwr    = acute / max(chronic, 1)

  acwr < 0.8          undertraining
  0.8 ≤ acwr < 1.3    sweet spot
  1.3 ≤ acwr < 1.5    high
  acwr ≥ 1.5          overreaching

// suppressed entirely below 14 distinct training days
acwrReliable = distinctTrainingDays ≥ 14

recompute-training-load/index.ts:64–88, 259–261

We use the exponentially-weighted variant rather than rolling averages, and we return no value at all rather than a misleading one while the chronic baseline is still forming. The 0.8–1.3 band is Gabbett's.

Strain budget

strain_budget = round(readiness × phase_multiplier)

phase_multiplier = { Base 0.70, Build 1.00,
                     Peak 0.85, Taper 0.50,
                     Deload 0.50 }

strain_used   = round(TRIMP_today / 4)
strain_left   = max(0, budget − used)

recompute-training-load/index.ts:133, 186–190

Strain is a linear rescale of cardiac TRIMP, not an independent model. Base-phase budgets are deliberately below Build: aerobic base is built with volume and recovery, not maximal daily output.

Intensity distribution — measured, not prescribed

Koncur classifies how your last 28 days of time-in-zone actually split. This is a description of what you did, presented so you can act on it. It is not a target the generator enforces.

easy = Z1+Z2   moderate = Z3   hard = Z4+Z5

easy ≥ 75 ∧ hard ≥ 15 ∧ mod < 15  → polarized
easy ≥ 70 ∧ mod  ≥ 15 ∧ hard < 10 → pyramidal
mod  > 30                          → threshold trap
otherwise                            → unbalanced

recompute-training-load/index.ts:108–119

Read this carefully

The classifier is real and the thresholds above are the shipped ones. The programme generator does not prescribe an 80/20 polarised distribution. There is no controller anywhere in the engine that targets an easy/hard ratio, and measured hard-session share across our own test personas ranges from roughly 42% down to 0% depending on training days and mode. If you have read an 80/20 claim about Koncur elsewhere, including in older versions of this page, it was aspirational. See On the roadmap.

03 — Recovery & readiness

Readiness

A 0–100 daily score from four independent signals. Components you cannot supply are excluded and the remaining weights renormalised — never zero-padded, which would silently punish you for not owning a device.

readiness = 0.30·sleep + 0.30·hrv
          + 0.20·rhr   + 0.20·strain_debt

// present components only, renormalised to sum to 1
w_i = present_i ? weight_i / Σ(present weights) : 0

band = readiness ≥ 75 → high
     | readiness ≥ 55 → moderate
     | else            → low

recompute-recovery/index.ts:99, 120–138

Sleep

duration    = clamp(minutes / target × 100, 0, 100)
efficiency  = clamp(asleep / in_bed × 100, 0, 100)   (90 if no in-bed)
consistency = clamp(100 − waketime_stddev_min / 60 × 100, 0, 100)
restfulness = clamp(100 − wake_count×4 − awake_min/2, 0, 100)

sleep = 0.40·duration + 0.25·efficiency
      + 0.20·consistency + 0.15·restfulness

target  = 450 min (7.5 h) default, configurable
if minutes < 240 → sleep capped at 50

recompute-recovery/index.ts:62, 245–268

Morning HRV

baseline  = mean(RMSSD over trailing 28 d, excluding today)
delta_pct = (today − baseline) / baseline × 100
hrv       = clamp(70 + delta_pct × 1.5, 0, 100)

// with no prior samples at all:
seed baseline = 50 ms, flat

recompute-recovery/index.ts:286–301

Baseline is your 28-day mean, not a population norm — a "low" HRV for one athlete is unremarkable for another. The one exception is a brand-new account with no history, which falls back to a single flat 50 ms seed. That seed is not adjusted for age or sex.

Resting heart rate

baseline = median(RHR over trailing 28 d)   needs ≥ 7 samples
band     = [ p25 , p75 ] of the same window

rhr = clamp(70 − (today − baseline) × 5, 0, 100)

drift, counting back from today, unbroken:
  0 days above p75    → normal
  1–2 days above p75  → drifting
  ≥ 3 days above p75  → elevated

recompute-recovery/index.ts:321–369

Three consecutive mornings above your own upper quartile is a signal worth acting on — illness, alcohol, or accumulated load. We surface it as a state, not a diagnosis, and we do not attach a probability to it.

Strain debt

acute   = Σ TRIMP, days 1–7
chronic = Σ TRIMP, days 8–28, ÷ 3   (21 d → weekly equivalent)
ratio   = min(3.0, acute / max(chronic, 1))

penalty = max(0, (ratio − 1) × 50)
        + (days_since_rest > 6 ? 30 : 0)

score   = clamp(100 − penalty, 0, 100)

// fallbacks
no training in 28 d, or no TRIMP data     → 80
fewer than 3 training days in the 21 d
chronic window ("baseline forming")       → 70

recompute-recovery/index.ts:399–457

What readiness actually changes

Readiness is read by the aerobic session picker before it recommends a session. It must be no more than one day old and not flagged low-confidence, otherwise it is treated as unknown.

design-session/index.ts:118–122 · design-session/logic.ts:159–179, 617–677

Steered, not locked

Nothing is taken away from you. A low readiness score changes what Koncur recommends and attaches a prompt to be honest about how you feel — it does not delete sets, force a rest day, or block a hard session you have decided to do. That is a deliberate design position, recorded in the code itself: agency over lockout.

Recovery scoring is opt-in, and so is everything downstream of it

Recovery tracking defaults off for new accounts. While it is off, recompute-recovery never runs — no readiness score, no strain budget, no ACWR, and no adaptive adjustment to your programme. Even with it on, the chain is triggered by opening and refreshing the Health tab, not by finishing a session. If you never turn recovery tracking on, you will train your full block on the programme exactly as it was generated.

Menstrual cycle phase is context, not a coefficient. Koncur computes and displays your current cycle phase with guidance, but it is not read by the readiness composite, any component, or the session picker. We would rather show you the context and let you interpret a low number than bake an untested multiplier into a score.

mobile/src/utils/menstrualPhase.ts — no reference in recompute-recovery or design-session

04 — Programme architecture

How a block is built

A programme is 8, 12 or 16 weeks, split into mesocycles of up to six weeks: five loading weeks and a deload.

LengthStructure
8 weeksBase 1–5 · Deload 6 · Build 7–8
12 weeksBase 1–5 · Deload 6 · Build 7–10 · Peak 11 · Deload 12
16 weeksBase 1–5 · Deload 6 · Build 7–11 · Deload 12 · Peak 13–16

generate-programme/periodisation.ts:16–66

There are exactly four phases in the model — base, build, peak, deload. A taper is a transform layered on the final week when you have set a priority-A event (strength sets ×0.5, aerobic duration ×0.55, floored at 20 min). A realisation week replaces the terminal deload for advanced athletes on blocks of 12+ weeks with no event, converting primaries to a single AMRAP top set.

generate-programme/engine.ts:314–349, 849–914

Intensity by phase

Week-1 prescriptions, before the weekly ramp. Percentages are of your one-rep max; RIR is reps-in-reserve.

PhaseBeginnerIntermediateAdvanced
Base3×10 @ 63% · RIR 34×8 @ 68% · RIR 34×8 @ 70% · RIR 2
Build3×6 @ 73% · RIR 34×5 @ 78% · RIR 25×4 @ 81% · RIR 2
Peak3×4 @ 80% · RIR 24×3 @ 85% · RIR 25×2 @ 88% · RIR 1
Deload3×5 @ 65% · RIR 53×5 @ 65% · RIR 53×5 @ 65% · RIR 5

generate-programme/periodisation.ts:115–136, 163–165

// weekly ramp within a loading mesocycle
pct1rm = base + (weekInMeso − 1) × 0.025

// hard ceiling per phase
base  ≤ 80%    build ≤ 85%    peak ≤ 92%

// week 5 (test week): sets − 1, floor 3
// RIR tightens by 1 every 2 weeks, floor 0

generate-programme/periodisation.ts:167–190

The phase ceilings are set to keep prescribed set volume inside Prilepin's classical rep ranges for each intensity band — the reason peak tops out at 92% rather than climbing to a true max is that Prilepin's tables stop being descriptive above it.

Age and sex adjustments

generate-programme/periodisation.ts:220–236

Rest

PhaseBeginnerIntermediateAdvanced
Base120 s150 s180 s
Build180 s210 s240 s
Peak210 s270 s300 s
Deload180 s180 s180 s

Secondary lifts rest for 80% of the primary's prescription. Accessories are fixed at 90 s, or 60 s for timed and bodyweight-timed work, and are not age-adjusted.

generate-programme/periodisation.ts:117–134 · assembler.ts:943, 1095, 1203

Deload

Week 6 of every mesocycle (except the final short mesocycle of a 16-week block) is a real deload, not a token one. Concretely:

generate-programme/periodisation.ts:163–165 · engine.ts:839–846 · meso-plan.ts:106–113 · assembler.ts:1447–1458

Measured across our persona test grid, that lands at roughly −33% aerobic minutes and −49% strength tonnage against the mesocycle's heaviest loading week.

−49%
strength tonnage, deload week
−33%
aerobic minutes, deload week
65%
deload primary intensity

Volume landmarks

Per-muscle weekly set landmarks are checked against the generated week and reported. Sets/week, after Pelland 2025:

MuscleLevelMVMEVMAVMRV
QuadsBeginner368–1216
QuadsIntermediate4812–1620
QuadsAdvanced61014–2024
LatsAdvanced61016–2226
HamstringsIntermediate4810–1418
TricepsBeginner246–812

generate-programme/volume.ts:5–42, 92

All ten muscle groups have a full table across three levels. To be precise about what this does: the landmarks drive a volume report that checks the generated week — they are not a week-to-week ramp. Progressive overload in Koncur is delivered through the intensity ramp above, not through climbing set counts. A per-muscle volume ramp was built, tested, and reverted in May 2026 for overshooting MRV on some muscle groups; that code remains in the tree, unused.

05 — Progressive overload

What your loads are anchored to

Prescribed weights are a percentage of a stored one-rep max, snapshotted into the programme at the moment it is generated.

prescribed_kg = pct1rm × profiles.squat_1rm   (bench / deadlift / press)

// frozen at generation into:
programme.strength_maxes = { squat, bench, deadlift, press }

generate-programme/index.ts:128–135 · engine.ts:1006–1011 · assembler.ts:277–297

This is open-loop, and you need to know that

The programme generator does not estimate a 1RM from your logged sets. It reads the 1RM stored on your profile and multiplies. Hitting a new PR in a session does not automatically raise the baseline your next block is built on — you have to update the number yourself. Automatic carry-forward of achieved loads is on the roadmap.

Estimated 1RM — Brzycki, for PRs

e1RM = weight / (1.02780.0278 × reps)

reps = 1   → returns the weight unchanged
reps > 10  → rejected; the formula stops being valid

recompute-strength-prs/index.ts:33–37 · sessionLogStore.ts:224

PR detection and the PR card use Brzycki, capped at 10 reps. Estimates are aggregated per canonical movement — "Barbell Back Squat", "Back Squat" and "Paused Squat" all roll into one lift — so you see one record per movement, not one per name variant. Brzycki is also used in reverse inside the periodisation model to derive the reps-in-reserve implied by a given percentage.

generate-programme/periodisation.ts:94–98

RPE and RIR

Base-phase work is prescribed as a percentage with an RIR label. Build and peak primaries are additionally anchored with an RPE target, mapped straight off the prescribed RIR and clamped to a usable band.

RPE = 10 − RIR,  clamped to [7, 9.5]

generate-programme/assembler.ts:196–211

Autoregulation from logged RPE

There is a closed loop, and it lives outside the generator: a separate adaptation pass compares the RPE you logged against the RPE that was prescribed over the trailing seven days, and patches the next occurrence of that exercise.

drift = mean(actual RPE) − target RPE

drift > +1.5  →  load_modifier = −min(0.15, drift × 0.05)
drift < −2.0  →  load_modifier = +min(0.10, |drift| × 0.03)

adapt-programme/index.ts:32–33, 409–485

Adjustments are written as a layer on top of the generated programme; the underlying percentage curve is not rewritten. Bounds are deliberately tight — a maximum 15% cut or 10% bump on a single exercise occurrence.

When this runs

Adaptation is chained off the training-load recomputation, which only runs when recovery tracking is enabled and the Health tab is refreshed. It does not run when you save a session, and it does not run at all for an athlete who has never turned recovery tracking on. If that describes you, your programme is exactly what was generated, unmodified, for the whole block.

Automatic deloads triggered by that same pass cut strength sets by 35% while holding intensity, and aerobic duration by 25%, with a limit of one auto-deload per 28 days.

adapt-programme/index.ts:29–31, 374, 390

Aerobic auto-deloads cut duration only

An auto-deload writes an intensity-downgrade flag alongside the duration cut. Nothing in the app reads that flag. In practice an aerobic auto-deload shortens the session and leaves the prescribed zone untouched. This is a known defect, not a design choice, and it is on the roadmap to fix.

06 — Concurrent training

Rules that are enforced, and rules that only warn

Training strength and endurance in the same block is the harder problem, and the interference effect is dose- and modality-dependent: running blunts squat development more than cycling does, because of the eccentric load on the same motor units. Koncur encodes this in scheduling. Here is exactly what is binding.

Enforced in generation

generate-programme/selection.ts:481–494 · engine.ts:672–677, 726–727

Deliberate carve-outs

generate-programme/engine.ts:704–725, 744–753

Advisory only

A post-generation validator re-checks the same rules and logs warnings. It mutates nothing. If you see us describe rule-checking, this is what it is: a safety net that tells us when generation produced something questionable, not a gate that blocks it.

generate-programme/engine.ts:64–119, 940

Maintenance doses

Both directions are supported as an explicit opt-in. A strength-led block can carry 1–2 easy cardio sessions per week; an aerobic-led block can carry a short heavy full-body lift — compound work, low reps, sized to keep tissue resilient rather than to drive hypertrophy.

generate-programme/selection.ts:642–664 · assembler.ts:851, 1814–1927

What concurrent isn't

It isn't two programmes glued together. Bolting a powerlifting plan onto a marathon plan gets you the worst of both. Concurrent done properly is one week structure where the strength and aerobic recovery windows are aware of each other — which is why the rules above live in the scheduler rather than in a warning banner.

07 — Personalisation

The six-axis profile

Your profile is six axes, each derived from one benchmark you have supplied, each expressed as a position within your sex and age cohort.

AxisInput
Max StrengthSquat and/or deadlift 1RM ÷ bodyweight (averaged if both)
Strength EnduranceReps at 70% of squat 1RM
Explosive PowerPeak power ÷ bodyweight (W/kg)
Running5K time
Erg2K row time
EnduranceFTP W/kg and/or 10K time (averaged if both)

mobile/src/utils/performanceProfile.ts:213–374, 586–597 · _shared/performance-profile.ts:170–198

Cohorts and interpolation

There are four cohorts: male and female, under 35 and 35-plus. A single age boundary, not decade bands. Each cohort has five anchor points per metric; your value is linearly interpolated between adjacent anchors and clamped at the ends.

// Squat 1RM ÷ bodyweight — anchors at P10 / P30 / P50 / P75 / P95
M <35    0.75   1.25   1.75   2.25   2.75
M ≥35    0.65   1.15   1.65   2.10   2.55
F <35    0.55   0.95   1.30   1.65   2.05
F ≥35    0.45   0.85   1.20   1.55   1.90

// Deadlift 1RM ÷ bodyweight
M <35    1.00   1.50   2.00   2.50   3.00
M ≥35    0.90   1.40   1.85   2.30   2.80
F <35    0.75   1.15   1.50   1.90   2.30
F ≥35    0.65   1.05   1.40   1.80   2.15

mobile/src/utils/performanceProfile.ts:94–154 · _shared/performance-profile.ts:92–139 — client and server tables verified identical

Values below the lowest anchor clamp to that percentile rather than extrapolating; the same at the top. Time-based axes (5K, 2K, 10K) use a descending interpolation so that faster is better, which is the correct behaviour and was a real bug once.

These anchors are engineering estimates, not a sampled population. They are hand-set reference points calibrated to published strength standards and our own athlete data. We call the output a percentile because that is what it is used as; we are not claiming a sampling frame behind it.

Weakness targeting

Your weakest axis is the lowest-scoring axis below the 50th percentile. What it changes, precisely:

generate-programme/assembler.ts:425–426, 487–524 · meso-plan.ts:87–93

// the strength-specific path requires ALL of:
readyAxisCount ≥ 3
measured strength axes (maxStr, strEnd, expPow) ≥ 2
weakest of those < 45th percentile

// only then:
maxStr → squat becomes the de-facto focus lift, + a 4th back-off set
strEnd → secondary lift reshaped to 4×10–12 @ RIR 3 (base/build)

generate-programme/index.ts:249–271 · assembler.ts:373–380, 1050–1064

Be sceptical of the headline

Two things follow from that gate. First, if you have fewer than two measured strength benchmarks, the strength-specific response never fires — the app may still name a strength axis as your gap while nothing in the programme changes in response to it. Second, the "targeting your gap" card recomputes live from your current benchmarks, while the plan you are holding was built against the gap as it stood at generation. Log a fast 2K mid-block and the card can name a different axis than the one your programme was actually shaped around.

Estimated benchmarks

If you told us you don't know your 1RMs

Onboarding offers an "I don't know my 1RM" path, which estimates squat, bench, deadlift and press from your bodyweight using a sex and experience-level multiplier table, rounded to 2.5 kg. Those estimates are written into the same profile columns as a tested max, with no flag marking them as estimates. Everything downstream — your Max Strength axis, your percentile, your prescribed weights — treats them as measured. Because both the estimate and the cohort table are bodyweight-scaled, the resulting percentile is largely a function of your sex and stated level rather than of anything you lifted. Replace them with real numbers as soon as you have them; it is the single biggest accuracy improvement available to you.

mobile/src/screens/onboarding/OnboardingScreen.tsx:337–368, 725–759

The one estimate the app does label is a Riegel-derived 10K feeding your Endurance axis when no FTP is present — that shows as a Day-1 estimate.

mobile/src/utils/performanceProfile.ts:456–476

08 — Known limits

What this page would be hiding if we let it

Every item here is a live defect or a real boundary, verified on 27 July 2026. They are listed because a methodology page that only lists strengths is marketing.

Things we deliberately do not do

09 — On the roadmap

Not yet shipped

Everything in this section describes work we intend to do. None of it is in the app today. It is stated in the future tense on purpose — if you find any of it written elsewhere in the present tense, that copy is wrong.

10 — References

Sources

Each entry names the thing in the code it is actually load-bearing for. References that decorated a claim we do not implement have been removed from this page.

Found an error, or a claim here that does not match what the app does? hello@koncur.app. We would rather be corrected than believed.