Skip to main content

max / ripgrow

diagnostic: schema flag, protocol driver, and seed helpers Migration 002 adds sets.is_diagnostic. Progression history and the top-load sparkline filter diagnostic rows so a calibration day never tips the state machine into a phantom deload; e1RM keeps counting them because reaching for a true top set is where the estimator gets its best signal. diagnostic::next_step is a pure escalation driver: opens at the seed, climbs 15% on RPE 1-2 and 7.5% on RPE 3, stops on RPE >= 4 or a missed set, caps at eight sets. Completion returns the top load, a 0.9x working weight (matches the deload ratio), and the last set's e1RM. Db::diagnostic_seed returns the top load from the most recent diagnostic so Generate can seed a real first prescription instead of prompting. No UI yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:30 UTC
Commit: b01907a0b7abf6ad4157327ba497db663217dd52
Parent: a61e87f
5 files changed, +383 insertions, -51 deletions
@@ -11,7 +11,7 @@ use rusqlite::{Connection, OptionalExtension, params};
11 11
12 12 use crate::error::Error;
13 13
14 - const MIGRATIONS: &[&str] = &[MIGRATION_001];
14 + const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002];
15 15
16 16 /// Unit preference for a profile. Stored as `'kg'` or `'lb'`; the choice is
17 17 /// made at profile-create time and hard to change safely later.
@@ -182,6 +182,12 @@ CREATE TABLE readiness (
182 182 );
183 183 "#;
184 184
185 + const MIGRATION_002: &str = r#"
186 + ALTER TABLE sets ADD COLUMN is_diagnostic INTEGER NOT NULL DEFAULT 0
187 + CHECK (is_diagnostic IN (0, 1));
188 + CREATE INDEX idx_sets_exercise_diagnostic ON sets(exercise_id, is_diagnostic);
189 + "#;
190 +
185 191 #[cfg(test)]
186 192 mod tests {
187 193 use super::*;
@@ -0,0 +1,222 @@
1 + //! First-session diagnostic protocol.
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`).
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
17 + //! 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.
20 +
21 + use crate::estimator::estimate_e1rm;
22 +
23 + pub const TARGET_REPS: i32 = 5;
24 + pub const MAX_SETS: usize = 8;
25 + const WORKING_WEIGHT_RATIO: f64 = 0.9;
26 +
27 + /// One completed set inside a diagnostic. Same shape as `SessionSet`'s
28 + /// working columns, kept separate so the protocol can be unit-tested
29 + /// without touching the DB layer.
30 + #[derive(Debug, Clone, Copy, PartialEq)]
31 + pub struct DiagnosticSet {
32 + pub load: f64,
33 + pub reps: i32,
34 + pub rpe: i32,
35 + }
36 +
37 + #[derive(Debug, Clone, PartialEq)]
38 + pub enum DiagnosticStep {
39 + /// Prescribe the next set. `reps` is the target rep count.
40 + Prescribe { load: f64, reps: i32 },
41 + /// Diagnostic is over. `top_load` is the heaviest completed set;
42 + /// `working_weight` is what to seed progression with; `e1rm` is the
43 + /// estimator's read on the last set (`None` if the last set carried
44 + /// no signal, e.g. warmup RPE).
45 + Complete {
46 + top_load: f64,
47 + working_weight: f64,
48 + e1rm: Option<f64>,
49 + },
50 + }
51 +
52 + /// Next step in a diagnostic given the seed load and sets already done.
53 + /// Passing an empty slice returns the opening prescription at `seed`.
54 + pub fn next_step(seed: f64, increment: f64, done: &[DiagnosticSet]) -> DiagnosticStep {
55 + if done.is_empty() {
56 + return DiagnosticStep::Prescribe {
57 + load: round_to_increment(seed, increment),
58 + reps: TARGET_REPS,
59 + };
60 + }
61 +
62 + let last = done[done.len() - 1];
63 +
64 + let stop = last.rpe >= 4 || last.reps < TARGET_REPS || done.len() >= MAX_SETS;
65 + if stop {
66 + return complete(&last);
67 + }
68 +
69 + let factor = match last.rpe {
70 + 1 | 2 => 1.15,
71 + 3 => 1.075,
72 + // Above RPE 3 is handled by `stop`; anything unexpected keeps
73 + // the load flat rather than pushing further.
74 + _ => 1.0,
75 + };
76 + DiagnosticStep::Prescribe {
77 + load: round_to_increment(last.load * factor, increment),
78 + reps: TARGET_REPS,
79 + }
80 + }
81 +
82 + fn complete(last: &DiagnosticSet) -> DiagnosticStep {
83 + let top_load = last.load;
84 + let working_weight = top_load * WORKING_WEIGHT_RATIO;
85 + let e1rm = estimate_e1rm(last.load, last.reps, last.rpe);
86 + DiagnosticStep::Complete {
87 + top_load,
88 + working_weight,
89 + e1rm,
90 + }
91 + }
92 +
93 + fn round_to_increment(value: f64, increment: f64) -> f64 {
94 + if increment <= 0.0 {
95 + return value;
96 + }
97 + (value / increment).round() * increment
98 + }
99 +
100 + #[cfg(test)]
101 + mod tests {
102 + use super::*;
103 +
104 + fn set(load: f64, reps: i32, rpe: i32) -> DiagnosticSet {
105 + DiagnosticSet { load, reps, rpe }
106 + }
107 +
108 + #[test]
109 + fn empty_history_prescribes_seed() {
110 + let step = next_step(60.0, 2.5, &[]);
111 + assert_eq!(
112 + step,
113 + DiagnosticStep::Prescribe {
114 + load: 60.0,
115 + reps: TARGET_REPS,
116 + }
117 + );
118 + }
119 +
120 + #[test]
121 + fn seed_is_rounded_to_increment() {
122 + // 61 rounds to nearest 2.5 = 60.
123 + let step = next_step(61.0, 2.5, &[]);
124 + let DiagnosticStep::Prescribe { load, .. } = step else {
125 + panic!("expected prescribe");
126 + };
127 + assert_eq!(load, 60.0);
128 + }
129 +
130 + #[test]
131 + fn rpe_2_escalates_fifteen_percent() {
132 + let step = next_step(0.0, 2.5, &[set(100.0, 5, 2)]);
133 + let DiagnosticStep::Prescribe { load, .. } = step else {
134 + panic!("expected prescribe");
135 + };
136 + // 115 rounded to 2.5 = 115.
137 + assert_eq!(load, 115.0);
138 + }
139 +
140 + #[test]
141 + fn rpe_3_escalates_seven_and_a_half_percent() {
142 + let step = next_step(0.0, 2.5, &[set(100.0, 5, 3)]);
143 + let DiagnosticStep::Prescribe { load, .. } = step else {
144 + panic!("expected prescribe");
145 + };
146 + // 107.5 rounded to 2.5 = 107.5.
147 + assert_eq!(load, 107.5);
148 + }
149 +
150 + #[test]
151 + fn rpe_4_completes_and_computes_working_weight() {
152 + let step = next_step(0.0, 2.5, &[set(100.0, 5, 4)]);
153 + let DiagnosticStep::Complete {
154 + top_load,
155 + working_weight,
156 + e1rm,
157 + } = step
158 + else {
159 + panic!("expected complete");
160 + };
161 + assert_eq!(top_load, 100.0);
162 + assert_eq!(working_weight, 90.0);
163 + assert!(e1rm.is_some());
164 + }
165 +
166 + #[test]
167 + fn rpe_5_completes_with_last_set_as_top() {
168 + let history = &[set(100.0, 5, 3), set(110.0, 5, 5)];
169 + let step = next_step(0.0, 2.5, history);
170 + let DiagnosticStep::Complete { top_load, .. } = step else {
171 + panic!("expected complete");
172 + };
173 + assert_eq!(top_load, 110.0);
174 + }
175 +
176 + #[test]
177 + fn missed_target_reps_completes_early() {
178 + let step = next_step(0.0, 2.5, &[set(100.0, 3, 3)]);
179 + assert!(matches!(step, DiagnosticStep::Complete { .. }));
180 + }
181 +
182 + #[test]
183 + fn hard_cap_completes_at_max_sets() {
184 + let history: Vec<DiagnosticSet> =
185 + (0..MAX_SETS).map(|_| set(100.0, 5, 2)).collect();
186 + let step = next_step(0.0, 2.5, &history);
187 + assert!(matches!(step, DiagnosticStep::Complete { .. }));
188 + }
189 +
190 + #[test]
191 + fn escalation_chain_reaches_stop() {
192 + // Start at 60, escalate on easy RPEs, stop when the user finally
193 + // reports RPE 4.
194 + let history = vec![
195 + set(60.0, 5, 2), // -> next 70 (60*1.15=69, round to 70 on inc 2.5)
196 + set(70.0, 5, 2), // -> next 80.5, round to 80
197 + set(80.0, 5, 3), // -> next 86.0, round to 85
198 + set(85.0, 5, 4), // stop
199 + ];
200 + let step = next_step(60.0, 2.5, &history);
201 + let DiagnosticStep::Complete {
202 + top_load,
203 + working_weight,
204 + ..
205 + } = step
206 + else {
207 + panic!("expected complete");
208 + };
209 + assert_eq!(top_load, 85.0);
210 + assert_eq!(working_weight, 76.5);
211 + }
212 +
213 + #[test]
214 + fn increment_zero_skips_rounding() {
215 + // Bodyweight-style exercise: seed 0, escalation is a no-op.
216 + let step = next_step(0.0, 0.0, &[set(0.0, 5, 2)]);
217 + let DiagnosticStep::Prescribe { load, .. } = step else {
218 + panic!("expected prescribe");
219 + };
220 + assert_eq!(load, 0.0);
221 + }
222 + }
@@ -4,6 +4,7 @@
4 4 //! is a file, hand-off is copy-the-file, deletion is `rm`.
5 5
6 6 pub mod db;
7 + pub mod diagnostic;
7 8 pub mod error;
8 9 pub mod estimator;
9 10 pub mod generation;
@@ -13,6 +14,7 @@ pub mod sets;
13 14 pub mod templates;
14 15
15 16 pub use db::{Db, Unit};
17 + pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
16 18 pub use error::Error;
17 19 pub use estimator::{estimate_e1rm, rir_from_rpe};
18 20 pub use generation::{PickedSlot, Readiness};
@@ -413,6 +413,7 @@ mod tests {
413 413 load,
414 414 reps,
415 415 rpe,
416 + is_diagnostic: false,
416 417 }
417 418 }
418 419
@@ -5,12 +5,32 @@
5 5 //! by CHECK at the schema level.
6 6
7 7 use chrono::NaiveDate;
8 - use rusqlite::params;
8 + use rusqlite::{OptionalExtension, Row, params};
9 9
10 10 use crate::db::Db;
11 11 use crate::error::Error;
12 12 use crate::estimator::estimate_e1rm;
13 13
14 + /// Row -> SessionSet decoder shared by every set-fetching query. Column
15 + /// order must match the callers' SELECT lists.
16 + fn row_to_session_set(row: &Row<'_>) -> rusqlite::Result<SessionSet> {
17 + let date_str: String = row.get(1)?;
18 + let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
19 + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
20 + })?;
21 + let flag: i64 = row.get(7)?;
22 + Ok(SessionSet {
23 + id: row.get(0)?,
24 + session_date: parsed,
25 + exercise_id: row.get(2)?,
26 + set_index: row.get(3)?,
27 + load: row.get(4)?,
28 + reps: row.get(5)?,
29 + rpe: row.get(6)?,
30 + is_diagnostic: flag != 0,
31 + })
32 + }
33 +
14 34 #[derive(Debug, Clone, PartialEq)]
15 35 pub struct SessionSet {
16 36 pub id: i64,
@@ -20,46 +40,33 @@ pub struct SessionSet {
20 40 pub load: f64,
21 41 pub reps: i32,
22 42 pub rpe: i32,
43 + pub is_diagnostic: bool,
23 44 }
24 45
25 46 impl Db {
26 47 /// List sets for an exercise on a given date, ordered by set_index.
48 + /// Includes diagnostic sets (the log screen shows every row that was
49 + /// actually recorded that day; filtering happens further downstream).
27 50 pub fn list_sets_for_session(
28 51 &self,
29 52 exercise_id: i64,
30 53 date: NaiveDate,
31 54 ) -> Result<Vec<SessionSet>, Error> {
32 55 let mut stmt = self.conn().prepare(
33 - "SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
56 + "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
34 57 FROM sets WHERE exercise_id = ?1 AND session_date = ?2 \
35 58 ORDER BY set_index",
36 59 )?;
37 60 let rows = stmt.query_map(
38 61 params![exercise_id, date.to_string()],
39 - |row| {
40 - let date_str: String = row.get(1)?;
41 - let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
42 - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
43 - 1,
44 - rusqlite::types::Type::Text,
45 - Box::new(e),
46 - ))?;
47 - Ok(SessionSet {
48 - id: row.get(0)?,
49 - session_date: parsed,
50 - exercise_id: row.get(2)?,
51 - set_index: row.get(3)?,
52 - load: row.get(4)?,
53 - reps: row.get(5)?,
54 - rpe: row.get(6)?,
55 - })
56 - },
62 + |row| row_to_session_set(row),
57 63 )?;
58 64 Ok(rows.collect::<Result<Vec<_>, _>>()?)
59 65 }
60 66
61 - /// Append a new set to a session. `set_index` is auto-assigned as one
62 - /// past the current max for this (date, exercise). Returns the new row id.
67 + /// Append a normal (non-diagnostic) working set. `set_index` is
68 + /// auto-assigned as one past the current max for this (date, exercise).
69 + /// Returns the new row id.
63 70 pub fn append_set(
64 71 &self,
65 72 exercise_id: i64,
@@ -68,6 +75,32 @@ impl Db {
68 75 reps: i32,
69 76 rpe: i32,
70 77 ) -> Result<i64, Error> {
78 + self.append_set_inner(exercise_id, date, load, reps, rpe, false)
79 + }
80 +
81 + /// Append a diagnostic set. Diagnostic sets feed e1RM and seed future
82 + /// prescriptions but are excluded from the progression state machine
83 + /// and the history sparkline.
84 + pub fn append_diagnostic_set(
85 + &self,
86 + exercise_id: i64,
87 + date: NaiveDate,
88 + load: f64,
89 + reps: i32,
90 + rpe: i32,
91 + ) -> Result<i64, Error> {
92 + self.append_set_inner(exercise_id, date, load, reps, rpe, true)
93 + }
94 +
95 + fn append_set_inner(
96 + &self,
97 + exercise_id: i64,
98 + date: NaiveDate,
99 + load: f64,
100 + reps: i32,
101 + rpe: i32,
102 + is_diagnostic: bool,
103 + ) -> Result<i64, Error> {
71 104 if !(1..=5).contains(&rpe) {
72 105 return Err(Error::InvalidRpe(rpe));
73 106 }
@@ -83,43 +116,35 @@ impl Db {
83 116 |row| row.get(0),
84 117 )?;
85 118 self.conn().execute(
86 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
87 - VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
88 - params![date.to_string(), exercise_id, next_index, load, reps, rpe],
119 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
120 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
121 + params![
122 + date.to_string(),
123 + exercise_id,
124 + next_index,
125 + load,
126 + reps,
127 + rpe,
128 + if is_diagnostic { 1 } else { 0 },
129 + ],
89 130 )?;
90 131 Ok(self.conn().last_insert_rowid())
91 132 }
92 133
93 - /// List every session for an exercise, oldest first, each session as a
94 - /// vec of its sets in `set_index` order. Empty vec when no history.
134 + /// List every non-diagnostic session for an exercise, oldest first,
135 + /// each session as a vec of its sets in `set_index` order. Empty vec
136 + /// when no history. Diagnostic sets are filtered so the progression
137 + /// state machine only walks real working sessions.
95 138 pub fn list_sessions_for_exercise(
96 139 &self,
97 140 exercise_id: i64,
98 141 ) -> Result<Vec<Vec<SessionSet>>, Error> {
99 142 let mut stmt = self.conn().prepare(
100 - "SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
101 - FROM sets WHERE exercise_id = ?1 \
143 + "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
144 + FROM sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
102 145 ORDER BY session_date ASC, set_index ASC",
103 146 )?;
104 - let rows = stmt.query_map(params![exercise_id], |row| {
105 - let date_str: String = row.get(1)?;
106 - let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
107 - rusqlite::Error::FromSqlConversionFailure(
108 - 1,
109 - rusqlite::types::Type::Text,
110 - Box::new(e),
111 - )
112 - })?;
113 - Ok(SessionSet {
114 - id: row.get(0)?,
115 - session_date: parsed,
116 - exercise_id: row.get(2)?,
117 - set_index: row.get(3)?,
118 - load: row.get(4)?,
119 - reps: row.get(5)?,
120 - rpe: row.get(6)?,
121 - })
122 - })?;
147 + let rows = stmt.query_map(params![exercise_id], |row| row_to_session_set(row))?;
123 148
124 149 let mut sessions: Vec<Vec<SessionSet>> = Vec::new();
125 150 for row in rows {
@@ -133,13 +158,15 @@ impl Db {
133 158 }
134 159
135 160 /// One row per session for an exercise: (session_date, top_load).
136 - /// Oldest first. Empty when the exercise has no history.
161 + /// Oldest first. Empty when the exercise has no history. Diagnostic
162 + /// sets are excluded so the sparkline reflects working-set progress.
137 163 pub fn top_loads_over_time(
138 164 &self,
139 165 exercise_id: i64,
140 166 ) -> Result<Vec<(NaiveDate, f64)>, Error> {
141 167 let mut stmt = self.conn().prepare(
142 - "SELECT session_date, MAX(load) FROM sets WHERE exercise_id = ?1 \
168 + "SELECT session_date, MAX(load) FROM sets \
169 + WHERE exercise_id = ?1 AND is_diagnostic = 0 \
143 170 GROUP BY session_date ORDER BY session_date ASC",
144 171 )?;
145 172 let rows = stmt.query_map(params![exercise_id], |row| {
@@ -181,6 +208,26 @@ impl Db {
181 208 Ok(best)
182 209 }
183 210
211 + /// Top load from the most recent diagnostic on this exercise. Used by
212 + /// the Generate screen to seed a first working prescription when
213 + /// there is no non-diagnostic history yet. `None` when no diagnostic
214 + /// has been logged.
215 + pub fn diagnostic_seed(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
216 + let mut stmt = self.conn().prepare(
217 + "SELECT MAX(load) FROM sets \
218 + WHERE exercise_id = ?1 AND is_diagnostic = 1 \
219 + AND session_date = ( \
220 + SELECT MAX(session_date) FROM sets \
221 + WHERE exercise_id = ?1 AND is_diagnostic = 1 \
222 + )",
223 + )?;
224 + let out: Option<f64> = stmt
225 + .query_row(params![exercise_id], |row| row.get(0))
226 + .optional()?
227 + .flatten();
228 + Ok(out)
229 + }
230 +
184 231 /// Delete a specific set row by id. Returns `NotFound` if it did not
185 232 /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
186 233 /// gaps are fine for the state machine (which reads ordered by index).
@@ -296,6 +343,60 @@ mod tests {
296 343 }
297 344
298 345 #[test]
346 + fn diagnostic_sets_excluded_from_progression_history() {
347 + let (db, ex) = setup();
348 + let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
349 + let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
350 + db.append_diagnostic_set(ex, d1, 60.0, 5, 3).unwrap();
351 + db.append_diagnostic_set(ex, d1, 80.0, 5, 4).unwrap();
352 + db.append_set(ex, d2, 72.0, 5, 3).unwrap();
353 + let sessions = db.list_sessions_for_exercise(ex).unwrap();
354 + assert_eq!(sessions.len(), 1, "diagnostic day should not appear");
355 + assert_eq!(sessions[0][0].session_date, d2);
356 + assert!(!sessions[0][0].is_diagnostic);
357 + }
358 +
359 + #[test]
360 + fn diagnostic_sets_excluded_from_top_loads_over_time() {
361 + let (db, ex) = setup();
362 + let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
363 + let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
364 + db.append_diagnostic_set(ex, d1, 120.0, 5, 4).unwrap();
365 + db.append_set(ex, d2, 80.0, 5, 3).unwrap();
366 + let series = db.top_loads_over_time(ex).unwrap();
367 + assert_eq!(series, vec![(d2, 80.0)]);
368 + }
369 +
370 + #[test]
371 + fn diagnostic_sets_still_count_toward_e1rm() {
372 + let (db, ex) = setup();
373 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
374 + db.append_diagnostic_set(ex, date, 120.0, 3, 4).unwrap();
375 + assert!(db.best_e1rm(ex).unwrap().is_some());
376 + }
377 +
378 + #[test]
379 + fn diagnostic_seed_returns_top_of_last_diagnostic() {
380 + let (db, ex) = setup();
381 + let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
382 + let d2 = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
383 + db.append_diagnostic_set(ex, d1, 70.0, 5, 3).unwrap();
384 + db.append_diagnostic_set(ex, d1, 80.0, 5, 4).unwrap();
385 + db.append_diagnostic_set(ex, d2, 60.0, 5, 3).unwrap();
386 + db.append_diagnostic_set(ex, d2, 90.0, 5, 4).unwrap();
387 + // Most recent diagnostic (d2) had a top of 90.
388 + assert_eq!(db.diagnostic_seed(ex).unwrap(), Some(90.0));
389 + }
390 +
391 + #[test]
392 + fn diagnostic_seed_none_when_no_diagnostic_history() {
393 + let (db, ex) = setup();
394 + let date = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
395 + db.append_set(ex, date, 100.0, 5, 3).unwrap();
396 + assert_eq!(db.diagnostic_seed(ex).unwrap(), None);
397 + }
398 +
399 + #[test]
299 400 fn delete_removes_and_gap_is_fine() {
300 401 let (db, ex) = setup();
301 402 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();