Skip to main content

max / ripgrow

effort: Kind trait + RepsKind impl + dispatch enums The trait is the contract: Payload / Set / Prescription associated types plus append / list_sets_for_session / list_sessions_for_exercise / delete_last_diagnostic_on_date / evaluate / compute_prescription. Adding a new kind means impl Kind for XKind and getting compile errors until every method is filled in — that's the whole point of using a trait rather than convention. RepsKind is the first impl. This slice keeps it as a delegate over crate::sets and crate::progression rather than moving code around; follow-up commits can migrate the reps SQL and prescription math into crate::effort::reps for symmetry with the timed/distance modules that land in slice 4c/4d. The Any* enums (AnyPayload, AnySet, AnyPrescription) plus append_any / compute_prescription_any are the dispatch boundary. Payload/kind mismatches (a distance exercise handed a reps payload) fail loudly via a new EffortKindMismatch error instead of writing to the wrong table. Nothing outside the trait uses this yet; existing callers still go through Db::compute_prescription / Db::append_set directly. The migration of callers to the dispatch boundary is a separate slice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 19:04 UTC
Commit: c50200d2ae44f59240b9264bd2075a3b70501e69
Parent: ec35639
5 files changed, +504 insertions, -59 deletions
@@ -1,58 +0,0 @@
1 - //! Per-kind effort layer.
2 - //!
3 - //! Phase 4 splits the single `sets` table into one table per effort kind
4 - //! (`reps_sets`, `timed_sets`, `distance_sets`). Each kind gets its own
5 - //! typed row shape, its own SQL, and its own protocol logic; the shared
6 - //! state machine reasons purely in terms of `SessionOutcome` and doesn't
7 - //! care what the numbers represent.
8 - //!
9 - //! This slice (4a) introduces only the discriminant enum. The `Kind`
10 - //! trait, per-kind modules, and dispatch enums land in follow-up slices.
11 -
12 - use crate::error::Error;
13 -
14 - /// Discriminant carried on every exercise. Stored as text
15 - /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`,
16 - /// which decides which per-kind sets table holds that exercise's history.
17 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18 - pub enum EffortKind {
19 - Reps,
20 - Timed,
21 - Distance,
22 - }
23 -
24 - impl EffortKind {
25 - pub fn as_str(&self) -> &'static str {
26 - match self {
27 - EffortKind::Reps => "reps",
28 - EffortKind::Timed => "timed",
29 - EffortKind::Distance => "distance",
30 - }
31 - }
32 -
33 - pub fn parse(s: &str) -> Result<Self, Error> {
34 - match s {
35 - "reps" => Ok(EffortKind::Reps),
36 - "timed" => Ok(EffortKind::Timed),
37 - "distance" => Ok(EffortKind::Distance),
38 - other => Err(Error::InvalidEffortKind(other.to_string())),
39 - }
40 - }
41 - }
42 -
43 - #[cfg(test)]
44 - mod tests {
45 - use super::*;
46 -
47 - #[test]
48 - fn round_trip() {
49 - for k in [EffortKind::Reps, EffortKind::Timed, EffortKind::Distance] {
50 - assert_eq!(EffortKind::parse(k.as_str()).unwrap(), k);
51 - }
52 - }
53 -
54 - #[test]
55 - fn parse_rejects_unknown() {
56 - assert!(EffortKind::parse("velocity").is_err());
57 - }
58 - }
@@ -0,0 +1,328 @@
1 + //! Per-kind effort layer.
2 + //!
3 + //! Phase 4 splits the single `sets` table into one table per effort kind
4 + //! (`reps_sets`, `timed_sets`, `distance_sets`). Each kind gets its own
5 + //! typed row shape, its own SQL, and its own protocol logic; the shared
6 + //! state machine reasons purely in terms of `SessionOutcome` and doesn't
7 + //! care what the numbers represent.
8 + //!
9 + //! The [`Kind`] trait is the contract every kind implements. It carries
10 + //! associated types (`Payload`, `Set`, `Prescription`) plus a fixed set
11 + //! of DB, evaluation, and prescription methods, so adding a new kind is
12 + //! a compile-time-verified list of "things to implement."
13 + //!
14 + //! Callsites that need to work across kinds dispatch on
15 + //! [`EffortKind`] and pack the results into [`AnyPayload`] /
16 + //! [`AnySet`] / [`AnyPrescription`] enums. Inside a kind's module
17 + //! everything is strongly typed.
18 +
19 + use chrono::NaiveDate;
20 +
21 + use crate::db::Db;
22 + use crate::error::Error;
23 + use crate::progression::SessionOutcome;
24 + use crate::templates::Exercise;
25 + use crate::values::Rpe;
26 +
27 + pub mod reps;
28 +
29 + /// Discriminant carried on every exercise. Stored as text
30 + /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`,
31 + /// which decides which per-kind sets table holds that exercise's history.
32 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33 + pub enum EffortKind {
34 + Reps,
35 + Timed,
36 + Distance,
37 + }
38 +
39 + impl EffortKind {
40 + pub fn as_str(&self) -> &'static str {
41 + match self {
42 + EffortKind::Reps => "reps",
43 + EffortKind::Timed => "timed",
44 + EffortKind::Distance => "distance",
45 + }
46 + }
47 +
48 + pub fn parse(s: &str) -> Result<Self, Error> {
49 + match s {
50 + "reps" => Ok(EffortKind::Reps),
51 + "timed" => Ok(EffortKind::Timed),
52 + "distance" => Ok(EffortKind::Distance),
53 + other => Err(Error::InvalidEffortKind(other.to_string())),
54 + }
55 + }
56 + }
57 +
58 + /// A completed session's result from the state machine's point of view.
59 + /// Renamed here for clarity — the shared state machine takes this from
60 + /// every kind's `evaluate` method.
61 + pub type Outcome = SessionOutcome;
62 +
63 + /// Generic version of the reps-only `PrescriptionResult`. Every kind
64 + /// returns its own `P`.
65 + #[derive(Debug, Clone, PartialEq)]
66 + pub enum PrescriptionResult<P> {
67 + /// No sessions logged for this exercise yet under this kind's
68 + /// working history. UI prompts for a first-log seed.
69 + NoHistory,
70 + Prescribed(P),
71 + }
72 +
73 + /// Contract every effort kind implements. The associated types let each
74 + /// kind carry its own payload / stored-set / prescription shape without
75 + /// leaking into shared code. Callers that stay inside one kind speak
76 + /// the associated types directly; callers that mix kinds use the
77 + /// [`Any*`](AnyPayload) enums at the boundary.
78 + pub trait Kind: 'static {
79 + /// Kind-specific input for inserting a set. Load + reps for reps;
80 + /// duration for timed; distance + duration for distance. RPE and the
81 + /// diagnostic flag live outside `Payload` because they're universal.
82 + type Payload: Clone + PartialEq + Send + Sync;
83 + /// Fully-decoded row from this kind's sets table. Includes the
84 + /// payload plus shared metadata (id, session_date, exercise_id,
85 + /// set_index, is_diagnostic, rpe).
86 + type Set: Clone + PartialEq + Send + Sync;
87 + /// The prescription this kind produces from history.
88 + type Prescription: Clone + PartialEq + Send + Sync;
89 +
90 + /// Discriminant this kind maps to. Must match the `effort_kind`
91 + /// column of every exercise that routes through this impl.
92 + const DISCRIMINANT: EffortKind;
93 + /// Name of the SQL table backing this kind (`reps_sets`, etc.).
94 + const TABLE: &'static str;
95 +
96 + // ---- persistence ---------------------------------------------------
97 +
98 + /// Append a set. The `is_diagnostic` flag decides which downstream
99 + /// queries the row participates in; see the per-kind list functions
100 + /// for the filtering rules.
101 + fn append(
102 + db: &Db,
103 + exercise_id: i64,
104 + date: NaiveDate,
105 + payload: Self::Payload,
106 + rpe: Rpe,
107 + is_diagnostic: bool,
108 + ) -> Result<i64, Error>;
109 +
110 + /// Every set for `exercise_id` on `date`, in set-index order.
111 + /// Includes diagnostic sets (the log screen shows every row that
112 + /// was actually recorded).
113 + fn list_sets_for_session(
114 + db: &Db,
115 + exercise_id: i64,
116 + date: NaiveDate,
117 + ) -> Result<Vec<Self::Set>, Error>;
118 +
119 + /// Every non-diagnostic session for `exercise_id`, oldest first,
120 + /// grouped by date. Diagnostic sets are filtered so the progression
121 + /// state machine only walks real working sessions.
122 + fn list_sessions_for_exercise(
123 + db: &Db,
124 + exercise_id: i64,
125 + ) -> Result<Vec<Vec<Self::Set>>, Error>;
126 +
127 + /// Delete the most recent diagnostic set for `exercise_id` on
128 + /// `date`. No-op if there isn't one.
129 + fn delete_last_diagnostic_on_date(
130 + db: &Db,
131 + exercise_id: i64,
132 + date: NaiveDate,
133 + ) -> Result<(), Error>;
134 +
135 + // ---- state machine / prescription ---------------------------------
136 +
137 + /// Reduce a single session's sets to a [`SessionOutcome`] the shared
138 + /// state machine can process. Each kind decides what "target hit"
139 + /// means for its payload shape.
140 + fn evaluate(sets: &[Self::Set]) -> Outcome;
141 +
142 + /// Compute the next prescription from full history + exercise-level
143 + /// config (increment, etc.). Handles the diagnostic-seed fallback
144 + /// for kinds that support it.
145 + fn compute_prescription(
146 + db: &Db,
147 + exercise: &Exercise,
148 + ) -> Result<PrescriptionResult<Self::Prescription>, Error>;
149 + }
150 +
151 + // ---- runtime-typed wrappers ------------------------------------------------
152 +
153 + /// Untyped payload for insertion at a dispatch boundary. Callers that
154 + /// have parsed a payload from user input use this to hand off to the
155 + /// per-kind append via [`append_any`].
156 + #[derive(Debug, Clone, PartialEq)]
157 + pub enum AnyPayload {
158 + Reps(<reps::RepsKind as Kind>::Payload),
159 + // Timed(TimedKind::Payload) — slice 4c
160 + // Distance(DistanceKind::Payload) — slice 4d
161 + }
162 +
163 + impl AnyPayload {
164 + pub fn kind(&self) -> EffortKind {
165 + match self {
166 + AnyPayload::Reps(_) => EffortKind::Reps,
167 + }
168 + }
169 + }
170 +
171 + /// Untyped stored-set for reads that cross kinds (rare — most reads
172 + /// stay inside one kind). Useful for the log screen when we render a
173 + /// mixed-kind history view.
174 + #[derive(Debug, Clone, PartialEq)]
175 + pub enum AnySet {
176 + Reps(<reps::RepsKind as Kind>::Set),
177 + // Timed / Distance follow in slice 4c/4d
178 + }
179 +
180 + impl AnySet {
181 + pub fn kind(&self) -> EffortKind {
182 + match self {
183 + AnySet::Reps(_) => EffortKind::Reps,
184 + }
185 + }
186 + }
187 +
188 + /// Untyped prescription. Every callsite that displays a "next session"
189 + /// number ends up unpacking this to route through kind-specific
190 + /// rendering (`format_prescription`).
191 + #[derive(Debug, Clone, PartialEq)]
192 + pub enum AnyPrescription {
193 + Reps(<reps::RepsKind as Kind>::Prescription),
194 + // Timed / Distance follow in slice 4c/4d
195 + }
196 +
197 + impl AnyPrescription {
198 + pub fn kind(&self) -> EffortKind {
199 + match self {
200 + AnyPrescription::Reps(_) => EffortKind::Reps,
201 + }
202 + }
203 + }
204 +
205 + /// Dispatch: hand a parsed [`AnyPayload`] to the right kind's `append`.
206 + pub fn append_any(
207 + db: &Db,
208 + exercise: &Exercise,
209 + date: NaiveDate,
210 + payload: AnyPayload,
211 + rpe: Rpe,
212 + is_diagnostic: bool,
213 + ) -> Result<i64, Error> {
214 + if payload.kind() != exercise.effort_kind {
215 + return Err(Error::EffortKindMismatch {
216 + exercise: exercise.effort_kind.as_str(),
217 + payload: payload.kind().as_str(),
218 + });
219 + }
220 + match payload {
221 + AnyPayload::Reps(p) => {
222 + reps::RepsKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
223 + }
224 + }
225 + }
226 +
227 + /// Dispatch: compute a prescription for the exercise regardless of kind.
228 + pub fn compute_prescription_any(
229 + db: &Db,
230 + exercise: &Exercise,
231 + ) -> Result<PrescriptionResult<AnyPrescription>, Error> {
232 + match exercise.effort_kind {
233 + EffortKind::Reps => match reps::RepsKind::compute_prescription(db, exercise)? {
234 + PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
235 + PrescriptionResult::Prescribed(p) => {
236 + Ok(PrescriptionResult::Prescribed(AnyPrescription::Reps(p)))
237 + }
238 + },
239 + EffortKind::Timed | EffortKind::Distance => {
240 + // Kinds not yet implemented — treat as no history so the UI
241 + // shows "seed on first log" instead of crashing. Slice 4c/4d
242 + // wire in the real impls.
243 + Ok(PrescriptionResult::NoHistory)
244 + }
245 + }
246 + }
247 +
248 + #[cfg(test)]
249 + mod tests {
250 + use super::*;
251 + use crate::values::{Load, LoadUnit, Reps};
252 + use crate::ResistanceType;
253 +
254 + #[test]
255 + fn effort_kind_round_trip() {
256 + for k in [EffortKind::Reps, EffortKind::Timed, EffortKind::Distance] {
257 + assert_eq!(EffortKind::parse(k.as_str()).unwrap(), k);
258 + }
259 + }
260 +
261 + #[test]
262 + fn effort_kind_parse_rejects_unknown() {
263 + assert!(EffortKind::parse("velocity").is_err());
264 + }
265 +
266 + #[test]
267 + fn append_any_dispatches_to_reps_kind() {
268 + let db = Db::open_in_memory().unwrap();
269 + db.init_profile("self", LoadUnit::Kg).unwrap();
270 + db.create_exercise(
271 + "row",
272 + ResistanceType::Freeweight,
273 + LoadUnit::Kg,
274 + 2.5,
275 + &[],
276 + )
277 + .unwrap();
278 + let ex = db.list_exercises().unwrap().into_iter().next().unwrap();
279 + let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
280 + append_any(
281 + &db,
282 + &ex,
283 + date,
284 + AnyPayload::Reps(reps::RepsPayload::new(
285 + Load::new(80.0).unwrap(),
286 + Reps::new(5).unwrap(),
287 + )),
288 + Rpe::new(3).unwrap(),
289 + false,
290 + )
291 + .unwrap();
292 + let sessions = reps::RepsKind::list_sessions_for_exercise(&db, ex.id).unwrap();
293 + assert_eq!(sessions.len(), 1);
294 + assert_eq!(sessions[0][0].load.get(), 80.0);
295 + }
296 +
297 + #[test]
298 + fn append_any_rejects_wrong_kind_for_exercise() {
299 + // Nothing but Reps is wired up yet, so the mismatch case here
300 + // requires poking a mismatched Exercise. A CardioDistance
301 + // exercise's effort_kind is Distance, but we hand it a Reps
302 + // payload — the dispatcher should refuse rather than corrupt a
303 + // table.
304 + let db = Db::open_in_memory().unwrap();
305 + db.init_profile("self", LoadUnit::Kg).unwrap();
306 + db.create_exercise(
307 + "run",
308 + ResistanceType::CardioDistance,
309 + LoadUnit::Kg,
310 + 0.0,
311 + &[],
312 + )
313 + .unwrap();
314 + let ex = db.list_exercises().unwrap().into_iter().next().unwrap();
315 + let err = append_any(
316 + &db,
317 + &ex,
318 + chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
319 + AnyPayload::Reps(reps::RepsPayload::new(
320 + Load::new(80.0).unwrap(),
321 + Reps::new(5).unwrap(),
322 + )),
323 + Rpe::new(3).unwrap(),
324 + false,
325 + );
326 + assert!(matches!(err, Err(Error::EffortKindMismatch { .. })));
327 + }
328 + }
@@ -0,0 +1,164 @@
1 + //! Reps effort kind.
2 + //!
3 + //! Weight moved for a rep count at an RPE. This is the shape ripgrow
4 + //! spoke natively before phase 4; the types and DB paths here delegate
5 + //! to the pre-existing [`crate::sets`] and [`crate::progression`] modules
6 + //! rather than re-implementing them. Later slices may move the code in
7 + //! here for symmetry with `timed` and `distance`, but that's ordering
8 + //! churn, not new logic.
9 +
10 + use chrono::NaiveDate;
11 +
12 + use crate::db::Db;
13 + use crate::error::Error;
14 + use crate::progression::{self, Prescription};
15 + use crate::sets::SessionSet;
16 + use crate::templates::Exercise;
17 + use crate::values::{Load, Reps, Rpe};
18 +
19 + use super::{EffortKind, Kind, Outcome, PrescriptionResult};
20 +
21 + /// Marker type carrying the reps [`Kind`] impl. All actual state lives
22 + /// in the associated types and the DB.
23 + pub struct RepsKind;
24 +
25 + /// Kind-specific input for inserting a reps set.
26 + #[derive(Debug, Clone, Copy, PartialEq)]
27 + pub struct RepsPayload {
28 + pub load: Load,
29 + pub reps: Reps,
30 + }
31 +
32 + /// The full stored row for a reps set. Historically this type was named
33 + /// `SessionSet`; that alias lives on in [`crate::sets`] and is what the
34 + /// existing progression / diagnostic / UI code speaks.
35 + pub type RepsSet = SessionSet;
36 +
37 + /// Prescription produced by walking a reps history through the state
38 + /// machine. Alias for backward compatibility with existing code paths;
39 + /// [`Prescription`](crate::Prescription) is the canonical name today.
40 + pub type RepsPrescription = Prescription;
41 +
42 + impl Kind for RepsKind {
43 + type Payload = RepsPayload;
44 + type Set = RepsSet;
45 + type Prescription = RepsPrescription;
46 +
47 + const DISCRIMINANT: EffortKind = EffortKind::Reps;
48 + const TABLE: &'static str = "reps_sets";
49 +
50 + fn append(
51 + db: &Db,
52 + exercise_id: i64,
53 + date: NaiveDate,
54 + payload: Self::Payload,
55 + rpe: Rpe,
56 + is_diagnostic: bool,
57 + ) -> Result<i64, Error> {
58 + if is_diagnostic {
59 + db.append_diagnostic_set(exercise_id, date, payload.load, payload.reps, rpe)
60 + } else {
61 + db.append_set(exercise_id, date, payload.load, payload.reps, rpe)
62 + }
63 + }
64 +
65 + fn list_sets_for_session(
66 + db: &Db,
67 + exercise_id: i64,
68 + date: NaiveDate,
69 + ) -> Result<Vec<Self::Set>, Error> {
70 + db.list_sets_for_session(exercise_id, date)
71 + }
72 +
73 + fn list_sessions_for_exercise(
74 + db: &Db,
75 + exercise_id: i64,
76 + ) -> Result<Vec<Vec<Self::Set>>, Error> {
77 + db.list_sessions_for_exercise(exercise_id)
78 + }
79 +
80 + fn delete_last_diagnostic_on_date(
81 + db: &Db,
82 + exercise_id: i64,
83 + date: NaiveDate,
84 + ) -> Result<(), Error> {
85 + let list = db.list_sets_for_session(exercise_id, date)?;
86 + if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
87 + db.delete_set(last.id)?;
88 + }
89 + Ok(())
90 + }
91 +
92 + fn evaluate(sets: &[Self::Set]) -> Outcome {
93 + // Target reps carries over from the previous session; the state
94 + // machine's own walker handles the seeding case. This helper is
95 + // for callers that want to evaluate a single session under a
96 + // known target elsewhere; for the walk-history path we delegate
97 + // to progression::evaluate_session at a lower level.
98 + let target = sets.iter().map(|s| s.reps.get()).max().unwrap_or(0);
99 + progression::evaluate_session(sets, target)
100 + }
101 +
102 + fn compute_prescription(
103 + db: &Db,
104 + exercise: &Exercise,
105 + ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
106 + use crate::progression::PrescriptionResult as OldResult;
107 + match db.compute_prescription(exercise.id)? {
108 + OldResult::NoHistory => Ok(PrescriptionResult::NoHistory),
109 + OldResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed(p)),
110 + }
111 + }
112 + }
113 +
114 + impl RepsPayload {
115 + pub fn new(load: Load, reps: Reps) -> Self {
116 + Self { load, reps }
117 + }
118 + }
119 +
120 + #[cfg(test)]
121 + mod tests {
122 + use super::*;
123 + use crate::values::LoadUnit;
124 + use crate::ResistanceType;
125 +
126 + fn setup() -> (Db, i64) {
127 + let db = Db::open_in_memory().unwrap();
128 + db.init_profile("self", LoadUnit::Kg).unwrap();
129 + let id = db
130 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
131 + .unwrap();
132 + (db, id)
133 + }
134 +
135 + #[test]
136 + fn kind_discriminant_matches_effort_kind() {
137 + assert_eq!(RepsKind::DISCRIMINANT, EffortKind::Reps);
138 + assert_eq!(RepsKind::TABLE, "reps_sets");
139 + }
140 +
141 + #[test]
142 + fn append_via_trait_writes_to_reps_sets() {
143 + let (db, ex) = setup();
144 + let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
145 + let payload = RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap());
146 + RepsKind::append(&db, ex, date, payload, Rpe::new(3).unwrap(), false).unwrap();
147 + let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap();
148 + assert_eq!(list.len(), 1);
149 + assert_eq!(list[0].load.get(), 100.0);
150 + }
151 +
152 + #[test]
153 + fn compute_prescription_no_history_via_trait() {
154 + let (db, ex) = setup();
155 + let exercise = db
156 + .list_exercises()
157 + .unwrap()
158 + .into_iter()
159 + .find(|e| e.id == ex)
160 + .unwrap();
161 + let r = RepsKind::compute_prescription(&db, &exercise).unwrap();
162 + assert!(matches!(r, PrescriptionResult::NoHistory));
163 + }
164 + }
@@ -17,6 +17,14 @@ pub enum Error {
17 17 #[error("invalid effort kind: {0}")]
18 18 InvalidEffortKind(String),
19 19
20 + #[error(
21 + "effort kind mismatch: exercise is {exercise} but payload is {payload}"
22 + )]
23 + EffortKindMismatch {
24 + exercise: &'static str,
25 + payload: &'static str,
26 + },
27 +
20 28 #[error("tag name cannot be empty")]
21 29 InvalidTagName,
22 30
@@ -19,7 +19,10 @@ pub mod values;
19 19
20 20 pub use db::Db;
21 21 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
22 - pub use effort::EffortKind;
22 + pub use effort::{
23 + AnyPayload, AnyPrescription, AnySet, EffortKind, Kind, PrescriptionResult as AnyPrescriptionResult,
24 + append_any, compute_prescription_any, reps,
25 + };
23 26 pub use error::Error;
24 27 pub use estimator::estimate_e1rm;
25 28 pub use generation::{PickedSlot, Readiness};