Optimized route planner - #31
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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
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.
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
hideSlowTrainsis 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
40ec803b79d350e3and58b6a93ff40292e6). A new test also checks the search against a verbatim copy of the old loop on 300 random timetables, tie-breaks included.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