Skip to content

Board each trip where the scan boarded it - #23

Merged
linusnorton merged 4 commits into
masterfrom
fix-journey-boarding
Sep 14, 2026
Merged

linusnorton merged 4 commits into
masterfrom
fix-journey-boarding

Conversation

@linusnorton

@linusnorton linusnorton commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The scan finds the right earliest arrival, but JourneyFactory.getLegs could describe a different
journey, including a change with less than the station's interchange time.

getLegs walked back through the last connection into each station and started a new leg wherever
consecutive connections were on different trips. If another trip reached one of a trip's
intermediate stations slightly earlier, the walk recorded a change there, even though the scan had
kept the passenger aboard from an earlier call.

For example, LTK → ORN on 2026-09-15 departing 06:00 over the GB rail feed returned
… LVJ 11:39>MRF 11:40 | MRF 11:43>ORN 12:00, a 3 minute change at Moorfields, where
interchange is 10 minutes. The scan actually boarded G30586 at Liverpool Lime Street (LPY) at 11:25.

Fix

Boarding pointers

  • ScanResults records the connection each trip is boarded from, in a per-trip array held by the
    factory next to tripArrivals. It needs no clearing between scans: it is only read for trips that
    have carried the passenger in the current scan.
  • setConnection stores that boarding connection in the connection index rather than the connection
    into the station, so each entry is one leg.
  • getLegs follows one entry per leg; the leg splitting is gone. getCompactedLegs is unchanged.

Where a trip is boarded

  • A trip that can be boarded at more than one call is boarded at the call reached in the fewest legs,
    then at the latest call. Always boarding at the first reachable call could take the passenger past a
    later call of the trip only to ride back through it (e.g. CBS → EDB through Haymarket, then a train
    from EDB back out through Haymarket).
  • The scan tracks legs per station, and each trip's boarding keeps a rank combining legs and call, so
    the comparison reads nothing from the large connection arrays.

Keeping the leg counts right

  • An equal arrival replaces a station's entry if it takes fewer legs, and on staying aboard only when
    the legs are equal. Before, staysAboard alone could replace a 4-leg path with a 6-leg one (FRL → PFL
    at Stratford).
  • A station reached in fewer legs has its footpaths walked again (setConnection reports it, and
    isTransferBetter accepts an equal time in fewer legs). Otherwise stations reached from it kept
    counts from the path that was replaced.

Changing where consecutive trips share a call

  • Legs are only known for the earliest arrival at each call, so a trip can pass a later call of the
    next one in as few legs without the scan seeing it (LOB → DGY rode C17869 through Runcorn to
    Liverpool South Parkway, then G38358 back through Runcorn).
  • After getCompactedLegs, JourneyFactory looks at each pair of consecutive timetable legs for a
    call the first sets down at and the second picks up at after boarding, with time to change. It cuts
    the first leg at the earliest such call and boards the second there. The leg count and arrival are
    unchanged.

Fixing the original bug on the way out instead (boarding each trip at a reachable call while building
legs) was also prototyped. It gave identical journeys but needed scan to return arrival times as
well, changed getJourneys's signature, and duplicated the scan's reachability rule in
JourneyFactory. Tracking a second fewest-legs label per station in the scan was not pursued.

Verification

Over 1,100 seeded random station pairs and departure times on the GB rail feed (921 journeys found):

master first-reachable boarding ranked boarding this PR
Journeys with a change shorter than interchange 107 0 0 0
Journeys passing a station twice 2 41 0 0
Arrival times different from master – 0 0 0
Total legs 4,003 3,816 3,766 3,759

Reviewer examples on 2026-09-15:

  • FRL → PFL at 06:45: 6 legs rather than 8, same 15:51 arrival.
  • LOB → DGY at 15:53: changes from C17869 to G38358 at Runcorn rather than at Liverpool South Parkway,
    same 20:33 arrival.

Speed, 6 alternating runs in separate processes:

  • The ranked boarding made the scan about 2–3% slower than first-reachable boarding. Reading the
    current boarding connection directly instead cost about 7%.
  • The equal-arrival and footpath changes make no measurable difference to the scan.
  • Building journeys takes about 5 ms per 1,100 rather than 3 ms, against about 4 ms for each scan.

Tests, each failing before its fix:

  • A planning test from the original fixture, and a ScanResults test that a station is indexed by the
    connection its trip was boarded from.
  • A trip boarded at its later call rather than doubling back, and not boarded later if that takes an
    extra leg.
  • Footpaths walked again from a station reached at the same time in fewer legs.
  • A change where the previous trip passed the next rather than riding on and back.
  • ScanResults tests for a connection and a transfer arriving at the same time in fewer legs.

Notes:

  • setConnection must now follow isReachable for the same connection, as the scan does, so specs
    use a take() helper that does both.
  • The changeset is a patch. Anyone constructing ScanResults directly, or reading the connection
    index as the connection into each station, is affected.

https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S

A journey could change trains at a station where another trip happened to
arrive first, with less than the station's interchange time, when the
passenger had really boarded that trip at an earlier call. getLegs split legs
wherever the last connection into a station was on a different trip, which
does not say where a trip was boarded.

The scan now records the connection each trip was first reachable from, and
the connection index holds that connection for each station, so each entry is
one leg.

Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S
Boarding each trip at the first call it was reachable from could take a
passenger past a later call of the trip only to ride back through it, such as
riding into Edinburgh through Haymarket to board a train back out through
Haymarket. A trip now changes where it is boarded when a call is reached in
fewer legs, or in as many legs at a later call.

Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S
A connection overwriting a station at the same arrival time, through
staysAboard, could replace a path with one of more legs, and the footpaths
from it were not walked again, so stations reached from it kept a count from
the path that was replaced. An equal arrival now wins on fewer legs, and on
staying aboard only when the legs are equal, and a station reached in fewer
legs has its footpaths walked again.

Legs are only known for the earliest arrival at each call, so a trip may pass
a later call of the next one in as few legs without the scan seeing it. Where
consecutive timetable legs share a call the second picks up at in time to
change, the journey changes at the first such call.

Claude-Session: https://claude.ai/code/session_018mxfah5W4un64ZieFiEx8S
@linusnorton
linusnorton merged commit 28cd827 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