Skip to content

Optimized route planner - #31

Merged
planecore merged 15 commits into
mainfrom
optimized-route-planner
Sep 4, 2026
Merged

Optimized route planner#31
planecore merged 15 commits into
mainfrom
optimized-route-planner

Conversation

@planecore

@planecore planecore commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Reworks itinerary search and selection so the planner never lists a journey slower than one it could have found, and stops listing journeys nobody would take.

Two files, no behaviour outside the planner.

How it was checked

Every ordered station pair (4,692) replayed for a full Sunday, then swept minute by minute: for each time a rider could arrive at the origin, is the earliest arrival we offer the best the timetable allows?

That check passes everywhere. Before these changes it failed on 354 pairs, costing 615,788 rider-minutes, including 1,950 minutes where the planner returned nothing at all.

before after
Pairs where a rider arrives later than they need to 354 0
Minutes offering nothing 1,950 0
Duplicate itineraries 13,339 0
Itineraries listed ~192,000 ~157,600

115 unit tests, all passing.

Search

Per-round RAPTOR labels. Keeping one global best-per-station is only a valid dominance test without a maximum connection window. With a ceiling in force, reaching a hub sooner can be useless when the wait then exceeds it — and the later arrival that would have connected was discarded for being "worse", losing the journey entirely. Labels are now kept per round, which also makes reconstruction exact: every step back drops precisely one round, so a journey can no longer come out with more legs than the budget that produced it.

Always take the earliest arrival. Selection used to accept a journey up to 20 minutes later if it saved a change, and never emitted the faster one as a separate option, so it was unreachable. Where arrivals tie, fewer changes still wins.

Up to three transfers, one connection ceiling. The tiered ceiling fallback made journeys slower for no gain.

Correctness

  • Alighting at Savidor. Its long dwell is the right time to show when boarding or passing through, and the wrong one when getting off — every journey ending or changing there read minutes late.
  • The day boundary, twice. Both ends of the window now test the time the rider is shown. Testing the upper end on departure dropped the last train out of Binyamina (in at 23:59, out at 00:01) along with every onward journey, leaving some stations blank after 23:08; testing the lower end on departure let a Saturday-night train head the Sunday list.
  • The last departures of the night, which were detours out and back rather than journeys. 118 of them across a day, and 43 options they had been beating are now listed. Every pair that loses its last entry keeps the same arrival — the boarding that replaces it is the first row of the next page.
  • Duplicates. An itinerary is identified by its own trains and departure, not by the train that seeded the search. The two can differ, which listed thousands two or three times and left the sort key describing a train the rider never boards.
  • Ordering. Ranked on the displayed boarding time. Using the underlying departure put an itinerary showing 05:54 ahead of one showing 05:55, so a rider arriving at 05:55 read the first as gone and lost a connection that was still there.

What gets left out

Each rule below requires the survivor to depart no earlier, change no more often and arrive no later, so none of them can delay anybody. Direct trains are exempt from all of it — every one is a real train to the destination and is listed however the timetable is arranged around it.

  • Riding out to catch a train that calls at the origin anyway, forwards or backwards, when that boarding is soon after. Applied once the change has been placed, and regardless of which page the simpler boarding lands on: a call past midnight is never a first train on this page, but it heads the next one, so requiring it in-day only kept the detour — the last departure out of Hadera-West ran south to Netanya at 23:56 to meet a train calling at Hadera-West itself at 00:21, arriving no earlier for two changes instead of one.
  • An all-night ride round the country: more than three times the straight-line distance through its changes, and over four hours. The one rule here that asks nothing about what else is on offer, because the case it exists for is a trip with only one listing — which then becomes the standard it is measured against and excuses itself. Jerusalem to Pa'ate Modi'in, half an hour's trip, was offered as 23:36 out to the airport, north to Ako and back down to Modi'in at 05:54. Both halves of the test are load-bearing: distance alone would take Nahariya to Karmiel and Hadera-East to Migdal Ha'emek, which run that way all day because no line joins them; hours alone would take the last train to Karmiel, three hours the same as every other train to Karmiel.
  • An hour and a half of the journey spent waiting. A single connection may run to 70 minutes, but nothing bounded a journey as a whole, so two changes could stack up over two hours on platforms. Jerusalem to Pa'ate Modi'in is a seventeen-minute trip; the 03:32 waited 56 minutes at the airport and 42 more at Modi'in-Center. Past 90 minutes of waiting the journey has to be worth it — something leaving later that gives the rider back ten minutes across both ends. Two bounds on what this costs: nothing leaving later at all means it is the last of them and it stays, and the rider may be asked to arrive at most half an hour later. 90 minutes clears the longest wait any daytime service asks for — Yavne-East to Lehavim-Rahat changes at Be'er Sheva with 62 minutes to wait, hourly all afternoon.
  • Doubling back, where a change sits outside the corridor between the two ends — either farther from the destination than the origin is, or farther from the origin than the destination is. Not disqualifying on its own; it halves the time a journey may waste before something else displaces it.
  • Wasted time: another journey gives back ten minutes of the day once the extra wait at the origin is counted alongside the later arrival. Waiting 27 minutes to arrive 4 minutes sooner is not a trade; two minutes for two minutes is, and stays.
  • Overtaken departures: something leaves after it and still arrives five minutes sooner. A departure at :08 has nothing to offer while a :15 express is running — and off-peak, with nothing behind it, the same departure is the best there is and stays.
  • Too many changes for what the trip is worth: one more than the quickest journey still to come from that departure, never measured against the whole day, or the rush-hour express would rule out the only three-change route available at midnight.
  • One itinerary per arrival minute among journeys with a change, keeping the one that leaves latest.

