Skip to main content

max / ripgrow

14.6 KB · 469 lines History Blame Raw
1 //! Timed effort kind.
2 //!
3 //! A single value per set — how long the position was held. Planks,
4 //! dead hangs, isometric wall-sits, any timed cardio unit lands here.
5 //! RPE still rides on every set (universal across kinds) as does the
6 //! diagnostic flag.
7 //!
8 //! The prescription math is a stripped-down mirror of the reps state
9 //! machine: each session's outcome is decided by max RPE (`<= 3` clean,
10 //! `4` hold, `5` miss), and the next duration climbs by a per-exercise
11 //! increment on clean, holds flat on hold, and deloads on miss. Same
12 //! four states as reps (Progressing/Probation/Deloading/Rebuilding),
13 //! same `SessionOutcome` -> `State` transitions, same
14 //! `DELOAD_RATIO`. Only the *value* progresses differently: seconds
15 //! instead of kilograms.
16 //!
17 //! Increment source: the exercise's [`Exercise::increment`] column,
18 //! interpreted as **seconds per successful session** rather than
19 //! kilograms per successful session. Bodyweight-holds with zero
20 //! increment therefore never progress numerically; that's a valid
21 //! choice for someone tracking their plank without programming it.
22
23 use chrono::NaiveDate;
24 use rusqlite::{Row, params};
25
26 use crate::db::Db;
27 use crate::error::Error;
28 use crate::heuristics::DELOAD_RATIO;
29 use crate::progression::{self, State};
30 use crate::templates::Exercise;
31 use crate::values::{Duration, Rpe};
32
33 use super::{EffortKind, Kind, Outcome, PrescriptionResult};
34
35 pub struct TimedKind;
36
37 #[derive(Debug, Clone, Copy, PartialEq)]
38 pub struct TimedPayload {
39 pub duration: Duration,
40 }
41
42 impl TimedPayload {
43 pub fn new(duration: Duration) -> Self {
44 Self { duration }
45 }
46 }
47
48 #[derive(Debug, Clone, PartialEq)]
49 pub struct TimedSet {
50 pub id: i64,
51 pub session_date: NaiveDate,
52 pub exercise_id: i64,
53 pub set_index: i32,
54 pub duration: Duration,
55 pub rpe: Rpe,
56 pub is_diagnostic: bool,
57 }
58
59 #[derive(Debug, Clone, PartialEq)]
60 pub struct TimedPrescription {
61 pub state: State,
62 /// Number of holds to program. Echoes the last session's set count.
63 pub sets: i32,
64 /// Target hold duration.
65 pub duration: Duration,
66 }
67
68 fn row_to_timed_set(row: &Row<'_>) -> rusqlite::Result<TimedSet> {
69 let date_str: String = row.get(1)?;
70 let session_date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
71 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
72 })?;
73 let secs: i32 = row.get(4)?;
74 let rpe_raw: i32 = row.get(5)?;
75 let diag: i64 = row.get(6)?;
76 let duration = Duration::from_seconds(secs).map_err(|e| {
77 rusqlite::Error::FromSqlConversionFailure(
78 4,
79 rusqlite::types::Type::Integer,
80 Box::new(std::io::Error::other(e.to_string())),
81 )
82 })?;
83 let rpe = Rpe::new(rpe_raw).map_err(|e| {
84 rusqlite::Error::FromSqlConversionFailure(
85 5,
86 rusqlite::types::Type::Integer,
87 Box::new(std::io::Error::other(e.to_string())),
88 )
89 })?;
90 Ok(TimedSet {
91 id: row.get(0)?,
92 session_date,
93 exercise_id: row.get(2)?,
94 set_index: row.get(3)?,
95 duration,
96 rpe,
97 is_diagnostic: diag != 0,
98 })
99 }
100
101 impl Kind for TimedKind {
102 type Payload = TimedPayload;
103 type Set = TimedSet;
104 type Prescription = TimedPrescription;
105
106 const DISCRIMINANT: EffortKind = EffortKind::Timed;
107 const TABLE: &'static str = "timed_sets";
108
109 fn append(
110 db: &Db,
111 exercise_id: i64,
112 date: NaiveDate,
113 payload: Self::Payload,
114 rpe: Rpe,
115 is_diagnostic: bool,
116 ) -> Result<i64, Error> {
117 let next_index: i32 = db.conn().query_row(
118 "SELECT COALESCE(MAX(set_index), 0) + 1 FROM timed_sets \
119 WHERE exercise_id = ?1 AND session_date = ?2",
120 params![exercise_id, date.to_string()],
121 |row| row.get(0),
122 )?;
123 db.conn().execute(
124 "INSERT INTO timed_sets \
125 (session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic) \
126 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
127 params![
128 date.to_string(),
129 exercise_id,
130 next_index,
131 payload.duration.seconds(),
132 rpe.get(),
133 if is_diagnostic { 1 } else { 0 },
134 ],
135 )?;
136 Ok(db.conn().last_insert_rowid())
137 }
138
139 fn list_sets_for_session(
140 db: &Db,
141 exercise_id: i64,
142 date: NaiveDate,
143 ) -> Result<Vec<Self::Set>, Error> {
144 let mut stmt = db.conn().prepare(
145 "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \
146 FROM timed_sets WHERE exercise_id = ?1 AND session_date = ?2 \
147 ORDER BY set_index",
148 )?;
149 let rows = stmt
150 .query_map(params![exercise_id, date.to_string()], |row| {
151 row_to_timed_set(row)
152 })?;
153 Ok(rows.collect::<Result<Vec<_>, _>>()?)
154 }
155
156 fn list_sessions_for_exercise(
157 db: &Db,
158 exercise_id: i64,
159 ) -> Result<Vec<Vec<Self::Set>>, Error> {
160 let mut stmt = db.conn().prepare(
161 "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \
162 FROM timed_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
163 ORDER BY session_date ASC, set_index ASC",
164 )?;
165 let rows = stmt.query_map(params![exercise_id], |row| row_to_timed_set(row))?;
166 let mut sessions: Vec<Vec<TimedSet>> = Vec::new();
167 for row in rows {
168 let set = row?;
169 match sessions.last_mut() {
170 Some(last) if last[0].session_date == set.session_date => last.push(set),
171 _ => sessions.push(vec![set]),
172 }
173 }
174 Ok(sessions)
175 }
176
177 fn delete_last_diagnostic_on_date(
178 db: &Db,
179 exercise_id: i64,
180 date: NaiveDate,
181 ) -> Result<(), Error> {
182 let list = Self::list_sets_for_session(db, exercise_id, date)?;
183 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
184 Self::delete(db, last.id)?;
185 }
186 Ok(())
187 }
188
189 fn evaluate(sets: &[Self::Set]) -> Outcome {
190 if sets.is_empty() {
191 return Outcome::Miss;
192 }
193 // Timed sessions have no rep-count target — outcome is decided
194 // purely by max RPE. Same thresholds as reps for consistency.
195 let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
196 if max_rpe >= 5 {
197 Outcome::Miss
198 } else if max_rpe == 4 {
199 Outcome::Hold
200 } else {
201 Outcome::CleanHit
202 }
203 }
204
205 fn compute_prescription(
206 db: &Db,
207 exercise: &Exercise,
208 ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
209 let sessions = Self::list_sessions_for_exercise(db, exercise.id)?;
210 if sessions.is_empty() {
211 return Ok(PrescriptionResult::NoHistory);
212 }
213 let increment_seconds = exercise.increment as i32;
214 let walk = walk_timed_history(&sessions, increment_seconds);
215 Ok(PrescriptionResult::Prescribed(TimedPrescription {
216 state: walk.state,
217 sets: walk.last_set_count,
218 duration: walk.next_duration,
219 }))
220 }
221 }
222
223 impl TimedKind {
224 /// Delete a specific timed set by id. Returns `NotFound` if it did
225 /// not exist. Symmetric with [`RepsKind::delete`](super::reps::RepsKind::delete).
226 pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
227 let n = db
228 .conn()
229 .execute("DELETE FROM timed_sets WHERE id = ?1", params![id])?;
230 if n == 0 {
231 return Err(Error::NotFound(format!("timed set {id}")));
232 }
233 Ok(())
234 }
235 }
236
237 /// Full walk state carried through the timed history, mirroring the
238 /// reps walker. `next_duration` is what we'd prescribe if the most
239 /// recent session were the last one.
240 struct TimedWalk {
241 state: State,
242 next_duration: Duration,
243 last_set_count: i32,
244 last_top_seconds: i32,
245 pre_deload_seconds: Option<i32>,
246 }
247
248 fn walk_timed_history(sessions: &[Vec<TimedSet>], increment_seconds: i32) -> TimedWalk {
249 let first = &sessions[0];
250 let first_top = top_duration_seconds(first);
251 let mut walk = TimedWalk {
252 state: State::Progressing,
253 next_duration: Duration::from_seconds((first_top + increment_seconds).max(0))
254 .expect("clamped to non-negative"),
255 last_set_count: first.len() as i32,
256 last_top_seconds: first_top,
257 pre_deload_seconds: None,
258 };
259 for session in &sessions[1..] {
260 let outcome = TimedKind::evaluate(session);
261 let prev_state = walk.state;
262 let new_state = progression::next_state(prev_state, outcome);
263 let session_top = top_duration_seconds(session);
264 if new_state == State::Deloading && prev_state != State::Deloading {
265 walk.pre_deload_seconds = Some(session_top);
266 }
267 walk.next_duration = compute_next_duration(
268 prev_state,
269 new_state,
270 outcome,
271 session_top,
272 increment_seconds,
273 );
274 walk.state = new_state;
275 walk.last_set_count = session.len() as i32;
276 walk.last_top_seconds = session_top;
277 if walk.state == State::Rebuilding
278 && let Some(pd) = walk.pre_deload_seconds
279 && session_top >= pd
280 {
281 walk.state = State::Progressing;
282 walk.pre_deload_seconds = None;
283 }
284 }
285 walk
286 }
287
288 fn compute_next_duration(
289 prev_state: State,
290 new_state: State,
291 outcome: Outcome,
292 session_top_seconds: i32,
293 increment_seconds: i32,
294 ) -> Duration {
295 if new_state == State::Deloading && prev_state != State::Deloading {
296 let deloaded = ((session_top_seconds as f64) * DELOAD_RATIO).round() as i32;
297 return Duration::from_seconds(deloaded.max(0))
298 .expect("clamped to non-negative");
299 }
300 let next = match outcome {
301 Outcome::CleanHit => session_top_seconds + increment_seconds,
302 Outcome::Hold | Outcome::Miss => session_top_seconds,
303 };
304 Duration::from_seconds(next.max(0)).expect("clamped to non-negative")
305 }
306
307 fn top_duration_seconds(session: &[TimedSet]) -> i32 {
308 session.iter().map(|s| s.duration.seconds()).max().unwrap_or(0)
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314 use crate::values::LoadUnit;
315 use crate::ResistanceType;
316
317 fn setup() -> (Db, i64) {
318 let db = Db::open_in_memory().unwrap();
319 db.init_profile("self", LoadUnit::Kg).unwrap();
320 let id = db
321 .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
322 .unwrap();
323 (db, id)
324 }
325
326 fn day(n: u32) -> NaiveDate {
327 NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
328 }
329
330 #[test]
331 fn discriminant_and_table_agree_with_schema() {
332 assert_eq!(TimedKind::DISCRIMINANT, EffortKind::Timed);
333 assert_eq!(TimedKind::TABLE, "timed_sets");
334 }
335
336 #[test]
337 fn append_and_list_round_trip() {
338 let (db, ex) = setup();
339 TimedKind::append(
340 &db,
341 ex,
342 day(18),
343 TimedPayload::new(Duration::from_seconds(60).unwrap()),
344 Rpe::new(3).unwrap(),
345 false,
346 )
347 .unwrap();
348 let list = TimedKind::list_sets_for_session(&db, ex, day(18)).unwrap();
349 assert_eq!(list.len(), 1);
350 assert_eq!(list[0].duration.seconds(), 60);
351 }
352
353 #[test]
354 fn diagnostic_sets_are_filtered_from_progression_history() {
355 let (db, ex) = setup();
356 TimedKind::append(
357 &db,
358 ex,
359 day(10),
360 TimedPayload::new(Duration::from_seconds(30).unwrap()),
361 Rpe::new(3).unwrap(),
362 true,
363 )
364 .unwrap();
365 TimedKind::append(
366 &db,
367 ex,
368 day(12),
369 TimedPayload::new(Duration::from_seconds(60).unwrap()),
370 Rpe::new(3).unwrap(),
371 false,
372 )
373 .unwrap();
374 let sessions = TimedKind::list_sessions_for_exercise(&db, ex).unwrap();
375 assert_eq!(sessions.len(), 1);
376 assert_eq!(sessions[0][0].session_date, day(12));
377 }
378
379 #[test]
380 fn no_history_yields_no_prescription() {
381 let (db, ex) = setup();
382 let exercise = db
383 .list_exercises()
384 .unwrap()
385 .into_iter()
386 .find(|e| e.id == ex)
387 .unwrap();
388 assert!(matches!(
389 TimedKind::compute_prescription(&db, &exercise).unwrap(),
390 PrescriptionResult::NoHistory
391 ));
392 }
393
394 #[test]
395 fn clean_hit_climbs_by_increment_seconds() {
396 let (db, ex) = setup();
397 TimedKind::append(
398 &db,
399 ex,
400 day(10),
401 TimedPayload::new(Duration::from_seconds(60).unwrap()),
402 Rpe::new(3).unwrap(),
403 false,
404 )
405 .unwrap();
406 let exercise = db
407 .list_exercises()
408 .unwrap()
409 .into_iter()
410 .find(|e| e.id == ex)
411 .unwrap();
412 let PrescriptionResult::Prescribed(pres) =
413 TimedKind::compute_prescription(&db, &exercise).unwrap()
414 else {
415 panic!("expected prescription");
416 };
417 // Increment on this exercise is 10 sec.
418 assert_eq!(pres.duration.seconds(), 70);
419 assert_eq!(pres.state, State::Progressing);
420 }
421
422 #[test]
423 fn miss_after_probation_deloads_by_ratio() {
424 let (db, ex) = setup();
425 // Session 1: clean at 60. Session 2: miss at 60 (probation).
426 // Session 3: miss at 60 (deload). Next prescription: 0.9 * 60 = 54.
427 TimedKind::append(
428 &db,
429 ex,
430 day(1),
431 TimedPayload::new(Duration::from_seconds(60).unwrap()),
432 Rpe::new(3).unwrap(),
433 false,
434 )
435 .unwrap();
436 TimedKind::append(
437 &db,
438 ex,
439 day(2),
440 TimedPayload::new(Duration::from_seconds(60).unwrap()),
441 Rpe::new(5).unwrap(),
442 false,
443 )
444 .unwrap();
445 TimedKind::append(
446 &db,
447 ex,
448 day(3),
449 TimedPayload::new(Duration::from_seconds(60).unwrap()),
450 Rpe::new(5).unwrap(),
451 false,
452 )
453 .unwrap();
454 let exercise = db
455 .list_exercises()
456 .unwrap()
457 .into_iter()
458 .find(|e| e.id == ex)
459 .unwrap();
460 let PrescriptionResult::Prescribed(pres) =
461 TimedKind::compute_prescription(&db, &exercise).unwrap()
462 else {
463 panic!()
464 };
465 assert_eq!(pres.state, State::Deloading);
466 assert_eq!(pres.duration.seconds(), 54);
467 }
468 }
469