refactor thin Workout, structural set-group prescriptions, recovery as elapsed time

Corrects three things surfaced while implementing Workout: 1. A workout prescription is just a plan reference and timestamps — no target, no recovery. Progressive-overload targets and recovery figures are read off the logbook, not baked into the plan. 2. Set groups need a prescribed counterpart with the same structural shape as the performed Set_group (straight / superset / pre-exhaust), since a superset pairs two exercises and neither can own the pairing alone. New module Set_group_prescription; Routine.Plan.set_groups replaces the flat Prescription.t list it had before. 3. Recovery is not a service with sessions and overrides — it is the elapsed time between two logbook entries, judged against a recommended duration. Dropped prescribe, session, override_reason, start_session*, end_session, overridden_recovery, and CLOCK entirely. What is left: timestamp, duration, elapsed, readiness, evaluate_readiness, is_ready. Gating is informational — Logbook reports it, the caller decides. Logbook.Entry now validates each logged set's exercise against the workout's plan directly (Set_group_prescription.exercises), not merely "prescribed somewhere" — catches a set logged for the wrong slot in a superset, which the previous flat-list model could not distinguish. Dropped Logbook.completed_workouts: answering it requires knowing which Routine a Plan belongs to, and Plan intentionally has no back-reference to avoid coupling the static template's parts together. Repository.S gains completed_workouts instead, since the app layer already assigns routine_id and is the right place to count against it. Design validated against an earlier attempt at this same project (~/git/my-fitness-tracker, Pharo/Smalltalk): its MFTStraightSetGroupPrescription / MFTSupersetGroupPrescription / MFTPreExhaustSupersetGroupPrescription hierarchy confirmed the structural-pairing approach, and MFTWorkoutLogBook>>durationSinceLastWorkout confirmed recovery as elapsed time between logs rather than a managed session. Authored test_set_group_prescription.ml, test_workout.ml, and test_logbook.ml (none existed for these shapes); rewrote test_recovery.ml for the pared-down module; fixed test_routine.ml for the renamed accessor. 62 tests total, all green.

