intermediate
rider-score
scoring
formula

Scoring Formula

How per-trip and rolling rider scores are computed - inputs, weights, and the EWMA model.

Levy Fleets TeamMay 18, 202610 min read

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

SignalSourceWhat it measures
Speed complianceride_locations.speed vs zone speed limit% of GPS samples at or under the zone limit. Normalized per zone, not absolute mph.
Parking complianceride_zone_events end-of-ride eventBinary: did the ride end in an approved parking zone?
Geofence violationsride_zone_events mid-rideCount, decayed by recency. Older violations within the trip count less.
Hard brakingGPS deceleration deltas from ride_locationsCount of deceleration events above threshold. Full IMU is used where the vehicle has a G-sensor.
Throttle aggressionride_locations.throttle_position% of ride spent above 85% throttle (where the vehicle reports it).
Clean endrides.end_methodTrue if the ride ended cleanly. force_end_by_operator_misuse penalizes; battery-dead force-ends do not.
Helmet verifiedrider_helmet_selfies.passed_atTrue if a non-expired helmet selfie was on file at unlock.
Sidewalk eventsCV pipeline (project 4)Count of sidewalk-detected frames. Weight is 0 until the CV pipeline ships.
Open violationsviolations.ride_idEach open violation removes 5 points. charged_external, disputed, and waived are ignored.
Open interventionsrider_interventions open countEach 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 tierRolling score
Platinum90+
Gold80 to 89
Silver70 to 79
Bronze50 to 69
At Riskbelow 50
Beginnerfewer 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

CodePurpose
src/lib/rider-score/compute-trip-score.tsPulls signals, computes per-trip score, writes ride_safety_signals.
src/lib/rider-score/update-rolling-score.tsComputes EWMA rolling score, picks tier, upserts rider_scores.
src/lib/rider-score/post-ride.tsCoordinator called from the ride-end pipeline. Soft-fail.
src/lib/rider-score/intervention-engine.tsEvaluates the ladder against new score.
src/lib/rider-score/reward-engine.tsIssues 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.