RFCs

Trip incident attribution

When a trip ends with a low rating or a complaint, which event in that trip's timeline most likely caused it?

RFC 0004MobilityIn designWritten 2026-10-20

Summary

We give support agents and ops teams a ranked, explained "likely cause" for every bad trip in a ride-hailing or delivery marketplace. HeapDB ranks the events of that one trip, from request to dropoff, and learns from the resolution codes agents already write.

Agents ask this when a ticket opens after a low rating, a complaint or a cancellation. Ops asks it after a rider-app release or a bad-weather night. Today both start from dashboards that show which slice moved. With HeapDB each ticket arrives with a suggested cause, and the same learned links roll up into a release-level risk view.

Model

The entity is the trip, not the rider or the driver. A trip has a short, closed timeline with a clear start and end, and every candidate cause sits inside it. Rider and driver traits, such as app version or hours online, become features on the trip's events. That keeps a per-person entity from carrying blame across unrelated trips.

HeapDBMobility
EntityOne trip, trip-<id>, from request to dropoff.
Eventsrequest, pin_edit, dispatch, eta_revision, pickup, reroute, gps_gap.
FeaturesRider app version app:rider-5.1 or app:rider-5.2 (on the request and on pin edits), app:driver-3.9, pin:edit, zone:<name>, tod:<bucket>, weather:rain or weather:dry, surge:shown, driver:hours-0-4, driver:hours-4-10, driver:hours-10plus, eta:revised-up, pickup:long or pickup:normal, route:reroute, gps:gap.
Weight (dose)register_event_type("pin_edit", weight=...): grows with how far the pin was dragged, 1.0 at 300 m, capped at 2.0. Other events weigh 1.0.
Effects and kernelslow_rating with LogNormalKernel(median=30 * M, sigma=0.8, tail_sigmas=2.0): peaks about 16 minutes before the rating, support about 2.5 hours. A complaint or rider_cancel effect would get its own, shorter kernel.
Leakleak=0.05: low ratings that no event in the trip explains.
Rulestiny pin nudge x0.2 for pin edits under 30 m.

Links start from default_prior=(1, 9). A trip has no history of its own, so global_pseudocount=1000.0 lets each trip borrow almost fully from the population layer.

Learning

  • Feedback. When an agent resolves a ticket, the resolution code (for example PIN_WRONG_LOCATION or LONG_PICKUP) maps to one event of the trip. feedback(trip, "low_rating", at, confirmed_event_id=..., rejected_ids=...) confirms that event and rejects the trip's other events.
  • Observe. Low ratings without a ticket call observe(trip, "low_rating", at). Each event in the window gets soft credit in proportion to its score, and the leak takes the rest.
  • Settle. Trips that end without a low rating call settle(until, effects="low_rating", entity=trip). "The window passed" means until is past the trip's last event plus the kernel support; the example uses dropoff plus 3 hours.
  • Priors. A sceptical default prior per feature, so most trip events start as innocent. A team could register stronger priors for known failure modes, such as long pickups.

Sidecar architecture

The system of record stays the trip service, the dispatch and telemetry pipeline, and the support tool. Trip events reach HeapDB from the existing event stream or from change data capture on the trip tables. Ratings and ticket resolutions arrive the same way.

HeapDB writes back a "likely cause" field, with its explanation, on each ticket, and a periodic link_report and feature_risk view per release. It holds no data the source systems do not have. It can be dropped and rebuilt by replaying the trip and ticket logs.

Worked example

A synthetic marketplace runs three weeks of trips. Rider app 5.2 ships on day 10 and misplaces the pickup pin, so riders have to move it, wait longer and rate low. Rain causes both surge and low ratings, so surge looks guilty on a dashboard though it does nothing.

Output of 0004-trip-incident-attribution.py

SCENARIO
  4,000 trips over 21 days, 15,035 events; rider app 5.2 rolls out from day 10.
  584 low ratings: 194 support tickets (feedback), 390 observed; 3,416 trips settled.
  Seeded cause: 5.2 misplaces the pickup pin. Distractor: surge (rain causes surge).

LIKELY CAUSE ON SUPPORT TICKETS (ranked before the agent resolves them)
  T-01250  likely: pin_edit [app:rider-5.2,pin:edit] 0.099   agent code: PIN_WRONG_LOCATION
  T-02814  likely: pin_edit [app:rider-5.2,pin:edit] 0.083   agent code: PIN_WRONG_LOCATION
  T-01057  likely: pin_edit [app:rider-5.2,pin:edit] 0.126   agent code: DRIVER_CONDUCT
  top-1 matches the agent's code on 40/88 tickets after day 14 (45%); last-event baseline 15/88 (17%)