Where a change is made

Given a comfortable change either way, the journey breaks at Haifa Center, Lod, Binyamina or Ashkelon rather than in Tel Aviv — easier places to be standing. It applies only after the tight-change test, and only between stations both trains already call at, so the trains and the arrival are the same either way.

Notes for review

  • Around ten tests were updated where they pinned behaviour this changes; each now asserts the new rule with the case that motivated it.
  • Several thresholds are judgment calls rather than derived values — five minutes for an overtaking arrival, ten for wasted time (five on a detour), the 2× / 10-minute pair for a disproportionate journey, and the twenty-minute bar for buying a tight connection. They were tuned against a single day's timetable, so a second day would be a genuine holdout.
  • hideSlowTrains is unchanged in intent and still filters (a sample of 182 pairs goes from 8,501 listed journeys to 8,069). Its allowance for a slightly slower direct is the only path by which a direct train is ever hidden.

Performance and timeouts (follow-up commit)

The search core now indexes the day's calls per station, so a round only looks at the trains leaving a reached station inside the connection window, found by binary search, instead of scanning every trip in the table. The merged three-day table and the legs the planner chooses are kept per feed, date, pair and toggle. Only the schedule-side plan is cached: the SIRI snapshot is read and laid over it on every request, so live delays, platforms and cancellations are exactly as fresh as before.

Output is unchanged. Every ordered station pair (4,692), both toggle states, on each of two days — 9,384 outputs per day — is byte-identical to the previous code (per-day digests 40ec803b79d350e3 and 58b6a93ff40292e6). A new test also checks the search against a verbatim copy of the old loop on 300 random timetables, tie-breaks included.

before after
Uncached search, Tel Aviv origin ~275 ms ~55 ms
Cached search 0.5 ms
20 identical concurrent requests 7.4 s 7 ms
Full 9,384-pair sweep 618 s 131 s

Postgres calls now time out (5 s to connect, 30 s per statement, on both the client and the server side), so a database that stops answering fails the request with a 500 instead of holding it open indefinitely. The ingest switches the statement limits off for its long-running COPY. 119 unit tests, all passing.

https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
https://claude.ai/code/session_01X8gxLxizwRk2bPft3PfYbN

Reworks itinerary search and selection so the planner never lists a journey
slower than one it could have found, and stops listing journeys nobody would
take.

Search
- Keep RAPTOR labels per round instead of one global best-per-station. With a
  maximum connection window in force, "arrives earliest" is not a dominating
  label: reaching a hub sooner can be useless when the wait then exceeds the
  ceiling, and the later arrival that would have connected was being discarded
  for being "worse", losing the journey outright. Per-round labels also make
  reconstruction exact, so a journey can no longer come back with more legs
  than the budget that produced it.
- Always take the earliest arrival. Selection used to accept a journey up to
  20 minutes later if it saved a change, and never emitted the faster one at
  all, so it was unreachable.
- Search up to three transfers, with a single connection ceiling rather than a
  tiered fallback that only made journeys slower for nothing.

Correctness
- Fix the alighting time at Tel Aviv Savidor: its long dwell is the right time
  to show when boarding or passing through, but the wrong one when getting
  off, which put every journey ending or changing there minutes late.
