Skip to main content

max / ripgrow

progression state machine and prescription math New ripgrow-core::progression module holds pure state-machine logic: State enum (Progressing / Probation / Deloading / Rebuilding), SessionOutcome (CleanHit / Hold / Miss), evaluate_session against a target rep count, next_state transitions per the design note. walk_history walks a chronologically-ordered list of sessions and tracks state + the load to prescribe for the next session (rather than recomputing from state alone), which matters because Hold outcomes keep the load flat without changing state. Pre-deload load is snapshotted at the transition INTO deloading; Rebuilding graduates to Progressing the session it clears that snapshot. New Db helpers: - list_sessions_for_exercise groups sets by date, oldest first. - compute_prescription: reads history, walks it, refreshes the progression_state cache row (upsert with the most recent session date as since_date, for display). - read_progression_state reads the cache. Rounding to nearest increment applies in every case except when increment is zero (bodyweight, cardio). Deload of 82.5 kg at 2.5 kg increment lands on 75, matching the design. 14 progression tests + 39 core tests total (54 including tui), all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:39 UTC
Commit: ef7edf6912d9576f646a666a87778592c570fe8e
Parent: 6dea4f1
3 files changed, +544 insertions, -0 deletions
@@ -6,11 +6,13 @@
6 6 pub mod db;
7 7 pub mod error;
8 8 pub mod profiles;
9 + pub mod progression;
9 10 pub mod sets;
10 11 pub mod templates;
11 12
12 13 pub use db::{Db, Unit};
13 14 pub use error::Error;
14 15 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
16 + pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
15 17 pub use sets::SessionSet;
16 18 pub use templates::{Exercise, ResistanceType, Tag};
@@ -0,0 +1,616 @@
1 + //! Progression state machine and prescription math.
2 + //!
3 + //! Per (profile, exercise) we track one of four states: `Progressing`,
4 + //! `Probation`, `Deloading`, `Rebuilding`. The state is a pure function of
5 + //! the exercise's session history and increment; the `progression_state`
6 + //! table is a cache that the DB layer refreshes.
7 + //!
8 + //! This module contains only pure functions. See `Db::compute_prescription`
9 + //! for the glue that reads history, walks it, and updates the cache.
10 +
11 + use crate::sets::SessionSet;
12 +
13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 + pub enum State {
15 + Progressing,
16 + Probation,
17 + Deloading,
18 + Rebuilding,
19 + }
20 +
21 + impl State {
22 + pub fn as_str(&self) -> &'static str {
23 + match self {
24 + State::Progressing => "progressing",
25 + State::Probation => "probation",
26 + State::Deloading => "deloading",
27 + State::Rebuilding => "rebuilding",
28 + }
29 + }
30 +
31 + pub fn parse(s: &str) -> Option<Self> {
32 + match s {
33 + "progressing" => Some(State::Progressing),
34 + "probation" => Some(State::Probation),
35 + "deloading" => Some(State::Deloading),
36 + "rebuilding" => Some(State::Rebuilding),
37 + _ => None,
38 + }
39 + }
40 + }
41 +
42 + /// How a single session performed against its implicit target reps.
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 + pub enum SessionOutcome {
45 + /// All sets hit the target rep count with RPE <= 3.
46 + CleanHit,
47 + /// All sets hit the target rep count, but max RPE was 4.
48 + Hold,
49 + /// Any set fell short of the target OR max RPE was 5.
50 + Miss,
51 + }
52 +
53 + /// Evaluate a session against the target rep count.
54 + ///
55 + /// `target_reps` is the max rep count from the previous session for this
56 + /// exercise; on the first session there is no target so we consider it a
57 + /// clean hit (which drives the initial +increment on the following one).
58 + pub fn evaluate_session(sets: &[SessionSet], target_reps: i32) -> SessionOutcome {
59 + if sets.is_empty() {
60 + return SessionOutcome::Miss;
61 + }
62 + let all_hit_target = sets.iter().all(|s| s.reps >= target_reps);
63 + let max_rpe = sets.iter().map(|s| s.rpe).max().unwrap_or(1);
64 + if !all_hit_target || max_rpe >= 5 {
65 + SessionOutcome::Miss
66 + } else if max_rpe == 4 {
67 + SessionOutcome::Hold
68 + } else {
69 + SessionOutcome::CleanHit
70 + }
71 + }
72 +
73 + /// The prescription for the next session of a given exercise.
74 + ///
75 + /// Not stored anywhere; recomputed on demand from history.
76 + #[derive(Debug, Clone, PartialEq)]
77 + pub struct Prescription {
78 + pub state: State,
79 + /// Number of sets to program. Same as the last session's set count so a
80 + /// user who typically does 3 sets keeps doing 3 sets.
81 + pub sets: i32,
82 + /// Target rep count. Same as the last session's target.
83 + pub reps: i32,
84 + /// Load in the exercise's `load_unit`.
85 + pub load: f64,
86 + }
87 +
88 + /// Result of walking history + computing what comes next.
89 + #[derive(Debug, Clone, PartialEq)]
90 + pub enum PrescriptionResult {
91 + /// No sessions logged for this exercise yet. The Log screen prompts;
92 + /// the Generate screen shows "seed on first log".
93 + NoHistory,
94 + Prescribed(Prescription),
95 + }
96 +
97 + /// The full state carried through the history walk. Includes the load that
98 + /// would be prescribed if the most recent session were the last one, so
99 + /// `Hold` outcomes propagate their "same load" semantics without needing a
100 + /// separate held-flag on `State`.
101 + #[derive(Debug, Clone, Copy, PartialEq)]
102 + struct Walk {
103 + state: State,
104 + /// Load prescribed for the NEXT (unlogged) session. Refreshed each step.
105 + next_load: f64,
106 + /// Target reps carried from the most recent session (used to evaluate
107 + /// the next one).
108 + last_target_reps: i32,
109 + /// Sets performed in the most recent session (echoed into the next
110 + /// prescription so 3x5 stays 3x5).
111 + last_set_count: i32,
112 + /// Top load performed in the most recent session. Needed for
113 + /// Rebuilding->Progressing graduation.
114 + last_top_load: f64,
115 + /// Load right before the last deload transition. `None` outside
116 + /// deloading/rebuilding.
117 + pre_deload_load: Option<f64>,
118 + }
119 +
120 + /// Walk sessions in chronological order and return the current state + the
121 + /// numbers to prescribe next. `sessions` must already be grouped by date and
122 + /// sorted oldest -> newest.
123 + pub fn walk_history(sessions: &[Vec<SessionSet>], increment: f64) -> Option<WalkResult> {
124 + if sessions.is_empty() {
125 + return None;
126 + }
127 +
128 + // Seed from the first session. There is no prior target, so this session
129 + // is treated as a clean hit — the follow-up gets +increment.
130 + let first = &sessions[0];
131 + let first_top = top_load(first);
132 + let mut walk = Walk {
133 + state: State::Progressing,
134 + next_load: round_to_increment(first_top + increment, increment),
135 + last_target_reps: top_reps(first),
136 + last_set_count: first.len() as i32,
137 + last_top_load: first_top,
138 + pre_deload_load: None,
139 + };
140 +
141 + for session in &sessions[1..] {
142 + let outcome = evaluate_session(session, walk.last_target_reps);
143 + let prev_state = walk.state;
144 + let new_state = next_state(prev_state, outcome);
145 + let session_top = top_load(session);
146 +
147 + // Snapshot pre-deload load right at the transition INTO deloading.
148 + if new_state == State::Deloading && prev_state != State::Deloading {
149 + walk.pre_deload_load = Some(session_top);
150 + }
151 +
152 + walk.next_load =
153 + compute_next_load(prev_state, new_state, outcome, session_top, increment);
154 + walk.state = new_state;
155 + walk.last_target_reps = top_reps(session);
156 + walk.last_set_count = session.len() as i32;
157 + walk.last_top_load = session_top;
158 +
159 + // If Rebuilding cleared the pre-deload load with this session,
160 + // graduate to Progressing and clear the snapshot. The prescription
161 + // (already computed above) is `session_top + increment`, which is
162 + // exactly what Progressing wants for a clean hit.
163 + if walk.state == State::Rebuilding
164 + && let Some(pd) = walk.pre_deload_load
165 + && session_top >= pd
166 + {
167 + walk.state = State::Progressing;
168 + walk.pre_deload_load = None;
169 + }
170 + }
171 +
172 + Some(WalkResult {
173 + state: walk.state,
174 + next_load: walk.next_load,
175 + last_target_reps: walk.last_target_reps,
176 + last_set_count: walk.last_set_count,
177 + last_top_load: walk.last_top_load,
178 + pre_deload_load: walk.pre_deload_load,
179 + })
180 + }
181 +
182 + #[derive(Debug, Clone, PartialEq)]
183 + pub struct WalkResult {
184 + pub state: State,
185 + pub next_load: f64,
186 + pub last_target_reps: i32,
187 + pub last_set_count: i32,
188 + pub last_top_load: f64,
189 + pub pre_deload_load: Option<f64>,
190 + }
191 +
192 + /// Pure state transition. See module docs for the rules.
193 + pub fn next_state(current: State, outcome: SessionOutcome) -> State {
194 + match (current, outcome) {
195 + (State::Progressing, SessionOutcome::CleanHit) => State::Progressing,
196 + (State::Progressing, SessionOutcome::Hold) => State::Progressing,
197 + (State::Progressing, SessionOutcome::Miss) => State::Probation,
198 + (State::Probation, SessionOutcome::CleanHit) => State::Progressing,
199 + (State::Probation, SessionOutcome::Hold) => State::Probation,
200 + (State::Probation, SessionOutcome::Miss) => State::Deloading,
201 + // "On success -> rebuilding" (design). Hold counts as success here;
202 + // a user hovering at the deloaded weight has already recovered.
203 + (State::Deloading, SessionOutcome::CleanHit) => State::Rebuilding,
204 + (State::Deloading, SessionOutcome::Hold) => State::Rebuilding,
205 + (State::Deloading, SessionOutcome::Miss) => State::Deloading,
206 + // A miss mid-rebuild is a soft signal, back to probation. Full
207 + // rules from the design note.
208 + (State::Rebuilding, SessionOutcome::CleanHit) => State::Rebuilding,
209 + (State::Rebuilding, SessionOutcome::Hold) => State::Rebuilding,
210 + (State::Rebuilding, SessionOutcome::Miss) => State::Probation,
211 + }
212 + }
213 +
214 + /// Compute the load to prescribe for the next session. The three inputs
215 + /// that matter are the transition (previous state -> new state), the
216 + /// outcome of the session that caused it, and the load actually performed.
217 + ///
218 + /// `increment` can be 0 (bodyweight, cardio); rounding skips in that case.
219 + fn compute_next_load(
220 + prev_state: State,
221 + new_state: State,
222 + outcome: SessionOutcome,
223 + session_top: f64,
224 + increment: f64,
225 + ) -> f64 {
226 + // Entering Deloading is the one place we compress the load; every
227 + // other transition either climbs or holds.
228 + if new_state == State::Deloading && prev_state != State::Deloading {
229 + return round_to_increment(session_top * 0.9, increment);
230 + }
231 + match outcome {
232 + SessionOutcome::CleanHit => round_to_increment(session_top + increment, increment),
233 + // Hold and Miss both keep the load the same. Deloading a second
234 + // time on repeated Miss would double-compress a struggling lifter,
235 + // which the design explicitly avoids.
236 + SessionOutcome::Hold | SessionOutcome::Miss => session_top,
237 + }
238 + }
239 +
240 + fn round_to_increment(value: f64, increment: f64) -> f64 {
241 + if increment <= 0.0 {
242 + return value;
243 + }
244 + (value / increment).round() * increment
245 + }
246 +
247 + fn top_load(session: &[SessionSet]) -> f64 {
248 + session
249 + .iter()
250 + .map(|s| s.load)
251 + .fold(f64::NEG_INFINITY, f64::max)
252 + }
253 +
254 + fn top_reps(session: &[SessionSet]) -> i32 {
255 + session.iter().map(|s| s.reps).max().unwrap_or(0)
256 + }
257 +
258 + /// Walk the exercise's session history and produce the next prescription.
259 + /// Returns `NoHistory` when nothing has been logged yet.
260 + pub fn compute_prescription(
261 + sessions: &[Vec<SessionSet>],
262 + increment: f64,
263 + ) -> PrescriptionResult {
264 + let Some(walk) = walk_history(sessions, increment) else {
265 + return PrescriptionResult::NoHistory;
266 + };
267 + PrescriptionResult::Prescribed(Prescription {
268 + state: walk.state,
269 + sets: walk.last_set_count,
270 + reps: walk.last_target_reps,
271 + load: walk.next_load,
272 + })
273 + }
274 +
275 + // -- DB glue -----------------------------------------------------------------
276 +
277 + use crate::db::Db;
278 + use crate::error::Error;
279 + use rusqlite::{OptionalExtension, params};
280 +
281 + impl Db {
282 + /// Compute the next prescription for `exercise_id`. Reads full history
283 + /// and walks the state machine from scratch each call. Refreshes the
284 + /// `progression_state` cache with the current state so other screens
285 + /// can display it without re-walking.
286 + pub fn compute_prescription(&self, exercise_id: i64) -> Result<PrescriptionResult, Error> {
287 + let increment: f64 = self
288 + .conn()
289 + .query_row(
290 + "SELECT increment FROM exercises WHERE id = ?1",
291 + params![exercise_id],
292 + |row| row.get(0),
293 + )
294 + .optional()?
295 + .ok_or_else(|| Error::NotFound(format!("exercise {exercise_id}")))?;
296 +
297 + let sessions = self.list_sessions_for_exercise(exercise_id)?;
298 + let result = compute_prescription(&sessions, increment);
299 +
300 + // Cache the state (with the most recent session date as since_date
301 + // for display purposes; not a precise "since when" transition marker
302 + // — see the history screen for the accurate walk).
303 + if let PrescriptionResult::Prescribed(pres) = &result
304 + && let Some(last_session) = sessions.last()
305 + {
306 + let since = last_session[0].session_date.to_string();
307 + self.conn().execute(
308 + "INSERT INTO progression_state (exercise_id, state, since_date) \
309 + VALUES (?1, ?2, ?3) \
310 + ON CONFLICT(exercise_id) DO UPDATE SET \
311 + state = excluded.state, since_date = excluded.since_date",
312 + params![exercise_id, pres.state.as_str(), since],
313 + )?;
314 + }
315 +
316 + Ok(result)
317 + }
318 +
319 + /// Read the persisted state for display. `None` when no prescription
320 + /// has been computed yet.
321 + pub fn read_progression_state(&self, exercise_id: i64) -> Result<Option<State>, Error> {
322 + let s: Option<String> = self
323 + .conn()
324 + .query_row(
325 + "SELECT state FROM progression_state WHERE exercise_id = ?1",
326 + params![exercise_id],
327 + |row| row.get(0),
328 + )
329 + .optional()?;
330 + Ok(s.and_then(|v| State::parse(&v)))
331 + }
332 + }
333 +
334 + #[cfg(test)]
335 + mod db_tests {
336 + use super::*;
337 + use crate::db::Unit;
338 + use crate::templates::ResistanceType;
339 + use chrono::NaiveDate;
340 +
341 + fn setup() -> (Db, i64) {
342 + let db = Db::open_in_memory().unwrap();
343 + db.init_profile("self", Unit::Kg).unwrap();
344 + let id = db
345 + .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
346 + .unwrap();
347 + (db, id)
348 + }
349 +
350 + #[test]
351 + fn compute_prescription_no_history() {
352 + let (db, id) = setup();
353 + assert_eq!(
354 + db.compute_prescription(id).unwrap(),
355 + PrescriptionResult::NoHistory
356 + );
357 + }
358 +
359 + #[test]
360 + fn compute_prescription_reads_history_and_caches_state() {
361 + let (db, id) = setup();
362 + let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
363 + for _ in 0..3 {
364 + db.append_set(id, day(1), 100.0, 5, 3).unwrap();
365 + }
366 + for _ in 0..3 {
367 + db.append_set(id, day(2), 102.5, 5, 4).unwrap();
368 + }
369 + let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
370 + panic!("expected prescription");
371 + };
372 + assert_eq!(pres.state, State::Progressing);
373 + assert_eq!(pres.load, 102.5, "hold keeps load flat");
374 + assert_eq!(
375 + db.read_progression_state(id).unwrap(),
376 + Some(State::Progressing)
377 + );
378 + }
379 +
380 + #[test]
381 + fn compute_prescription_updates_cache_across_calls() {
382 + let (db, id) = setup();
383 + let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
384 + db.append_set(id, day(1), 100.0, 5, 3).unwrap();
385 + db.compute_prescription(id).unwrap();
386 + assert_eq!(
387 + db.read_progression_state(id).unwrap(),
388 + Some(State::Progressing)
389 + );
390 + // Add a miss to drop to probation.
391 + db.append_set(id, day(2), 100.0, 5, 3).unwrap();
392 + db.append_set(id, day(2), 100.0, 5, 3).unwrap();
393 + db.append_set(id, day(2), 100.0, 4, 3).unwrap();
394 + db.compute_prescription(id).unwrap();
395 + assert_eq!(
396 + db.read_progression_state(id).unwrap(),
397 + Some(State::Probation)
398 + );
399 + }
400 + }
401 +
402 + #[cfg(test)]
403 + mod tests {
404 + use super::*;
405 + use chrono::NaiveDate;
406 +
407 + fn s(date: NaiveDate, load: f64, reps: i32, rpe: i32, set_index: i32) -> SessionSet {
408 + SessionSet {
409 + id: 0,
410 + session_date: date,
411 + exercise_id: 1,
412 + set_index,
413 + load,
414 + reps,
415 + rpe,
416 + }
417 + }
418 +
419 + fn day(n: u32) -> NaiveDate {
420 + NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
421 + }
422 +
423 + fn session(date: NaiveDate, load: f64, reps: i32, rpe: i32, sets: i32) -> Vec<SessionSet> {
424 + (1..=sets).map(|i| s(date, load, reps, rpe, i)).collect()
425 + }
426 +
427 + #[test]
428 + fn evaluate_examples() {
429 + let sets = session(day(1), 100.0, 5, 3, 3);
430 + assert_eq!(evaluate_session(&sets, 5), SessionOutcome::CleanHit);
431 +
432 + let mut mixed = session(day(1), 100.0, 5, 3, 3);
433 + mixed[2].rpe = 4;
434 + assert_eq!(evaluate_session(&mixed, 5), SessionOutcome::Hold);
435 +
436 + let mut missed = session(day(1), 100.0, 5, 3, 3);
437 + missed[2].reps = 4;
438 + assert_eq!(evaluate_session(&missed, 5), SessionOutcome::Miss);
439 +
440 + let mut hard = session(day(1), 100.0, 5, 3, 3);
441 + hard[0].rpe = 5;
442 + assert_eq!(evaluate_session(&hard, 5), SessionOutcome::Miss);
443 + }
444 +
445 + #[test]
446 + fn empty_sessions_no_history() {
447 + assert_eq!(compute_prescription(&[], 2.5), PrescriptionResult::NoHistory);
448 + }
449 +
450 + #[test]
451 + fn single_session_is_progressing_plus_increment() {
452 + let sessions = vec![session(day(1), 100.0, 5, 3, 3)];
453 + let p = compute_prescription(&sessions, 2.5);
454 + let PrescriptionResult::Prescribed(pres) = p else {
455 + panic!("expected prescription");
456 + };
457 + assert_eq!(pres.state, State::Progressing);
458 + assert_eq!(pres.sets, 3);
459 + assert_eq!(pres.reps, 5);
460 + assert_eq!(pres.load, 102.5);
461 + }
462 +
463 + #[test]
464 + fn hold_keeps_load_same() {
465 + let sessions = vec![
466 + session(day(1), 100.0, 5, 3, 3),
467 + session(day(2), 102.5, 5, 4, 3),
468 + ];
469 + let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
470 + panic!()
471 + };
472 + assert_eq!(pres.state, State::Progressing);
473 + assert_eq!(pres.load, 102.5);
474 + }
475 +
476 + #[test]
477 + fn miss_drops_to_probation_same_load() {
478 + let sessions = vec![
479 + session(day(1), 100.0, 5, 3, 3),
480 + {
481 + let mut s = session(day(2), 102.5, 5, 3, 3);
482 + s[2].reps = 4;
483 + s
484 + },
485 + ];
486 + let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
487 + panic!()
488 + };
489 + assert_eq!(pres.state, State::Probation);
490 + assert_eq!(pres.load, 102.5);
491 + }
492 +
493 + #[test]
494 + fn probation_then_clean_hit_resumes_progressing() {
495 + let sessions = vec![
496 + session(day(1), 100.0, 5, 3, 3),
497 + {
498 + let mut s = session(day(2), 102.5, 5, 3, 3);
499 + s[2].reps = 4;
500 + s
Lines truncated
@@ -89,6 +89,48 @@ impl Db {
89 89 Ok(self.conn().last_insert_rowid())
90 90 }
91 91
92 + /// List every session for an exercise, oldest first, each session as a
93 + /// vec of its sets in `set_index` order. Empty vec when no history.
94 + pub fn list_sessions_for_exercise(
95 + &self,
96 + exercise_id: i64,
97 + ) -> Result<Vec<Vec<SessionSet>>, Error> {
98 + let mut stmt = self.conn().prepare(
99 + "SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
100 + FROM sets WHERE exercise_id = ?1 \
101 + ORDER BY session_date ASC, set_index ASC",
102 + )?;
103 + let rows = stmt.query_map(params![exercise_id], |row| {
104 + let date_str: String = row.get(1)?;
105 + let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
106 + rusqlite::Error::FromSqlConversionFailure(
107 + 1,
108 + rusqlite::types::Type::Text,
109 + Box::new(e),
110 + )
111 + })?;
112 + Ok(SessionSet {
113 + id: row.get(0)?,
114 + session_date: parsed,
115 + exercise_id: row.get(2)?,
116 + set_index: row.get(3)?,
117 + load: row.get(4)?,
118 + reps: row.get(5)?,
119 + rpe: row.get(6)?,
120 + })
121 + })?;
122 +
123 + let mut sessions: Vec<Vec<SessionSet>> = Vec::new();
124 + for row in rows {
125 + let set = row?;
126 + match sessions.last_mut() {
127 + Some(last) if last[0].session_date == set.session_date => last.push(set),
128 + _ => sessions.push(vec![set]),
129 + }
130 + }
131 + Ok(sessions)
132 + }
133 +
92 134 /// Delete a specific set row by id. Returns `NotFound` if it did not
93 135 /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
94 136 /// gaps are fine for the state machine (which reads ordered by index).