Skip to main content

max / ripgrow

4.6 KB · 117 lines History Blame Raw
1 //! Every tunable number in ripgrow that isn't a measurement.
2 //!
3 //! Progression, generation, diagnostic escalation, and the e1RM estimator
4 //! all sit on top of policy choices — ratios, formulas, and mappings we
5 //! wrote down without a randomized-controlled-trial to back them up.
6 //! Putting them in one file makes them auditable ("show me every
7 //! heuristic in this app": `grep heuristics`), and makes each one
8 //! findable by name in the modules that use it instead of hiding as a
9 //! literal in a match arm.
10 //!
11 //! When you change a number here, write down why in its docstring, and
12 //! make sure the corresponding module's tests still describe the intent.
13
14 use crate::values::Rpe;
15
16 // ---- progression state machine --------------------------------------------
17
18 /// Fraction of the previous top load applied when transitioning INTO
19 /// Deloading. 0.9 is the number most strength coaches use as a rule of
20 /// thumb; there's no principled reason it isn't 0.85 or 0.92. Kept
21 /// aligned with `DIAGNOSTIC_WORKING_WEIGHT_RATIO` so a fresh calibration
22 /// and a post-deload rebuild converge on the same starting point.
23 pub const DELOAD_RATIO: f64 = 0.9;
24
25 // ---- diagnostic escalation ------------------------------------------------
26
27 /// Multiplier for the next diagnostic set when the last one felt easy
28 /// (RPE 1 or 2). 15% jumps are aggressive; the goal is to reach a
29 /// meaningful top set in a small number of attempts, not to be gentle.
30 pub const DIAGNOSTIC_EASY_STEP_UP: f64 = 1.15;
31
32 /// Multiplier when the last diagnostic set was moderate (RPE 3). Half
33 /// the easy jump; there's no data behind this specific value.
34 pub const DIAGNOSTIC_MEDIUM_STEP_UP: f64 = 1.075;
35
36 /// Fraction of the diagnostic's top load taken as the first working
37 /// weight. Same value as `DELOAD_RATIO` on purpose.
38 pub const DIAGNOSTIC_WORKING_WEIGHT_RATIO: f64 = 0.9;
39
40 /// Safety cap on diagnostic set count so a stream of RPE 1s doesn't
41 /// escalate forever.
42 pub const DIAGNOSTIC_MAX_SETS: usize = 8;
43
44 /// Target reps per diagnostic set. Fixed at 5; the diagnostic is a
45 /// probe, not a hypertrophy session.
46 pub const DIAGNOSTIC_TARGET_REPS: i32 = 5;
47
48 // ---- generation scoring ---------------------------------------------------
49
50 /// Points added per tag marked Ready.
51 pub const READY_TAG_BONUS: f64 = 1.0;
52
53 /// Points subtracted per tag marked Tired.
54 pub const TIRED_TAG_PENALTY: f64 = 1.0;
55
56 /// Days since last performed / this = recency bonus, capped by
57 /// `RECENCY_BONUS_CAP`. Weekly cadence assumption.
58 pub const RECENCY_BONUS_DIVISOR_DAYS: f64 = 7.0;
59
60 /// Upper bound on the recency bonus. Also the value awarded to
61 /// never-done exercises so novelty is preferred over stale rotations.
62 pub const RECENCY_BONUS_CAP: f64 = 3.0;
63
64 /// How much each tag's contribution is dampened after being picked. The
65 /// scaling factor is `1 / (1 + saturation)`, so `1.0` here means the
66 /// second occurrence of a tag is worth half as much.
67 pub const SATURATION_STEP: f64 = 1.0;
68
69 // ---- e1RM estimator -------------------------------------------------------
70
71 /// Divisor in the Epley-style formula
72 /// `load * (1 + (reps + rir) / EPLEY_DIVISOR)`. Boyd Epley's original
73 /// value from 1985. Not backed by an ripgrow-specific fit.
74 pub const EPLEY_DIVISOR: f64 = 30.0;
75
76 /// Label carried on every `Estimate` produced by the e1RM helper. If we
77 /// swap the estimator, updating this label is a required part of the
78 /// change so downstream UI never lies about provenance.
79 pub const E1RM_METHOD_LABEL: &str = "epley on reps + rir (from rpe)";
80
81 /// Reps in reserve implied by a self-reported RPE. This mapping is a
82 /// policy choice, not a fact: RPE 5 is called "maximum effort" but a
83 /// lifter with an inflated sense of ceiling might miss the rep at RPE 5
84 /// (0 RIR) while another leaves 1 rep in the tank. Kept simple on
85 /// purpose; a per-user calibration would go here.
86 ///
87 /// `None` for RPE 1 (warmup) — a warmup set carries no signal about the
88 /// ceiling.
89 pub fn rir_from_rpe(rpe: Rpe) -> Option<i32> {
90 match rpe.get() {
91 5 => Some(0),
92 4 => Some(1),
93 3 => Some(2),
94 2 => Some(3),
95 _ => None,
96 }
97 }
98
99 #[cfg(test)]
100 mod tests {
101 use super::*;
102
103 #[test]
104 fn rir_map_covers_two_through_five() {
105 assert_eq!(rir_from_rpe(Rpe::new(5).unwrap()), Some(0));
106 assert_eq!(rir_from_rpe(Rpe::new(4).unwrap()), Some(1));
107 assert_eq!(rir_from_rpe(Rpe::new(3).unwrap()), Some(2));
108 assert_eq!(rir_from_rpe(Rpe::new(2).unwrap()), Some(3));
109 assert_eq!(rir_from_rpe(Rpe::new(1).unwrap()), None);
110 }
111
112 #[test]
113 fn deload_and_diagnostic_ratios_agree() {
114 assert_eq!(DELOAD_RATIO, DIAGNOSTIC_WORKING_WEIGHT_RATIO);
115 }
116 }
117