- Bound the day on the time the rider is shown, not on departure. A train that
  pulls in at 23:59 and leaves at 00:01 belongs to this day; testing departure
  dropped it and every onward journey with it, leaving some stations with
  nothing listed after 23:08.
- Identify an itinerary by its own trains and departure rather than by the
  train that seeded the search. The two can differ, which listed thousands of
  itineraries two or three times and left the sort key describing a train the
  rider never boards.
- Order and compare on the displayed boarding time. Ranking by the underlying
  departure put an itinerary showing 05:54 ahead of one showing 05:55, so a
  rider arriving at 05:55 read the first as gone and lost a connection that
  was still there.

Noise
- Drop journeys that ride out of the way to board a train calling at the
  origin anyway, in either direction, but only when that simpler boarding is
  actually listed and soon.
- Drop journeys far longer than the best way to make the same trip when
  something else already covers them — riding a stop up the line to wait 34
  minutes for the train that would have collected you. Both a ratio and a
  margin, so short hops are not judged by proportion alone, and never a direct
  train.

Selection keeps one itinerary per arrival minute, preferring the later
departure since the difference is time spent on a platform, and never
collapses a direct train away. Hiding slower options stays opt-in behind
hideSlowTrains, where a direct train within 15 minutes of the best option
still survives.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
An itinerary that lands on the same minute as one with fewer changes, and
departs no later than it, gives something up for nothing: you set out earlier
and change more to arrive at the same moment.

Kiryat Motzkin -> Tel Aviv University is the shape. Riding out to Ako at 21:20
to wait for train 135 reaches the university at 23:28 — exactly when 135 gets
there having collected you at Kiryat Motzkin at 22:04. The 44 minutes and the
change buy nothing.

Only the journey that departs earlier goes; one leaving later that still lands
together is not the one giving anything up, and stays listed. Direct trains are
never dropped. Whoever could catch the dropped journey can catch the survivor,
which departs no earlier and arrives at the same moment, so this cannot delay
anybody.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
A journey that something else leaves no earlier than, changes no more often
than, and arrives at least fifteen minutes before is giving up real time for
nothing.

Netanya-Sapir -> Holon-Wolfson is the shape: a 06:26 taking three changes to
arrive 07:38, listed next to a 06:32 taking two and arriving 07:23. Later out,
sooner in, one change fewer.

The margin matters. Leaving four minutes earlier to arrive four minutes later
is a trade some riders make, so narrow cases stay listed and only a real gap
counts. Direct trains are never dropped, and the comparison runs
latest-departure first against survivors only, so nothing goes in favour of
something itself dropped — which keeps the never-slower guarantee intact.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
Two bounds were set too loose to catch journeys that are plainly not worth
listing.

The length rule needed a 30-minute margin on top of its 2x ratio, which on a
short hop is unreachable: Tel Aviv HaHagana -> Holon Junction is five minutes
direct and twenty-six via Rishon LeTsiyon — five times as long, but only
twenty-one minutes longer, so it survived. The margin drops to ten; the ratio
is what protects a roundabout route that is still in proportion.

The outclassed rule asked for a fifteen-minute gap, which spared Herzliya ->
Kiryat Motzkin at 07:43 with a change against the 07:59 direct arriving
thirteen minutes sooner. Ten minutes is where leaving earlier stops buying
anything.

Both leave the narrow cases alone. A journey four minutes behind on a trip
whose best is 33 minutes is 1.3x, misses the ratio, and misses the margin.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
Two changes to what counts as a journey worth listing.

A change at a station farther from the destination than the origin is means
riding away from where you are going and doubling back: Hadera-West to Tel Aviv
by way of Binyamina, or Kiryat Gat to Netanya through Be'er Sheva. Straight-line
distance from the station coordinates is the whole test, so a change genuinely
on the way is untouched — Netivot to Herzliya through Tel Aviv stays, because
Tel Aviv is nearer Herzliya than Netivot is. Sometimes doubling back is the only
way and has to be offered, so it only goes when another journey already covers
it.

And the margin at which a journey stops being a trade and starts being a waste
drops to five minutes. Ra'anana West to Lod at 06:08 changing at HaHagana,
against the 06:20 changing at Herzliya that still arrives eight minutes sooner,
is not a choice anybody makes. Under five minutes is left alone: setting out a
little earlier to arrive a little later is a real preference.

Together these take the redundant listings on a full day from 986 to 54. Both
require the survivor to depart no earlier, change no more often and arrive no
later, so neither can delay anybody.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
…lone

