Skip to content

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:

SurfaceSourceAffected by anti-cheat
MovementTracker.repCount (counter)PhaseStateMachine.completedRepsNO — the gate never decrements it
onRepCompleted (stream of events)_repCompletedController.add inside _completeRepYES — 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:

StrategySiteTrigger
Cyclephase_state_machine.dart:421Re-entering the primary phase after visiting at least one non-primary
Sequencephase_state_machine.dart:441Visit 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:

DimensionHelperRangeDefaultPinned by
Form_currentRepFormScores avg0–100100 (no rule violations)bucket_override_form_score_test.dart
Tempo_calculateTempoScore(duration)30–100100 (1000–4000ms)rep_pipeline_coverage_test.dart B1
Stability_calculateStabilityScore()0–100100 (no frames)rep_pipeline_coverage_test.dart B2
ROM_calculateRomScore()0–100100 (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:

AccumulatorPurposeCleared by
_currentRepFormScoresList of per-frame form scores_startRep, _completeRep, reset
_currentRepViolationsForm-rule violations during this repsame
_currentRepStabilitySumSum of per-frame stability scoressame
_currentRepStabilityFramesFrame count for stability averagesame
_currentRepValidFramesSubset of frames where FrameValidator.isValidsame
_currentRepAngleMins / _MaxsPer-channel min/max for ROM coveragesame
_lastRepClearedDid the most recent rep pass the gate?reset only — survives rep boundaries so UIs can read it
_lastCompletedRepQualityScore breakdown of the most recent CLEARED represet, 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 clear minQualityScore.

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

MethodSiteWhat it touches
MovementTracker.load(movement)movement_tracker.dart:511Rebuilds scoring profile, ROM reference, gate, then reset()
MovementTracker.reset()movement_tracker.dart:825Zeroes every accumulator, phase machine, form service, frame validator
MovementTracker.updateRepScoring(config)movement_tracker.dart:850Swaps _scoringGate only — counter and accumulators untouched
MovementTracker.updatePhaseHoldTimeDefaultMs(v)movement_tracker.dart:865Forwards to phase machine; clears its candidate timers; doesn’t touch reps
PhaseStateMachine.initialize(config) / reset()phase_state_machine.dart:220Resets _completedReps and visit list

All pinned by rep_pipeline_coverage_test.dart group F.

Public read API

MemberSourceGated by anti-cheat?
MovementTracker.repCount_phaseMachine.completedRepsNO
MovementTracker.lastRepCleared_lastRepClearedreflects gate decision
MovementTracker.lastCompletedRepQuality_lastCompletedRepQualityYES (null when last rep was gated)
MovementTracker.onRepCompleted_repCompletedController.streamYES
MovementTracker.onRepQuality_repQualityController.streamYES
TrackingResult.repCountper-frame snapshot of _phaseMachine.completedRepsNO
TrackingResult.pipeline.lastRepClearedper-frame snapshot of _lastRepClearedreflects gate
PhaseStateMachine.onRepCompleted (internal)phase machine’s own streamNO — 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:

LayerBucket-invariant?How
Phase state machineYESupdate(trackingValues) has no bucket input
MovementTracker.repCountYESRead-through to the phase machine
TrackingResult.repCountYESPer-frame snapshot of the same
Per-frame form scoringNO — uses the active bucket’s AngleBand overrideEach frame is judged against its own bucket — the design intent
Per-rep form scoreYES — MAX of per-bucket averagesThe “if any bucket cleared, you cleared” rule (see “Bucket-flip protection” above)
Anti-cheat gate decisionYES — uses the bucket-protected scoreGate decides on the MAX-bucket score, not the cross-bucket average
onRepCompleted streamYES — emits when the bucket-protected score clearsNo 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

GroupPinned in
Phase machine bucket invariancebucket_flip_rep_counting_test.dart (16 tests)
Bucket-override form scoringbucket_override_form_score_test.dart (22 tests)
repCount source-of-truthrep_pipeline_coverage_test.dart A (3)
Tempo formularep_pipeline_coverage_test.dart B1 (6)
Stability formularep_pipeline_coverage_test.dart B2 (5)
ROM formularep_pipeline_coverage_test.dart B3 (2)
Weighted sumrep_pipeline_coverage_test.dart B4 (5)
Runtime mutatorsrep_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 contractrep_pipeline_coverage_test.dart E (3)
Construction sanityrep_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.