| 1 |
|
- |
//! First-session diagnostic protocol.
|
|
1 |
+ |
//! First-session (or recalibration) diagnostic protocols.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! Given a starting seed load and the sets the user has logged so far,
|
| 4 |
|
- |
//! return either the next set to prescribe or a completion result with
|
| 5 |
|
- |
//! a working weight and e1RM estimate. Pure functions; the DB layer
|
| 6 |
|
- |
//! only persists the sets themselves (flagged `is_diagnostic = 1`).
|
|
3 |
+ |
//! Given a seed and the sets logged so far, return either the next set to
|
|
4 |
+ |
//! prescribe or a completion result carrying the value to seed progression
|
|
5 |
+ |
//! with. Pure functions; the DB layer only persists the sets themselves
|
|
6 |
+ |
//! (flagged `is_diagnostic = 1`).
|
| 7 |
7 |
|
//!
|
| 8 |
|
- |
//! Rules:
|
| 9 |
|
- |
//! - Target reps per set = 5.
|
| 10 |
|
- |
//! - RPE 1 or 2 -> next load = last * 1.15.
|
| 11 |
|
- |
//! - RPE 3 -> next load = last * 1.075.
|
| 12 |
|
- |
//! - RPE 4 or 5 -> stop; the last set is the top set.
|
| 13 |
|
- |
//! - A set that missed target reps also stops the diagnostic.
|
| 14 |
|
- |
//! - Hard cap at 8 sets in case someone keeps reporting easy RPEs.
|
| 15 |
|
- |
//!
|
| 16 |
|
- |
//! On completion, working weight = 0.9 * top load (mirrors the deload
|
|
8 |
+ |
//! Two protocols, one shape. Both escalate while the work feels easy, stop
|
|
9 |
+ |
//! when it gets hard or falls short of the target, and hand back 90% of the
|
|
10 |
+ |
//! top set as the working value. That ratio is
|
|
11 |
+ |
//! [`DIAGNOSTIC_WORKING_WEIGHT_RATIO`], deliberately equal to the deload
|
| 17 |
12 |
|
//! ratio in the progression state machine, so a fresh diagnostic and a
|
| 18 |
|
- |
//! post-deload rebuild converge on the same starting point). e1RM comes
|
| 19 |
|
- |
//! from the estimator applied to the last set.
|
|
13 |
+ |
//! post-deload rebuild converge on the same starting point. The RPE
|
|
14 |
+ |
//! response is shared through `step_up_factor` and `stops_on` so the two
|
|
15 |
+ |
//! cannot drift apart:
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! - RPE 1 or 2 -> escalate by 1.15.
|
|
18 |
+ |
//! - RPE 3 -> escalate by 1.075.
|
|
19 |
+ |
//! - RPE 4 or 5 -> stop.
|
|
20 |
+ |
//! - Hard cap at 8 sets, in case someone keeps reporting easy RPEs.
|
|
21 |
+ |
//!
|
|
22 |
+ |
//! **Reps** ([`next_step`]) escalates load, targets 5 reps per set, stops
|
|
23 |
+ |
//! early if a set misses that target, and reports an e1RM from the
|
|
24 |
+ |
//! estimator.
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! **Timed** ([`next_timed_step`]) escalates hold duration and stops early
|
|
27 |
+ |
//! if a hold falls short of what was prescribed. No e1RM: that arithmetic
|
|
28 |
+ |
//! is load-and-reps shaped and means nothing for a hold. Its top value is
|
|
29 |
+ |
//! the longest hold achieved rather than the last one, because the set that
|
|
30 |
+ |
//! ends the run is often a failed attempt.
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! **Distance has no protocol on purpose.** `DistanceKind` is track-only:
|
|
33 |
+ |
//! its `compute_prescription` always returns `NoHistory` and
|
|
34 |
+ |
//! `DistancePrescription` is a zero-sized placeholder. A diagnostic exists
|
|
35 |
+ |
//! to seed a progression, so with nothing to seed there is nothing to run.
|
|
36 |
+ |
//! Give distance a progression model first, then a diagnostic.
|
| 20 |
37 |
|
|
| 21 |
38 |
|
use crate::estimator::estimate_e1rm_from_set;
|
| 22 |
39 |
|
use crate::heuristics::{
|
| 23 |
40 |
|
DIAGNOSTIC_EASY_STEP_UP, DIAGNOSTIC_MAX_SETS, DIAGNOSTIC_MEDIUM_STEP_UP,
|
| 24 |
41 |
|
DIAGNOSTIC_TARGET_REPS, DIAGNOSTIC_WORKING_WEIGHT_RATIO,
|
| 25 |
42 |
|
};
|
| 26 |
|
- |
use crate::values::{Estimate, Load, Reps, Rpe};
|
|
43 |
+ |
use crate::values::{Duration, Estimate, Load, Reps, Rpe};
|
| 27 |
44 |
|
|
| 28 |
45 |
|
/// Re-exports kept so external callers can name these directly. All
|
| 29 |
46 |
|
/// values live in [`crate::heuristics`].
|
| 73 |
90 |
|
|
| 74 |
91 |
|
let last = done[done.len() - 1];
|
| 75 |
92 |
|
|
| 76 |
|
- |
let stop = last.rpe.get() >= 4
|
| 77 |
|
- |
|| last.reps.get() < TARGET_REPS
|
| 78 |
|
- |
|| done.len() >= MAX_SETS;
|
| 79 |
|
- |
if stop {
|
|
93 |
+ |
if stops_on(last.rpe, done.len()) || last.reps.get() < TARGET_REPS {
|
| 80 |
94 |
|
return complete(&last);
|
| 81 |
95 |
|
}
|
| 82 |
96 |
|
|
| 83 |
|
- |
let factor = match last.rpe.get() {
|
| 84 |
|
- |
1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
|
| 85 |
|
- |
3 => DIAGNOSTIC_MEDIUM_STEP_UP,
|
| 86 |
|
- |
// Above RPE 3 is handled by `stop`; anything unexpected keeps
|
| 87 |
|
- |
// the load flat rather than pushing further.
|
| 88 |
|
- |
_ => 1.0,
|
| 89 |
|
- |
};
|
|
97 |
+ |
let factor = step_up_factor(last.rpe);
|
| 90 |
98 |
|
DiagnosticStep::Prescribe {
|
| 91 |
99 |
|
load: Load::new(round_to_increment(last.load.get() * factor, increment))
|
| 92 |
100 |
|
.expect("escalation of non-negative load stays non-negative"),
|
| 113 |
121 |
|
(value / increment).round() * increment
|
| 114 |
122 |
|
}
|
| 115 |
123 |
|
|
|
124 |
+ |
/// Escalation factor for the next set given how the last one felt.
|
|
125 |
+ |
/// Shared by the reps and timed protocols so the two cannot drift: an
|
|
126 |
+ |
/// "easy" hold escalates by the same ratio an easy lift does.
|
|
127 |
+ |
/// RPE 4 and 5 are handled by the stop rules before this is reached;
|
|
128 |
+ |
/// anything unexpected holds flat rather than pushing further.
|
|
129 |
+ |
fn step_up_factor(rpe: Rpe) -> f64 {
|
|
130 |
+ |
match rpe.get() {
|
|
131 |
+ |
1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
|
|
132 |
+ |
3 => DIAGNOSTIC_MEDIUM_STEP_UP,
|
|
133 |
+ |
_ => 1.0,
|
|
134 |
+ |
}
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
/// Whether the diagnostic stops after this set, ignoring the
|
|
138 |
+ |
/// kind-specific "missed the target" rule the caller adds.
|
|
139 |
+ |
fn stops_on(rpe: Rpe, done_len: usize) -> bool {
|
|
140 |
+ |
rpe.get() >= 4 || done_len >= MAX_SETS
|
|
141 |
+ |
}
|
|
142 |
+ |
|
|
143 |
+ |
// ---- timed protocol --------------------------------------------------------
|
|
144 |
+ |
|
|
145 |
+ |
/// One completed hold inside a timed diagnostic.
|
|
146 |
+ |
///
|
|
147 |
+ |
/// `achieved` is what was actually held, which is not always what was
|
|
148 |
+ |
/// prescribed: a hold that fails early still carries signal, and the
|
|
149 |
+ |
/// protocol treats falling short of the prescription the way the reps
|
|
150 |
+ |
/// protocol treats missing target reps.
|
|
151 |
+ |
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
152 |
+ |
pub struct TimedDiagnosticSet {
|
|
153 |
+ |
/// What the protocol asked for on this set.
|
|
154 |
+ |
pub prescribed: Duration,
|
|
155 |
+ |
/// What was actually held.
|
|
156 |
+ |
pub achieved: Duration,
|
|
157 |
+ |
pub rpe: Rpe,
|
|
158 |
+ |
}
|
|
159 |
+ |
|
|
160 |
+ |
#[derive(Debug, Clone, PartialEq)]
|
|
161 |
+ |
pub enum TimedDiagnosticStep {
|
|
162 |
+ |
/// Prescribe the next hold.
|
|
163 |
+ |
Prescribe { duration: Duration },
|
|
164 |
+ |
/// Diagnostic is over. `top_duration` is the longest completed hold;
|
|
165 |
+ |
/// `working_duration` is what to seed progression with.
|
|
166 |
+ |
///
|
|
167 |
+ |
/// No e1RM: that estimate is load-and-reps arithmetic and has no
|
|
168 |
+ |
/// meaning for a hold.
|
|
169 |
+ |
Complete {
|
|
170 |
+ |
top_duration: Duration,
|
|
171 |
+ |
working_duration: Duration,
|
|
172 |
+ |
},
|
|
173 |
+ |
}
|
|
174 |
+ |
|
|
175 |
+ |
/// Next step in a timed diagnostic, given a seed hold and the holds
|
|
176 |
+ |
/// already done. Empty slice returns the opening prescription at `seed`.
|
|
177 |
+ |
///
|
|
178 |
+ |
/// Same shape as [`next_step`]: escalate while the hold is easy, stop
|
|
179 |
+ |
/// when it gets hard or falls short, then take 90% of the top hold as
|
|
180 |
+ |
/// the working duration. Distance has no equivalent because it is
|
|
181 |
+ |
/// track-only and produces no prescription to seed.
|
|
182 |
+ |
pub fn next_timed_step(seed: Duration, done: &[TimedDiagnosticSet]) -> TimedDiagnosticStep {
|
|
183 |
+ |
let Some(last) = done.last() else {
|
|
184 |
+ |
return TimedDiagnosticStep::Prescribe { duration: seed };
|
|
185 |
+ |
};
|
|
186 |
+ |
|
|
187 |
+ |
let fell_short = last.achieved.seconds() < last.prescribed.seconds();
|
|
188 |
+ |
if stops_on(last.rpe, done.len()) || fell_short {
|
|
189 |
+ |
return complete_timed(done);
|
|
190 |
+ |
}
|
|
191 |
+ |
|
|
192 |
+ |
let next = (f64::from(last.achieved.seconds()) * step_up_factor(last.rpe)).round();
|
|
193 |
+ |
// Escalating a zero-second hold would stay at zero forever; the cap
|
|
194 |
+ |
// on set count still terminates it, but prescribing 0s is useless,
|
|
195 |
+ |
// so nudge by a second.
|
|
196 |
+ |
let next = if next as i32 <= last.achieved.seconds() {
|
|
197 |
+ |
last.achieved.seconds() + 1
|
|
198 |
+ |
} else {
|
|
199 |
+ |
next as i32
|
|
200 |
+ |
};
|
|
201 |
+ |
TimedDiagnosticStep::Prescribe {
|
|
202 |
+ |
duration: Duration::from_seconds(next).expect("escalation of a non-negative hold"),
|
|
203 |
+ |
}
|
|
204 |
+ |
}
|
|
205 |
+ |
|
|
206 |
+ |
fn complete_timed(done: &[TimedDiagnosticSet]) -> TimedDiagnosticStep {
|
|
207 |
+ |
// Longest hold actually achieved, not the last one: a set that fell
|
|
208 |
+ |
// short ends the diagnostic but must not be mistaken for the best.
|
|
209 |
+ |
let top = done
|
|
210 |
+ |
.iter()
|
|
211 |
+ |
.map(|s| s.achieved.seconds())
|
|
212 |
+ |
.max()
|
|
213 |
+ |
.unwrap_or_default();
|
|
214 |
+ |
let working = (f64::from(top) * DIAGNOSTIC_WORKING_WEIGHT_RATIO).round() as i32;
|
|
215 |
+ |
TimedDiagnosticStep::Complete {
|
|
216 |
+ |
top_duration: Duration::from_seconds(top).expect("max of non-negative holds"),
|
|
217 |
+ |
working_duration: Duration::from_seconds(working).expect("ratio of a non-negative hold"),
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
|
| 116 |
221 |
|
#[cfg(test)]
|
| 117 |
222 |
|
mod tests {
|
| 118 |
223 |
|
use super::*;
|
| 238 |
343 |
|
assert_eq!(load.get(), 0.0);
|
| 239 |
344 |
|
}
|
| 240 |
345 |
|
}
|
|
346 |
+ |
|
|
347 |
+ |
#[cfg(test)]
|
|
348 |
+ |
mod timed_tests {
|
|
349 |
+ |
use super::*;
|
|
350 |
+ |
|
|
351 |
+ |
fn secs(n: i32) -> Duration {
|
|
352 |
+ |
Duration::from_seconds(n).unwrap()
|
|
353 |
+ |
}
|
|
354 |
+ |
|
|
355 |
+ |
fn hold(prescribed: i32, achieved: i32, rpe: i32) -> TimedDiagnosticSet {
|
|
356 |
+ |
TimedDiagnosticSet {
|
|
357 |
+ |
prescribed: secs(prescribed),
|
|
358 |
+ |
achieved: secs(achieved),
|
|
359 |
+ |
rpe: Rpe::new(rpe).unwrap(),
|
|
360 |
+ |
}
|
|
361 |
+ |
}
|
|
362 |
+ |
|
|
363 |
+ |
#[test]
|
|
364 |
+ |
fn empty_history_opens_at_the_seed() {
|
|
365 |
+ |
assert_eq!(
|
|
366 |
+ |
next_timed_step(secs(30), &[]),
|
|
367 |
+ |
TimedDiagnosticStep::Prescribe { duration: secs(30) }
|
|
368 |
+ |
);
|
|
369 |
+ |
}
|
|
370 |
+ |
|
|
371 |
+ |
#[test]
|
|
372 |
+ |
fn easy_hold_escalates_by_the_same_ratio_as_an_easy_lift() {
|
|
373 |
+ |
// 30s at RPE 2 -> 30 * 1.15 = 34.5 -> 35s.
|
|
374 |
+ |
assert_eq!(
|
|
375 |
+ |
next_timed_step(secs(30), &[hold(30, 30, 2)]),
|
|
376 |
+ |
TimedDiagnosticStep::Prescribe { duration: secs(35) }
|
|
377 |
+ |
);
|
|
378 |
+ |
}
|
|
379 |
+ |
|
|
380 |
+ |
#[test]
|
|
381 |
+ |
fn medium_hold_escalates_more_gently() {
|
|
382 |
+ |
// 40s at RPE 3 -> 40 * 1.075 = 43.
|
|
383 |
+ |
assert_eq!(
|
|
384 |
+ |
next_timed_step(secs(40), &[hold(40, 40, 3)]),
|
|
385 |
+ |
TimedDiagnosticStep::Prescribe { duration: secs(43) }
|
|
386 |
+ |
);
|
|
387 |
+ |
}
|
|
388 |
+ |
|
|
389 |
+ |
#[test]
|
|
390 |
+ |
fn hard_hold_stops_and_takes_ninety_percent() {
|
|
391 |
+ |
// 60s at RPE 4 stops. Working = 0.9 * 60 = 54.
|
|
392 |
+ |
assert_eq!(
|
|
393 |
+ |
next_timed_step(secs(60), &[hold(60, 60, 4)]),
|
|
394 |
+ |
TimedDiagnosticStep::Complete {
|
|
395 |
+ |
top_duration: secs(60),
|
|
396 |
+ |
working_duration: secs(54),
|
|
397 |
+ |
}
|
|
398 |
+ |
);
|
|
399 |
+ |
}
|
|
400 |
+ |
|
|
401 |
+ |
#[test]
|
|
402 |
+ |
fn falling_short_of_the_prescription_stops_the_diagnostic() {
|
|
403 |
+ |
// Asked for 60s, held 45s at an easy RPE. Falling short ends it
|
|
404 |
+ |
// regardless of how it felt.
|
|
405 |
+ |
assert_eq!(
|
|
406 |
+ |
next_timed_step(secs(30), &[hold(30, 30, 2), hold(60, 45, 2)]),
|
|
407 |
+ |
TimedDiagnosticStep::Complete {
|
|
408 |
+ |
top_duration: secs(45),
|
|
409 |
+ |
working_duration: secs(41),
|
|
410 |
+ |
}
|
|
411 |
+ |
);
|
|
412 |
+ |
}
|
|
413 |
+ |
|
|
414 |
+ |
#[test]
|
|
415 |
+ |
fn top_duration_is_the_best_hold_not_the_last() {
|
|
416 |
+ |
// A strong 80s hold then a failed 90s attempt: the top is 80,
|
|
417 |
+ |
// not the 50 that ended the run.
|
|
418 |
+ |
let done = [hold(80, 80, 3), hold(90, 50, 5)];
|
|
419 |
+ |
assert_eq!(
|
|
420 |
+ |
next_timed_step(secs(80), &done),
|
|
421 |
+ |
TimedDiagnosticStep::Complete {
|
|
422 |
+ |
top_duration: secs(80),
|
|
423 |
+ |
working_duration: secs(72),
|
|
424 |
+ |
}
|
|
425 |
+ |
);
|
|
426 |
+ |
}
|
|
427 |
+ |
|
|
428 |
+ |
#[test]
|
|
429 |
+ |
fn set_cap_terminates_an_endless_easy_run() {
|
|
430 |
+ |
let done: Vec<TimedDiagnosticSet> =
|
|
431 |
+ |
(0..MAX_SETS).map(|_| hold(30, 30, 1)).collect();
|
|
432 |
+ |
assert!(matches!(
|
|
433 |
+ |
next_timed_step(secs(30), &done),
|
|
434 |
+ |
TimedDiagnosticStep::Complete { .. }
|
|
435 |
+ |
));
|
|
436 |
+ |
}
|
|
437 |
+ |
|
|
438 |
+ |
#[test]
|
|
439 |
+ |
fn zero_second_hold_still_escalates() {
|
|
440 |
+ |
// 0 * 1.15 is 0, which would loop until the set cap prescribing
|
|
441 |
+ |
// nothing. Nudge to 1s instead.
|
|
442 |
+ |
assert_eq!(
|
|
443 |
+ |
next_timed_step(secs(0), &[hold(0, 0, 1)]),
|
|
444 |
+ |
TimedDiagnosticStep::Prescribe { duration: secs(1) }
|
|
445 |
+ |
);
|
|
446 |
+ |
}
|
|
447 |
+ |
}
|