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
| Layer | Where | Default behaviour | Can it silently drop a rep? |
|---|---|---|---|
| Phase debounce | PhaseStateMachine _candidateSatisfiedSince timer | 80ms continuous-satisfaction floor | Yes — if a phase’s conditions don’t hold for the timer window |
| Anti-cheat gate | MovementTracker._completeRep _scoringGate check | Disabled (every rep counts) | No when off; yes when on, but visible via lastRepCleared and bucket-flip protected (see below) |
.pose cache staleness | TrackingConfigLoader._cache | In-memory cache per process | Yes 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:
- 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. Eachfalseframe resets the timer; the phase never becomes actual; the rep doesn’t tick. - Fast reps with
holdTimeauthored too high. If the trainer setholdTime: 200on 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, _completeRepfinal 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); onlyminQualityScoreis seeded from the tracker config so it’s available ifupdateRepScoringlater flipsenabledon. Whileenabledstays 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:
- 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. cache-control: max-age=0, must-revalidateon the Storage side, so the CDN edge never serves a stale body.- Firestore listener invalidation inside
TrackingConfigLoaderthat watchesexercises/{id}.updated_atand drops the in-memory entry the moment it advances past the version we hydrated from. The loader emits aStream<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:
MovementConfig.phaseConfigsandMovementConfig.formRulesare flat lists — not keyed by camera bucket.PhaseStateMachineinitializes once from these and never re-initializes on bucket change.- The only place bucket detection feeds into the runtime is
_resolveBucketOverrides, which produces AngleBand overrides forFormConditionevaluation only. The rep counter never sees the bucket. - 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:
| Setting | Default | Where it lives | Resettable |
|---|---|---|---|
| Phase holdTime default | 80ms | MovementTrackerConfig.phaseHoldTimeDefaultMs | Yes (button always visible) |
| Per-condition holdTime | 80ms | PhaseCondition.holdTime on each .pose | Yes (clear field → default) |
| Anti-cheat enabled | Off | RepScoringConfig.enabled on the Movement | Yes (toggle in Anti-cheat panel) |
| Anti-cheat threshold | 50 (when seeded) | RepScoringConfig.minQualityScore | Yes |
| Anti-cheat weights | Equal split | RepScoringConfig.weights | Yes |
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:
StableCameraAngleDetectorhysteresis 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.PhaseStateMachinecandidate 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.
Read next
- Rep counting — the consumer-facing model that this page is the engineering companion for.
PhaseStateMachineAPI.RepScoringConfigmodel.- Studio’s Anti-cheat panel.