feat implement Recovery with an unforgeable clearance to train

Time primitives (timestamps, durations, elapsed) plus a readiness reading judged against a recommended window. Elapsed rest clamps at zero, since rest cannot run backwards. Recovery is a precondition here, not advice. HD1 makes training before the body has replenished its reserves the primary error, and its effects systemic rather than local, so beginning a workout demands a clearance that cannot be forged: it is either earned by having recovered, or taken deliberately through override. An override is granted rather than refused — the app must not prevent you training — but it records how long you had actually rested and why you went ahead. That is what turns a bad decision into evidence: Progression can later weigh it when diagnosing a stall, which a purely advisory readiness flag could never support. Overriding when recovery is in fact complete is simply Recovered; there was nothing to override. Implemented ahead of Routine, which needs a duration for its baseline. 54 Alcotests.

Commit
36ab76c40056b827b9474471ef68671f755c7ce7
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 101ca57b..875a2cd7 100644..100644
@@ -1,20 +1,48 @@
1 Removed: (* Blank slate: stub only. Interface (recovery.mli) is the design surface. *)
2 Removed:
3 1 type timestamp = int
4 2
5 Removed: let timestamp_of_unix_seconds _ = failwith "TODO"
6 Removed: let timestamp_to_unix_seconds _ = failwith "TODO"
3 Added: let timestamp_of_unix_seconds s = s
4 Added: let timestamp_to_unix_seconds t = t
7 5
8 6 type duration = int
9 7
10 Removed: let hours _ = failwith "TODO"
11 Removed: let days _ = failwith "TODO"
12 Removed: let duration_to_seconds _ = failwith "TODO"
13 Removed: let elapsed ~since:_ ~now:_ = failwith "TODO"
8 Added: let hours n = n * 3600
9 Added: let days n = n * 86_400
10 Added: let duration_to_seconds d = d
11 Added: let elapsed ~since ~now = Int.max 0 (now - since)
14 12
13 Added: let pp_duration ppf d =
14 Added: if d mod 86_400 = 0 && d <> 0 then Format.fprintf ppf "%dd" (d / 86_400)
15 Added: else if d mod 3600 = 0 && d <> 0 then Format.fprintf ppf "%dh" (d / 3600)
16 Added: else Format.fprintf ppf "%ds" d
17 Added:
15 18 type readiness =
16 19 | Ready
17 20 | Recovering of { rested : duration; recommended : duration }
18 21
19 Removed: let evaluate_readiness ~elapsed:_ ~recommended:_ = failwith "TODO"
20 Removed: let is_ready _ = failwith "TODO"
22 Added: let evaluate_readiness ~elapsed ~recommended =
23 Added: if elapsed >= recommended then Ready
24 Added: else Recovering { rested = elapsed; recommended }
25 Added:
26 Added: let is_ready = function Ready -> true | Recovering _ -> false
27 Added:
28 Added: let pp_readiness ppf = function
29 Added: | Ready -> Format.pp_print_string ppf "ready"
30 Added: | Recovering { rested; recommended } ->
31 Added: Format.fprintf ppf "recovering (%a of %a)" pp_duration rested pp_duration
32 Added: recommended
33 Added:
34 Added: type basis =
35 Added: | Recovered
36 Added: | Overridden of { rested : duration; recommended : duration; reason : string }
37 Added:
38 Added: type clearance = basis
39 Added:
40 Added: let clear = function Ready -> Some Recovered | Recovering _ -> None
41 Added:
42 Added: let override readiness ~reason =
43 Added: match readiness with
44 Added: | Ready -> Recovered
45 Added: | Recovering { rested; recommended } ->
46 Added: Overridden { rested; recommended; reason }
47 Added:
48 Added: let basis c = c
lib/core/recovery.mli
index 023ae8b8..69ee8e58 100644..100644
@@ -1,8 +1,12 @@
1 Removed: (** Recovery is read off the logbook, not managed here: the elapsed time between
2 Removed: two workouts, judged against a recommended window. There is no session,
3 Removed: override, or evidence-based prescription — {!Logbook} owns that context and
4 Removed: decides what to do with a {!readiness} reading. *)
1 Added: (** Recovery: the elapsed time between two workouts, judged against a
2 Added: recommended window, and the permission to train that follows from it.
5 3
4 Added: HD1 treats recovery as a precondition of growth rather than a suggestion —
5 Added: training before the body has replenished its reserves is the primary error,
6 Added: and its effects are systemic, not local to the muscles worked. So readiness
7 Added: is not merely reported: starting a workout requires a {!clearance}, which is
8 Added: either earned by resting or taken deliberately and on the record. *)
9 Added:
6 10 type timestamp = private int
7 11
8 12 val timestamp_of_unix_seconds : int -> timestamp
@@ -13,8 +17,14 @@
13 17 val hours : int -> duration
14 18 val days : int -> duration
15 19 val duration_to_seconds : duration -> int
20 Added:
16 21 val elapsed : since:timestamp -> now:timestamp -> duration
22 Added: (** Zero if [now] precedes [since]: rest cannot be negative. *)
17 23
24 Added: val pp_duration : Format.formatter -> duration -> unit
25 Added:
26 Added: (** {1 Readiness} *)
27 Added:
18 28 type readiness =
19 29 | Ready
20 30 | Recovering of { rested : duration; recommended : duration }
@@ -22,3 +32,25 @@
22 32
23 33 val evaluate_readiness : elapsed:duration -> recommended:duration -> readiness
24 34 val is_ready : readiness -> bool
35 Added: val pp_readiness : Format.formatter -> readiness -> unit
36 Added:
37 Added: (** {1 Clearance to train} *)
38 Added:
39 Added: type clearance
40 Added: (** Permission to begin a workout. Unforgeable: obtainable only by having
41 Added: recovered, or by {!override}. *)
42 Added:
43 Added: (** Why training was permitted. Recorded, so that an unrecovered workout remains
44 Added: visible to {!Progression} when diagnosing a stall. *)
45 Added: type basis =
46 Added: | Recovered
47 Added: | Overridden of { rested : duration; recommended : duration; reason : string }
48 Added:
49 Added: val clear : readiness -> clearance option
50 Added: (** [Some] iff recovery is complete. *)
51 Added:
52 Added: val override : readiness -> reason:string -> clearance
53 Added: (** Train regardless, on the record. Under HD1 this is an error, not a shortcut
54 Added: — the reason exists so the decision can be weighed later. *)
55 Added:
56 Added: val basis : clearance -> basis
test/dune
index 046870a1..8d2b801c 100644..100644
@@ -9,5 +9,6 @@
9 9 test_muscle
10 10 test_exercise
11 11 test_prescription
12 Removed: test_workout_prescription)
12 Added: test_workout_prescription
13 Added: test_recovery)
13 14 (libraries hito.core alcotest))
test/test_hito.ml
index d2d5333e..963e60e3 100644..100644
@@ -4,4 +4,5 @@
4 4 let () =
5 5 Alcotest.run "hito"
6 6 (Test_units.suite @ Test_muscle.suite @ Test_exercise.suite
7 Removed: @ Test_prescription.suite @ Test_workout_prescription.suite)
7 Added: @ Test_prescription.suite @ Test_workout_prescription.suite
8 Added: @ Test_recovery.suite)
test/test_recovery.ml
index ddc49d56..87aed574 100644..100644
@@ -1,41 +1,107 @@
1 Removed: (** Unit tests for {!Recovery}, authored against recovery.mli.
1 Added: (** Unit tests for {!Recovery}. *)
2 2
3 Removed: Recovery is now just time primitives plus a readiness judgment; sessions,
4 Removed: overrides, and evidence-based prescription moved to {!Logbook}
5 Removed: (informational gating) and were dropped from this module entirely. *)
3 Added: let secs d = Recovery.duration_to_seconds d
4 Added: let at s = Recovery.timestamp_of_unix_seconds s
6 5
7 Removed: let ts = Recovery.timestamp_of_unix_seconds
8 Removed:
9 Removed: let elapsed_tests =
6 Added: let time_tests =
10 7 [
11 Removed: ( "elapsed is the gap between two timestamps",
8 Added: ( "hours and days convert to seconds",
12 9 `Quick,
13 10 fun () ->
14 Removed: let e = Recovery.elapsed ~since:(ts 1000) ~now:(ts 1900) in
15 Removed: Alcotest.(check int) "900s" 900 (Recovery.duration_to_seconds e) );
11 Added: Alcotest.(check int) "48h" 172_800 (secs (Recovery.hours 48));
12 Added: Alcotest.(check int) "2d" 172_800 (secs (Recovery.days 2)) );
13 Added: ( "timestamps round-trip",
14 Added: `Quick,
15 Added: fun () ->
16 Added: Alcotest.(check int)
17 Added: "unix seconds" 1_700_000_000
18 Added: (Recovery.timestamp_to_unix_seconds (at 1_700_000_000)) );
19 Added: ( "elapsed measures the gap between workouts",
20 Added: `Quick,
21 Added: fun () ->
22 Added: Alcotest.(check int)
23 Added: "48h apart" 172_800
24 Added: (secs (Recovery.elapsed ~since:(at 0) ~now:(at 172_800))) );
25 Added: ( "elapsed never goes negative",
26 Added: `Quick,
27 Added: fun () ->
28 Added: Alcotest.(check int)
29 Added: "clamped" 0
30 Added: (secs (Recovery.elapsed ~since:(at 172_800) ~now:(at 0))) );
16 31 ]
17 32
18 33 let readiness_tests =
19 34 [
20 Removed: ( "fully elapsed window is Ready",
35 Added: ( "the recommended window having passed means ready",
21 36 `Quick,
22 37 fun () ->
23 Removed: let e = Recovery.elapsed ~since:(ts 0) ~now:(ts 1_000_000) in
24 38 let r =
25 Removed: Recovery.evaluate_readiness ~elapsed:e ~recommended:(Recovery.days 4)
39 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 48)
40 Added: ~recommended:(Recovery.hours 48)
26 41 in
27 42 Alcotest.(check bool) "ready" true (Recovery.is_ready r) );
28 Removed: ( "within the window is not Ready",
43 Added: ( "short of the window means still recovering",
29 44 `Quick,
30 45 fun () ->
31 Removed: let e = Recovery.elapsed ~since:(ts 0) ~now:(ts 3600) in
32 Removed: let r =
33 Removed: Recovery.evaluate_readiness ~elapsed:e ~recommended:(Recovery.days 4)
46 Added: match
47 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 24)
48 Added: ~recommended:(Recovery.hours 48)
49 Added: with
50 Added: | Recovery.Ready -> Alcotest.fail "expected Recovering"
51 Added: | Recovery.Recovering { rested; recommended } ->
52 Added: Alcotest.(check int) "rested" 86_400 (secs rested);
53 Added: Alcotest.(check int) "recommended" 172_800 (secs recommended) );
54 Added: ]
55 Added:
56 Added: let clearance_tests =
57 Added: [
58 Added: ( "recovery earns a clearance",
59 Added: `Quick,
60 Added: fun () ->
61 Added: match Recovery.clear Recovery.Ready with
62 Added: | Some c -> (
63 Added: match Recovery.basis c with
64 Added: | Recovery.Recovered -> ()
65 Added: | Recovery.Overridden _ -> Alcotest.fail "expected Recovered")
66 Added: | None -> Alcotest.fail "expected a clearance" );
67 Added: ( "an incomplete recovery earns none",
68 Added: `Quick,
69 Added: fun () ->
70 Added: let recovering =
71 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12)
72 Added: ~recommended:(Recovery.hours 48)
34 73 in
35 Removed: Alcotest.(check bool) "not ready" false (Recovery.is_ready r) );
74 Added: Alcotest.(check bool)
75 Added: "refused" true
76 Added: (Option.is_none (Recovery.clear recovering)) );
77 Added: ( "an override is granted but leaves its reason on the record",
78 Added: `Quick,
79 Added: fun () ->
80 Added: let recovering =
81 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12)
82 Added: ~recommended:(Recovery.hours 48)
83 Added: in
84 Added: let c = Recovery.override recovering ~reason:"travelling tomorrow" in
85 Added: match Recovery.basis c with
86 Added: | Recovery.Overridden { rested; recommended; reason } ->
87 Added: Alcotest.(check int) "rested" 43_200 (secs rested);
88 Added: Alcotest.(check int) "recommended" 172_800 (secs recommended);
89 Added: Alcotest.(check string) "reason" "travelling tomorrow" reason
90 Added: | Recovery.Recovered -> Alcotest.fail "expected Overridden" );
91 Added: ( "overriding when already recovered is simply recovered",
92 Added: `Quick,
93 Added: fun () ->
94 Added: match
95 Added: Recovery.basis (Recovery.override Recovery.Ready ~reason:"impatient")
96 Added: with
97 Added: | Recovery.Recovered -> ()
98 Added: | Recovery.Overridden _ ->
99 Added: Alcotest.fail "no recovery was outstanding to override" );
36 100 ]
37 101
38 102 let suite =
39 103 [
40 Removed: ("recovery.elapsed", elapsed_tests); ("recovery.readiness", readiness_tests);
104 Added: ("recovery.time", time_tests);
105 Added: ("recovery.readiness", readiness_tests);
106 Added: ("recovery.clearance", clearance_tests);
41 107 ]