Commit
e7aa57b8919ab08f5d906680d0e4f66f3125618a
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/app/memory_repo.ml
index 3f83b8eb..a0343722 100644..100644
@@ -8,3 +8,4 @@
8 8 let save _ _ = failwith "TODO"
9 9 let logbook _ = failwith "TODO"
10 10 let history _ = failwith "TODO"
11 Added: let completed_workouts _ _ = failwith "TODO"
lib/app/repository.ml
index fbdd95cd..357925cf 100644..100644
@@ -17,4 +17,5 @@
17 17 val save : t -> record -> unit
18 18 val logbook : t -> Hito_core.Logbook.t
19 19 val history : t -> record list
20 Added: val completed_workouts : t -> routine_id -> int
20 21 end
lib/app/repository.mli
index 2158d769..32908359 100644..100644
@@ -19,8 +19,12 @@
19 19 val save : t -> record -> unit
20 20
21 21 val logbook : t -> Hito_core.Logbook.t
22 Removed: (** The stored log, from which evidence and rotation position are derived. *)
22 Added: (** The stored log, from which evidence is derived. *)
23 23
24 24 val history : t -> record list
25 25 (** Most recent first. *)
26 Added:
27 Added: val completed_workouts : t -> routine_id -> int
28 Added: (** How many workouts of this routine have been logged; drives rotation via
29 Added: {!Hito_core.Routine.next_plan}. *)
26 30 end
lib/app/service.ml
index cbc8f800..ee875d7c 100644..100644
@@ -1,24 +1,16 @@
1 1 (* Minimal stubs only; implementation deferred until after the review gate. *)
2 2
3 Removed: open Hito_core
4 Removed:
5 3 module Make (R : Repository.S) = struct
6 Removed: type t = { repo : R.t; clock : (module Recovery.CLOCK) } [@@warning "-69"]
4 Added: type t = { repo : R.t } [@@warning "-69"]
7 5
8 Removed: let make ~repo ~clock = { repo; clock }
6 Added: let make ~repo = { repo }
9 7 let list_routines _ = failwith "TODO"
10 8
11 Removed: type error = Unknown_routine | Not_substitutable of Exercise.error
9 Added: type error = Unknown_routine
12 10
13 Removed: let prescribe _ ~routine:_ ~substitutions:_ = failwith "TODO"
14 Removed:
15 Removed: type start_outcome =
16 Removed: | Started of Logbook.Entry.t
17 Removed: | Not_recovered of Recovery.error
18 Removed:
19 Removed: let start _ _ = failwith "TODO"
20 Removed: let start_overriding _ _ ~acknowledged:_ = failwith "TODO"
11 Added: let prescribe _ ~routine:_ ~now:_ = failwith "TODO"
12 Added: let start _ = failwith "TODO"
21 13 let log_group _ _ = failwith "TODO"
22 Removed: let finish _ _ = failwith "TODO"
14 Added: let finish _ _ ~ended_at:_ = failwith "TODO"
23 15 let history _ = failwith "TODO"
24 16 end
lib/app/service.mli
index 7a504241..a4e50ab3 100644..100644
@@ -1,43 +1,35 @@
1 Removed: (** Application service: orchestrates the core over a {!Repository.S} and a
2 Removed: clock. The API the web (and future native) layer calls; no Eliom or
3 Removed: serialization concerns. *)
1 Added: (** Application service: orchestrates the core over a {!Repository.S}. The API
2 Added: the web (and future native) layer calls; no Eliom or serialization concerns.
3 Added: Recovery gating is informational — the caller decides whether to proceed on
4 Added: an early workout, since the core no longer gates it. *)
4 5
5 6 open Hito_core
6 7
7 8 module Make (R : Repository.S) : sig
8 9 type t
9 10
10 Removed: val make : repo:R.t -> clock:(module Recovery.CLOCK) -> t
11 Added: val make : repo:R.t -> t
11 12 val list_routines : t -> (Repository.routine_id * Routine.t) list
12 13
13 Removed: type error = Unknown_routine | Not_substitutable of Exercise.error
14 Added: type error = Unknown_routine
14 15
15 16 val prescribe :
16 17 t ->
17 18 routine:Repository.routine_id ->
18 Removed: substitutions:(Exercise.id * Exercise.t) list ->
19 Added: now:Recovery.timestamp ->
19 20 (Workout.t * Recovery.readiness, error) result
20 Removed: (** Today's workout, with targets and rotation position drawn from the stored
21 Removed: logbook, alongside current readiness so the caller can warn first. *)
21 Added: (** Today's workout — the routine's next plan by rotation — alongside current
22 Added: readiness so the caller can warn before logging begins. *)
22 23
23 Removed: (** Whether logging could begin under the recovery window. *)
24 Removed: type start_outcome =
25 Removed: | Started of Logbook.Entry.t
26 Removed: | Not_recovered of Recovery.error
27 Removed: (** Retry via {!start_overriding} if the trainee insists. *)
24 Added: val start : Workout.t -> Logbook.Entry.t
28 25
29 Removed: val start : t -> Workout.t -> start_outcome
30 Removed:
31 Removed: val start_overriding :
32 Removed: t -> Workout.t -> acknowledged:string -> Logbook.Entry.t
33 Removed: (** Begin despite an incomplete window, recording the acknowledgement. *)
34 Removed:
35 26 val log_group :
36 27 Logbook.Entry.t ->
37 28 Set_group.t ->
38 29 (Logbook.Entry.t, Logbook.Entry.error) result
39 30
40 Removed: val finish : t -> Logbook.Entry.t -> Repository.record
31 Added: val finish :
32 Added: t -> Logbook.Entry.t -> ended_at:Recovery.timestamp -> Repository.record
41 33 (** Complete, persist, and return the stored record. *)
42 34
43 35 val history : t -> Repository.record list
lib/core/logbook.ml
index 4d7b80a8..a55559aa 100644..100644
@@ -1,31 +1,96 @@
1 Removed: (* Minimal stubs only; implementation deferred until after the review gate. *)
2 Removed:
3 1 module Entry = struct
4 Removed: type t = unit
2 Added: type t = { workout : Workout.t; groups : Set_group.t list; finished : bool }
5 3 type error = Set_exercise_not_prescribed of Exercise.id | Already_finished
6 4
7 Removed: let start _ _ = failwith "TODO"
8 Removed: let add_group _ _ = failwith "TODO"
9 Removed: let finish _ _ = failwith "TODO"
10 Removed: let is_finished _ = failwith "TODO"
11 Removed: let workout _ = failwith "TODO"
12 Removed: let session _ = failwith "TODO"
13 Removed: let groups _ = failwith "TODO"
14 Removed: let working_sets _ = failwith "TODO"
15 Removed: let performances _ = failwith "TODO"
16 Removed: let guidance _ = failwith "TODO"
17 Removed: let unperformed _ = failwith "TODO"
18 Removed: let volume _ = failwith "TODO"
19 Removed: let duration _ = failwith "TODO"
20 Removed: let pp _ _ = failwith "TODO"
5 Added: let start workout = { workout; groups = []; finished = false }
6 Added:
7 Added: let prescribed_exercise_ids t =
8 Added: Routine.Plan.set_groups (Workout.plan t.workout)
9 Added: |> List.concat_map Set_group_prescription.exercises
10 Added: |> List.map Exercise.id
11 Added:
12 Added: let add_group t group =
13 Added: if t.finished then Error Already_finished
14 Added: else
15 Added: let prescribed = prescribed_exercise_ids t in
16 Added: let offending =
17 Added: Set_group.working_sets group
18 Added: |> List.map Set.Working.exercise
19 Added: |> List.find_opt (fun ex -> not (List.mem (Exercise.id ex) prescribed))
20 Added: in
21 Added: match offending with
22 Added: | Some ex -> Error (Set_exercise_not_prescribed (Exercise.id ex))
23 Added: | None -> Ok { t with groups = t.groups @ [ group ] }
24 Added:
25 Added: let finish t ~ended_at =
26 Added: { t with workout = Workout.finish t.workout ~ended_at; finished = true }
27 Added:
28 Added: let is_finished t = t.finished
29 Added: let workout t = t.workout
30 Added: let groups t = t.groups
31 Added: let working_sets t = List.concat_map Set_group.working_sets t.groups
32 Added:
33 Added: let performances t =
34 Added: List.map
35 Added: (fun s ->
36 Added: (Exercise.id (Set.Working.exercise s), Set.Working.performance s))
37 Added: (working_sets t)
38 Added:
39 Added: let prescription_for t exercise_id =
40 Added: Routine.Plan.set_groups (Workout.plan t.workout)
41 Added: |> List.concat_map Set_group_prescription.prescriptions
42 Added: |> List.find_opt (fun p ->
43 Added: Exercise.id (Prescription.exercise p) = exercise_id)
44 Added:
45 Added: let guidance t =
46 Added: List.filter_map
47 Added: (fun (id, sample) ->
48 Added: match prescription_for t id with
49 Added: | None -> None
50 Added: | Some p ->
51 Added: Some
52 Added: ( id,
53 Added: Progression.guide
54 Added: ~target_reps:(Prescription.target_reps p)
55 Added: sample ))
56 Added: (performances t)
57 Added:
58 Added: let unperformed t =
59 Added: let performed_ids = List.map fst (performances t) in
60 Added: Routine.Plan.set_groups (Workout.plan t.workout)
61 Added: |> List.concat_map Set_group_prescription.exercises
62 Added: |> List.filter (fun ex -> not (List.mem (Exercise.id ex) performed_ids))
63 Added:
64 Added: let volume t = Progression.volume (List.map snd (performances t))
65 Added:
66 Added: let pp fmt t =
67 Added: Format.fprintf fmt "%a (%d groups)" Workout.pp t.workout
68 Added: (List.length t.groups)
21 69 end
22 70
23 Removed: type t = unit
71 Added: type t = Entry.t list
24 72
25 Removed: let empty = ()
26 Removed: let add _ _ = failwith "TODO"
27 Removed: let entries _ = failwith "TODO"
28 Removed: let evidence _ _ = failwith "TODO"
29 Removed: let completed_workouts _ ~routine:_ = failwith "TODO"
30 Removed: let last_workout_at _ = failwith "TODO"
31 Removed: let pp _ _ = failwith "TODO"
73 Added: let empty = []
74 Added: let add t entry = entry :: t
75 Added: let entries t = t
76 Added:
77 Added: let evidence t exercise_id =
78 Added: List.concat_map
79 Added: (fun entry ->
80 Added: Entry.performances entry
81 Added: |> List.filter_map (fun (id, sample) ->
82 Added: if id = exercise_id then Some sample else None))
83 Added: (List.rev t)
84 Added:
85 Added: let readiness t ~now ~recommended =
86 Added: match t with
87 Added: | [] -> Recovery.Ready
88 Added: | last :: _ -> (
89 Added: match Workout.ended_at (Entry.workout last) with
90 Added: | None -> Recovery.Ready
91 Added: | Some since ->
92 Added: Recovery.evaluate_readiness
93 Added: ~elapsed:(Recovery.elapsed ~since ~now)
94 Added: ~recommended)
95 Added:
96 Added: let pp fmt t = Format.fprintf fmt "%d entries" (List.length t)
lib/core/logbook.mli
index e154b1fe..706e79b3 100644..100644
@@ -1,26 +1,29 @@
1 1 (** The training log: what was actually performed.
2 2
3 Removed: Each entry records the {!Hito_core.Workout.t} it was performed against — a
4 Removed: log without its prescription is meaningless. The logbook is also the source
5 Removed: of the evidence future prescriptions are calculated from. *)
3 Added: Each entry wraps the {!Hito_core.Workout.t} it was performed against — a log
4 Added: without its plan is meaningless. The logbook is also the source of the
5 Added: evidence future prescriptions are calculated from, and of the elapsed time
6 Added: {!Hito_core.Recovery} judges. *)
6 7
7 8 (** One performed workout. *)
8 9 module Entry : sig
9 10 type t
10 Removed: type error = Set_exercise_not_prescribed of Exercise.id | Already_finished
11 11
12 Removed: val start : Workout.t -> Recovery.session -> t
12 Added: type error =
13 Added: | Set_exercise_not_prescribed of Exercise.id
14 Added: (** A set's exercise matches no slot in the workout's plan. *)
15 Added: | Already_finished
13 16
17 Added: val start : Workout.t -> t
18 Added:
14 19 val add_group : t -> Set_group.t -> (t, error) result
15 Removed: (** Append a performed group. Each working set's exercise is validated
16 Removed: individually, since a group may span exercises. *)
20 Added: (** Append a performed group. Each working set's exercise must match a slot in
21 Added: the plan's corresponding set group; a group may span exercises, so each
22 Added: set is checked individually. *)
17 23
18 Removed: val finish : (module Recovery.CLOCK) -> t -> t
19 Removed: (** Complete the entry and stamp the session's end. *)
20 Removed:
24 Added: val finish : t -> ended_at:Recovery.timestamp -> t
21 25 val is_finished : t -> bool
22 26 val workout : t -> Workout.t
23 Removed: val session : t -> Recovery.session
24 27 val groups : t -> Set_group.t list
25 28
26 29 val working_sets : t -> Set.Working.t list
@@ -39,9 +42,6 @@
39 42 val volume : t -> float
40 43 (** Σ (load × reps). Diagnostic, not a target. *)
41 44
42 Removed: val duration : t -> Recovery.duration option
43 Removed: (** Wall-clock time, once finished. Heavy Duty prescribes brief workouts. *)
44 Removed:
45 45 val pp : Format.formatter -> t -> unit
46 46 end
47 47
@@ -58,11 +58,13 @@
58 58 (** An exercise's past performances, oldest-first — the input to
59 59 {!Hito_core.Progression.prescribe}. *)
60 60
61 Removed: val completed_workouts : t -> routine:string -> int
62 Removed: (** How many workouts of a named routine have been logged; drives rotation via
63 Removed: {!Hito_core.Routine.next_plan}. *)
64 Removed:
65 Removed: val last_workout_at : t -> Recovery.timestamp option
66 Removed: (** When the most recent entry finished; feeds recovery gating. *)
61 Added: val readiness :
62 Added: t ->
63 Added: now:Recovery.timestamp ->
64 Added: recommended:Recovery.duration ->
65 Added: Recovery.readiness
66 Added: (** Elapsed time since the most recent finished entry, judged against
67 Added: [recommended]. Informational: nothing in the logbook prevents starting the
68 Added: next workout regardless. *)
67 69
68 70 val pp : Format.formatter -> t -> unit
lib/core/recovery.ml
index 770832b2..7322f580 100644..100644
@@ -8,81 +8,14 @@
8 8 let hours n = n * 3600
9 9 let days n = n * 86400
10 10 let duration_to_seconds d = d
11 Added: let elapsed ~since ~now = now - since
11 12
12 Removed: (* A stall lengthens the window rather than shortening it: fuller recovery,
13 Removed: not more work. Progressing keeps the nominal base. *)
14 Removed: let prescribe ~base ~evidence =
15 Removed: let status =
16 Removed: match Progression.evaluate ~history:evidence with
17 Removed: | Ok s -> s
18 Removed: | Error Progression.Insufficient_data -> Progression.Progressing
19 Removed: in
20 Removed: let value =
21 Removed: match status with Progression.Progressing -> base | Stalled -> base * 2
22 Removed: in
23 Removed: { Progression.value; evidence; status }
24 Removed:
25 Removed: module type CLOCK = sig
26 Removed: val now : unit -> timestamp
27 Removed: end
28 Removed:
29 13 type readiness =
30 14 | Ready
31 15 | Recovering of { rested : duration; recommended : duration }
32 16
33 Removed: let evaluate_readiness ~now ~last_workout ~recommended =
34 Removed: let rested = now - last_workout in
35 Removed: if rested >= recommended then Ready else Recovering { rested; recommended }
17 Added: let evaluate_readiness ~elapsed ~recommended =
18 Added: if elapsed >= recommended then Ready
19 Added: else Recovering { rested = elapsed; recommended }
36 20
37 21 let is_ready = function Ready -> true | Recovering _ -> false
38 Removed:
39 Removed: type override_reason = { note : string; readiness_at_start : readiness }
40 Removed:
41 Removed: type session = {
42 Removed: started_at : timestamp;
43 Removed: ended_at : timestamp option;
44 Removed: overridden_recovery : override_reason option;
45 Removed: }
46 Removed:
47 Removed: type error = Not_recovered of { readiness : readiness }
48 Removed:
49 Removed: let readiness_now (module Clock : CLOCK) ~last_workout ~recommended =
50 Removed: match last_workout with
51 Removed: | None -> Ready
52 Removed: | Some last ->
53 Removed: evaluate_readiness ~now:(Clock.now ()) ~last_workout:last ~recommended
54 Removed:
55 Removed: let start_session clock ~last_workout ~recommended =
56 Removed: let readiness = readiness_now clock ~last_workout ~recommended in
57 Removed: if is_ready readiness then
58 Removed: let (module Clock) = clock in
59 Removed: Ok
60 Removed: { started_at = Clock.now (); ended_at = None; overridden_recovery = None }
61 Removed: else Error (Not_recovered { readiness })
62 Removed:
63 Removed: let start_session_overriding_recovery clock ~last_workout ~recommended
64 Removed: ~acknowledged =
65 Removed: let readiness_at_start = readiness_now clock ~last_workout ~recommended in
66 Removed: let (module Clock) = clock in
67 Removed: {
68 Removed: started_at = Clock.now ();
69 Removed: ended_at = None;
70 Removed: overridden_recovery = Some { note = acknowledged; readiness_at_start };
71 Removed: }
72 Removed:
73 Removed: let end_session (module Clock : CLOCK) session =
74 Removed: { session with ended_at = Some (Clock.now ()) }
75 Removed:
76 Removed: let started_at session = session.started_at
77 Removed: let ended_at session = session.ended_at
78 Removed:
79 Removed: let session_duration session =
80 Removed: Option.map (fun ended -> ended - session.started_at) session.ended_at
81 Removed:
82 Removed: let overridden_recovery session = session.overridden_recovery
83 Removed:
84 Removed: let pp_session fmt session =
85 Removed: match session.ended_at with
86 Removed: | None -> Format.fprintf fmt "started at %d" session.started_at
87 Removed: | Some ended ->
88 Removed: Format.fprintf fmt "started at %d, ended at %d" session.started_at ended
lib/core/recovery.mli
index 33ffb903..501bd49f 100644..100644
@@ -1,12 +1,8 @@
1 Removed: (** Recovery: systemic and time-based — training is a whole-body stressor, so a
2 Removed: workout gates the next one regardless of what it targeted.
1 Added: (** Recovery is read off the logbook, not managed here: the elapsed time between
2 Added: two workouts, judged against a recommended window. There is no session,
3 Added: override, or evidence-based prescription — {!Hito_core.Logbook} owns that
4 Added: context and decides what to do with a {!readiness} reading. *)
3 5
4 Removed: Gating is advisory, not inviolable: {!start_session} refuses when the window
5 Removed: has not elapsed, and only {!start_session_overriding_recovery} proceeds,
6 Removed: recording the override. Time is injected via {!CLOCK}. *)
7 Removed:
8 Removed: (** {1 Time} *)
9 Removed:
10 6 type timestamp = private int
11 7
12 8 val timestamp_of_unix_seconds : int -> timestamp
@@ -17,66 +13,12 @@
17 13 val hours : int -> duration
18 14 val days : int -> duration
19 15 val duration_to_seconds : duration -> int
16 Added: val elapsed : since:timestamp -> now:timestamp -> duration
20 17
21 Removed: module type CLOCK = sig
22 Removed: val now : unit -> timestamp
23 Removed: end
24 Removed:
25 Removed: (** {1 Prescribed recovery}
26 Removed:
27 Removed: Like exercise prescription, the rest window is derived from logged evidence
28 Removed: and carries the justification for the figure. *)
29 Removed:
30 Removed: val prescribe :
31 Removed: base:duration ->
32 Removed: evidence:Progression.sample list ->
33 Removed: duration Progression.prescribed
34 Removed: (** [base] is the routine's nominal window; a stall lengthens it — fuller
35 Removed: recovery rather than more work. *)
36 Removed:
37 Removed: (** {1 Readiness} *)
38 Removed:
39 18 type readiness =
40 19 | Ready
41 20 | Recovering of { rested : duration; recommended : duration }
42 21 (** [rested] of [recommended] has passed. *)
43 22
44 Removed: val evaluate_readiness :
45 Removed: now:timestamp -> last_workout:timestamp -> recommended:duration -> readiness
46 Removed:
23 Added: val evaluate_readiness : elapsed:duration -> recommended:duration -> readiness
47 24 val is_ready : readiness -> bool
48 Removed:
49 Removed: (** {1 Sessions and gating} *)
50 Removed:
51 Removed: type override_reason = { note : string; readiness_at_start : readiness }
52 Removed: type session
53 Removed: type error = Not_recovered of { readiness : readiness }
54 Removed:
55 Removed: val start_session :
56 Removed: (module CLOCK) ->
57 Removed: last_workout:timestamp option ->
58 Removed: recommended:duration ->
59 Removed: (session, error) result
60 Removed: (** [last_workout] is [None] for the first workout. *)
61 Removed:
62 Removed: val start_session_overriding_recovery :
63 Removed: (module CLOCK) ->
64 Removed: last_workout:timestamp option ->
65 Removed: recommended:duration ->
66 Removed: acknowledged:string ->
67 Removed: session
68 Removed: (** Always succeeds; records an {!override_reason}. *)
69 Removed:
70 Removed: val end_session : (module CLOCK) -> session -> session
71 Removed: (** Stamp the session's end, fixing its {!elapsed} duration. *)
72 Removed:
73 Removed: val started_at : session -> timestamp
74 Removed: val ended_at : session -> timestamp option
75 Removed:
76 Removed: val session_duration : session -> duration option
77 Removed: (** Wall-clock length of the workout, once ended. *)
78 Removed:
79 Removed: val overridden_recovery : session -> override_reason option
80 Removed: (** [Some _] iff started early. *)
81 Removed:
82 Removed: val pp_session : Format.formatter -> session -> unit
lib/core/routine.ml
index 5338df78..9b5a01ce 100644..100644
@@ -1,21 +1,21 @@
1 1 module Plan = struct
2 Removed: type t = { name : string; prescriptions : Prescription.t list }
2 Added: type t = { name : string; set_groups : Set_group_prescription.t list }
3 3 type error = Empty_plan
4 4
5 Removed: let make ~name ~prescriptions =
6 Removed: match prescriptions with
5 Added: let make ~name ~set_groups =
6 Added: match set_groups with
7 7 | [] -> Error Empty_plan
8 Removed: | _ -> Ok { name; prescriptions }
8 Added: | _ -> Ok { name; set_groups }
9 9
10 10 let name t = t.name
11 Removed: let prescriptions t = t.prescriptions
11 Added: let set_groups t = t.set_groups
12 12
13 13 let pp fmt t =
14 14 Format.fprintf fmt "%s: %a" t.name
15 15 (Format.pp_print_list
16 16 ~pp_sep:(fun fmt () -> Format.fprintf fmt ", ")
17 Removed: Prescription.pp)
18 Removed: t.prescriptions
17 Added: Set_group_prescription.pp)
18 Added: t.set_groups
19 19 end
20 20
21 21 type t = {
@@ -68,8 +68,26 @@
68 68 ~target_reps:(band lo hi)
69 69 ~allowed_substitutes:(List.map by_name substitutes))
70 70
71 Removed: let plan name prescriptions = ok (Plan.make ~name ~prescriptions)
71 Added: let straight ?substitutes exercise_name lo hi =
72 Added: Set_group_prescription.Straight (prescribe ?substitutes exercise_name lo hi)
72 73
74 Added: let superset (first_name, flo, fhi, fsub) (second_name, slo, shi, ssub) =
75 Added: Set_group_prescription.Superset
76 Added: {
77 Added: first = prescribe ?substitutes:fsub first_name flo fhi;
78 Added: second = prescribe ?substitutes:ssub second_name slo shi;
79 Added: }
80 Added:
81 Added: let pre_exhaust (isolation_name, ilo, ihi, isub) (compound_name, clo, chi, csub)
82 Added: =
83 Added: Set_group_prescription.Pre_exhaust
84 Added: {
85 Added: isolation = prescribe ?substitutes:isub isolation_name ilo ihi;
86 Added: compound = prescribe ?substitutes:csub compound_name clo chi;
87 Added: }
88 Added:
89 Added: let plan name set_groups = ok (Plan.make ~name ~set_groups)
90 Added:
73 91 (* Mentzer's Ideal Routine: a three-way split built around pre-exhaustion
74 92 supersets — an isolation movement immediately preceding the compound for
75 93 the same target, so the larger muscle is the limiting factor. *)
@@ -77,31 +95,30 @@
77 95 let workout_a =
78 96 plan "Workout A: Chest, Shoulders, Triceps"
79 97 [
80 Removed: prescribe "Chest Flye" 6 8;
81 Removed: prescribe "Barbell Bench Press" 6 8
82 Removed: ~substitutes:[ "Dumbbell Bench Press" ];
83 Removed: prescribe "Lateral Raise" 6 8;
84 Removed: prescribe "Overhead Press" 6 8
85 Removed: ~substitutes:[ "Dumbbell Shoulder Press" ];
86 Removed: prescribe "Triceps Pushdown" 6 8 ~substitutes:[ "Skullcrusher" ];
98 Added: pre_exhaust ("Chest Flye", 6, 8, None)
99 Added: ("Barbell Bench Press", 6, 8, Some [ "Dumbbell Bench Press" ]);
100 Added: straight "Lateral Raise" 6 8;
101 Added: straight ~substitutes:[ "Dumbbell Shoulder Press" ] "Overhead Press" 6 8;
102 Added: straight ~substitutes:[ "Skullcrusher" ] "Triceps Pushdown" 6 8;
87 103 ]
88 104 in
89 105 let workout_b =
90 106 plan "Workout B: Back, Biceps"
91 107 [
92 Removed: prescribe "Pulldown" 6 8 ~substitutes:[ "Pull-Up" ];
93 Removed: prescribe "Barbell Row" 6 8
94 Removed: ~substitutes:[ "Dumbbell Row"; "Seated Cable Row" ];
95 Removed: prescribe "Barbell Curl" 6 8 ~substitutes:[ "Dumbbell Curl" ];
108 Added: superset
109 Added: ("Pulldown", 6, 8, Some [ "Pull-Up" ])
110 Added: ("Barbell Row", 6, 8, Some [ "Dumbbell Row"; "Seated Cable Row" ]);
111 Added: straight ~substitutes:[ "Dumbbell Curl" ] "Barbell Curl" 6 8;
96 112 ]
97 113 in
98 114 let workout_c =
99 115 plan "Workout C: Legs"
100 116 [
101 Removed: prescribe "Leg Extension" 6 8;
102 Removed: prescribe "Back Squat" 6 8 ~substitutes:[ "Leg Press" ];
103 Removed: prescribe "Leg Curl" 6 8;
104 Removed: prescribe "Calf Raise" 6 8;
117 Added: pre_exhaust
118 Added: ("Leg Extension", 6, 8, None)
119 Added: ("Back Squat", 6, 8, Some [ "Leg Press" ]);
120 Added: straight "Leg Curl" 6 8;
121 Added: straight "Calf Raise" 6 8;
105 122 ]
106 123 in
107 124 {
@@ -116,16 +133,16 @@
116 133 let workout_a =
117 134 plan "Workout A"
118 135 [
119 Removed: prescribe "Back Squat" 6 8 ~substitutes:[ "Leg Press" ];
120 Removed: prescribe "Pulldown" 6 8;
136 Added: straight ~substitutes:[ "Leg Press" ] "Back Squat" 6 8;
137 Added: straight "Pulldown" 6 8;
121 138 ]
122 139 in
123 140 let workout_b =
124 141 plan "Workout B"
125 142 [
126 Removed: prescribe "Barbell Bench Press" 6 8
127 Removed: ~substitutes:[ "Dumbbell Bench Press" ];
128 Removed: prescribe "Deadlift" 6 8;
143 Added: straight ~substitutes:[ "Dumbbell Bench Press" ] "Barbell Bench Press" 6
144 Added: 8;
145 Added: straight "Deadlift" 6 8;
129 146 ]
130 147 in
131 148 {
lib/core/routine.mli
index bb2d0ea3..badc92df 100644..100644
@@ -9,11 +9,11 @@
9 9 type error = Empty_plan
10 10
11 11 val make :
12 Removed: name:string -> prescriptions:Prescription.t list -> (t, error) result
12 Added: name:string -> set_groups:Set_group_prescription.t list -> (t, error) result
13 13
14 14 val name : t -> string
15 15
16 Removed: val prescriptions : t -> Prescription.t list
16 Added: val set_groups : t -> Set_group_prescription.t list
17 17 (** In order; sequence matters for pre-exhaust pairings. *)
18 18
19 19 val pp : Format.formatter -> t -> unit
@@ -32,8 +32,7 @@
32 32 (** The workout due next, cycling through {!plans}. *)
33 33
34 34 val recovery_base : t -> Recovery.duration
35 Removed: (** Nominal rest between workouts; Consolidation rests longer than Ideal.
36 Removed: Evidence adjusts it (see {!Hito_core.Recovery.prescribe}). *)
35 Added: (** Nominal rest between workouts; Consolidation rests longer than Ideal. *)
37 36
38 37 val pp : Format.formatter -> t -> unit
39 38
lib/core/set_group_prescription.ml
index 00000000..256293ee 000000..100644
@@ -0,0 +1,16 @@
1 Added: type t =
2 Added: | Straight of Prescription.t
3 Added: | Superset of { first : Prescription.t; second : Prescription.t }
4 Added: | Pre_exhaust of { isolation : Prescription.t; compound : Prescription.t }
5 Added:
6 Added: let prescriptions = function
7 Added: | Straight p -> [ p ]
8 Added: | Superset { first; second } -> [ first; second ]
9 Added: | Pre_exhaust { isolation; compound } -> [ isolation; compound ]
10 Added:
11 Added: let exercises t = List.map Prescription.exercise (prescriptions t)
12 Added:
13 Added: let pp fmt t =
14 Added: Format.pp_print_list
15 Added: ~pp_sep:(fun fmt () -> Format.fprintf fmt " + ")
16 Added: Prescription.pp fmt (prescriptions t)
lib/core/set_group_prescription.mli
index 00000000..cf263cd2 000000..100644
@@ -0,0 +1,16 @@
1 Added: (** The prescribed counterpart to {!Hito_core.Set_group}: which exercises, in
2 Added: which structure, with no performance data. A routine's
3 Added: {!Hito_core.Routine.Plan} is built from these. *)
4 Added:
5 Added: type t =
6 Added: | Straight of Prescription.t
7 Added: | Superset of { first : Prescription.t; second : Prescription.t }
8 Added: | Pre_exhaust of { isolation : Prescription.t; compound : Prescription.t }
9 Added:
10 Added: val prescriptions : t -> Prescription.t list
11 Added: (** Every slot's prescription, in order. *)
12 Added:
13 Added: val exercises : t -> Exercise.t list
14 Added: (** The prescribed movements, in order. *)
15 Added:
16 Added: val pp : Format.formatter -> t -> unit
lib/core/workout.ml
index ec7c7eef..1c4b8393 100644..100644
@@ -1,21 +1,20 @@
1 Removed: (* Minimal stubs only; implementation deferred until after the review gate. *)
2 Removed:
3 Removed: type item = {
4 Removed: prescription : Prescription.t;
5 Removed: exercise : Exercise.t;
6 Removed: target : Progression.target Progression.prescribed option;
1 Added: type t = {
2 Added: plan : Routine.Plan.t;
3 Added: started_at : Recovery.timestamp;
4 Added: ended_at : Recovery.timestamp option;
7 5 }
8 Removed: [@@warning "-69"]
9 6
10 Removed: type t = unit
11 Removed: type error = Not_substitutable of Exercise.error
7 Added: let create ~plan ~started_at = { plan; started_at; ended_at = None }
8 Added: let finish t ~ended_at = { t with ended_at = Some ended_at }
9 Added: let plan t = t.plan
10 Added: let started_at t = t.started_at
11 Added: let ended_at t = t.ended_at
12 12
13 Removed: let create ~routine:_ ~completed_workouts:_ ~substitutions:_ ~evidence:_ =
14 Removed: failwith "TODO"
13 Added: let duration t =
14 Added: Option.map
15 Added: (fun ended -> Recovery.elapsed ~since:t.started_at ~now:ended)
16 Added: t.ended_at
15 17
16 Removed: let routine _ = failwith "TODO"
17 Removed: let plan _ = failwith "TODO"
18 Removed: let items _ = failwith "TODO"
19 Removed: let prescribes _ _ = failwith "TODO"
20 Removed: let recovery _ = failwith "TODO"
21 Removed: let pp _ _ = failwith "TODO"
18 Added: let pp fmt t =
19 Added: Format.fprintf fmt "%a starting at %d" Routine.Plan.pp t.plan
20 Added: (Recovery.timestamp_to_unix_seconds t.started_at)
lib/core/workout.mli
index fe7ba091..94442f12 100644..100644
@@ -1,39 +1,17 @@
1 Removed: (** Today's workout: the routine's next plan made concrete, with a target per
2 Removed: exercise derived from past evidence.
1 Added: (** A workout in progress or finished: a routine's plan, anchored to when it
2 Added: started and (once finished) ended. No targets, no recovery — those are
3 Added: {!Hito_core.Progression} and {!Hito_core.Recovery}'s concerns, applied by
4 Added: whoever reads the {!Hito_core.Logbook}. *)
3 5
4 Removed: Still a plan, not a record — performing it produces a
5 Removed: {!Hito_core.Logbook.Entry}. Targets come from evidence rather than log
6 Removed: entries, which keeps this module independent of the logbook. *)
7 Removed:
8 Removed: type item = {
9 Removed: prescription : Prescription.t;
10 Removed: exercise : Exercise.t;
11 Removed: (** The prescribed movement, or a chosen substitute. *)
12 Removed: target : Progression.target Progression.prescribed option;
13 Removed: (** [None] with no prior evidence. *)
14 Removed: }
15 Removed:
16 6 type t
17 Removed: type error = Not_substitutable of Exercise.error
18 7
19 Removed: val create :
20 Removed: routine:Routine.t ->
21 Removed: completed_workouts:int ->
22 Removed: substitutions:(Exercise.id * Exercise.t) list ->
23 Removed: evidence:(Exercise.id -> Progression.sample list) ->
24 Removed: (t, error) result
25 Removed: (** Instantiate the routine's next workout, selected by rotation from
26 Removed: [completed_workouts]. [evidence] yields an exercise's past performances,
27 Removed: from which its target is calculated. *)
8 Added: val create : plan:Routine.Plan.t -> started_at:Recovery.timestamp -> t
28 9
29 Removed: val routine : t -> Routine.t
30 Removed: val plan : t -> Routine.Plan.t
31 Removed: val items : t -> item list
10 Added: val finish : t -> ended_at:Recovery.timestamp -> t
11 Added: (** Fixes the workout's end. Calling this again replaces the previous end. *)
32 12
33 Removed: val prescribes : t -> Exercise.id -> bool
34 Removed: (** Whether [id] is the movement of one of this workout's items. *)
35 Removed:
36 Removed: val recovery : t -> Recovery.duration Progression.prescribed
37 Removed: (** The rest window prescribed before the next workout. *)
38 Removed:
13 Added: val plan : t -> Routine.Plan.t
14 Added: val started_at : t -> Recovery.timestamp
15 Added: val ended_at : t -> Recovery.timestamp option
16 Added: val duration : t -> Recovery.duration option
39 17 val pp : Format.formatter -> t -> unit
lib/web/pages.ml
index d46010f5..93d191c1 100644..100644
@@ -3,4 +3,4 @@
3 3 let choose_routine ~routines:_ = failwith "TODO"
4 4 let log_workout ~entry:_ = failwith "TODO"
5 5 let history ~records:_ = failwith "TODO"
6 Removed: let recovery_warning ~error:_ = failwith "TODO"
6 Added: let recovery_notice ~readiness:_ = failwith "TODO"
lib/web/pages.mli
index 93cc7dd5..9c925e40 100644..100644
@@ -14,5 +14,8 @@
14 14 val history :
15 15 records:Repository.record list -> Html_types.html Eliom_content.Html.elt
16 16
17 Removed: val recovery_warning :
18 Removed: error:Hito_core.Recovery.error -> Html_types.html Eliom_content.Html.elt
17 Added: val recovery_notice :
18 Added: readiness:Hito_core.Recovery.readiness ->
19 Added: Html_types.html Eliom_content.Html.elt
20 Added: (** Informational: shown alongside {!log_workout} when the trainee has not fully
21 Added: rested. Never blocks logging. *)
test/test_hito.ml
index 918e0ab2..7e81dcb2 100644..100644
@@ -4,5 +4,7 @@
4 4 let () =
5 5 Alcotest.run "hito"
6 6 (Test_units.suite @ Test_exercise.suite @ Test_set.suite
7 Removed: @ Test_set_group.suite @ Test_prescription.suite @ Test_routine.suite
8 Removed: @ Test_progression.suite @ Test_recovery.suite)
7 Added: @ Test_set_group.suite @ Test_prescription.suite
8 Added: @ Test_set_group_prescription.suite @ Test_routine.suite
9 Added: @ Test_progression.suite @ Test_recovery.suite @ Test_workout.suite
10 Added: @ Test_logbook.suite)
test/test_logbook.ml
index 00000000..3b43afb4 000000..100644
@@ -0,0 +1,116 @@
1 Added: (** Unit tests for {!Hito_core.Logbook}, authored against logbook.mli.
2 Added:
3 Added: Under test: an entry accepts sets matching its plan's prescriptions and
4 Added: rejects others; the logbook derives per-exercise evidence and an
5 Added: informational readiness reading from finished entries. *)
6 Added:
7 Added: open Hito_core
8 Added:
9 Added: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
10 Added: let ts = Recovery.timestamp_of_unix_seconds
11 Added: let mk_w kg = ok (Units.Weight.of_kg kg)
12 Added: let mk_r n = ok (Units.Reps.of_int n)
13 Added: let plan = List.hd (Routine.plans Routine.consolidation_routine)
14 Added: (* Consolidation Workout A: Back Squat (straight), Pulldown (straight). *)
15 Added:
16 Added: let prescribed_exercises =
17 Added: Routine.Plan.set_groups plan
18 Added: |> List.concat_map Set_group_prescription.exercises
19 Added:
20 Added: let squat = List.hd prescribed_exercises
21 Added:
22 Added: let unprescribed =
23 Added: List.find
24 Added: (fun ex -> not (List.exists (Exercise.equal ex) prescribed_exercises))
25 Added: Exercise.catalog
26 Added:
27 Added: let working exercise load reps =
28 Added: Set.Working.make ~exercise ~load:(mk_w load) ~reps:(mk_r reps)
29 Added: ~outcome:Set.Working.Positive_failure
30 Added:
31 Added: let entry_tests =
32 Added: [
33 Added: ( "a set for a prescribed exercise is accepted",
34 Added: `Quick,
35 Added: fun () ->
36 Added: let e = Logbook.Entry.start (Workout.create ~plan ~started_at:(ts 0)) in
37 Added: let group = Set_group.straight ~working:(working squat 100.0 6) () in
38 Added: Alcotest.(check bool)
39 Added: "accepted" true
40 Added: (Result.is_ok (Logbook.Entry.add_group e group)) );
41 Added: ( "a set for an unprescribed exercise is rejected",
42 Added: `Quick,
43 Added: fun () ->
44 Added: let e = Logbook.Entry.start (Workout.create ~plan ~started_at:(ts 0)) in
45 Added: let group =
46 Added: Set_group.straight ~working:(working unprescribed 20.0 12) ()
47 Added: in
48 Added: Alcotest.(check bool)
49 Added: "rejected" true
50 Added: (Result.is_error (Logbook.Entry.add_group e group)) );
51 Added: ( "a finished entry rejects further groups",
52 Added: `Quick,
53 Added: fun () ->
54 Added: let e = Logbook.Entry.start (Workout.create ~plan ~started_at:(ts 0)) in
55 Added: let e = Logbook.Entry.finish e ~ended_at:(ts 100) in
56 Added: let group = Set_group.straight ~working:(working squat 100.0 6) () in
57 Added: Alcotest.(check bool)
58 Added: "rejected" true
59 Added: (Result.is_error (Logbook.Entry.add_group e group)) );
60 Added: ( "unperformed lists prescribed exercises with no logged set",
61 Added: `Quick,
62 Added: fun () ->
63 Added: let e = Logbook.Entry.start (Workout.create ~plan ~started_at:(ts 0)) in
64 Added: Alcotest.(check int)
65 Added: "both unperformed" 2
66 Added: (List.length (Logbook.Entry.unperformed e)) );
67 Added: ( "logging one exercise leaves the other unperformed",
68 Added: `Quick,
69 Added: fun () ->
70 Added: let e = Logbook.Entry.start (Workout.create ~plan ~started_at:(ts 0)) in
71 Added: let group = Set_group.straight ~working:(working squat 100.0 6) () in
72 Added: let e = ok (Logbook.Entry.add_group e group) in
73 Added: Alcotest.(check int)
74 Added: "one left" 1
75 Added: (List.length (Logbook.Entry.unperformed e)) );
76 Added: ]
77 Added:
78 Added: let logbook_tests =
79 Added: [
80 Added: ( "evidence is empty for an exercise never logged",
81 Added: `Quick,
82 Added: fun () ->
83 Added: Alcotest.(check int)
84 Added: "empty" 0
85 Added: (List.length (Logbook.evidence Logbook.empty (Exercise.id squat))) );
86 Added: ( "evidence accumulates oldest-first across entries",
87 Added: `Quick,
88 Added: fun () ->
89 Added: let make_entry started load =
90 Added: let e =
91 Added: Logbook.Entry.start (Workout.create ~plan ~started_at:(ts started))
92 Added: in
93 Added: let group = Set_group.straight ~working:(working squat load 6) () in
94 Added: Logbook.Entry.finish
95 Added: (ok (Logbook.Entry.add_group e group))
96 Added: ~ended_at:(ts (started + 10))
97 Added: in
98 Added: let book =
99 Added: Logbook.(add (add empty (make_entry 0 80.0)) (make_entry 100 82.5))
100 Added: in
101 Added: let ev = Logbook.evidence book (Exercise.id squat) in
102 Added: Alcotest.(check int) "two samples" 2 (List.length ev);
103 Added: Alcotest.(check (float 0.0001))
104 Added: "oldest first" 80.0
105 Added: (Units.Weight.to_kg (List.hd ev).Set.Working.load) );
106 Added: ( "an empty logbook is always Ready",
107 Added: `Quick,
108 Added: fun () ->
109 Added: let r =
110 Added: Logbook.readiness Logbook.empty ~now:(ts 1000)
111 Added: ~recommended:(Recovery.days 4)
112 Added: in
113 Added: Alcotest.(check bool) "ready" true (Recovery.is_ready r) );
114 Added: ]
115 Added:
116 Added: let suite = [ ("logbook.entry", entry_tests); ("logbook", logbook_tests) ]
test/test_recovery.ml
index 1215a469..3f473ec2 100644..100644
@@ -1,142 +1,43 @@
1 1 (** Unit tests for {!Hito_core.Recovery}, authored against recovery.mli.
2 2
3 Removed: NOTE: Not yet registered in the main runner; wired in during implementation
4 Removed: (Task 8). recovery.mli is the source of truth over these assertions.
3 Added: Recovery is now just time primitives plus a readiness judgment; sessions,
4 Added: overrides, and evidence-based prescription moved to {!Hito_core.Logbook}
5 Added: (informational gating) and were dropped from this module entirely. *)
5 6
6 Removed: Under test: time-based (systemic) readiness via an injected fake clock, that
7 Removed: an unrecovered state blocks the ordinary start, and that the override path
8 Removed: always succeeds while recording the early-training fact. *)
9 Removed:
10 7 open Hito_core
11 8
12 Removed: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
9 Added: let ts = Recovery.timestamp_of_unix_seconds
13 10
14 Removed: let fake_clock at : (module Recovery.CLOCK) =
15 Removed: (module struct
16 Removed: let now () = Recovery.timestamp_of_unix_seconds at
17 Removed: end)
11 Added: let elapsed_tests =
12 Added: [
13 Added: ( "elapsed is the gap between two timestamps",
14 Added: `Quick,
15 Added: fun () ->
16 Added: let e = Recovery.elapsed ~since:(ts 1000) ~now:(ts 1900) in
17 Added: Alcotest.(check int) "900s" 900 (Recovery.duration_to_seconds e) );
18 Added: ]
18 19
19 20 let readiness_tests =
20 21 [
21 22 ( "fully elapsed window is Ready",
22 23 `Quick,
23 24 fun () ->
24 Removed: let now = Recovery.timestamp_of_unix_seconds 1_000_000 in
25 Removed: let last = Recovery.timestamp_of_unix_seconds 0 in
25 Added: let e = Recovery.elapsed ~since:(ts 0) ~now:(ts 1_000_000) in
26 26 let r =
27 Removed: Recovery.evaluate_readiness ~now ~last_workout:last
28 Removed: ~recommended:(Recovery.days 4)
27 Added: Recovery.evaluate_readiness ~elapsed:e ~recommended:(Recovery.days 4)
29 28 in
30 29 Alcotest.(check bool) "ready" true (Recovery.is_ready r) );
31 Removed: ( "within window is not Ready",
30 Added: ( "within the window is not Ready",
32 31 `Quick,
33 32 fun () ->
34 Removed: let now = Recovery.timestamp_of_unix_seconds 3600 in
35 Removed: let last = Recovery.timestamp_of_unix_seconds 0 in
33 Added: let e = Recovery.elapsed ~since:(ts 0) ~now:(ts 3600) in
36 34 let r =
37 Removed: Recovery.evaluate_readiness ~now ~last_workout:last
38 Removed: ~recommended:(Recovery.days 4)
35 Added: Recovery.evaluate_readiness ~elapsed:e ~recommended:(Recovery.days 4)
39 36 in
40 37 Alcotest.(check bool) "not ready" false (Recovery.is_ready r) );
41 38 ]
42 39
43 Removed: let gating_tests =
44 Removed: [
45 Removed: ( "unrecovered blocks ordinary start",
46 Removed: `Quick,
47 Removed: fun () ->
48 Removed: let clock = fake_clock 3600 in
49 Removed: let last = Some (Recovery.timestamp_of_unix_seconds 0) in
50 Removed: let result =
51 Removed: Recovery.start_session clock ~last_workout:last
52 Removed: ~recommended:(Recovery.days 4)
53 Removed: in
54 Removed: Alcotest.(check bool) "blocked" true (Result.is_error result) );
55 Removed: ( "first workout (no last) is always ready",
56 Removed: `Quick,
57 Removed: fun () ->
58 Removed: let clock = fake_clock 3600 in
59 Removed: let result =
60 Removed: Recovery.start_session clock ~last_workout:None
61 Removed: ~recommended:(Recovery.days 4)
62 Removed: in
63 Removed: Alcotest.(check bool) "allowed" true (Result.is_ok result) );
64 Removed: ( "override always succeeds and records the reason",
65 Removed: `Quick,
66 Removed: fun () ->
67 Removed: let clock = fake_clock 3600 in
68 Removed: let last = Some (Recovery.timestamp_of_unix_seconds 0) in
69 Removed: let session =
70 Removed: Recovery.start_session_overriding_recovery clock ~last_workout:last
71 Removed: ~recommended:(Recovery.days 4) ~acknowledged:"felt strong today"
72 Removed: in
73 Removed: Alcotest.(check bool)
74 Removed: "override recorded" true
75 Removed: (Option.is_some (Recovery.overridden_recovery session)) );
76 Removed: ]
77 Removed:
78 40 let suite =
79 Removed: [ ("recovery.readiness", readiness_tests); ("recovery.gating", gating_tests) ]
80 Removed:
81 Removed: let duration_tests =
82 41 [
83 Removed: ( "session_duration is None before ending",
84 Removed: `Quick,
85 Removed: fun () ->
86 Removed: let clock = fake_clock 3600 in
87 Removed: let session =
88 Removed: ok
89 Removed: (Recovery.start_session clock ~last_workout:None
90 Removed: ~recommended:(Recovery.days 4))
91 Removed: in
92 Removed: Alcotest.(check bool)
93 Removed: "none" true
94 Removed: (Option.is_none (Recovery.session_duration session)) );
95 Removed: ( "session_duration is the gap between start and end",
96 Removed: `Quick,
97 Removed: fun () ->
98 Removed: let started = fake_clock 1000 in
99 Removed: let session =
100 Removed: ok
101 Removed: (Recovery.start_session started ~last_workout:None
102 Removed: ~recommended:(Recovery.days 4))
103 Removed: in
104 Removed: let ended = fake_clock 1900 in
105 Removed: let session = Recovery.end_session ended session in
106 Removed: Alcotest.(check (option int))
107 Removed: "900s" (Some 900)
108 Removed: (Option.map Recovery.duration_to_seconds
109 Removed: (Recovery.session_duration session)) );
42 Added: ("recovery.elapsed", elapsed_tests); ("recovery.readiness", readiness_tests);
110 43 ]
111 Removed:
112 Removed: let prescribe_tests =
113 Removed: let sample kg reps : Progression.sample =
114 Removed: { load = ok (Units.Weight.of_kg kg); reps = ok (Units.Reps.of_int reps) }
115 Removed: in
116 Removed: [
117 Removed: ( "progressing keeps the nominal base",
118 Removed: `Quick,
119 Removed: fun () ->
120 Removed: let evidence = [ sample 80.0 6; sample 82.5 6 ] in
121 Removed: let p = Recovery.prescribe ~base:(Recovery.days 4) ~evidence in
122 Removed: Alcotest.(check int)
123 Removed: "base"
124 Removed: (Recovery.duration_to_seconds (Recovery.days 4))
125 Removed: (Recovery.duration_to_seconds p.Progression.value) );
126 Removed: ( "a stall lengthens the window",
127 Removed: `Quick,
128 Removed: fun () ->
129 Removed: let evidence = [ sample 80.0 6; sample 80.0 6 ] in
130 Removed: let p = Recovery.prescribe ~base:(Recovery.days 4) ~evidence in
131 Removed: Alcotest.(check bool)
132 Removed: "longer" true
133 Removed: (Recovery.duration_to_seconds p.Progression.value
134 Removed: > Recovery.duration_to_seconds (Recovery.days 4)) );
135 Removed: ]
136 Removed:
137 Removed: let suite =
138 Removed: suite
139 Removed: @ [
140 Removed: ("recovery.duration", duration_tests);
141 Removed: ("recovery.prescribe", prescribe_tests);
142 Removed: ]
test/test_routine.ml
index 3809ec56..bf7562c4 100644..100644
@@ -22,14 +22,14 @@
22 22 Alcotest.(check bool)
23 23 "non-empty" true
24 24 (List.length (Routine.plans Routine.consolidation_routine) > 0) );
25 Removed: ( "every workout prescribes at least one exercise",
25 Added: ( "every workout prescribes at least one set group",
26 26 `Quick,
27 27 fun () ->
28 28 List.iter
29 29 (fun plan ->
30 30 Alcotest.(check bool)
31 31 "plan non-empty" true
32 Removed: (List.length (Routine.Plan.prescriptions plan) > 0))
32 Added: (List.length (Routine.Plan.set_groups plan) > 0))
33 33 (Routine.plans Routine.ideal_routine) );
34 34 ( "rotation wraps around the cycle",
35 35 `Quick,
@@ -56,8 +56,8 @@
56 56 fun () ->
57 57 Alcotest.(check bool)
58 58 "empty rejected" true
59 Removed: (Result.is_error
60 Removed: (Routine.Plan.make ~name:"Workout A" ~prescriptions:[])) );
59 Added: (Result.is_error (Routine.Plan.make ~name:"Workout A" ~set_groups:[]))
60 Added: );
61 61 ]
62 62
63 63 let suite = [ ("routine.presets", preset_tests); ("routine.make", make_tests) ]
test/test_set_group_prescription.ml
index 00000000..cc6ab53a 000000..100644
@@ -0,0 +1,68 @@
1 Added: (** Unit tests for {!Hito_core.Set_group_prescription}, authored against
2 Added: set_group_prescription.mli.
3 Added:
4 Added: Under test: each shape exposes its slots' exercises in order — the
5 Added: prescribed counterpart to {!Hito_core.Set_group}. *)
6 Added:
7 Added: open Hito_core
8 Added:
9 Added: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
10 Added:
11 Added: let by_name n =
12 Added: match
13 Added: List.find_opt (fun ex -> String.equal (Exercise.name ex) n) Exercise.catalog
14 Added: with
15 Added: | Some ex -> ex
16 Added: | None -> Alcotest.failf "no exercise named %s" n
17 Added:
18 Added: let band lo hi =
19 Added: ok
20 Added: (Units.Rep_range.make
21 Added: ~min:(ok (Units.Reps.of_int lo))
22 Added: ~max:(ok (Units.Reps.of_int hi)))
23 Added:
24 Added: let p name =
25 Added: ok
26 Added: (Prescription.make ~exercise:(by_name name) ~target_reps:(band 6 8)
27 Added: ~allowed_substitutes:[])
28 Added:
29 Added: let tests =
30 Added: [
31 Added: ( "straight has one exercise",
32 Added: `Quick,
33 Added: fun () ->
34 Added: let g = Set_group_prescription.Straight (p "Barbell Bench Press") in
35 Added: Alcotest.(check int)
36 Added: "one" 1
37 Added: (List.length (Set_group_prescription.exercises g)) );
38 Added: ( "superset has two exercises in order",
39 Added: `Quick,
40 Added: fun () ->
41 Added: let g =
42 Added: Set_group_prescription.Superset
43 Added: { first = p "Pulldown"; second = p "Barbell Row" }
44 Added: in
45 Added: let names =
46 Added: List.map Exercise.name (Set_group_prescription.exercises g)
47 Added: in
48 Added: Alcotest.(check (list string))
49 Added: "order"
50 Added: [ "Pulldown"; "Barbell Row" ]
51 Added: names );
52 Added: ( "pre_exhaust orders isolation before compound",
53 Added: `Quick,
54 Added: fun () ->
55 Added: let g =
56 Added: Set_group_prescription.Pre_exhaust
57 Added: { isolation = p "Leg Extension"; compound = p "Back Squat" }
58 Added: in
59 Added: let names =
60 Added: List.map Exercise.name (Set_group_prescription.exercises g)
61 Added: in
62 Added: Alcotest.(check (list string))
63 Added: "order"
64 Added: [ "Leg Extension"; "Back Squat" ]
65 Added: names );
66 Added: ]
67 Added:
68 Added: let suite = [ ("set_group_prescription", tests) ]
test/test_workout.ml
index 00000000..5abb404d 000000..100644
@@ -0,0 +1,40 @@
1 Added: (** Unit tests for {!Hito_core.Workout}, authored against workout.mli.
2 Added:
3 Added: A workout is just a plan reference plus timestamps; under test: creation,
4 Added: finishing, and the resulting duration. *)
5 Added:
6 Added: open Hito_core
7 Added:
8 Added: let ts = Recovery.timestamp_of_unix_seconds
9 Added: let plan = List.hd (Routine.plans Routine.consolidation_routine)
10 Added:
11 Added: let tests =
12 Added: [
13 Added: ( "a fresh workout has no end",
14 Added: `Quick,
15 Added: fun () ->
16 Added: let w = Workout.create ~plan ~started_at:(ts 0) in
17 Added: Alcotest.(check bool)
18 Added: "no end" true
19 Added: (Option.is_none (Workout.ended_at w)) );
20 Added: ( "finishing fixes the end and duration",
21 Added: `Quick,
22 Added: fun () ->
23 Added: let w = Workout.create ~plan ~started_at:(ts 1000) in
24 Added: let w = Workout.finish w ~ended_at:(ts 1900) in
25 Added: Alcotest.(check (option int))
26 Added: "900s" (Some 900)
27 Added: (Option.map Recovery.duration_to_seconds (Workout.duration w)) );
28 Added: ( "plan and started_at round-trip",
29 Added: `Quick,
30 Added: fun () ->
31 Added: let w = Workout.create ~plan ~started_at:(ts 42) in
32 Added: Alcotest.(check int)
33 Added: "started_at" 42
34 Added: (Recovery.timestamp_to_unix_seconds (Workout.started_at w));
35 Added: Alcotest.(check string)
36 Added: "plan" (Routine.Plan.name plan)
37 Added: (Routine.Plan.name (Workout.plan w)) );
38 Added: ]
39 Added:
40 Added: let suite = [ ("workout", tests) ]