feat implement Logbook, the only source of evidence

Every workout performed, plus the questions history can answer: what was performed last, so the routine knows where the cycle stands; how long since the last finished workout, so readiness can be judged; and what a given movement has done over time. Evidence is dated. A stall is defined by progress ceasing for two weeks, so undated samples could not answer the question Progression has to ask. Each observation pairs a movement with the day it was performed, and both halves of a pre-exhaust are recorded separately, since each is its own progression thread. Entries are sorted on read rather than trusted to arrive in order, so a logbook assembled from storage in any order still reports correctly. Readiness comes from the last *finished* workout: an unfinished one is not a completed effort to recover from. The module records and reports; it never interprets. 94 Alcotests.

Commit
2982ef59c812c769605d849e797fb100b037d187
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/core/dune
index 4558b602..30021d5e 100644..100644
@@ -14,5 +14,6 @@
14 14 workout_prescription
15 15 routine
16 16 stimulus
17 Removed: entry)
17 Added: entry
18 Added: logbook)
18 19 (wrapped false))
lib/core/logbook.ml
index 58d1c9b4..f2ff3a14 100644..100644
@@ -1,32 +1,50 @@
1 Removed: (* Blank slate: stub only. Interface (logbook.mli) is the design surface. *)
1 Added: type t = Entry.t list
2 2
3 Removed: module Entry = struct
4 Removed: type t = unit
5 Removed: type error = Set_exercise_not_prescribed of Exercise.id | Already_finished
3 Added: type observation = {
4 Added: exercise : Exercise.t;
5 Added: movement : Stimulus.Movement.t;
6 Added: performed_at : Recovery.timestamp;
7 Added: }
6 8
7 Removed: let start _ ~started_at:_ = failwith "TODO"
8 Removed: let add_group _ _ = failwith "TODO"
9 Removed: let finish _ ~ended_at:_ = failwith "TODO"
10 Removed: let is_finished _ = failwith "TODO"
11 Removed: let prescription _ = failwith "TODO"
12 Removed: let started_at _ = failwith "TODO"
13 Removed: let ended_at _ = failwith "TODO"
14 Removed: let duration _ = failwith "TODO"
15 Removed: let groups _ = failwith "TODO"
16 Removed: let working_sets _ = failwith "TODO"
17 Removed: let performances _ = failwith "TODO"
18 Removed: let guidance _ = failwith "TODO"
19 Removed: let unperformed _ = failwith "TODO"
20 Removed: let volume _ = failwith "TODO"
21 Removed: let pp _ _ = failwith "TODO"
22 Removed: end
9 Added: let empty = []
10 Added: let add t entry = entry :: t
11 Added: let started entry = Recovery.timestamp_to_unix_seconds (Entry.started_at entry)
23 12
24 Removed: type t = unit
13 Added: (* Sorted on read, so entries need not be added in order. *)
14 Added: let chronological t =
15 Added: List.sort (fun a b -> Int.compare (started a) (started b)) t
25 16
26 Removed: let empty = ()
27 Removed: let add _ _ = failwith "TODO"
28 Removed: let entries _ = failwith "TODO"
29 Removed: let evidence _ _ = failwith "TODO"
30 Removed: let last_prescription _ = failwith "TODO"
31 Removed: let readiness _ ~now:_ ~recommended:_ = failwith "TODO"
32 Removed: let pp _ _ = failwith "TODO"
17 Added: let entries t = List.rev (chronological t)
18 Added:
19 Added: let last_prescription t =
20 Added: match entries t with
21 Added: | [] -> None
22 Added: | latest :: _ -> Some (Entry.prescription latest)
23 Added:
24 Added: let observations_of entry =
25 Added: Entry.stimuli entry
26 Added: |> List.concat_map Stimulus.movements
27 Added: |> List.map (fun movement ->
28 Added: {
29 Added: exercise = Stimulus.Movement.exercise movement;
30 Added: movement;
31 Added: performed_at = Entry.started_at entry;
32 Added: })
33 Added:
34 Added: let evidence t exercise =
35 Added: chronological t
36 Added: |> List.concat_map observations_of
37 Added: |> List.filter (fun o -> Exercise.equal o.exercise exercise)
38 Added:
39 Added: let readiness t ~now ~recommended =
40 Added: let finished =
41 Added: chronological t |> List.filter_map (fun e -> Entry.ended_at e) |> List.rev
42 Added: in
43 Added: match finished with
44 Added: | [] -> Recovery.Ready
45 Added: | last :: _ ->
46 Added: Recovery.evaluate_readiness
47 Added: ~elapsed:(Recovery.elapsed ~since:last ~now)
48 Added: ~recommended
49 Added:
50 Added: let pp ppf t = Format.fprintf ppf "%d workouts" (List.length t)
lib/core/logbook.mli
index f25dbaec..400286c0 100644..100644
@@ -1,76 +1,42 @@
1 Removed: (** The training log: what was actually performed.
1 Added: (** The training diary: every workout performed, and the questions history can
2 Added: answer.
2 3
3 Removed: Each entry wraps the {!Workout_prescription.t} it was performed against — a
4 Removed: log without its prescription is meaningless — and its start/end timestamps.
5 Removed: The logbook is also the source of the evidence future prescriptions are
6 Removed: calculated from, and of the elapsed time {!Recovery} judges. *)
4 Added: HD1 insists on keeping records — progress is knowable only from them, and
5 Added: "even a one rep increase is significant". So this is the only source of
6 Added: evidence in the system. It records; it does not interpret. What the evidence
7 Added: means is {!Progression}'s business. *)
7 8
8 Removed: (** One performed workout. *)
9 Removed: module Entry : sig
10 Removed: type t
11 Removed:
12 Removed: type error =
13 Removed: | Set_exercise_not_prescribed of Exercise.id
14 Removed: (** A set's exercise matches no slot in the prescription. *)
15 Removed: | Already_finished
16 Removed:
17 Removed: val start : Workout_prescription.t -> started_at:Recovery.timestamp -> t
18 Removed:
19 Removed: val add_group : t -> Set_group.t -> (t, error) result
20 Removed: (** Append a performed group. Each working set's exercise must match a slot in
21 Removed: the prescription; a group may span exercises, so each set is checked. *)
22 Removed:
23 Removed: val finish : t -> ended_at:Recovery.timestamp -> t
24 Removed: val is_finished : t -> bool
25 Removed: val prescription : t -> Workout_prescription.t
26 Removed: val started_at : t -> Recovery.timestamp
27 Removed: val ended_at : t -> Recovery.timestamp option
28 Removed: val duration : t -> Recovery.duration option
29 Removed: val groups : t -> Set_group.t list
30 Removed:
31 Removed: val working_sets : t -> Set.Working.t list
32 Removed: (** Every working set performed, in order. *)
33 Removed:
34 Removed: val performances : t -> (Exercise.id * Progression.sample) list
35 Removed: (** Each working set's performance, keyed by its own exercise. *)
36 Removed:
37 Removed: val guidance : t -> (Exercise.id * Progression.guidance) list
38 Removed: (** What was performed, judged against the prescription it was logged against.
39 Removed: *)
40 Removed:
41 Removed: val unperformed : t -> Exercise.t list
42 Removed: (** Prescribed exercises with no logged working set. *)
43 Removed:
44 Removed: val volume : t -> float
45 Removed: (** Σ (load × reps). Diagnostic, not a target. *)
46 Removed:
47 Removed: val pp : Format.formatter -> t -> unit
48 Removed: end
49 Removed:
50 9 type t
51 Removed: (** A chronological log of finished entries. *)
52 10
53 11 val empty : t
54 12 val add : t -> Entry.t -> t
55 13
56 14 val entries : t -> Entry.t list
57 Removed: (** Most recent first. *)
15 Added: (** Most recent first, by when each workout began. *)
58 16
59 Removed: val evidence : t -> Exercise.id -> Progression.sample list
60 Removed: (** An exercise's past performances, oldest-first — the input to
61 Removed: {!Progression.prescribe}. *)
62 Removed:
63 17 val last_prescription : t -> Workout_prescription.t option
64 Removed: (** The prescription of the most recent entry; feeds {!Routine.workout_after}.
65 Removed: *)
18 Added: (** What was performed most recently, which is what {!Routine.workout_after}
19 Added: needs to know where the cycle stands. *)
66 20
21 Added: type observation = {
22 Added: exercise : Exercise.t;
23 Added: movement : Stimulus.Movement.t;
24 Added: performed_at : Recovery.timestamp;
25 Added: }
26 Added: (** One movement as it was performed, on the day it was performed. Dated because
27 Added: a stall is defined by progress ceasing *for two weeks*, so evidence without
28 Added: time cannot answer the question. *)
29 Added:
30 Added: val evidence : t -> Exercise.t -> observation list
31 Added: (** Every recorded performance of that movement, oldest first. *)
32 Added:
67 33 val readiness :
68 34 t ->
69 35 now:Recovery.timestamp ->
70 36 recommended:Recovery.duration ->
71 37 Recovery.readiness
72 Removed: (** Elapsed time since the most recent finished entry, judged against
73 Removed: [recommended]. Informational: nothing here prevents starting the next
74 Removed: workout regardless. *)
38 Added: (** Measured from the end of the last finished workout. [Ready] when nothing has
39 Added: been logged yet, or when the last workout is still unfinished — there is no
40 Added: completed effort to recover from. *)
75 41
76 42 val pp : Format.formatter -> t -> unit
test/dune
index f12b485a..def9150e 100644..100644
@@ -13,5 +13,6 @@
13 13 test_routine
14 14 test_stimulus
15 15 test_recovery
16 Removed: test_entry)
16 Added: test_entry
17 Added: test_logbook)
17 18 (libraries hito.core alcotest))
test/test_hito.ml
index 6571469e..b5d76fc2 100644..100644
@@ -6,4 +6,4 @@
6 6 (Test_units.suite @ Test_muscle.suite @ Test_exercise.suite
7 7 @ Test_prescription.suite @ Test_workout_prescription.suite
8 8 @ Test_routine.suite @ Test_stimulus.suite @ Test_recovery.suite
9 Removed: @ Test_entry.suite)
9 Added: @ Test_entry.suite @ Test_logbook.suite)
test/test_logbook.ml
index 2e35f1c5..46aad259 100644..100644
@@ -1,124 +1,196 @@
1 Removed: (** Unit tests for {!Logbook}, authored against logbook.mli.
1 Added: (** Unit tests for {!Logbook}. *)
2 2
3 Removed: Under test: an entry accepts sets matching its prescription and rejects
4 Removed: others; the logbook derives per-exercise evidence and an informational
5 Removed: readiness reading from finished entries. *)
6 Removed:
7 3 let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
8 Removed: let ts = Recovery.timestamp_of_unix_seconds
9 Removed: let mk_w kg = ok (Units.Weight.of_kg kg)
10 Removed: let mk_r n = ok (Units.Reps.of_int n)
11 Removed: let prescription = List.hd (Routine.workouts Routine.consolidation_routine)
12 Removed: (* Consolidation Workout A: Back Squat (straight), Pulldown (straight). *)
13 4
14 Removed: let prescribed_exercises =
15 Removed: Workout_prescription.set_groups prescription
16 Removed: |> List.concat_map Set_group_prescription.exercises
5 Added: let get id =
6 Added: match Exercise.find id with
7 Added: | Some e -> e
8 Added: | None -> Alcotest.failf "catalog is missing %S" id
17 9
18 Removed: let squat = List.hd prescribed_exercises
10 Added: let kg n = ok (Units.Weight.of_kg n)
11 Added: let reps n = ok (Units.Reps.of_int n)
12 Added: let at s = Recovery.timestamp_of_unix_seconds s
13 Added: let day n = at (n * 86_400)
14 Added: let cleared = Option.get (Recovery.clear Recovery.Ready)
15 Added: let routine = Routine.ideal_routine
16 Added: let workout n = List.nth (Routine.workouts routine) n
19 17
20 Removed: let unprescribed =
21 Removed: List.find
22 Removed: (fun ex -> not (List.exists (Exercise.equal ex) prescribed_exercises))
23 Removed: Exercise.catalog
18 Added: let move id load r =
19 Added: Stimulus.Movement.make ~exercise:(get id) ~load:(kg load) ~reps:(reps r)
20 Added: ~outcome:Stimulus.Positive_failure
24 21
25 Removed: let working exercise load reps =
26 Removed: Set.Working.make ~exercise ~load:(mk_w load) ~reps:(mk_r reps)
27 Removed: ~outcome:Set.Working.Positive_failure
22 Added: let single id load r = ok (Stimulus.make (Stimulus.Single (move id load r)))
28 23
29 Removed: let start () = Logbook.Entry.start prescription ~started_at:(ts 0)
24 Added: (* A finished workout: Day n of the Ideal Routine, with one stimulus logged. *)
25 Added: let logged ~workout:w ~on ~stimuli =
26 Added: let e =
27 Added: List.fold_left
28 Added: (fun e s -> ok (Entry.add_stimulus e s))
29 Added: (Entry.start w ~clearance:cleared ~started_at:on)
30 Added: stimuli
31 Added: in
32 Added: ok (Entry.finish e ~ended_at:on)
30 33
31 Removed: let entry_tests =
34 Added: let laterals load r = single "laterals" load r
35 Added:
36 Added: let basic_tests =
32 37 [
33 Removed: ( "a set for a prescribed exercise is accepted",
38 Added: ( "an empty logbook knows nothing",
34 39 `Quick,
35 40 fun () ->
36 Removed: let e = start () in
37 Removed: let group = Set_group.straight ~working:(working squat 100.0 6) () in
41 Added: Alcotest.(check int)
42 Added: "no entries" 0
43 Added: (List.length (Logbook.entries Logbook.empty));
38 44 Alcotest.(check bool)
39 Removed: "accepted" true
40 Removed: (Result.is_ok (Logbook.Entry.add_group e group)) );
41 Removed: ( "a set for an unprescribed exercise is rejected",
45 Added: "no last prescription" true
46 Added: (Option.is_none (Logbook.last_prescription Logbook.empty)) );
47 Added: ( "entries come back most recent first, however they were added",
42 48 `Quick,
43 49 fun () ->
44 Removed: let e = start () in
45 Removed: let group =
46 Removed: Set_group.straight ~working:(working unprescribed 20.0 12) ()
50 Added: let book =
51 Added: ( Logbook.empty |> fun b ->
52 Added: Logbook.add b (logged ~workout:(workout 1) ~on:(day 3) ~stimuli:[])
53 Added: )
54 Added: |> fun b ->
55 Added: Logbook.add b (logged ~workout:(workout 0) ~on:(day 1) ~stimuli:[])
47 56 in
48 Removed: Alcotest.(check bool)
49 Removed: "rejected" true
50 Removed: (Result.is_error (Logbook.Entry.add_group e group)) );
51 Removed: ( "a finished entry rejects further groups",
57 Added: Alcotest.(check (list string))
58 Added: "newest first" [ "Day 2"; "Day 1" ]
59 Added: (List.map
60 Added: (fun e -> Workout_prescription.name (Entry.prescription e))
61 Added: (Logbook.entries book)) );
62 Added: ( "the last prescription is what the cycle should advance from",
52 63 `Quick,
53 64 fun () ->
54 Removed: let e = Logbook.Entry.finish (start ()) ~ended_at:(ts 100) in
55 Removed: let group = Set_group.straight ~working:(working squat 100.0 6) () in
56 Removed: Alcotest.(check bool)
57 Removed: "rejected" true
58 Removed: (Result.is_error (Logbook.Entry.add_group e group)) );
59 Removed: ( "unperformed lists prescribed exercises with no logged set",
65 Added: let book =
66 Added: Logbook.add Logbook.empty
67 Added: (logged ~workout:(workout 1) ~on:(day 1) ~stimuli:[])
68 Added: in
69 Added: let last = Option.get (Logbook.last_prescription book) in
70 Added: Alcotest.(check string)
71 Added: "performed Day 2" "Day 2"
72 Added: (Workout_prescription.name last);
73 Added: Alcotest.(check string)
74 Added: "so Day 3 is next" "Day 3"
75 Added: (Workout_prescription.name (Routine.workout_after routine last)) );
76 Added: ]
77 Added:
78 Added: let evidence_tests =
79 Added: [
80 Added: ( "evidence for a movement comes back oldest first",
60 81 `Quick,
61 82 fun () ->
62 Removed: let e = start () in
83 Added: let book =
84 Added: ( Logbook.empty |> fun b ->
85 Added: Logbook.add b
86 Added: (logged ~workout:(workout 0) ~on:(day 5)
87 Added: ~stimuli:[ laterals 14. 7 ]) )
88 Added: |> fun b ->
89 Added: Logbook.add b
90 Added: (logged ~workout:(workout 0) ~on:(day 1)
91 Added: ~stimuli:[ laterals 12. 8 ])
92 Added: in
93 Added: let history = Logbook.evidence book (get "laterals") in
94 Added: Alcotest.(check int) "two observations" 2 (List.length history);
95 Added: Alcotest.(check (list (float 0.001)))
96 Added: "12kg then 14kg" [ 12.; 14. ]
97 Added: (List.map
98 Added: (fun (o : Logbook.observation) ->
99 Added: Units.Weight.to_kg (Stimulus.Movement.load o.movement))
100 Added: history) );
101 Added: ( "observations are dated, so a stall can be measured",
102 Added: `Quick,
103 Added: fun () ->
104 Added: let book =
105 Added: Logbook.add Logbook.empty
106 Added: (logged ~workout:(workout 0) ~on:(day 2)
107 Added: ~stimuli:[ laterals 12. 8 ])
108 Added: in
109 Added: match Logbook.evidence book (get "laterals") with
110 Added: | [ o ] ->
111 Added: Alcotest.(check int)
112 Added: "day 2" 172_800
113 Added: (Recovery.timestamp_to_unix_seconds o.performed_at)
114 Added: | _ -> Alcotest.fail "expected one observation" );
115 Added: ( "a movement never performed has no evidence",
116 Added: `Quick,
117 Added: fun () ->
118 Added: let book =
119 Added: Logbook.add Logbook.empty
120 Added: (logged ~workout:(workout 0) ~on:(day 1)
121 Added: ~stimuli:[ laterals 12. 8 ])
122 Added: in
63 123 Alcotest.(check int)
64 Removed: "both unperformed" 2
65 Removed: (List.length (Logbook.Entry.unperformed e)) );
66 Removed: ( "logging one exercise leaves the other unperformed",
124 Added: "none" 0
125 Added: (List.length (Logbook.evidence book (get "squats"))) );
126 Added: ( "both halves of a pre-exhaust are recorded separately",
67 127 `Quick,
68 128 fun () ->
69 Removed: let e = start () in
70 Removed: let group = Set_group.straight ~working:(working squat 100.0 6) () in
71 Removed: let e = ok (Logbook.Entry.add_group e group) in
129 Added: let pair =
130 Added: ok
131 Added: (Stimulus.make
132 Added: (Stimulus.Pre_exhaust
133 Added: {
134 Added: isolation = move "dumbbell-flyes" 20. 9;
135 Added: compound = move "incline-press" 60. 7;
136 Added: }))
137 Added: in
138 Added: let book =
139 Added: Logbook.add Logbook.empty
140 Added: (logged ~workout:(workout 0) ~on:(day 1) ~stimuli:[ pair ])
141 Added: in
72 142 Alcotest.(check int)
73 Removed: "one left" 1
74 Removed: (List.length (Logbook.Entry.unperformed e)) );
143 Added: "isolation" 1
144 Added: (List.length (Logbook.evidence book (get "dumbbell-flyes")));
145 Added: Alcotest.(check int)
146 Added: "compound" 1
147 Added: (List.length (Logbook.evidence book (get "incline-press"))) );
75 148 ]
76 149
77 Removed: let logbook_tests =
150 Added: let readiness_tests =
78 151 [
79 Removed: ( "evidence is empty for an exercise never logged",
152 Added: ( "an empty logbook is ready: nothing to recover from",
80 153 `Quick,
81 154 fun () ->
82 Removed: Alcotest.(check int)
83 Removed: "empty" 0
84 Removed: (List.length (Logbook.evidence Logbook.empty (Exercise.id squat))) );
85 Removed: ( "evidence accumulates oldest-first across entries",
155 Added: Alcotest.(check bool)
156 Added: "ready" true
157 Added: (Recovery.is_ready
158 Added: (Logbook.readiness Logbook.empty ~now:(day 1)
159 Added: ~recommended:Routine.training_interval)) );
160 Added: ( "readiness is measured from the last finished workout",
86 161 `Quick,
87 162 fun () ->
88 Removed: let make_entry started load =
89 Removed: let e = Logbook.Entry.start prescription ~started_at:(ts started) in
90 Removed: let group = Set_group.straight ~working:(working squat load 6) () in
91 Removed: Logbook.Entry.finish
92 Removed: (ok (Logbook.Entry.add_group e group))
93 Removed: ~ended_at:(ts (started + 10))
94 Removed: in
95 163 let book =
96 Removed: Logbook.(add (add empty (make_entry 0 80.0)) (make_entry 100 82.5))
164 Added: Logbook.add Logbook.empty
165 Added: (logged ~workout:(workout 0) ~on:(day 1) ~stimuli:[])
97 166 in
98 Removed: let ev = Logbook.evidence book (Exercise.id squat) in
99 Removed: Alcotest.(check int) "two samples" 2 (List.length ev);
100 Removed: Alcotest.(check (float 0.0001))
101 Removed: "oldest first" 80.0
102 Removed: (Units.Weight.to_kg (List.hd ev).Set.Working.load) );
103 Removed: ( "last_prescription is the most recent entry's",
167 Added: Alcotest.(check bool)
168 Added: "one day later, still recovering" false
169 Added: (Recovery.is_ready
170 Added: (Logbook.readiness book ~now:(day 2)
171 Added: ~recommended:Routine.training_interval));
172 Added: Alcotest.(check bool)
173 Added: "two days later, ready" true
174 Added: (Recovery.is_ready
175 Added: (Logbook.readiness book ~now:(day 3)
176 Added: ~recommended:Routine.training_interval)) );
177 Added: ( "an unfinished workout leaves nothing to recover from",
104 178 `Quick,
105 179 fun () ->
106 Removed: let e = Logbook.Entry.finish (start ()) ~ended_at:(ts 10) in
107 Removed: let book = Logbook.add Logbook.empty e in
108 Removed: match Logbook.last_prescription book with
109 Removed: | Some p ->
110 Removed: Alcotest.(check bool)
111 Removed: "matches" true
112 Removed: (Workout_prescription.equal p prescription)
113 Removed: | None -> Alcotest.fail "expected Some" );
114 Removed: ( "an empty logbook is always Ready",
115 Removed: `Quick,
116 Removed: fun () ->
117 Removed: let r =
118 Removed: Logbook.readiness Logbook.empty ~now:(ts 1000)
119 Removed: ~recommended:(Recovery.days 4)
180 Added: let unfinished =
181 Added: Entry.start (workout 0) ~clearance:cleared ~started_at:(day 1)
120 182 in
121 Removed: Alcotest.(check bool) "ready" true (Recovery.is_ready r) );
183 Added: let book = Logbook.add Logbook.empty unfinished in
184 Added: Alcotest.(check bool)
185 Added: "ready" true
186 Added: (Recovery.is_ready
187 Added: (Logbook.readiness book ~now:(day 1)
188 Added: ~recommended:Routine.training_interval)) );
122 189 ]
123 190
124 Removed: let suite = [ ("logbook.entry", entry_tests); ("logbook", logbook_tests) ]
191 Added: let suite =
192 Added: [
193 Added: ("logbook.basics", basic_tests);
194 Added: ("logbook.evidence", evidence_tests);
195 Added: ("logbook.readiness", readiness_tests);
196 Added: ]