Rust implementation of RAPTOR (Delling, Pajor, Werneck): given a transit network, find all Pareto-optimal journeys between two stops, trading fewer transfers against earlier arrival.
Quick start
Most users start with the bundled [gtfs] adapter, which wraps a parsed
GTFS feed and implements [Timetable] for it.
use gtfs_structures::Gtfs;
use jiff::civil::date;
use vulture::{SecondOfDay, Timetable};
use vulture::gtfs::GtfsTimetable;
# fn main() -> anyhow::Result<()> {
let gtfs = Gtfs::new("path/to/gtfs.zip")?;
// Pin the timetable to one service date; inactive trips are filtered out.
let tt = GtfsTimetable::new(>fs, date(2026, 5, 4))?;
// The algorithm takes dense u32 indices, not GTFS string IDs — resolve first.
let start = tt.stop_idx("dilshad_garden").expect("unknown stop");
let target = tt.stop_idx("vishwavidyalaya").expect("unknown stop");
let journeys = tt
.query()
.from(start)
.to(target)
.max_transfers(10)
.depart_at(SecondOfDay::hms(9, 0, 0))
.run();
for j in &journeys {
println!("{} trip(s), arrives {}", j.plan.len(), j.arrival());
}
# Ok(())
# }
Concepts
RAPTOR proceeds in rounds. Round k holds the earliest known arrival time
at every stop reachable using at most k trips. Each round scans the routes
through stops improved in the previous round and then relaxes walking
footpaths to a fixed point. The output is a Pareto front: one [Journey]
per trip count between zero and max_transfers, with strictly earlier
arrivals as you allow more trips. There is no Dijkstra-style priority queue
and no shortest-path tree — just successive rounds of array updates.
The default routing is single-criterion: minimise arrival time, then
report one journey per trip count. The criterion under optimisation is
abstracted by the [Label] trait; the default [ArrivalTime] just carries
a [SecondOfDay]. Multi-criterion routing (e.g. trade-offs between arrival
time and accumulated walking time) plugs in via [Timetable::query_with_label]
with one of the [labels] impls or your own.
Common patterns
- Single query. [
Timetable::query] returns a typestate builder. Chain.from(...).to(...).max_transfers(...).depart_at(...).run(). - Multi-platform stations. [
gtfs::GtfsTimetable::station_stops] expands a parent station to its child platforms; pass it to.from(...)/.to(...). - Range queries. [
Query::depart_in_window] returns aVec<RangeJourney>Pareto profile across departure times. Serial.run()is rRAPTOR;.run_par()/.run_with_pool(&pool)fan per-departure work across Rayon (default-onparallelfeature). - Server reuse. Allocate a [
RaptorCache] once via [RaptorCache::for_timetable] and finish each chain with.run_with_cache(&mut cache)— amortises scratch-buffer allocation across queries. - Multi-threaded reuse. [
RaptorCachePool] is theSyncvariant. Each worker calls [RaptorCachePool::checkout] to get a cache for one query; the cache returns to the pool on drop. - Multi-criterion routing. [
Timetable::query_with_label] takes a custom [Label]. The bundled [labels::ArrivalAndWalk] trades arrival time against accumulated walking time. - Per-leg trip IDs and timing. [
Journey::with_timing] reconstructs per-leg(boarding stop, trip, depart, alight stop, arrive)tuples.Journey.planon its own is just topology. - Sparse
transfers.txt. [gtfs::GtfsTimetable::with_walking_footpaths] builds bidirectional walking edges from stop coordinates using an R-tree.
Custom backends
Implement the [Timetable] trait directly if your data is not in GTFS
form. Identifiers are dense u32 newtypes ([StopIdx], [RouteIdx],
[TripIdx]); your adapter interns external IDs to dense indices at
construction. The trait carries one mandatory soundness contract —
no-overtaking within a route — documented on [Timetable]. Footpaths
returned by [Timetable::get_footpaths_from] describe direct walks only;
the algorithm chains them within a round, so transitive closure of the
footpath relation is not required (see the [Timetable] trait's
Footpaths section for what closure means and when it matters).
Adapters whose relation is closed can opt into a faster single-pass
relaxation via [Timetable::footpaths_are_transitively_closed].
[manual::SimpleTimetable] is a hand-rolled in-memory adapter useful for
tests and small fixtures.
For an issue-by-issue walk through the algorithmic correctness of the
implementation against the paper, see
docs/soundness.md
in the repository.
Errors
The library uses three patterns, picked per-call to match what the caller can usefully do:
Resultfor construction. Building a [gtfs::GtfsTimetable] from a parsed feed validates many invariants and returnsResult<_, gtfs::GtfsError>. Custom adapters should follow the same pattern with their own typed error enum.Optionfor lookups. Resolving an external ID ([gtfs::GtfsTimetable::stop_idx], [gtfs::GtfsTimetable::route_idx]) returnsOption—Nonesimply means "not in this timetable". Same for any "find me anXmatchingY" accessor; there's no useful structured error.Resultfor plan reconstruction. [Journey::with_timing] returns [Result<_, TimingError>] — failure modes (no boarding stop reachable, no catchable trip, alighting stop not on route) are surfaced as variants because they carry useful debug context.- Panics for programmer-violated invariants. Passing a
[
RaptorCache] sized for one timetable to a query against a differently-sized timetable panics — the mistake is unrecoverable and the assertion message is the diagnostic. Custom [Timetable] adapters that violate the no-overtaking contract documented on the trait will likely produce wrong answers rather than panic.
There is no all-encompassing vulture::Error enum — each failure
lives at a clear boundary, so unifying them would lose information
rather than add it.
Cargo features
parallel(default-on) — pulls inrayon, enables [Query::run_par] / [Query::run_with_pool]. Opt out withdefault-features = falsefor wasm or minimal builds; [RaptorCachePool] itself stays available.gtfs-bench— enables thegtfscriterion benchmark.internal— enables theraptorcriterion benchmark over [manual].