(** Importing a third-party workout export into confirmed evidence. This is an application use case, not a core entity. A batch holds the immutable raw source and the workouts parsed from it. Cleaning operations (exercise mapping, acknowledgement) return a revised batch. Promotion turns a fully cleaned workout into canonical {!Evidence.Observation} values. The module upholds one invariant on behalf of the core: the core receives only confirmed, catalogued, failure-defined evidence. A row reaches the core only after its exercise is mapped to a catalog entry and its workout-level acknowledgement is present when required. A drop set is not one clean drive to failure, so a [Dropset] row never promotes and never blocks: it is discarded like a warmup, leaving the set taken to failure — the effort HD1 counts. Every promoted set becomes one {!Evidence.Observation} recorded at {!Positive_failure}: the export's set type calibrates what confirmation the import demands, never the recorded outcome. *) type batch_id = private string type workout_id = private string val batch_id : string -> batch_id val workout_id : string -> workout_id val batch_id_to_string : batch_id -> string val workout_id_to_string : workout_id -> string (** How the export classified a set. Warmups and unsupported rows never reach here — an adapter turns them into {!warning} values instead. *) type source_set_type = | Normal (** A working set the export did not mark as taken to failure. *) | Failure (** A set the export marked as taken to failure. *) | Dropset (** A drop set. Not one clean drive to failure, so it is discarded on promotion — never an observation, never a blocker. *) type source_set = { row : int; (** The source row, retained for provenance. *) exercise_name : string; (** The export's exercise name, before mapping. *) set_index : int; (** The set's position within its exercise. *) set_type : source_set_type; weight_kg : float; reps : int; superset_id : int option; (** The export's superset grouping. Provenance only: it never changes how a set is promoted. *) } (** A row the adapter dropped, with the reason. Warmups and unsupported rows are reported here, never as provisional sets. *) type warning_kind = Warmup_dropped | Unsupported_row of string type warning = { row : int; exercise_name : string option; kind : warning_kind } type workout type batch type error = | Unknown_workout of workout_id | Empty_acknowledgement | Invalid_time (** [ended_at] precedes [started_at]. *) val make_workout : id:workout_id -> title:string -> started_at:Recovery.timestamp -> ended_at:Recovery.timestamp -> sets:source_set list -> (workout, error) result (** Builds one imported workout. Returns [Error Invalid_time] when [ended_at] precedes [started_at]. The sets are retained in the given order. *) val make_batch : id:batch_id -> source:string -> fingerprint:string -> imported_at:Recovery.timestamp -> workouts:workout list -> warnings:warning list -> batch (** Builds a batch. [source] is the complete immutable export text. *) (** {1 Accessors} *) val batch_identity : batch -> batch_id val batch_source : batch -> string (** The complete immutable export text, exactly as imported. *) val batch_fingerprint : batch -> string val batch_imported_at : batch -> Recovery.timestamp val batch_workouts : batch -> workout list val batch_warnings : batch -> warning list val find_workout : batch -> workout_id -> workout option val workout_identity : workout -> workout_id val workout_title : workout -> string val workout_started_at : workout -> Recovery.timestamp val workout_ended_at : workout -> Recovery.timestamp val workout_sets : workout -> source_set list (** The source sets in source order. *) val workout_revision : workout -> int (** Starts at zero. Each change to a mapping or the acknowledgement increments it. Promotion of an earlier revision is stale. *) val workout_acknowledgement : workout -> string option (** The retained workout-level acknowledgement, when present. *) val workout_mapping : workout -> source_name:string -> Exercise.t option (** The catalog entry a source exercise name is mapped to, when confirmed. *) (** {1 Cleaning} *) val map_exercise : batch -> workout:workout_id -> source_name:string -> Exercise.t -> (batch, error) result (** Confirms that [source_name] denotes [Exercise.t] for every matching set in the workout. Increments the workout revision only when the mapping changed. Returns [Error (Unknown_workout _)] for an unknown workout. *) val confirm_normal_sets : batch -> workout:workout_id -> acknowledgement:string -> (batch, error) result (** Records the one workout-level acknowledgement that promotes the workout's [Normal] sets. The exact acknowledgement is retained. Whitespace-only acknowledgements are rejected with [Error Empty_acknowledgement]. Increments the workout revision only when the acknowledgement changed. *) (** {1 Blockers} *) (** Why a workout cannot promote yet. *) type blocker = | No_sets (** No promotable sets. A workout of only drop sets reduces to this, since drop sets are discarded. *) | Unmapped_exercise of string (** An export exercise name without a mapping. *) | Normal_sets_unconfirmed val blockers : workout -> blocker list (** The workout's blockers, computed over its promotable sets — the drop sets are excluded, so they never appear here. Deterministic order: [No_sets] first, then one [Unmapped_exercise] per distinct unmapped name in first-appearance order, then [Normal_sets_unconfirmed]. An empty list means the workout can promote. *) (** {1 Promotion} *) type provenance = { batch_id : batch_id; workout_id : workout_id; source_rows : int list; (** The promoted rows, in source order. *) normal_confirmation : string option; (** The acknowledgement, present when the workout had [Normal] sets. *) revision : int; (** The workout revision this promotion reflects. *) } type promoted = { title : string; started_at : Recovery.timestamp; ended_at : Recovery.timestamp; observations : Evidence.Observation.t list; (** One per promoted source set, in source order. *) provenance : provenance; } type workout_result = | Promoted of promoted | Already_promoted of promoted (** The workout was already promoted at its current revision. *) | Blocked of blocker list type batch_result = { promoted : (workout_id * promoted) list; already_promoted : (workout_id * promoted) list; blocked : (workout_id * blocker list) list; } val promote_workout : batch -> workout_id -> (batch * workout_result, error) result (** Promotes one workout, atomically. When the workout has no blockers, every source set becomes one {!Evidence.Observation} at {!Positive_failure}, performed at the workout's [ended_at], and the promotion is stored on the returned batch. A workout already promoted at its current revision yields [Already_promoted]; a later revision re-promotes and replaces the stored data. A blocked workout yields [Blocked] and the batch is unchanged. Returns [Error (Unknown_workout _)] for an unknown workout. *) val promote_batch : batch -> batch * batch_result (** Promotes every workout in the batch. Promotion is partial: each workout is promoted, already promoted, or blocked independently, and a blocked workout never prevents another from promoting. *) (** {1 Snapshot} A serialization-neutral view of a batch, so a repository can store and restore a batch without the third-party parser. The snapshot exposes plain records that carry only strings, ints, floats, timestamps, and the public {!source_set}, {!warning}, {!provenance}, and acknowledgement values. It never exposes a mutable internal. A restore rebuilds the opaque batch, resolving each exercise id through a lookup, so a snapshot stays valid only as long as the catalog still names its exercises. A prior promotion is preserved exactly, so {!Already_promoted} and the edited re-promote behaviour survive a reconnect. Because every promoted outcome is {!Positive_failure}, an observation snapshots as an exercise id, a load, a rep count, and a performed time. *) type observation_snapshot = { exercise_id : Exercise.id; load : float; reps : int; performed_at : Recovery.timestamp; } (** One promoted effort: always {!Positive_failure}, so the outcome is implicit. *) type promoted_snapshot = { title : string; started_at : Recovery.timestamp; ended_at : Recovery.timestamp; observations : observation_snapshot list; source_rows : int list; normal_confirmation : string option; revision : int; } (** A stored promotion, flattened from {!promoted} and its {!provenance}. The batch and workout ids are carried by the enclosing {!workout_snapshot}. *) type workout_snapshot = { id : workout_id; title : string; started_at : Recovery.timestamp; ended_at : Recovery.timestamp; sets : source_set list; mappings : (string * Exercise.id) list; (** Confirmed source-name-to-exercise mappings. *) acknowledgement : string option; revision : int; promotion : promoted_snapshot option; (** The prior promotion, when the workout was promoted. *) } type snapshot = { batch_id : batch_id; source : string; fingerprint : string; imported_at : Recovery.timestamp; workouts : workout_snapshot list; warnings : warning list; } (** Why a snapshot cannot be restored. *) type restore_error = | Unknown_exercise of string (** A stored exercise id is not in the catalog. *) | Invalid_time (** A stored [ended_at] precedes its [started_at]. *) | Invalid_effort of Evidence.Stimulus.Effort.error (** A stored observation the core refuses: a nonsensical load or rep count. The snapshot is corrupt, not merely stale. *) val snapshot : batch -> snapshot (** A serialization-neutral view of the batch, including any prior promotion. *) val restore : find_exercise:(Exercise.id -> Exercise.t option) -> snapshot -> (batch, restore_error) result (** Rebuilds the opaque batch, resolving each mapping's exercise id through [find_exercise]. Returns [Error (Unknown_exercise _)] for a mapping or observation whose exercise id is not in the catalog, and [Error Invalid_time] when a workout's [ended_at] precedes its [started_at]. A restored batch reproduces the mappings, acknowledgement, revision, and prior promotion of the snapshot exactly. *)