Rep pipeline map
This page is the engineering ledger for the rep-counting pipeline.
Every site that touches rep counting, scoring, gate clearance, or
emission is listed below with the test that pins its behaviour. Use
this as the pre-flight checklist for any change to
MovementTracker, PhaseStateMachine, or RepScoringConfig.
The companion concepts/rep-counting-internals.md covers the
why; this page covers the what and where.
The contract trainers see
There are two trainer-facing surfaces, and they can disagree under anti-cheat:
| Surface | Source | Affected by anti-cheat |
|---|---|---|
MovementTracker.repCount (counter) | PhaseStateMachine.completedReps | NO — the gate never decrements it |
onRepCompleted (stream of events) | _repCompletedController.add inside _completeRep | YES — uncleared reps suppress the emission |
The counter is the bucket-invariant + anti-cheat-invariant source of truth. UIs that tally events instead of reading the counter will undercount when the gate is on. If the rep number on screen doesn’t match the user’s perception, this divergence is where to look first.
MovementTracker.lastRepCleared is the per-rep “was the most
recent rep gated through?” flag. UIs should expose it as a
“rep dropped” chip so trainers see when the gate is filtering.
Where each piece lives
Rep counter (the number)
PhaseStateMachine.completedReps is the source. Incremented from
_completeRep (phase_state_machine.dart:471).
That function is called from _maybeCompleteRep along two paths:
| Strategy | Site | Trigger |
|---|---|---|
| Cycle | phase_state_machine.dart:421 | Re-entering the primary phase after visiting at least one non-primary |
| Sequence | phase_state_machine.dart:441 | Visit history’s tail matches the authored sequence |
MovementTracker.repCount (movement_tracker.dart:904)
is a pure read-through to the phase machine. The gate never
mutates this number. Pinned by rep_pipeline_coverage_test.dart
group A.
Rep scoring (the quality)
Computed by MovementTracker._scoreRep (movement_tracker.dart:714).
Four dimensions feed in:
| Dimension | Helper | Range | Default | Pinned by |
|---|---|---|---|---|
| Form | _currentRepFormScores avg | 0–100 | 100 (no rule violations) | bucket_override_form_score_test.dart |
| Tempo | _calculateTempoScore(duration) | 30–100 | 100 (1000–4000ms) | rep_pipeline_coverage_test.dart B1 |
| Stability | _calculateStabilityScore() | 0–100 | 100 (no frames) | rep_pipeline_coverage_test.dart B2 |
| ROM | _calculateRomScore() | 0–100 | 100 (no reference) | rep_pipeline_coverage_test.dart B3 |
The weighted sum lives at _calculateOverallScore (movement_tracker.dart:790).
Weights come from the inferred ScoringProfile, NOT from the
trainer’s RepScoringConfig. See “Dead surface” below.
In-progress accumulators
Cleared on _startRep, _completeRep, and reset. Populated
per-frame inside processFrame only when _repStartTime != null:
| Accumulator | Purpose | Cleared by |
|---|---|---|
_currentRepFormScores | List of per-frame form scores | _startRep, _completeRep, reset |
_currentRepViolations | Form-rule violations during this rep | same |
_currentRepStabilitySum | Sum of per-frame stability scores | same |
_currentRepStabilityFrames | Frame count for stability average | same |
_currentRepValidFrames | Subset of frames where FrameValidator.isValid | same |
_currentRepAngleMins / _Maxs | Per-channel min/max for ROM coverage | same |
_lastRepCleared | Did the most recent rep pass the gate? | reset only — survives rep boundaries so UIs can read it |
_lastCompletedRepQuality | Score breakdown of the most recent CLEARED rep | reset, and one frame after consumption |
Anti-cheat gate
Single short-circuit at _completeRep (movement_tracker.dart:673):
final cleared = !_scoringGate.enabled || quality.score >= _scoringGate.minQualityScore;enabled == false→ every rep clears, no score check.enabled == true→ rep must clearminQualityScore.
The branch at if (cleared) controls stream emission only —
the counter ticks one line above regardless.
_resolveInitialGate (movement_tracker.dart:1239)
seeds the active gate at load() time. Movements that don’t opt
in stay disabled; the tracker config can pre-seed
minQualityScore for “if this movement ever flips enabled on”
but never sneakily enables the gate.
Runtime mutators
| Method | Site | What it touches |
|---|---|---|
MovementTracker.load(movement) | movement_tracker.dart:511 | Rebuilds scoring profile, ROM reference, gate, then reset() |
MovementTracker.reset() | movement_tracker.dart:825 | Zeroes every accumulator, phase machine, form service, frame validator |
MovementTracker.updateRepScoring(config) | movement_tracker.dart:850 | Swaps _scoringGate only — counter and accumulators untouched |
MovementTracker.updatePhaseHoldTimeDefaultMs(v) | movement_tracker.dart:865 | Forwards to phase machine; clears its candidate timers; doesn’t touch reps |
PhaseStateMachine.initialize(config) / reset() | phase_state_machine.dart:220 | Resets _completedReps and visit list |
All pinned by rep_pipeline_coverage_test.dart group F.
Public read API
| Member | Source | Gated by anti-cheat? |
|---|---|---|
MovementTracker.repCount | _phaseMachine.completedReps | NO |
MovementTracker.lastRepCleared | _lastRepCleared | reflects gate decision |
MovementTracker.lastCompletedRepQuality | _lastCompletedRepQuality | YES (null when last rep was gated) |
MovementTracker.onRepCompleted | _repCompletedController.stream | YES |
MovementTracker.onRepQuality | _repQualityController.stream | YES |
TrackingResult.repCount | per-frame snapshot of _phaseMachine.completedReps | NO |
TrackingResult.pipeline.lastRepCleared | per-frame snapshot of _lastRepCleared | reflects gate |
PhaseStateMachine.onRepCompleted (internal) | phase machine’s own stream | NO — pre-gate |
RepScoringConfig toggles and weights — WIRED
RepScoringConfig exposes six per-dimension toggles (useForm,
useTempo, useDepth, useStability, useRangeOfMotion,
useHoldDuration) and six weight overrides (formWeight,
tempoWeight, …).
These are now live at _calculateOverallScore →
_weightedQualityScore (movement_tracker.dart:818).
For each dimension, the weight is resolved by:
weight = use* == false ? 0 : *Weight (if non-null override) : profile.*Weight (inferred fallback)The remaining non-zero weights normalise themselves through the
/ totalWeight divisor so the result stays on a 0–100 scale
regardless of which dimensions the trainer has toggled off.
depthWeight and rangeOfMotionWeight both apply to the single
ROM score the runtime emits — their (toggle-respected) values are
summed before being multiplied. This matches the model docstring
that says both contribute to the ROM bucket.
useHoldDuration / holdDurationWeight caveat. Accepted but
ignored because the runtime doesn’t yet compute a hold-duration
score dimension. Hold-based movements use a separate strategy at
the phase level. When/if a hold dimension lands, the weight will
plug in here. Pinned by the “documented as not-yet-wired” test in
rep_pipeline_coverage_test.dart group D.
Pinned by rep_pipeline_coverage_test.dart group D (7 tests),
including: toggling form off, toggling tempo off, depth +
rangeOfMotion independence, weight-override-beats-inferred, and
the hold-duration caveat.
Bucket-flip protection — MAX of per-bucket averages
Rep clearance now honours the bucket-invariance principle even when anti-cheat is ON.
Per-frame form scores are accumulated keyed by the active
camera bucket id as detected by StableCameraAngleDetector
inside _resolveBucketOverrides. At rep completion, the form
score that feeds rep quality is the MAX of per-bucket
averages — NOT the cross-bucket global average that the
previous implementation used.
double _bestBucketFormAverage() { if (_currentRepFormScoresByBucket.isEmpty) return 100.0; double best = -1; for (final scores in _currentRepFormScoresByBucket.values) { if (scores.isEmpty) continue; final avg = scores.reduce((a, b) => a + b) / scores.length; if (avg > best) best = avg; } return best < 0 ? 100.0 : best;}Trainer-facing translation: “If you were satisfying ANY single bucket’s rules throughout your frames in that bucket, the rep counts.” A mid-rep flip from a strict bucket A (avg 50) to a lenient bucket B (avg 100) will produce a rep form score of 100, not the previous global average of 75. The lenient bucket’s verdict wins.
The protection is not “any bucket pass guarantees clearance”: if every bucket’s average is low, the MAX is still low and the rep correctly fails the gate. Bucket-flip protection only kicks in when at least one bucket’s rules WERE being satisfied.
Backwards compatibility: single-bucket reps and movements without
per-bucket angleBands collapse to a single __none__ group,
and the MAX equals that group’s average — identical to the
pre-protection behaviour.
Pinned by rep_pipeline_coverage_test.dart group D2 (8 tests),
including: no bucket data fallback, single bucket pass-through,
strict + lenient MAX-wins headline, all-buckets-bad still
correctly drops, three-bucket chatter, empty-group skip,
no-form-data default, and reset-clears.
The bucket-invariance principle this map satisfies
Reps should never be dropped due to camera-angle bucket changes, even with anti-cheat ON. As long as the user satisfies whatever bucket-conditions are live at any given moment, the rep should count — the same as if the bucket never changed.
How the architecture honors this:
| Layer | Bucket-invariant? | How |
|---|---|---|
| Phase state machine | YES | update(trackingValues) has no bucket input |
MovementTracker.repCount | YES | Read-through to the phase machine |
TrackingResult.repCount | YES | Per-frame snapshot of the same |
| Per-frame form scoring | NO — uses the active bucket’s AngleBand override | Each frame is judged against its own bucket — the design intent |
| Per-rep form score | YES — MAX of per-bucket averages | The “if any bucket cleared, you cleared” rule (see “Bucket-flip protection” above) |
| Anti-cheat gate decision | YES — uses the bucket-protected score | Gate decides on the MAX-bucket score, not the cross-bucket average |
onRepCompleted stream | YES — emits when the bucket-protected score clears | No longer suppressed by mid-rep bucket flips alone |
The principle is now honored at every layer. Bucket flips cannot drop a rep that would have cleared under any single bucket’s rules. If every bucket’s rules WERE being violated, the rep correctly drops — bucket-flip protection isn’t a free pass, it just stops bucket flipping from being a SECONDARY penalty on top of correct per-bucket evaluation.
Test coverage matrix
| Group | Pinned in |
|---|---|
| Phase machine bucket invariance | bucket_flip_rep_counting_test.dart (16 tests) |
| Bucket-override form scoring | bucket_override_form_score_test.dart (22 tests) |
repCount source-of-truth | rep_pipeline_coverage_test.dart A (3) |
| Tempo formula | rep_pipeline_coverage_test.dart B1 (6) |
| Stability formula | rep_pipeline_coverage_test.dart B2 (5) |
| ROM formula | rep_pipeline_coverage_test.dart B3 (2) |
| Weighted sum | rep_pipeline_coverage_test.dart B4 (5) |
| Runtime mutators | rep_pipeline_coverage_test.dart C (2), F (3) |
| RepScoringConfig toggles + weights (LIVE) | rep_pipeline_coverage_test.dart D (7) |
| Bucket-flip protection (MAX of per-bucket averages) | rep_pipeline_coverage_test.dart D2 (8) |
| Bucket invariance contract | rep_pipeline_coverage_test.dart E (3) |
| Construction sanity | rep_pipeline_coverage_test.dart G (3) |
Total: 85 tests across the three rep-pipeline files. Plus the
21 existing phase_state_machine_test.dart cases that pin the
underlying state machine.
Read next
- Rep counting — the consumer-facing model.
- Rep counting internals — debounce, gate semantics, cache layer, controllability.