//! Progression state machine and prescription math. //! //! Per (profile, exercise) we track one of four states: `Progressing`, //! `Probation`, `Deloading`, `Rebuilding`. The state is a pure function of //! the exercise's session history and increment; the `progression_state` //! table is a cache that the DB layer refreshes. //! //! This module contains only pure functions. See `Db::compute_prescription` //! for the glue that reads history, walks it, and updates the cache. use crate::effort::reps::{RepsKind, RepsSet}; use crate::heuristics::DELOAD_RATIO; use crate::values::{Load, Reps}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { Progressing, Probation, Deloading, Rebuilding, } impl State { pub fn as_str(&self) -> &'static str { match self { State::Progressing => "progressing", State::Probation => "probation", State::Deloading => "deloading", State::Rebuilding => "rebuilding", } } pub fn parse(s: &str) -> Option { match s { "progressing" => Some(State::Progressing), "probation" => Some(State::Probation), "deloading" => Some(State::Deloading), "rebuilding" => Some(State::Rebuilding), _ => None, } } } /// How a single session performed against its implicit target reps. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionOutcome { /// All sets hit the target rep count with RPE <= 3. CleanHit, /// All sets hit the target rep count, but max RPE was 4. Hold, /// Any set fell short of the target OR max RPE was 5. Miss, } /// Evaluate a session against the target rep count. /// /// `target_reps` is the max rep count from the previous session for this /// exercise; on the first session there is no target so we consider it a /// clean hit (which drives the initial +increment on the following one). pub fn evaluate_session(sets: &[RepsSet], target_reps: i32) -> SessionOutcome { if sets.is_empty() { return SessionOutcome::Miss; } let all_hit_target = sets.iter().all(|s| s.reps.get() >= target_reps); let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1); if !all_hit_target || max_rpe >= 5 { SessionOutcome::Miss } else if max_rpe == 4 { SessionOutcome::Hold } else { SessionOutcome::CleanHit } } /// The prescription for the next session of a given exercise. /// /// Not stored anywhere; recomputed on demand from history. Note that /// `sets` here is a *count* of sets to program, not a `Set` — the /// primitive `i32` is fine. #[derive(Debug, Clone, PartialEq)] pub struct Prescription { pub state: State, /// Number of sets to program. Same as the last session's set count so a /// user who typically does 3 sets keeps doing 3 sets. pub sets: i32, /// Target rep count. Same as the last session's target. pub reps: Reps, /// Load in the exercise's [`crate::LoadUnit`]. pub load: Load, } /// Result of walking history + computing what comes next. #[derive(Debug, Clone, PartialEq)] pub enum PrescriptionResult { /// No sessions logged for this exercise yet. The Log screen prompts; /// the Generate screen shows "seed on first log". NoHistory, Prescribed(Prescription), } /// The full state carried through the history walk. Includes the load that /// would be prescribed if the most recent session were the last one, so /// `Hold` outcomes propagate their "same load" semantics without needing a /// separate held-flag on `State`. #[derive(Debug, Clone, Copy, PartialEq)] struct Walk { state: State, /// Load prescribed for the NEXT (unlogged) session. Refreshed each step. next_load: f64, /// Target reps carried from the most recent session (used to evaluate /// the next one). last_target_reps: i32, /// Sets performed in the most recent session (echoed into the next /// prescription so 3x5 stays 3x5). last_set_count: i32, /// Top load performed in the most recent session. Needed for /// Rebuilding->Progressing graduation. last_top_load: f64, /// Load right before the last deload transition. `None` outside /// deloading/rebuilding. pre_deload_load: Option, } /// Walk sessions in chronological order and return the current state + the /// numbers to prescribe next. `sessions` must already be grouped by date and /// sorted oldest -> newest. pub fn walk_history(sessions: &[Vec], increment: f64) -> Option { if sessions.is_empty() { return None; } // Seed from the first session. There is no prior target, so this session // is treated as a clean hit — the follow-up gets +increment. let first = &sessions[0]; let first_top = top_load(first); let mut walk = Walk { state: State::Progressing, next_load: round_to_increment(first_top + increment, increment), last_target_reps: top_reps(first), last_set_count: first.len() as i32, last_top_load: first_top, pre_deload_load: None, }; for session in &sessions[1..] { let outcome = evaluate_session(session, walk.last_target_reps); let prev_state = walk.state; let new_state = next_state(prev_state, outcome); let session_top = top_load(session); // Snapshot pre-deload load right at the transition INTO deloading. if new_state == State::Deloading && prev_state != State::Deloading { walk.pre_deload_load = Some(session_top); } walk.next_load = compute_next_load(prev_state, new_state, outcome, session_top, increment); walk.state = new_state; walk.last_target_reps = top_reps(session); walk.last_set_count = session.len() as i32; walk.last_top_load = session_top; // If Rebuilding cleared the pre-deload load with this session, // graduate to Progressing and clear the snapshot. The prescription // (already computed above) is `session_top + increment`, which is // exactly what Progressing wants for a clean hit. if walk.state == State::Rebuilding && let Some(pd) = walk.pre_deload_load && session_top >= pd { walk.state = State::Progressing; walk.pre_deload_load = None; } } Some(WalkResult { state: walk.state, next_load: walk.next_load, last_target_reps: walk.last_target_reps, last_set_count: walk.last_set_count, last_top_load: walk.last_top_load, pre_deload_load: walk.pre_deload_load, }) } #[derive(Debug, Clone, PartialEq)] pub struct WalkResult { pub state: State, pub next_load: f64, pub last_target_reps: i32, pub last_set_count: i32, pub last_top_load: f64, pub pre_deload_load: Option, } /// Pure state transition. See module docs for the rules. pub fn next_state(current: State, outcome: SessionOutcome) -> State { match (current, outcome) { (State::Progressing, SessionOutcome::CleanHit) => State::Progressing, (State::Progressing, SessionOutcome::Hold) => State::Progressing, (State::Progressing, SessionOutcome::Miss) => State::Probation, (State::Probation, SessionOutcome::CleanHit) => State::Progressing, (State::Probation, SessionOutcome::Hold) => State::Probation, (State::Probation, SessionOutcome::Miss) => State::Deloading, // "On success -> rebuilding" (design). Hold counts as success here; // a user hovering at the deloaded weight has already recovered. (State::Deloading, SessionOutcome::CleanHit) => State::Rebuilding, (State::Deloading, SessionOutcome::Hold) => State::Rebuilding, (State::Deloading, SessionOutcome::Miss) => State::Deloading, // A miss mid-rebuild is a soft signal, back to probation. Full // rules from the design note. (State::Rebuilding, SessionOutcome::CleanHit) => State::Rebuilding, (State::Rebuilding, SessionOutcome::Hold) => State::Rebuilding, (State::Rebuilding, SessionOutcome::Miss) => State::Probation, } } /// Compute the load to prescribe for the next session. The three inputs /// that matter are the transition (previous state -> new state), the /// outcome of the session that caused it, and the load actually performed. /// /// `increment` can be 0 (bodyweight, cardio); rounding skips in that case. fn compute_next_load( prev_state: State, new_state: State, outcome: SessionOutcome, session_top: f64, increment: f64, ) -> f64 { // Entering Deloading is the one place we compress the load; every // other transition either climbs or holds. The ratio is a heuristic // shared with the diagnostic's working-weight computation. if new_state == State::Deloading && prev_state != State::Deloading { return round_to_increment(session_top * DELOAD_RATIO, increment); } match outcome { SessionOutcome::CleanHit => round_to_increment(session_top + increment, increment), // Hold and Miss both keep the load the same. Deloading a second // time on repeated Miss would double-compress a struggling lifter, // which the design explicitly avoids. SessionOutcome::Hold | SessionOutcome::Miss => session_top, } } fn round_to_increment(value: f64, increment: f64) -> f64 { if increment <= 0.0 { return value; } (value / increment).round() * increment } fn top_load(session: &[RepsSet]) -> f64 { session .iter() .map(|s| s.load.get()) .fold(f64::NEG_INFINITY, f64::max) } fn top_reps(session: &[RepsSet]) -> i32 { session.iter().map(|s| s.reps.get()).max().unwrap_or(0) } /// Walk the exercise's session history and produce the next prescription. /// Returns `NoHistory` when nothing has been logged yet. pub fn compute_prescription( sessions: &[Vec], increment: f64, ) -> PrescriptionResult { let Some(walk) = walk_history(sessions, increment) else { return PrescriptionResult::NoHistory; }; PrescriptionResult::Prescribed(Prescription { state: walk.state, sets: walk.last_set_count, // last_target_reps and next_load came out of a session that was // just validated at decode time, so unwrap is fine — a bad row // would have failed to load in the first place. reps: Reps::new(walk.last_target_reps).expect("target reps from validated history"), load: Load::new(walk.next_load).expect("next load from validated history"), }) } // -- DB glue ----------------------------------------------------------------- use crate::db::Db; use crate::error::Error; use rusqlite::{OptionalExtension, params}; impl Db { /// Compute the next prescription for `exercise_id`. Reads full history /// and walks the state machine from scratch each call. Refreshes the /// `progression_state` cache with the current state so other screens /// can display it without re-walking. pub fn compute_prescription(&self, exercise_id: i64) -> Result { let increment: f64 = self .conn() .query_row( "SELECT increment FROM exercises WHERE id = ?1", params![exercise_id], |row| row.get(0), ) .optional()? .ok_or_else(|| Error::NotFound(format!("exercise {exercise_id}")))?; let sessions = ::list_sessions_for_exercise( self, exercise_id, )?; let mut result = compute_prescription(&sessions, increment); // Fall back to a diagnostic seed when there is no working-set // history. A completed diagnostic hands back a top load; the // first prescribed working session sits at 0.9x that (same ratio // the deload path uses, so a fresh calibration converges with a // post-deload rebuild rather than diverging). if matches!(result, PrescriptionResult::NoHistory) && let Some(seed) = RepsKind::diagnostic_seed(self, exercise_id)? { let load_raw = round_to_increment( seed * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO, increment, ); result = PrescriptionResult::Prescribed(Prescription { state: State::Progressing, sets: 3, reps: Reps::new(5).unwrap(), load: Load::new(load_raw) .expect("diagnostic seed produces non-negative load"), }); } // Cache the state (with the most recent session date as since_date // for display purposes; not a precise "since when" transition marker // — see the history screen for the accurate walk). if let PrescriptionResult::Prescribed(pres) = &result { let since = sessions .last() .map(|s| s[0].session_date.to_string()) .unwrap_or_else(|| chrono::Local::now().date_naive().to_string()); self.conn().execute( "INSERT INTO progression_state (exercise_id, state, since_date) \ VALUES (?1, ?2, ?3) \ ON CONFLICT(exercise_id) DO UPDATE SET \ state = excluded.state, since_date = excluded.since_date", params![exercise_id, pres.state.as_str(), since], )?; } Ok(result) } /// Read the persisted state for display. `None` when no prescription /// has been computed yet. pub fn read_progression_state(&self, exercise_id: i64) -> Result, Error> { let s: Option = self .conn() .query_row( "SELECT state FROM progression_state WHERE exercise_id = ?1", params![exercise_id], |row| row.get(0), ) .optional()?; Ok(s.and_then(|v| State::parse(&v))) } } #[cfg(test)] mod db_tests { use super::*; use crate::effort::reps::RepsPayload; use crate::templates::ResistanceType; use crate::values::{Load, LoadUnit, Reps, Rpe}; use chrono::NaiveDate; fn setup() -> (Db, i64) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let id = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); (db, id) } fn log_set(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) { let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap()); ::append( db, ex, date, payload, Rpe::new(rpe).unwrap(), false, ) .unwrap(); } fn log_diagnostic(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) { let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap()); ::append( db, ex, date, payload, Rpe::new(rpe).unwrap(), true, ) .unwrap(); } #[test] fn compute_prescription_no_history() { let (db, id) = setup(); assert_eq!( db.compute_prescription(id).unwrap(), PrescriptionResult::NoHistory ); } #[test] fn compute_prescription_seeds_from_diagnostic_when_no_working_history() { let (db, id) = setup(); let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); // Diagnostic finished at 100. Working weight = 0.9 * 100 = 90. log_diagnostic(&db, id, d, 100.0, 5, 4); let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else { panic!("expected prescription"); }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.sets, 3); assert_eq!(pres.reps.get(), 5); assert_eq!(pres.load.get(), 90.0); } #[test] fn compute_prescription_prefers_working_history_over_diagnostic_seed() { let (db, id) = setup(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 12).unwrap(); log_diagnostic(&db, id, d1, 100.0, 5, 4); log_set(&db, id, d2, 60.0, 5, 3); let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else { panic!("expected prescription"); }; // Working set at 60 with a clean first-session hit -> next 62.5. assert_eq!(pres.load.get(), 62.5); } #[test] fn compute_prescription_reads_history_and_caches_state() { let (db, id) = setup(); let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap(); for _ in 0..3 { log_set(&db, id, day(1), 100.0, 5, 3); } for _ in 0..3 { log_set(&db, id, day(2), 102.5, 5, 4); } let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else { panic!("expected prescription"); }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.load.get(), 102.5, "hold keeps load flat"); assert_eq!( db.read_progression_state(id).unwrap(), Some(State::Progressing) ); } #[test] fn compute_prescription_updates_cache_across_calls() { let (db, id) = setup(); let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap(); log_set(&db, id, day(1), 100.0, 5, 3); db.compute_prescription(id).unwrap(); assert_eq!( db.read_progression_state(id).unwrap(), Some(State::Progressing) ); // Add a miss to drop to probation. log_set(&db, id, day(2), 100.0, 5, 3); log_set(&db, id, day(2), 100.0, 5, 3); log_set(&db, id, day(2), 100.0, 4, 3); db.compute_prescription(id).unwrap(); assert_eq!( db.read_progression_state(id).unwrap(), Some(State::Probation) ); } } #[cfg(test)] mod tests { use super::*; use chrono::NaiveDate; fn s(date: NaiveDate, load: f64, reps: i32, rpe: i32, set_index: i32) -> RepsSet { RepsSet { id: 0, session_date: date, exercise_id: 1, set_index, load: crate::values::Load::new(load).unwrap(), reps: crate::values::Reps::new(reps).unwrap(), rpe: crate::values::Rpe::new(rpe).unwrap(), is_diagnostic: false, } } fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 7, n).unwrap() } fn session(date: NaiveDate, load: f64, reps: i32, rpe: i32, sets: i32) -> Vec { (1..=sets).map(|i| s(date, load, reps, rpe, i)).collect() } #[test] fn evaluate_examples() { let sets = session(day(1), 100.0, 5, 3, 3); assert_eq!(evaluate_session(&sets, 5), SessionOutcome::CleanHit); let mut mixed = session(day(1), 100.0, 5, 3, 3); mixed[2].rpe = crate::values::Rpe::new(4).unwrap(); assert_eq!(evaluate_session(&mixed, 5), SessionOutcome::Hold); let mut missed = session(day(1), 100.0, 5, 3, 3); missed[2].reps = crate::values::Reps::new(4).unwrap(); assert_eq!(evaluate_session(&missed, 5), SessionOutcome::Miss); let mut hard = session(day(1), 100.0, 5, 3, 3); hard[0].rpe = crate::values::Rpe::new(5).unwrap(); assert_eq!(evaluate_session(&hard, 5), SessionOutcome::Miss); } #[test] fn empty_sessions_no_history() { assert_eq!(compute_prescription(&[], 2.5), PrescriptionResult::NoHistory); } #[test] fn single_session_is_progressing_plus_increment() { let sessions = vec![session(day(1), 100.0, 5, 3, 3)]; let p = compute_prescription(&sessions, 2.5); let PrescriptionResult::Prescribed(pres) = p else { panic!("expected prescription"); }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.sets, 3); assert_eq!(pres.reps.get(), 5); assert_eq!(pres.load.get(), 102.5); } #[test] fn hold_keeps_load_same() { let sessions = vec![ session(day(1), 100.0, 5, 3, 3), session(day(2), 102.5, 5, 4, 3), ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.load.get(), 102.5); } #[test] fn miss_drops_to_probation_same_load() { let sessions = vec![ session(day(1), 100.0, 5, 3, 3), { let mut s = session(day(2), 102.5, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Probation); assert_eq!(pres.load.get(), 102.5); } #[test] fn probation_then_clean_hit_resumes_progressing() { let sessions = vec![ session(day(1), 100.0, 5, 3, 3), { let mut s = session(day(2), 102.5, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, session(day(3), 102.5, 5, 3, 3), ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.load.get(), 105.0); } #[test] fn probation_then_miss_drops_to_deloading_at_ninety_percent() { let sessions = vec![ session(day(1), 100.0, 5, 3, 3), { let mut s = session(day(2), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, { let mut s = session(day(3), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Deloading); // 100 * 0.9 = 90, rounded to nearest 2.5 -> 90. assert_eq!(pres.load.get(), 90.0); } #[test] fn rebuilding_climbs_and_graduates_when_pre_deload_reached() { // History: 100 (progressing) -> 100 miss (probation) -> 100 miss // (deloading, pre_deload=100) -> 90 clean (rebuilding, still <100) // -> 92.5 clean (rebuilding) -> 100 clean (still rebuilding until // AFTER the pre-deload session; state becomes Progressing here). let sessions = vec![ session(day(1), 100.0, 5, 3, 3), { let mut s = session(day(2), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, { let mut s = session(day(3), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, session(day(4), 90.0, 5, 3, 3), session(day(5), 92.5, 5, 3, 3), session(day(6), 100.0, 5, 3, 3), ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Progressing); assert_eq!(pres.load.get(), 102.5); } #[test] fn rebuilding_stays_when_still_below_pre_deload() { let sessions = vec![ session(day(1), 100.0, 5, 3, 3), { let mut s = session(day(2), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, { let mut s = session(day(3), 100.0, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, session(day(4), 90.0, 5, 3, 3), ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; assert_eq!(pres.state, State::Rebuilding); assert_eq!(pres.load.get(), 92.5); } #[test] fn bodyweight_increment_zero_does_not_round() { let sessions = vec![session(day(1), 0.0, 8, 3, 3)]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 0.0) else { panic!() }; assert_eq!(pres.load.get(), 0.0); } #[test] fn deload_rounds_to_nearest_increment() { let sessions = vec![ session(day(1), 82.5, 5, 3, 3), { let mut s = session(day(2), 82.5, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, { let mut s = session(day(3), 82.5, 5, 3, 3); s[2].reps = crate::values::Reps::new(4).unwrap(); s }, ]; let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else { panic!() }; // 82.5 * 0.9 = 74.25, nearest 2.5 = 75. assert_eq!(pres.load.get(), 75.0); } }