Skip to content

Rep counting internals

This page is the engineering-level companion to Rep counting. It exists so trainers and host-app authors can reason about why a rep didn’t count, what’s filtering it, and how to turn every filter off.

The guiding principle: nothing about rep counting is hidden. Every gate is observable; every default is visible; every threshold is controllable. If repCount doesn’t tick when the trainer thinks it should, the answer is always somewhere on this page.

The three things that can suppress a rep

LayerWhereDefault behaviourCan it silently drop a rep?
Phase debouncePhaseStateMachine _candidateSatisfiedSince timer80ms continuous-satisfaction floorYes — if a phase’s conditions don’t hold for the timer window
Anti-cheat gateMovementTracker._completeRep _scoringGate checkDisabled (every rep counts)No when off; yes when on, but visible via lastRepCleared and bucket-flip protected (see below)
.pose cache stalenessTrackingConfigLoader._cacheIn-memory cache per processYes if a trainer edits a .pose and a consumer still holds the old one

Bucket flips alone can NO LONGER drop a rep — the gate evaluates against the bucket-flip-protected form score (see “Bucket-flip protection (rep clearance)” below). The legacy cross-bucket average path is gone.

Phase debounce — the holdTime model

PhaseStateMachine doesn’t transition phases on the first frame the conditions are met. It waits for them to be continuously satisfied for at least max(authored holdTime, default) milliseconds, where the default is currently 80ms. Any single frame where a condition flips false resets that phase’s timer to null.

// _candidateSatisfiedSince[phase.id] tracks when each phase's
// conditions first became continuously satisfied.
if (met) {
_candidateSatisfiedSince[phase.id] ??= now;
} else {
_candidateSatisfiedSince[phase.id] = null; // reset on flip
}

A phase becomes “actual” only once `now - _candidateSatisfiedSince

= holdTime`. Until then it stays in the candidate map, invisible to consumers.

Why 80ms

At 30fps, 80ms is ~2.4 frames. The trade-off:

  • Too low → single-frame landmark mis-detection registers as a real phase entry. The rep counter sees a phantom transition, the visit history corrupts, and reps either over- or under-count.
  • Too high → fast reps (e.g. plyometric jumps with ~150ms per hemi-cycle) fail to register because the phase never holds long enough to become actual.

80ms catches single-frame jitter without blocking 6-rep-per-second movements. It’s not a universal sweet spot — see the controllability section below.

When holdTime can drop a rep

Two failure modes, both visible in PhaseChangeEvent stream and the live currentPhaseId:

  1. Conditions barely satisfied, flicker frame-to-frame. A pose landmark on the boundary of a condition (e.g. knee angle right at 100° when the threshold is lt 100°) will flip true/false on noise. Each false frame resets the timer; the phase never becomes actual; the rep doesn’t tick.
  2. Fast reps with holdTime authored too high. If the trainer set holdTime: 200 on a condition for a tempo their athlete exceeds, the phase will never qualify.

Both surface in the trainer’s diagnostics as “the user is clearly in the phase but currentPhaseId keeps dropping to null.” That’s the diagnostic signal — not a silent suppression.

Controllability

The runtime MovementTracker exposes the default holdTime as MovementTrackerConfig.phaseHoldTimeDefaultMs, surfaced in the Studio’s Settings panel. The control’s “Reset to default” button is always present and labelled with the SDK default value, so a trainer who has tuned aggressively can always get back to a known-good floor in one click.

Per-condition holdTime (authored into the .pose file) always overrides the default — a movement that needs 50ms for a fast lift and 200ms for a deliberate hold can specify both, and the default only applies to conditions that left holdTime unset.

Anti-cheat gate — what “off” actually means

The gate lives at one site:

// movement_tracker.dart, _completeRep
final cleared =
!_scoringGate.enabled || quality.score >= _scoringGate.minQualityScore;

This is a logical short-circuit: if _scoringGate.enabled == false, cleared is true regardless of any score. The rep is emitted, the counter ticks, the onRepCompleted stream fires.

The _resolveInitialGate nuance

When MovementTracker.load(movement) runs, _resolveInitialGate(movement.repScoring) chooses the active gate:

RepScoringConfig _resolveInitialGate(RepScoringConfig movementGate) {
if (movementGate != RepScoringConfig.disabled) return movementGate;
if (!_config.useRepScoringGate) {
return movementGate.copyWith(minQualityScore: 0.0);
}
return movementGate.copyWith(minQualityScore: _config.minRepQualityScore);
}

Read this carefully:

  • Movement opts in to the gate (movementGate != disabled) → the movement’s gate wins, full stop.
  • Movement doesn’t opt in → the gate stays disabled (enabled == false); only minQualityScore is seeded from the tracker config so it’s available if updateRepScoring later flips enabled on. While enabled stays false, the score is irrelevant.

In other words: a movement whose repScoring.enabled is false ships with the gate off, and no tracker preset or config can sneakily turn it on. The principle holds — “anti-cheat off” means no gating.

Observability when the gate is on

When a gate is enabled and a rep gets dropped, the runtime sets PoseFlowLiveState.lastRepCleared = false. Trainer-facing UIs should render this as a visible “rep dropped by anti-cheat” chip so the trainer can see exactly what’s happening rather than guessing at silence.

.pose cache — staleness as a rep-count problem

