feat implement Progression, and register every suite

The only module that judges. Prescriptions state what to do, the logbook states what happened, and this decides what it means. Progress follows HD1's own definition — an increase in reps, weight or both, with even one rep significant. Fewer reps at a heavier load still counts, since forcing yourself back down the rep range is exactly what raising the load is for. A stall is progress ceasing entirely for two weeks, and its remedy is taken verbatim from the source: one week off to replenish reserves, then eliminate one set from each workout and add an extra day off between them. That the remedy only ever subtracts is structural — there is a single constructor and it has no additive form. HD1 is emphatic that the urge to raise volume and frequency on a stall must be "avoided at all costs". Load adequacy respects a subtlety we had previously missed: the trigger is twelve reps, not merely exceeding the prescribed window. A 6-10 prescription is not breached at eleven. Beyond twelve the set ends in cardiorespiratory failure before the muscle does, which is why the load must rise, and it rises into a 10-20% window rather than to a computed figure — "or any amount necessary", and what a bar can be loaded to is not a training principle. This replaces an invented fixed 2.5kg increment. Diagnostics are where the log's honesty pays off. Extending every stimulus and beginning a workout on an overridden clearance are both HD1 causes of overtraining, and both are readable only because the record was allowed to admit them. They are observations about history, never constraints on it. No volume metric exists anywhere in core. Tonnage is not a Heavy Duty measure, and a count of stimuli would only restate the prescription. All eleven suites are now registered: 118 Alcotests.