A journey that arrives only a little later can still be the wrong one to list:
waiting 27 minutes at the origin to get in 4 minutes sooner is 31 minutes of a
day spent on nothing, but the arrival gap alone reads as a near miss.

So a route is dropped when another we list gives back ten minutes or more once
both ends are counted — the extra wait as well as the later arrival — on top of
the existing test for a clearly earlier arrival. As before the survivor has to
leave no earlier, change no more often and arrive no later, so neither test can
delay anybody, and direct trains are never dropped.

Small trades stay: two minutes of waiting for two minutes of arrival is a real
preference, and there are reasons to pick a train beyond when it lands.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
A change only makes sense at a station between the two ends of the trip. It can
fall outside that in two ways, and only one was being caught: riding backwards,
farther from the destination than the origin is; or riding straight past the
stop to come back, farther from the origin than the destination is. Tel Aviv
HaHagana to Kfar Habad by way of Lod-Gane Aviv is the second — eight minutes
beyond the stop on the same line.

Geometry alone cannot decide these, because Lod to Yavne West by way of Ashdod
also leaves the corridor and is worth keeping: it costs two minutes, and a
quieter train is a reason to take it. So leaving the corridor is not
disqualifying on its own; it halves the time a journey is allowed to waste
before something else displaces it, from ten minutes to five.

A change genuinely between the two ends is untouched, whatever it costs.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
…splace a

stopping train

Two related corrections to which journeys are worth listing.

A journey may need one change more than the quickest way to make the trip, but
not two, and the standard for "quickest" is what is still to come from that
departure onwards — never the quickest of the whole day. The express that does
the trip in one go runs at rush hour; measuring an eleven-o'clock departure
against it would rule out the only three-change route anybody can take at that
time. Where the quickest way itself needs three changes, three is what the trip
costs. It applies only when another journey already covers the same trip, so
Dimona to Yokne'am at 10:17 keeps its three changes rather than sending that
rider six hours later.

And a direct train is no longer exempt from being beaten. The stopping service
at :08 has nothing to offer while the express at :15 is running, because the
express leaves after it and still arrives first. Off-peak, with no express
behind it, the same stopping train is the best there is and stays — which falls
out of comparing each departure only against what actually follows it.

Two direct trains landing on the same minute are still both listed: neither
beats the other, and one may simply be the one you can make. The toggle's
allowance for a slightly slower direct now matches the margin the plain list
uses, so hiding slow trains can only ever show less than not hiding them.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
A direct train is always a real way to make the trip, so a rider looking for one
sees it however the timetable is arranged around it. Nothing in the plain list
withholds one: not the same-arrival collapse, not the length or corridor tests,
and not a later departure that overtakes it.

The overtaking rule stays as it was for journeys with a change in them. A
departure at :08 has nothing to offer while something at :15 leaves after it and
still arrives first; off-peak, when nothing follows, that same departure is the
best there is and stays. It follows from measuring each departure against what
actually comes after it rather than against the whole day.

Only the hideSlowTrains toggle can hide a direct at all, and only one trailing
the best option by more than a quarter of an hour.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
The window for a first train tested the real departure at its lower end and the
displayed time at its upper end. A train that pulls into the origin at 23:58 and
leaves at 00:03 satisfies both, so a Sunday search opened with a Saturday-night
departure — listed as "23:58" with no hint it belonged to the previous day.

Both ends now use the time the rider is shown, which is what the bound was
always about. Journeys that start before midnight and finish after it are
unaffected: only the boarding is bounded, so the last connections of the night
still run into the small hours.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
Given a comfortable change either way, break the journey at Haifa Center, Lod,
Binyamina or Ashkelon rather than in Tel Aviv. They are easier places to be
standing, and the crowds at HaHagana or Savidor in the rush are their own
argument.

It sits after the tight-change test, so comfort is never bought with a
connection you might miss, and it only ever picks between stations both trains
already call at — the trains and the arrival time are the same either way.

Where the first train never reaches the interchange this cannot help: Be'er
Sheva to Kiryat Motzkin on train 28 ends at Savidor, so Tel Aviv is the only
place to change off it. Reaching Haifa there would mean choosing different
trains, which is a question about which journeys to plan rather than where to
break them.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
…uests

A search round now looks only at the trains leaving a reached station inside
the connection window, found by binary search into a per-station list, instead
of scanning every trip in the table on every round. The merged three-day table
and the legs the planner chooses are kept per feed; only the schedule-side plan
is cached, and the SIRI snapshot is read and laid over it on every request, so
live delays, platforms and cancellations are as fresh as before.