The third silent dropper is not in the tracker — it’s in the loader. TrackingConfigLoader holds an in-memory cache of Movement instances keyed by exercise id, populated on first load. Two consumers (e.g. a phone and the user’s tablet, or two browser tabs) can be running the same exercise with different phase graphs if one has the pre-edit .pose and the other has the post- edit one.

Symptoms:

  • Rep counts intermittently differ across devices for the same workout.
  • A trainer’s recent edit “doesn’t apply” until the app is restarted.

The fix in this branch ([CHANGELOG]: fix(pose_flow,poseflow,studio,cms)) adds three independent cache busts:

  1. Per-update cache-bust query param on consumer HTTP GETs — ?v=<updated_at_ms>, so the byte stream’s URL changes on every trainer save.
  2. cache-control: max-age=0, must-revalidate on the Storage side, so the CDN edge never serves a stale body.
  3. Firestore listener invalidation inside TrackingConfigLoader that watches exercises/{id}.updated_at and drops the in-memory entry the moment it advances past the version we hydrated from. The loader emits a Stream<String> of busted ids so consumers (RepConTrackingBloc) can re-load mid-session without an app restart.

The CMS exercise-metadata card now shows the .pose file’s Updated time and Version string so trainers can verify the bust flowed end-to-end.

Why bucket flips don’t affect reps

A common hypothesis when reps intermittently miscount is that the camera-angle bucket detector flipping mid-rep is to blame. It isn’t. Three structural reasons:

  1. MovementConfig.phaseConfigs and MovementConfig.formRules are flat lists — not keyed by camera bucket. PhaseStateMachine initializes once from these and never re-initializes on bucket change.
  2. The only place bucket detection feeds into the runtime is _resolveBucketOverrides, which produces AngleBand overrides for FormCondition evaluation only. The rep counter never sees the bucket.
  3. The bucket detector itself is wrapped in StableCameraAngleDetector (3-frame azimuth, 12-frame height- tier hysteresis). Sub-frame chatter is suppressed before it reaches anything.

Bucket flips can change which AngleBand a FormCondition uses, which can shift the per-rep form score, which can change whether an enabled anti-cheat gate clears a rep. They cannot cause a rep to fire when it otherwise wouldn’t, nor lose a rep that the phase state machine emitted.

The controllability contract

Every threshold this page describes is now a knob in the Studio’s Settings panel:

SettingDefaultWhere it livesResettable
Phase holdTime default80msMovementTrackerConfig.phaseHoldTimeDefaultMsYes (button always visible)
Per-condition holdTime80msPhaseCondition.holdTime on each .poseYes (clear field → default)
Anti-cheat enabledOffRepScoringConfig.enabled on the MovementYes (toggle in Anti-cheat panel)
Anti-cheat threshold50 (when seeded)RepScoringConfig.minQualityScoreYes
Anti-cheat weightsEqual splitRepScoringConfig.weightsYes

The Studio’s Settings panel renders each setting’s current value alongside the SDK default so a trainer always sees what they’ve changed from baseline.

Bucket-flip protection (rep clearance)

The per-frame form score depends on which camera bucket the detector reports for that frame (via AngleBand overrides). The rep-level form score that the anti-cheat gate sees is the MAX of per-bucket averages — not the cross-bucket global average.

The principle: a mid-rep bucket flip should never DROP a rep that would have cleared under any single bucket’s rules alone. If the user complied with bucket A’s rules throughout their frames in bucket A, and bucket A’s average would have cleared, the rep clears — even if frames in bucket B violated bucket B’s rules and the cross-bucket average wouldn’t have cleared.

Implementation: form scores are accumulated into a per-bucket map keyed by the active bucket id at each frame (_currentRepFormScoresByBucket). At rep completion, _bestBucketFormAverage returns the MAX of per-bucket averages, which is what feeds the gate.

Not a free pass: if every bucket’s rules were being violated, every per-bucket average is low, and the MAX is too. The rep correctly fails the gate.

Backward compatible: movements without per-bucket angleBands use a single __none__ group → MAX is identical to the old global average.

Pinned in bucket_override_form_score_test.dart (the strict + lenient bucket scenarios) and rep_pipeline_coverage_test.dart group D2.

RepScoringConfig per-dimension wiring

The model’s six per-dimension toggles (useForm, useTempo, useDepth, useStability, useRangeOfMotion, useHoldDuration) and six weight overrides are now consumed at runtime in _calculateOverallScore:

weight = use* == false ? 0
: *Weight (if non-null override)
: profile.*Weight (inferred fallback)

Remaining non-zero weights normalise themselves through the / totalWeight divisor so the result stays on 0–100 regardless of which dimensions the trainer has toggled off.

useHoldDuration / holdDurationWeight are accepted but ignored because the runtime doesn’t compute a hold-duration score dimension yet. Pinned by the “not-yet-wired” test in rep_pipeline_coverage_test.dart group D.

What we have NOT yet made controllable

For honesty, here’s the surface that’s still constant in the SDK:

  • StableCameraAngleDetector hysteresis windows (3-frame azimuth confirmation, 12-frame height-tier confirmation). These affect AngleBand overrides but not rep counting, so the trainer-facing surface for them lives further down the priority list.
  • PhaseStateMachine candidate tie-break (earliest-candidate wins). This is a correctness invariant, not a tuning knob.

If a future failure mode points at either, this doc gets a new section and the Settings panel gets a new toggle. The principle doesn’t change: nothing hidden.