Skip to content

Label each station for each number of legs - #25

Merged
linusnorton merged 5 commits into
masterfrom
labels-per-legs
Sep 14, 2026
Merged

linusnorton merged 5 commits into
masterfrom
labels-per-legs

Conversation

@linusnorton

@linusnorton linusnorton commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The scan kept one label per station: its earliest arrival, with legs only breaking ties between equal
arrivals. An arrival a few minutes later in fewer legs was thrown away even when it made the same
onward train, and every journey on from it inherited the extra legs. getCompactedLegs and
getStraightenedLegs repaired some of those journeys afterwards, but couldn't recover a label the scan
never kept.

For example, KDG → SEA on 2026-09-15 departing 14:03 returned
KDG>MAN | MAN 15:00>HUD 15:41 [C24728] | HUD 15:46>EAG 17:22 [C24619] | EAG 17:34>SEA [Y14852].
The scan reached YRK at 16:37 in 3 legs via HUD, so it discarded C24728's 16:41 arrival there in 2.
Y14852 could then be boarded at YRK or EAG in 3 legs, and the tie went to the later call. Straightening
couldn't help, because C24728 never reaches EAG. The same 18:10 arrival is possible in 3 legs:
KDG>MAN | MAN 15:00>YRK 16:41 [C24728] | YRK 16:52>SEA [Y14852].

Fix

A label per number of legs

  • Each station keeps the soonest it is reached in at most 1, 2, … maxLegs legs (8 by default, set on
    ScanResultsFactory). The last label holds that many legs or more and keeps how many, so past
    maxLegs a station still has its earliest arrival in the fewest legs, as 3.0.1's single label did.
    Trips and footpaths count real legs, so boarding a trip again from a call it carried the passenger to
    always costs a leg more than staying aboard. With maxLegs of 1 the journeys are 3.0.1's.
  • An origin's departure time is only its label of no legs. A train can be boarded there at that time,
    but walking out of an origin is charged its interchange time, so the origin may still be reached
    another way in time to walk on sooner (only possible when origins set off at different times).
  • A station's labels never get later as the legs go up. A connection is boarded from the fewest legs
    whose label is in time for it, and a label is only kept if it is sooner than every label with as many
    legs or more.
  • Labels hold the time a passenger can board: arrival plus interchange, or the departure time at an
    origin. Rejecting a connection reads one number.
  • Trips keep one rank: the fewest legs boarded with, then the latest call, as before. The coupling rule
    (equal arrival, equal legs, stay aboard) still applies.
  • Footpaths are walked from each number of legs a station is reached in, and a footpath is a leg. A
    station is only walked on from by a footpath in the legs its label was reached in; otherwise a
    footpath from an origin and back can walk back and forth for ever.

Journeys

  • JourneyFactory walks back through the labels. Each label is one leg, and the label before it is the
    fewest legs its station was reached in, in time for that leg.
  • getJourneys returns one journey per destination: the earliest arrival, in the fewest legs that
    arrive then.
  • getCompactedLegs and getStraightenedLegs are removed. Over the benchmark queries neither changed
    a single journey once the scan kept labels per leg count.

Stopping

  • The scan still stops once every destination has its earliest arrival. A full Pareto front at each
    destination (later but fewer legs) needs the scan to run on until every destination has a 1-leg
    journey. That is usually the end of the timetable, and was 3.8× slower. A scan without destinations
    (transfer-pattern-planner) runs to the end anyway, so it can read every label.

Speed

  • Footpaths out of a station are all taken before any is walked on from. Walking on from each as it was
    taken reached stations through a neighbour, walked on, then reached them sooner directly and walked
    on all over again: about 4.5 sets per footpath per scan. This commit gives identical journeys on its
    own.
  • The label arrays belong to ScanResultsFactory and are filled afresh per scan, like the trip arrays.
    Allocating them per scan cost about 15%. The index a scan returns is only good until the next scan.

Verification

test/performance.ts's 24 queries at 46 departure times (every 30 minutes from 00:00) on 2026-09-15
over the GB rail feed, 1,104 queries, 5,643 journeys:

3.0.1 this PR
Arrival time at a destination different from 3.0.1 – 0
Journeys with fewer legs than 3.0.1 – 172
Journeys with more legs than 3.0.1 – 0
Total legs 13,686 13,510
Journeys with a change shorter than interchange 0 0
Journeys passing a station twice 0 0
Journeys changed by getCompactedLegs/getStraightenedLegs (before removing them) – 0

KDG → SEA at 14:03 now returns the 3-leg journey above.

With a lower maxLegs, against 3.0.1:

maxLegs Arrivals different Journeys with fewer legs Journeys with more legs Rides split into two legs
8 0 172 0 0
2 0 10 0 0
1 0 0 0 0 (journeys identical to 3.0.1)

