//! First-session diagnostic protocol. //! //! Given a starting seed load and the sets the user has logged so far, //! return either the next set to prescribe or a completion result with //! a working weight and e1RM estimate. Pure functions; the DB layer //! only persists the sets themselves (flagged `is_diagnostic = 1`). //! //! Rules: //! - Target reps per set = 5. //! - RPE 1 or 2 -> next load = last * 1.15. //! - RPE 3 -> next load = last * 1.075. //! - RPE 4 or 5 -> stop; the last set is the top set. //! - A set that missed target reps also stops the diagnostic. //! - Hard cap at 8 sets in case someone keeps reporting easy RPEs. //! //! On completion, working weight = 0.9 * top load (mirrors the deload //! ratio in the progression state machine, so a fresh diagnostic and a //! post-deload rebuild converge on the same starting point). e1RM comes //! from the estimator applied to the last set. use crate::estimator::estimate_e1rm_from_set; use crate::heuristics::{ DIAGNOSTIC_EASY_STEP_UP, DIAGNOSTIC_MAX_SETS, DIAGNOSTIC_MEDIUM_STEP_UP, DIAGNOSTIC_TARGET_REPS, DIAGNOSTIC_WORKING_WEIGHT_RATIO, }; use crate::values::{Estimate, Load, Reps, Rpe}; /// Re-exports kept so external callers can name these directly. All /// values live in [`crate::heuristics`]. pub const TARGET_REPS: i32 = DIAGNOSTIC_TARGET_REPS; pub const MAX_SETS: usize = DIAGNOSTIC_MAX_SETS; /// One completed set inside a diagnostic. Same shape as /// [`crate::RepsSet`]'s working columns, kept separate so the protocol /// can be unit-tested without touching the DB layer. #[derive(Debug, Clone, Copy, PartialEq)] pub struct DiagnosticSet { pub load: Load, pub reps: Reps, pub rpe: Rpe, } #[derive(Debug, Clone, PartialEq)] pub enum DiagnosticStep { /// Prescribe the next set. `reps` is the target rep count. Prescribe { load: Load, reps: Reps }, /// Diagnostic is over. `top_load` is the heaviest completed set; /// `working_weight` is what to seed progression with; `e1rm` is the /// estimator's read on the last set (`None` if the last set carried /// no signal, e.g. warmup RPE). The e1RM comes back as an Estimate /// so callers can display the method label if they want. Complete { top_load: Load, working_weight: Load, e1rm: Option>, }, } /// Next step in a diagnostic given the seed load and sets already done. /// Passing an empty slice returns the opening prescription at `seed`. /// /// `seed` and `increment` stay as raw `f64` because they're arithmetic /// intermediates, not validated observations. The prescribed load returns /// as a `Load` newtype. pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> DiagnosticStep { if done.is_empty() { return DiagnosticStep::Prescribe { load: Load::new(round_to_increment(seed.max(0.0), increment)) .expect("seed clamped to non-negative"), reps: Reps::new(TARGET_REPS).unwrap(), }; } let last = done[done.len() - 1]; let stop = last.rpe.get() >= 4 || last.reps.get() < TARGET_REPS || done.len() >= MAX_SETS; if stop { return complete(&last); } let factor = match last.rpe.get() { 1 | 2 => DIAGNOSTIC_EASY_STEP_UP, 3 => DIAGNOSTIC_MEDIUM_STEP_UP, // Above RPE 3 is handled by `stop`; anything unexpected keeps // the load flat rather than pushing further. _ => 1.0, }; DiagnosticStep::Prescribe { load: Load::new(round_to_increment(last.load.get() * factor, increment)) .expect("escalation of non-negative load stays non-negative"), reps: Reps::new(TARGET_REPS).unwrap(), } } fn complete(last: &DiagnosticSet) -> DiagnosticStep { let top_load = last.load; let working_weight = Load::new(top_load.get() * DIAGNOSTIC_WORKING_WEIGHT_RATIO) .expect("ratio scaling of non-negative load stays non-negative"); let e1rm = estimate_e1rm_from_set(last.load, last.reps, last.rpe); DiagnosticStep::Complete { top_load, working_weight, e1rm, } } fn round_to_increment(value: f64, increment: f64) -> f64 { if increment <= 0.0 { return value; } (value / increment).round() * increment } #[cfg(test)] mod tests { use super::*; fn set(load: f64, reps: i32, rpe: i32) -> DiagnosticSet { DiagnosticSet { load: Load::new(load).unwrap(), reps: Reps::new(reps).unwrap(), rpe: Rpe::new(rpe).unwrap(), } } #[test] fn empty_history_prescribes_seed() { let step = next_step(60.0, 2.5, &[]); assert_eq!( step, DiagnosticStep::Prescribe { load: Load::new(60.0).unwrap(), reps: Reps::new(TARGET_REPS).unwrap(), } ); } #[test] fn seed_is_rounded_to_increment() { // 61 rounds to nearest 2.5 = 60. let step = next_step(61.0, 2.5, &[]); let DiagnosticStep::Prescribe { load, .. } = step else { panic!("expected prescribe"); }; assert_eq!(load.get(), 60.0); } #[test] fn rpe_2_escalates_fifteen_percent() { let step = next_step(0.0, 2.5, &[set(100.0, 5, 2)]); let DiagnosticStep::Prescribe { load, .. } = step else { panic!("expected prescribe"); }; // 115 rounded to 2.5 = 115. assert_eq!(load.get(), 115.0); } #[test] fn rpe_3_escalates_seven_and_a_half_percent() { let step = next_step(0.0, 2.5, &[set(100.0, 5, 3)]); let DiagnosticStep::Prescribe { load, .. } = step else { panic!("expected prescribe"); }; // 107.5 rounded to 2.5 = 107.5. assert_eq!(load.get(), 107.5); } #[test] fn rpe_4_completes_and_computes_working_weight() { let step = next_step(0.0, 2.5, &[set(100.0, 5, 4)]); let DiagnosticStep::Complete { top_load, working_weight, e1rm, } = step else { panic!("expected complete"); }; assert_eq!(top_load.get(), 100.0); assert_eq!(working_weight.get(), 90.0); assert!(e1rm.is_some()); } #[test] fn rpe_5_completes_with_last_set_as_top() { let history = &[set(100.0, 5, 3), set(110.0, 5, 5)]; let step = next_step(0.0, 2.5, history); let DiagnosticStep::Complete { top_load, .. } = step else { panic!("expected complete"); }; assert_eq!(top_load.get(), 110.0); } #[test] fn missed_target_reps_completes_early() { let step = next_step(0.0, 2.5, &[set(100.0, 3, 3)]); assert!(matches!(step, DiagnosticStep::Complete { .. })); } #[test] fn hard_cap_completes_at_max_sets() { let history: Vec = (0..MAX_SETS).map(|_| set(100.0, 5, 2)).collect(); let step = next_step(0.0, 2.5, &history); assert!(matches!(step, DiagnosticStep::Complete { .. })); } #[test] fn escalation_chain_reaches_stop() { let history = vec![ set(60.0, 5, 2), set(70.0, 5, 2), set(80.0, 5, 3), set(85.0, 5, 4), ]; let step = next_step(60.0, 2.5, &history); let DiagnosticStep::Complete { top_load, working_weight, .. } = step else { panic!("expected complete"); }; assert_eq!(top_load.get(), 85.0); assert_eq!(working_weight.get(), 76.5); } #[test] fn increment_zero_skips_rounding() { // Bodyweight-style exercise: seed 0, escalation is a no-op. let step = next_step(0.0, 0.0, &[set(0.0, 5, 2)]); let DiagnosticStep::Prescribe { load, .. } = step else { panic!("expected prescribe"); }; assert_eq!(load.get(), 0.0); } }