[OCaml] High Intensity Training Online
1
open Hito_app
2
module WI = Workout_import
3
4
type error = Malformed of string | Unknown_exercise of string | Invalid_time
5
6
let pp_error ppf = function
7
| Malformed detail -> Format.fprintf ppf "malformed import record: %s" detail
8
| Unknown_exercise id -> Format.fprintf ppf "unknown exercise %S" id
9
| Invalid_time -> Format.fprintf ppf "ended_at precedes started_at"
10
11
(* --- length-prefixed writer ---
12
13
The writer emits every field as its byte length in decimal, a colon, then
14
its raw bytes. The length frames the bytes exactly. A field may therefore
15
hold any byte, including newlines, commas, and colons, and the reader never
16
has to escape or guess where a field ends. *)
17
18
module Writer = struct
19
let create () = Buffer.create 256
20
21
let field buf (s : string) =
22
Buffer.add_string buf (string_of_int (String.length s));
23
Buffer.add_char buf ':';
24
Buffer.add_string buf s
25
26
let string = field
27
let int buf n = field buf (string_of_int n)
28
29
(* [%h] renders a float as a round-trippable hexadecimal literal. *)
30
let float buf x = field buf (Printf.sprintf "%h" x)
31
let timestamp buf ts = field buf (Timestamp.encode ts)
32
33
(* An option is one field carrying its presence, then the value when present.
34
A one-field option lead keeps the framing regular. *)
35
let option write buf = function
36
| None -> field buf "0"
37
| Some v ->
38
field buf "1";
39
write buf v
40
41
(* A list is a count field followed by that many encoded elements. *)
42
let list write buf xs =
43
int buf (List.length xs);
44
List.iter (write buf) xs
45
46
let contents = Buffer.contents
47
end
48
49
(* --- length-prefixed reader --- *)
50
51
module Reader = struct
52
type t = { text : string; mutable pos : int }
53
54
exception Bad of string
55
exception Unknown of string (* an exercise id not in the catalog *)
56
57
let create text = { text; pos = 0 }
58
59
let field r =
60
let len = String.length r.text in
61
(* Read the decimal length up to the colon. *)
62
let start = r.pos in
63
let rec scan i =
64
if i >= len then raise (Bad "truncated field length")
65
else if r.text.[i] = ':' then i
66
else scan (i + 1)
67
in
68
let colon = scan start in
69
let n =
70
match int_of_string_opt (String.sub r.text start (colon - start)) with
71
| Some n when n >= 0 -> n
72
| _ -> raise (Bad "bad field length")
73
in
74
let value_start = colon + 1 in
75
if value_start + n > len then raise (Bad "field overruns input");
76
r.pos <- value_start + n;
77
String.sub r.text value_start n
78
79
let string = field
80
81
let int r =
82
match int_of_string_opt (field r) with
83
| Some n -> n
84
| None -> raise (Bad "expected int")
85
86
let float r =
87
match float_of_string_opt (field r) with
88
| Some x -> x
89
| None -> raise (Bad "expected float")
90
91
let timestamp r =
92
match Timestamp.decode (field r) with
93
| Ok ts -> ts
94
| Error _ -> raise (Bad "bad timestamp")
95
96
let option read r =
97
match field r with
98
| "0" -> None
99
| "1" -> Some (read r)
100
| _ -> raise (Bad "bad option lead")
101
102
let list read r =
103
let n = int r in
104
if n < 0 then raise (Bad "negative list length");
105
let rec go acc i =
106
if i = 0 then List.rev acc else go (read r :: acc) (i - 1)
107
in
108
go [] n
109
110
(* Resolve an exercise id string into an [Exercise.id]. The id type is
111
private, so a valid id can only come from the catalog. The reader reports a
112
stored id the catalog no longer names as [Unknown]. *)
113
let exercise_id r =
114
let raw = field r in
115
match Exercise.find_id raw with
116
| Some e -> Exercise.id e
117
| None -> raise (Unknown raw)
118
119
(* Resolve an exercise id string into the catalog entry itself. *)
120
let exercise r =
121
let raw = field r in
122
match Exercise.find_id raw with Some e -> e | None -> raise (Unknown raw)
123
124
let ensure_consumed r =
125
if r.pos <> String.length r.text then raise (Bad "trailing bytes")
126
127
(* Run a reader, turning framing faults and unknown ids into typed errors. *)
128
let run text f =
129
let r = create text in
130
match f r with
131
| v ->
132
ensure_consumed r;
133
Ok v
134
| exception Bad detail -> Error (Malformed detail)
135
| exception Unknown id -> Error (Unknown_exercise id)
136
end
137
138
(* --- observation wire fields ---
139
140
A promoted observation always serializes as the same four fields, in the same
141
order: an exercise id, a load, a rep count, and a performed time. The snapshot
142
path keeps the id as a raw [Exercise.id]. The record path resolves it to a
143
catalog [Exercise.t] and rebuilds the effort at [Positive_failure]. Both share
144
this framing, so the byte layout lives here once. *)
145
146
module Observation_wire = struct
147
(* Write the four fields from already-extracted primitive parts. *)
148
let write buf ~exercise_id ~load ~reps ~performed_at =
149
Writer.string buf exercise_id;
150
Writer.float buf load;
151
Writer.int buf reps;
152
Writer.timestamp buf performed_at
153
154
(* Read the four fields in order, resolving the leading exercise id with
155
[resolve]. The snapshot path resolves to an [Exercise.id]. The record path
156
resolves to a catalog [Exercise.t]. The remaining three fields are shared. *)
157
let read r ~resolve ~build =
158
let exercise = resolve r in
159
let load = Reader.float r in
160
let reps = Reader.int r in
161
let performed_at = Reader.timestamp r in
162
build ~exercise ~load ~reps ~performed_at
163
end
164
165
(* --- source set --- *)
166
167
let set_type_code : WI.source_set_type -> string = function
168
| Normal -> "N"
169
| Failure -> "F"
170
| Dropset -> "D"
171
172
let set_type_of_code = function
173
| "N" -> WI.Normal
174
| "F" -> WI.Failure
175
| "D" -> WI.Dropset
176
| other -> raise (Reader.Bad (Printf.sprintf "set type %S" other))
177
178
let write_source_set buf (s : WI.source_set) =
179
Writer.int buf s.row;
180
Writer.string buf s.exercise_name;
181
Writer.int buf s.set_index;
182
Writer.string buf (set_type_code s.set_type);
183
Writer.float buf s.weight_kg;
184
Writer.int buf s.reps;
185
Writer.option Writer.int buf s.superset_id
186
187
let read_source_set r : WI.source_set =
188
let row = Reader.int r in
189
let exercise_name = Reader.string r in
190
let set_index = Reader.int r in
191
let set_type = set_type_of_code (Reader.string r) in
192
let weight_kg = Reader.float r in
193
let reps = Reader.int r in
194
let superset_id = Reader.option Reader.int r in
195
{ row; exercise_name; set_index; set_type; weight_kg; reps; superset_id }
196
197
(* --- warning --- *)
198
199
let write_warning buf (w : WI.warning) =
200
Writer.int buf w.row;
201
Writer.option Writer.string buf w.exercise_name;
202
match w.kind with
203
| Warmup_dropped -> Writer.string buf "warmup"
204
| Unsupported_row detail ->
205
Writer.string buf "unsupported";
206
Writer.string buf detail
207
208
let read_warning r : WI.warning =
209
let row = Reader.int r in
210
let exercise_name = Reader.option Reader.string r in
211
let kind =
212
match Reader.string r with
213
| "warmup" -> WI.Warmup_dropped
214
| "unsupported" -> WI.Unsupported_row (Reader.string r)
215
| other -> raise (Reader.Bad (Printf.sprintf "warning kind %S" other))
216
in
217
{ row; exercise_name; kind }
218
219
(* --- mapping --- *)
220
221
let write_mapping buf ((name, id) : string * Exercise.id) =
222
Writer.string buf name;
223
Writer.string buf (id :> string)
224
225
let read_mapping r =
226
let name = Reader.string r in
227
let id = Reader.exercise_id r in
228
(name, id)
229
230
(* --- observation snapshot --- *)
231
232
let write_observation buf (o : WI.observation_snapshot) =
233
Observation_wire.write buf
234
~exercise_id:(o.exercise_id :> string)
235
~load:o.load ~reps:o.reps ~performed_at:o.performed_at
236
237
(* Read one observation snapshot, resolving its exercise id to an [Exercise.id]
238
through the catalog. *)
239
let read_observation_snapshot r : WI.observation_snapshot =
240
Observation_wire.read r ~resolve:Reader.exercise_id
241
~build:(fun ~exercise ~load ~reps ~performed_at ->
242
{ WI.exercise_id = exercise; load; reps; performed_at })
243
244
(* --- promotion snapshot --- *)
245
246
let write_promotion buf (p : WI.promoted_snapshot) =
247
Writer.string buf p.title;
248
Writer.timestamp buf p.started_at;
249
Writer.timestamp buf p.ended_at;
250
Writer.list write_observation buf p.observations;
251
Writer.list Writer.int buf p.source_rows;
252
Writer.option Writer.string buf p.normal_confirmation;
253
Writer.int buf p.revision
254
255
(* --- workout snapshot --- *)
256
257
let write_workout buf (w : WI.workout_snapshot) =
258
Writer.string buf (WI.workout_id_to_string w.id);
259
Writer.string buf w.title;
260
Writer.timestamp buf w.started_at;
261
Writer.timestamp buf w.ended_at;
262
Writer.list write_source_set buf w.sets;
263
Writer.list write_mapping buf w.mappings;
264
Writer.option Writer.string buf w.acknowledgement;
265
Writer.int buf w.revision;
266
Writer.option write_promotion buf w.promotion
267
268
(* --- snapshot --- *)
269
270
let encode_snapshot (s : WI.snapshot) =
271
let buf = Writer.create () in
272
Writer.string buf (WI.batch_id_to_string s.batch_id);
273
Writer.string buf s.source;
274
Writer.string buf s.fingerprint;
275
Writer.timestamp buf s.imported_at;
276
Writer.list write_workout buf s.workouts;
277
Writer.list write_warning buf s.warnings;
278
Writer.contents buf
279
280
(* The reader builds the parser-free {!WI.snapshot}, keeping every exercise id
281
as a raw string. Restoration through {!WI.restore} resolves the ids and
282
rebuilds the opaque batch. The use case therefore reports an unknown id or an
283
invalid time once. *)
284
let read_promotion r : WI.promoted_snapshot =
285
let title = Reader.string r in
286
let started_at = Reader.timestamp r in
287
let ended_at = Reader.timestamp r in
288
let observations = Reader.list read_observation_snapshot r in
289
let source_rows = Reader.list Reader.int r in
290
let normal_confirmation = Reader.option Reader.string r in
291
let revision = Reader.int r in
292
{
293
WI.title;
294
started_at;
295
ended_at;
296
observations;
297
source_rows;
298
normal_confirmation;
299
revision;
300
}
301
302
let read_workout r : WI.workout_snapshot =
303
let id = WI.workout_id (Reader.string r) in
304
let title = Reader.string r in
305
let started_at = Reader.timestamp r in
306
let ended_at = Reader.timestamp r in
307
let sets = Reader.list read_source_set r in
308
let mappings = Reader.list read_mapping r in
309
let acknowledgement = Reader.option Reader.string r in
310
let revision = Reader.int r in
311
let promotion = Reader.option read_promotion r in
312
{
313
WI.id;
314
title;
315
started_at;
316
ended_at;
317
sets;
318
mappings;
319
acknowledgement;
320
revision;
321
promotion;
322
}
323
324
let read_snapshot r : WI.snapshot =
325
let batch_id = WI.batch_id (Reader.string r) in
326
let source = Reader.string r in
327
let fingerprint = Reader.string r in
328
let imported_at = Reader.timestamp r in
329
let workouts = Reader.list read_workout r in
330
let warnings = Reader.list read_warning r in
331
{ WI.batch_id; source; fingerprint; imported_at; workouts; warnings }
332
333
let restore_error : WI.restore_error -> error = function
334
| WI.Unknown_exercise id -> Unknown_exercise id
335
| WI.Invalid_time -> Invalid_time
336
| WI.Invalid_effort e ->
337
Malformed
338
(Format.asprintf "observation %a" Evidence.Stimulus.Effort.pp_error e)
339
340
let decode_snapshot ~find_exercise text =
341
match Reader.run text read_snapshot with
342
| Error _ as e -> e
343
| Ok snapshot -> (
344
match WI.restore ~find_exercise snapshot with
345
| Ok batch -> Ok batch
346
| Error e -> Error (restore_error e))
347
348
(* --- imported record --- *)
349
350
let encode_record (rec_ : Repository.imported_record) =
351
let buf = Writer.create () in
352
Writer.string buf (WI.batch_id_to_string rec_.batch_id);
353
Writer.string buf (WI.workout_id_to_string rec_.workout_id);
354
Writer.int buf rec_.revision;
355
Writer.string buf rec_.title;
356
Writer.timestamp buf rec_.started_at;
357
Writer.timestamp buf rec_.ended_at;
358
(* An observation is exercise id, load, reps, performed time: a promoted
359
outcome is always Positive_failure, so the outcome is implicit. *)
360
Writer.list
361
(fun buf obs ->
362
let effort = Evidence.Observation.effort obs in
363
Observation_wire.write buf
364
~exercise_id:
365
(Exercise.id (Evidence.Stimulus.Effort.exercise effort) :> string)
366
~load:(Evidence.Stimulus.Effort.load effort)
367
~reps:(Evidence.Stimulus.Effort.reps effort)
368
~performed_at:(Evidence.Observation.performed_at obs))
369
buf rec_.observations;
370
(* Provenance. The batch and workout ids repeat the record's own; storing them
371
keeps the provenance self-contained after a decode. *)
372
Writer.string buf (WI.batch_id_to_string rec_.provenance.batch_id);
373
Writer.string buf (WI.workout_id_to_string rec_.provenance.workout_id);
374
Writer.list Writer.int buf rec_.provenance.source_rows;
375
Writer.option Writer.string buf rec_.provenance.normal_confirmation;
376
Writer.int buf rec_.provenance.revision;
377
Writer.contents buf
378
379
(* Read the record's framing. Observations resolve their exercise through the
380
catalog during the read, so an unknown id surfaces as [Unknown_exercise]. *)
381
let read_record r : Repository.imported_record =
382
let batch_id = Reader.string r in
383
let workout_id = Reader.string r in
384
let revision = Reader.int r in
385
let title = Reader.string r in
386
let started_at = Reader.timestamp r in
387
let ended_at = Reader.timestamp r in
388
let observations =
389
Reader.list
390
(fun r ->
391
Observation_wire.read r ~resolve:Reader.exercise
392
~build:(fun ~exercise ~load ~reps ~performed_at ->
393
let effort =
394
match
395
Evidence.Stimulus.Effort.make ~exercise ~load ~reps
396
~outcome:Evidence.Stimulus.Positive_failure
397
with
398
| Ok effort -> effort
399
| Error e ->
400
raise
401
(Reader.Bad
402
(Format.asprintf "observation %a"
403
Evidence.Stimulus.Effort.pp_error e))
404
in
405
Evidence.Observation.make ~exercise ~effort ~performed_at))
406
r
407
in
408
let prov_batch_id = Reader.string r in
409
let prov_workout_id = Reader.string r in
410
let source_rows = Reader.list Reader.int r in
411
let normal_confirmation = Reader.option Reader.string r in
412
let prov_revision = Reader.int r in
413
{
414
Repository.batch_id = WI.batch_id batch_id;
415
workout_id = WI.workout_id workout_id;
416
revision;
417
title;
418
started_at;
419
ended_at;
420
observations;
421
provenance =
422
{
423
WI.batch_id = WI.batch_id prov_batch_id;
424
workout_id = WI.workout_id prov_workout_id;
425
source_rows;
426
normal_confirmation;
427
revision = prov_revision;
428
};
429
}
430
431
let decode_record text =
432
match Reader.run text read_record with
433
| Error _ as e -> e
434
| Ok record ->
435
if
436
Recovery.timestamp_to_unix_seconds record.Repository.ended_at
437
< Recovery.timestamp_to_unix_seconds record.Repository.started_at
438
then Error Invalid_time
439
else Ok record
440