//! Every tunable number in ripgrow that isn't a measurement. //! //! Progression, generation, diagnostic escalation, and the e1RM estimator //! all sit on top of policy choices — ratios, formulas, and mappings we //! wrote down without a randomized-controlled-trial to back them up. //! Putting them in one file makes them auditable ("show me every //! heuristic in this app": `grep heuristics`), and makes each one //! findable by name in the modules that use it instead of hiding as a //! literal in a match arm. //! //! When you change a number here, write down why in its docstring, and //! make sure the corresponding module's tests still describe the intent. use crate::values::Rpe; // ---- progression state machine -------------------------------------------- /// Fraction of the previous top load applied when transitioning INTO /// Deloading. 0.9 is the number most strength coaches use as a rule of /// thumb; there's no principled reason it isn't 0.85 or 0.92. Kept /// aligned with `DIAGNOSTIC_WORKING_WEIGHT_RATIO` so a fresh calibration /// and a post-deload rebuild converge on the same starting point. pub const DELOAD_RATIO: f64 = 0.9; // ---- diagnostic escalation ------------------------------------------------ /// Multiplier for the next diagnostic set when the last one felt easy /// (RPE 1 or 2). 15% jumps are aggressive; the goal is to reach a /// meaningful top set in a small number of attempts, not to be gentle. pub const DIAGNOSTIC_EASY_STEP_UP: f64 = 1.15; /// Multiplier when the last diagnostic set was moderate (RPE 3). Half /// the easy jump; there's no data behind this specific value. pub const DIAGNOSTIC_MEDIUM_STEP_UP: f64 = 1.075; /// Fraction of the diagnostic's top load taken as the first working /// weight. Same value as `DELOAD_RATIO` on purpose. pub const DIAGNOSTIC_WORKING_WEIGHT_RATIO: f64 = 0.9; /// Safety cap on diagnostic set count so a stream of RPE 1s doesn't /// escalate forever. pub const DIAGNOSTIC_MAX_SETS: usize = 8; /// Target reps per diagnostic set. Fixed at 5; the diagnostic is a /// probe, not a hypertrophy session. pub const DIAGNOSTIC_TARGET_REPS: i32 = 5; // ---- generation scoring --------------------------------------------------- /// Points added per tag marked Ready. pub const READY_TAG_BONUS: f64 = 1.0; /// Points subtracted per tag marked Tired. pub const TIRED_TAG_PENALTY: f64 = 1.0; /// Days since last performed / this = recency bonus, capped by /// `RECENCY_BONUS_CAP`. Weekly cadence assumption. pub const RECENCY_BONUS_DIVISOR_DAYS: f64 = 7.0; /// Upper bound on the recency bonus. Also the value awarded to /// never-done exercises so novelty is preferred over stale rotations. pub const RECENCY_BONUS_CAP: f64 = 3.0; /// How much each tag's contribution is dampened after being picked. The /// scaling factor is `1 / (1 + saturation)`, so `1.0` here means the /// second occurrence of a tag is worth half as much. pub const SATURATION_STEP: f64 = 1.0; // ---- e1RM estimator ------------------------------------------------------- /// Divisor in the Epley-style formula /// `load * (1 + (reps + rir) / EPLEY_DIVISOR)`. Boyd Epley's original /// value from 1985. Not backed by an ripgrow-specific fit. pub const EPLEY_DIVISOR: f64 = 30.0; /// Label carried on every `Estimate` produced by the e1RM helper. If we /// swap the estimator, updating this label is a required part of the /// change so downstream UI never lies about provenance. pub const E1RM_METHOD_LABEL: &str = "epley on reps + rir (from rpe)"; /// Reps in reserve implied by a self-reported RPE. This mapping is a /// policy choice, not a fact: RPE 5 is called "maximum effort" but a /// lifter with an inflated sense of ceiling might miss the rep at RPE 5 /// (0 RIR) while another leaves 1 rep in the tank. Kept simple on /// purpose; a per-user calibration would go here. /// /// `None` for RPE 1 (warmup) — a warmup set carries no signal about the /// ceiling. pub fn rir_from_rpe(rpe: Rpe) -> Option { match rpe.get() { 5 => Some(0), 4 => Some(1), 3 => Some(2), 2 => Some(3), _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn rir_map_covers_two_through_five() { assert_eq!(rir_from_rpe(Rpe::new(5).unwrap()), Some(0)); assert_eq!(rir_from_rpe(Rpe::new(4).unwrap()), Some(1)); assert_eq!(rir_from_rpe(Rpe::new(3).unwrap()), Some(2)); assert_eq!(rir_from_rpe(Rpe::new(2).unwrap()), Some(3)); assert_eq!(rir_from_rpe(Rpe::new(1).unwrap()), None); } #[test] fn deload_and_diagnostic_ratios_agree() { assert_eq!(DELOAD_RATIO, DIAGNOSTIC_WORKING_WEIGHT_RATIO); } }