ONE TRIP, EXPLAINED (trip-01621: rider 5.2, rain, surge shown; not learned from yet)
Top causes of 'low_rating' for trip-01621 at t=2026-09-27T23:42:11Z:
  1. #15015 pin_edit@2026-09-27T23:06:26Z [app:rider-5.2,pin:edit] w=1.56 score=0.1338
      lag 0.60h -> kernel 0.595 x weight 1.56 x link 0.144 [noisy-OR of app:rider-5.2=0.12 (a=120.64, b=889.36), pin:edit=0.03 (a=27.93, b=982.07)]
  2. #15018 pickup@2026-09-27T23:30:56Z [pickup:long] w=1 score=0.1055
      lag 0.19h -> kernel 0.913 x weight 1 x link 0.116 [noisy-OR of pickup:long=0.12 (a=90.57, b=693)]
  3. #15014 request@2026-09-27T23:04:12Z [app:rider-5.2,surge:shown,tod:night,weather:rain,zone:suburbs] w=1 score=0.0863
      lag 0.63h -> kernel 0.549 x weight 1 x link 0.157 [noisy-OR of app:rider-5.2=0.12 (a=120.64, b=889.36), weather:rain=0.03 (a=19.98, b=620.59), tod:night=0.01 (a=3.14, b=600.13), surge:shown=0.00 (a=3.53, b=829.17), zone:suburbs=0.00 (a=2.38, b=927.65)]
  4. #15017 eta_revision@2026-09-27T23:16:59Z [eta:revised-up] w=1 score=0.0426
      lag 0.42h -> kernel 0.844 x weight 1 x link 0.050 [noisy-OR of eta:revised-up=0.05 (a=25.26, b=476)]
  [segments: 3 scanned / 0 pruned / 3 in window; events scored: 5]

ACROSS ALL TRIPS (link_report, global view, top 8)
Links of 'low_rating' for (global) (baseline 0.018; associations under the model, not causal effects):
  feature                                mean          90% CI   lift        n    alpha     beta
  app:rider-5.2                         0.120 [0.108, 0.132]   6.57   1951.5   235.00  1726.48
  pickup:long                           0.116 [0.098, 0.135]   6.36    775.0    90.95   694.00
  eta:revised-up                        0.051 [0.036, 0.068]   2.78    491.4    25.42   476.00
  route:reroute                         0.036 [0.021, 0.055]   1.98    313.7    11.66   312.00
  weather:rain                          0.031 [0.021, 0.043]   1.71    632.8    20.02   622.75
  pin:edit                              0.028 [0.020, 0.036]   1.52   1161.5    32.40  1139.10
  driver:hours-10plus                   0.025 [0.016, 0.035]   1.36    682.4    17.15   675.29
  gps:gap                               0.007 [0.002, 0.015]   0.39    427.1     3.14   434.00

RELEASE VIEW (feature_risk, global links; no timing term)
  app:rider-5.2 0.120 vs app:rider-5.1 0.001; a pin edit on 5.2 0.144 vs on 5.1 0.029 (x5.0)
  dashboard slice after day 14: low-rating rate 5.2 30.0% vs 5.1 8.4%
  confounder: low-rating rate with surge 18.6% vs without 13.4%,
  yet surge:shown ranks #10 of 25 links (weather:rain #5)

CHECK
  explained trip: #1 is the 5.2 pin edit: True; 5.2 risk above 2x 5.1 (feature and pin edit): True
  ticket top-1 beats last-event baseline: True; pin:edit link rank #6, app:rider-5.2 #1, surge:shown #10