Speed: all variants ran at once, each pinned to its own core, best of 3 passes, 3 repeats. Sequential
runs varied by ±10% on this machine.

Scan time, 1,104 queries 3.0.1 footpaths only this PR
Stopping at the destinations 3.11–3.24 s 2.27–2.30 s 2.34–2.36 s
Not stopping (as a pattern scan does) 9.43–9.99 s 9.14–9.23 s 9.56–10.11 s

The labels cost about 3% on point-to-point scans and 4–9% on full scans, against footpaths only.
Point-to-point scans end up about 25% faster than 3.0.1, and full scans about level with it.

The origin and last-label fixes, measured in a separate run of the same kind: before 2.35–2.39 s and
9.77–10.07 s, after 2.36–2.55 s and 9.71–10.12 s, with 3.0.1 at 2.94–3.13 s and 9.28–9.89 s.

Tests, the planning ones failing on 3.0.1:

  • A later arrival in fewer legs that still makes the next trip (the reduced KDG → SEA case).
  • Footpaths walked on from each number of legs a station is reached in.
  • A journey arriving as soon in fewer legs, scanned after the destination is first reached.
  • A journey of more legs than maxLegs.
  • A trip stayed aboard rather than boarded again at its next call, with labels up to 1, 2 and 8 legs.
  • A walk on through an origin reached from another origin before its interchange time is up.
  • No walking back and forth between an origin and a station no time away (overflowed the stack without
    the exact-legs check).
  • ScanResults: a later arrival in fewer legs kept alongside a sooner one, a label no sooner in more
    legs rejected, transfers per number of legs, whether a station is still reached by a transfer.
  • Each scan starts with nothing reached.

Notes:

  • The changeset is a minor. ConnectionIndex is now { levels, boardingTimes, connections } indexed
    by station * levels + legs. ScanResults.setConnection returns legs, and isTransferBetter,
    setTransfer and isReachedByTransfer take the legs walked from. By .changeset/README.md an export
    change is breaking, so this could be a major instead.
  • transfer-pattern-planner has its own copy of the scan loop, compaction and straightening against the
    old index, and needs updating. Reading every label rather than one per station is what gives it the
    fewer-change patterns raptor finds.

https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS

Walking on from each footpath as it was taken reached stations through a
neighbour, walked on from them, then reached them sooner directly and walked
on from them all over again. Over the GB rail benchmark queries each footpath
was set about four and a half times a scan. Taking them all first gives the
same journeys and scans 12-25% faster.

Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS
A station kept only its earliest arrival, with legs breaking ties, so an
arrival a few minutes later in fewer legs was thrown away even when it made the
same onward train. Each station now keeps the soonest it is reached in at most
each number of legs, up to maxLegs with the last holding that many or more, and
a trip is boarded from the fewest legs in time for it. A journey is walked back
through the labels, and each destination's is its earliest arrival in the
fewest legs that arrive then.

The labels are the factory's and reused by every scan, as the trip arrays
already were: allocating them for each scan cost about 15%. Their times are
when a passenger can board, arrival plus interchange, so rejecting a connection
reads one number.

Over the benchmark queries the arrival at every destination is unchanged, 172
of 5,643 journeys take fewer legs and none more, and getCompactedLegs and
getStraightenedLegs change none of them.

Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS
getCompactedLegs and getStraightenedLegs made up for the scan only knowing the
legs of each station's earliest arrival. With a label per number of legs a
journey is already on the fewest legs and changes at the latest call, and over
the benchmark queries neither changed any journey.

Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS
Every label of an origin held its departure time, but walking out of an origin
is charged its interchange time where boarding a train there is not. Reaching
the origin some other way within that time was discarded as no sooner, even
when walking on from it would be. Only the origin's label of no legs now holds
its departure time.

With its other labels open, a footpath from an origin and back could leave each
station reached by the other's footpath, and walking on from them went back and
forth for ever. A station is only walked on from by a footpath in the legs its
label was reached in.

Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS
The last label held that many legs or more without saying how many, so boarding
a trip again from a call it had carried the passenger to cost no extra leg. The
tie went to the later call, and one ride became two legs, which
getCompactedLegs used to merge back. Past the last label an equal arrival also
could not be told apart on legs.

The last label now keeps its real number of legs, trips and footpaths count
legs past maxLegs, and an equal arrival in fewer legs replaces the last label,
as the one label per station of 3.0.1 did. Over the benchmark queries maxLegs
of 8 gives the same journeys as before, 2 no longer splits 40 rides or adds legs
to 376 journeys, and 1 gives exactly 3.0.1's journeys.

Claude-Session: https://claude.ai/code/session_01Pqqed3uofhXmvwhRLgu8sS
@linusnorton
linusnorton merged commit 01a7c03 into master Sep 14, 2026
4 checks passed
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