[OCaml] High Intensity Training Online
1
module Effort = Evidence.Stimulus.Effort
2
3
let beats ~previous ~current =
4
let load = Float.compare (Effort.load current) (Effort.load previous) in
5
let reps = Int.compare (Effort.reps current) (Effort.reps previous) in
6
load > 0 || (load = 0 && reps > 0)
7
8
(* The mirror of [beats]: [current] is strictly worse than [previous] — less
9
load, or equal load and fewer reps. The mark of decompensation. *)
10
let declines ~previous ~current =
11
let load = Float.compare (Effort.load current) (Effort.load previous) in
12
let reps = Int.compare (Effort.reps current) (Effort.reps previous) in
13
load < 0 || (load = 0 && reps < 0)
14
15
type assessment = Progressing | Stalled | Decompensating
16
type error = Insufficient_data
17
18
let pp_assessment ppf = function
19
| Progressing -> Format.pp_print_string ppf "progressing"
20
| Stalled -> Format.pp_print_string ppf "stalled"
21
| Decompensating -> Format.pp_print_string ppf "decompensating"
22
23
let pp_error ppf Insufficient_data =
24
Format.pp_print_string ppf "not enough record to judge"
25
26
let stall_window = Recovery.days 14
27
let rec last = function [] -> None | [ x ] -> Some x | _ :: rest -> last rest
28
29
(* The most recent observation that improved on the one before it. *)
30
let last_advance observations =
31
let rec scan found = function
32
| (previous : Evidence.Observation.t) :: (current :: _ as rest) ->
33
let found =
34
if
35
beats
36
~previous:(Evidence.Observation.effort previous)
37
~current:(Evidence.Observation.effort current)
38
then Some current
39
else found
40
in
41
scan found rest
42
| _ -> found
43
in
44
scan None observations
45
46
let elapsed_between (a : Evidence.Observation.t) (b : Evidence.Observation.t) =
47
Recovery.duration_to_seconds
48
(Recovery.elapsed
49
~since:(Evidence.Observation.performed_at a)
50
~now:(Evidence.Observation.performed_at b))
51
52
let assess observations =
53
let window = Recovery.duration_to_seconds stall_window in
54
match (observations, last observations) with
55
| ([] | [ _ ]), _ | _, None -> Error Insufficient_data
56
| first :: _, Some latest -> (
57
(* After the window passes without progress, separate a halt from a loss.
58
Strength below the reference is decompensation. Strength that holds at
59
the reference is a stall. The reference is the peak reached (the last
60
advance), or the first observation when the record never advanced. *)
61
let effort = Evidence.Observation.effort in
62
let verdict ~reference =
63
if declines ~previous:(effort reference) ~current:(effort latest) then
64
Decompensating
65
else Stalled
66
in
67
match last_advance observations with
68
| Some advance ->
69
if elapsed_between advance latest >= window then
70
Ok (verdict ~reference:advance)
71
else Ok Progressing
72
| None ->
73
(* Never advanced. Only a stall once the record is long enough to say
74
so. Otherwise two sessions a day apart would condemn the routine. *)
75
if elapsed_between first latest >= window then
76
Ok (verdict ~reference:first)
77
else Error Insufficient_data)
78
79
type remedy =
80
| Lay_off_then_reduce of {
81
lay_off : Recovery.duration;
82
drop_stimuli_per_workout : int;
83
extra_rest : Recovery.duration;
84
}
85
86
let remedy = function
87
| Progressing -> None
88
| Stalled | Decompensating ->
89
Some
90
(Lay_off_then_reduce
91
{
92
lay_off = Recovery.days 7;
93
drop_stimuli_per_workout = 1;
94
extra_rest = Recovery.days 1;
95
})
96
97
let pp_remedy ppf
98
(Lay_off_then_reduce { lay_off; drop_stimuli_per_workout; extra_rest }) =
99
Format.fprintf ppf
100
"take %a off, then drop %d stimulus per workout and add %a between workouts"
101
Recovery.pp_duration lay_off drop_stimuli_per_workout Recovery.pp_duration
102
extra_rest
103
104
let load_increase_trigger = 12
105
let load_increase ~current = (current *. 1.10, current *. 1.20)
106
107
type load_verdict = Hold | Increase of float * float | Too_heavy
108
109
(* The trigger is absolute. The window ceiling never gates the verdict. Only
110
the window floor does. *)
111
let judge_load ~rep_range effort =
112
let reps = Effort.reps effort in
113
let min_reps, _ = Prescription.Rep_range.bounds rep_range in
114
if reps >= load_increase_trigger then
115
let low, high = load_increase ~current:(Effort.load effort) in
116
Increase (low, high)
117
else if reps < min_reps then Too_heavy
118
else Hold
119
120
let pp_load_verdict ppf = function
121
| Hold -> Format.pp_print_string ppf "hold the load"
122
| Increase (low, high) ->
123
Format.fprintf ppf "raise the load to %g-%g kg" low high
124
| Too_heavy -> Format.pp_print_string ppf "load is too heavy for the window"
125
126
type diagnostic =
127
| Excess_volume of int
128
| Extensions_on_every_stimulus of int
129
| Trained_under_recovered of int
130
131
let excess_volume workout =
132
List.length (Evidence.Workout.stimuli workout)
133
> Evidence.Workout.filled_slots workout
134
135
let all_extended workout =
136
match Evidence.Workout.stimuli workout with
137
| [] -> false
138
| stimuli -> List.for_all Evidence.Stimulus.is_extended stimuli
139
140
let under_recovered workout =
141
match Recovery.basis (Evidence.Workout.clearance workout) with
142
| Recovery.Recovered -> false
143
| Recovery.Overridden _ -> true
144
145
let diagnose log =
146
let workouts = Evidence.Log.workouts log in
147
let count predicate = List.length (List.filter predicate workouts) in
148
let excessive = count excess_volume in
149
let extended = count all_extended in
150
let overridden = count under_recovered in
151
let when_present n d = if n > 0 then [ d n ] else [] in
152
when_present excessive (fun n -> Excess_volume n)
153
@ when_present extended (fun n -> Extensions_on_every_stimulus n)
154
@ when_present overridden (fun n -> Trained_under_recovered n)
155
156
let pp_diagnostic ppf = function
157
| Excess_volume n ->
158
Format.fprintf ppf "%d workout(s) recorded more stimuli than prescribed" n
159
| Extensions_on_every_stimulus n ->
160
Format.fprintf ppf "%d workout(s) extended every stimulus beyond failure"
161
n
162
| Trained_under_recovered n ->
163
Format.fprintf ppf "%d workout(s) begun before recovery was complete" n
164
165
type workout_comparison = {
166
exercise : Exercise.t;
167
previous : Evidence.Stimulus.Effort.t;
168
current : Evidence.Stimulus.Effort.t;
169
}
170
171
(* The latest observation strictly before [at]. This prevents a correction from
172
comparing an exercise against itself when the recorded observation shares its
173
workout's start time. Callers scope the observation series by exercise before
174
they call this helper. *)
175
let latest_before ~at observations =
176
observations
177
|> List.filter (fun (observation : Evidence.Observation.t) ->
178
Recovery.duration_to_seconds
179
(Recovery.elapsed
180
~since:(Evidence.Observation.performed_at observation)
181
~now:at)
182
> 0)
183
|> List.sort (fun a b ->
184
Int.compare
185
(Recovery.timestamp_to_unix_seconds
186
(Evidence.Observation.performed_at a))
187
(Recovery.timestamp_to_unix_seconds
188
(Evidence.Observation.performed_at b)))
189
|> last
190
191
(* The latest observation strictly before the workout started. This prevents a
192
correction of the saved workout from comparing an exercise against itself. *)
193
let previous_effort log ~started exercise =
194
Evidence.Log.observations log exercise
195
|> latest_before ~at:started
196
|> Option.map Evidence.Observation.effort
197
198
let workout_comparisons log workout =
199
let started = Evidence.Workout.started_at workout in
200
Evidence.Workout.stimuli workout
201
|> List.concat_map Evidence.Stimulus.efforts
202
|> List.fold_left
203
(fun (seen, comparisons) current ->
204
let exercise = Evidence.Stimulus.Effort.exercise current in
205
if List.exists (Exercise.equal exercise) seen then (seen, comparisons)
206
else
207
let comparison =
208
Option.map
209
(fun previous -> { exercise; previous; current })
210
(previous_effort log ~started exercise)
211
in
212
(exercise :: seen, Option.to_list comparison @ comparisons))
213
([], [])
214
|> snd |> List.rev
215
216
let workout_changes log workout =
217
workout_comparisons log workout
218
|> List.filter (fun comparison ->
219
beats ~previous:comparison.previous ~current:comparison.current)
220
221
(* How many distinct exercises in [workout] beat their most recent earlier
222
observation in [log]. Progress is the Heavy Duty signal: an increase in reps
223
or load. This module judges it, so a caller receives a count and never
224
re-derives the rule. An exercise with no earlier observation does not
225
count. *)
226
let workout_progress log workout = List.length (workout_changes log workout)
227
228
(* The most recent observation of [target]'s exercise strictly before [target]
229
was performed, taken from [observations] (any order). This applies the same
230
baseline rule as [previous_effort], but over a bare observation series rather
231
than a log. Imported evidence is not a native workout. A caller can still
232
compare it against the observations before it. *)
233
let previous_observation observations (target : Evidence.Observation.t) =
234
let exercise = Evidence.Observation.exercise target in
235
let performed = Evidence.Observation.performed_at target in
236
observations
237
|> List.filter (fun (o : Evidence.Observation.t) ->
238
Exercise.equal (Evidence.Observation.exercise o) exercise)
239
|> latest_before ~at:performed
240