Skip to content
2024

Stations Pathfinder

A command-line railway dispatcher that plans the fewest turns, not the shortest route.

Overview

We built a Go command-line railway scheduler that moves multiple trains through shared stations and tracks. Candidate schedules are compared by simulated turn count, with routing heuristics intended to reduce contention rather than simply choose the shortest path.

context
Coursework
status
Archived learning project
team
2 people
role
Shared responsibilities
started
Aug 2024
setting
Command-line railway scheduling exercise over supplied network maps.

my contribution

My main contributions were the routing and train-scheduling implementation, validation improvements, debugging and documentation. My teammate contributed the initial brainstorming and error-checking implementation, which I subsequently extended.

built with
  • Go

Key decisions

Optimise simulated turn count
Route length is only a proxy. I compared completed schedules by the number of global turns needed to move all trains.
Include contention in route selection
The ranking combines path length, shared stations and bottleneck penalties, then checks candidate choices through the movement simulator.

Demonstrated results

Track occupancy became an explicit simulation rule
The implementation checks track and station use per turn, including a documented fix for two trains attempting the same track.
Validation can report multiple map problems
The -ae mode collects errors instead of stopping at the first one, with an annotated map fixture covering invalid stations and connections.
Candidate scores can be inspected in a worked example
The documented network exposes how shared paths affect scores and why equal-length candidates can still need simulation to choose between them.

Known limitations

The search has no practical scaling guarantee
Enumerating simple paths can grow combinatorially on dense graphs. A depth cap makes the search finite, not necessarily fast or globally optimal.
Repeated runs mitigate rather than remove nondeterminism
Map iteration affects tie-breaking. The program keeps the best of repeated runs for smaller fleets and uses one run above twenty trains.

The objective is not distance

The acceptance criterion is the turn count. In main.go, one variable holds the objective:

var leastTurns int = math.MaxInt32
...
if turnsCount1 < leastTurns {
    bestMoves = moves1
    leastTurns = turnsCount1
}

The final objective is turn count, although path length is one term in the ranking heuristic. Routes of equal length can still take different numbers of turns because of contention at intermediate stations.

What a turn actually is

One tick of a global clock. In each turn, every train may traverse at most one track, and:

  • a track may carry only one train per turn
  • a station may hold only one train
  • the start station can always be left, and the destination can always be entered
// Start station can always be left
if occupyingTrain, occupied := stationOccupancy[from]; occupied && occupyingTrain != trainID {
    return false
}
// End station can always be entered
if to == endStation {
    return true
}

That last pair of exceptions is why the problem is interesting: a train is never stuck at its origin, and never locked out of its destination.

Not Dijkstra

The obvious assumption is wrong. There is no shortest-path search here at all. The algorithm enumerates every simple path by depth-first search with backtracking:

for neighbor := range net.Connections[current] {
    if !visited[neighbor] {
        dfs(net, neighbor, end, visited, append(path, neighbor), allPaths, maxDepth)
    }
}
visited[current] = false   // unmark on the way out

Then it ranks them. There is a textbook container/heap min-heap in the same file, fully implemented with Len, Less, Swap, Push and Pop. It is dead code. It appears nowhere except its own definition.

The search enumerates candidates up to its depth limit and ranks them with heuristics rather than proving a globally optimal schedule. Depth is capped at 100 and maps above 10,000 stations are rejected, but a dense graph can still produce an impractically large number of simple paths within those bounds.

The heuristic predicts contention, not length

score := 2*pathLength +
    (sharedStationScore / float64(numTrains)) +
    float64(bottleneckPenalty) / float64(numTrains)

Three terms, sorted ascending to minimise:

  • 2 * len(path): longer is worse, and it dominates
  • shared / numTrains: overlap with other candidate paths is a penalty, normalised by fleet size. Two trains cannot occupy the same station in the same turn, so candidates that share stations will contend
  • bottleneck / numTrains: a station appearing on more paths than there are trains is a structural chokepoint

A worked example

The README's own example: jungle → desert, 10 trains, over a 14-station network. DFS finds exactly three simple paths:

Path len shared score
p0 jungle → farms → mountain → wetlands → desert 5 7 10.70
p1 jungle → farms → mountain → treetop → desert 5 7 10.70
p2 jungle → farms → downtown → metropolis → industrial → desert 6 6 12.60

farms appears on all three paths; jungle and desert are endpoints shared by definition. No station exceeds 10 occurrences, so the bottleneck term is zero throughout.

The six-station route loses despite being a viable shape, and the two five-station routes tie at 10.70. That is because wetlands and treetop each appear on only one path and so cannot differentiate them. Resolving a genuine tie is the simulator's job. The next section is about that.

Ten iterations, because randomness broke determinism

