Skip to main content

max / ripgrow

estimator, progression, diagnostic, generation: source constants from heuristics The inline magic numbers are gone. Deload ratio, diagnostic escalation multipliers, diagnostic working-weight ratio, generation's tag scores and recency parameters, and Epley's divisor all resolve to named consts in crate::heuristics; the modules that use them now compose policy rather than embedding it. estimate_e1rm gains a companion estimate_e1rm_from_set that takes the Load/Reps/Rpe newtypes and returns Estimate<f64> with the RTS method label, so downstream UI can render "e1RM (epley on reps + rir)" instead of a bare number. The primitive-input wrapper stays for the DB layer, which reads raw columns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:08 UTC
Commit: baaf8957cec7507c1fc77433fdd5e9c8af178c2f
Parent: 7c74c55
4 files changed, +74 insertions, -56 deletions
@@ -19,10 +19,15 @@
19 19 //! from the estimator applied to the last set.
20 20
21 21 use crate::estimator::estimate_e1rm;
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 + };
22 26
23 - pub const TARGET_REPS: i32 = 5;
24 - pub const MAX_SETS: usize = 8;
25 - const WORKING_WEIGHT_RATIO: f64 = 0.9;
27 + /// Re-exports kept so external callers can name these directly. All
28 + /// values live in [`crate::heuristics`].
29 + pub const TARGET_REPS: i32 = DIAGNOSTIC_TARGET_REPS;
30 + pub const MAX_SETS: usize = DIAGNOSTIC_MAX_SETS;
26 31
27 32 /// One completed set inside a diagnostic. Same shape as `SessionSet`'s
28 33 /// working columns, kept separate so the protocol can be unit-tested
@@ -67,8 +72,8 @@ pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> Diagnosti
67 72 }
68 73
69 74 let factor = match last.rpe {
70 - 1 | 2 => 1.15,
71 - 3 => 1.075,
75 + 1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
76 + 3 => DIAGNOSTIC_MEDIUM_STEP_UP,
72 77 // Above RPE 3 is handled by `stop`; anything unexpected keeps
73 78 // the load flat rather than pushing further.
74 79 _ => 1.0,
@@ -81,7 +86,7 @@ pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> Diagnosti
81 86
82 87 fn complete(last: &DiagnosticSet) -> DiagnosticStep {
83 88 let top_load = last.load;
84 - let working_weight = top_load * WORKING_WEIGHT_RATIO;
89 + let working_weight = top_load * DIAGNOSTIC_WORKING_WEIGHT_RATIO;
85 90 let e1rm = estimate_e1rm(last.load, last.reps, last.rpe);
86 91 DiagnosticStep::Complete {
87 92 top_load,
@@ -8,69 +8,70 @@
8 8 //! approximates the RTS percentage chart within a few percent and needs
9 9 //! no lookup table.
10 10 //!
11 - //! e1RM = load * (1 + (reps + rir) / 30)
11 + //! e1RM = load * (1 + (reps + rir) / EPLEY_DIVISOR)
12 12 //!
13 - //! RIR mapping for ripgrow's 1-5 RPE scale:
14 - //! - RPE 5 = 0 RIR (a max effort or a missed rep)
15 - //! - RPE 4 = 1 RIR
16 - //! - RPE 3 = 2 RIR
17 - //! - RPE 2 = 3 RIR
18 - //! - RPE 1 = warmup, no signal about the ceiling; the estimator returns None.
19 - //!
20 - //! Swapping in the empirical RTS chart later is a private-function change.
13 + //! All the constants and the RIR mapping live in [`crate::heuristics`]
14 + //! so they're auditable in one place; this module only composes them.
15 + //! The return type is [`Estimate<f64>`], not `f64`, so callers cannot
16 + //! forget they're looking at a formula-derived number.
21 17
22 - /// Reps in reserve implied by an RPE value on ripgrow's 1-5 scale.
23 - /// Returns `None` for values outside the domain including RPE 1 (warmup).
24 - pub fn rir_from_rpe(rpe: i32) -> Option<i32> {
25 - match rpe {
26 - 5 => Some(0),
27 - 4 => Some(1),
28 - 3 => Some(2),
29 - 2 => Some(3),
30 - _ => None,
31 - }
32 - }
18 + use crate::heuristics::{E1RM_METHOD_LABEL, EPLEY_DIVISOR, rir_from_rpe};
19 + use crate::values::{Estimate, Load, Reps, Rpe};
33 20
34 21 /// Estimated 1RM from a single set. `None` when the set carries no signal
35 - /// (warmup RPE, zero/negative reps, or non-positive load).
36 - pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option<f64> {
37 - if load <= 0.0 || reps <= 0 {
22 + /// — warmup RPE (see `heuristics::rir_from_rpe`) or zero reps.
23 + pub fn estimate_e1rm_from_set(load: Load, reps: Reps, rpe: Rpe) -> Option<Estimate<f64>> {
24 + if load.get() <= 0.0 || reps.get() <= 0 {
38 25 return None;
39 26 }
40 27 let rir = rir_from_rpe(rpe)?;
41 - let reps_to_failure = (reps + rir) as f64;
42 - Some(load * (1.0 + reps_to_failure / 30.0))
28 + let reps_to_failure = (reps.get() + rir) as f64;
29 + let value = load.get() * (1.0 + reps_to_failure / EPLEY_DIVISOR);
30 + Some(Estimate::new(value, E1RM_METHOD_LABEL))
31 + }
32 +
33 + /// Convenience wrapper over primitives for the DB layer, which reads
34 + /// f64/i32 columns and cannot construct the newtypes without additional
35 + /// error handling for stored-invalid rows. Returns the bare value; if
36 + /// you need the method label, use [`estimate_e1rm_from_set`].
37 + ///
38 + /// Panics-free: any invariant violation from the raw inputs (negative
39 + /// load, out-of-range RPE) collapses to `None`, matching the pre-newtype
40 + /// behavior.
41 + pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option<f64> {
42 + let load = Load::new(load).ok()?;
43 + let reps = Reps::new(reps).ok()?;
44 + let rpe = Rpe::new(rpe).ok()?;
45 + estimate_e1rm_from_set(load, reps, rpe).map(|e| e.value)
43 46 }
44 47
45 48 #[cfg(test)]
46 49 mod tests {
47 50 use super::*;
48 51
49 - #[test]
50 - fn rir_map_covers_rpe_2_through_5() {
51 - assert_eq!(rir_from_rpe(5), Some(0));
52 - assert_eq!(rir_from_rpe(4), Some(1));
53 - assert_eq!(rir_from_rpe(3), Some(2));
54 - assert_eq!(rir_from_rpe(2), Some(3));
52 + fn set(load: f64, reps: i32, rpe: i32) -> (Load, Reps, Rpe) {
53 + (
54 + Load::new(load).unwrap(),
55 + Reps::new(reps).unwrap(),
56 + Rpe::new(rpe).unwrap(),
57 + )
55 58 }
56 59
57 60 #[test]
58 - fn rpe_1_is_warmup_and_yields_none() {
59 - assert_eq!(rir_from_rpe(1), None);
61 + fn rpe_1_yields_none() {
60 62 assert_eq!(estimate_e1rm(100.0, 5, 1), None);
61 63 }
62 64
63 65 #[test]
64 66 fn max_effort_five_reps_matches_epley_at_zero_rir() {
65 - // 100 * (1 + 5/30) = 116.67
66 - let e = estimate_e1rm(100.0, 5, 5).unwrap();
67 - assert!((e - 116.666_666_67).abs() < 1e-6);
67 + let (l, r, e) = set(100.0, 5, 5);
68 + let est = estimate_e1rm_from_set(l, r, e).unwrap();
69 + assert!((est.value - 116.666_666_67).abs() < 1e-6);
70 + assert_eq!(est.method, E1RM_METHOD_LABEL);
68 71 }
69 72
70 73 #[test]
71 74 fn easy_set_reads_higher_than_hard_set_at_same_load() {
72 - // Same load, same reps: easier set implies more in the tank, so a
73 - // bigger 1RM ceiling.
74 75 let hard = estimate_e1rm(100.0, 5, 5).unwrap();
75 76 let easy = estimate_e1rm(100.0, 5, 2).unwrap();
76 77 assert!(easy > hard);
@@ -85,16 +86,19 @@ mod tests {
85 86 }
86 87
87 88 #[test]
88 - fn non_positive_inputs_return_none() {
89 + fn non_positive_or_out_of_range_inputs_return_none() {
89 90 assert_eq!(estimate_e1rm(0.0, 5, 3), None);
90 91 assert_eq!(estimate_e1rm(-1.0, 5, 3), None);
91 92 assert_eq!(estimate_e1rm(100.0, 0, 3), None);
92 93 assert_eq!(estimate_e1rm(100.0, -1, 3), None);
94 + assert_eq!(estimate_e1rm(100.0, 5, 0), None);
95 + assert_eq!(estimate_e1rm(100.0, 5, 6), None);
93 96 }
94 97
95 98 #[test]
96 - fn out_of_range_rpe_returns_none() {
97 - assert_eq!(estimate_e1rm(100.0, 5, 0), None);
98 - assert_eq!(estimate_e1rm(100.0, 5, 6), None);
99 + fn method_label_flows_through_estimate() {
100 + let (l, r, e) = set(100.0, 5, 3);
101 + let est = estimate_e1rm_from_set(l, r, e).unwrap();
102 + assert!(est.method.contains("rir"));
99 103 }
100 104 }
@@ -20,6 +20,10 @@
20 20
21 21 use std::collections::HashMap;
22 22
23 + use crate::heuristics::{
24 + READY_TAG_BONUS, RECENCY_BONUS_CAP, RECENCY_BONUS_DIVISOR_DAYS, SATURATION_STEP,
25 + TIRED_TAG_PENALTY,
26 + };
23 27 use crate::templates::Exercise;
24 28
25 29 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -70,8 +74,8 @@ pub fn score(
70 74 Some(Readiness::Sore) => return None,
71 75 Some(r) => {
72 76 let base = match r {
73 - Readiness::Ready => 1.0,
74 - Readiness::Tired => -1.0,
77 + Readiness::Ready => READY_TAG_BONUS,
78 + Readiness::Tired => -TIRED_TAG_PENALTY,
75 79 Readiness::Sore => unreachable!(),
76 80 };
77 81 let sat = tag_saturation.get(tid).copied().unwrap_or(0.0);
@@ -81,8 +85,8 @@ pub fn score(
81 85 }
82 86 }
83 87 let recency = match days_since_last {
84 - Some(d) => ((d as f64) / 7.0).min(3.0),
85 - None => 3.0,
88 + Some(d) => ((d as f64) / RECENCY_BONUS_DIVISOR_DAYS).min(RECENCY_BONUS_CAP),
89 + None => RECENCY_BONUS_CAP,
86 90 };
87 91 Some(total + recency)
88 92 }
@@ -136,7 +140,7 @@ pub fn pick_session(
136 140 .collect();
137 141
138 142 for tid in &primary.tag_ids {
139 - *saturation.entry(*tid).or_insert(0.0) += 1.0;
143 + *saturation.entry(*tid).or_insert(0.0) += SATURATION_STEP;
140 144 }
141 145 picked_ids.insert(primary.id);
142 146 picks.push(PickedSlot { primary, alternates });
@@ -8,6 +8,7 @@
8 8 //! This module contains only pure functions. See `Db::compute_prescription`
9 9 //! for the glue that reads history, walks it, and updates the cache.
10 10
11 + use crate::heuristics::DELOAD_RATIO;
11 12 use crate::sets::SessionSet;
12 13
13 14 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -224,9 +225,10 @@ fn compute_next_load(
224 225 increment: f64,
225 226 ) -> f64 {
226 227 // Entering Deloading is the one place we compress the load; every
227 - // other transition either climbs or holds.
228 + // other transition either climbs or holds. The ratio is a heuristic
229 + // shared with the diagnostic's working-weight computation.
228 230 if new_state == State::Deloading && prev_state != State::Deloading {
229 - return round_to_increment(session_top * 0.9, increment);
231 + return round_to_increment(session_top * DELOAD_RATIO, increment);
230 232 }
231 233 match outcome {
232 234 SessionOutcome::CleanHit => round_to_increment(session_top + increment, increment),
@@ -305,7 +307,10 @@ impl Db {
305 307 if matches!(result, PrescriptionResult::NoHistory)
306 308 && let Some(seed) = self.diagnostic_seed(exercise_id)?
307 309 {
308 - let load = round_to_increment(seed * 0.9, increment);
310 + let load = round_to_increment(
311 + seed * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO,
312 + increment,
313 + );
309 314 result = PrescriptionResult::Prescribed(Prescription {
310 315 state: State::Progressing,
311 316 sets: 3,