Skip to content

Rank operators by days-without-cancellations (the unbuilt "method 3") #1801

Description

@NoamGaash

⚠️ AI-generated draft — needs human validation

This issue was written by an AI agent from materials produced during the
hasadna Open Bus hackathon (July 2026). The underlying analysis was built fast,
under hackathon conditions, and has not been peer-reviewed. Figures,
endpoint behaviour and conclusions all need independent verification before
anyone acts on them or quotes them publicly.

Please validate before implementing. Corrections very welcome.

🛑 On hold — do not start implementing

The metric this ticket proposes to build has a design problem that is not yet
resolved (see "Why this is on hold" below). The ticket is kept open because
the question it asks is worth answering; the method it proposes is probably
the wrong way to answer it. If you want to work on this, the useful contribution
right now is deciding what the metric should be — not writing the code.

Background — what this is about, from scratch

You do not need to have followed the hackathon to read this. Here is the whole story.

There is no "cancelled" flag in the Israeli bus data. A cancellation has to be
inferred. The Stride API has an endpoint, /rides_execution/list, that returns one
row per planned ride with two columns that matter: planned_start_time and
actual_start_time. If actual_start_time is null, that planned ride has no
recorded execution — which is usually a cancellation.

During the hackathon, someone built a reliability score on top of that. The idea:
for a given bus line, look at the last 15 service days. A day where every planned
ride ran counts as 1. A day with even one cancellation counts as 0. The score is
good_days / days_scored. It is deliberately harsh — the argument was that "did my
line have a clean day" is closer to lived passenger experience than "99.4% of trips
operated".

That work defined three methods, in a code comment
(orion/days_with_no_cancellations.py:6-11):

Built? Ticket
Method 1 score one line ✅ yes #1795
Method 2 score every line of one operator, ranked worst-first ✅ yes #1796
Method 3 compare operators against each other ❌ never built this ticket

Method 3 is one sentence of a comment — # method 3: script that compares average score per bus company, and provide a visualization. There is no spec beyond that.
The obvious reading is: run method 2 for every operator, average each operator's
line scores, rank the operators.

That is what this ticket originally proposed. It should probably not be built as
described.
Here is why.

Why this is on hold

@arielvino raised the objection that a
binary per-day metric is arbitrary and misleading:

A day without cancellation is a binary question. You don't know if the outage was
one single missing ride, or a 50% of all planned rides, for example. So it can flag
operators with constant small misses as worse than operators with frequent severe
outages, just because the first is smeared upon more calendar dates.

This was checked against the live API and it is correct. 52 lines across 9
operators, 15-day window (2026-08-01 → 2026-08-15), scoring logic copied from the
hackathon implementation, with a volume-weighted rate (1 − cancelled/planned)
computed from the same rows for comparison:

1. Two lines with near-identical real service, scored 6.7× apart:

line rides/day trips actually operated binary score
תנופה 11 26 89.7% 0.47
בית שמש אקספרס 12 113 87.8% 0.07

The line that runs more of its trips scores worse, because it has more chances
per day to miss one.

2. Lines with an identical score delivering very different service. Five sampled
lines scored exactly 0.20. Their actual operated-trip rates ranged from 83.4%
(גלים 494) to 96.4% (סופרבוס 1) — a 13-point spread compressed into one number.
Two lines of the same operator, גלים 492 and 494, both score 0.20 while running
94.1% and 83.4% of their trips.

3. The score tracks frequency, not just reliability. Spearman correlation between
the binary score and rides/day is −0.69.

rides/day lines mean binary score mean trips operated
< 20 17 0.90 99.2%
20–59 16 0.67 95.5%
60–119 10 0.41 95.6%
≥ 120 6 0.46 98.3%

A ~1 percentage-point difference in delivered service between the top and bottom
band shows up as a ~0.5 difference on a 0–1 headline score.

4. And that propagates straight into the operator ranking this ticket asks for.
Averaging each operator's line scores — literally what method 3 does — produced this:

operator lines sampled mean-of-binary (what method 3 publishes) trips actually operated median rides/day
מועצה אזורית גולן 6 0.96 99.6% 11
גי.בי.טורס 6 0.83 99.4% 34
תבל 2 0.77 99.8% 245
סופרבוס 10 0.72 97.1% 35
גלים 10 0.70 98.0% 47
תנופה 5 0.53 85.2% 26
בית שמש אקספרס 10 0.37 95.1% 96

תנופה delivers 10 percentage points less service than בית שמש אקספרס and ranks
above it.
The ranking is inverted for the two worst operators in the sample,
because תנופה's average is propped up by two low-frequency lines that score 1.00 and
0.93. A national table built this way would partly be measuring which operators run
rural low-frequency routes
, and publishing it as an accountability metric.