Output is unchanged: all 9,384 outputs on each of two days are byte-identical
to the previous code, and a new test checks the search against a copy of the
old loop on random timetables, tie-breaks included. An uncached Tel Aviv search
drops from ~275 ms to ~55 ms; a cached one is under a millisecond, and a burst
of twenty identical requests goes from 7.4 s to 7 ms.

Postgres calls now time out (5 s to connect, 30 s per statement, on both the
client and the server side), so a database that stops answering fails the
request instead of holding it open for good. The ingest switches the statement
limits off for its long-running COPY.

Claude-Session: https://claude.ai/code/session_01X8gxLxizwRk2bPft3PfYbN
…after midnight

Riding away from the origin to board a train that calls there anyway was
already refused, but only when that call fell inside the day being listed.
A call past midnight is never a first train on this page, so the check
treated it as though no simpler boarding existed — and kept the detour.

It does exist, on the next day's page, which the client loads as the rider
scrolls. The last departure out of Hadera-West ran south to Netanya at
23:56 to meet a train calling at Hadera-West itself at 00:21, arriving no
earlier for two changes instead of one; the plain 00:21 boarding heads the
following page.

Also test it once the change has been placed rather than before. Moving
where the rider gets off carries the next leg further back up the line, so
a journey can start doubling back only after the transfer is optimised.

Across a full day this drops 118 such listings and unhides 43 options they
had been beating. Every pair that loses its last entry keeps the same
arrival: the boarding that replaces it is the first row of the next page.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
Every other rule for leaving a journey out asks what else is on offer, so
that the last train of the night is never dropped for being slow. That
breaks down when the timetable has only one way to make a trip: it becomes
the standard it is measured against, and excuses itself however absurd it
is.

Jerusalem -> Pa'ate Modi'in is half an hour's trip. On a Saturday night the
sole listing was 23:36 out to the airport, north to Ako, and back down to
Modi'in at 05:54 — six and a quarter hours and 263km of straight lines to
cover 26. Sunday's page opens with a direct train arriving at the same
05:54.

So one rule needs nothing to compare against: a journey that travels more
than three times the straight-line distance through its changes and takes
over four hours is never the answer. Both halves are load-bearing. Distance
alone would take Nahariya -> Karmiel, twice the straight line because that
is where the branch leaves, and Hadera-East -> Migdal Ha'emek, nearly five
times it every hour of the day because no line joins the two; hours alone
would take the last train to Karmiel, three hours the same as every other
train to Karmiel. Checked over five days, nothing that runs in daylight is
both.

It drops 58 listings on a Sunday and 176 on a Saturday, all of them
departing after 23:30 and arriving after 06:00, each at least three times
its own pair's best for the day. No weekday page is emptied.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
A single connection may run to 70 minutes, but nothing bounded a journey as
a whole, so two changes could legally stack up over two hours of standing on
platforms. Jerusalem -> Pa'ate Modi'in is a seventeen-minute trip; the 03:32
waited 56 minutes at the airport and 42 more at Modi'in-Center to get in at
05:54, when the 04:32 does it with 54 minutes of waiting in all and lands at
06:13.

Past 90 minutes of waiting a journey now has to be worth it: another one
leaving later, changing no more often, that gives the rider back at least
ten minutes once both ends are counted. Setting out an hour later to arrive
nineteen minutes later gives back forty-one.

Two things bound what this can cost. Nothing leaving later at all means it
is the last of them and it stays, which is what keeps the 19:49 out of Bet
Shemesh, the last train north with 51 minutes of it waiting at Haifa Center.
And the rider may be asked to arrive at most half an hour later, which is
what keeps Yokne'am -> Modi'in-Center at 00:17 — grim, but the only way to
arrive before 08:14, and that is their call rather than ours.

90 minutes clears the longest wait any daytime service asks for by a wide
margin: Yavne-East -> Lehavim-Rahat changes at Be'er Sheva with 62 minutes
to wait, every hour of the afternoon. Over a Sunday the rule drops 15
listings and adds none, and no rider's earliest arrival moves by more than
the half hour the bound allows.

Claude-Session: https://claude.ai/code/session_01Weq1fraeqoMJ2UyZAMoH4R
@planecore
planecore merged commit 7e1572a into main Sep 4, 2026
1 check passed
@planecore
planecore deleted the optimized-route-planner branch September 4, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant