Skip to content

Instantly share code, notes, and snippets.

@kangax
Created June 15, 2026 13:22
Show Gist options
  • Select an option

  • Save kangax/aa7f3cf4177935b0157ba55b64bd83f4 to your computer and use it in GitHub Desktop.

Select an option

Save kangax/aa7f3cf4177935b0157ba55b64bd83f4 to your computer and use it in GitHub Desktop.
OpenSet vs CrossFit logging: where an open workout-data standard fits, and where it breaks

OpenSet vs CrossFit logging: where an open workout-data standard fits, and where it breaks

TL;DR

I evaluated OpenSet (openset.dev, an MIT-licensed open JSON standard for workout data — repo: https://github.com/getdiby/openset) against how we model workout data in PRzilla, focused on whether we could adopt it for logging.

Verdict: OpenSet is a strong, well-built standard and a clean fit for strength / hybrid (concurrent) training. It is a poor fit for CrossFit metcons for structural reasons, not polish. Its execution (logging) model is a delta against a prescription: it logs every set and derives outcomes. CrossFit logging is outcome-first (one score) and scaling-heavy (substituted movements), and those two facts break the model.

Single strongest finding: a prescription-anchored log cannot represent movement scaling (e.g. Amanda done with ring dips), and scaling is the common case in CrossFit.

Worth borrowing for strength logging: first-class per-set RPE/RIR, and explicit miss/skip logging.


What OpenSet is

  • Open interchange standard: spec + JSON Schema (2020-12) + TypeScript types + CLI validator (22 rules). MIT.
  • Two document families:
    • Prescriptionworkout / program / workout_library. Hierarchy: Program > Phase > Workout > Block > Series > Exercise > Set.
    • Execution (logging)workout_execution, references a prescription and records per-set actuals.
  • Prescription shape: Workout {openset_version, type, name, date, sports[], blocks[]} → Block {name, series[]} → Series {execution_mode, rounds, rest_after, exercises[]} → Exercise {exercise_id | name, sets[]} → Set {dimensions[], <per-dimension {type, value, unit}>}.
  • 10 execution modes: SEQUENTIAL, CIRCUIT, SUPERSET, AMRAP, FOR_TIME, INTERVAL, TABATA, EMOM, LADDER, CLUSTER.
  • 21+ dimensions: reps, sides, rounds, load, duration, duration_per_side, rest_between_sides, rest_after, tempo, distance, height, incline, pace, speed, power, heart_rate, heart_rate_zone, rpe, rir, velocity, calories, cadence, resistance.
  • Value types: fixed | range | min | max | amrap | any.
  • Units: explicit per value (kg, lb, %1RM, %BW, m, s, bpm, %HRR, W, %FTP …), stored as authored (no normalization).
  • Extensibility: namespaced x_* extensions.

Confirmed schema facts (verified against the repo)

Prescription:

  • No time-cap field at Series or Block level. Only Workout.duration, which is an estimate. In their example a "12 minute AMRAP" stores the 12 minutes in a note string.
  • No gender variants, no scaling tiers, no vest/worn-load, no partner/team concept.
  • rounds implies identical repetition.

Execution:

  • required: [openset_version, type, execution_id, workout_ref, started_at, completed_at, set_executions]set_executions is required (no outcome-only logging); workout_ref is required (execution cannot stand alone).
  • Workout-level summary is volume only: sets_completed, sets_skipped, total_volume_kg. No score/time/rounds/reps.
  • SetExecution: set_ref{block,series,exercise,set} (required positional pointer into the prescription), status (skipped|partial|completed), started_at/completed_at, dimensions{value, unit, completion: met|partial|missed|not_logged}, rpe, rest_actual, exercise_id (optional, "from prescription"), feedback, media.
  • Mapping is strictly one-to-one (one SetExecution per prescribed set). No substitution / scaled / alternate-exercise field.
  • rpe is first-class; rir and heart_rate are NOT present in execution (even though both are prescribable dimensions).

How we model it in PRzilla (for contrast)

(Sharing the shape of the model, since the whole point is that this is hard and worth being open about.)

  • Prescription — a JSONB prescription on the WOD: blocks[] of type:"movement"|"round". Movement block: movement, reps, repStep, weight/weightFemale, percentIntent, vest, distance, boxHeightIn/Female, calories(nullable)/Female, time, each, bodyweightLoad, loggingMode. Round block: rounds:number|"amrap", time (cap), kind:emom|tabata|intervals, workSeconds, restSeconds, repScheme[]. Plus vestWeight/Female, workScope (individual | estimated_individual_share | team_total | mixed), teamSize, scoreShareDivisor.
  • Performed — a score (headline: time_seconds, reps, load, rounds_completed, distance, partial_reps, is_rx, finish_status, cap_seconds, scoreIsCapped, wore_vest, teamSize, aggregationMode) plus per-movement lift-score rows (per-set detail stored as JSON: sets[]{load, reps, success, distance, seconds, boxHeightIn} + weightUnit, distanceUnit, loggingMode, scoreboardValue, movementReps[]), linked to the score by a join that records which prescription block and round each component came from.
  • Decomposition — a single headline score can be inverted through the prescription into per-movement reps and tonnage, then into work (ft-lb) and power (W).
  • Movement identity — canonical movements (UUID) plus an alias table (with confidence) and complex (multi-movement) lifts.

Prescription side: can OpenSet prescribe hybrid / Hyrox / CrossFit?

It can prescribe the movement-and-rep skeleton of all three; the sport-defining semantics leak into note strings and x_* extensions. Evidence from their own examples:

Hybrid (mixed-workout.json, sports:["crossfit"]) — their sweet spot. SEQUENTIAL strength block (deadlift with rpe + rest_after, ending load:{type:"max"} for a 1RM test) + EMOM conditioning + LADDER finisher, one doc. Genuinely good. Caveat: the EMOM's "odd = work, even = recovery" and the 1-minute window live in a note.

CrossFit (conditioning-circuit.json) — the smoking gun:

{
  "execution_mode": "AMRAP",
  "rounds": { "type": "amrap" },
  "note": "12 minute AMRAP — as many rounds as possible",
  "exercises": [ /* 15 push-ups, 15 back squats @20kg, 15 cal row */ ]
}

The defining parameter (the 12-minute window) is free text in note. The FOR_TIME block has rounds:3 and no cap. (Credit: the TABATA block does put 20/10 in data via duration:20 + rest_after:10 per interval.)

Periodized program (program-hypertrophy-strength-periodized.json): sports:["strength"] only — no shipped multi-week hybrid program. Periodization intelligence (x_macrocycle_goal, x_phase_type, x_split, x_intensity_trend) lives in extensions, not core.

Hyrox (no example shipped): structurally a decent fit (For-Time + distance/rep based). Gaps: the "8 rounds" are heterogeneous so rounds:8 doesn't apply (enumerate 16 segments); no division/gender load variants (x_division); per-station splits + roxzone transition time have no home; movement-library coverage for sled/sandbag/etc. unconfirmed.


Completed-work (logging) side: can OpenSet log a CrossFit result?

OpenSet's logging surface is workout_execution. It logs the process (every set) and derives the outcome. CrossFit logging is outcome-first.

Type Native fit Score field Reality
For Time (Fran 3:01) OK — 6 real set rows none; hides in completed_at − started_at + extensions for score/Rx
AMRAP (Cindy 20+8) Bad — ~62 rows for one logged number none; no rounds, set_executions required recoverable from sets in theory; in practice needs x_rounds/x_partial
Tabata (8×20/10) Best fit — 8 interval rows min/sum derivable if all logged; "lowest round" is a convention + score-type
EMOM (Chelsea 30) Mediocre — up to 90 rows; fall-off via status:"skipped" "rounds survived" needs derivation + score-type

Extensions you'd have to add to log CF results: x_result (typed: for_time / amrap{rounds,partial_reps} / amrap_reps / load / distance), x_rx (rx|scaled|rx_plus), x_capped + x_time_cap_seconds, x_tiebreak_seconds, x_vest, x_partner_share. Net: ~6 extensions carry all the CF meaning; native fields carry per-set tonnage you care least about for metcons.


Strongest finding: a prescription-anchored log cannot represent movement scaling

Confirmed structurally: workout_ref required + set_ref required (positional {block,series,exercise,set}) + strictly one-to-one + no substitution field.

Example — Amanda (9-7-5 muscle-ups + squat snatches @95):

  • Load scale (85 lb snatch): recordable but mislabeled. Same movement, load:{value:85}. But completion reads 85 < 95 as missed, not "chose to scale."
  • Movement scale (muscle-up → ring pull-up + ring dip): impossible.
    1. One prescribed exercise slot, two performed movements. The ring dips have no set_ref to point at — no prescribed set exists for them.
    2. exercise_id is "from prescription" (an echo for analytics), not a substitution override.
    3. completion has no "substituted" value; zero muscle-ups reads as missed (failed), not scaled.
  • Only escape: mint a new prescription that is scaled-Amanda — losing the benchmark link, exploding into a bespoke doc per athlete per scaling choice, and destroying comparability.

Root cause: execution is a delta against a fixed plan. Quantitative scaling (less load) is a smaller delta (representable). Qualitative scaling (different movement) is rewriting the plan (not representable). And scaling is the norm in CrossFit, not the exception (banded pull-ups, dumbbells instead of a barbell, rowing instead of running, knee push-ups). A model that can only log "the prescribed movements, maybe lighter" misses the majority of real entries.

Contrast: we store the performed movement as the source of truth (the lift-score points at the movement you actually did), linked to the WOD score and flagged not-Rx. Scaling is native because the log records what you did, not how you deviated from a plan.


Key conceptual findings (from the debate)

  • The score is the log, and it's an aggregation with a type. A chunk of work rolls up into one result; the aggregation changes per workout (max = top single, min = Tabata's worst round, total = a row, average = intervals, time = For Time). SugarWOD makes this a first-class field; OpenSet's summary is volume-only, so the result the athlete came to record has no home.
  • The score's data type is polymorphic. Seconds (Fran), rounds+reps (Cindy), load (1RM), reps (capped), tiebreak (Open). A single column must be a tagged union.
  • completed_at − started_at is not a general score.
    • Works for a completed For-Time WOD (and a result field there would be redundant denormalization).
    • Fails for AMRAP (the delta is the fixed cap, constant for everyone; the result lives in the sets, but the sets are usually unlogged and reconstruction is prescription-dependent and lossy).
    • Fails for capped/DNF (the delta becomes a lie: "capped at 5:00" reads as "finished in 5:00").
    • Best argument: the delta only equals the score if the app is the live WOD timer; most logging is retrospective, so timestamps collapse to form-open/save. The timer and the logger are different instruments at different times.
  • completion: partial is derivable, not a feature. It's adherence-to-target (logged value vs prescribed), the same denormalization critique as total_volume_kg. Its existence is the tell that OpenSet's execution measures adherence-to-plan, not what happened. The only real keeper nearby is binary make/miss for maximal attempts (failed 1RM, where reps:0 is ambiguous).
  • Predetermined vs measured. OpenSet asks you to author the predetermined part (the prescribed sets) and derive the measured part (the result). For strength that's right (each set's result is new info). For CrossFit it's backwards (the result is one scalar; the sets are the prescription replayed).