Go randomises map iteration order. The DFS visits neighbours in a different order on every run, greedy tie-breaks land differently, and identical input produced different turn counts. For fleets up to twenty trains, the program runs the search ten times and keeps the best simulated result; larger fleets use one run:

// Running pathfinding in loop to mitigate randomness, choosing the best solution
loops := 10
if numTrains > 20 {
    loops = 1
}

That loop is not a performance hack. It is an admission that the scoring proxy does not reliably predict turn count. So both ranking functions are simulated on every iteration, and the winner is whichever produced fewer turns.

Validation you can read at a glance

A -ae flag collects every error instead of exiting at the first, which is why the repository contains an errormap fixture annotated with the failure each line provokes:

Duplicate: "jungle" appears again
Duplicate coordinates: "4,4"
invalid!station,7,7
negative,-1,-1
jungle-jungle
nonexistent1-nonexistent2

Thirteen distinct checks in total, including unreachable endpoints, self-connections, dangling edges, stations at identical coordinates, and illegal characters in names.

Dependencies and search limits

The no-third-party-dependency claim is grounded in go.mod, which declares no external requirements, and the source imports, which are all standard-library or project-local packages. The standard-library imports are bufio, flag, fmt, math, os, path/filepath, sort, strconv and strings. Absence of go.sum alone would not prove the claim.

Repeated search is a heuristic workaround. It improves the chance of finding a better simulated schedule without establishing determinism, a runtime guarantee or global optimality.

Interactive models

Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.

Route contention and turn simulation

RailwaySolver

Minimum-turns ranking over every simple path. Drag the fleet size to change the contention terms.

score = 2·len + shared/10 + bottleneck/10

p0 → 10.70

recorded in the README as 10.70 · bottleneck stations none above fleet size

candidates

2 paths tie at 10.70. p0 and p1 differ only in stations that appear on one path, so the contention term cannot separate them. That is why the program runs the simulator rather than trusting this score.

station frequency across all paths

  • desert ×3
  • farms ×3
  • jungle ×3
  • mountain ×2
  • downtown ×1
  • industrial ×1
  • metropolis ×1
  • treetop ×1
  • wetlands ×1

The source has a complete container/heap min-heap that is dead code, and no Dijkstra anywhere — the search enumerates every simple path and ranks by this proxy. At ten trains the bottleneck term is zero for every station, so distance and contention decide it and the tie goes to the simulator.

pathfinder README · internal/simulation

client:visible

Reconstruction of the routing and simulation from the project source. Not a live dispatcher.
Stack and source figures

how it fits together

solver
  • Exhaustive simple-path DFS Every simple path enumerated with backtracking, then ranked. There is no Dijkstra in the codebase. A textbook container/heap min-heap is present and is dead code.
  • Ten-iteration loop Go randomises map iteration order, so identical input could produce different results. The search runs ten times and keeps the best measured turn count.
simulation
  • Global turn clock One tick advances every train by at most one track; a track and a station each hold one train per turn, with the origin and destination exempt.
dependencies
  • Standard library only go.mod declares no external requirements, and the source imports only standard-library and project-local packages. 1,340 lines total.
LOC
1,340 Across 10 files. No external requirements in go.mod; imports are standard-library or project-local.
commits
53
build window
17 days 2024-08-12 to 2024-08-28.
input checks
13 Distinct validation failures, all collected by one flag.
Full issue record · 4 entries

what broke, and what happened

  • 2 fixed / 4
  • 2 workaround / 4

Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.

  1. fixed

    The first two algorithms were abandoned for taking over a minute, then calculating endlessly

    Commit 2024-08-18: "Added better? pathfinding algorithm, takes in some cases over minute to calculate". Commit 2024-08-21: "New solution that takes all the available paths and calculates endlesly...."

    then: Replaced with exhaustive simple-path enumeration plus the turn simulator, which terminates because depth is capped at 100 and the parser rejects maps over 10,000 stations.

  2. workaround

    Two ranking functions disagreed with each other, so both were kept

    Commit 2024-08-24: "problem.. sometime works, but other times older version works, added comparison for both, should choose the one with least moves"

    then: Never resolved in principle. Both scoring functions are simulated on every iteration and the one that produced fewer turns wins, so the disagreement is measured rather than decided.

  3. workaround

    Go randomises map iteration order, so results changed between runs of identical input

    Mitigated twice: an alphabetical tiebreak for equal scores, and a ten-iteration loop that keeps the best measured run.

    then: Mitigated, not eliminated. An alphabetical tiebreak plus the loop at `main.go:93`, which keeps the best of ten runs. Go still randomises iteration order, so identical input can differ between invocations.

  4. fixed

    Two trains could occupy the same track in one turn

    Commit 2024-08-27: "Added check so same track wont get used in same turn"

    then: Occupancy check added to the simulator, so a track already claimed in the current turn is no longer selectable.