//! Exercise scorer + greedy session picker. //! //! Given a set of exercise templates, per-tag readiness, and how recently //! each exercise was last done, produce a session of `count` picks plus a //! ranked alternates list per slot. The scorer is deliberately tunable and //! transparent so the reroll UX can explain "why is X here." //! //! Rules per the design note: //! //! - `Ready` tag contributes +1.0 to any exercise carrying that tag. //! - `Tired` tag contributes -1.0. //! - `Sore` tag is a hard block: exercises carrying that tag are removed //! from the pool entirely. //! - Recency bonus: `days_since_last / 7.0`, capped at 3.0. Never-done //! exercises get the cap, so novelty is preferred over stale rotations. //! - Diminishing returns: after an exercise is picked, its tags gain 1.0 //! of "saturation". Each contributing tag is scaled by `1 / (1 + sat)` //! for subsequent picks, so the greedy pass fans out rather than //! stacking on one muscle group. use std::collections::HashMap; use crate::heuristics::{ READY_TAG_BONUS, RECENCY_BONUS_CAP, RECENCY_BONUS_DIVISOR_DAYS, SATURATION_STEP, TIRED_TAG_PENALTY, }; use crate::templates::Exercise; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Readiness { Ready, Tired, Sore, } impl Readiness { pub fn as_str(&self) -> &'static str { match self { Readiness::Ready => "ready", Readiness::Tired => "tired", Readiness::Sore => "sore", } } pub fn parse(s: &str) -> Option { match s { "ready" => Some(Readiness::Ready), "tired" => Some(Readiness::Tired), "sore" => Some(Readiness::Sore), _ => None, } } } /// One slot in the generated session: a primary pick plus alternates, /// ranked by score under the same saturation state that produced the pick. #[derive(Debug, Clone, PartialEq)] pub struct PickedSlot { pub primary: Exercise, pub alternates: Vec, } /// Score a single exercise. Returns `None` when any of its tags is marked /// `Sore` (hard block). pub fn score( exercise: &Exercise, readiness: &HashMap, days_since_last: Option, tag_saturation: &HashMap, ) -> Option { let mut total = 0.0; for tid in &exercise.tag_ids { match readiness.get(tid) { Some(Readiness::Sore) => return None, Some(r) => { let base = match r { Readiness::Ready => READY_TAG_BONUS, Readiness::Tired => -TIRED_TAG_PENALTY, Readiness::Sore => unreachable!(), }; let sat = tag_saturation.get(tid).copied().unwrap_or(0.0); total += base / (1.0 + sat); } None => {} } } let recency = match days_since_last { Some(d) => ((d as f64) / RECENCY_BONUS_DIVISOR_DAYS).min(RECENCY_BONUS_CAP), None => RECENCY_BONUS_CAP, }; Some(total + recency) } /// Pick a full session of `count` exercises. /// /// Uses the greedy strategy from the design: on each iteration, score every /// unpicked exercise under the current saturation, take the top scorer as /// the primary for the slot, record the next `alternates_per_slot` as /// alternates, then bump saturation for the picked exercise's tags. pub fn pick_session( exercises: &[Exercise], readiness: &HashMap, days_since_last: &HashMap, count: usize, alternates_per_slot: usize, ) -> Vec { let mut picks: Vec = Vec::new(); let mut saturation: HashMap = HashMap::new(); let mut picked_ids: std::collections::HashSet = std::collections::HashSet::new(); for _ in 0..count { // Score every candidate. Sore tags exclude entirely; already-picked // exercises are also excluded. let mut scored: Vec<(f64, &Exercise)> = exercises .iter() .filter(|e| !picked_ids.contains(&e.id)) .filter_map(|e| { let s = score(e, readiness, days_since_last.get(&e.id).copied(), &saturation)?; Some((s, e)) }) .collect(); if scored.is_empty() { break; } // Descending score; ties broken by name for determinism. scored.sort_by(|a, b| { b.0.partial_cmp(&a.0) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.1.name.cmp(&b.1.name)) }); let primary = scored[0].1.clone(); let alternates: Vec = scored .iter() .skip(1) .take(alternates_per_slot) .map(|(_, e)| (*e).clone()) .collect(); for tid in &primary.tag_ids { *saturation.entry(*tid).or_insert(0.0) += SATURATION_STEP; } picked_ids.insert(primary.id); picks.push(PickedSlot { primary, alternates }); } picks } /// Union of tags across the day's picks, ordered by how many picks each /// tag appears in (descending). Used for the printed warmup line. pub fn warmup_tag_ids(picks: &[PickedSlot]) -> Vec { let mut counts: HashMap = HashMap::new(); for slot in picks { for tid in &slot.primary.tag_ids { *counts.entry(*tid).or_insert(0) += 1; } } let mut pairs: Vec<(i64, i32)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs.into_iter().map(|(id, _)| id).collect() } // -- DB glue ----------------------------------------------------------------- use crate::db::Db; use crate::error::Error; use chrono::NaiveDate; use rusqlite::params; impl Db { /// Read a HashMap of `tag_id -> Readiness` for the given date. /// Absent tags are simply not in the map. pub fn read_readiness(&self, date: NaiveDate) -> Result, Error> { let mut stmt = self .conn() .prepare("SELECT tag_id, state FROM readiness WHERE date = ?1")?; let rows = stmt.query_map(params![date.to_string()], |row| { let s: String = row.get(1)?; Ok((row.get::<_, i64>(0)?, s)) })?; let mut out = HashMap::new(); for row in rows { let (tag_id, s) = row?; if let Some(r) = Readiness::parse(&s) { out.insert(tag_id, r); } } Ok(out) } /// Set a tag's readiness for `date`. Pass `None` to clear. pub fn set_readiness( &self, date: NaiveDate, tag_id: i64, readiness: Option, ) -> Result<(), Error> { match readiness { Some(r) => { self.conn().execute( "INSERT INTO readiness (date, tag_id, state) VALUES (?1, ?2, ?3) \ ON CONFLICT(date, tag_id) DO UPDATE SET state = excluded.state", params![date.to_string(), tag_id, r.as_str()], )?; } None => { self.conn().execute( "DELETE FROM readiness WHERE date = ?1 AND tag_id = ?2", params![date.to_string(), tag_id], )?; } } Ok(()) } /// For every exercise with logged sets, the number of days between /// its most-recent session and `today`. Exercises never logged are /// not in the map. pub fn days_since_last_map( &self, today: NaiveDate, ) -> Result, Error> { let mut stmt = self.conn().prepare( "SELECT exercise_id, MAX(session_date) FROM reps_sets GROUP BY exercise_id", )?; let rows = stmt.query_map([], |row| { let id: i64 = row.get(0)?; let date_str: String = row.get(1)?; Ok((id, date_str)) })?; let mut out = HashMap::new(); for row in rows { let (id, date_str) = row?; if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { let days = (today - d).num_days().max(0); out.insert(id, days); } } Ok(out) } } #[cfg(test)] mod tests { use super::*; use crate::effort::Kind; use crate::effort::reps::{RepsKind, RepsPayload}; use crate::templates::ResistanceType; use crate::values::{Load, LoadUnit, Reps, Rpe}; fn ex(id: i64, name: &str, tags: Vec) -> Exercise { Exercise { id, name: name.to_string(), resistance_type: ResistanceType::Freeweight, effort_kind: crate::EffortKind::Reps, load_unit: LoadUnit::Kg, increment: 2.5, notes: String::new(), tag_ids: tags, } } #[test] fn score_sore_returns_none() { let e = ex(1, "bench", vec![10]); let readiness: HashMap<_, _> = [(10, Readiness::Sore)].into_iter().collect(); assert_eq!(score(&e, &readiness, Some(2), &HashMap::new()), None); } #[test] fn score_ready_beats_tired() { let ready = ex(1, "a", vec![10]); let tired = ex(2, "b", vec![20]); let readiness: HashMap<_, _> = [(10, Readiness::Ready), (20, Readiness::Tired)] .into_iter() .collect(); let sa = score(&ready, &readiness, Some(2), &HashMap::new()).unwrap(); let sb = score(&tired, &readiness, Some(2), &HashMap::new()).unwrap(); assert!(sa > sb); } #[test] fn recency_bonus_prefers_less_recently_done() { let stale = ex(1, "a", vec![10]); let fresh = ex(2, "b", vec![10]); let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); let s_stale = score(&stale, &readiness, Some(14), &HashMap::new()).unwrap(); let s_fresh = score(&fresh, &readiness, Some(0), &HashMap::new()).unwrap(); assert!(s_stale > s_fresh); } #[test] fn never_done_gets_full_recency_bonus() { let e = ex(1, "a", vec![10]); let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); let s = score(&e, &readiness, None, &HashMap::new()).unwrap(); // 1.0 (ready) + 3.0 (max recency) = 4.0. assert!((s - 4.0).abs() < 1e-9); } #[test] fn diminishing_returns_deprioritizes_saturated_tags() { let e = ex(1, "a", vec![10]); let readiness: HashMap<_, _> = [(10, Readiness::Ready)].into_iter().collect(); let no_sat = score(&e, &readiness, Some(0), &HashMap::new()).unwrap(); let sat: HashMap<_, _> = [(10, 1.0)].into_iter().collect(); let with_sat = score(&e, &readiness, Some(0), &sat).unwrap(); assert!(with_sat < no_sat); } #[test] fn picker_fans_out_across_tags() { // 3 exercises: chest+triceps, back+biceps, legs+core. Two picks // should hit disjoint tag groups. let readiness: HashMap<_, _> = [1, 2, 3, 4, 5, 6] .into_iter() .map(|t| (t, Readiness::Ready)) .collect(); let exercises = vec![ ex(1, "bench", vec![1, 2]), ex(2, "row", vec![3, 4]), ex(3, "squat", vec![5, 6]), ]; let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3); assert_eq!(picks.len(), 2); let ids: Vec = picks.iter().map(|p| p.primary.id).collect(); assert_ne!(ids[0], ids[1]); } #[test] fn picker_skips_sore_exercises() { let readiness: HashMap<_, _> = [ (1, Readiness::Ready), (2, Readiness::Sore), (3, Readiness::Ready), ] .into_iter() .collect(); let exercises = vec![ ex(1, "bench", vec![1, 2]), // blocked (has sore tag) ex(2, "row", vec![3]), ]; let picks = pick_session(&exercises, &readiness, &HashMap::new(), 2, 3); assert_eq!(picks.len(), 1); assert_eq!(picks[0].primary.id, 2); } #[test] fn alternates_are_ranked_and_exclude_primary() { let readiness: HashMap<_, _> = [1, 2, 3].into_iter().map(|t| (t, Readiness::Ready)).collect(); let exercises = vec![ ex(1, "a", vec![1]), ex(2, "b", vec![2]), ex(3, "c", vec![3]), ]; let picks = pick_session(&exercises, &readiness, &HashMap::new(), 1, 5); assert_eq!(picks.len(), 1); assert!(!picks[0].alternates.iter().any(|e| e.id == picks[0].primary.id)); } #[test] fn warmup_tags_ordered_by_frequency() { let picks = vec![ PickedSlot { primary: ex(1, "a", vec![10, 20]), alternates: vec![], }, PickedSlot { primary: ex(2, "b", vec![10, 30]), alternates: vec![], }, ]; let tags = warmup_tag_ids(&picks); assert_eq!(tags[0], 10, "tag 10 appears in both picks"); assert!(tags.contains(&20) && tags.contains(&30)); } // -- DB glue tests -------------------------------------------------- fn setup() -> Db { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db } #[test] fn readiness_round_trip_and_clear() { let db = setup(); let t = db.upsert_tag("chest").unwrap(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); db.set_readiness(date, t.id, Some(Readiness::Ready)).unwrap(); assert_eq!( db.read_readiness(date).unwrap().get(&t.id), Some(&Readiness::Ready) ); db.set_readiness(date, t.id, Some(Readiness::Tired)).unwrap(); assert_eq!( db.read_readiness(date).unwrap().get(&t.id), Some(&Readiness::Tired) ); db.set_readiness(date, t.id, None).unwrap(); assert_eq!(db.read_readiness(date).unwrap().get(&t.id), None); } #[test] fn days_since_last_map_populated_only_for_logged_exercises() { let db = setup(); let a = db .create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); let _b = db .create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); RepsKind::append( &db, a, d, RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let map = db .days_since_last_map(NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()) .unwrap(); assert_eq!(map.get(&a).copied(), Some(5)); assert_eq!(map.get(&_b), None); } }