Where OpenSet is genuinely good, and what's worth borrowing

Good: strength/hypertrophy logging (per-set load with units, completion, timestamps, rest_actual, rpe), broad dimension vocabulary (tempo, velocity/VBT, cadence, power/%FTP, pace, HR-zone), hybrid concurrent programming via Program/Phase, and a published schema + validator.

Worth borrowing (for strength logging):

  1. First-class miss/skip (status:skipped + a make/miss flag) — most trackers have a boolean "success" but don't let users log misses (failed singles, missed 1RM, bailed sets).
  2. First-class per-set RPE/RIR — too often buried in a free-text notes blob; structured fields enable autoregulation and fatigue tracking.

Not worth copying: total_volume_kg (derived; compute tonnage live) and completion: partial (derivable adherence flag).


The complexities of CrossFit modeling (distilled)

  • A score is an aggregation (max/min/total/average/time), and most formats don't make it first-class.
  • A score isn't one data type (seconds / rounds+reps / load / tiebreak) — tagged union.
  • Prescribed vs performed are separate records; you need both.
  • Scaling changes the movements, not just the numbers (one movement can become two, with different IDs); scaling is the common case.
  • The logged number is lossy; per-movement detail must be reconstructed (decomposition).
  • Time is many structures (For Time, AMRAP, EMOM/E2MOM, Tabata, ladders, partner YGIG) with different work/rest/cap rules; a 12-min AMRAP and a 12-min cap mean opposite things.
  • Elapsed time often isn't the result (caps → reps; tiebreaks = a second clock).
  • Load has several forms (absolute, %1RM, %BW, worn vest); and the per-movement unit itself varies (reps / calories / meters / time) — calories and meters are output, not load.

