View raw

1 type error = 2 | Malformed of string 3 | Unknown_exercise of string 4 | Unknown_routine of string 5 | Unknown_workout_name of { routine : string; workout : string } 6 | Replay_rejected of string 7 | Unsupported_version of int 8 9 (* The layout this codec writes and reads. A stored record names its version on 10 its first line, so a future layout change can tell old rows from new. A row 11 with no version line predates the tag and carries this same layout. Bump this 12 when the layout changes, and keep a reader for every version still on disk. *) 13 let current_version = 1 14 15 let pp_error ppf = function 16 | Malformed detail -> Format.fprintf ppf "malformed record: %s" detail 17 | Unknown_exercise id -> Format.fprintf ppf "unknown exercise %S" id 18 | Unknown_routine name -> Format.fprintf ppf "unknown routine %S" name 19 | Unknown_workout_name { routine; workout } -> 20 Format.fprintf ppf "routine %S has no workout %S" routine workout 21 | Replay_rejected detail -> 22 Format.fprintf ppf "stored record rejected: %s" detail 23 | Unsupported_version n -> 24 Format.fprintf ppf 25 "record version %d is newer than this codec's version %d" n 26 current_version 27 28 (* --- extension and outcome codes --- *) 29 30 let extension_code : Evidence.Stimulus.extension -> string = function 31 | Forced_reps reps -> Printf.sprintf "forced=%d" reps 32 | Negatives reps -> Printf.sprintf "negatives=%d" reps 33 | Static_hold seconds -> Printf.sprintf "static=%d" seconds 34 35 let extension_of_code code = 36 match String.split_on_char '=' code with 37 | [ "forced"; reps ] -> ( 38 match int_of_string_opt reps with 39 | Some reps -> Ok (Evidence.Stimulus.Forced_reps reps) 40 | None -> Error (Malformed (Printf.sprintf "extension %S" code))) 41 | [ "negatives"; reps ] -> ( 42 match int_of_string_opt reps with 43 | Some reps -> Ok (Evidence.Stimulus.Negatives reps) 44 | None -> Error (Malformed (Printf.sprintf "extension %S" code))) 45 | [ "static"; seconds ] -> ( 46 match int_of_string_opt seconds with 47 | Some seconds -> Ok (Evidence.Stimulus.Static_hold seconds) 48 | None -> Error (Malformed (Printf.sprintf "extension %S" code))) 49 | _ -> Error (Malformed (Printf.sprintf "extension %S" code)) 50 51 let encode_outcome (outcome : Evidence.Stimulus.outcome) = 52 match outcome with 53 | Positive_failure -> "positive" 54 | Beyond_failure (first, rest) -> 55 "beyond:" ^ String.concat "," (List.map extension_code (first :: rest)) 56 57 let decode_outcome text = 58 if String.equal text "positive" then Ok Evidence.Stimulus.Positive_failure 59 else 60 match String.split_on_char ':' text with 61 | [ "beyond"; codes ] -> ( 62 match String.split_on_char ',' codes with 63 | [] | [ "" ] -> Error (Malformed "empty beyond-failure") 64 | first :: rest -> 65 let ( let* ) = Result.bind in 66 let* first = extension_of_code first in 67 let rec go acc = function 68 | [] -> Ok (List.rev acc) 69 | code :: tl -> ( 70 match extension_of_code code with 71 | Ok e -> go (e :: acc) tl 72 | Error _ as e -> e) 73 in 74 let* rest = go [] rest in 75 Ok (Evidence.Stimulus.Beyond_failure (first, rest))) 76 | _ -> Error (Malformed (Printf.sprintf "outcome %S" text)) 77 78 (* --- efforts and stimuli --- *) 79 80 let encode_effort effort = 81 let exercise = Evidence.Stimulus.Effort.exercise effort in 82 Printf.sprintf "%s\t%h\t%d\t%s" 83 (Exercise.id exercise :> string) 84 (Evidence.Stimulus.Effort.load effort) 85 (Evidence.Stimulus.Effort.reps effort) 86 (encode_outcome (Evidence.Stimulus.Effort.outcome effort)) 87 88 let encode_stimulus ~slot stimulus = 89 match Evidence.Stimulus.delivery stimulus with 90 | Evidence.Stimulus.Single effort -> 91 Printf.sprintf "stimulus\t%d\tsingle\t%s" slot (encode_effort effort) 92 | Evidence.Stimulus.Pair { first; second } -> 93 Printf.sprintf "stimulus\t%d\tpair\t%s\t%s" slot (encode_effort first) 94 (encode_effort second) 95 96 let ( let* ) = Result.bind 97 98 let decode_effort = function 99 | [ exid; load; reps; outcome ] -> ( 100 match Exercise.find_id exid with 101 | None -> Error (Unknown_exercise exid) 102 | Some exercise -> ( 103 match (float_of_string_opt load, int_of_string_opt reps) with 104 | Some load, Some reps -> 105 let* outcome = decode_outcome outcome in 106 Result.map_error 107 (fun e -> 108 Malformed 109 (Format.asprintf "effort %a" 110 Evidence.Stimulus.Effort.pp_error e)) 111 (Evidence.Stimulus.Effort.make ~exercise ~load ~reps ~outcome) 112 | _ -> Error (Malformed "effort load/reps"))) 113 | _ -> Error (Malformed "effort arity") 114 115 (* Decode a stimulus and the slot it filled. The current format prefixes the 116 slot. A record from before hito stored slots omits it, and replays in 117 performance order through [add_stimulus] instead. *) 118 let decode_stimulus fields = 119 let delivery = function 120 | "single" :: rest -> 121 let* effort = decode_effort rest in 122 Ok (Evidence.Stimulus.make (Evidence.Stimulus.Single effort)) 123 | "pair" :: rest -> ( 124 match rest with 125 | [ a; b; c; d; e; f; g; h ] -> 126 let* first = decode_effort [ a; b; c; d ] in 127 let* second = decode_effort [ e; f; g; h ] in 128 Ok 129 (Evidence.Stimulus.make 130 (Evidence.Stimulus.Pair { first; second })) 131 | _ -> Error (Malformed "pair arity")) 132 | _ -> Error (Malformed "stimulus delivery") 133 in 134 match fields with 135 | slot :: rest when Option.is_some (int_of_string_opt slot) -> 136 let* stimulus = delivery rest in 137 Ok (Some (Option.get (int_of_string_opt slot)), stimulus) 138 | rest -> 139 let* stimulus = delivery rest in 140 Ok (None, stimulus) 141 142 (* --- clearance --- *) 143 144 let encode_clearance clearance = 145 match Recovery.basis clearance with 146 | Recovery.Recovered -> "clearance\trecovered" 147 | Recovery.Overridden { rested; recommended } -> 148 Printf.sprintf "clearance\toverridden\t%d\t%d" 149 (Recovery.duration_to_seconds rested) 150 (Recovery.duration_to_seconds recommended) 151 152 (* Recovery exposes only hour and day constructors, so derive an arbitrary 153 second-valued duration through elapsed: since=0, now=secs yields exactly 154 [secs] seconds. *) 155 let seconds_duration secs = 156 Recovery.elapsed 157 ~since:(Recovery.timestamp_of_unix_seconds 0) 158 ~now:(Recovery.timestamp_of_unix_seconds secs) 159 160 (* A clearance cannot be minted directly. It is derived from a readiness. 161 [Recovered] comes from a [Ready] reading. An override needs a [Recovering] 162 reading carrying the stored durations. *) 163 let decode_clearance = function 164 | [ "recovered" ] -> Ok (Option.get (Recovery.clear Recovery.Ready)) 165 | [ "overridden"; rested; recommended ] -> ( 166 match (int_of_string_opt rested, int_of_string_opt recommended) with 167 | Some rested, Some recommended -> 168 Ok 169 (Recovery.override 170 (Recovery.Recovering 171 { 172 rested = seconds_duration rested; 173 recommended = seconds_duration recommended; 174 })) 175 | _ -> Error (Malformed "clearance durations")) 176 | _ -> Error (Malformed "clearance basis") 177 178 (* --- workout --- *) 179 180 let encode_workout ~routine_name workout = 181 let buf = Buffer.create 256 in 182 let line s = 183 Buffer.add_string buf s; 184 Buffer.add_char buf '\n' 185 in 186 line (Printf.sprintf "version\t%d" current_version); 187 line (Printf.sprintf "routine\t%s" routine_name); 188 line 189 (Printf.sprintf "prescription\t%s" 190 (Prescription.Workout.name (Evidence.Workout.prescription workout))); 191 line (encode_clearance (Evidence.Workout.clearance workout)); 192 line 193 (Printf.sprintf "started\t%s" 194 (Timestamp.encode (Evidence.Workout.started_at workout))); 195 (match Evidence.Workout.ended_at workout with 196 | Some ended -> line (Printf.sprintf "ended\t%s" (Timestamp.encode ended)) 197 | None -> ()); 198 List.iter 199 (fun (slot, stimulus) -> line (encode_stimulus ~slot stimulus)) 200 (Evidence.Workout.performed workout); 201 Buffer.contents buf 202 203 (* A fully-parsed header, gathered before any replay. Parsing produces this 204 typed record. Replay consumes it. Keeping the two phases apart removes the 205 mutable accumulators the old single pass needed. *) 206 type parsed = { 207 routine_name : string; 208 prescription_name : string; 209 clearance : Recovery.clearance; 210 started_at : Recovery.timestamp; 211 ended_at : Recovery.timestamp option; 212 stimuli : (int option * Evidence.Stimulus.t) list; 213 (** Performance order, each with the slot it filled when known. *) 214 } 215 216 (* Fields accumulated while folding over lines. Every field is optional until 217 its line is seen. [stimuli] is reversed for O(1) prepend and flipped once at 218 the end. *) 219 type acc = { 220 a_routine : string option; 221 a_prescription : string option; 222 a_clearance : Recovery.clearance option; 223 a_started : Recovery.timestamp option; 224 a_ended : Recovery.timestamp option; 225 a_stimuli_rev : (int option * Evidence.Stimulus.t) list; 226 } 227 228 let empty_acc = 229 { 230 a_routine = None; 231 a_prescription = None; 232 a_clearance = None; 233 a_started = None; 234 a_ended = None; 235 a_stimuli_rev = []; 236 } 237 238 let parse_timestamp label text = 239 match Timestamp.decode text with 240 | Ok timestamp -> Ok timestamp 241 | Error _ -> Error (Malformed label) 242 243 (* Fold one line into the accumulator. A line either sets a header field or 244 appends a stimulus. Anything else is malformed. The version line, when 245 present, must be one this codec knows; a record from a newer codec is refused 246 rather than misread. *) 247 let step acc = function 248 | [ "version"; v ] -> ( 249 match int_of_string_opt v with 250 | Some n when n = current_version -> Ok acc 251 | Some n -> Error (Unsupported_version n) 252 | None -> Error (Malformed (Printf.sprintf "version %S" v))) 253 | [ "routine"; name ] -> Ok { acc with a_routine = Some name } 254 | [ "prescription"; name ] -> Ok { acc with a_prescription = Some name } 255 | "clearance" :: rest -> 256 let* c = decode_clearance rest in 257 Ok { acc with a_clearance = Some c } 258 | [ "started"; text ] -> 259 let* timestamp = parse_timestamp "started timestamp" text in 260 Ok { acc with a_started = Some timestamp } 261 | [ "ended"; text ] -> 262 let* timestamp = parse_timestamp "ended timestamp" text in 263 Ok { acc with a_ended = Some timestamp } 264 | "stimulus" :: rest -> 265 let* s = decode_stimulus rest in 266 Ok { acc with a_stimuli_rev = s :: acc.a_stimuli_rev } 267 | fields -> Error (Malformed (String.concat "|" fields)) 268 269 (* Parse the whole text into a typed header, or fail. This checks the mandatory 270 fields here, so replay never sees a partial record. *) 271 let parse text = 272 let lines = 273 String.split_on_char '\n' text 274 |> List.filter (fun l -> String.length l > 0) 275 |> List.map (fun l -> String.split_on_char '\t' l) 276 in 277 let rec fold acc = function 278 | [] -> Ok acc 279 | line :: tl -> 280 let* acc = step acc line in 281 fold acc tl 282 in 283 let* acc = fold empty_acc lines in 284 match (acc.a_routine, acc.a_prescription, acc.a_clearance, acc.a_started) with 285 | Some routine_name, Some prescription_name, Some clearance, Some started_at 286 -> 287 Ok 288 { 289 routine_name; 290 prescription_name; 291 clearance; 292 started_at; 293 ended_at = acc.a_ended; 294 stimuli = List.rev acc.a_stimuli_rev; 295 } 296 | _ -> Error (Malformed "missing header fields") 297 298 (* Resolve the prescription named in the header against the catalog. *) 299 let resolve_prescription ~find_routine parsed = 300 match find_routine parsed.routine_name with 301 | None -> Error (Unknown_routine parsed.routine_name) 302 | Some routine -> ( 303 match 304 List.find_opt 305 (fun w -> 306 String.equal (Prescription.Workout.name w) parsed.prescription_name) 307 (Prescription.Routine.workouts routine) 308 with 309 | Some prescription -> Ok prescription 310 | None -> 311 Error 312 (Unknown_workout_name 313 { 314 routine = parsed.routine_name; 315 workout = parsed.prescription_name; 316 })) 317 318 (* Drive the core constructors from a typed header. The core raises on a 319 rejected stimulus. Catch it once here and report it as data corruption. *) 320 let replay ~prescription parsed = 321 try 322 let workout = 323 Evidence.Workout.start prescription ~clearance:parsed.clearance 324 ~started_at:parsed.started_at 325 in 326 let workout = 327 List.fold_left 328 (fun workout (slot, stimulus) -> 329 match slot with 330 | Some slot -> Evidence.Workout.record_at workout ~slot stimulus 331 | None -> Evidence.Workout.add_stimulus workout stimulus) 332 workout parsed.stimuli 333 in 334 let workout = 335 match parsed.ended_at with 336 | Some ended_at -> Evidence.Workout.finish workout ~ended_at 337 | None -> workout 338 in 339 Ok workout 340 with Evidence.Workout.Invalid err -> 341 Error (Replay_rejected (Format.asprintf "%a" Evidence.Workout.pp_error err)) 342 343 let decode_workout ~find_routine text = 344 let* parsed = parse text in 345 let* prescription = resolve_prescription ~find_routine parsed in 346 replay ~prescription parsed 347 348 (* --- subjective feedback --- *) 349 350 let level_code : Feedback.level -> string = function 351 | Worse_than_usual -> "worse" 352 | As_usual -> "usual" 353 | Better_than_usual -> "better" 354 355 let level_of_code = function 356 | "worse" | "1" | "2" -> Ok Feedback.Worse_than_usual 357 | "usual" | "3" -> Ok Feedback.As_usual 358 | "better" | "4" | "5" -> Ok Feedback.Better_than_usual 359 | code -> Error (Malformed (Printf.sprintf "level %S" code)) 360 361 let signal_code : Feedback.signal -> string = function 362 | Motivation l -> "motivation:" ^ level_code l 363 | Difficulty l -> "difficulty:" ^ level_code l 364 365 let signal_of_code code = 366 let leveled make rest = 367 let* l = level_of_code rest in 368 Ok (make l) 369 in 370 match String.split_on_char ':' code with 371 | [ "motivation"; l ] -> leveled (fun l -> Feedback.Motivation l) l 372 | [ "difficulty"; l ] -> leveled (fun l -> Feedback.Difficulty l) l 373 | _ -> Error (Malformed (Printf.sprintf "signal %S" code)) 374 375 let encode_feedback report = 376 let reported_at = Timestamp.encode (Feedback.reported_at report) in 377 let signals = 378 String.concat "," (List.map signal_code (Feedback.signals report)) 379 in 380 Printf.sprintf "%s\t%s" reported_at signals 381 382 let decode_feedback text = 383 match String.split_on_char '\t' text with 384 | [ timestamp; signals ] -> ( 385 match Timestamp.decode timestamp with 386 | Error _ -> Error (Malformed "feedback timestamp") 387 | Ok reported_at -> 388 let codes = 389 String.split_on_char ',' signals 390 |> List.filter (fun s -> String.length s > 0) 391 in 392 let rec go acc = function 393 | [] -> Ok (List.rev acc) 394 | code :: tl -> ( 395 match signal_of_code code with 396 | Ok s -> go (s :: acc) tl 397 | Error _ as e -> e) 398 in 399 let* signals = go [] codes in 400 Ok (Feedback.make ~reported_at signals)) 401 | _ -> Error (Malformed "feedback arity") 402