In fairness to the metric — it is not noise. Within a single frequency band it
discriminates cleanly: at ~230 rides/day, תבל lines 1 and 3 score 0.80/0.73 (99.8%
operated) while סופרבוס lines 1 and 71 score 0.20/0.27 (96.4% operated). Cancellations
also genuinely cluster on particular days — סופרבוס line 1 scores 0.20 where an
independent-failure model predicts ~0.00005, so "some days are bad days" is a real
effect the metric captures. The problem is specifically cross-line and
cross-operator comparison
, which is the only thing method 3 does.

What would actually be worth building

Pick one of these and say so on the ticket before starting:

Option A — publish a volume-weighted execution rate instead (recommended).
Pooled 1 − Σcancelled / Σplanned per operator. It is computable from the exact same
API rows at zero extra cost, it is comparable across operators, and it is what most
transit regulators publish. This is what the hackathon author themselves recommended
in the write-up's own criticism section. The binary score can stay as a secondary
"clean days" column, clearly labelled as not comparable across lines.

Option B — keep the binary score but make it comparable. Band lines by frequency
and only ever compare within a band, or normalise against an expected clean-day rate
given the line's trip count. Harder, and it still throws away the severity
information @arielvino points out.

Option C — close this ticket. Methods 1 and 2 (#1795, #1796) are per-line
diagnostics and stand on their own merits. If nobody wants to defend a
cross-operator ranking, that is a legitimate outcome and worth recording.

Blocker: feed gaps are not cancellations, and the existing guard misses them

Whatever metric is chosen, this has to be solved first, because it can manufacture a
"worst operator in Israel" out of nothing.

The hackathon code guards against this with no_actuals = (cancelled == planned)
if a line reports zero actuals for the entire window, it is drawn grey rather than
scored 0.00. That guard is not sufficient. Verified live in the same window:

  • כרמלית (operator_ref 20) — 2,040 planned rides, 2,040 "cancelled". Confirmed to
    have zero rows in /siri_rides/list for the window. It is the Haifa funicular;
    it has no SIRI feed. Caught by the guard.
  • כפיר (operator_ref 21) — 4,490 planned, 4,341 "cancelled" → 3.3% operated.
    But it does return rows from /siri_rides/list, so cancelled == planned is
    false and the guard does not fire. It would be scored 0.00 and ranked dead
    last in a national table.
  • תנופה line 41 — 847 planned, 846 "cancelled" (0.1%). Same problem, one line
    rather than a whole operator.

3 of the 52 lines sampled were in this state. Whether the root cause is feed coverage
or the SIRI→GTFS matching described in #1775 was not established here — that is
itself worth investigating. Related: #1780 (operators absent from SIRI), #1788 /
#1802 (coverage measurement).

A workable floor: exclude an operator-day whose operated rate is below some threshold
(the sampled genuinely-bad lines bottom out around 68%, while the feed-dead ones are
under 4% — there is a wide gap to put a line in), and cross-check against
network-wide SIRI volume for that day.

Getting started

  • Skills: Python or TypeScript, plus a willingness to argue about metric design.
    The statistics involved are averages and rank correlations — nothing exotic.
  • The data, hands-on: /rides_execution/list requires all four of line_ref,
    operator_ref, date_from, date_to. Resolve line_refs from /gtfs_routes/list
    for the window you are scoring — one "line" is many line_refs (line 480 was 2
    in Nov 2025 and 8 in Jul 2026), and the set changes over time. No API key needed;
    the checked-in .env already points at production.
  • Reference implementation: orion/days_with_no_cancellations.py
    — methods 1 and 2, ~380 lines, with the five live data caveats documented inline.
    Read orion/days_with_no_cancellations.md
    first; it explains why /rides_execution/list was chosen over two alternatives.
  • Known cost: one paged request per line_ref. ~80 for a small company, ~1,240
    for אגד
    . A national ranking is a batch job, not a live query — which is part of
    why Pre-aggregate derived arrival times so these analyses can run interactively #1803 (pre-aggregation) matters.
  • Do not average None as 0.0. The reference code returns None (not 0.0) for
    "no planned rides in the window" specifically so this averaging step cannot go
    wrong. Lines with no planned rides must be excluded from the denominator, not
    scored zero.

Related tickets

#1795 (method 1, per line) · #1796 (method 2, per operator) · #1794 (worst days) ·
#1775, #1780 (why actuals go missing) · #1803 (pre-aggregation, the shared cost
bottleneck)


Credit & provenance
Found during the hasadna Open Bus hackathon, July 2026 — analysis by the hackathon team.
· Method, evidence and caveats: algorithms/upstream-issues.md
· Original work: https://github.com/hasadna/open-bus-hackathon-26
· Issue drafts and the full defect list: algorithms/upstream-issues.md

Rewritten 2026-08-16 to be readable standalone, and to record the live-API check of
@arielvino's objection. The measurements in "Why this is on hold" were run by an AI
agent against the production Stride API on 2026-08-16 and are reproducible from the
description above — but they are a 52-line sample of small operators, not a census,
and have not been independently reproduced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-generatedDrafted by an AI agent; content needs human verificationbackendbackend developers issueenhancementNew feature or requestneeds-validationFindings or figures require independent confirmation before useon holdthis issue temporarily can't be worked on

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions