Skip to main content

max / ripgrow

diagnostic: DiagnosticSet and DiagnosticStep carry newtypes The protocol's inputs and outputs are now typed. DiagnosticSet fields are Load/Reps/Rpe. DiagnosticStep::Prescribe returns Load and Reps; Complete returns Load for top_load and working_weight and Option<Estimate<f64>> for the e1RM (so the result screen can render the method label alongside the number). The diagnostic TUI's Result stage now shows "e1RM: 132.5 kg (epley on reps + rir (from rpe))" — the estimator's own label bleeds through, which is exactly the honesty the newtype refactor was meant to buy. Phase 1 complete: values, heuristics, Estimate<T>, LoadUnit rename, SessionSet/Prescription/Exercise/DiagnosticSet all wear typed primitives, and all four heuristic-heavy modules (progression, diagnostic, generation, estimator) source their constants from a single named-and-explained home. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:38 UTC
Commit: 2f9849e8fcad9750545add90822cd7eac0261516
Parent: 5fcdf7d
2 files changed, +73 insertions, -62 deletions
@@ -18,11 +18,12 @@
18 18 //! post-deload rebuild converge on the same starting point). e1RM comes
19 19 //! from the estimator applied to the last set.
20 20
21 - use crate::estimator::estimate_e1rm;
21 + use crate::estimator::estimate_e1rm_from_set;
22 22 use crate::heuristics::{
23 23 DIAGNOSTIC_EASY_STEP_UP, DIAGNOSTIC_MAX_SETS, DIAGNOSTIC_MEDIUM_STEP_UP,
24 24 DIAGNOSTIC_TARGET_REPS, DIAGNOSTIC_WORKING_WEIGHT_RATIO,
25 25 };
26 + use crate::values::{Estimate, Load, Reps, Rpe};
26 27
27 28 /// Re-exports kept so external callers can name these directly. All
28 29 /// values live in [`crate::heuristics`].
@@ -34,44 +35,52 @@ pub const MAX_SETS: usize = DIAGNOSTIC_MAX_SETS;
34 35 /// without touching the DB layer.
35 36 #[derive(Debug, Clone, Copy, PartialEq)]
36 37 pub struct DiagnosticSet {
37 - pub load: f64,
38 - pub reps: i32,
39 - pub rpe: i32,
38 + pub load: Load,
39 + pub reps: Reps,
40 + pub rpe: Rpe,
40 41 }
41 42
42 43 #[derive(Debug, Clone, PartialEq)]
43 44 pub enum DiagnosticStep {
44 45 /// Prescribe the next set. `reps` is the target rep count.
45 - Prescribe { load: f64, reps: i32 },
46 + Prescribe { load: Load, reps: Reps },
46 47 /// Diagnostic is over. `top_load` is the heaviest completed set;
47 48 /// `working_weight` is what to seed progression with; `e1rm` is the
48 49 /// estimator's read on the last set (`None` if the last set carried
49 - /// no signal, e.g. warmup RPE).
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.
50 52 Complete {
51 - top_load: f64,
52 - working_weight: f64,
53 - e1rm: Option<f64>,
53 + top_load: Load,
54 + working_weight: Load,
55 + e1rm: Option<Estimate<f64>>,
54 56 },
55 57 }
56 58
57 59 /// Next step in a diagnostic given the seed load and sets already done.
58 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.
59 65 pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> DiagnosticStep {
60 66 if done.is_empty() {
61 67 return DiagnosticStep::Prescribe {
62 - load: round_to_increment(seed, increment),
63 - reps: TARGET_REPS,
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(),
64 71 };
65 72 }
66 73
67 74 let last = done[done.len() - 1];
68 75
69 - let stop = last.rpe >= 4 || last.reps < TARGET_REPS || done.len() >= MAX_SETS;
76 + let stop = last.rpe.get() >= 4
77 + || last.reps.get() < TARGET_REPS
78 + || done.len() >= MAX_SETS;
70 79 if stop {
71 80 return complete(&last);
72 81 }
73 82
74 - let factor = match last.rpe {
83 + let factor = match last.rpe.get() {
75 84 1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
76 85 3 => DIAGNOSTIC_MEDIUM_STEP_UP,
77 86 // Above RPE 3 is handled by `stop`; anything unexpected keeps
@@ -79,15 +88,17 @@ pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> Diagnosti
79 88 _ => 1.0,
80 89 };
81 90 DiagnosticStep::Prescribe {
82 - load: round_to_increment(last.load * factor, increment),
83 - reps: TARGET_REPS,
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(),
84 94 }
85 95 }
86 96
87 97 fn complete(last: &DiagnosticSet) -> DiagnosticStep {
88 98 let top_load = last.load;
89 - let working_weight = top_load * DIAGNOSTIC_WORKING_WEIGHT_RATIO;
90 - let e1rm = estimate_e1rm(last.load, last.reps, last.rpe);
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);
91 102 DiagnosticStep::Complete {
92 103 top_load,
93 104 working_weight,
@@ -107,7 +118,11 @@ mod tests {
107 118 use super::*;
108 119
109 120 fn set(load: f64, reps: i32, rpe: i32) -> DiagnosticSet {
110 - DiagnosticSet { load, reps, rpe }
121 + DiagnosticSet {
122 + load: Load::new(load).unwrap(),
123 + reps: Reps::new(reps).unwrap(),
124 + rpe: Rpe::new(rpe).unwrap(),
125 + }
111 126 }
112 127
113 128 #[test]
@@ -116,8 +131,8 @@ mod tests {
116 131 assert_eq!(
117 132 step,
118 133 DiagnosticStep::Prescribe {
119 - load: 60.0,
120 - reps: TARGET_REPS,
134 + load: Load::new(60.0).unwrap(),
135 + reps: Reps::new(TARGET_REPS).unwrap(),
121 136 }
122 137 );
123 138 }
@@ -129,7 +144,7 @@ mod tests {
129 144 let DiagnosticStep::Prescribe { load, .. } = step else {
130 145 panic!("expected prescribe");
131 146 };
132 - assert_eq!(load, 60.0);
147 + assert_eq!(load.get(), 60.0);
133 148 }
134 149
135 150 #[test]
@@ -139,7 +154,7 @@ mod tests {
139 154 panic!("expected prescribe");
140 155 };
141 156 // 115 rounded to 2.5 = 115.
142 - assert_eq!(load, 115.0);
157 + assert_eq!(load.get(), 115.0);
143 158 }
144 159
145 160 #[test]
@@ -149,7 +164,7 @@ mod tests {
149 164 panic!("expected prescribe");
150 165 };
151 166 // 107.5 rounded to 2.5 = 107.5.
152 - assert_eq!(load, 107.5);
167 + assert_eq!(load.get(), 107.5);
153 168 }
154 169
155 170 #[test]
@@ -163,8 +178,8 @@ mod tests {
163 178 else {
164 179 panic!("expected complete");
165 180 };
166 - assert_eq!(top_load, 100.0);
167 - assert_eq!(working_weight, 90.0);
181 + assert_eq!(top_load.get(), 100.0);
182 + assert_eq!(working_weight.get(), 90.0);
168 183 assert!(e1rm.is_some());
169 184 }
170 185
@@ -175,7 +190,7 @@ mod tests {
175 190 let DiagnosticStep::Complete { top_load, .. } = step else {
176 191 panic!("expected complete");
177 192 };
178 - assert_eq!(top_load, 110.0);
193 + assert_eq!(top_load.get(), 110.0);
179 194 }
180 195
181 196 #[test]
@@ -194,13 +209,11 @@ mod tests {
194 209
195 210 #[test]
196 211 fn escalation_chain_reaches_stop() {
197 - // Start at 60, escalate on easy RPEs, stop when the user finally
198 - // reports RPE 4.
199 212 let history = vec![
200 - set(60.0, 5, 2), // -> next 70 (60*1.15=69, round to 70 on inc 2.5)
201 - set(70.0, 5, 2), // -> next 80.5, round to 80
202 - set(80.0, 5, 3), // -> next 86.0, round to 85
203 - set(85.0, 5, 4), // stop
213 + set(60.0, 5, 2),
214 + set(70.0, 5, 2),
215 + set(80.0, 5, 3),
216 + set(85.0, 5, 4),
204 217 ];
205 218 let step = next_step(60.0, 2.5, &history);
206 219 let DiagnosticStep::Complete {
@@ -211,8 +224,8 @@ mod tests {
211 224 else {
212 225 panic!("expected complete");
213 226 };
214 - assert_eq!(top_load, 85.0);
215 - assert_eq!(working_weight, 76.5);
227 + assert_eq!(top_load.get(), 85.0);
228 + assert_eq!(working_weight.get(), 76.5);
216 229 }
217 230
218 231 #[test]
@@ -222,6 +235,6 @@ mod tests {
222 235 let DiagnosticStep::Prescribe { load, .. } = step else {
223 236 panic!("expected prescribe");
224 237 };
225 - assert_eq!(load, 0.0);
238 + assert_eq!(load.get(), 0.0);
226 239 }
227 240 }
@@ -22,7 +22,7 @@ use ratatui::style::{Modifier, Style};
22 22 use ratatui::text::{Line, Span};
23 23 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
24 24 use ripgrow_core::diagnostic::{DiagnosticSet, DiagnosticStep, next_step};
25 - use ripgrow_core::{Db, Exercise, SessionSet};
25 + use ripgrow_core::{Db, Estimate, Exercise, Load, Reps, Rpe, SessionSet};
26 26
27 27 pub struct DiagnosticScreen {
28 28 date: NaiveDate,
@@ -48,16 +48,16 @@ enum Stage {
48 48 prefilled: bool,
49 49 },
50 50 Run {
51 - next_load: f64,
52 - target_reps: i32,
51 + next_load: Load,
52 + target_reps: Reps,
53 53 committed: Vec<DiagnosticSet>,
54 54 pending: PendingRow,
55 55 focus: RunField,
56 56 },
57 57 Result {
58 - top_load: f64,
59 - working_weight: f64,
60 - e1rm: Option<f64>,
58 + top_load: Load,
59 + working_weight: Load,
60 + e1rm: Option<Estimate<f64>>,
61 61 },
62 62 }
63 63
@@ -304,7 +304,7 @@ impl DiagnosticScreen {
304 304 return;
305 305 }
306 306 // Reset the prescription to what next_step wants now.
307 - let step = next_step(*next_load, s.exercise.increment, committed);
307 + let step = next_step(next_load.get(), s.exercise.increment, committed);
308 308 if let DiagnosticStep::Prescribe { load, reps } = step {
309 309 *next_load = load;
310 310 *target_reps = reps;
@@ -387,21 +387,18 @@ fn commit_diagnostic_set(
387 387 db: &Db,
388 388 exercise: &Exercise,
389 389 date: NaiveDate,
390 - prescribed_load: f64,
391 - target_reps: i32,
390 + prescribed_load: Load,
391 + target_reps: Reps,
392 392 pending: &mut PendingRow,
393 393 committed: &mut Vec<DiagnosticSet>,
394 394 ) -> Result<(), ripgrow_core::Error> {
395 - let reps_raw: i32 = parse_field("reps", &pending.reps)?;
396 - let rpe_raw: i32 = parse_field("rpe", &pending.rpe)?;
397 - let load = ripgrow_core::Load::new(prescribed_load)?;
398 - let reps = ripgrow_core::Reps::new(reps_raw)?;
399 - let rpe = ripgrow_core::Rpe::new(rpe_raw)?;
400 - db.append_diagnostic_set(exercise.id, date, load, reps, rpe)?;
395 + let reps = Reps::new(parse_field("reps", &pending.reps)?)?;
396 + let rpe = Rpe::new(parse_field("rpe", &pending.rpe)?)?;
397 + db.append_diagnostic_set(exercise.id, date, prescribed_load, reps, rpe)?;
401 398 committed.push(DiagnosticSet {
402 399 load: prescribed_load,
403 - reps: reps_raw,
404 - rpe: rpe_raw,
400 + reps,
401 + rpe,
405 402 });
406 403 *pending = PendingRow::default();
407 404 let _ = target_reps; // reserved for future variable-target variants
@@ -434,9 +431,9 @@ fn load_prior_diagnostic_today(
434 431 list.into_iter()
435 432 .filter(|s| s.is_diagnostic)
436 433 .map(|s: SessionSet| DiagnosticSet {
437 - load: s.load.get(),
438 - reps: s.reps.get(),
439 - rpe: s.rpe.get(),
434 + load: s.load,
435 + reps: s.reps,
436 + rpe: s.rpe,
440 437 })
441 438 .collect()
442 439 }
@@ -509,18 +506,19 @@ fn render_session(frame: &mut Frame, area: Rect, s: &SessionState) {
509 506 e1rm,
510 507 } => {
511 508 let e1rm_str = match e1rm {
512 - Some(v) => format!("{v:.1} {}", s.exercise.load_unit),
509 + Some(est) => format!("{value:.1} {unit} ({method})",
510 + value = est.value, unit = s.exercise.load_unit, method = est.method),
513 511 None => "(no signal)".to_string(),
514 512 };
515 513 let text = format!(
516 514 "diagnostic complete\n\n\
517 - top load: {top_load} {unit}\n\
515 + top load: {top} {unit}\n\
518 516 working weight: {working:.1} {unit}\n\
519 517 e1RM: {e1rm_str}\n\n\
520 518 generate will now seed this exercise from the top load.",
521 - top_load = top_load,
519 + top = top_load.get(),
522 520 unit = s.exercise.load_unit,
523 - working = working_weight,
521 + working = working_weight.get(),
524 522 e1rm_str = e1rm_str,
525 523 );
526 524 frame.render_widget(Paragraph::new(text), inner);
@@ -533,8 +531,8 @@ fn render_run(
533 531 frame: &mut Frame,
534 532 area: Rect,
535 533 exercise: &Exercise,
536 - next_load: f64,
537 - target_reps: i32,
534 + next_load: Load,
535 + target_reps: Reps,
538 536 committed: &[DiagnosticSet],
539 537 pending: &PendingRow,
540 538 focus: RunField,
@@ -684,7 +682,7 @@ mod tests {
684 682 else {
685 683 panic!("expected run stage");
686 684 };
687 - assert_eq!(*next_load, 70.0);
685 + assert_eq!(next_load.get(), 70.0);
688 686 }
689 687
690 688 #[test]