Skip to main content

max / ripgrow

values, heuristics: name the primitives and gather the magic numbers New crate::values module introduces Rpe, Reps, Load, Increment, and a generic Estimate<T> wrapper that carries a method label so any formula-derived number can announce itself as an estimate rather than posing as an observation. Unit renamed to LoadUnit and moved here, since it now describes both a profile default and each exercise's load column. New crate::heuristics module collects every tunable magic number in one place, each as a named const with a docstring explaining where it came from (or that it was picked without empirical grounding). Deload ratio, diagnostic escalation multipliers, generation scoring weights, the Epley divisor, and the RIR-from-RPE mapping all live here now. The point is that grep heuristics returns the auditable list of ripgrow's policy choices; nothing hides in a match arm. Nothing outside these two modules consumes the new types or constants yet. Follow-up commits wire them into SessionSet, Prescription, Exercise, and the pure functions that currently spell out the constants inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:06 UTC
Commit: 7c74c55be9b9d87aa2792c5f20f2b7d3ae46d02c
Parent: 9705fb2
18 files changed, +389 insertions, -91 deletions
@@ -10,33 +10,14 @@ use std::path::Path;
10 10 use rusqlite::{Connection, OptionalExtension, params};
11 11
12 12 use crate::error::Error;
13 + use crate::values::LoadUnit;
13 14
14 15 const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002];
15 16
16 - /// Unit preference for a profile. Stored as `'kg'` or `'lb'`; the choice is
17 - /// made at profile-create time and hard to change safely later.
18 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 - pub enum Unit {
20 - Kg,
21 - Lb,
22 - }
23 -
24 - impl Unit {
25 - pub fn as_str(&self) -> &'static str {
26 - match self {
27 - Unit::Kg => "kg",
28 - Unit::Lb => "lb",
29 - }
30 - }
31 -
32 - pub fn parse(s: &str) -> Result<Self, Error> {
33 - match s {
34 - "kg" => Ok(Unit::Kg),
35 - "lb" => Ok(Unit::Lb),
36 - other => Err(Error::InvalidUnit(other.to_string())),
37 - }
38 - }
39 - }
17 + // Historically `Unit` lived here as the profile's weight-unit preference.
18 + // It moved into `values` as `LoadUnit` (same type, better name — it also
19 + // describes each exercise's load column). Older imports still get `Unit`
20 + // via `crate::Unit`.
40 21
41 22 pub struct Db {
42 23 conn: Connection,
@@ -67,7 +48,7 @@ impl Db {
67 48
68 49 /// Insert the `meta` row on a freshly-created profile. Idempotent guard:
69 50 /// returns Ok without touching the row if one already exists.
70 - pub fn init_profile(&self, profile_name: &str, unit: Unit) -> Result<(), Error> {
51 + pub fn init_profile(&self, profile_name: &str, unit: LoadUnit) -> Result<(), Error> {
71 52 let exists: bool = self
72 53 .conn
73 54 .query_row("SELECT 1 FROM meta LIMIT 1", [], |_| Ok(true))
@@ -91,12 +72,12 @@ impl Db {
91 72 .optional()?)
92 73 }
93 74
94 - pub fn unit(&self) -> Result<Option<Unit>, Error> {
75 + pub fn unit(&self) -> Result<Option<LoadUnit>, Error> {
95 76 let s: Option<String> = self
96 77 .conn
97 78 .query_row("SELECT unit FROM meta LIMIT 1", [], |row| row.get(0))
98 79 .optional()?;
99 - s.map(|v| Unit::parse(&v)).transpose()
80 + s.map(|v| LoadUnit::parse(&v)).transpose()
100 81 }
101 82
102 83 pub fn conn(&self) -> &Connection {
@@ -205,16 +186,16 @@ mod tests {
205 186 #[test]
206 187 fn init_profile_writes_meta_row_and_is_idempotent() {
207 188 let db = Db::open_in_memory().unwrap();
208 - db.init_profile("self", Unit::Kg).unwrap();
209 - db.init_profile("self", Unit::Kg).unwrap();
189 + db.init_profile("self", LoadUnit::Kg).unwrap();
190 + db.init_profile("self", LoadUnit::Kg).unwrap();
210 191 assert_eq!(db.profile_name().unwrap().as_deref(), Some("self"));
211 - assert_eq!(db.unit().unwrap(), Some(Unit::Kg));
192 + assert_eq!(db.unit().unwrap(), Some(LoadUnit::Kg));
212 193 }
213 194
214 195 #[test]
215 196 fn rpe_check_constraint_rejects_out_of_range() {
216 197 let db = Db::open_in_memory().unwrap();
217 - db.init_profile("self", Unit::Kg).unwrap();
198 + db.init_profile("self", LoadUnit::Kg).unwrap();
218 199 db.conn
219 200 .execute(
220 201 "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
@@ -244,7 +225,7 @@ mod tests {
244 225 #[test]
245 226 fn sets_unique_constraint_on_session_exercise_set() {
246 227 let db = Db::open_in_memory().unwrap();
247 - db.init_profile("self", Unit::Kg).unwrap();
228 + db.init_profile("self", LoadUnit::Kg).unwrap();
248 229 db.conn
249 230 .execute(
250 231 "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
@@ -29,6 +29,12 @@ pub enum Error {
29 29 #[error("reps must be non-negative, got {0}")]
30 30 InvalidReps(i32),
31 31
32 + #[error("load must be a finite non-negative number, got {0}")]
33 + InvalidLoad(f64),
34 +
35 + #[error("increment must be a finite non-negative number, got {0}")]
36 + InvalidIncrement(f64),
37 +
32 38 #[error("could not parse {field}: {value:?}")]
33 39 ParseField { field: &'static str, value: String },
34 40 }
@@ -242,7 +242,7 @@ impl Db {
242 242 #[cfg(test)]
243 243 mod tests {
244 244 use super::*;
245 - use crate::db::Unit;
245 + use crate::values::LoadUnit;
246 246 use crate::templates::ResistanceType;
247 247
248 248 fn ex(id: i64, name: &str, tags: Vec<i64>) -> Exercise {
@@ -377,7 +377,7 @@ mod tests {
377 377
378 378 fn setup() -> Db {
379 379 let db = Db::open_in_memory().unwrap();
380 - db.init_profile("self", Unit::Kg).unwrap();
380 + db.init_profile("self", LoadUnit::Kg).unwrap();
381 381 db
382 382 }
383 383
@@ -0,0 +1,116 @@
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 + }
@@ -8,19 +8,23 @@ pub mod diagnostic;
8 8 pub mod error;
9 9 pub mod estimator;
10 10 pub mod generation;
11 + pub mod heuristics;
11 12 pub mod profiles;
12 13 pub mod progression;
13 14 pub mod seed;
14 15 pub mod sets;
15 16 pub mod templates;
17 + pub mod values;
16 18
17 - pub use db::{Db, Unit};
19 + pub use db::Db;
18 20 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
19 21 pub use error::Error;
20 - pub use estimator::{estimate_e1rm, rir_from_rpe};
22 + pub use estimator::estimate_e1rm;
21 23 pub use generation::{PickedSlot, Readiness};
24 + pub use heuristics::rir_from_rpe;
22 25 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
23 26 pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
24 27 pub use seed::seed_starter_content;
25 28 pub use sets::SessionSet;
26 29 pub use templates::{Exercise, ResistanceType, Tag};
30 + pub use values::{Estimate, Increment, Load, LoadUnit, Reps, Rpe};
@@ -9,7 +9,8 @@
9 9 use std::fs;
10 10 use std::path::{Path, PathBuf};
11 11
12 - use crate::db::{Db, Unit};
12 + use crate::db::Db;
13 + use crate::values::LoadUnit;
13 14 use crate::error::Error;
14 15
15 16 /// Return the profiles directory, creating it if missing.
@@ -94,7 +95,7 @@ pub fn list_profiles_in(dir: &Path) -> Result<Vec<Profile>, Error> {
94 95 pub fn create_profile_in(
95 96 dir: &Path,
96 97 display_name: &str,
97 - unit: Unit,
98 + unit: LoadUnit,
98 99 ) -> Result<(Profile, Db), Error> {
99 100 let slug = slugify(display_name);
100 101 let path = dir.join(format!("{slug}.db"));
@@ -137,7 +138,7 @@ mod tests {
137 138 #[test]
138 139 fn create_and_list_round_trip() {
139 140 let t = tmp();
140 - let (p, _db) = create_profile_in(t.path(), "self", Unit::Kg).unwrap();
141 + let (p, _db) = create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
141 142 assert_eq!(p.slug, "self");
142 143 let listed = list_profiles_in(t.path()).unwrap();
143 144 assert_eq!(listed.len(), 1);
@@ -147,16 +148,16 @@ mod tests {
147 148 #[test]
148 149 fn create_profile_writes_meta_row() {
149 150 let t = tmp();
150 - let (_, db) = create_profile_in(t.path(), "Jane Doe", Unit::Lb).unwrap();
151 + let (_, db) = create_profile_in(t.path(), "Jane Doe", LoadUnit::Lb).unwrap();
151 152 assert_eq!(db.profile_name().unwrap().as_deref(), Some("Jane Doe"));
152 - assert_eq!(db.unit().unwrap(), Some(Unit::Lb));
153 + assert_eq!(db.unit().unwrap(), Some(LoadUnit::Lb));
153 154 }
154 155
155 156 #[test]
156 157 fn create_profile_rejects_collision() {
157 158 let t = tmp();
158 - create_profile_in(t.path(), "self", Unit::Kg).unwrap();
159 - let err = create_profile_in(t.path(), "SELF", Unit::Kg);
159 + create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
160 + let err = create_profile_in(t.path(), "SELF", LoadUnit::Kg);
160 161 assert!(err.is_err(), "second create with same slug must error");
161 162 }
162 163
@@ -165,7 +166,7 @@ mod tests {
165 166 let t = tmp();
166 167 std::fs::write(t.path().join("notes.txt"), b"x").unwrap();
167 168 std::fs::write(t.path().join("readme"), b"x").unwrap();
168 - create_profile_in(t.path(), "self", Unit::Kg).unwrap();
169 + create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
169 170 let listed = list_profiles_in(t.path()).unwrap();
170 171 assert_eq!(listed.len(), 1);
171 172 assert_eq!(listed[0].slug, "self");
@@ -352,13 +352,13 @@ impl Db {
352 352 #[cfg(test)]
353 353 mod db_tests {
354 354 use super::*;
355 - use crate::db::Unit;
355 + use crate::values::LoadUnit;
356 356 use crate::templates::ResistanceType;
357 357 use chrono::NaiveDate;
358 358
359 359 fn setup() -> (Db, i64) {
360 360 let db = Db::open_in_memory().unwrap();
361 - db.init_profile("self", Unit::Kg).unwrap();
361 + db.init_profile("self", LoadUnit::Kg).unwrap();
362 362 let id = db
363 363 .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
364 364 .unwrap();
@@ -4,7 +4,8 @@
4 4 //! first open. The user is free to edit or delete anything here; the seed
5 5 //! runs once at profile creation and is never re-applied.
6 6
7 - use crate::db::{Db, Unit};
7 + use crate::db::Db;
8 + use crate::values::LoadUnit;
8 9 use crate::error::Error;
9 10 use crate::templates::ResistanceType;
10 11
@@ -146,8 +147,8 @@ const SEED_EXERCISES: &[SeedExercise] = &[
146 147 /// profile. Idempotent: pre-existing tags are reused via `upsert_tag`;
147 148 /// pre-existing exercises by name are skipped (`UNIQUE` constraint on
148 149 /// `exercises.name` would otherwise error). Load unit and increment are
149 - /// derived from the profile's `Unit`.
150 - pub fn seed_starter_content(db: &Db, unit: Unit) -> Result<(), Error> {
150 + /// derived from the profile's `LoadUnit`.
151 + pub fn seed_starter_content(db: &Db, unit: LoadUnit) -> Result<(), Error> {
151 152 for tag in SEED_TAGS {
152 153 db.upsert_tag(tag)?;
153 154 }
@@ -174,17 +175,17 @@ pub fn seed_starter_content(db: &Db, unit: Unit) -> Result<(), Error> {
174 175 Ok(())
175 176 }
176 177
177 - fn increment_for(rt: ResistanceType, unit: Unit) -> f64 {
178 + fn increment_for(rt: ResistanceType, unit: LoadUnit) -> f64 {
178 179 match rt {
179 180 ResistanceType::Bodyweight => 0.0,
180 181 ResistanceType::CardioTime | ResistanceType::CardioDistance => 0.0,
181 182 ResistanceType::Freeweight => match unit {
182 - Unit::Kg => 2.5,
183 - Unit::Lb => 5.0,
183 + LoadUnit::Kg => 2.5,
184 + LoadUnit::Lb => 5.0,
184 185 },
185 186 ResistanceType::Machine => match unit {
186 - Unit::Kg => 5.0,
187 - Unit::Lb => 10.0,
187 + LoadUnit::Kg => 5.0,
188 + LoadUnit::Lb => 10.0,
188 189 },
189 190 }
190 191 }
@@ -195,14 +196,14 @@ mod tests {
195 196
196 197 fn fresh() -> Db {
197 198 let db = Db::open_in_memory().unwrap();
198 - db.init_profile("self", Unit::Kg).unwrap();
199 + db.init_profile("self", LoadUnit::Kg).unwrap();
199 200 db
200 201 }
201 202
202 203 #[test]
203 204 fn seed_creates_expected_counts() {
204 205 let db = fresh();
205 - seed_starter_content(&db, Unit::Kg).unwrap();
206 + seed_starter_content(&db, LoadUnit::Kg).unwrap();
206 207 assert_eq!(db.list_tags().unwrap().len(), SEED_TAGS.len());
207 208 assert_eq!(db.list_exercises().unwrap().len(), SEED_EXERCISES.len());
208 209 }
@@ -210,15 +211,15 @@ mod tests {
210 211 #[test]
211 212 fn seed_is_idempotent_when_run_twice() {
212 213 let db = fresh();
213 - seed_starter_content(&db, Unit::Kg).unwrap();
214 - seed_starter_content(&db, Unit::Kg).unwrap();
214 + seed_starter_content(&db, LoadUnit::Kg).unwrap();
215 + seed_starter_content(&db, LoadUnit::Kg).unwrap();
215 216 assert_eq!(db.list_exercises().unwrap().len(), SEED_EXERCISES.len());
216 217 }
217 218
218 219 #[test]
219 220 fn seed_respects_unit_for_increments() {
220 221 let db = fresh();
221 - seed_starter_content(&db, Unit::Lb).unwrap();
222 + seed_starter_content(&db, LoadUnit::Lb).unwrap();
222 223 let squat = db
223 224 .list_exercises()
224 225 .unwrap()
@@ -248,7 +249,7 @@ mod tests {
248 249 #[test]
249 250 fn seed_wires_up_tags() {
250 251 let db = fresh();
251 - seed_starter_content(&db, Unit::Kg).unwrap();
252 + seed_starter_content(&db, LoadUnit::Kg).unwrap();
252 253 let squat = db
253 254 .list_exercises()
254 255 .unwrap()
@@ -245,12 +245,12 @@ impl Db {
245 245 #[cfg(test)]
246 246 mod tests {
247 247 use super::*;
248 - use crate::db::Unit;
248 + use crate::values::LoadUnit;
249 249 use crate::templates::ResistanceType;
250 250
251 251 fn setup() -> (Db, i64) {
252 252 let db = Db::open_in_memory().unwrap();
253 - db.init_profile("self", Unit::Kg).unwrap();
253 + db.init_profile("self", LoadUnit::Kg).unwrap();
254 254 let id = db
255 255 .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
256 256 .unwrap();
@@ -236,11 +236,11 @@ impl Db {
236 236 #[cfg(test)]
237 237 mod tests {
238 238 use super::*;
239 - use crate::db::Unit;
239 + use crate::values::LoadUnit;
240 240
241 241 fn setup() -> Db {
242 242 let db = Db::open_in_memory().unwrap();
243 - db.init_profile("self", Unit::Kg).unwrap();
243 + db.init_profile("self", LoadUnit::Kg).unwrap();
244 244 db
245 245 }
246 246
@@ -0,0 +1,189 @@
1 + //! Validated domain primitives.
2 + //!
3 + //! Newtype wrappers around the raw numeric types used across ripgrow.
4 + //! Their point is not run-time safety (the schema also enforces most of
5 + //! these invariants); the point is that a function signature says exactly
6 + //! what it works with. `fn append_set(rpe: i32)` will accept `0` or `-1`;
7 + //! `fn append_set(rpe: Rpe)` will not.
8 + //!
9 + //! Every constructor is fallible and returns `crate::Error`. Accessors
10 + //! return the raw inner value.
11 +
12 + use crate::error::Error;
13 +
14 + /// Rate of Perceived Exertion on ripgrow's 1-5 scale. RPE is a
15 + /// self-report, not a measurement; even a "correct" value is subjective.
16 + /// The scale mapping (RPE 5 = maximum effort, RPE 1 = warmup) is documented
17 + /// in `heuristics::RIR_FROM_RPE`.
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19 + pub struct Rpe(i32);
20 +
21 + impl Rpe {
22 + pub const MIN: Rpe = Rpe(1);
23 + pub const MAX: Rpe = Rpe(5);
24 +
25 + pub fn new(n: i32) -> Result<Self, Error> {
26 + if (1..=5).contains(&n) {
27 + Ok(Rpe(n))
28 + } else {
29 + Err(Error::InvalidRpe(n))
30 + }
31 + }
32 +
33 + pub fn get(self) -> i32 {
34 + self.0
35 + }
36 + }
37 +
38 + /// Reps performed in a set. A real measurement (someone counted).
39 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
40 + pub struct Reps(i32);
41 +
42 + impl Reps {
43 + pub fn new(n: i32) -> Result<Self, Error> {
44 + if n >= 0 {
45 + Ok(Reps(n))
46 + } else {
47 + Err(Error::InvalidReps(n))
48 + }
49 + }
50 +
51 + pub fn get(self) -> i32 {
52 + self.0
53 + }
54 + }
55 +
56 + /// Weight moved on a set, in the exercise's [`LoadUnit`]. A real
57 + /// measurement (whatever the plate stack or the barbell reads).
58 + #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
59 + pub struct Load(f64);
60 +
61 + impl Load {
62 + /// Zero load — for bodyweight or before-plates entries.
63 + pub const ZERO: Load = Load(0.0);
64 +
65 + pub fn new(v: f64) -> Result<Self, Error> {
66 + if v.is_finite() && v >= 0.0 {
67 + Ok(Load(v))
68 + } else {
69 + Err(Error::InvalidLoad(v))
70 + }
71 + }
72 +
73 + pub fn get(self) -> f64 {
74 + self.0
75 + }
76 + }
77 +
78 + /// Smallest legal load change for an exercise. May be zero (bodyweight,
79 + /// cardio). A real measurement of the equipment.
80 + #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
81 + pub struct Increment(f64);
82 +
83 + impl Increment {
84 + pub const ZERO: Increment = Increment(0.0);
85 +
86 + pub fn new(v: f64) -> Result<Self, Error> {
87 + if v.is_finite() && v >= 0.0 {
88 + Ok(Increment(v))
89 + } else {
90 + Err(Error::InvalidIncrement(v))
91 + }
92 + }
93 +
94 + pub fn get(self) -> f64 {
95 + self.0
96 + }
97 + }
98 +
99 + /// Weight unit for the load column. Both the profile's default unit and
100 + /// each exercise's `load_unit` use this type. Timed and distance efforts
101 + /// have implicit units (seconds, meters) and do not carry a `LoadUnit`.
102 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103 + pub enum LoadUnit {
104 + Kg,
105 + Lb,
106 + }
107 +
108 + impl LoadUnit {
109 + pub fn as_str(&self) -> &'static str {
110 + match self {
111 + LoadUnit::Kg => "kg",
112 + LoadUnit::Lb => "lb",
113 + }
114 + }
115 +
116 + pub fn parse(s: &str) -> Result<Self, Error> {
117 + match s {
118 + "kg" => Ok(LoadUnit::Kg),
119 + "lb" => Ok(LoadUnit::Lb),
120 + other => Err(Error::InvalidUnit(other.to_string())),
121 + }
122 + }
123 + }
124 +
125 + /// Wrapper around a value derived from a formula rather than measured.
126 + /// The `method` field carries a short human-readable label naming the
127 + /// formula so that UI code can render "e1RM (rpe-adjusted Epley)" instead
128 + /// of a bare number that pretends to be an observation.
129 + ///
130 + /// This exists so the type system pushes back on treating estimates as
131 + /// ground truth. Anywhere a function returns `Estimate<T>` instead of
132 + /// `T`, that is a load-bearing signal that the caller is looking at a
133 + /// heuristic.
134 + #[derive(Debug, Clone, Copy, PartialEq)]
135 + pub struct Estimate<T> {
136 + pub value: T,
137 + pub method: &'static str,
138 + }
139 +
140 + impl<T> Estimate<T> {
141 + pub fn new(value: T, method: &'static str) -> Self {
142 + Estimate { value, method }
143 + }
144 + }
145 +
146 + #[cfg(test)]
147 + mod tests {
148 + use super::*;
149 +
150 + #[test]
151 + fn rpe_validates_range() {
152 + assert!(Rpe::new(0).is_err());
153 + assert!(Rpe::new(6).is_err());
154 + assert_eq!(Rpe::new(3).unwrap().get(), 3);
155 + }
156 +
157 + #[test]
158 + fn reps_rejects_negative() {
159 + assert!(Reps::new(-1).is_err());
160 + assert_eq!(Reps::new(0).unwrap().get(), 0);
161 + }
162 +
163 + #[test]
164 + fn load_rejects_negative_and_nan() {
165 + assert!(Load::new(-0.1).is_err());
166 + assert!(Load::new(f64::NAN).is_err());
167 + assert!(Load::new(f64::INFINITY).is_err());
168 + assert_eq!(Load::new(100.0).unwrap().get(), 100.0);
169 + }
170 +
171 + #[test]
172 + fn increment_rejects_negative() {
173 + assert!(Increment::new(-1.0).is_err());
174 + assert_eq!(Increment::ZERO.get(), 0.0);
175 + }
176 +
177 + #[test]
178 + fn load_unit_round_trip() {
179 + assert_eq!(LoadUnit::parse("kg").unwrap(), LoadUnit::Kg);
180 + assert_eq!(LoadUnit::Lb.as_str(), "lb");
181 + assert!(LoadUnit::parse("stone").is_err());
182 + }
183 +
184 + #[test]
185 + fn estimate_carries_method_label() {
186 + let e = Estimate::new(150.0, "epley");
187 + assert_eq!(e.method, "epley");
188 + }
189 + }
@@ -13,7 +13,7 @@ use ratatui::style::{Modifier, Style};
13 13 use ratatui::text::{Line, Span};
14 14 use ratatui::widgets::{Block, Borders, Tabs};
15 15 use ripgrow_core::{
16 - Db, Unit, create_profile_in, list_profiles, profiles_dir, seed_starter_content,
16 + Db, LoadUnit, create_profile_in, list_profiles, profiles_dir, seed_starter_content,
17 17 };
18 18
19 19 use crate::screens::diagnostic::DiagnosticScreen;
@@ -274,7 +274,7 @@ impl App {
274 274 });
275 275 }
276 276
277 - fn create_profile(&mut self, display_name: &str, unit: Unit) {
277 + fn create_profile(&mut self, display_name: &str, unit: LoadUnit) {
278 278 let dir = match profiles_dir() {
279 279 Ok(d) => d,
280 280 Err(e) => {
@@ -597,7 +597,7 @@ fn subsequence_match(haystack: &str, needle: &str) -> bool {
597 597 mod tests {
598 598 use super::*;
599 599 use crossterm::event::KeyEvent;
600 - use ripgrow_core::{ResistanceType, Unit};
600 + use ripgrow_core::{ResistanceType, LoadUnit};
601 601
602 602 fn key(c: KeyCode) -> KeyEvent {
603 603 KeyEvent::new(c, KeyModifiers::empty())
@@ -605,7 +605,7 @@ mod tests {
605 605
606 606 fn setup() -> (Db, i64) {
607 607 let db = Db::open_in_memory().unwrap();
608 - db.init_profile("self", Unit::Kg).unwrap();
608 + db.init_profile("self", LoadUnit::Kg).unwrap();
609 609 let sq = db
610 610 .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
611 611 .unwrap();
@@ -519,7 +519,7 @@ fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str {
519 519 mod tests {
520 520 use super::*;
521 521 use crossterm::event::KeyEvent;
522 - use ripgrow_core::{ResistanceType, Unit};
522 + use ripgrow_core::{ResistanceType, LoadUnit};
523 523
524 524 fn key(c: KeyCode) -> KeyEvent {
525 525 KeyEvent::new(c, KeyModifiers::empty())
@@ -527,7 +527,7 @@ mod tests {
527 527
528 528 fn setup() -> (Db, Vec<i64>, Vec<i64>) {
529 529 let db = Db::open_in_memory().unwrap();
530 - db.init_profile("self", Unit::Kg).unwrap();
530 + db.init_profile("self", LoadUnit::Kg).unwrap();
531 531 let t1 = db.upsert_tag("chest").unwrap();
532 532 let t2 = db.upsert_tag("back").unwrap();
533 533 let t3 = db.upsert_tag("legs").unwrap();
@@ -245,7 +245,7 @@ fn state_name(s: State) -> &'static str {
245 245 mod tests {
246 246 use super::*;
247 247 use crossterm::event::KeyEvent;
248 - use ripgrow_core::{ResistanceType, Unit};
248 + use ripgrow_core::{ResistanceType, LoadUnit};
249 249
250 250 fn key(c: KeyCode) -> KeyEvent {
251 251 KeyEvent::new(c, KeyModifiers::empty())
@@ -253,7 +253,7 @@ mod tests {
253 253
254 254 fn setup() -> (Db, i64) {
255 255 let db = Db::open_in_memory().unwrap();
256 - db.init_profile("self", Unit::Kg).unwrap();
256 + db.init_profile("self", LoadUnit::Kg).unwrap();
257 257 let id = db
258 258 .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
259 259 .unwrap();
@@ -273,7 +273,7 @@ mod tests {
273 273 #[test]
274 274 fn j_k_wraps_selection() {
275 275 let db = Db::open_in_memory().unwrap();
276 - db.init_profile("self", Unit::Kg).unwrap();
276 + db.init_profile("self", LoadUnit::Kg).unwrap();
277 277 db.create_exercise("a", ResistanceType::Freeweight, "kg", 2.5, &[])
278 278 .unwrap();
279 279 db.create_exercise("b", ResistanceType::Freeweight, "kg", 2.5, &[])
@@ -381,7 +381,7 @@ fn subsequence_match(haystack: &str, needle: &str) -> bool {
381 381 mod tests {
382 382 use super::*;
383 383 use crossterm::event::KeyEvent;
384 - use ripgrow_core::{ResistanceType, Unit};
384 + use ripgrow_core::{ResistanceType, LoadUnit};
385 385
386 386 fn key(c: KeyCode) -> KeyEvent {
387 387 KeyEvent::new(c, KeyModifiers::empty())
@@ -389,7 +389,7 @@ mod tests {
389 389
390 390 fn setup() -> (Db, i64) {
391 391 let db = Db::open_in_memory().unwrap();
392 - db.init_profile("self", Unit::Kg).unwrap();
392 + db.init_profile("self", LoadUnit::Kg).unwrap();
393 393 let sq = db
394 394 .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
395 395 .unwrap();
@@ -477,7 +477,7 @@ mod tests {
477 477 #[test]
478 478 fn bracket_keys_change_date() {
479 479 let db = Db::open_in_memory().unwrap();
480 - db.init_profile("self", Unit::Kg).unwrap();
480 + db.init_profile("self", LoadUnit::Kg).unwrap();
481 481 let mut screen = LogScreen::load(&db).unwrap();
482 482 let start = screen.date;
483 483 screen.on_key(&db, key(KeyCode::Char('[')));
@@ -12,7 +12,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
12 12 use ratatui::style::{Modifier, Style};
13 13 use ratatui::text::Line;
14 14 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
15 - use ripgrow_core::{Profile, Unit};
15 + use ripgrow_core::{Profile, LoadUnit};
16 16
17 17 pub struct ProfilePicker {
18 18 pub profiles: Vec<Profile>,
@@ -25,7 +25,7 @@ pub enum PickerAction {
25 25 None,
26 26 Quit,
27 27 Open(PathBuf),
28 - Create { display_name: String, unit: Unit },
28 + Create { display_name: String, unit: LoadUnit },
29 29 }
30 30
31 31 impl ProfilePicker {
@@ -134,13 +134,13 @@ impl ProfilePicker {
134 134 #[derive(Default)]
135 135 pub struct NewProfileModal {
136 136 pub name: String,
137 - pub unit: Option<Unit>,
137 + pub unit: Option<LoadUnit>,
138 138 }
139 139
140 140 enum ModalAction {
141 141 None,
142 142 Cancel,
143 - Submit { display_name: String, unit: Unit },
143 + Submit { display_name: String, unit: LoadUnit },
144 144 }
145 145
146 146 impl NewProfileModal {
@@ -164,9 +164,9 @@ impl NewProfileModal {
164 164 }
165 165 KeyCode::Tab => {
166 166 self.unit = Some(match self.unit {
167 - None => Unit::Kg,
168 - Some(Unit::Kg) => Unit::Lb,
169 - Some(Unit::Lb) => Unit::Kg,
167 + None => LoadUnit::Kg,
168 + Some(LoadUnit::Kg) => LoadUnit::Lb,
169 + Some(LoadUnit::Lb) => LoadUnit::Kg,
170 170 });
171 171 ModalAction::None
172 172 }
@@ -182,8 +182,8 @@ impl NewProfileModal {
182 182 let modal = centered_rect(60, 30, area);
183 183 let unit_str = match self.unit {
184 184 None => "(tab to choose kg or lb)",
185 - Some(Unit::Kg) => "kg",
186 - Some(Unit::Lb) => "lb",
185 + Some(LoadUnit::Kg) => "kg",
186 + Some(LoadUnit::Lb) => "lb",
187 187 };
188 188 let text = format!(
189 189 "name: {}_\nunit: {}\n\nenter save tab toggle unit esc cancel",
@@ -279,7 +279,7 @@ mod tests {
279 279 match p.on_key(key(KeyCode::Enter)) {
280 280 PickerAction::Create { display_name, unit } => {
281 281 assert_eq!(display_name, "jane");
282 - assert_eq!(unit, Unit::Kg);
282 + assert_eq!(unit, LoadUnit::Kg);
283 283 }
284 284 _ => panic!("expected Create action"),
285 285 }
@@ -291,10 +291,10 @@ mod tests {
291 291 let mut m = NewProfileModal::default();
292 292 assert_eq!(m.unit, None);
293 293 m.on_key(key(KeyCode::Tab));
294 - assert_eq!(m.unit, Some(Unit::Kg));
294 + assert_eq!(m.unit, Some(LoadUnit::Kg));
295 295 m.on_key(key(KeyCode::Tab));
296 - assert_eq!(m.unit, Some(Unit::Lb));
296 + assert_eq!(m.unit, Some(LoadUnit::Lb));
297 297 m.on_key(key(KeyCode::Tab));
298 - assert_eq!(m.unit, Some(Unit::Kg));
298 + assert_eq!(m.unit, Some(LoadUnit::Kg));
299 299 }
300 300 }
@@ -530,7 +530,7 @@ fn text_edit(target: &mut String, key: KeyEvent) {
530 530 mod tests {
531 531 use super::*;
532 532 use crossterm::event::KeyEvent;
533 - use ripgrow_core::Unit;
533 + use ripgrow_core::LoadUnit;
534 534
535 535 fn key(c: KeyCode) -> KeyEvent {
536 536 KeyEvent::new(c, KeyModifiers::empty())
@@ -542,7 +542,7 @@ mod tests {
542 542
543 543 fn fresh_db() -> Db {
544 544 let db = Db::open_in_memory().unwrap();
545 - db.init_profile("self", Unit::Kg).unwrap();
545 + db.init_profile("self", LoadUnit::Kg).unwrap();
546 546 db
547 547 }
548 548