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
Signed with PGP, not checked
Commit: 7c74c55be9b9d87aa2792c5f20f2b7d3ae46d02c
Parent: 9705fb2
18 files changed, +389 insertions, -91 deletions
@@ -10,33 +10,14 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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
@@ -8,19 +8,23 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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
@@ -13,7 +13,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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('[')));