Vulture
Rust implementation of RAPTOR (Delling, Pajor, Werneck): given a public transit network, find all Pareto-optimal journeys between two stops, trading fewer transfers against earlier arrival.
Browser demo: https://urschrei.github.io/vulture/. WASM bindings (~290 KB gzipped) in vulture-wasm/, on npm as vulture-wasm.
Quick start
The gtfs adapter implements Timetable over a parsed GTFS feed. aux/dmrc_gtfs.zip (Delhi Metro) is bundled.
use Gtfs;
use date;
use ;
use GtfsTimetable;
let gtfs = new?;
let tt = new?;
let start = tt.stop_idx.expect;
let target = tt.stop_idx.expect;
let journeys = tt
.query
.from
.to
.max_transfers
.depart_at
.run;
for j in &journeys
Runnable: cargo run --example gtfs-timetable -- aux/dmrc_gtfs.zip 2024-01-15 1 44.
Each Journey carries plan: Vec<(RouteIdx, StopIdx)>, an origin, a target, and a label. journey.arrival() returns the SecondOfDay. For trip IDs and per-leg times, see Journey::with_timing.
Gtfs::new(path) reads from disk. gtfs-structures also exposes Gtfs::from_url, Gtfs::from_url_async, and Gtfs::from_reader; all yield a Gtfs that GtfsTimetable::new accepts.
let gtfs = from_url?;
let tt = new?;
from_urlrequires theread-urlfeature ongtfs-structures. vulture pulls it withdefault-features = false; for the URL paths, depend ongtfs-structuresdirectly:gtfs-structures = { version = "0.47", features = ["read-url"] }.
Worked examples
Berlin VBB: station-level query
GtfsTimetable::station_stops(parent_id) expands a parent station into its child platforms. Passing the result to .from(...) / .to(...) runs a multi-source / multi-target search.
let tt = new?;
let journeys = tt
.query
.from // Hbf, ~301 platforms
.to // Alex, ~50 platforms
.max_transfers
.depart_at
.run;
Berlin VBB (~42k stops, ~71k active trips): 385 µs.
Helsinki HSL: augmenting sparse footpaths
HSL ships an empty transfers.txt. Build coordinate-derived walking edges before querying:
let tt = new?
.with_walking_footpaths; // 500 m at 5 km/h
let journeys = tt
.query
.from // Kamppi metro
.to // Itäkeskus metro
.max_transfers
.depart_at
.run;
with_walking_footpaths builds bidirectional R-tree edges and preserves existing transfers.txt entries.
Paris IDFM: expensive case
Paris IDFM is the largest of the bundled benchmark feeds (~54k stops, ~146k active trips per weekday). assert_footpaths_closed() opts into the single-pass O(E) footpath relaxation when transfers.txt is publisher-curated.
let tt = new?
.assert_footpaths_closed;
let journeys = tt
.query
.from // Châtelet
.to // Versailles RD
.max_transfers
.depart_at
.run;
Châtelet → Versailles RD: ~17 ms. Shorter cross-Paris queries (Châtelet → Gare du Nord, Châtelet → La Défense): under 1 ms. See docs/cross-city-benchmarks.md for the full table and methodology.
Range queries: "leave between X and Y"
let profile = tt
.query
.from
.to
.max_transfers
.depart_in_window
.run;
Returns Vec<RangeJourney>: (depart, journey) pairs Pareto-filtered on (later depart, fewer transfers, earlier arrival). Serial .run() is rRAPTOR; .run_par() fans the per-departure work across a Rayon thread pool.
Server workload: many queries, multi-threaded
Allocate a RaptorCachePool once; each worker checks out a cache for the scope of one query.
use *;
use RaptorCachePool;
let pool = for_timetable;
let results: = queries.par_iter.map.collect;
pool.checkout() returns a PooledCache handle; the cache returns to the pool on drop.
Runnable examples
Synthetic-network examples in vulture/examples/:
cargo run --release --example custom_label– customLabelwith its ownCtx, route-preference scoring.cargo run --release --example fare_aware– fare-aware Pareto routing viaArrivalAndFareand aFareTablecontext.cargo run --release --example range_query–depart_in_window(...); serial rRAPTOR vs parallel naïve batch, with an equality assertion.cargo run --release --example cache_reuse–RaptorCachereuse with an equality assertion against one-shot results.
Features
Timetabletrait – the network abstraction.GtfsTimetablecovers GTFS; implement directly for non-GTFS data.- Typestate query builder –
tt.query().from(...).to(...).max_transfers(...).depart_at(...).run(). Order is compile-time enforced;.depart_at(...)/.depart_in_window(...)switch the return type. - Multi-source / multi-target –
.from(...)/.to(...)accept aStopIdx, a slice, aVec, or(stop, walk_offset)pairs. Usestation_stops(parent_id)for any-platform queries. - Range queries –
.depart_in_window(times)returns a Pareto profile. Serial path is rRAPTOR;.run_par()/.run_with_pool(&pool)use a parallel naïve batch. Labeltrait – defaultArrivalTimefor single-criterion.vulture::labelsshipsArrivalAndWalk(arrival vs walking time) andArrivalAndFare(arrival vs accumulated fare). Custom impls plug in viaQuery::with_context.- Walking footpaths from coordinates –
with_walking_footpaths(>fs, max_dist_m, speed_m_per_s)builds bidirectional R-tree edges, preserving existingtransfers.txt. - Closed-footpath fast path –
assert_footpaths_closed()opts into single-passO(E)relaxation instead of Dijkstra. RaptorCache– reusable scratch allocations per timetable.RaptorCachePoolis theSyncvariant.- Per-leg timing –
journey.with_timing(&tt, depart, origin_walk)attaches trip IDs and per-leg depart / arrive times to aJourney. - Wheelchair-accessibility filter –
require_wheelchair_accessible()skips trips and stops flaggedNotAvailablein GTFS. - Multi-day search –
GtfsTimetable::new_with_overnight_days(>fs, date, OvernightDays(n))loadsnadditional service days, shifting day-dtrips byd × 86 400seconds onto one time axis. - Per-leg shape access –
GtfsTimetable::shape_for_leg(>fs, trip_id, board, alight)returns the polyline points for one leg, sliced viashape_dist_traveledwhen present. - Feed introspection –
GtfsTimetable::features()returns aFeedFeaturessnapshot.FeedFeatures::suggestions()returns an advisory string list mapping the snapshot to relevant vulture knobs.
When To Use What
| Goal | API |
|---|---|
| Single query, default routing | tt.query()...run() |
| Many queries, one server | allocate a RaptorCache, finish each chain with .run_with_cache(&mut cache) |
| Same as above, multi-threaded | RaptorCachePool::for_timetable(&tt), pool.checkout() per worker |
| "Leave between X and Y" | .depart_in_window(times).run() (serial rRAPTOR), or .run_par() (multi-core wins on wide windows) |
| "Slower route with less walking" | tt.query_with_label::<ArrivalAndWalk>()...run() |
| "Cheapest vs. fastest journey" | tt.query_with_label::<ArrivalAndFare>().with_context(fares)...run() |
| Wheelchair-only routing | tt.query()....require_wheelchair_accessible()....run() |
| "Last train home" / overnight queries | GtfsTimetable::new_with_overnight_days(>fs, date, OvernightDays(1))? |
| Any platform of a station | tt.station_stops(parent_id) into .from(...) / .to(...) |
Sparse transfers.txt |
.with_walking_footpaths(>fs, max_dist_m, speed_m_per_s) at construction |
| Trip IDs and per-leg times in output | journey.with_timing(&tt, depart, origin_walk) |
| Polyline geometry per leg (for map drawing) | tt.shape_for_leg(>fs, trip_id, board, alight) |
Perf
Single-query latency. Warm RaptorCache; M-series Apple Silicon; single thread. Full table and methodology in docs/cross-city-benchmarks.md.
| Feed | Stops | Trips/day | Representative query | Latency |
|---|---|---|---|---|
| Delhi Metro | 262 | 5,400 | 2-trip cross-network | 22 µs |
| Helsinki HSL | 8,400 | 24,000 | Kamppi → Itäkeskus (with walking) | 1.2 ms |
| Berlin VBB | 42,000 | 71,000 | Hbf → Alex (station-to-station) | 576 µs |
| Paris IDFM | 54,000 | 146,000 | Châtelet → Versailles RD | 19 ms |
Range-query numbers (serial rRAPTOR vs parallel naïve batch): see the bench source and the linked benchmarks page. Head-to-head against the TypeScript raptor-journey-planner: docs/cross-impl-comparison.md.
Consumer build profile
The numbers above are measured with lto = true and codegen-units = 1 in this workspace's [profile.release]. Cargo does not propagate workspace profile settings to downstream crates; a vanilla cargo add vulture build inherits Cargo's defaults (lto = false, codegen-units = 16, opt-level = 3). To match these numbers from a downstream crate, set:
[]
= true
= 1
Soundness
docs/soundness.md audits the implementation issue-by-issue against the paper. The vulture-proptest harness runs the algorithm against a brute-force reference solver on every test invocation.
Testing
Correctness
Three layers, all run by cargo nextest r on every PR:
- Unit tests in
vulture/src/test.rscover the algorithm (single-source, multi-source, range, footpath relaxation, wheelchair filtering, overnight) against hand-builtSimpleTimetablefixtures and the bundled Delhi Metro feed. - Doctests on every public type with a non-trivial contract (
Label,Query,Journey,RaptorCache,GtfsTimetable::station_stops, etc.). - Property-based tests in
vulture-proptestgenerate random transit networks and check the algorithm against a brute-force reference solver, via Hegel.
The proptest harness has three generator layers (Layer 1: 1–2 routes, 2–4 stops, no footpaths. Layer 2: adds 1–4 footpaths. Layer 3: 1–4 routes, up to 6 stops, optional footpaths, multi-source / multi-target queries, per-route fares). Properties:
| Property | What it checks |
|---|---|
layer{1,2,3}_matches_reference |
Algorithm output equals the reference Pareto front of (arrival, trip_count) at each layer. |
parallel_naive_matches_serial_rrap |
Serial rRAPTOR range path and parallel naïve batch return the same Pareto profile. |
range_query_matches_reference |
Range-query output equals a per-τ reference solve plus the same 3-D Pareto filter filter_range_pareto_front uses. |
fare_label_matches_per_leg_sum |
ArrivalAndFare's accumulated fare equals the per-leg fare sum on every returned journey. |
Hegel persists shrunk failing seeds to .hegel/. See vulture-proptest/README.md for the layer / issue map.
Perf regressions
Run scripts/perf-smoke.sh before finalising any feature or Cargo.toml profile change. It runs the Delhi gtfs_query group from vulture/benches/gtfs.rs in criterion --quick mode against the last saved baseline in target/criterion/. Wall time: ~2 s with no source change, ~24 s after a source edit (the release profile's LTO link dominates). Criterion prints "Performance has regressed." when the median moves past noise.
The first run on a fresh checkout has no prior baseline; run twice, or pass --save-baseline <name> through to criterion (extra arguments are forwarded). Skip only when the change cannot affect runtime. The full criterion suite (cargo bench --features gtfs-bench, ~5 min) is the right thing to run before tagging a release.
For diagnosis after a regression: vulture/examples/profile-delhi.rs loops the same three queries with no criterion-harness overhead, sized for samply / cargo-flamegraph. Build with cargo build --profile profiling --example profile-delhi, then samply record -- target/profiling/examples/profile-delhi.
Cargo features
parallel(default-on) – enablesQuery::run_par/Query::run_with_poolviarayon.default-features = falsedrops the dependency;RaptorCachePoolstays available.gtfs-bench– thegtfscriterion benchmark.internal– themanualcriterion benchmark overmanual::SimpleTimetable.
License
Apache-2.0