Show the full script (261 lines)
"""Trip incident attribution: what most likely went wrong on THIS trip?

Simulates three weeks of a ride-hailing marketplace: 4,000 trips across four
zones. Each trip is one HeapDB entity, and its events are the trip's own
timeline from request to dropoff: the request (rider app version, zone, time
of day, weather, surge shown), pickup-pin edits, the dispatch (driver app
version, hours online), upward ETA revisions, the pickup (long or normal),
reroutes and GPS gaps. The effect is a low rating (1-2 stars), submitted a
few minutes after dropoff.

Hidden ground truth (the seeded cause):

* Rider app 5.2 ships on day 10 (staged rollout to 70% of riders over three
  days). It misplaces the pickup pin. Riders on 5.2 edit the pin far more
  often and by hundreds of meters, and each such edit causes a low rating
  with probability 0.30 (0.02 for an ordinary edit on 5.1). Wrong pins also
  make long pickups more likely, which cause low ratings on their own (0.12).
* Smaller true causes: rain 0.08, ETA revised upward 0.05, reroute 0.03,
  driver online 10h or more 0.05. Background (no event): 3% of trips.
* Distractor and honest confounder: surge has NO effect, but rain triggers
  surge, so surge is shown on many low-rated trips. Downtown has more long
  pickups and more GPS gaps; GPS gaps have no effect either.

Learning, in time order, as a support team would see it:

* 35% of low ratings become a support ticket. Before the agent works it,
  HeapDB ranks the trip's events ("likely cause" on the ticket). The agent's
  resolution code names the real cause: feedback() confirms that event and
  rejects the trip's other events.
* Low ratings without a ticket are observe()d (no label).
* Trips that end without a low rating are settle()d once their window passed.

Run:
    pip install heapdb
    python 0004-trip-incident-attribution.py
"""

from __future__ import annotations

import math
import random

from heapdb import HeapDB, LogNormalKernel, format_result

M, H, D = 60.0, 3600.0, 86400.0
SEED = 11
N_TRIPS = 4000
DAYS = 21
T0 = 1788739200.0          # 2026-09-07T00:00:00Z; times are seconds after T0
RELEASE_DAY = 10           # rider app 5.2 starts rolling out
EFFECT = "low_rating"

ZONES = {  # zone -> (share of trips, P(long pickup), P(GPS gap))
    "zone:downtown": (0.35, 0.22, 0.25),
    "zone:airport": (0.15, 0.10, 0.05),
    "zone:eastside": (0.25, 0.08, 0.06),
    "zone:suburbs": (0.25, 0.12, 0.04),
}
TRUTH = {  # (event type, feature) -> P(this event causes a low rating)
    "pin_bug": 0.30, "pin_ok": 0.02, "pickup:long": 0.12, "weather:rain": 0.08,
    "eta:revised-up": 0.05, "route:reroute": 0.03, "driver:hours-10plus": 0.05,
}
BACKGROUND = 0.03
CODES = {"pin_bug": "PIN_WRONG_LOCATION", "pin_ok": "PIN_WRONG_LOCATION",
         "pickup:long": "LONG_PICKUP", "weather:rain": "WEATHER", "eta:revised-up": "ETA_MISSED",
         "route:reroute": "ROUTE", "driver:hours-10plus": "DRIVER_CONDUCT", None: "OTHER"}


def rider_on_52(rng: random.Random, t: float) -> bool:
    day = t / D
    if day < RELEASE_DAY:
        return False
    return rng.random() < 0.70 * min(1.0, (day - RELEASE_DAY) / 3.0)


def tod_bucket(t: float) -> str:
    h = (t % D) / H
    return ("tod:late-night" if h < 5 else "tod:morning-peak" if h < 10 else
            "tod:midday" if h < 16 else "tod:evening-peak" if h < 20 else "tod:night")