A short writeup of all this

I spent this week trying to model CrossFit in OpenSet, a new open standard for workout data. It's a strong spec, and for barbell training it maps cleanly. CrossFit is where it strained, and not really through any fault of the standard: the sport is just unusually hard to store.

The score itself is the most basic piece, and how a chunk of work rolls up into one result changes per workout: a heavy day is a max (your best single), a Tabata is a min (your worst round), a long row is a total, intervals an average. SugarWOD bakes that choice into the workout itself; without it you just have logged sets and no result.

That is before any of the structural problems, most of which we have run into building our own tracker:

  • A score isn't one data type. Fran is elapsed seconds, Cindy is rounds plus leftover reps, a 1-rep max is a load, a capped workout is reps completed, and Open workouts add a tiebreak time on top. The score field has to be a tagged union, not a number.
  • Prescribed and performed are separate records. The board says muscle-ups and 95 lb snatches; you did ring dips and 85. You need both: the prescription to know it was Amanda, the performance to know what you actually did against it.
  • Scaling changes the movements, not just the numbers. Lowering the weight keeps the same movement at a smaller load. But scaling a muscle-up to a ring pull-up plus a ring dip turns one prescribed movement into two performed ones with different IDs. Any model that assumes performed movements line up with prescribed movements falls over here, and scaling is the common case.
  • The logged number is lossy, and you spend real work rebuilding the detail. From "Fran, 3:01" you have to re-derive 21-15-9 thrusters and pull-ups before you can show tonnage, work per movement, or a thruster PR. The athlete enters one value; the app has to reconstruct the per-movement detail behind it.
  • Time is many structures, and elapsed time often isn't the result. For Time, AMRAP, EMOM, every-two-minutes, Tabata, ladders, and partner you-go-i-go all carry different work and rest windows and round rules. Hit a cap and the score becomes reps completed, not the clock. A 12-minute AMRAP and a 12-minute cap share a number and mean opposite things.
  • Load isn't just a number. It can be absolute weight, a percent of 1-rep max, a percent of bodyweight (Linda's deadlifts), or a vest worn across every bodyweight movement (Murph).
  • The unit you log changes per movement. A barbell movement is weight and reps. A row is calories or meters, a run is distance, a plank is time. There's no universal "amount" column; each movement decides what it's measured in.

A normal Tuesday workout hits four or five of these at once, which is why a schema that logs barbell work cleanly tends to break the first time someone enters a metcon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment