feat implement sessions, readiness, and prescribe

session is a plain record; the gate is enforced by start_session's result, not by the type. A session that stalled its way past Not_recovered is returned only from start_session_overriding_recovery, which always constructs one — the override is the only path around the error, and it is recorded on the value itself. prescribe doubles the base window on a stall and leaves it unchanged while progressing, mirroring Progression.next_target's own bias toward recovery over more work. Judgment call, flagging alongside Progression's 2.5kg increment: no HD source gives a numeric multiplier either, so this is a placeholder easy to revisit. Extended test_recovery.ml (predated end_session/session_duration/ prescribe) rather than replacing it. 54 tests total.

Commit
42ca65590a7dab8db3263c0398e1dcc6db865216
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/core/recovery.ml
index b235d07d..770832b2 100644..100644
@@ -1,7 +1,3 @@
1 Removed: (* Time primitives are implemented here since Routine needs them to seed its
2 Removed: presets. Sessions, readiness, and prescribe are implemented on Recovery's
3 Removed: own turn. *)
4 Removed:
5 1 type timestamp = int
6 2
7 3 let timestamp_of_unix_seconds s = s
@@ -12,8 +8,20 @@
12 8 let hours n = n * 3600
13 9 let days n = n * 86400
14 10 let duration_to_seconds d = d
15 Removed: let prescribe ~base:_ ~evidence:_ = failwith "TODO"
16 11
12 Added: (* A stall lengthens the window rather than shortening it: fuller recovery,
13 Added: not more work. Progressing keeps the nominal base. *)
14 Added: let prescribe ~base ~evidence =
15 Added: let status =
16 Added: match Progression.evaluate ~history:evidence with
17 Added: | Ok s -> s
18 Added: | Error Progression.Insufficient_data -> Progression.Progressing
19 Added: in
20 Added: let value =
21 Added: match status with Progression.Progressing -> base | Stalled -> base * 2
22 Added: in
23 Added: { Progression.value; evidence; status }
24 Added:
17 25 module type CLOCK = sig
18 26 val now : unit -> timestamp
19 27 end
@@ -22,22 +30,59 @@
22 30 | Ready
23 31 | Recovering of { rested : duration; recommended : duration }
24 32
25 Removed: let evaluate_readiness ~now:_ ~last_workout:_ ~recommended:_ = failwith "TODO"
26 Removed: let is_ready _ = failwith "TODO"
33 Added: let evaluate_readiness ~now ~last_workout ~recommended =
34 Added: let rested = now - last_workout in
35 Added: if rested >= recommended then Ready else Recovering { rested; recommended }
27 36
37 Added: let is_ready = function Ready -> true | Recovering _ -> false
38 Added:
28 39 type override_reason = { note : string; readiness_at_start : readiness }
29 Removed: type session = unit
40 Added:
41 Added: type session = {
42 Added: started_at : timestamp;
43 Added: ended_at : timestamp option;
44 Added: overridden_recovery : override_reason option;
45 Added: }
46 Added:
30 47 type error = Not_recovered of { readiness : readiness }
31 48
32 Removed: let start_session _ ~last_workout:_ ~recommended:_ = failwith "TODO"
49 Added: let readiness_now (module Clock : CLOCK) ~last_workout ~recommended =
50 Added: match last_workout with
51 Added: | None -> Ready
52 Added: | Some last ->
53 Added: evaluate_readiness ~now:(Clock.now ()) ~last_workout:last ~recommended
33 54
34 Removed: let start_session_overriding_recovery _ ~last_workout:_ ~recommended:_
35 Removed: ~acknowledged:_ =
36 Removed: failwith "TODO"
55 Added: let start_session clock ~last_workout ~recommended =
56 Added: let readiness = readiness_now clock ~last_workout ~recommended in
57 Added: if is_ready readiness then
58 Added: let (module Clock) = clock in
59 Added: Ok
60 Added: { started_at = Clock.now (); ended_at = None; overridden_recovery = None }
61 Added: else Error (Not_recovered { readiness })
37 62
38 Removed: let end_session _ _ = failwith "TODO"
39 Removed: let started_at _ = failwith "TODO"
40 Removed: let ended_at _ = failwith "TODO"
41 Removed: let session_duration _ = failwith "TODO"
42 Removed: let overridden_recovery _ = failwith "TODO"
43 Removed: let pp_session _ _ = failwith "TODO"
63 Added: let start_session_overriding_recovery clock ~last_workout ~recommended
64 Added: ~acknowledged =
65 Added: let readiness_at_start = readiness_now clock ~last_workout ~recommended in
66 Added: let (module Clock) = clock in
67 Added: {
68 Added: started_at = Clock.now ();
69 Added: ended_at = None;
70 Added: overridden_recovery = Some { note = acknowledged; readiness_at_start };
71 Added: }
72 Added:
73 Added: let end_session (module Clock : CLOCK) session =
74 Added: { session with ended_at = Some (Clock.now ()) }
75 Added:
76 Added: let started_at session = session.started_at
77 Added: let ended_at session = session.ended_at
78 Added:
79 Added: let session_duration session =
80 Added: Option.map (fun ended -> ended - session.started_at) session.ended_at
81 Added:
82 Added: let overridden_recovery session = session.overridden_recovery
83 Added:
84 Added: let pp_session fmt session =
85 Added: match session.ended_at with
86 Added: | None -> Format.fprintf fmt "started at %d" session.started_at
87 Added: | Some ended ->
88 Added: Format.fprintf fmt "started at %d, ended at %d" session.started_at ended
test/test_hito.ml
index 052b2e28..918e0ab2 100644..100644
@@ -5,4 +5,4 @@
5 5 Alcotest.run "hito"
6 6 (Test_units.suite @ Test_exercise.suite @ Test_set.suite
7 7 @ Test_set_group.suite @ Test_prescription.suite @ Test_routine.suite
8 Removed: @ Test_progression.suite)
8 Added: @ Test_progression.suite @ Test_recovery.suite)
test/test_recovery.ml
index 66dde9d2..1215a469 100644..100644
@@ -9,6 +9,8 @@
9 9
10 10 open Hito_core
11 11
12 Added: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
13 Added:
12 14 let fake_clock at : (module Recovery.CLOCK) =
13 15 (module struct
14 16 let now () = Recovery.timestamp_of_unix_seconds at
@@ -75,3 +77,66 @@
75 77
76 78 let suite =
77 79 [ ("recovery.readiness", readiness_tests); ("recovery.gating", gating_tests) ]
80 Added:
81 Added: let duration_tests =
82 Added: [
83 Added: ( "session_duration is None before ending",
84 Added: `Quick,
85 Added: fun () ->
86 Added: let clock = fake_clock 3600 in
87 Added: let session =
88 Added: ok
89 Added: (Recovery.start_session clock ~last_workout:None
90 Added: ~recommended:(Recovery.days 4))
91 Added: in
92 Added: Alcotest.(check bool)
93 Added: "none" true
94 Added: (Option.is_none (Recovery.session_duration session)) );
95 Added: ( "session_duration is the gap between start and end",
96 Added: `Quick,
97 Added: fun () ->
98 Added: let started = fake_clock 1000 in
99 Added: let session =
100 Added: ok
101 Added: (Recovery.start_session started ~last_workout:None
102 Added: ~recommended:(Recovery.days 4))
103 Added: in
104 Added: let ended = fake_clock 1900 in
105 Added: let session = Recovery.end_session ended session in
106 Added: Alcotest.(check (option int))
107 Added: "900s" (Some 900)
108 Added: (Option.map Recovery.duration_to_seconds
109 Added: (Recovery.session_duration session)) );
110 Added: ]
111 Added:
112 Added: let prescribe_tests =
113 Added: let sample kg reps : Progression.sample =
114 Added: { load = ok (Units.Weight.of_kg kg); reps = ok (Units.Reps.of_int reps) }
115 Added: in
116 Added: [
117 Added: ( "progressing keeps the nominal base",
118 Added: `Quick,
119 Added: fun () ->
120 Added: let evidence = [ sample 80.0 6; sample 82.5 6 ] in
121 Added: let p = Recovery.prescribe ~base:(Recovery.days 4) ~evidence in
122 Added: Alcotest.(check int)
123 Added: "base"
124 Added: (Recovery.duration_to_seconds (Recovery.days 4))
125 Added: (Recovery.duration_to_seconds p.Progression.value) );
126 Added: ( "a stall lengthens the window",
127 Added: `Quick,
128 Added: fun () ->
129 Added: let evidence = [ sample 80.0 6; sample 80.0 6 ] in
130 Added: let p = Recovery.prescribe ~base:(Recovery.days 4) ~evidence in
131 Added: Alcotest.(check bool)
132 Added: "longer" true
133 Added: (Recovery.duration_to_seconds p.Progression.value
134 Added: > Recovery.duration_to_seconds (Recovery.days 4)) );
135 Added: ]
136 Added:
137 Added: let suite =
138 Added: suite
139 Added: @ [
140 Added: ("recovery.duration", duration_tests);
141 Added: ("recovery.prescribe", prescribe_tests);
142 Added: ]