def simulate(rng: random.Random):
    """Return a list of trips: dict(id, events=[(t, type, feats, attrs, cause_key)], ...)."""
    rain_days = {d for d in range(DAYS) if rng.random() < 0.3}
    trips = []
    for i in range(N_TRIPS):
        t = rng.uniform(0, DAYS * D)
        zone = rng.choices(list(ZONES), [z[0] for z in ZONES.values()])[0]
        _, p_long, p_gps = ZONES[zone]
        rain = int(t // D) in rain_days and rng.random() < 0.8
        surge = rng.random() < (0.60 if rain else 0.15)
        v52 = rider_on_52(rng, t)
        app = "app:rider-5.2" if v52 else "app:rider-5.1"
        tod = tod_bucket(t)
        evs = []
        req = {app, zone, tod, "weather:rain" if rain else "weather:dry"}
        if surge:
            req.add("surge:shown")
        evs.append((t, "request", req, {}, "weather:rain" if rain else None))
        # Pin edits: the 5.2 bug misplaces the pin, so riders drag it a long way.
        n_edits = (rng.choice([1, 1, 2, 3]) if rng.random() < 0.55 else 0) if v52 else \
            (1 if rng.random() < 0.12 else 0)
        tp = t
        for _ in range(n_edits):
            tp += rng.uniform(0.3, 2.5) * M
            moved = rng.uniform(150, 700) if v52 else rng.uniform(10, 160)
            evs.append((tp, "pin_edit", {"pin:edit", app}, {"moved_m": round(moved)},
                        "pin_bug" if v52 else "pin_ok"))
        td = tp + rng.uniform(0.5, 2) * M
        hours = rng.choices(["driver:hours-0-4", "driver:hours-4-10", "driver:hours-10plus"],
                            [0.45, 0.40, 0.15 if tod != "tod:late-night" else 0.45])[0]
        drv = rng.choice(["app:driver-3.8", "app:driver-3.9"])
        evs.append((td, "dispatch", {drv, hours}, {}, hours if hours.endswith("10plus") else None))
        if n_edits and v52:
            p_long = max(p_long, 0.55)
        if rain:
            p_long += 0.10
        long_pickup = rng.random() < p_long
        wait = rng.uniform(12, 25) * M if long_pickup else rng.uniform(3, 9) * M
        if long_pickup and rng.random() < 0.7:
            evs.append((td + 0.4 * wait, "eta_revision", {"eta:revised-up"}, {}, "eta:revised-up"))
        tpk = td + wait
        evs.append((tpk, "pickup", {"pickup:long" if long_pickup else "pickup:normal"}, {},
                    "pickup:long" if long_pickup else None))
        ride = rng.uniform(8, 30) * M
        if rng.random() < 0.08:
            evs.append((tpk + rng.uniform(0.2, 0.8) * ride, "reroute", {"route:reroute"}, {},
                        "route:reroute"))
        if rng.random() < p_gps:
            evs.append((tpk + rng.uniform(0.1, 0.9) * ride, "gps_gap", {"gps:gap"}, {}, None))
        dropoff = tpk + ride
        # Outcome: noisy-OR over the trip's events, plus background.
        fired = []
        for ev in evs:
            key = ev[4]
            if key and rng.random() < TRUTH[key]:
                fired.append(ev)
        low = bool(fired) or rng.random() < BACKGROUND
        trips.append({"id": f"trip-{i:05d}", "events": sorted(evs, key=lambda e: e[0]),
                      "v52": v52, "rain": rain, "surge": surge, "end": dropoff,
                      "rating_at": dropoff + rng.uniform(1, 8) * M if low else None,
                      "cause": rng.choice(fired) if fired else None})
    trips.sort(key=lambda tr: tr["end"])
    return trips


def make_db() -> HeapDB:
    db = HeapDB(bucket_seconds=10 * M)
    # Dose of a pin edit grows with how far the pin was dragged (1.0 at 300 m).
    db.register_event_type("pin_edit", weight=lambda a: min(2.0, 0.4 + a["moved_m"] / 500))
    # Rating lags its cause by minutes to about an hour; a trip has no history of
    # its own, so each trip borrows almost fully from the population layer.
    db.register_effect(EFFECT, LogNormalKernel(median=30 * M, sigma=0.8, tail_sigmas=2.0),
                       default_prior=(1, 9), global_pseudocount=1000.0, leak=0.05)
    db.add_rule(EFFECT, "tiny pin nudge", lambda e: e.attrs.get("moved_m", 999) < 30, 0.2,
                types={"pin_edit"})
    return db


def main() -> None:
    rng = random.Random(SEED)
    trips = simulate(rng)
    db = make_db()
    pick = max((tr for tr in trips if tr["rating_at"] and tr["v52"] and tr["rain"]
                and tr["surge"] and tr["cause"] and tr["cause"][4] == "pin_bug"),
               key=lambda tr: tr["end"])
    ids = {}
    n_ticket = n_obs = n_settle = 0
    hits = base_hits = n_eval = 0
    ticket_lines, case_text = [], None
    for tr in trips:
        tid = tr["id"]
        ids[tid] = [db.insert(tid, T0 + t, typ, feats, attrs=attrs).id
                    for t, typ, feats, attrs, _ in tr["events"]]
        if tr["rating_at"] is None:
            db.settle(T0 + tr["end"] + 3 * H, effects=EFFECT, entity=tid)
            n_settle += 1
            continue
        at = T0 + tr["rating_at"]
        if tr is pick:
            case_text = format_result(db.top_causes(tid, EFFECT, at, k=4, explain=True))
        if rng.random() < 0.35 and tr is not pick:          # a support ticket
            n_ticket += 1
            res = db.top_causes(tid, EFFECT, at, k=3)        # "likely cause" on the ticket
            cause = tr["cause"]
            true_id = ids[tid][tr["events"].index(cause)] if cause else None
            if tr["end"] > (RELEASE_DAY + 4) * D and cause is not None:
                n_eval += 1
                hits += res.top is not None and res.top.event.id == true_id
                base_hits += ids[tid][-1] == true_id         # baseline: last event before rating
                if len(ticket_lines) < 3 and tr["v52"]:
                    top = res.top.event
                    ticket_lines.append(f"  T-{tid[5:]}  likely: {top.type} "
                                        f"[{','.join(sorted(top.features))}] {res.top.score:.3f}"
                                        f"   agent code: {CODES[cause[4]]}")
            others = [e for e in ids[tid] if e != true_id]
            db.feedback(tid, EFFECT, at, confirmed_event_id=true_id, rejected_ids=others)
        else:
            db.observe(tid, EFFECT, at)
            n_obs += 1

    low = [tr for tr in trips if tr["rating_at"]]
    post = [tr for tr in trips if tr["end"] > (RELEASE_DAY + 4) * D]

    def rate(rows):
        return sum(1 for tr in rows if tr["rating_at"]) / max(1, len(rows))

    print("SCENARIO")
    print(f"  {len(trips):,} trips over {DAYS} days, {sum(len(tr['events']) for tr in trips):,} events; "
          f"rider app 5.2 rolls out from day {RELEASE_DAY}.")
    print(f"  {len(low)} low ratings: {n_ticket} support tickets (feedback), {n_obs} observed; "
          f"{n_settle:,} trips settled.")
    print("  Seeded cause: 5.2 misplaces the pickup pin. Distractor: surge (rain causes surge).")
    print()
    print("LIKELY CAUSE ON SUPPORT TICKETS (ranked before the agent resolves them)")
    print("\n".join(ticket_lines))
    print(f"  top-1 matches the agent's code on {hits}/{n_eval} tickets after day {RELEASE_DAY + 4} "
          f"({hits / n_eval:.0%}); last-event baseline {base_hits}/{n_eval} ({base_hits / n_eval:.0%})")
    print()
    print(f"ONE TRIP, EXPLAINED ({pick['id']}: rider 5.2, rain, surge shown; not learned from yet)")
    print(case_text)
    print()
    print("ACROSS ALL TRIPS (link_report, global view, top 8)")
    rep = db.link_report(EFFECT, min_support=20)
    lines = str(rep).splitlines()
    print("\n".join(lines[:2 + 8]))
    feats = [r.feature for r in rep]
    print()
    print("RELEASE VIEW (feature_risk, global links; no timing term)")
    r52 = db.feature_risk(EFFECT, {"app:rider-5.2"})
    r51 = db.feature_risk(EFFECT, {"app:rider-5.1"})
    e52 = db.feature_risk(EFFECT, {"pin:edit", "app:rider-5.2"}, weight=1.0, type="pin_edit")
    e51 = db.feature_risk(EFFECT, {"pin:edit", "app:rider-5.1"}, weight=1.0, type="pin_edit")
    print(f"  app:rider-5.2 {r52:.3f} vs app:rider-5.1 {r51:.3f}; "
          f"a pin edit on 5.2 {e52:.3f} vs on 5.1 {e51:.3f} (x{e52 / e51:.1f})")
    v52 = [tr for tr in post if tr["v52"]]
    v51 = [tr for tr in post if not tr["v52"]]
    print(f"  dashboard slice after day {RELEASE_DAY + 4}: low-rating rate 5.2 {rate(v52):.1%} "
          f"vs 5.1 {rate(v51):.1%}")
    surge = [tr for tr in trips if tr["surge"]]
    calm = [tr for tr in trips if not tr["surge"]]
    s_rank = feats.index("surge:shown") + 1 if "surge:shown" in feats else None
    print(f"  confounder: low-rating rate with surge {rate(surge):.1%} vs without {rate(calm):.1%},")
    print(f"  yet surge:shown ranks #{s_rank} of {len(feats)} links "
          f"(weather:rain #{feats.index('weather:rain') + 1})")
    print()
    print("CHECK")
    top = case_text.splitlines()[1]
    case_ok = " pin_edit@" in top
    risk_ok = r52 > 2 * r51 and e52 > 2 * e51
    print(f"  explained trip: #1 is the 5.2 pin edit: {case_ok}; "
          f"5.2 risk above 2x 5.1 (feature and pin edit): {risk_ok}")
    print(f"  ticket top-1 beats last-event baseline: {hits > base_hits}; "
          f"pin:edit link rank #{feats.index('pin:edit') + 1}, "
          f"app:rider-5.2 #{feats.index('app:rider-5.2') + 1}, surge:shown #{s_rank}")
    if not (case_ok and risk_ok and hits > base_hits and s_rank > feats.index("pin:edit") + 1):
        raise SystemExit(1)


if __name__ == "__main__":
    main()
  • The ticket view ranks causes before the agent resolves the ticket. Top-1 matches the agent's code on 40/88 tickets after day 14 (45%), against 15/88 (17%) for "blame the last event". The third sample ticket is a miss: the agent coded DRIVER_CONDUCT.
  • In the explained trip, the 5.2 pin edit ranks first with score 0.1338, ahead of the long pickup at 0.1055. The request event, which carries rain and surge, ranks third.
  • app:rider-5.2 has the strongest link, with lift 6.57. The credit lands on the version, not on pin:edit: ordinary pin edits on 5.1 rarely matter, and the bug only exists on 5.2.
  • The release view gives a pin edit on 5.2 a risk of 0.144 against 0.029 on 5.1. The dashboard slice agrees: a low-rating rate of 30.0% on 5.2 against 8.4% on 5.1 after day 14.
  • Surge is the honest confounder. The low-rating rate is 18.6% with surge against 13.4% without, yet surge:shown ranks #10 of 25 links, below weather:rain at #5.

Run it:

pip install heapdb
python 0004-trip-incident-attribution.py   # copy the script from "Show the full script" above

Evaluation

  • Metric. Top-1 and top-3 agreement of the ticket "likely cause" with the agent's resolution code, on tickets held out from feedback. For releases, the rank of the release feature in link_report within a day of rollout.
  • Baseline. "Blame the last event before the rating", and a per-slice rate comparison by app version, zone and weather. HeapDB must beat the last-event rule per ticket and agree with the slice view on releases.
  • Ground truth. Agent resolution codes are noisy labels, so audit a sample by a second reviewer. For releases, staged rollouts and rollbacks give a natural comparison between versions on the same days.

Limits and risks

  • Associations, not causes. HeapDB ranks events under its model. A ranked cause is a lead for a person, not a finding of fault.
  • Never auto-penalize a driver. A driver-side feature at #1, such as hours online, must not trigger deactivation, pay changes or rating adjustments. A human reviews every action that affects a person.
  • Confounders must be features. Neighborhood, time of day and weather drive both conditions and ratings. If they are missing, their credit leaks to whatever co-occurs, such as surge or a zone's long pickups.
  • Telemetry gaps. GPS gaps and dropped events remove candidates. A trip with a missing pin edit cannot blame it, so gaps should be recorded as events, as the example does.
  • Release credit can stick to the version. As the example shows, the version feature can absorb credit from the event that is really broken. Engineers still need the session data to find the bug.
  • Location privacy. Trips encode where people live and work. Store zones and buckets, not coordinates, keep retention short, and use forget_entity(trip, scrub_global=True) on deletion requests.
  • Label quality. Agents under time pressure pick default codes. Biased codes teach biased links.

Prior art

Marketplace analytics teams already run anomaly detection on cancellations, wait times and ratings, with drill-down by dimension. The StarTree ThirdEye rideshare recipe is one example: it detects anomalies in cancellations and wait time and finds the contributing dimensions.

That answers "which slice moved". HeapDB answers "what went wrong on this trip", from that trip's own timeline, and learns from ticket resolutions. The two complement each other: slice-level detection finds the incident, and per-trip attribution explains individual tickets and checks a release hypothesis. See also Code changes for release attribution on the engineering side.

Status and open questions

In design. The example on this page runs today. Your answers to these open questions shape what we build.

  • Should the entity be the trip alone, or should rider and driver devices be second entities for causes that span trips, such as a broken phone GPS?
  • How should resolution codes map to events when one code fits several events, such as two pin edits?
  • Should complaint and rider_cancel share links with low_rating, or learn separately with their own kernels?
  • How can release credit be pushed from the version feature to the broken interaction, for example with version-specific event features?