Skip to main content

max / ripgrow

diagnostic: add the timed protocol and let it seed progression The diagnostic was reps-only. Timed exercises were filtered out of the picker, so a plank or dead hang never got calibrated and its first prescription had nothing to start from. Add next_timed_step, escalating hold duration on the same RPE response the reps protocol uses (1.15 easy, 1.075 medium, stop at 4). It stops early when a hold falls short of what was prescribed, the way reps stops on a missed rep target, and reports the longest hold achieved rather than the last one, since the set that ends a run is usually a failed attempt. No e1RM: that arithmetic is load-and-reps shaped and means nothing for a hold. Pull the shared RPE response into step_up_factor and stops_on so the reps and timed protocols cannot drift apart. Two pieces were missing to make the result actually count. TimedKind had no diagnostic_seed, and its compute_prescription returned NoHistory whenever there were no working sessions, so a completed timed diagnostic would have seeded nothing. Both now mirror the reps path, including the 0.9 ratio that makes a fresh calibration converge with a post-deload rebuild. Distance stays out on purpose, and the module docs now say why: it is track-only, compute_prescription always returns NoHistory, and DistancePrescription is a zero-sized placeholder. A diagnostic exists to seed a progression, so with nothing to seed there is nothing to run. Core only. The TUI still filters every non-reps exercise out of the picker.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 15:55 UTC
Signed with PGP, not checked
Commit: 3ceea35ffcdac3dfd1aabd8a75b9df9cf071abed
Parent: fcb3631
3 files changed, +367 insertions, -30 deletions
@@ -1,29 +1,46 @@
1 - //! First-session diagnostic protocol.
1 + //! First-session (or recalibration) diagnostic protocols.
2 2 //!
3 - //! Given a starting seed load and the sets the user has logged so far,
4 - //! return either the next set to prescribe or a completion result with
5 - //! a working weight and e1RM estimate. Pure functions; the DB layer
6 - //! only persists the sets themselves (flagged `is_diagnostic = 1`).
3 + //! Given a seed and the sets logged so far, return either the next set to
4 + //! prescribe or a completion result carrying the value to seed progression
5 + //! with. Pure functions; the DB layer only persists the sets themselves
6 + //! (flagged `is_diagnostic = 1`).
7 7 //!
8 - //! Rules:
9 - //! - Target reps per set = 5.
10 - //! - RPE 1 or 2 -> next load = last * 1.15.
11 - //! - RPE 3 -> next load = last * 1.075.
12 - //! - RPE 4 or 5 -> stop; the last set is the top set.
13 - //! - A set that missed target reps also stops the diagnostic.
14 - //! - Hard cap at 8 sets in case someone keeps reporting easy RPEs.
15 - //!
16 - //! On completion, working weight = 0.9 * top load (mirrors the deload
8 + //! Two protocols, one shape. Both escalate while the work feels easy, stop
9 + //! when it gets hard or falls short of the target, and hand back 90% of the
10 + //! top set as the working value. That ratio is
11 + //! [`DIAGNOSTIC_WORKING_WEIGHT_RATIO`], deliberately equal to the deload
17 12 //! ratio in the progression state machine, so a fresh diagnostic and a
18 - //! post-deload rebuild converge on the same starting point). e1RM comes
19 - //! from the estimator applied to the last set.
13 + //! post-deload rebuild converge on the same starting point. The RPE
14 + //! response is shared through `step_up_factor` and `stops_on` so the two
15 + //! cannot drift apart:
16 + //!
17 + //! - RPE 1 or 2 -> escalate by 1.15.
18 + //! - RPE 3 -> escalate by 1.075.
19 + //! - RPE 4 or 5 -> stop.
20 + //! - Hard cap at 8 sets, in case someone keeps reporting easy RPEs.
21 + //!
22 + //! **Reps** ([`next_step`]) escalates load, targets 5 reps per set, stops
23 + //! early if a set misses that target, and reports an e1RM from the
24 + //! estimator.
25 + //!
26 + //! **Timed** ([`next_timed_step`]) escalates hold duration and stops early
27 + //! if a hold falls short of what was prescribed. No e1RM: that arithmetic
28 + //! is load-and-reps shaped and means nothing for a hold. Its top value is
29 + //! the longest hold achieved rather than the last one, because the set that
30 + //! ends the run is often a failed attempt.
31 + //!
32 + //! **Distance has no protocol on purpose.** `DistanceKind` is track-only:
33 + //! its `compute_prescription` always returns `NoHistory` and
34 + //! `DistancePrescription` is a zero-sized placeholder. A diagnostic exists
35 + //! to seed a progression, so with nothing to seed there is nothing to run.
36 + //! Give distance a progression model first, then a diagnostic.
20 37
21 38 use crate::estimator::estimate_e1rm_from_set;
22 39 use crate::heuristics::{
23 40 DIAGNOSTIC_EASY_STEP_UP, DIAGNOSTIC_MAX_SETS, DIAGNOSTIC_MEDIUM_STEP_UP,
24 41 DIAGNOSTIC_TARGET_REPS, DIAGNOSTIC_WORKING_WEIGHT_RATIO,
25 42 };
26 - use crate::values::{Estimate, Load, Reps, Rpe};
43 + use crate::values::{Duration, Estimate, Load, Reps, Rpe};
27 44
28 45 /// Re-exports kept so external callers can name these directly. All
29 46 /// values live in [`crate::heuristics`].
@@ -73,20 +90,11 @@
73 90
74 91 let last = done[done.len() - 1];
75 92
76 - let stop = last.rpe.get() >= 4
77 - || last.reps.get() < TARGET_REPS
78 - || done.len() >= MAX_SETS;
79 - if stop {
93 + if stops_on(last.rpe, done.len()) || last.reps.get() < TARGET_REPS {
80 94 return complete(&last);
81 95 }
82 96
83 - let factor = match last.rpe.get() {
84 - 1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
85 - 3 => DIAGNOSTIC_MEDIUM_STEP_UP,
86 - // Above RPE 3 is handled by `stop`; anything unexpected keeps
87 - // the load flat rather than pushing further.
88 - _ => 1.0,
89 - };
97 + let factor = step_up_factor(last.rpe);
90 98 DiagnosticStep::Prescribe {
91 99 load: Load::new(round_to_increment(last.load.get() * factor, increment))
92 100 .expect("escalation of non-negative load stays non-negative"),
@@ -113,6 +121,103 @@
113 121 (value / increment).round() * increment
114 122 }
115 123
124 + /// Escalation factor for the next set given how the last one felt.
125 + /// Shared by the reps and timed protocols so the two cannot drift: an
126 + /// "easy" hold escalates by the same ratio an easy lift does.
127 + /// RPE 4 and 5 are handled by the stop rules before this is reached;
128 + /// anything unexpected holds flat rather than pushing further.
129 + fn step_up_factor(rpe: Rpe) -> f64 {
130 + match rpe.get() {
131 + 1 | 2 => DIAGNOSTIC_EASY_STEP_UP,
132 + 3 => DIAGNOSTIC_MEDIUM_STEP_UP,
133 + _ => 1.0,
134 + }
135 + }
136 +
137 + /// Whether the diagnostic stops after this set, ignoring the
138 + /// kind-specific "missed the target" rule the caller adds.
139 + fn stops_on(rpe: Rpe, done_len: usize) -> bool {
140 + rpe.get() >= 4 || done_len >= MAX_SETS
141 + }
142 +
143 + // ---- timed protocol --------------------------------------------------------
144 +
145 + /// One completed hold inside a timed diagnostic.
146 + ///
147 + /// `achieved` is what was actually held, which is not always what was
148 + /// prescribed: a hold that fails early still carries signal, and the
149 + /// protocol treats falling short of the prescription the way the reps
150 + /// protocol treats missing target reps.
151 + #[derive(Debug, Clone, Copy, PartialEq)]
152 + pub struct TimedDiagnosticSet {
153 + /// What the protocol asked for on this set.
154 + pub prescribed: Duration,
155 + /// What was actually held.
156 + pub achieved: Duration,
157 + pub rpe: Rpe,
158 + }
159 +
160 + #[derive(Debug, Clone, PartialEq)]
161 + pub enum TimedDiagnosticStep {
162 + /// Prescribe the next hold.
163 + Prescribe { duration: Duration },
164 + /// Diagnostic is over. `top_duration` is the longest completed hold;
165 + /// `working_duration` is what to seed progression with.
166 + ///
167 + /// No e1RM: that estimate is load-and-reps arithmetic and has no
168 + /// meaning for a hold.
169 + Complete {
170 + top_duration: Duration,
171 + working_duration: Duration,
172 + },
173 + }
174 +
175 + /// Next step in a timed diagnostic, given a seed hold and the holds
176 + /// already done. Empty slice returns the opening prescription at `seed`.
177 + ///
178 + /// Same shape as [`next_step`]: escalate while the hold is easy, stop
179 + /// when it gets hard or falls short, then take 90% of the top hold as
180 + /// the working duration. Distance has no equivalent because it is
181 + /// track-only and produces no prescription to seed.
182 + pub fn next_timed_step(seed: Duration, done: &[TimedDiagnosticSet]) -> TimedDiagnosticStep {
183 + let Some(last) = done.last() else {
184 + return TimedDiagnosticStep::Prescribe { duration: seed };
185 + };
186 +
187 + let fell_short = last.achieved.seconds() < last.prescribed.seconds();
188 + if stops_on(last.rpe, done.len()) || fell_short {
189 + return complete_timed(done);
190 + }
191 +
192 + let next = (f64::from(last.achieved.seconds()) * step_up_factor(last.rpe)).round();
193 + // Escalating a zero-second hold would stay at zero forever; the cap
194 + // on set count still terminates it, but prescribing 0s is useless,
195 + // so nudge by a second.
196 + let next = if next as i32 <= last.achieved.seconds() {
197 + last.achieved.seconds() + 1
198 + } else {
199 + next as i32
200 + };
201 + TimedDiagnosticStep::Prescribe {
202 + duration: Duration::from_seconds(next).expect("escalation of a non-negative hold"),
203 + }
204 + }
205 +
206 + fn complete_timed(done: &[TimedDiagnosticSet]) -> TimedDiagnosticStep {
207 + // Longest hold actually achieved, not the last one: a set that fell
208 + // short ends the diagnostic but must not be mistaken for the best.
209 + let top = done
210 + .iter()
211 + .map(|s| s.achieved.seconds())
212 + .max()
213 + .unwrap_or_default();
214 + let working = (f64::from(top) * DIAGNOSTIC_WORKING_WEIGHT_RATIO).round() as i32;
215 + TimedDiagnosticStep::Complete {
216 + top_duration: Duration::from_seconds(top).expect("max of non-negative holds"),
217 + working_duration: Duration::from_seconds(working).expect("ratio of a non-negative hold"),
218 + }
219 + }
220 +
116 221 #[cfg(test)]
117 222 mod tests {
118 223 use super::*;
@@ -238,3 +343,105 @@
238 343 assert_eq!(load.get(), 0.0);
239 344 }
240 345 }
346 +
347 + #[cfg(test)]
348 + mod timed_tests {
349 + use super::*;
350 +
351 + fn secs(n: i32) -> Duration {
352 + Duration::from_seconds(n).unwrap()
353 + }
354 +
355 + fn hold(prescribed: i32, achieved: i32, rpe: i32) -> TimedDiagnosticSet {
356 + TimedDiagnosticSet {
357 + prescribed: secs(prescribed),
358 + achieved: secs(achieved),
359 + rpe: Rpe::new(rpe).unwrap(),
360 + }
361 + }
362 +
363 + #[test]
364 + fn empty_history_opens_at_the_seed() {
365 + assert_eq!(
366 + next_timed_step(secs(30), &[]),
367 + TimedDiagnosticStep::Prescribe { duration: secs(30) }
368 + );
369 + }
370 +
371 + #[test]
372 + fn easy_hold_escalates_by_the_same_ratio_as_an_easy_lift() {
373 + // 30s at RPE 2 -> 30 * 1.15 = 34.5 -> 35s.
374 + assert_eq!(
375 + next_timed_step(secs(30), &[hold(30, 30, 2)]),
376 + TimedDiagnosticStep::Prescribe { duration: secs(35) }
377 + );
378 + }
379 +
380 + #[test]
381 + fn medium_hold_escalates_more_gently() {
382 + // 40s at RPE 3 -> 40 * 1.075 = 43.
383 + assert_eq!(
384 + next_timed_step(secs(40), &[hold(40, 40, 3)]),
385 + TimedDiagnosticStep::Prescribe { duration: secs(43) }
386 + );
387 + }
388 +
389 + #[test]
390 + fn hard_hold_stops_and_takes_ninety_percent() {
391 + // 60s at RPE 4 stops. Working = 0.9 * 60 = 54.
392 + assert_eq!(
393 + next_timed_step(secs(60), &[hold(60, 60, 4)]),
394 + TimedDiagnosticStep::Complete {
395 + top_duration: secs(60),
396 + working_duration: secs(54),
397 + }
398 + );
399 + }
400 +
401 + #[test]
402 + fn falling_short_of_the_prescription_stops_the_diagnostic() {
403 + // Asked for 60s, held 45s at an easy RPE. Falling short ends it
404 + // regardless of how it felt.
405 + assert_eq!(
406 + next_timed_step(secs(30), &[hold(30, 30, 2), hold(60, 45, 2)]),
407 + TimedDiagnosticStep::Complete {
408 + top_duration: secs(45),
409 + working_duration: secs(41),
410 + }
411 + );
412 + }
413 +
414 + #[test]
415 + fn top_duration_is_the_best_hold_not_the_last() {
416 + // A strong 80s hold then a failed 90s attempt: the top is 80,
417 + // not the 50 that ended the run.
418 + let done = [hold(80, 80, 3), hold(90, 50, 5)];
419 + assert_eq!(
420 + next_timed_step(secs(80), &done),
421 + TimedDiagnosticStep::Complete {
422 + top_duration: secs(80),
423 + working_duration: secs(72),
424 + }
425 + );
426 + }
427 +
428 + #[test]
429 + fn set_cap_terminates_an_endless_easy_run() {
430 + let done: Vec<TimedDiagnosticSet> =
431 + (0..MAX_SETS).map(|_| hold(30, 30, 1)).collect();
432 + assert!(matches!(
433 + next_timed_step(secs(30), &done),
434 + TimedDiagnosticStep::Complete { .. }
435 + ));
436 + }
437 +
438 + #[test]
439 + fn zero_second_hold_still_escalates() {
440 + // 0 * 1.15 is 0, which would loop until the set cap prescribing
441 + // nothing. Nudge to 1s instead.
442 + assert_eq!(
443 + next_timed_step(secs(0), &[hold(0, 0, 1)]),
444 + TimedDiagnosticStep::Prescribe { duration: secs(1) }
445 + );
446 + }
447 + }
@@ -17,7 +17,10 @@
17 17 pub mod values;
18 18
19 19 pub use db::Db;
20 - pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
20 + pub use diagnostic::{
21 + DiagnosticSet, DiagnosticStep, TimedDiagnosticSet, TimedDiagnosticStep,
22 + next_step as next_diagnostic_step, next_timed_step as next_timed_diagnostic_step,
23 + };
21 24 pub use effort::distance::{DistanceKind, DistancePayload, DistancePrescription, DistanceSet};
22 25 pub use effort::reps::{RepsKind, RepsPayload, RepsPrescription, RepsSet};
23 26 pub use effort::timed::{TimedKind, TimedPayload, TimedPrescription, TimedSet};
@@ -21,7 +21,7 @@
21 21 //! choice for someone tracking their plank without programming it.
22 22
23 23 use chrono::NaiveDate;
24 - use rusqlite::{Row, params};
24 + use rusqlite::{OptionalExtension, Row, params};
25 25
26 26 use crate::db::Db;
27 27 use crate::error::Error;
@@ -208,6 +208,21 @@
208 208 ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
209 209 let sessions = Self::list_sessions_for_exercise(db, exercise.id)?;
210 210 if sessions.is_empty() {
211 + // Fall back to a diagnostic seed, the way the reps path does in
212 + // `Db::compute_prescription`. A completed timed diagnostic hands
213 + // back a top hold; the first working session sits at 0.9x it, the
214 + // same ratio the deload path uses, so a fresh calibration and a
215 + // post-deload rebuild converge instead of diverging.
216 + if let Some(seed) = Self::diagnostic_seed(db, exercise.id)? {
217 + let working = (f64::from(seed) * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO)
218 + .round() as i32;
219 + return Ok(PrescriptionResult::Prescribed(TimedPrescription {
220 + state: State::Progressing,
221 + sets: 3,
222 + duration: Duration::from_seconds(working)
223 + .expect("diagnostic seed produces a non-negative hold"),
224 + }));
225 + }
211 226 return Ok(PrescriptionResult::NoHistory);
212 227 }
213 228 let increment_seconds = exercise.increment as i32;
@@ -221,6 +236,28 @@
221 236 }
222 237
223 238 impl TimedKind {
239 + /// Longest hold from the most recent diagnostic session, in seconds.
240 + /// `None` when the exercise has never been calibrated. Mirrors
241 + /// [`RepsKind::diagnostic_seed`](super::reps::RepsKind::diagnostic_seed),
242 + /// which reads top load rather than top duration.
243 + pub fn diagnostic_seed(db: &Db, exercise_id: i64) -> Result<Option<i32>, Error> {
244 + let out: Option<i32> = db
245 + .conn()
246 + .query_row(
247 + "SELECT MAX(duration_seconds) FROM timed_sets \
248 + WHERE exercise_id = ?1 AND is_diagnostic = 1 \
249 + AND session_date = ( \
250 + SELECT MAX(session_date) FROM timed_sets \
251 + WHERE exercise_id = ?1 AND is_diagnostic = 1 \
252 + )",
253 + params![exercise_id],
254 + |row| row.get(0),
255 + )
256 + .optional()?
257 + .flatten();
258 + Ok(out)
259 + }
260 +
224 261 /// Delete a specific timed set by id. Returns `NotFound` if it did
225 262 /// not exist. Symmetric with [`RepsKind::delete`](super::reps::RepsKind::delete).
226 263 pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
@@ -466,3 +503,93 @@
466 503 assert_eq!(pres.duration.seconds(), 54);
467 504 }
468 505 }
506 +
507 + #[cfg(test)]
508 + mod diagnostic_seed_tests {
509 + use super::*;
510 + use crate::templates::ResistanceType;
511 + use crate::values::LoadUnit;
512 +
513 + fn setup() -> (Db, i64) {
514 + let db = Db::open_in_memory().unwrap();
515 + db.init_profile("self", LoadUnit::Kg).unwrap();
516 + let id = db
517 + .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
518 + .unwrap();
519 + (db, id)
520 + }
521 +
522 + fn day(n: u32) -> NaiveDate {
523 + NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
524 + }
525 +
526 + fn log(db: &Db, ex: i64, d: NaiveDate, secs: i32, rpe: i32, diagnostic: bool) {
527 + TimedKind::append(
528 + db,
529 + ex,
530 + d,
531 + TimedPayload::new(Duration::from_seconds(secs).unwrap()),
532 + Rpe::new(rpe).unwrap(),
533 + diagnostic,
534 + )
535 + .unwrap();
536 + }
537 +
538 + fn exercise(db: &Db, id: i64) -> Exercise {
539 + db.list_exercises()
540 + .unwrap()
541 + .into_iter()
542 + .find(|e| e.id == id)
543 + .unwrap()
544 + }
545 +
546 + #[test]
547 + fn seed_is_none_without_diagnostic_history() {
548 + let (db, ex) = setup();
549 + log(&db, ex, day(10), 60, 3, false);
550 + assert_eq!(TimedKind::diagnostic_seed(&db, ex).unwrap(), None);
551 + }
552 +
553 + #[test]
554 + fn seed_takes_top_hold_of_the_latest_diagnostic() {
555 + let (db, ex) = setup();
556 + log(&db, ex, day(10), 45, 2, true);
557 + log(&db, ex, day(10), 70, 4, true);
558 + // A later diagnostic supersedes the earlier one entirely.
559 + log(&db, ex, day(20), 50, 2, true);
560 + log(&db, ex, day(20), 55, 4, true);
561 + assert_eq!(TimedKind::diagnostic_seed(&db, ex).unwrap(), Some(55));
562 + }
563 +
564 + #[test]
565 + fn prescription_seeds_from_diagnostic_when_no_working_history() {
566 + let (db, ex) = setup();
567 + log(&db, ex, day(10), 80, 4, true);
568 + let PrescriptionResult::Prescribed(p) =
569 + TimedKind::compute_prescription(&db, &exercise(&db, ex)).unwrap()
570 + else {
571 + panic!("expected a prescription seeded from the diagnostic");
572 + };
573 + // 0.9 * 80 = 72, matching the deload ratio.
574 + assert_eq!(p.duration.seconds(), 72);
575 + assert_eq!(p.state, State::Progressing);
576 + }
577 +
578 + #[test]
579 + fn working_history_beats_the_diagnostic_seed() {
580 + let (db, ex) = setup();
581 + log(&db, ex, day(10), 200, 4, true);
582 + log(&db, ex, day(12), 30, 3, false);
583 + let PrescriptionResult::Prescribed(p) =
584 + TimedKind::compute_prescription(&db, &exercise(&db, ex)).unwrap()
585 + else {
586 + panic!("expected a prescription");
587 + };
588 + // Driven by the 30s working set, not the 200s calibration.
589 + assert!(
590 + p.duration.seconds() < 100,
591 + "expected working history to win, got {}s",
592 + p.duration.seconds()
593 + );
594 + }
595 + }