Scoring Formula
The scoring pipeline lives in src/lib/rider-score/. The pure kernel is scoreFromSignals() in compute-trip-score.ts and is unit-tested. The rolling score lives in update-rolling-score.ts.
This article explains exactly what each signal measures, how it is combined, and how the rolling 90-day score relates to per-trip scores.
Per-trip score
Each completed ride gets a score from 0 to 100, clipped at both ends, written to ride_safety_signals along with a weights_snapshot so historical scores stay reproducible even when you change weights later.
Default formula
score = clamp(
20 * speed_compliance_pct
+ 15 * parking_compliance
+ 15 * (1 - geofence_violation_decay)
+ 10 * (1 - hard_brake_rate)
+ 10 * (1 - throttle_aggression_rate)
+ 10 * clean_end_bool
+ 10 * helmet_verified_bool
+ 10 * (1 - sidewalk_event_rate) // weight 0 until CV ships
- 5 * open_violation_count
- 2 * open_intervention_count,
0, 100
)
Inputs
| Signal | Source | What it measures |
|---|---|---|
| Speed compliance | ride_locations.speed vs zone speed limit | % of GPS samples at or under the zone limit. Normalized per zone, not absolute mph. |
| Parking compliance | ride_zone_events end-of-ride event | Binary: did the ride end in an approved parking zone? |
| Geofence violations | ride_zone_events mid-ride | Count, decayed by recency. Older violations within the trip count less. |
| Hard braking | GPS deceleration deltas from ride_locations | Count of deceleration events above threshold. Full IMU is used where the vehicle has a G-sensor. |
| Throttle aggression | ride_locations.throttle_position | % of ride spent above 85% throttle (where the vehicle reports it). |
| Clean end | rides.end_method | True if the ride ended cleanly. force_end_by_operator_misuse penalizes; battery-dead force-ends do not. |
| Helmet verified | rider_helmet_selfies.passed_at | True if a non-expired helmet selfie was on file at unlock. |
| Sidewalk events | CV pipeline (project 4) | Count of sidewalk-detected frames. Weight is 0 until the CV pipeline ships. |
| Open violations | violations.ride_id | Each open violation removes 5 points. charged_external, disputed, and waived are ignored. |
| Open interventions | rider_interventions open count | Each open intervention removes 2 points. |
Cold-start rule
For rides 1-3, the rider is in Beginner tier. The score is recorded but does not drive interventions and does not yet contribute to the rolling score.
Short-ride exclusion
Rides shorter than 60 seconds or 200 meters are excluded from the rolling score (configurable). They generate too much noise to score reliably.
Ride-length normalization
Per-trip scoring is rate-based, not total-based. Hard brake count, geofence violation count, and throttle aggression are computed per minute or per kilometer, not as raw totals. A long ride is not penalized for being long.
Rolling rider score
The rolling score is an exponentially weighted moving average of per-trip scores over the last 90 days.
rolling_score = clamp(
sum(trip_score_i * weight_i) / sum(weight_i),
0, 100
)
where weight_i = exp(-ln(2) * age_days_i / halflife_days)
- Window: 90 days, configurable.
- Halflife: 30 days, configurable. A trip from 30 days ago is worth half what today's trip is worth. A trip from 60 days ago is worth a quarter. A trip from 90 days ago is worth one-eighth.
- Cold start: if the rider has fewer than 3 scored rides in the window, they stay in Beginner tier regardless of computed score.
Stored on rider_scores.rolling_score. Updated after each new trip score lands.
Tier assignment
After the rolling score updates, the rider's tier is recomputed using the per-subaccount tier thresholds. The pickTierForScore() function is unit-tested.
| Default tier | Rolling score |
|---|---|
| Platinum | 90+ |
| Gold | 80 to 89 |
| Silver | 70 to 79 |
| Bronze | 50 to 69 |
| At Risk | below 50 |
| Beginner | fewer than 3 scored rides |
Tier transitions are what trigger reward grants and intervention ladder re-evaluations. A move from Gold to Platinum may queue a reward. A move from Bronze to At Risk may open the next ladder step.
Reproducibility
Every per-trip score row carries a weights_snapshot JSON blob with the exact weights, thresholds, and signals used to compute it. If you later change your weights, old trip scores stay frozen with the original weights for audit and appeal purposes. The rolling score is the only thing that re-aggregates against new weights when you change them.
What runs where
| Code | Purpose |
|---|---|
src/lib/rider-score/compute-trip-score.ts | Pulls signals, computes per-trip score, writes ride_safety_signals. |
src/lib/rider-score/update-rolling-score.ts | Computes EWMA rolling score, picks tier, upserts rider_scores. |
src/lib/rider-score/post-ride.ts | Coordinator called from the ride-end pipeline. Soft-fail. |
src/lib/rider-score/intervention-engine.ts | Evaluates the ladder against new score. |
src/lib/rider-score/reward-engine.ts | Issues tier rewards through the ride-refund pipeline. |
What does not happen
- We do not score the operator or the fleet.
- We do not penalize rides cut short by a dead battery or operator retrieval.
- We do not reuse a score across subaccounts in v1. Each operator has its own rider score for the same customer.
Next
See Weights and Settings to tune the formula. See Troubleshooting if a score does not match a rider's expectation.