Skip to main content

max / ripgrow

14.7 KB · 436 lines History Blame Raw
1 //! Exercise scorer + greedy session picker.
2 //!
3 //! Given a set of exercise templates, per-tag readiness, and how recently
4 //! each exercise was last done, produce a session of `count` picks plus a
5 //! ranked alternates list per slot. The scorer is deliberately tunable and
6 //! transparent so the reroll UX can explain "why is X here."
7 //!
8 //! Rules per the design note:
9 //!
10 //! - `Ready` tag contributes +1.0 to any exercise carrying that tag.
11 //! - `Tired` tag contributes -1.0.
12 //! - `Sore` tag is a hard block: exercises carrying that tag are removed
13 //! from the pool entirely.
14 //! - Recency bonus: `days_since_last / 7.0`, capped at 3.0. Never-done
15 //! exercises get the cap, so novelty is preferred over stale rotations.
16 //! - Diminishing returns: after an exercise is picked, its tags gain 1.0
17 //! of "saturation". Each contributing tag is scaled by `1 / (1 + sat)`
18 //! for subsequent picks, so the greedy pass fans out rather than
19 //! stacking on one muscle group.
20
21 use std::collections::HashMap;
22
23 use crate::heuristics::{
24 READY_TAG_BONUS, RECENCY_BONUS_CAP, RECENCY_BONUS_DIVISOR_DAYS, SATURATION_STEP,
25 TIRED_TAG_PENALTY,
26 };
27 use crate::templates::Exercise;
28
29 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30 pub enum Readiness {
31 Ready,
32 Tired,
33 Sore,
34 }
35
36 impl Readiness {
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Readiness::Ready => "ready",
40 Readiness::Tired => "tired",
41 Readiness::Sore => "sore",
42 }
43 }
44
45 pub fn parse(s: &str) -> Option<Self> {
46 match s {
47 "ready" => Some(Readiness::Ready),
48 "tired" => Some(Readiness::Tired),
49 "sore" => Some(Readiness::Sore),
50 _ => None,
51 }
52 }
53 }
54
55 /// One slot in the generated session: a primary pick plus alternates,
56 /// ranked by score under the same saturation state that produced the pick.
57 #[derive(Debug, Clone, PartialEq)]
58 pub struct PickedSlot {
59 pub primary: Exercise,
60 pub alternates: Vec<Exercise>,
61 }
62
63 /// Score a single exercise. Returns `None` when any of its tags is marked
64 /// `Sore` (hard block).
65 pub fn score(
66 exercise: &Exercise,
67 readiness: &HashMap<i64, Readiness>,
68 days_since_last: Option<i64>,
69 tag_saturation: &HashMap<i64, f64>,
70 ) -> Option<f64> {
71 let mut total = 0.0;
72 for tid in &exercise.tag_ids {
73 match readiness.get(tid) {
74 Some(Readiness::Sore) => return None,
75 Some(r) => {
76 let base = match r {
77 Readiness::Ready => READY_TAG_BONUS,
78 Readiness::Tired => -TIRED_TAG_PENALTY,
79 Readiness::Sore => unreachable!(),
80 };
81 let sat = tag_saturation.get(tid).copied().unwrap_or(0.0);
82 total += base / (1.0 + sat);
83 }
84 None => {}
85 }
86 }
87 let recency = match days_since_last {
88 Some(d) => ((d as f64) / RECENCY_BONUS_DIVISOR_DAYS).min(RECENCY_BONUS_CAP),
89 None => RECENCY_BONUS_CAP,
90 };
91 Some(total + recency)
92 }
93
94 /// Pick a full session of `count` exercises.
95 ///
96 /// Uses the greedy strategy from the design: on each iteration, score every
97 /// unpicked exercise under the current saturation, take the top scorer as
98 /// the primary for the slot, record the next `alternates_per_slot` as
99 /// alternates, then bump saturation for the picked exercise's tags.
100 pub fn pick_session(
101 exercises: &[Exercise],
102 readiness: &HashMap<i64, Readiness>,
103 days_since_last: &HashMap<i64, i64>,
104 count: usize,
105 alternates_per_slot: usize,
106 ) -> Vec<PickedSlot> {
107 let mut picks: Vec<PickedSlot> = Vec::new();
108 let mut saturation: HashMap<i64, f64> = HashMap::new();
109 let mut picked_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
110
111 for _ in 0..count {
112 // Score every candidate. Sore tags exclude entirely; already-picked
113 // exercises are also excluded.
114 let mut scored: Vec<(f64, &Exercise)> = exercises
115 .iter()
116 .filter(|e| !picked_ids.contains(&e.id))
117 .filter_map(|e| {
118 let s = score(e, readiness, days_since_last.get(&e.id).copied(), &saturation)?;
119 Some((s, e))
120 })
121 .collect();
122
123 if scored.is_empty() {
124 break;
125 }
126
127 // Descending score; ties broken by name for determinism.
128 scored.sort_by(|a, b| {
129 b.0.partial_cmp(&a.0)
130 .unwrap_or(std::cmp::Ordering::Equal)
131 .then_with(|| a.1.name.cmp(&b.1.name))
132 });
133
134 let primary = scored[0].1.clone();
135 let alternates: Vec<Exercise> = scored
136 .iter()
137 .skip(1)
138 .take(alternates_per_slot)
139 .map(|(_, e)| (*e).clone())
140 .collect();
141
142 for tid in &primary.tag_ids {
143 *saturation.entry(*tid).or_insert(0.0) += SATURATION_STEP;
144 }
145 picked_ids.insert(primary.id);
146 picks.push(PickedSlot { primary, alternates });
147 }
148
149 picks
150 }
151
152 /// Union of tags across the day's picks, ordered by how many picks each
153 /// tag appears in (descending). Used for the printed warmup line.
154 pub fn warmup_tag_ids(picks: &[PickedSlot]) -> Vec<i64> {
155 let mut counts: HashMap<i64, i32> = HashMap::new();
156 for slot in picks {
157 for tid in &slot.primary.tag_ids {
158 *counts.entry(*tid).or_insert(0) += 1;
159 }
160 }
161 let mut pairs: Vec<(i64, i32)> = counts.into_iter().collect();
162 pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
163 pairs.into_iter().map(|(id, _)| id).collect()
164 }
165
166 // -- DB glue -----------------------------------------------------------------
167
168 use crate::db::Db;
169 use crate::error::Error;
170 use chrono::NaiveDate;
171 use rusqlite::params;
172
173 impl Db {
174 /// Read a HashMap of `tag_id -> Readiness` for the given date.
175 /// Absent tags are simply not in the map.
176 pub fn read_readiness(&self, date: NaiveDate) -> Result<HashMap<i64, Readiness>, Error> {
177 let mut stmt = self
178 .conn()
179 .prepare("SELECT tag_id, state FROM readiness WHERE date = ?1")?;
180 let rows = stmt.query_map(params![date.to_string()], |row| {
181 let s: String = row.get(1)?;
182 Ok((row.get::<_, i64>(0)?, s))
183 })?;
184 let mut out = HashMap::new();
185 for row in rows {
186 let (tag_id, s) = row?;
187 if let Some(r) = Readiness::parse(&s) {
188 out.insert(tag_id, r);
189 }
190 }
191 Ok(out)
192 }
193
194 /// Set a tag's readiness for `date`. Pass `None` to clear.
195 pub fn set_readiness(
196 &self,
197 date: NaiveDate,
198 tag_id: i64,
199 readiness: Option<Readiness>,
200 ) -> Result<(), Error> {
201 match readiness {
202 Some(r) => {
203 self.conn().execute(
204 "INSERT INTO readiness (date, tag_id, state) VALUES (?1, ?2, ?3) \
205 ON CONFLICT(date, tag_id) DO UPDATE SET state = excluded.state",
206 params![date.to_string(), tag_id, r.as_str()],
207 )?;
208 }
209 None => {
210 self.conn().execute(
211 "DELETE FROM readiness WHERE date = ?1 AND tag_id = ?2",
212 params![date.to_string(), tag_id],
213 )?;
214 }
215 }
216 Ok(())
217 }
218
219 /// For every exercise with logged sets, the number of days between
220 /// its most-recent session and `today`. Exercises never logged are
221 /// not in the map.
222 pub fn days_since_last_map(
223 &self,
224 today: NaiveDate,
225 ) -> Result<HashMap<i64, i64>, Error> {
226 let mut stmt = self.conn().prepare(
227 "SELECT exercise_id, MAX(session_date) FROM reps_sets GROUP BY exercise_id",
228 )?;
229 let rows = stmt.query_map([], |row| {
230 let id: i64 = row.get(0)?;
231 let date_str: String = row.get(1)?;
232 Ok((id, date_str))
233 })?;
234 let mut out = HashMap::new();
235 for row in rows {
236 let (id, date_str) = row?;
237 if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
238 let days = (today - d).num_days().max(0);
239 out.insert(id, days);
240 }
241 }
242 Ok(out)
243 }
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249 use crate::effort::Kind;
250 use crate::effort::reps::{RepsKind, RepsPayload};
251 use crate::templates::ResistanceType;
252 use crate::values::{Load, LoadUnit, Reps, Rpe};
253
254 fn ex(id: i64, name: &str, tags: Vec<i64>) -> Exercise {
255 Exercise {
256 id,
257 name: name.to_string(),
258 resistance_type: ResistanceType::Freeweight,
259 effort_kind: crate::EffortKind::Reps,
260 load_unit: LoadUnit::Kg,
261 increment: 2.5,
262 notes: String::new(),
263 tag_ids: tags,
264 }
265 }
266
267 #[test]
268 fn score_sore_returns_none() {
269 let e = ex(1, "bench", vec![10]);
270 let readiness: HashMap<_, _> = [(10, Readiness::Sore)].into_iter().collect();
271 assert_eq!(score(&e, &readiness, Some(2), &HashMap::new()), None);
272 }
273
274 #[test]
275 fn score_ready_beats_tired() {
276 let ready = ex(1, "a", vec![10]);
277 let tired = ex(2, "b", vec![20]);
278 let readiness: HashMap<_, _> = [(10, Readiness::Ready), (20, Readiness::Tired)]
279 .into_iter()
280 .collect();
281 let sa = score(&ready, &readiness, Some(2), &HashMap::new()).unwrap();
282 let sb = score(&tired, &readiness, Some(2), &HashMap::new()).unwrap();
283 assert!(sa > sb);
284 }
285
286 #[test]
287 fn recency_bonus_prefers_less_recently_done() {
288 let stale = ex(1, "a", vec![10]);
289 let fresh = ex(2, "b", vec![10]);
290 let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect();
291 let s_stale = score(&stale, &readiness, Some(14), &HashMap::new()).unwrap();
292 let s_fresh = score(&fresh, &readiness, Some(0), &HashMap::new()).unwrap();
293 assert!(s_stale > s_fresh);
294 }
295
296 #[test]
297 fn never_done_gets_full_recency_bonus() {
298 let e = ex(1, "a", vec![10]);
299 let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect();
300 let s = score(&e, &readiness, None, &HashMap::new()).unwrap();
301 // 1.0 (ready) + 3.0 (max recency) = 4.0.
302 assert!((s - 4.0).abs() < 1e-9);
303 }
304
305 #[test]
306 fn diminishing_returns_deprioritizes_saturated_tags() {
307 let e = ex(1, "a", vec![10]);
308 let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect();
309 let no_sat = score(&e, &readiness, Some(0), &HashMap::new()).unwrap();
310 let sat: HashMap<_, _> = [(10, 1.0)].into_iter().collect();
311 let with_sat = score(&e, &readiness, Some(0), &sat).unwrap();
312 assert!(with_sat < no_sat);
313 }
314
315 #[test]
316 fn picker_fans_out_across_tags() {
317 // 3 exercises: chest+triceps, back+biceps, legs+core. Two picks
318 // should hit disjoint tag groups.
319 let readiness: HashMap<_, _> = [1, 2, 3, 4, 5, 6]
320 .into_iter()
321 .map(|t| (t, Readiness::Ready))
322 .collect();
323 let exercises = vec![
324 ex(1, "bench", vec![1, 2]),
325 ex(2, "row", vec![3, 4]),
326 ex(3, "squat", vec![5, 6]),
327 ];
328 let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3);
329 assert_eq!(picks.len(), 2);
330 let ids: Vec<i64> = picks.iter().map(|p| p.primary.id).collect();
331 assert_ne!(ids[0], ids[1]);
332 }
333
334 #[test]
335 fn picker_skips_sore_exercises() {
336 let readiness: HashMap<_, _> = [
337 (1, Readiness::Ready),
338 (2, Readiness::Sore),
339 (3, Readiness::Ready),
340 ]
341 .into_iter()
342 .collect();
343 let exercises = vec![
344 ex(1, "bench", vec![1, 2]), // blocked (has sore tag)
345 ex(2, "row", vec![3]),
346 ];
347 let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3);
348 assert_eq!(picks.len(), 1);
349 assert_eq!(picks[0].primary.id, 2);
350 }
351
352 #[test]
353 fn alternates_are_ranked_and_exclude_primary() {
354 let readiness: HashMap<_, _> =
355 [1, 2, 3].into_iter().map(|t| (t, Readiness::Ready)).collect();
356 let exercises = vec![
357 ex(1, "a", vec![1]),
358 ex(2, "b", vec![2]),
359 ex(3, "c", vec![3]),
360 ];
361 let picks = pick_session(&exercises, &readiness, &HashMap::new(), 1, 5);
362 assert_eq!(picks.len(), 1);
363 assert!(!picks[0].alternates.iter().any(|e| e.id == picks[0].primary.id));
364 }
365
366 #[test]
367 fn warmup_tags_ordered_by_frequency() {
368 let picks = vec![
369 PickedSlot {
370 primary: ex(1, "a", vec![10, 20]),
371 alternates: vec![],
372 },
373 PickedSlot {
374 primary: ex(2, "b", vec![10, 30]),
375 alternates: vec![],
376 },
377 ];
378 let tags = warmup_tag_ids(&picks);
379 assert_eq!(tags[0], 10, "tag 10 appears in both picks");
380 assert!(tags.contains(&20) && tags.contains(&30));
381 }
382
383 // -- DB glue tests --------------------------------------------------
384
385 fn setup() -> Db {
386 let db = Db::open_in_memory().unwrap();
387 db.init_profile("self", LoadUnit::Kg).unwrap();
388 db
389 }
390
391 #[test]
392 fn readiness_round_trip_and_clear() {
393 let db = setup();
394 let t = db.upsert_tag("chest").unwrap();
395 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
396 db.set_readiness(date, t.id, Some(Readiness::Ready)).unwrap();
397 assert_eq!(
398 db.read_readiness(date).unwrap().get(&t.id),
399 Some(&Readiness::Ready)
400 );
401 db.set_readiness(date, t.id, Some(Readiness::Tired)).unwrap();
402 assert_eq!(
403 db.read_readiness(date).unwrap().get(&t.id),
404 Some(&Readiness::Tired)
405 );
406 db.set_readiness(date, t.id, None).unwrap();
407 assert_eq!(db.read_readiness(date).unwrap().get(&t.id), None);
408 }
409
410 #[test]
411 fn days_since_last_map_populated_only_for_logged_exercises() {
412 let db = setup();
413 let a = db
414 .create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
415 .unwrap();
416 let _b = db
417 .create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
418 .unwrap();
419 let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
420 RepsKind::append(
421 &db,
422 a,
423 d,
424 RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap()),
425 Rpe::new(3).unwrap(),
426 false,
427 )
428 .unwrap();
429 let map = db
430 .days_since_last_map(NaiveDate::from_ymd_opt(2026, 7, 15).unwrap())
431 .unwrap();
432 assert_eq!(map.get(&a).copied(), Some(5));
433 assert_eq!(map.get(&_b), None);
434 }
435 }
436