Skip to main content

0011 — Unilateral set aggregation

Status: Accepted · Date: 2026-09-03

Context​

A unilateral exercise is tracked one side at a time: mapScheme (packages/core/src/training/set-scheme.ts) expands one prescribed set into two WorkoutSet records, a left and a right, and WorkoutSet.side carries which one it was.

Only one aggregation ever knew this — numberOfCompleted, which drives set-list numbering and workout-item status. The session summary engine (packages/core/src/training/exercise-overview.ts) did not: it wrote side onto every SetRow and then read it in no reduction. The same physical session (3 working sets, 8 reps @ 40 kg, 90 s rest) therefore summarized two different ways depending only on whether the coach had toggled per-side tracking:

setstotalRepsvolumeLoadavgRest
bilateral32496090 s
unilateral648192039 s

Worse, the two counters contradicted each other on one screen: tracking's set list read "Set 3" while the Overview stat card beside it read "6 sets".

Decision​

A unilateral set is half of a prescribed set — once it has a partner. A side still on its own counts whole. Every summary reduction weights accordingly, so a session reads the same whether or not it was tracked per side.

unmatched = |leftCount − rightCount|, taken latest-first on the majority side

weight(set) = 0.5 if unilateral AND matched
= 1 otherwise (bilateral, or an unmatched side)

volumeLoad = Σ weight · load · reps over working sets
totalReps = Σ weight · reps over working sets
sets = bilateralCount + max(leftCount, rightCount)
avgRest = session span / (sets − 1)
highestLoad = max raw load — unchanged

Worked examples, all at 8 reps @ 40 kg:

recordingsetsrepsvolumerest
3 bilateral32496090 s
3 L/R pairs32496098 s
L, R, L21664095 s
L, L, L (rehab)32496090 s
a single L18320—

Three exports carry this, all in @enode/core/training/exercise-overview unless noted:

  • countWorkingSets — how many prescribed sets a session is, counting a set from the moment it is started. What the summaries and tables show.
  • countCompletedSets — how many are finished, so an unpartnered side does not count. What numberOfCompleted, and through it workout progress, reports. The two deliberately disagree while a pair is half-done.
  • sideWeights — each set's share of a prescribed set (see above).
  • markSecondSides — flags, per set, whether it completes the prescribed set before it rather than starting a new one. Every view that numbers sets uses it, so a pair carries one number across the app.
  • sideLabel (@enode/ui/set-side) — the "L" / "R" marker, null for a bilateral set, plus setTickLabel / setFullLabel which compose it with a chart set's displayOrder ("2 L", "Set 2 L").

Numbering is the only thing that reads set order; the totals never do.

Why weight per set rather than pair sets up​

Pairing is the obvious model and it is unsound, because nothing guarantees a left and its right are adjacent:

  • the athlete picks the side by hand (the Left/Right/Both picker in CompleteSetSheet), so all-left-then-all-right is one tap away
  • moveSet re-inserts a set by date, which can land a left between an existing pair
  • deleting one side leaves an orphan mid-list

Under adjacency-pairing an all-left-then-all-right session has no pairs at all, and the double-count returns. Set order is no escape hatch either — both sides of a pair persist the same order.

Weighting by the L/R tallies avoids all of that. It reads order in exactly one narrow place: picking which sides are the unmatched surplus, latest-first by created. That choice only moves the total when the loads differ between same-side sets, and it degrades to array order when created is absent — where adjacency-pairing gets the answer flatly wrong on an ordinary recording.

Consequences​

  • An unmatched side counts whole, and volume can fall when its partner lands. L at 40×8 reads 320; when R at 30×8 completes the pair, both become halves and the set reads 280. The card is a per-side-equivalent estimate, and the second side revised it down — the work is not lost, the estimate got better. The alternative (always halving) has no such step, but it reports a single-side session as half of what it was, which is worse: see the rehab row above, where halving gives 480 and a set count of 0.
  • sets and progress deliberately disagree while a pair is half-done. A card can read "2 sets" (both were started) while the schedule still wants the second one finished. They answer different questions — countWorkingSets vs countCompletedSets.
  • highestLoad stays the heaviest load actually lifted. It is a max, so it was never broken; averaging a 40/30 pair would report a 35 kg the athlete never touched on a card coaches read as a PR.
  • avgRest still reads a few seconds high for a per-side session: the side switches sit inside the session span. That is the same kind of over-estimate the stat already carried for a set's own work time, and far better than the near-halving it produced before.
  • SetRow keeps one row per recorded set and gains unitIndex, the number of the prescribed set it belongs to. Both sides share it, so a card numbers the pair once and marks the second row with its side — pair numbering without averaging away the left/right asymmetry, which is the main thing per-side tracking exists to show.
  • Set counts that previously read sets.length now exclude warm-ups too. This changes bilateral sessions as well: 2 warm-ups + 3 working sets now reads 3, not 5.

Open: the server's convention is unknown​

The backend computes its own volume and set counts (history-stats muscleUsage.setCount, volumeCompletion.absoluteValue, /analysis/volume_over_time). Nothing in this repo states whether it counts a unilateral L/R pair as one set or two — no comment, field name, doc, test or fixture. The portal's session Summary tab renders a client-computed volumeLoad next to the server's muscleMapVolume.

This decision covers the client only, which is what makes the client self-consistent. Still to do:

  1. Capture a real unilateral session against backdev and diff the server's figures against these.
  2. If they diverge, stop rendering a client-computed volume beside a server-computed one on the same card — do not un-fix the client.

Worth raising with the backend team regardless: if the server counts each side as a set while athletes' muscle targets were set in whole sets, weekly goal completion is already inflated for unilateral-heavy athletes.

Not covered​

The live-session set counters (live/derive-*.ts) still count raw rows. The tracking set list (set-list.tsx) numbers pairs correctly but does it with its own Math.floor(index / 2) heuristic, which halves bilateral sets in a session that mixes both and mis-numbers a list containing a stray side; it should move to markSecondSides.

sessionDrop (performance-insights.ts) measures the peak → last fall-off across a flat list of per-set means, so for a per-side session it reads the zig-zag between a strong and a weak side as fall-off. Fixing it needs a product call first: whether drop-off is per side or across pairs.

The retrospective fill path hardcodes side: "bilateral" (retrospective/use-retrospective-upload.ts), so a retrospectively-entered unilateral exercise loses its L/R split entirely.