Skip to main content

max / ripgrow

7.8 KB · 241 lines History Blame Raw
1 //! First-session diagnostic protocol.
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`).
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
17 //! 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.
20
21 use crate::estimator::estimate_e1rm_from_set;
22 use crate::heuristics::{
23 DIAGNOSTIC_EASY_STEP_UP, DIAGNOSTIC_MAX_SETS, DIAGNOSTIC_MEDIUM_STEP_UP,
24 DIAGNOSTIC_TARGET_REPS, DIAGNOSTIC_WORKING_WEIGHT_RATIO,
25 };
26 use crate::values::{Estimate, Load, Reps, Rpe};
27
28 /// Re-exports kept so external callers can name these directly. All
29 /// values live in [`crate::heuristics`].
30 pub const TARGET_REPS: i32 = DIAGNOSTIC_TARGET_REPS;
31 pub const MAX_SETS: usize = DIAGNOSTIC_MAX_SETS;
32
33 /// One completed set inside a diagnostic. Same shape as
34 /// [`crate::RepsSet`]'s working columns, kept separate so the protocol
35 /// can be unit-tested without touching the DB layer.
36 #[derive(Debug, Clone, Copy, PartialEq)]
37 pub struct DiagnosticSet {
38 pub load: Load,
39 pub reps: Reps,
40 pub rpe: Rpe,
41 }
42
43 #[derive(Debug, Clone, PartialEq)]
44 pub enum DiagnosticStep {
45 /// Prescribe the next set. `reps` is the target rep count.
46 Prescribe { load: Load, reps: Reps },
47 /// Diagnostic is over. `top_load` is the heaviest completed set;
48 /// `working_weight` is what to seed progression with; `e1rm` is the
49 /// estimator's read on the last set (`None` if the last set carried
50 /// no signal, e.g. warmup RPE). The e1RM comes back as an Estimate
51 /// so callers can display the method label if they want.
52 Complete {
53 top_load: Load,
54 working_weight: Load,
55 e1rm: Option<Estimate<f64>>,
56 },
57 }
58
59 /// Next step in a diagnostic given the seed load and sets already done.
60 /// Passing an empty slice returns the opening prescription at `seed`.
61 ///
62 /// `seed` and `increment` stay as raw `f64` because they're arithmetic
63 /// intermediates, not validated observations. The prescribed load returns
64 /// as a `Load` newtype.
65 pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> DiagnosticStep {
66 if done.is_empty() {
67 return DiagnosticStep::Prescribe {
68 load: Load::new(round_to_increment(seed.max(0.0), increment))
69 .expect("seed clamped to non-negative"),
70 reps: Reps::new(TARGET_REPS).unwrap(),
71 };
72 }
73
74 let last = done[done.len() - 1];
75
76 let stop = last.rpe.get() >= 4
77 || last.reps.get() < TARGET_REPS
78 || done.len() >= MAX_SETS;
79 if stop {
80 return complete(&last);
81 }
82
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 };
90 DiagnosticStep::Prescribe {
91 load: Load::new(round_to_increment(last.load.get() * factor, increment))
92 .expect("escalation of non-negative load stays non-negative"),
93 reps: Reps::new(TARGET_REPS).unwrap(),
94 }
95 }
96
97 fn complete(last: &DiagnosticSet) -> DiagnosticStep {
98 let top_load = last.load;
99 let working_weight = Load::new(top_load.get() * DIAGNOSTIC_WORKING_WEIGHT_RATIO)
100 .expect("ratio scaling of non-negative load stays non-negative");
101 let e1rm = estimate_e1rm_from_set(last.load, last.reps, last.rpe);
102 DiagnosticStep::Complete {
103 top_load,
104 working_weight,
105 e1rm,
106 }
107 }
108
109 fn round_to_increment(value: f64, increment: f64) -> f64 {
110 if increment <= 0.0 {
111 return value;
112 }
113 (value / increment).round() * increment
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119
120 fn set(load: f64, reps: i32, rpe: i32) -> DiagnosticSet {
121 DiagnosticSet {
122 load: Load::new(load).unwrap(),
123 reps: Reps::new(reps).unwrap(),
124 rpe: Rpe::new(rpe).unwrap(),
125 }
126 }
127
128 #[test]
129 fn empty_history_prescribes_seed() {
130 let step = next_step(60.0, 2.5, &[]);
131 assert_eq!(
132 step,
133 DiagnosticStep::Prescribe {
134 load: Load::new(60.0).unwrap(),
135 reps: Reps::new(TARGET_REPS).unwrap(),
136 }
137 );
138 }
139
140 #[test]
141 fn seed_is_rounded_to_increment() {
142 // 61 rounds to nearest 2.5 = 60.
143 let step = next_step(61.0, 2.5, &[]);
144 let DiagnosticStep::Prescribe { load, .. } = step else {
145 panic!("expected prescribe");
146 };
147 assert_eq!(load.get(), 60.0);
148 }
149
150 #[test]
151 fn rpe_2_escalates_fifteen_percent() {
152 let step = next_step(0.0, 2.5, &[set(100.0, 5, 2)]);
153 let DiagnosticStep::Prescribe { load, .. } = step else {
154 panic!("expected prescribe");
155 };
156 // 115 rounded to 2.5 = 115.
157 assert_eq!(load.get(), 115.0);
158 }
159
160 #[test]
161 fn rpe_3_escalates_seven_and_a_half_percent() {
162 let step = next_step(0.0, 2.5, &[set(100.0, 5, 3)]);
163 let DiagnosticStep::Prescribe { load, .. } = step else {
164 panic!("expected prescribe");
165 };
166 // 107.5 rounded to 2.5 = 107.5.
167 assert_eq!(load.get(), 107.5);
168 }
169
170 #[test]
171 fn rpe_4_completes_and_computes_working_weight() {
172 let step = next_step(0.0, 2.5, &[set(100.0, 5, 4)]);
173 let DiagnosticStep::Complete {
174 top_load,
175 working_weight,
176 e1rm,
177 } = step
178 else {
179 panic!("expected complete");
180 };
181 assert_eq!(top_load.get(), 100.0);
182 assert_eq!(working_weight.get(), 90.0);
183 assert!(e1rm.is_some());
184 }
185
186 #[test]
187 fn rpe_5_completes_with_last_set_as_top() {
188 let history = &[set(100.0, 5, 3), set(110.0, 5, 5)];
189 let step = next_step(0.0, 2.5, history);
190 let DiagnosticStep::Complete { top_load, .. } = step else {
191 panic!("expected complete");
192 };
193 assert_eq!(top_load.get(), 110.0);
194 }
195
196 #[test]
197 fn missed_target_reps_completes_early() {
198 let step = next_step(0.0, 2.5, &[set(100.0, 3, 3)]);
199 assert!(matches!(step, DiagnosticStep::Complete { .. }));
200 }
201
202 #[test]
203 fn hard_cap_completes_at_max_sets() {
204 let history: Vec<DiagnosticSet> =
205 (0..MAX_SETS).map(|_| set(100.0, 5, 2)).collect();
206 let step = next_step(0.0, 2.5, &history);
207 assert!(matches!(step, DiagnosticStep::Complete { .. }));
208 }
209
210 #[test]
211 fn escalation_chain_reaches_stop() {
212 let history = vec![
213 set(60.0, 5, 2),
214 set(70.0, 5, 2),
215 set(80.0, 5, 3),
216 set(85.0, 5, 4),
217 ];
218 let step = next_step(60.0, 2.5, &history);
219 let DiagnosticStep::Complete {
220 top_load,
221 working_weight,
222 ..
223 } = step
224 else {
225 panic!("expected complete");
226 };
227 assert_eq!(top_load.get(), 85.0);
228 assert_eq!(working_weight.get(), 76.5);
229 }
230
231 #[test]
232 fn increment_zero_skips_rounding() {
233 // Bodyweight-style exercise: seed 0, escalation is a no-op.
234 let step = next_step(0.0, 0.0, &[set(0.0, 5, 2)]);
235 let DiagnosticStep::Prescribe { load, .. } = step else {
236 panic!("expected prescribe");
237 };
238 assert_eq!(load.get(), 0.0);
239 }
240 }
241