Skip to main content

max / ripgrow

25.1 KB · 697 lines History Blame Raw
1 //! Progression state machine and prescription math.
2 //!
3 //! Per (profile, exercise) we track one of four states: `Progressing`,
4 //! `Probation`, `Deloading`, `Rebuilding`. The state is a pure function of
5 //! the exercise's session history and increment; the `progression_state`
6 //! table is a cache that the DB layer refreshes.
7 //!
8 //! This module contains only pure functions. See `Db::compute_prescription`
9 //! for the glue that reads history, walks it, and updates the cache.
10
11 use crate::effort::reps::{RepsKind, RepsSet};
12 use crate::heuristics::DELOAD_RATIO;
13 use crate::values::{Load, Reps};
14
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 pub enum State {
17 Progressing,
18 Probation,
19 Deloading,
20 Rebuilding,
21 }
22
23 impl State {
24 pub fn as_str(&self) -> &'static str {
25 match self {
26 State::Progressing => "progressing",
27 State::Probation => "probation",
28 State::Deloading => "deloading",
29 State::Rebuilding => "rebuilding",
30 }
31 }
32
33 pub fn parse(s: &str) -> Option<Self> {
34 match s {
35 "progressing" => Some(State::Progressing),
36 "probation" => Some(State::Probation),
37 "deloading" => Some(State::Deloading),
38 "rebuilding" => Some(State::Rebuilding),
39 _ => None,
40 }
41 }
42 }
43
44 /// How a single session performed against its implicit target reps.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 pub enum SessionOutcome {
47 /// All sets hit the target rep count with RPE <= 3.
48 CleanHit,
49 /// All sets hit the target rep count, but max RPE was 4.
50 Hold,
51 /// Any set fell short of the target OR max RPE was 5.
52 Miss,
53 }
54
55 /// Evaluate a session against the target rep count.
56 ///
57 /// `target_reps` is the max rep count from the previous session for this
58 /// exercise; on the first session there is no target so we consider it a
59 /// clean hit (which drives the initial +increment on the following one).
60 pub fn evaluate_session(sets: &[RepsSet], target_reps: i32) -> SessionOutcome {
61 if sets.is_empty() {
62 return SessionOutcome::Miss;
63 }
64 let all_hit_target = sets.iter().all(|s| s.reps.get() >= target_reps);
65 let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
66 if !all_hit_target || max_rpe >= 5 {
67 SessionOutcome::Miss
68 } else if max_rpe == 4 {
69 SessionOutcome::Hold
70 } else {
71 SessionOutcome::CleanHit
72 }
73 }
74
75 /// The prescription for the next session of a given exercise.
76 ///
77 /// Not stored anywhere; recomputed on demand from history. Note that
78 /// `sets` here is a *count* of sets to program, not a `Set` — the
79 /// primitive `i32` is fine.
80 #[derive(Debug, Clone, PartialEq)]
81 pub struct Prescription {
82 pub state: State,
83 /// Number of sets to program. Same as the last session's set count so a
84 /// user who typically does 3 sets keeps doing 3 sets.
85 pub sets: i32,
86 /// Target rep count. Same as the last session's target.
87 pub reps: Reps,
88 /// Load in the exercise's [`crate::LoadUnit`].
89 pub load: Load,
90 }
91
92 /// Result of walking history + computing what comes next.
93 #[derive(Debug, Clone, PartialEq)]
94 pub enum PrescriptionResult {
95 /// No sessions logged for this exercise yet. The Log screen prompts;
96 /// the Generate screen shows "seed on first log".
97 NoHistory,
98 Prescribed(Prescription),
99 }
100
101 /// The full state carried through the history walk. Includes the load that
102 /// would be prescribed if the most recent session were the last one, so
103 /// `Hold` outcomes propagate their "same load" semantics without needing a
104 /// separate held-flag on `State`.
105 #[derive(Debug, Clone, Copy, PartialEq)]
106 struct Walk {
107 state: State,
108 /// Load prescribed for the NEXT (unlogged) session. Refreshed each step.
109 next_load: f64,
110 /// Target reps carried from the most recent session (used to evaluate
111 /// the next one).
112 last_target_reps: i32,
113 /// Sets performed in the most recent session (echoed into the next
114 /// prescription so 3x5 stays 3x5).
115 last_set_count: i32,
116 /// Top load performed in the most recent session. Needed for
117 /// Rebuilding->Progressing graduation.
118 last_top_load: f64,
119 /// Load right before the last deload transition. `None` outside
120 /// deloading/rebuilding.
121 pre_deload_load: Option<f64>,
122 }
123
124 /// Walk sessions in chronological order and return the current state + the
125 /// numbers to prescribe next. `sessions` must already be grouped by date and
126 /// sorted oldest -> newest.
127 pub fn walk_history(sessions: &[Vec<RepsSet>], increment: f64) -> Option<WalkResult> {
128 if sessions.is_empty() {
129 return None;
130 }
131
132 // Seed from the first session. There is no prior target, so this session
133 // is treated as a clean hit — the follow-up gets +increment.
134 let first = &sessions[0];
135 let first_top = top_load(first);
136 let mut walk = Walk {
137 state: State::Progressing,
138 next_load: round_to_increment(first_top + increment, increment),
139 last_target_reps: top_reps(first),
140 last_set_count: first.len() as i32,
141 last_top_load: first_top,
142 pre_deload_load: None,
143 };
144
145 for session in &sessions[1..] {
146 let outcome = evaluate_session(session, walk.last_target_reps);
147 let prev_state = walk.state;
148 let new_state = next_state(prev_state, outcome);
149 let session_top = top_load(session);
150
151 // Snapshot pre-deload load right at the transition INTO deloading.
152 if new_state == State::Deloading && prev_state != State::Deloading {
153 walk.pre_deload_load = Some(session_top);
154 }
155
156 walk.next_load =
157 compute_next_load(prev_state, new_state, outcome, session_top, increment);
158 walk.state = new_state;
159 walk.last_target_reps = top_reps(session);
160 walk.last_set_count = session.len() as i32;
161 walk.last_top_load = session_top;
162
163 // If Rebuilding cleared the pre-deload load with this session,
164 // graduate to Progressing and clear the snapshot. The prescription
165 // (already computed above) is `session_top + increment`, which is
166 // exactly what Progressing wants for a clean hit.
167 if walk.state == State::Rebuilding
168 && let Some(pd) = walk.pre_deload_load
169 && session_top >= pd
170 {
171 walk.state = State::Progressing;
172 walk.pre_deload_load = None;
173 }
174 }
175
176 Some(WalkResult {
177 state: walk.state,
178 next_load: walk.next_load,
179 last_target_reps: walk.last_target_reps,
180 last_set_count: walk.last_set_count,
181 last_top_load: walk.last_top_load,
182 pre_deload_load: walk.pre_deload_load,
183 })
184 }
185
186 #[derive(Debug, Clone, PartialEq)]
187 pub struct WalkResult {
188 pub state: State,
189 pub next_load: f64,
190 pub last_target_reps: i32,
191 pub last_set_count: i32,
192 pub last_top_load: f64,
193 pub pre_deload_load: Option<f64>,
194 }
195
196 /// Pure state transition. See module docs for the rules.
197 pub fn next_state(current: State, outcome: SessionOutcome) -> State {
198 match (current, outcome) {
199 (State::Progressing, SessionOutcome::CleanHit) => State::Progressing,
200 (State::Progressing, SessionOutcome::Hold) => State::Progressing,
201 (State::Progressing, SessionOutcome::Miss) => State::Probation,
202 (State::Probation, SessionOutcome::CleanHit) => State::Progressing,
203 (State::Probation, SessionOutcome::Hold) => State::Probation,
204 (State::Probation, SessionOutcome::Miss) => State::Deloading,
205 // "On success -> rebuilding" (design). Hold counts as success here;
206 // a user hovering at the deloaded weight has already recovered.
207 (State::Deloading, SessionOutcome::CleanHit) => State::Rebuilding,
208 (State::Deloading, SessionOutcome::Hold) => State::Rebuilding,
209 (State::Deloading, SessionOutcome::Miss) => State::Deloading,
210 // A miss mid-rebuild is a soft signal, back to probation. Full
211 // rules from the design note.
212 (State::Rebuilding, SessionOutcome::CleanHit) => State::Rebuilding,
213 (State::Rebuilding, SessionOutcome::Hold) => State::Rebuilding,
214 (State::Rebuilding, SessionOutcome::Miss) => State::Probation,
215 }
216 }
217
218 /// Compute the load to prescribe for the next session. The three inputs
219 /// that matter are the transition (previous state -> new state), the
220 /// outcome of the session that caused it, and the load actually performed.
221 ///
222 /// `increment` can be 0 (bodyweight, cardio); rounding skips in that case.
223 fn compute_next_load(
224 prev_state: State,
225 new_state: State,
226 outcome: SessionOutcome,
227 session_top: f64,
228 increment: f64,
229 ) -> f64 {
230 // Entering Deloading is the one place we compress the load; every
231 // other transition either climbs or holds. The ratio is a heuristic
232 // shared with the diagnostic's working-weight computation.
233 if new_state == State::Deloading && prev_state != State::Deloading {
234 return round_to_increment(session_top * DELOAD_RATIO, increment);
235 }
236 match outcome {
237 SessionOutcome::CleanHit => round_to_increment(session_top + increment, increment),
238 // Hold and Miss both keep the load the same. Deloading a second
239 // time on repeated Miss would double-compress a struggling lifter,
240 // which the design explicitly avoids.
241 SessionOutcome::Hold | SessionOutcome::Miss => session_top,
242 }
243 }
244
245 fn round_to_increment(value: f64, increment: f64) -> f64 {
246 if increment <= 0.0 {
247 return value;
248 }
249 (value / increment).round() * increment
250 }
251
252 fn top_load(session: &[RepsSet]) -> f64 {
253 session
254 .iter()
255 .map(|s| s.load.get())
256 .fold(f64::NEG_INFINITY, f64::max)
257 }
258
259 fn top_reps(session: &[RepsSet]) -> i32 {
260 session.iter().map(|s| s.reps.get()).max().unwrap_or(0)
261 }
262
263 /// Walk the exercise's session history and produce the next prescription.
264 /// Returns `NoHistory` when nothing has been logged yet.
265 pub fn compute_prescription(
266 sessions: &[Vec<RepsSet>],
267 increment: f64,
268 ) -> PrescriptionResult {
269 let Some(walk) = walk_history(sessions, increment) else {
270 return PrescriptionResult::NoHistory;
271 };
272 PrescriptionResult::Prescribed(Prescription {
273 state: walk.state,
274 sets: walk.last_set_count,
275 // last_target_reps and next_load came out of a session that was
276 // just validated at decode time, so unwrap is fine — a bad row
277 // would have failed to load in the first place.
278 reps: Reps::new(walk.last_target_reps).expect("target reps from validated history"),
279 load: Load::new(walk.next_load).expect("next load from validated history"),
280 })
281 }
282
283 // -- DB glue -----------------------------------------------------------------
284
285 use crate::db::Db;
286 use crate::error::Error;
287 use rusqlite::{OptionalExtension, params};
288
289 impl Db {
290 /// Compute the next prescription for `exercise_id`. Reads full history
291 /// and walks the state machine from scratch each call. Refreshes the
292 /// `progression_state` cache with the current state so other screens
293 /// can display it without re-walking.
294 pub fn compute_prescription(&self, exercise_id: i64) -> Result<PrescriptionResult, Error> {
295 let increment: f64 = self
296 .conn()
297 .query_row(
298 "SELECT increment FROM exercises WHERE id = ?1",
299 params![exercise_id],
300 |row| row.get(0),
301 )
302 .optional()?
303 .ok_or_else(|| Error::NotFound(format!("exercise {exercise_id}")))?;
304
305 let sessions = <RepsKind as crate::effort::Kind>::list_sessions_for_exercise(
306 self,
307 exercise_id,
308 )?;
309 let mut result = compute_prescription(&sessions, increment);
310
311 // Fall back to a diagnostic seed when there is no working-set
312 // history. A completed diagnostic hands back a top load; the
313 // first prescribed working session sits at 0.9x that (same ratio
314 // the deload path uses, so a fresh calibration converges with a
315 // post-deload rebuild rather than diverging).
316 if matches!(result, PrescriptionResult::NoHistory)
317 && let Some(seed) = RepsKind::diagnostic_seed(self, exercise_id)?
318 {
319 let load_raw = round_to_increment(
320 seed * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO,
321 increment,
322 );
323 result = PrescriptionResult::Prescribed(Prescription {
324 state: State::Progressing,
325 sets: 3,
326 reps: Reps::new(5).unwrap(),
327 load: Load::new(load_raw)
328 .expect("diagnostic seed produces non-negative load"),
329 });
330 }
331
332 // Cache the state (with the most recent session date as since_date
333 // for display purposes; not a precise "since when" transition marker
334 // — see the history screen for the accurate walk).
335 if let PrescriptionResult::Prescribed(pres) = &result {
336 let since = sessions
337 .last()
338 .map(|s| s[0].session_date.to_string())
339 .unwrap_or_else(|| chrono::Local::now().date_naive().to_string());
340 self.conn().execute(
341 "INSERT INTO progression_state (exercise_id, state, since_date) \
342 VALUES (?1, ?2, ?3) \
343 ON CONFLICT(exercise_id) DO UPDATE SET \
344 state = excluded.state, since_date = excluded.since_date",
345 params![exercise_id, pres.state.as_str(), since],
346 )?;
347 }
348
349 Ok(result)
350 }
351
352 /// Read the persisted state for display. `None` when no prescription
353 /// has been computed yet.
354 pub fn read_progression_state(&self, exercise_id: i64) -> Result<Option<State>, Error> {
355 let s: Option<String> = self
356 .conn()
357 .query_row(
358 "SELECT state FROM progression_state WHERE exercise_id = ?1",
359 params![exercise_id],
360 |row| row.get(0),
361 )
362 .optional()?;
363 Ok(s.and_then(|v| State::parse(&v)))
364 }
365 }
366
367 #[cfg(test)]
368 mod db_tests {
369 use super::*;
370 use crate::effort::reps::RepsPayload;
371 use crate::templates::ResistanceType;
372 use crate::values::{Load, LoadUnit, Reps, Rpe};
373 use chrono::NaiveDate;
374
375 fn setup() -> (Db, i64) {
376 let db = Db::open_in_memory().unwrap();
377 db.init_profile("self", LoadUnit::Kg).unwrap();
378 let id = db
379 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
380 .unwrap();
381 (db, id)
382 }
383
384 fn log_set(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) {
385 let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap());
386 <RepsKind as crate::effort::Kind>::append(
387 db, ex, date, payload, Rpe::new(rpe).unwrap(), false,
388 )
389 .unwrap();
390 }
391
392 fn log_diagnostic(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) {
393 let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap());
394 <RepsKind as crate::effort::Kind>::append(
395 db, ex, date, payload, Rpe::new(rpe).unwrap(), true,
396 )
397 .unwrap();
398 }
399
400 #[test]
401 fn compute_prescription_no_history() {
402 let (db, id) = setup();
403 assert_eq!(
404 db.compute_prescription(id).unwrap(),
405 PrescriptionResult::NoHistory
406 );
407 }
408
409 #[test]
410 fn compute_prescription_seeds_from_diagnostic_when_no_working_history() {
411 let (db, id) = setup();
412 let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
413 // Diagnostic finished at 100. Working weight = 0.9 * 100 = 90.
414 log_diagnostic(&db, id, d, 100.0, 5, 4);
415 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
416 panic!("expected prescription");
417 };
418 assert_eq!(pres.state, State::Progressing);
419 assert_eq!(pres.sets, 3);
420 assert_eq!(pres.reps.get(), 5);
421 assert_eq!(pres.load.get(), 90.0);
422 }
423
424 #[test]
425 fn compute_prescription_prefers_working_history_over_diagnostic_seed() {
426 let (db, id) = setup();
427 let d1 = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
428 let d2 = NaiveDate::from_ymd_opt(2026, 7, 12).unwrap();
429 log_diagnostic(&db, id, d1, 100.0, 5, 4);
430 log_set(&db, id, d2, 60.0, 5, 3);
431 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
432 panic!("expected prescription");
433 };
434 // Working set at 60 with a clean first-session hit -> next 62.5.
435 assert_eq!(pres.load.get(), 62.5);
436 }
437
438 #[test]
439 fn compute_prescription_reads_history_and_caches_state() {
440 let (db, id) = setup();
441 let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
442 for _ in 0..3 {
443 log_set(&db, id, day(1), 100.0, 5, 3);
444 }
445 for _ in 0..3 {
446 log_set(&db, id, day(2), 102.5, 5, 4);
447 }
448 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
449 panic!("expected prescription");
450 };
451 assert_eq!(pres.state, State::Progressing);
452 assert_eq!(pres.load.get(), 102.5, "hold keeps load flat");
453 assert_eq!(
454 db.read_progression_state(id).unwrap(),
455 Some(State::Progressing)
456 );
457 }
458
459 #[test]
460 fn compute_prescription_updates_cache_across_calls() {
461 let (db, id) = setup();
462 let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
463 log_set(&db, id, day(1), 100.0, 5, 3);
464 db.compute_prescription(id).unwrap();
465 assert_eq!(
466 db.read_progression_state(id).unwrap(),
467 Some(State::Progressing)
468 );
469 // Add a miss to drop to probation.
470 log_set(&db, id, day(2), 100.0, 5, 3);
471 log_set(&db, id, day(2), 100.0, 5, 3);
472 log_set(&db, id, day(2), 100.0, 4, 3);
473 db.compute_prescription(id).unwrap();
474 assert_eq!(
475 db.read_progression_state(id).unwrap(),
476 Some(State::Probation)
477 );
478 }
479 }
480
481 #[cfg(test)]
482 mod tests {
483 use super::*;
484 use chrono::NaiveDate;
485
486 fn s(date: NaiveDate, load: f64, reps: i32, rpe: i32, set_index: i32) -> RepsSet {
487 RepsSet {
488 id: 0,
489 session_date: date,
490 exercise_id: 1,
491 set_index,
492 load: crate::values::Load::new(load).unwrap(),
493 reps: crate::values::Reps::new(reps).unwrap(),
494 rpe: crate::values::Rpe::new(rpe).unwrap(),
495 is_diagnostic: false,
496 }
497 }
498
499 fn day(n: u32) -> NaiveDate {
500 NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
501 }
502
503 fn session(date: NaiveDate, load: f64, reps: i32, rpe: i32, sets: i32) -> Vec<RepsSet> {
504 (1..=sets).map(|i| s(date, load, reps, rpe, i)).collect()
505 }
506
507 #[test]
508 fn evaluate_examples() {
509 let sets = session(day(1), 100.0, 5, 3, 3);
510 assert_eq!(evaluate_session(&sets, 5), SessionOutcome::CleanHit);
511
512 let mut mixed = session(day(1), 100.0, 5, 3, 3);
513 mixed[2].rpe = crate::values::Rpe::new(4).unwrap();
514 assert_eq!(evaluate_session(&mixed, 5), SessionOutcome::Hold);
515
516 let mut missed = session(day(1), 100.0, 5, 3, 3);
517 missed[2].reps = crate::values::Reps::new(4).unwrap();
518 assert_eq!(evaluate_session(&missed, 5), SessionOutcome::Miss);
519
520 let mut hard = session(day(1), 100.0, 5, 3, 3);
521 hard[0].rpe = crate::values::Rpe::new(5).unwrap();
522 assert_eq!(evaluate_session(&hard, 5), SessionOutcome::Miss);
523 }
524
525 #[test]
526 fn empty_sessions_no_history() {
527 assert_eq!(compute_prescription(&[], 2.5), PrescriptionResult::NoHistory);
528 }
529
530 #[test]
531 fn single_session_is_progressing_plus_increment() {
532 let sessions = vec![session(day(1), 100.0, 5, 3, 3)];
533 let p = compute_prescription(&sessions, 2.5);
534 let PrescriptionResult::Prescribed(pres) = p else {
535 panic!("expected prescription");
536 };
537 assert_eq!(pres.state, State::Progressing);
538 assert_eq!(pres.sets, 3);
539 assert_eq!(pres.reps.get(), 5);
540 assert_eq!(pres.load.get(), 102.5);
541 }
542
543 #[test]
544 fn hold_keeps_load_same() {
545 let sessions = vec![
546 session(day(1), 100.0, 5, 3, 3),
547 session(day(2), 102.5, 5, 4, 3),
548 ];
549 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
550 panic!()
551 };
552 assert_eq!(pres.state, State::Progressing);
553 assert_eq!(pres.load.get(), 102.5);
554 }
555
556 #[test]
557 fn miss_drops_to_probation_same_load() {
558 let sessions = vec![
559 session(day(1), 100.0, 5, 3, 3),
560 {
561 let mut s = session(day(2), 102.5, 5, 3, 3);
562 s[2].reps = crate::values::Reps::new(4).unwrap();
563 s
564 },
565 ];
566 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
567 panic!()
568 };
569 assert_eq!(pres.state, State::Probation);
570 assert_eq!(pres.load.get(), 102.5);
571 }
572
573 #[test]
574 fn probation_then_clean_hit_resumes_progressing() {
575 let sessions = vec![
576 session(day(1), 100.0, 5, 3, 3),
577 {
578 let mut s = session(day(2), 102.5, 5, 3, 3);
579 s[2].reps = crate::values::Reps::new(4).unwrap();
580 s
581 },
582 session(day(3), 102.5, 5, 3, 3),
583 ];
584 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
585 panic!()
586 };
587 assert_eq!(pres.state, State::Progressing);
588 assert_eq!(pres.load.get(), 105.0);
589 }
590
591 #[test]
592 fn probation_then_miss_drops_to_deloading_at_ninety_percent() {
593 let sessions = vec![
594 session(day(1), 100.0, 5, 3, 3),
595 {
596 let mut s = session(day(2), 100.0, 5, 3, 3);
597 s[2].reps = crate::values::Reps::new(4).unwrap();
598 s
599 },
600 {
601 let mut s = session(day(3), 100.0, 5, 3, 3);
602 s[2].reps = crate::values::Reps::new(4).unwrap();
603 s
604 },
605 ];
606 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
607 panic!()
608 };
609 assert_eq!(pres.state, State::Deloading);
610 // 100 * 0.9 = 90, rounded to nearest 2.5 -> 90.
611 assert_eq!(pres.load.get(), 90.0);
612 }
613
614 #[test]
615 fn rebuilding_climbs_and_graduates_when_pre_deload_reached() {
616 // History: 100 (progressing) -> 100 miss (probation) -> 100 miss
617 // (deloading, pre_deload=100) -> 90 clean (rebuilding, still <100)
618 // -> 92.5 clean (rebuilding) -> 100 clean (still rebuilding until
619 // AFTER the pre-deload session; state becomes Progressing here).
620 let sessions = vec![
621 session(day(1), 100.0, 5, 3, 3),
622 {
623 let mut s = session(day(2), 100.0, 5, 3, 3);
624 s[2].reps = crate::values::Reps::new(4).unwrap();
625 s
626 },
627 {
628 let mut s = session(day(3), 100.0, 5, 3, 3);
629 s[2].reps = crate::values::Reps::new(4).unwrap();
630 s
631 },
632 session(day(4), 90.0, 5, 3, 3),
633 session(day(5), 92.5, 5, 3, 3),
634 session(day(6), 100.0, 5, 3, 3),
635 ];
636 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
637 panic!()
638 };
639 assert_eq!(pres.state, State::Progressing);
640 assert_eq!(pres.load.get(), 102.5);
641 }
642
643 #[test]
644 fn rebuilding_stays_when_still_below_pre_deload() {
645 let sessions = vec![
646 session(day(1), 100.0, 5, 3, 3),
647 {
648 let mut s = session(day(2), 100.0, 5, 3, 3);
649 s[2].reps = crate::values::Reps::new(4).unwrap();
650 s
651 },
652 {
653 let mut s = session(day(3), 100.0, 5, 3, 3);
654 s[2].reps = crate::values::Reps::new(4).unwrap();
655 s
656 },
657 session(day(4), 90.0, 5, 3, 3),
658 ];
659 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
660 panic!()
661 };
662 assert_eq!(pres.state, State::Rebuilding);
663 assert_eq!(pres.load.get(), 92.5);
664 }
665
666 #[test]
667 fn bodyweight_increment_zero_does_not_round() {
668 let sessions = vec![session(day(1), 0.0, 8, 3, 3)];
669 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 0.0) else {
670 panic!()
671 };
672 assert_eq!(pres.load.get(), 0.0);
673 }
674
675 #[test]
676 fn deload_rounds_to_nearest_increment() {
677 let sessions = vec![
678 session(day(1), 82.5, 5, 3, 3),
679 {
680 let mut s = session(day(2), 82.5, 5, 3, 3);
681 s[2].reps = crate::values::Reps::new(4).unwrap();
682 s
683 },
684 {
685 let mut s = session(day(3), 82.5, 5, 3, 3);
686 s[2].reps = crate::values::Reps::new(4).unwrap();
687 s
688 },
689 ];
690 let PrescriptionResult::Prescribed(pres) = compute_prescription(&sessions, 2.5) else {
691 panic!()
692 };
693 // 82.5 * 0.9 = 74.25, nearest 2.5 = 75.
694 assert_eq!(pres.load.get(), 75.0);
695 }
696 }
697