Commit
bff1cc073f1cdd2fb189b0f053b6884b148c5d36
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/core/dune
index 30021d5e..b0030a9e 100644..100644
@@ -15,5 +15,6 @@
15 15 routine
16 16 stimulus
17 17 entry
18 Removed: logbook)
18 Added: logbook
19 Added: progression)
19 20 (wrapped false))
lib/core/progression.ml
index 059799f5..992280f3 100644..100644
@@ -1,30 +1,143 @@
1 Removed: (* Blank slate: stub only. Interface (progression.mli) is the design surface. *)
1 Added: let beats ~previous ~current =
2 Added: let load =
3 Added: Units.Weight.compare
4 Added: (Stimulus.Movement.load current)
5 Added: (Stimulus.Movement.load previous)
6 Added: in
7 Added: let reps =
8 Added: Units.Reps.compare
9 Added: (Stimulus.Movement.reps current)
10 Added: (Stimulus.Movement.reps previous)
11 Added: in
12 Added: load > 0 || (load = 0 && reps > 0)
2 13
3 Removed: type t = Progressing | Stalled
14 Added: type assessment = Progressing | Stalled
15 Added: type error = Insufficient_data
4 16
5 Removed: let equal _ _ = failwith "TODO"
6 Removed: let pp _ _ = failwith "TODO"
17 Added: let pp_assessment ppf = function
18 Added: | Progressing -> Format.pp_print_string ppf "progressing"
19 Added: | Stalled -> Format.pp_print_string ppf "stalled"
7 20
8 Removed: type sample = Set.Working.performance
9 Removed: type error = Insufficient_data
21 Added: let pp_error ppf Insufficient_data =
22 Added: Format.pp_print_string ppf "not enough record to judge"
10 23
11 Removed: let evaluate ~history:_ = failwith "TODO"
12 Removed: let beats ~previous:_ ~current:_ = failwith "TODO"
24 Added: let stall_window = Recovery.days 14
25 Added: let rec last = function [] -> None | [ x ] -> Some x | _ :: rest -> last rest
13 26
14 Removed: type band = Below_range | In_range | Above_range
27 Added: (* The most recent observation that improved on the one before it. *)
28 Added: let last_advance observations =
29 Added: let rec scan found = function
30 Added: | (previous : Logbook.observation) :: (current :: _ as rest) ->
31 Added: let found =
32 Added: if beats ~previous:previous.movement ~current:current.movement then
33 Added: Some current
34 Added: else found
35 Added: in
36 Added: scan found rest
37 Added: | _ -> found
38 Added: in
39 Added: scan None observations
15 40
16 Removed: let classify ~target_reps:_ _ = failwith "TODO"
41 Added: let elapsed_between (a : Logbook.observation) (b : Logbook.observation) =
42 Added: Recovery.duration_to_seconds
43 Added: (Recovery.elapsed ~since:a.performed_at ~now:b.performed_at)
17 44
18 Removed: type target =
19 Removed: | Add_reps of { load : Units.Weight.t; min_reps : Units.Reps.t }
20 Removed: | Add_load of { min_load : Units.Weight.t; reps : Units.Reps.t }
45 Added: let assess observations =
46 Added: let window = Recovery.duration_to_seconds stall_window in
47 Added: match (observations, last observations) with
48 Added: | ([] | [ _ ]), _ | _, None -> Error Insufficient_data
49 Added: | first :: _, Some latest -> (
50 Added: match last_advance observations with
51 Added: | Some advance ->
52 Added: if elapsed_between advance latest >= window then Ok Stalled
53 Added: else Ok Progressing
54 Added: | None ->
55 Added: (* Never advanced. Only a stall once the record is long enough to say
56 Added: so; otherwise two sessions a day apart would condemn the routine. *)
57 Added: if elapsed_between first latest >= window then Ok Stalled
58 Added: else Error Insufficient_data)
21 59
22 Removed: type guidance = { band : band; next : target } [@@warning "-69"]
60 Added: type remedy =
61 Added: | Lay_off_then_reduce of {
62 Added: lay_off : Recovery.duration;
63 Added: drop_stimuli_per_workout : int;
64 Added: extra_rest : Recovery.duration;
65 Added: }
23 66
24 Removed: let guide ~target_reps:_ _ = failwith "TODO"
67 Added: let remedy = function
68 Added: | Progressing -> None
69 Added: | Stalled ->
70 Added: Some
71 Added: (Lay_off_then_reduce
72 Added: {
73 Added: lay_off = Recovery.days 7;
74 Added: drop_stimuli_per_workout = 1;
75 Added: extra_rest = Recovery.days 1;
76 Added: })
25 77
26 Removed: type 'a prescribed = { value : 'a; evidence : sample list; status : t }
27 Removed: [@@warning "-69"]
78 Added: let pp_remedy ppf
79 Added: (Lay_off_then_reduce { lay_off; drop_stimuli_per_workout; extra_rest }) =
80 Added: Format.fprintf ppf
81 Added: "take %a off, then drop %d stimulus per workout and add %a between workouts"
82 Added: Recovery.pp_duration lay_off drop_stimuli_per_workout Recovery.pp_duration
83 Added: extra_rest
28 84
29 Removed: let prescribe ~target_reps:_ ~evidence:_ = failwith "TODO"
30 Removed: let volume _ = failwith "TODO"
85 Added: let load_increase_trigger =
86 Added: match Units.Reps.of_int 12 with Ok r -> r | Error _ -> assert false
87 Added:
88 Added: let load_increase ~current =
89 Added: let scale factor =
90 Added: Units.Weight.of_kg (Units.Weight.to_kg current *. factor)
91 Added: |> Result.value ~default:current
92 Added: in
93 Added: (scale 1.10, scale 1.20)
94 Added:
95 Added: type load_verdict =
96 Added: | Hold
97 Added: | Increase of Units.Weight.t * Units.Weight.t
98 Added: | Too_heavy
99 Added:
100 Added: let judge_load ~rep_range movement =
101 Added: let reps = Stimulus.Movement.reps movement in
102 Added: if Units.Reps.compare reps load_increase_trigger >= 0 then
103 Added: let low, high = load_increase ~current:(Stimulus.Movement.load movement) in
104 Added: Increase (low, high)
105 Added: else if Units.Reps.compare reps (Units.Rep_range.min rep_range) < 0 then
106 Added: Too_heavy
107 Added: else Hold
108 Added:
109 Added: let pp_load_verdict ppf = function
110 Added: | Hold -> Format.pp_print_string ppf "hold the load"
111 Added: | Increase (low, high) ->
112 Added: Format.fprintf ppf "raise the load to %a-%a" Units.Weight.pp low
113 Added: Units.Weight.pp high
114 Added: | Too_heavy -> Format.pp_print_string ppf "load is too heavy for the window"
115 Added:
116 Added: type diagnostic =
117 Added: | Extensions_on_every_stimulus of int
118 Added: | Trained_under_recovered of int
119 Added:
120 Added: let all_extended entry =
121 Added: match Entry.stimuli entry with
122 Added: | [] -> false
123 Added: | stimuli -> List.for_all Stimulus.is_extended stimuli
124 Added:
125 Added: let under_recovered entry =
126 Added: match Recovery.basis (Entry.clearance entry) with
127 Added: | Recovery.Recovered -> false
128 Added: | Recovery.Overridden _ -> true
129 Added:
130 Added: let diagnose entries =
131 Added: let count predicate = List.length (List.filter predicate entries) in
132 Added: let extended = count all_extended in
133 Added: let overridden = count under_recovered in
134 Added: let when_present n d = if n > 0 then [ d n ] else [] in
135 Added: when_present extended (fun n -> Extensions_on_every_stimulus n)
136 Added: @ when_present overridden (fun n -> Trained_under_recovered n)
137 Added:
138 Added: let pp_diagnostic ppf = function
139 Added: | Extensions_on_every_stimulus n ->
140 Added: Format.fprintf ppf "%d workout(s) extended every stimulus beyond failure"
141 Added: n
142 Added: | Trained_under_recovered n ->
143 Added: Format.fprintf ppf "%d workout(s) begun before recovery was complete" n
lib/core/progression.mli
index e46e82a7..c20cf50c 100644..100644
@@ -1,56 +1,90 @@
1 Removed: (** Assessment: what the logged evidence says, and what it prescribes next.
1 Added: (** Assessment: what the record says, and what follows from it.
2 2
3 Removed: This is the only module that relates performance to plan. Prescriptions do
4 Removed: not judge, and the logbook does not interpret. *)
3 Added: The only module that judges. Prescriptions state what to do, the logbook
4 Added: states what happened, and this decides what that means.
5 5
6 Removed: type t = Progressing | Stalled
6 Added: Its central asymmetry is Mentzer's: when progress halts, the answer is never
7 Added: more work. HD1 is explicit that the urge to raise volume and frequency on a
8 Added: stall "must be kept in check and avoided at all costs", so no remedy here
9 Added: can add either. That is structural — there is only one remedy, and it
10 Added: subtracts. *)
7 11
8 Removed: val equal : t -> t -> bool
9 Removed: val pp : Format.formatter -> t -> unit
12 Added: (** {1 Progress} *)
10 13
11 Removed: type sample = Set.Working.performance
12 Removed: (** One working set's performance — the unit of evidence. *)
14 Added: val beats : previous:Stimulus.Movement.t -> current:Stimulus.Movement.t -> bool
15 Added: (** Whether the later performance is an advance: more load, or the same load for
16 Added: more reps. HD1 counts "an increase in reps, weight or both", and holds that
17 Added: even a single extra rep is significant. Fewer reps at a heavier load still
18 Added: counts — that is exactly what raising the load is meant to produce. *)
13 19
20 Added: type assessment = Progressing | Stalled
14 21 type error = Insufficient_data
15 22
16 Removed: val evaluate : history:sample list -> (t, error) result
17 Removed: (** [history] oldest-first. *)
23 Added: val pp_assessment : Format.formatter -> assessment -> unit
24 Added: val pp_error : Format.formatter -> error -> unit
18 25
19 Removed: val beats : previous:sample -> current:sample -> bool
20 Removed: (** Progressive overload: heavier, or equal load for more reps. *)
26 Added: val stall_window : Recovery.duration
27 Added: (** Two weeks. Progress must have ceased *entirely* for this long before the
28 Added: routine is judged to be overtraining. *)
21 29
22 Removed: (** {1 Judging a performance against a band} *)
30 Added: val assess : Logbook.observation list -> (assessment, error) result
31 Added: (** Oldest first. [Error Insufficient_data] while the record is too short or too
32 Added: recent to distinguish a stall from an ordinary gap between advances. *)
23 33
24 Removed: type band =
25 Removed: | Below_range (** Load too heavy. *)
26 Removed: | In_range
27 Removed: | Above_range (** Load too light. *)
34 Added: (** {1 The one sanctioned response to a stall} *)
28 35
29 Removed: val classify : target_reps:Units.Rep_range.t -> sample -> band
36 Added: type remedy =
37 Added: | Lay_off_then_reduce of {
38 Added: lay_off : Recovery.duration; (** One week, to replenish reserves. *)
39 Added: drop_stimuli_per_workout : int;
40 Added: (** Eliminate one set from each workout. *)
41 Added: extra_rest : Recovery.duration;
42 Added: (** An extra day off between workouts thereafter. *)
43 Added: }
30 44
31 Removed: (** What to aim for next. *)
32 Removed: type target =
33 Removed: | Add_reps of { load : Units.Weight.t; min_reps : Units.Reps.t }
34 Removed: | Add_load of { min_load : Units.Weight.t; reps : Units.Reps.t }
35 Removed: (** Band exceeded: raise load, reset to the band's bottom. *)
45 Added: val remedy : assessment -> remedy option
46 Added: (** [Some] only when stalled. There is no remedy that adds work. *)
36 47
37 Removed: type guidance = { band : band; next : target }
38 Removed: (** How one performance landed, and what follows from it. *)
48 Added: val pp_remedy : Format.formatter -> remedy -> unit
39 49
40 Removed: val guide : target_reps:Units.Rep_range.t -> sample -> guidance
50 Added: (** {1 Whether the load still calibrates the window} *)
41 51
42 Removed: (** {1 Prescribing from evidence}
52 Added: val load_increase_trigger : Units.Reps.t
53 Added: (** Twelve reps. Note the slack: a window of 6-10 is not breached at 11 — HD1
54 Added: raises the load once twelve are reached, because beyond twelve the set ends
55 Added: in cardiorespiratory failure before the muscle does. *)
43 56
44 Removed: Anything prescribed carries the evidence it came from and the status that
45 Removed: evidence showed. *)
57 Added: val load_increase : current:Units.Weight.t -> Units.Weight.t * Units.Weight.t
58 Added: (** The 10-20% window to move into. A window rather than a figure: HD1 says "or
59 Added: any amount necessary" to be forced back into the rep range, and what a bar
60 Added: can actually be loaded to is not a training principle. *)
46 61
47 Removed: type 'a prescribed = { value : 'a; evidence : sample list; status : t }
62 Added: type load_verdict =
63 Added: | Hold (** The load still places failure inside the window. *)
64 Added: | Increase of Units.Weight.t * Units.Weight.t
65 Added: (** Twelve reps reached: move into this window. *)
66 Added: | Too_heavy (** Failure arrived below the window's floor. *)
48 67
49 Removed: val prescribe :
50 Removed: target_reps:Units.Rep_range.t -> evidence:sample list -> target prescribed
68 Added: val judge_load :
69 Added: rep_range:Units.Rep_range.t -> Stimulus.Movement.t -> load_verdict
51 70
52 Removed: (** {1 Metrics} *)
71 Added: val pp_load_verdict : Format.formatter -> load_verdict -> unit
53 72
54 Removed: val volume : sample list -> float
55 Removed: (** Σ (load × reps), in kilogram-reps. A diagnostic, never a target: under Heavy
56 Removed: Duty rising volume is a warning, not an achievement. *)
73 Added: (** {1 Diagnostics}
74 Added:
75 Added: Habits that HD1 identifies as causes of overtraining. These are observations
76 Added: about the record, never constraints on it — the log's job is to say what
77 Added: happened, so these can only be read back out of it afterwards. *)
78 Added:
79 Added: type diagnostic =
80 Added: | Extensions_on_every_stimulus of int
81 Added: (** Workouts in which every stimulus went beyond failure. HD1 uses
82 Added: extensions occasionally and "never with every exercise of a workout".
83 Added: *)
84 Added: | Trained_under_recovered of int
85 Added: (** Workouts begun on an overridden clearance. *)
86 Added:
87 Added: val diagnose : Entry.t list -> diagnostic list
88 Added: (** Only the habits actually present. *)
89 Added:
90 Added: val pp_diagnostic : Format.formatter -> diagnostic -> unit
test/dune
index def9150e..6250a2e6 100644..100644
@@ -1,5 +1,4 @@
1 Removed: ; Suites are added here as the HD1 rebuild lands each module (Task 12 completes
2 Removed: ; the re-registration).
1 Added: ; Every module of the HD1 rebuild now has a registered suite.
3 2
4 3 (test
5 4 (name test_hito)
@@ -14,5 +13,6 @@
14 13 test_stimulus
15 14 test_recovery
16 15 test_entry
17 Removed: test_logbook)
16 Added: test_logbook
17 Added: test_progression)
18 18 (libraries hito.core alcotest))
test/test_hito.ml
index b5d76fc2..4b5facc9 100644..100644
@@ -1,9 +1,11 @@
1 Removed: (** Test harness entry point. Suites are registered here as the HD1 rebuild
2 Removed: lands each module. *)
1 Added: (** Test harness entry point.
3 2
3 Added: Suites are ordered as the modules layer: vocabulary, then what is
4 Added: prescribed, then what was performed, then what it means. *)
5 Added:
4 6 let () =
5 7 Alcotest.run "hito"
6 8 (Test_units.suite @ Test_muscle.suite @ Test_exercise.suite
7 9 @ Test_prescription.suite @ Test_workout_prescription.suite
8 10 @ Test_routine.suite @ Test_stimulus.suite @ Test_recovery.suite
9 Removed: @ Test_entry.suite @ Test_logbook.suite)
11 Added: @ Test_entry.suite @ Test_logbook.suite @ Test_progression.suite)
test/test_progression.ml
index 49a2cdcf..25018252 100644..100644
@@ -1,164 +1,276 @@
1 Removed: (** Unit tests for {!Progression}, authored against progression.mli.
1 Added: (** Unit tests for {!Progression}. *)
2 2
3 Removed: NOTE: Not yet registered in the main runner; wired in during implementation
4 Removed: (Task 8). progression.mli is the source of truth over these assertions.
3 Added: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
5 4
6 Removed: Under test: an improving history is [Progressing]; a flat/declining history
7 Removed: is [Stalled]; the [beats] relation captures progressive overload. *)
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
8 9
9 Removed: let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
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 secs = Recovery.duration_to_seconds
15 Added: let range lo hi = ok (Units.Rep_range.make ~min:(reps lo) ~max:(reps hi))
16 Added: let six_to_ten = range 6 10
10 17
11 Removed: let sample kg reps : Progression.sample =
12 Removed: { load = ok (Units.Weight.of_kg kg); reps = ok (Units.Reps.of_int reps) }
18 Added: let move ?(outcome = Stimulus.Positive_failure) ?(id = "laterals") load r =
19 Added: Stimulus.Movement.make ~exercise:(get id) ~load:(kg load) ~reps:(reps r)
20 Added: ~outcome
13 21
14 Removed: let progression_tests =
22 Added: (* An observation is a public record, so evidence can be written directly. *)
23 Added: let seen ~on load r : Logbook.observation =
24 Added: { exercise = get "laterals"; movement = move load r; performed_at = day on }
25 Added:
26 Added: let beats_tests =
15 27 [
16 Removed: ( "increasing weight progresses",
28 Added: ( "one more rep at the same load is progress",
17 29 `Quick,
18 30 fun () ->
19 Removed: let history = [ sample 80.0 6; sample 82.5 6 ] in
20 31 Alcotest.(check bool)
21 Removed: "progressing" true
22 Removed: (Progression.evaluate ~history = Ok Progressing) );
23 Removed: ( "more reps at same weight progresses",
32 Added: "8 -> 9" true
33 Added: (Progression.beats ~previous:(move 12. 8) ~current:(move 12. 9)) );
34 Added: ( "more load is progress",
24 35 `Quick,
25 36 fun () ->
26 37 Alcotest.(check bool)
27 Removed: "beats" true
28 Removed: (Progression.beats ~previous:(sample 80.0 6) ~current:(sample 80.0 7))
29 Removed: );
30 Removed: ( "flat performance stalls",
38 Added: "12 -> 14kg" true
39 Added: (Progression.beats ~previous:(move 12. 8) ~current:(move 14. 8)) );
40 Added: ( "more load at fewer reps is still progress",
31 41 `Quick,
32 42 fun () ->
33 Removed: let history = [ sample 80.0 6; sample 80.0 6 ] in
43 Added: (* Raising the load is meant to force you back down the rep range. *)
34 44 Alcotest.(check bool)
35 Removed: "stalled" true
36 Removed: (Progression.evaluate ~history = Ok Stalled) );
37 Removed: ( "insufficient data is an error, not a status",
45 Added: "12kg x 12 -> 14kg x 7" true
46 Added: (Progression.beats ~previous:(move 12. 12) ~current:(move 14. 7)) );
47 Added: ( "repeating a performance is not progress",
38 48 `Quick,
39 49 fun () ->
40 50 Alcotest.(check bool)
41 Removed: "single sample errors" true
42 Removed: (Progression.evaluate ~history:[ sample 80.0 6 ]
43 Removed: = Error Progression.Insufficient_data) );
51 Added: "identical" false
52 Added: (Progression.beats ~previous:(move 12. 8) ~current:(move 12. 8)) );
53 Added: ( "going backwards is not progress",
54 Added: `Quick,
55 Added: fun () ->
56 Added: Alcotest.(check bool)
57 Added: "fewer reps" false
58 Added: (Progression.beats ~previous:(move 12. 8) ~current:(move 12. 7));
59 Added: Alcotest.(check bool)
60 Added: "more reps at a lighter load" false
61 Added: (Progression.beats ~previous:(move 12. 8) ~current:(move 10. 12)) );
44 62 ]
45 63
46 Removed: let suite = [ ("progression.evaluate", progression_tests) ]
47 Removed:
48 Removed: let band_tests =
49 Removed: let target_reps =
50 Removed: Units.Rep_range.make
51 Removed: ~min:(ok (Units.Reps.of_int 6))
52 Removed: ~max:(ok (Units.Reps.of_int 8))
53 Removed: |> ok
54 Removed: in
64 Added: let assess_tests =
55 65 [
56 Removed: ( "below the band is Below_range",
66 Added: ( "the stall window is two weeks",
57 67 `Quick,
58 68 fun () ->
69 Added: Alcotest.(check int) "14 days" 1_209_600 (secs Progression.stall_window)
70 Added: );
71 Added: ( "too little record to judge",
72 Added: `Quick,
73 Added: fun () ->
59 74 Alcotest.(check bool)
60 Removed: "below" true
61 Removed: (Progression.classify ~target_reps (sample 80.0 4)
62 Removed: = Progression.Below_range) );
63 Removed: ( "within the band is In_range",
75 Added: "nothing" true
76 Added: (Result.is_error (Progression.assess []));
77 Added: Alcotest.(check bool)
78 Added: "a single session" true
79 Added: (Result.is_error (Progression.assess [ seen ~on:1 12. 8 ]));
80 Added: Alcotest.(check bool)
81 Added: "flat but recent" true
82 Added: (Result.is_error
83 Added: (Progression.assess [ seen ~on:1 12. 8; seen ~on:3 12. 8 ])) );
84 Added: ( "a recent advance is progress",
64 85 `Quick,
65 86 fun () ->
66 87 Alcotest.(check bool)
67 Removed: "in range" true
68 Removed: (Progression.classify ~target_reps (sample 80.0 7)
69 Removed: = Progression.In_range) );
70 Removed: ( "above the band is Above_range",
88 Added: "progressing" true
89 Added: (Progression.assess
90 Added: [ seen ~on:1 12. 8; seen ~on:3 12. 9; seen ~on:5 12. 9 ]
91 Added: = Ok Progression.Progressing) );
92 Added: ( "no advance for two weeks is a stall",
71 93 `Quick,
72 94 fun () ->
73 95 Alcotest.(check bool)
74 Removed: "above" true
75 Removed: (Progression.classify ~target_reps (sample 80.0 9)
76 Removed: = Progression.Above_range) );
96 Added: "stalled" true
97 Added: (Progression.assess
98 Added: [ seen ~on:1 12. 8; seen ~on:8 12. 8; seen ~on:16 12. 8 ]
99 Added: = Ok Progression.Stalled) );
100 Added: ( "an advance more than two weeks ago is also a stall",
101 Added: `Quick,
102 Added: fun () ->
103 Added: Alcotest.(check bool)
104 Added: "stalled" true
105 Added: (Progression.assess
106 Added: [ seen ~on:1 12. 8; seen ~on:3 12. 9; seen ~on:20 12. 9 ]
107 Added: = Ok Progression.Stalled) );
77 108 ]
78 109
79 Removed: let guide_tests =
80 Removed: let target_reps =
81 Removed: Units.Rep_range.make
82 Removed: ~min:(ok (Units.Reps.of_int 6))
83 Removed: ~max:(ok (Units.Reps.of_int 8))
84 Removed: |> ok
85 Removed: in
110 Added: let remedy_tests =
86 111 [
87 Removed: ( "under the band top: next target adds a rep at the same load",
112 Added: ( "progress calls for no change",
88 113 `Quick,
89 114 fun () ->
90 Removed: let g = Progression.guide ~target_reps (sample 80.0 6) in
91 Removed: match g.next with
92 Removed: | Progression.Add_reps { load; min_reps } ->
93 Removed: Alcotest.(check (float 0.0001))
94 Removed: "same load" 80.0 (Units.Weight.to_kg load);
95 Removed: Alcotest.(check int) "one more rep" 7 (Units.Reps.to_int min_reps)
96 Removed: | Add_load _ -> Alcotest.fail "expected Add_reps" );
97 Removed: ( "at the band top: next target adds load and resets to the bottom",
115 Added: Alcotest.(check bool)
116 Added: "none" true
117 Added: (Option.is_none (Progression.remedy Progression.Progressing)) );
118 Added: ( "a stall calls for a lay-off, then less work and more rest",
98 119 `Quick,
99 120 fun () ->
100 Removed: let g = Progression.guide ~target_reps (sample 80.0 8) in
101 Removed: match g.next with
102 Removed: | Progression.Add_load { min_load; reps } ->
103 Removed: Alcotest.(check bool)
104 Removed: "load increased" true
105 Removed: (Units.Weight.to_kg min_load > 80.0);
121 Added: match Progression.remedy Progression.Stalled with
122 Added: | Some
123 Added: (Progression.Lay_off_then_reduce
124 Added: { lay_off; drop_stimuli_per_workout; extra_rest }) ->
125 Added: Alcotest.(check int) "one week off" 604_800 (secs lay_off);
106 126 Alcotest.(check int)
107 Removed: "resets to band bottom" 6 (Units.Reps.to_int reps)
108 Removed: | Add_reps _ -> Alcotest.fail "expected Add_load" );
109 Removed: ( "past the band: next target adds load too",
127 Added: "one fewer stimulus per workout" 1 drop_stimuli_per_workout;
128 Added: Alcotest.(check int) "one extra rest day" 86_400 (secs extra_rest)
129 Added: | None -> Alcotest.fail "a stall must have a remedy" );
130 Added: ( "no remedy ever adds work",
110 131 `Quick,
111 132 fun () ->
112 Removed: let g = Progression.guide ~target_reps (sample 80.0 9) in
113 Removed: match g.next with
114 Removed: | Progression.Add_load _ -> ()
115 Removed: | Add_reps _ -> Alcotest.fail "expected Add_load" );
133 Added: (* Structural: the only remedy subtracts. This asserts the intent. *)
134 Added: match Progression.remedy Progression.Stalled with
135 Added: | Some (Progression.Lay_off_then_reduce { drop_stimuli_per_workout; _ })
136 Added: ->
137 Added: Alcotest.(check bool)
138 Added: "subtracts volume" true
139 Added: (drop_stimuli_per_workout > 0)
140 Added: | None -> Alcotest.fail "expected a remedy" );
116 141 ]
117 142
118 Removed: let prescribe_tests =
119 Removed: let target_reps =
120 Removed: Units.Rep_range.make
121 Removed: ~min:(ok (Units.Reps.of_int 6))
122 Removed: ~max:(ok (Units.Reps.of_int 8))
123 Removed: |> ok
124 Removed: in
143 Added: let load_tests =
125 144 [
126 Removed: ( "prescribe carries the evidence it was derived from",
145 Added: ( "the load rises at twelve reps",
127 146 `Quick,
128 147 fun () ->
129 Removed: let evidence = [ sample 80.0 6; sample 82.5 6 ] in
130 Removed: let p = Progression.prescribe ~target_reps ~evidence in
131 148 Alcotest.(check int)
132 Removed: "evidence retained" 2
133 Removed: (List.length p.Progression.evidence) );
134 Removed: ( "prescribe's status matches evaluate",
149 Added: "trigger" 12
150 Added: (Units.Reps.to_int Progression.load_increase_trigger) );
151 Added: ( "the increase is a 10-20% window",
135 152 `Quick,
136 153 fun () ->
137 Removed: let evidence = [ sample 80.0 6; sample 82.5 6 ] in
138 Removed: let p = Progression.prescribe ~target_reps ~evidence in
154 Added: let low, high = Progression.load_increase ~current:(kg 100.) in
155 Added: Alcotest.(check (float 0.001)) "110kg" 110. (Units.Weight.to_kg low);
156 Added: Alcotest.(check (float 0.001)) "120kg" 120. (Units.Weight.to_kg high) );
157 Added: ( "inside the window the load holds",
158 Added: `Quick,
159 Added: fun () ->
139 160 Alcotest.(check bool)
140 Removed: "progressing" true
141 Removed: (p.Progression.status = Progression.Progressing) );
161 Added: "8 reps of 6-10" true
162 Added: (Progression.judge_load ~rep_range:six_to_ten (move 12. 8)
163 Added: = Progression.Hold) );
164 Added: ( "eleven reps is slack, not yet a load increase",
165 Added: `Quick,
166 Added: fun () ->
167 Added: (* HD1 raises the load at twelve, not merely above the window. *)
168 Added: Alcotest.(check bool)
169 Added: "11 reps of 6-10 holds" true
170 Added: (Progression.judge_load ~rep_range:six_to_ten (move 12. 11)
171 Added: = Progression.Hold) );
172 Added: ( "twelve reps calls for more load",
173 Added: `Quick,
174 Added: fun () ->
175 Added: match Progression.judge_load ~rep_range:six_to_ten (move 100. 12) with
176 Added: | Progression.Increase (low, high) ->
177 Added: Alcotest.(check (float 0.001)) "110kg" 110. (Units.Weight.to_kg low);
178 Added: Alcotest.(check (float 0.001))
179 Added: "120kg" 120. (Units.Weight.to_kg high)
180 Added: | _ -> Alcotest.fail "expected Increase" );
181 Added: ( "failing below the window means the load is too heavy",
182 Added: `Quick,
183 Added: fun () ->
184 Added: Alcotest.(check bool)
185 Added: "4 reps of 6-10" true
186 Added: (Progression.judge_load ~rep_range:six_to_ten (move 12. 4)
187 Added: = Progression.Too_heavy) );
142 188 ]
143 189
144 Removed: let volume_tests =
190 Added: (* Entries, for the diagnostics. *)
191 Added: let cleared = Option.get (Recovery.clear Recovery.Ready)
192 Added: let workout = List.hd (Routine.workouts Routine.ideal_routine)
193 Added:
194 Added: let entry ~clearance ~stimuli =
195 Added: List.fold_left
196 Added: (fun e s -> ok (Entry.add_stimulus e s))
197 Added: (Entry.start workout ~clearance ~started_at:(day 1))
198 Added: stimuli
199 Added:
200 Added: let laterals ?outcome load r =
201 Added: ok (Stimulus.make (Stimulus.Single (move ?outcome ~id:"laterals" load r)))
202 Added:
203 Added: let extended =
204 Added: Stimulus.Beyond_failure (Stimulus.Forced_reps, [ Stimulus.Negatives ])
205 Added:
206 Added: let diagnostic_tests =
145 207 [
146 Removed: ( "volume sums load times reps",
208 Added: ( "a clean record yields no diagnostics",
147 209 `Quick,
148 210 fun () ->
149 Removed: let v = Progression.volume [ sample 80.0 6; sample 20.0 12 ] in
150 Removed: Alcotest.(check (float 0.0001)) "volume" 720.0 v );
151 Removed: ( "empty history has zero volume",
211 Added: let e = entry ~clearance:cleared ~stimuli:[ laterals 12. 8 ] in
212 Added: Alcotest.(check int) "none" 0 (List.length (Progression.diagnose [ e ]))
213 Added: );
214 Added: ( "extending every stimulus is flagged",
152 215 `Quick,
153 216 fun () ->
154 Removed: Alcotest.(check (float 0.0001)) "zero" 0.0 (Progression.volume []) );
217 Added: let e =
218 Added: entry ~clearance:cleared ~stimuli:[ laterals ~outcome:extended 12. 8 ]
219 Added: in
220 Added: match Progression.diagnose [ e ] with
221 Added: | [ Progression.Extensions_on_every_stimulus n ] ->
222 Added: Alcotest.(check int) "one workout" 1 n
223 Added: | _ -> Alcotest.fail "expected the extension diagnostic" );
224 Added: ( "extending only some stimuli is not flagged",
225 Added: `Quick,
226 Added: fun () ->
227 Added: let e =
228 Added: entry ~clearance:cleared
229 Added: ~stimuli:[ laterals ~outcome:extended 12. 8; laterals 10. 9 ]
230 Added: in
231 Added: Alcotest.(check int) "none" 0 (List.length (Progression.diagnose [ e ]))
232 Added: );
233 Added: ( "training on an override is flagged",
234 Added: `Quick,
235 Added: fun () ->
236 Added: let recovering =
237 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12)
238 Added: ~recommended:Routine.training_interval
239 Added: in
240 Added: let e =
241 Added: entry
242 Added: ~clearance:(Recovery.override recovering ~reason:"impatient")
243 Added: ~stimuli:[ laterals 12. 8 ]
244 Added: in
245 Added: match Progression.diagnose [ e ] with
246 Added: | [ Progression.Trained_under_recovered n ] ->
247 Added: Alcotest.(check int) "one workout" 1 n
248 Added: | _ -> Alcotest.fail "expected the under-recovery diagnostic" );
249 Added: ( "a stall alongside both habits names both as suspects",
250 Added: `Quick,
251 Added: fun () ->
252 Added: let recovering =
253 Added: Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12)
254 Added: ~recommended:Routine.training_interval
255 Added: in
256 Added: let e =
257 Added: entry
258 Added: ~clearance:(Recovery.override recovering ~reason:"impatient")
259 Added: ~stimuli:[ laterals ~outcome:extended 12. 8 ]
260 Added: in
261 Added: Alcotest.(check int) "both" 2 (List.length (Progression.diagnose [ e ]));
262 Added: Alcotest.(check bool)
263 Added: "and the routine is stalled" true
264 Added: (Progression.assess
265 Added: [ seen ~on:1 12. 8; seen ~on:8 12. 8; seen ~on:16 12. 8 ]
266 Added: = Ok Progression.Stalled) );
155 267 ]
156 268
157 269 let suite =
158 Removed: suite
159 Removed: @ [
160 Removed: ("progression.band", band_tests);
161 Removed: ("progression.guide", guide_tests);
162 Removed: ("progression.prescribe", prescribe_tests);
163 Removed: ("progression.volume", volume_tests);
164 Removed: ]
270 Added: [
271 Added: ("progression.beats", beats_tests);
272 Added: ("progression.assess", assess_tests);
273 Added: ("progression.remedy", remedy_tests);
274 Added: ("progression.load", load_tests);
275 Added: ("progression.diagnostics", diagnostic_tests);
276 Added: ]