feat discard drop sets on import instead of blocking

A drop set is not one clean drive to failure. HD1 names only forced reps, negatives, and a static hold past failure -- never a strip set ("Training to Failure", "Negatives"). So on import a Dropset row is discarded the way a warmup is: it neither promotes nor blocks, leaving the set taken to failure, which is the effort the doctrine counts. Remove the Dropset_present blocker; compute blockers, observations, and provenance over the promotable (non-dropset) sets. A workout of only drop sets reduces to No_sets. The parser still keeps the row verbatim for provenance -- the app layer decides its fate, not the adapter. The discard is silent, like a warmup: hito tracks and lets structure teach, it does not lecture.

Commit
71be74327d6ec6f85b4a12b9c9efbef05451c533
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/app/workout_import.ml
index 45b86ca5..c15ac2b7 100644..100644
@@ -172,11 +172,7 @@
172 172
173 173 (* {1 Blockers} *)
174 174
175 Removed: type blocker =
176 Removed: | No_sets
177 Removed: | Dropset_present of int list
178 Removed: | Unmapped_exercise of string
179 Removed: | Normal_sets_unconfirmed
175 Added: type blocker = No_sets | Unmapped_exercise of string | Normal_sets_unconfirmed
180 176
181 177 (* Distinct source exercise names in first-appearance order. *)
182 178 let distinct_names (sets : source_set list) =
@@ -185,31 +181,33 @@
185 181 if List.mem s.exercise_name acc then acc else acc @ [ s.exercise_name ])
186 182 [] sets
187 183
184 Added: (* The sets that promote to canonical evidence. A drop set is not one clean
185 Added: drive to failure — HD1 knows only forced reps, negatives, and a static hold
186 Added: past failure, never a strip set — so a [Dropset] row is non-evidence, dropped
187 Added: the way a warmup is. What remains is the set taken to failure, which is what
188 Added: the doctrine counts. Every downstream decision — blockers, observations,
189 Added: provenance — reads this, so a drop set neither blocks a workout nor records
190 Added: an observation. *)
191 Added: let promotable_sets w =
192 Added: List.filter (fun (s : source_set) -> s.set_type <> Dropset) w.sets
193 Added:
188 194 let blockers w =
189 Removed: if w.sets = [] then [ No_sets ]
195 Added: let sets = promotable_sets w in
196 Added: if sets = [] then [ No_sets ]
190 197 else
191 Removed: let dropset_rows =
192 Removed: List.filter_map
193 Removed: (fun (s : source_set) ->
194 Removed: if s.set_type = Dropset then Some s.row else None)
195 Removed: w.sets
196 Removed: in
197 Removed: let dropset_blocker =
198 Removed: if dropset_rows = [] then [] else [ Dropset_present dropset_rows ]
199 Removed: in
200 198 let unmapped =
201 Removed: distinct_names w.sets
199 Added: distinct_names sets
202 200 |> List.filter (fun name -> not (List.mem_assoc name w.mappings))
203 201 |> List.map (fun name -> Unmapped_exercise name)
204 202 in
205 203 let normal_blocker =
206 204 if
207 Removed: List.exists (fun (s : source_set) -> s.set_type = Normal) w.sets
205 Added: List.exists (fun (s : source_set) -> s.set_type = Normal) sets
208 206 && w.acknowledgement = None
209 207 then [ Normal_sets_unconfirmed ]
210 208 else []
211 209 in
212 Removed: dropset_blocker @ unmapped @ normal_blocker
210 Added: unmapped @ normal_blocker
213 211
214 212 (* {1 Promotion} *)
215 213
@@ -244,18 +242,19 @@
244 242 Evidence.Observation.make ~exercise ~effort ~performed_at:w.ended_at
245 243
246 244 let promote_of_workout b (w : workout) =
245 Added: let sets = promotable_sets w in
247 246 {
248 247 title = w.title;
249 248 started_at = w.started_at;
250 249 ended_at = w.ended_at;
251 Removed: observations = List.map (observe w) w.sets;
250 Added: observations = List.map (observe w) sets;
252 251 provenance =
253 252 {
254 253 batch_id = b.batch_identity;
255 254 workout_id = w.id;
256 Removed: source_rows = List.map (fun (s : source_set) -> s.row) w.sets;
255 Added: source_rows = List.map (fun (s : source_set) -> s.row) sets;
257 256 normal_confirmation =
258 Removed: (if List.exists (fun (s : source_set) -> s.set_type = Normal) w.sets
257 Added: (if List.exists (fun (s : source_set) -> s.set_type = Normal) sets
259 258 then w.acknowledgement
260 259 else None);
261 260 revision = w.revision;
lib/app/workout_import.mli
index 35ebde74..fc252270 100644..100644
@@ -7,9 +7,11 @@
7 7
8 8 The module upholds one invariant on behalf of the core: the core receives
9 9 only confirmed, catalogued, failure-defined evidence. A row reaches the core
10 Removed: only after its exercise is mapped to a catalog entry, its workout-level
11 Removed: acknowledgement is present when required, and no dropset blocks the workout.
12 Removed: Every promoted set becomes one {!Evidence.Observation} recorded at
10 Added: only after its exercise is mapped to a catalog entry and its workout-level
11 Added: acknowledgement is present when required. A drop set is not one clean drive
12 Added: to failure, so a [Dropset] row never promotes and never blocks: it is
13 Added: discarded like a warmup, leaving the set taken to failure — the effort HD1
14 Added: counts. Every promoted set becomes one {!Evidence.Observation} recorded at
13 15 {!Positive_failure}: the export's set type calibrates what confirmation the
14 16 import demands, never the recorded outcome. *)
15 17
@@ -26,7 +28,9 @@
26 28 type source_set_type =
27 29 | Normal (** A working set the export did not mark as taken to failure. *)
28 30 | Failure (** A set the export marked as taken to failure. *)
29 Removed: | Dropset (** A drop set. Not representable as one drive to failure. *)
31 Added: | Dropset
32 Added: (** A drop set. Not one clean drive to failure, so it is discarded on
33 Added: promotion — never an observation, never a blocker. *)
30 34
31 35 type source_set = {
32 36 row : int; (** The source row, retained for provenance. *)
@@ -127,16 +131,18 @@
127 131 (** Why a workout cannot promote yet. *)
128 132 type blocker =
129 133 | No_sets
130 Removed: | Dropset_present of int list (** The source rows of the dropsets. *)
134 Added: (** No promotable sets. A workout of only drop sets reduces to this, since
135 Added: drop sets are discarded. *)
131 136 | Unmapped_exercise of string
132 137 (** An export exercise name without a mapping. *)
133 138 | Normal_sets_unconfirmed
134 139
135 140 val blockers : workout -> blocker list
136 Removed: (** The workout's blockers, in a deterministic order: [No_sets] first, then one
137 Removed: [Dropset_present] with every dropset row, then one [Unmapped_exercise] per
138 Removed: distinct unmapped name in first-appearance order, then
139 Removed: [Normal_sets_unconfirmed]. An empty list means the workout can promote. *)
141 Added: (** The workout's blockers, computed over its promotable sets — the drop sets
142 Added: are excluded, so they never appear here. Deterministic order: [No_sets]
143 Added: first, then one [Unmapped_exercise] per distinct unmapped name in
144 Added: first-appearance order, then [Normal_sets_unconfirmed]. An empty list means
145 Added: the workout can promote. *)
140 146
141 147 (** {1 Promotion} *)
142 148
lib/web/handlers_import.ml
index 3804ff9c..d62552f0 100644..100644
@@ -29,9 +29,6 @@
29 29 (* Phrase one promotion blocker for the view. *)
30 30 let blocker_string : Workout_import.blocker -> string = function
31 31 | Workout_import.No_sets -> "The workout has no promotable sets."
32 Removed: | Workout_import.Dropset_present rows ->
33 Removed: Printf.sprintf "Drop sets cannot be promoted (rows %s)."
34 Removed: (String.concat ", " (List.map string_of_int rows))
35 32 | Workout_import.Unmapped_exercise name ->
36 33 Printf.sprintf "The exercise %S is not mapped to a catalog entry." name
37 34 | Workout_import.Normal_sets_unconfirmed ->
test/test_hevy_csv.ml
index 748059fb..283ded44 100644..100644
@@ -5,8 +5,8 @@
5 5 exact 14-column header in any order, RFC4180 quoting, workout grouping by
6 6 the datetime triple, deterministic workout ids, the local-to-UTC offset, and
7 7 the full family of dropped-row warnings. They also confirm the source text
8 Removed: is retained byte for byte and that a dropset survives as a blocker rather
9 Removed: than silently vanishing. *)
8 Added: is retained byte for byte and that a dropset parses to a set for provenance,
9 Added: which the app layer then discards rather than promoting. *)
10 10
11 11 open Hito_app
12 12 module WI = Workout_import
@@ -50,8 +50,6 @@
50 50
51 51 let show_blocker : WI.blocker -> string = function
52 52 | No_sets -> "no_sets"
53 Removed: | Dropset_present rows ->
54 Removed: "dropset:" ^ String.concat "," (List.map string_of_int rows)
55 53 | Unmapped_exercise name -> "unmapped:" ^ name
56 54 | Normal_sets_unconfirmed -> "normal_unconfirmed"
57 55
@@ -316,21 +314,23 @@
316 314 (String.length reason >= 12 && String.sub reason 0 12 = "unknown set ")
317 315 )
318 316
319 Removed: (* {1 Dropset retained as a blocker} *)
317 Added: (* {1 Dropset parses to a set, but is discarded on promotion} *)
320 318
321 Removed: let dropset_retained_blocker =
322 Removed: ( "a dropset creates a set that blocks its workout, never a silent drop",
319 Added: let dropset_parsed_then_discarded =
320 Added: ( "a dropset parses to a set for provenance, but never promotes or blocks",
323 321 `Quick,
324 322 fun () ->
325 323 let b = doc [ mk ~set_type:"dropset" () ] |> parse in
326 324 let w = List.hd (WI.batch_workouts b) in
327 Removed: Alcotest.(check int) "one set" 1 (List.length (WI.workout_sets w));
328 Removed: let blockers = List.map show_blocker (WI.blockers w) in
329 Removed: Alcotest.(check bool)
330 Removed: "a dropset blocker is present" true
331 Removed: (List.exists
332 Removed: (fun s -> String.length s >= 8 && String.sub s 0 8 = "dropset:")
333 Removed: blockers) )
325 Added: (* The parser keeps the row verbatim: the set survives for provenance. *)
326 Added: Alcotest.(check int)
327 Added: "one set retained" 1
328 Added: (List.length (WI.workout_sets w));
329 Added: (* The app layer discards it, so a dropset-only workout has nothing to
330 Added: promote and reduces to No_sets — not a dropset blocker. *)
331 Added: Alcotest.(check (list string))
332 Added: "discarded dropset leaves only No_sets" [ "no_sets" ]
333 Added: (List.map show_blocker (WI.blockers w)) )
334 334
335 335 (* {1 Superset provenance} *)
336 336
@@ -443,7 +443,7 @@
443 443 unsupported_missing_reps;
444 444 unsupported_distance_only;
445 445 unknown_set_type;
446 Removed: dropset_retained_blocker;
446 Added: dropset_parsed_then_discarded;
447 447 superset_provenance;
448 448 raw_source_exact;
449 449 malformed_csv;
test/test_workout_import.ml
index 835b37cf..8c466e00 100644..100644
@@ -46,8 +46,6 @@
46 46 content without depending on structural equality. *)
47 47 let show_blocker : WI.blocker -> string = function
48 48 | No_sets -> "no_sets"
49 Removed: | Dropset_present rows ->
50 Removed: "dropset:" ^ String.concat "," (List.map string_of_int rows)
51 49 | Unmapped_exercise name -> "unmapped:" ^ name
52 50 | Normal_sets_unconfirmed -> "normal_unconfirmed"
53 51
@@ -134,15 +132,18 @@
134 132 | Error WI.Empty_acknowledgement -> ()
135 133 | _ -> Alcotest.fail "expected Empty_acknowledgement" )
136 134
137 Removed: (* {1 A dropset blocks the whole workout} *)
135 Added: (* {1 A dropset is discarded, never a blocker} *)
138 136
139 Removed: let dropset_blocks_whole_workout =
140 Removed: ( "a dropset blocks the workout even beside a promotable failure set",
137 Added: let dropset_discarded_failure_promotes =
138 Added: ( "a dropset is discarded; the failure set beside it promotes",
141 139 `Quick,
142 140 fun () ->
143 141 let good =
144 142 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
145 143 in
144 Added: (* A drop set for a second, deliberately unmapped exercise. It must not
145 Added: block: it is discarded like a warmup, so its exercise never needs a
146 Added: mapping. *)
146 147 let drop =
147 148 set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 ()
148 149 in
@@ -152,21 +153,30 @@
152 153 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
153 154 (get "squats"))
154 155 in
155 Removed: let b =
156 Removed: ok
157 Removed: (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Leg Press"
158 Removed: (get "leg-presses"))
159 Removed: in
160 156 let w = Option.get (WI.find_workout b (wid "w1")) in
161 Removed: Alcotest.(check bool)
162 Removed: "dropset blocker present" true
163 Removed: (List.exists
164 Removed: (function WI.Dropset_present _ -> true | _ -> false)
165 Removed: (WI.blockers w));
157 Added: Alcotest.(check blocker_list)
158 Added: "no blocker from a discarded dropset" [] (blockers_of w);
166 159 match WI.promote_workout b (wid "w1") with
167 Removed: | Ok (_, WI.Blocked _) -> ()
168 Removed: | _ -> Alcotest.fail "expected Blocked while a dropset is present" )
160 Added: | Ok (_, WI.Promoted p) ->
161 Added: Alcotest.(check int)
162 Added: "only the failure set is observed" 1
163 Added: (List.length p.observations);
164 Added: Alcotest.(check (list int))
165 Added: "provenance keeps only the failure row" [ 1 ]
166 Added: p.provenance.source_rows
167 Added: | _ -> Alcotest.fail "expected Promoted with the dropset discarded" )
169 168
169 Added: let dropset_only_workout_has_no_sets =
170 Added: ( "a workout of only drop sets reduces to No_sets",
171 Added: `Quick,
172 Added: fun () ->
173 Added: let drop =
174 Added: set ~row:1 ~name:"Barbell Squat" ~kind:WI.Dropset ~kg:100. ~reps:8 ()
175 Added: in
176 Added: let b = batch [ workout ~id:"w1" ~sets:[ drop ] ] in
177 Added: let w = Option.get (WI.find_workout b (wid "w1")) in
178 Added: Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) )
179 Added:
170 180 (* {1 No sets is its own blocker} *)
171 181
172 182 let no_sets_blocker =
@@ -237,9 +247,11 @@
237 247 (* {1 Deterministic blocker order} *)
238 248
239 249 let deterministic_blocker_order =
240 Removed: ( "blockers appear in dropset, unmapped (first appearance), normal order",
250 Added: ( "blockers appear in unmapped (first appearance) then normal order",
241 251 `Quick,
242 252 fun () ->
253 Added: (* A discarded dropset contributes no blocker, not even an unmapped one
254 Added: for its exercise. *)
243 255 let drop =
244 256 set ~row:3 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 ()
245 257 in
@@ -252,13 +264,9 @@
252 264 (* Order of sets fixes first-appearance order of names. *)
253 265 let w = workout ~id:"w1" ~sets:[ normal; other; drop ] in
254 266 Alcotest.(check blocker_list)
255 Removed: "dropset, then names by first appearance, then normal"
267 Added: "names by first appearance, then normal; the dropset is absent"
256 268 [
257 Removed: "dropset:3";
258 Removed: "unmapped:Bench Press";
259 Removed: "unmapped:Barbell Squat";
260 Removed: "unmapped:Leg Press";
261 Removed: "normal_unconfirmed";
269 Added: "unmapped:Bench Press"; "unmapped:Barbell Squat"; "normal_unconfirmed";
262 270 ]
263 271 (blockers_of w) )
264 272
@@ -513,7 +521,8 @@
513 521 failure_promotes_without_acknowledgement;
514 522 normal_blocks_until_confirmed;
515 523 empty_acknowledgement_rejected;
516 Removed: dropset_blocks_whole_workout;
524 Added: dropset_discarded_failure_promotes;
525 Added: dropset_only_workout_has_no_sets;
517 526 no_sets_blocker;
518 527 mapping_covers_all_matching_sets;
519 528 every_distinct_name_must_be_mapped;
test/web/import.ml
index 41e5c54d..86157419 100644..100644
@@ -204,9 +204,12 @@
204 204 Alcotest.(check int)
205 205 "one imported record exists" 1
206 206 (List.length (imported repo trainee)) );
207 Removed: ( "a dropset workout stays blocked and writes no record",
207 Added: ( "a dropset-only workout promotes nothing, writing no record",
208 208 `Quick,
209 209 fun () ->
210 Added: (* A drop set is discarded on import — HD1 keeps only the drive to
211 Added: failure. A workout of nothing but a drop set therefore has no
212 Added: promotable set and writes no record. *)
210 213 let c, repo = client_with_repo () in
211 214 let token = signed_in_profile_token c in
212 215 let trainee = the_trainee repo ~username:"lifter" in
@@ -233,7 +236,7 @@
233 236 (List.hd (Workout_import.batch_workouts batch)))
234 237 in
235 238 let review = "/profile/import/" ^ bid in
236 Removed: (* Map the dropset's exercise so only the dropset itself blocks. *)
239 Added: (* Even mapping the discarded set's exercise cannot make it promote. *)
237 240 let t = review_token c review in
238 241 let _ =
239 242 post c
@@ -252,7 +255,7 @@
252 255 in
253 256 Alcotest.(check int) "still a redirect" 303 (status promote);
254 257 Alcotest.(check int)
255 Removed: "no record written for a dropset" 0
258 Added: "no record written for a dropset-only workout" 0
256 259 (List.length (imported repo trainee)) );
257 260 ( "a partial batch promotion promotes the eligible sibling and retains \
258 261 the blocked one",
@@ -261,7 +264,8 @@
261 264 let c, repo = client_with_repo () in
262 265 let token = signed_in_profile_token c in
263 266 let trainee = the_trainee repo ~username:"lifter" in
264 Removed: (* Two workouts: one ready failure set, one dropset that blocks. *)
267 Added: (* Two workouts: one ready failure set, one dropset-only workout
268 Added: that promotes nothing once its drop set is discarded. *)
265 269 let source =
266 270 csv
267 271 [
@@ -315,8 +319,9 @@
315 319 in
316 320 Alcotest.(check int)
317 321 "batch promotion redirects" 303 (status promote);
318 Removed: (* Only the eligible sibling is promoted. The blocked dropset
319 Removed: writes no record and stays in the batch. *)
322 Added: (* Only the eligible sibling is promoted. The dropset-only workout
323 Added: has nothing to promote, writes no record, and stays in the
324 Added: batch. *)
320 325 Alcotest.(check int)
321 326 "one record from the eligible sibling" 1
322 327 (List.length (imported repo trainee)) );