Skip to main content

max / ripgrow

progression: Prescription carries Load and Reps newtypes The next-session prescription now surfaces Load and Reps directly. sets stays i32 because it's a count, not a value; adding a SetCount newtype for a small positive integer would be ceremony without payoff. Callers reach the raw f64/i32 via .get() only when the consumer genuinely wants a primitive (PDF formatting, sparkline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:30 UTC
Commit: 0fe0dc9b190491bbfd8e7bead84f4d44bb11a883
Parent: 942a801
3 files changed, +39 insertions, -32 deletions
@@ -10,6 +10,7 @@
10 10
11 11 use crate::heuristics::DELOAD_RATIO;
12 12 use crate::sets::SessionSet;
13 + use crate::values::{Load, Reps};
13 14
14 15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 16 pub enum State {
@@ -73,7 +74,9 @@ pub fn evaluate_session(sets: &[SessionSet], target_reps: i32) -> SessionOutcome
73 74
74 75 /// The prescription for the next session of a given exercise.
75 76 ///
76 - /// Not stored anywhere; recomputed on demand from history.
77 + /// Not stored anywhere; recomputed on demand from history. Note that
78 + /// `sets` here is a *count* of sets to program, not a `Set` — the
79 + /// primitive `i32` is fine.
77 80 #[derive(Debug, Clone, PartialEq)]
78 81 pub struct Prescription {
79 82 pub state: State,
@@ -81,9 +84,9 @@ pub struct Prescription {
81 84 /// user who typically does 3 sets keeps doing 3 sets.
82 85 pub sets: i32,
83 86 /// Target rep count. Same as the last session's target.
84 - pub reps: i32,
85 - /// Load in the exercise's `load_unit`.
86 - pub load: f64,
87 + pub reps: Reps,
88 + /// Load in the exercise's [`crate::LoadUnit`].
89 + pub load: Load,
87 90 }
88 91
89 92 /// Result of walking history + computing what comes next.
@@ -269,8 +272,11 @@ pub fn compute_prescription(
269 272 PrescriptionResult::Prescribed(Prescription {
270 273 state: walk.state,
271 274 sets: walk.last_set_count,
272 - reps: walk.last_target_reps,
273 - load: walk.next_load,
275 + // last_target_reps and next_load came out of a session that was
276 + // just validated at decode time, so unwrap is fine — a bad row
277 + // would have failed to load in the first place.
278 + reps: Reps::new(walk.last_target_reps).expect("target reps from validated history"),
279 + load: Load::new(walk.next_load).expect("next load from validated history"),
274 280 })
275 281 }
276 282
@@ -307,15 +313,16 @@ impl Db {
307 313 if matches!(result, PrescriptionResult::NoHistory)
308 314 && let Some(seed) = self.diagnostic_seed(exercise_id)?
309 315 {
310 - let load = round_to_increment(
316 + let load_raw = round_to_increment(
311 317 seed * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO,
312 318 increment,
313 319 );
314 320 result = PrescriptionResult::Prescribed(Prescription {
315 321 state: State::Progressing,
316 322 sets: 3,
317 - reps: 5,
318 - load,
323 + reps: Reps::new(5).unwrap(),
324 + load: Load::new(load_raw)
325 + .expect("diagnostic seed produces non-negative load"),
319 326 });
320 327 }
321 328
@@ -390,8 +397,8 @@ mod db_tests {
390 397 };
391 398 assert_eq!(pres.state, State::Progressing);
392 399 assert_eq!(pres.sets, 3);
393 - assert_eq!(pres.reps, 5);
394 - assert_eq!(pres.load, 90.0);
400 + assert_eq!(pres.reps.get(), 5);
401 + assert_eq!(pres.load.get(), 90.0);
395 402 }
396 403
397 404 #[test]
@@ -405,7 +412,7 @@ mod db_tests {
405 412 panic!("expected prescription");
406 413 };
407 414 // Working set at 60 with a clean first-session hit -> next 62.5.
408 - assert_eq!(pres.load, 62.5);
415 + assert_eq!(pres.load.get(), 62.5);
409 416 }
410 417
411 418 #[test]
@@ -422,7 +429,7 @@ mod db_tests {
422 429 panic!("expected prescription");
423 430 };
424 431 assert_eq!(pres.state, State::Progressing);
425 - assert_eq!(pres.load, 102.5, "hold keeps load flat");
432 + assert_eq!(pres.load.get(), 102.5, "hold keeps load flat");
426 433 assert_eq!(
427 434 db.read_progression_state(id).unwrap(),
428 435 Some(State::Progressing)
@@ -509,8 +516,8 @@ mod tests {
509 516 };
510 517 assert_eq!(pres.state, State::Progressing);
511 518 assert_eq!(pres.sets, 3);
512 - assert_eq!(pres.reps, 5);
513 - assert_eq!(pres.load, 102.5);
519 + assert_eq!(pres.reps.get(), 5);
520 + assert_eq!(pres.load.get(), 102.5);
514 521 }
515 522
516 523 #[test]
@@ -523,7 +530,7 @@ mod tests {
523 530 panic!()
524 531 };
525 532 assert_eq!(pres.state, State::Progressing);
526 - assert_eq!(pres.load, 102.5);
533 + assert_eq!(pres.load.get(), 102.5);
527 534 }
528 535
529 536 #[test]
@@ -540,7 +547,7 @@ mod tests {
540 547 panic!()
541 548 };
542 549 assert_eq!(pres.state, State::Probation);
543 - assert_eq!(pres.load, 102.5);
550 + assert_eq!(pres.load.get(), 102.5);
544 551 }
545 552
546 553 #[test]
@@ -558,7 +565,7 @@ mod tests {
558 565 panic!()
559 566 };
560 567 assert_eq!(pres.state, State::Progressing);
561 - assert_eq!(pres.load, 105.0);
568 + assert_eq!(pres.load.get(), 105.0);
562 569 }
563 570
564 571 #[test]
@@ -581,7 +588,7 @@ mod tests {
581 588 };
582 589 assert_eq!(pres.state, State::Deloading);
583 590 // 100 * 0.9 = 90, rounded to nearest 2.5 -> 90.
584 - assert_eq!(pres.load, 90.0);
591 + assert_eq!(pres.load.get(), 90.0);
585 592 }
586 593
587 594 #[test]
@@ -610,7 +617,7 @@ mod tests {
610 617 panic!()
611 618 };
612 619 assert_eq!(pres.state, State::Progressing);
613 - assert_eq!(pres.load, 102.5);
620 + assert_eq!(pres.load.get(), 102.5);
614 621 }
615 622
616 623 #[test]
@@ -633,7 +640,7 @@ mod tests {
633 640 panic!()
634 641 };
635 642 assert_eq!(pres.state, State::Rebuilding);
636 - assert_eq!(pres.load, 92.5);
643 + assert_eq!(pres.load.get(), 92.5);
637 644 }
638 645
639 646 #[test]
@@ -642,7 +649,7 @@ mod tests {
642 649 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 0.0) else {
643 650 panic!()
644 651 };
645 - assert_eq!(pres.load, 0.0);
652 + assert_eq!(pres.load.get(), 0.0);
646 653 }
647 654
648 655 #[test]
@@ -664,6 +671,6 @@ mod tests {
664 671 panic!()
665 672 };
666 673 // 82.5 * 0.9 = 74.25, nearest 2.5 = 75.
667 - assert_eq!(pres.load, 75.0);
674 + assert_eq!(pres.load.get(), 75.0);
668 675 }
669 676 }
@@ -52,7 +52,7 @@ pub struct PrescriptionParts {
52 52 impl PrescriptionParts {
53 53 pub fn from(ex: &Exercise, p: &Prescription) -> Self {
54 54 match ex.resistance_type {
55 - ResistanceType::Bodyweight if p.load == 0.0 => Self {
55 + ResistanceType::Bodyweight if p.load.get() == 0.0 => Self {
56 56 sets: p.sets.to_string(),
57 57 reps: p.reps.to_string(),
58 58 load: "BW".to_string(),
@@ -61,19 +61,19 @@ impl PrescriptionParts {
61 61 ResistanceType::Bodyweight => Self {
62 62 sets: p.sets.to_string(),
63 63 reps: p.reps.to_string(),
64 - load: format!("BW+{}", trim(p.load)),
64 + load: format!("BW+{}", trim(p.load.get())),
65 65 unit: ex.load_unit.clone(),
66 66 },
67 67 ResistanceType::CardioTime | ResistanceType::CardioDistance => Self {
68 68 sets: "-".to_string(),
69 69 reps: "-".to_string(),
70 - load: trim(p.load),
70 + load: trim(p.load.get()),
71 71 unit: ex.load_unit.clone(),
72 72 },
73 73 ResistanceType::Freeweight | ResistanceType::Machine => Self {
74 74 sets: p.sets.to_string(),
75 75 reps: p.reps.to_string(),
76 - load: trim(p.load),
76 + load: trim(p.load.get()),
77 77 unit: ex.load_unit.clone(),
78 78 },
79 79 }
@@ -149,7 +149,7 @@ pub fn format_prescription(ex: &Exercise, p: &Prescription) -> String {
149 149 let parts = PrescriptionParts::from(ex, p);
150 150 let (sets, reps, load, unit) = (parts.sets, parts.reps, parts.load, parts.unit);
151 151 match ex.resistance_type {
152 - ResistanceType::Bodyweight if p.load == 0.0 => format!("{sets} x {reps}"),
152 + ResistanceType::Bodyweight if p.load.get() == 0.0 => format!("{sets} x {reps}"),
153 153 ResistanceType::Bodyweight => format!("{sets} x {reps} {load} {unit}"),
154 154 ResistanceType::CardioTime | ResistanceType::CardioDistance => {
155 155 format!("{load} {unit}")
@@ -278,8 +278,8 @@ mod tests {
278 278 Prescription {
279 279 state: State::Progressing,
280 280 sets,
281 - reps,
282 - load,
281 + reps: ripgrow_core::Reps::new(reps).unwrap(),
282 + load: ripgrow_core::Load::new(load).unwrap(),
283 283 }
284 284 }
285 285
@@ -252,7 +252,7 @@ impl GenerateScreen {
252 252 let (parts, glyph) = match db.compute_prescription(ex.id) {
253 253 Ok(PrescriptionResult::Prescribed(pres)) => {
254 254 let parts = PrescriptionParts::from(ex, &pres);
255 - let g = direction_glyph(db, ex, pres.load);
255 + let g = direction_glyph(db, ex, pres.load.get());
256 256 (parts, g.to_string())
257 257 }
258 258 Ok(PrescriptionResult::NoHistory) => (
@@ -485,7 +485,7 @@ fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) {
485 485 Ok(PrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "),
486 486 Ok(PrescriptionResult::Prescribed(p)) => {
487 487 let text = format_prescription(&p, &ex.load_unit);
488 - let glyph = direction_glyph(db, ex, p.load);
488 + let glyph = direction_glyph(db, ex, p.load.get());
489 489 (text, glyph)
490 490 }
491 491 Err(_) => ("(error)".to_string(), " "),