ML

murthyroshan/lastlift

Developer tools
0 stars 0 forks Quality 78 Trend 20MIT

Find the last lift before it fails. Every MBTA station's step-free routes as a flow network in FalkorDB: certified max-flow redundancy, live lift closures, what-ifs and step-free reroutes, served to...

Overview

Unofficial decision-support prototype. Not affiliated with or endorsed by the MBTA or MassDOT. Uses MBTA GTFS and V3 API data under the MassDOT Developers License Agreement. Data may be incomplete or out of date. Always check mbta.com/alerts/access before travelling. Elevator status line 617-222-2828; customer support 617-222-3200 (TTY 711). Results describe the open-data model and may differ from conditions on the ground. LastLift turns the MBTA's published station layouts into a flow network in FalkorDB, so for every platform it knows how many lift failures it takes to cut riders who need step-free access off, which lift that is, and what to do when it happens. An agent answers questions about it through tools that any MCP client can call. - Every step-free route through a station is a path through lifts, walkways and fare gates. Give each lift a capacity of one and . One means a single point of failure.

README

LastLift

Find the last lift before it fails. No prediction. Just the graph.

Unofficial decision-support prototype. Not affiliated with or endorsed by the MBTA or MassDOT. Uses MBTA GTFS and V3 API data under the MassDOT Developers License Agreement. Data may be incomplete or out of date. Always check mbta.com/alerts/access before travelling. Elevator status line 617-222-2828; customer support 617-222-3200 (TTY 711).

Results describe the open-data model and may differ from conditions on the ground.

89 of 366 step-free platforms in Boston’s MBTA station data hang on a single lift. LastLift turns the MBTA’s published station layouts into a flow network in FalkorDB, so for every platform it knows how many lift failures it takes to cut riders who need step-free access off, which lift that is, and what to do when it happens. An agent answers questions about it through tools that any MCP client can call.

Graph Hacks track: Agents That Act on Connected Data.

Live API (FastAPI + FalkorDB) https://lastlift-api.onrender.com/v1/docs (free hosting: the first request may take a minute to wake it)
MCP endpoint https://lastlift-api.onrender.com/mcp/
Website link added when deployed
Demo video link added when recorded

If you have 60 seconds

  • Every step-free route through a station is a path through lifts, walkways and fare gates. Give each lift a capacity of one and max-flow is the number of lifts that must fail before a platform is cut off. One means a single point of failure.
  • Today, with lift 961 closed at Alewife, lift 813 is newly the only lift for the Red Line, Track 1 and Track 2 platforms. No alert says that; the graph does.
  • 74 of the single-lift platforms are at stations with two or more lifts: counting lifts is not enough.
  • Every number is a certified FalkorDB result, cross-checked against NetworkX, and every agent answer cites the exact Cypher it ran.

Instead of “Elevator out of service”, LastLift says what it means for you

The alert LastLift
“Revere Beach Elevator 885 is unavailable.” The Wonderland platform loses step-free access in the open-data model. To board step-free: lift 884, ride the Blue Line to Beachmont, change platforms there step-free, ride back (about 17 minutes). There is no backup avoiding lifts 884, 886 and 887.
“Alewife Elevator 961 is unavailable.” No platform loses access, but lift 813 is now the only lift for three platforms. Protect it.
“Elevator 782 at Worcester is unavailable.” Cannot assess: MBTA publishes no station layout for Worcester.

Judge it in 90 seconds

  1. POST https://lastlift-api.onrender.com/v1/tools/platform_redundancy with {"station_id": "place-alfcl"}: Alewife, with the lift that became the last lift and why.
  2. POST .../v1/tools/what_if_close with {"facility_ids": ["813"]}: close a working lift on a copy of the graph; three platforms lose step-free access; the live state is untouched.
  3. POST .../api/ask with {"question": "Can I get step-free onto the Wonderland-bound platform at Revere Beach right now?"}: the agent’s answer and every tool call it made, via MCP.
  4. GET .../v1/queries: every Cypher statement LastLift runs.
  5. python scripts/smoke.py --prod checks all of the above from your machine.

Every submission requirement, and exactly where it is

Rule 10 requirement Where
Public source-code repository this repository
README with setup instructions and the graph data model Run it yourself, The graph data model
The Cypher queries or graph algorithms the product depends on The Cypher and the algorithms (generated from lastlift/queries.py)
A demo video added when recorded
A live deployment, or complete local setup https://lastlift-api.onrender.com and Run it yourself
The track Agents That Act on Connected Data

Rule 3 (the graph does real work): see Turn FalkorDB off. Rule 6 (data you may use): public MBTA open data only. Rule 11 (AI disclosure): AI assistance.

Every redundancy number comes with a certificate

FalkorDB 4.22’s algo.maxFlow can return a wrong value on some networks: on an 11-edge reproduction it returns 300 where the true max-flow is 453 (reported by the Chokepoint project; reproduced in docs/falkordb-maxflow-note.md). So no FalkorDB flow is trusted on its own:

  • each result is certified by checking that the returned flow is a feasible flow whose residual graph has no augmenting path, and repaired with an exact Dinic run if it is not;
  • the minimum cut names the lifts; each lift on a single route is removed in turn to find every single point of failure;
  • an independent checker validates the k lift-disjoint routes as a proof.

On LastLift’s network: 734 max-flow calls, 734 certified on the first pass, 0 repaired, 734 equal to NetworkX, 333 route proofs accepted and 0 rejected. scripts/verify_claims.py re-derives every headline number from the committed snapshot with NetworkX alone.

The same caution applies to path search: on 4.22 algo.SPpaths enumerates paths and ignored the server timeout in our tests, so LastLift routes with Dijkstra over edges read from the graph and re-checks every edge of a route in FalkorDB (docs/falkordb-sppaths-note.md).

One closed lift, seen across the whole network

A lift can cut a platform off, yet riders may still ride a train to another station and back. So LastLift also asks the network question: which journeys between stations does a closure break?

  • 69 lifts can each cut a platform off on their own; only 15 can cut a whole station off from the step-free network (13 stations; each such closure breaks 176 of the 7,832 journeys between the 89 stations with an accessible entrance). For the other 54 the network re-routes riders: median worst detour 11 min, longest 20.2 min.
  • How: dominator trees. Every lift is node-split, so closing it deletes one node; the journeys it breaks from an origin are exactly the stations in that node’s dominator subtree. One tree per origin (Cooper-Harvey-Kennedy, on the network contracted to its lift-free strongly connected parts) gives the whole N-1 table in a fraction of a second. A plain search with each lift removed agrees for 198 of 198 lifts, and the tests apply closures in FalkorDB with Cypher and re-read the graph to confirm it.
  • Conditional last lifts: with one lift closed, its partner can become the only link between a station and the network. There are 42 such pairs, and 0 of them span two stations: in the open data the turnaround stations always have a spare. In the demo state, lift 885 is closed, so 884 alone now links Revere Beach to every other station.
  • Critical pairs (N-2): 42 lift pairs are each harmless alone but cut platforms off together; the worst is 821+973 (18 platform-directions). Each pair is re-checked in FalkorDB: max-flow 0 with both closed, above 0 with either alone.
  • Every minimum cut, not just one: for each of the 333 lift-dependent platform-directions, all minimum cuts come from the certified flow’s residual graph (Picard-Queyranne); 333 equal a brute-force search over every set of lifts, and 71 have more than one.
  • Exact reliability: no platform depends on more than twelve lifts, so the chance of losing access when each lift is out with probability q is computed exactly by enumeration (the station page has a slider). With every lift out 2% of the time, about 99% of journeys between stations stay step-free (Monte-Carlo, seeded).
  • Repair order: for the lifts closed now, the order that gives back the most soonest is computed exactly (dynamic programming over subsets) and compared with greedy.
  • Query plans: GET /v1/queries/{id}/plan?mode=explain|profile returns FalkorDB’s own plan (profiles run on a private copy; write queries are refused); examples are in docs/profiles/.

The Silver Line (SL1-SL5) counts as a ride in this analysis (model rules version 2): Courthouse and World Trade Center are reachable step-free only by it.

Seven years of closures, replayed through the graph

MBTA keeps no lift-outage history of its own, but MassDOT publishes every service alert, and MBTA’s LAMP archive goes back to 2019. LastLift replays every reported elevator closure since 1 January 2024 through today’s step-free graph (validation/HISTORY.md):

  • 285 platform-directions lost step-free access at least once, for 58,286 platform-direction hours within scheduled service; 93% of those hours trace to a single lift. Redundancy was defeated (two or more lifts out together) 390 times.
  • The costliest single lift was 811 (6,994 service hours). Each lift’s availability, closures and median closure length are in the graph ((:Outage)-[:OF]->(:Lift)) and in the agent’s lift_history tool.
  • The certified k explains real exposure: single-lift platform-directions lost access in 47% of months, those with two or more lift routes in 6%.
  • What history cannot do (a negative result we publish): ranking lifts by their 2024 record does not pick out the lifts that cost the most in 2025 (Spearman 0.17 over 69 lifts, precision of the top ten 0.1), and a monthly closure model fails the calibration bar set in advance (Brier skill 0.084). So LastLift shows historical frequencies and never a failure forecast.
  • With each lift’s own outage rate instead of a uniform guess, 20 platform-directions would be without step-free access more than 5% of the time.
  • Checks: 20 random moments of the replay re-checked with FalkorDB’s certified max-flow on a copy of the graph; totals reproduce from the committed files in the tests; a sensitivity table (imputed ends in or out, episode gaps of 0, 30 and 120 minutes, calendar or service hours); the 2025 total from the portal alone reproduces the planning probe within 1%, and the sources are compared side by side. The portal’s timestamps turned out to be Boston local time labelled as UTC; the history reads them as local time.

Checked against the operator’s own words

Fourteen live MBTA lift alerts were labelled blind by the author, from MBTA’s alert text only, before the evaluation ran (validation/). Of the 8 that both the author and the model could assess: 6 agree, 2 partial, 0 disagree (agreement interval 41%, 93%, Wilson 95%). False accessible: 1 (L04 board): for State 803 MBTA sends riders to Downtown Crossing, while the open data holds a longer in-station route; LastLift shows both and tells riders to follow MBTA’s advice. The model abstains where MBTA publishes no layout (unknown rate 6/14). Every row, including every disagreement, is in validation/EVALUATION.md. A second sweep over the alternative-service text of every MBTA elevator is labelled by the author before it is scored (awaiting author review (69 of 215 rows flagged)).

Turn FalkorDB off, and here is exactly what stops working

Everything LastLift computes is computed in FalkorDB: redundancy (algo.maxFlow on the step-free subgraph), closures (relationship-type swaps SF → SF_CLOSED), what-ifs (GRAPH.COPY of the live graph, closures applied to the copy), incident memory (Incident nodes linked to lifts), the repair ranking (DEPENDS_ON / DEPENDS_NOW relationships), the station drawings and the route certificates. Stop the database and:

  • /healthz returns 503 and every tool and the agent answer 503 graph_unavailable, never a guess (tested in tests/test_memory.py);
  • no what-if, reroute, ranking or answer can be produced;
  • the website falls back to a frozen copy of the demo state that FalkorDB computed earlier, and says so.

The agent acts through the same tools as everyone

  • Ten tools (lastlift/tools.py): resolve_place, current_closures, platform_redundancy, what_if_close, step_free_route, repair_priority, lift_history, draft_action, network_impact, repair_order. The LLM never writes Cypher; every tool runs fixed, parameterised queries and reports their ids.
  • The agent reaches the tools only through MCP (an in-memory MCP client; a test breaks every direct path and the agent still answers).
  • Every answer is grounded: an id or number that is not in a tool result, or wording our copy rules forbid, sends the answer back to a deterministic template built from the tool results.
  • Repair priority ranks lifts to repair now (closed, platforms cut off), to protect (working, but newly the last lift) and structural single points of failure; draft_action drafts a ticket and never files it.
  • Providers fail over in a fixed chain: Groq qwen/qwen3.8-27b, then openai/gpt-oss-120b, then Gemini gemini-3.1-flash-lite, then the template. Each switch appears in the run’s trace, the answer names the model that wrote it, and a daily token budget (counted from the providers’ usage figures) stops LLM calls before the free quotas run out. A fault-injection test (tests/test_chain.py) fails each tier in turn.
  • Every run has a W3C trace id (send a traceparent header to join your own trace); each tool call has its own span id, and LLM calls are logged with OpenTelemetry GenAI attribute names (gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens).
  • Seven recorded runs in web/public/replay/ re-execute against the graph in CI (scripts/verify_replays.py): a recorded fact that no longer matches the graph fails the build.

Use the same tools from any MCP client

Point any MCP client that supports streamable HTTP at the endpoint:

{ "mcpServers": { "lastlift": { "url": "https://lastlift-api.onrender.com/mcp/" } } }

All ten tools are read-only (readOnlyHint); what_if_close works on a throwaway copy. The free host sleeps when idle, so the first call may take a minute.

Built for the people it is about

  • Every state is shown as a glyph, a number and a word, never colour alone; “good” is blue, never green.
  • The station drawing is decorative for assistive technology; the platform, lift and route tables say the same in words.
  • Atkinson Hyperlegible type, a two-tone focus ring, 44-pixel targets, reflow at 320 pixels, reduced-motion and forced-colours support.
  • CI runs axe on every core screen in light and dark themes with the API blocked, plus keyboard-only and 320-pixel checks (web/tests/a11y.spec.ts).
  • Wording rules and exact disclaimers are enforced by a lint (lastlift/wording.py); see docs/ETHICS.md. Not yet reviewed by disabled riders; designed for that review.

The graph data model

graph LR
  Station -- HAS_PLATFORM --> Platform
  Station -- HAS_ENTRANCE --> Entrance
  Station -- HAS_LIFT --> Lift
  Street -- "SF (street)" --> Entrance
  Entrance -- "SF (walk / gate)" --> Generic
  Generic -- "SF (lift_access)" --> Lift
  Lift -- "SF (lift, capacity 1) / SF_CLOSED" --> LiftOut
  LiftOut -- "SF (lift_access)" --> Platform
  Generic -. "STEP (stairs, escalator)" .-> Platform
  Platform -- "RIDE (train, minutes)" --> Platform
  Platform -- "DEPENDS_ON / DEPENDS_NOW {dir}" --> Lift
  Lift -- CLOSED_IN --> Incident

Every node in a station complex carries cx; step-free walkways, gates and lift-access edges have capacity 1,000,000 and lift edges capacity 1, so a platform’s max-flow from its station’s street node is its number of lift-disjoint step-free routes. A closed lift’s edge is retyped SF_CLOSED, which takes it out of every algorithm at once. Model rules, including five evidence-backed overrides of the published data, are in docs/model_rules.md.

Node labels Count
Lift 42
Node:Entrance 330
Node:Generic 1,924
Node:Lift 202
Node:LiftOut 202
Node:Platform 388
Node:Street 89
Platform 312
Station 276
Relationship Count
HAS_ENTRANCE 330
HAS_LIFT 244
HAS_PLATFORM 388
RIDE 650
SF 9,248
STEP 1,069
Step-free (SF) edge kind Count
gate 274
lift 202
lift_access 856
street 424
walk 7,492

Snapshot 2026-10-15, GTFS feed “Fall 2026, 2026-10-14T18:47:38+00:00, version D”, seed sha256 8277887f216a…: 3,765 nodes, 11,929 relationships.

The Cypher and the algorithms

FalkorDB algorithms used: algo.maxFlow (redundancy, certified), algo.WCC (load verification), full-text index (station search), GRAPH.COPY (what-ifs). Every statement LastLift runs, generated from lastlift/queries.py:

Query Cypher
Q_CLEAR_DEPENDS_NOW MATCH (p:Node:Platform {cx:$cx})-[d:DEPENDS_NOW]->() DELETE d
Q_CLEAR_INCIDENTS MATCH (i:Incident) DETACH DELETE i
Q_CLOSED_AT_COMPLEX MATCH (l:Lift {cx:$cx})-[:SF_CLOSED]->() RETURN collect(DISTINCT l.facility_id)
Q_CLOSED_FACILITIES MATCH (i:Lift)-[:SF_CLOSED]->() RETURN collect(DISTINCT i.facility_id)
Q_CLOSE_LIFTS UNWIND $fids AS fid MATCH (i:Lift {facility_id:fid})-[r:SF {kind:'lift'}]->(o) CREATE (i)-[c:SF_CLOSED]->(o) SET c = properties(r), i.closed = true DELETE r RETURN count(c)
Q_COMPLEX_FLOW_EDGES MATCH (a:Node {cx:$cx})-[e:SF]->(b:Node) RETURN e.eid, a.uid, b.uid, e.cap, e.kind, e.facility_id
Q_COMPLEX_PLATFORMS MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id, collect(p.uid), head(collect(p.name)), head(collect(p.wheelchair_boarding)), head(collect(p.platform_name))
Q_COMPLEX_STREETS MATCH (s:Street {cx:$cx}) RETURN collect(s.uid)
Q_CONDITIONAL MATCH (a:Node:Lift)-[c:CRITICAL_PAIR]->(b:Node:Lift) WHERE c.scope = 'network' RETURN a.facility_id, b.facility_id, b.station_id, c.journeys, c.same_station ORDER BY c.journeys DESC, a.facility_id, b.facility_id
Q_CREATE_DEPENDS UNWIND $rows AS r MATCH (p:Node:Platform) WHERE p.stop_id = r.stop MATCH (l:Node:Lift {{facility_id:r.fid, cx:p.cx}}) MERGE (p)-[d:{rel} {{dir:r.dir}}]->(l)
Q_CRITICAL_PAIRS MATCH (a:Node:Lift)-[c:CRITICAL_PAIR]->(b:Node:Lift) WHERE c.scope = 'platform' RETURN a.facility_id, b.facility_id, a.station_id, c.n, c.dirs ORDER BY c.n DESC, a.facility_id, b.facility_id
Q_DOMINATES_FOR MATCH (l:Node:Lift {facility_id:$fid})-[:DOMINATES_FOR]->(s:Station) RETURN collect(DISTINCT s.uid)
Q_LIFT_BLAST MATCH (l:Node:Lift) WHERE l.facility_id IN $fids WITH l.facility_id AS fid, head(collect(l)) AS l RETURN fid, l.name, l.station_id, l.od_cut_base, l.od_cut_off, l.od_slow_base, l.detour_max_s, l.detour_mean_s ORDER BY fid
Q_LIFT_COMPLEXES MATCH (l:Node:Lift) WHERE l.facility_id IN $fids RETURN collect(DISTINCT l.cx)
Q_LIFT_HISTORY MATCH (l:Lift {facility_id:$fid})-[:CLOSED_IN]->(i:Incident) RETURN DISTINCT i.alert_id, i.effect, i.header, i.started, i.first_seen, i.last_seen, i.ongoing, i.ended_by, i.versions ORDER BY i.first_seen
Q_LIFT_INCIDENT_COUNT MATCH (l:Lift {facility_id:$fid}) OPTIONAL MATCH (l)-[:CLOSED_IN]->(i:Incident) RETURN count(DISTINCT i)
Q_LIFT_INFO MATCH (l:Lift {facility_id:$fid}) RETURN l.facility_id, l.name, l.short_name, l.station_id, l.cx, l.modelled, coalesce(l.closed, false) LIMIT 1
Q_LIFT_RECORD MATCH (l:Lift) WHERE l.facility_id = $fid WITH l ORDER BY l.uid LIMIT 1 OPTIONAL MATCH (o:Outage)-[:OF]->(l) RETURN l.avail_12m, l.closed_h_12m, l.outages_12m, l.mttr_h, l.single_svc_h, count(o), sum(o.hours), max(o.end), sum(CASE WHEN o.imputed THEN 1 ELSE 0 END)
Q_LOST_NOW MATCH (p:Node:Platform) WHERE (p.k_board = 0 AND (p.k_board_base > 0 OR p.at_grade_board)) OR (p.k_exit = 0 AND (p.k_exit_base > 0 OR p.at_grade_exit)) RETURN p.stop_id, p.name, p.platform_name, p.station_id, p.k_board, p.k_exit, p.deps_day
Q_MAXFLOW MATCH (s:Node) WHERE s.uid IN $src WITH collect(s) AS S MATCH (t:Node) WHERE t.uid IN $dst WITH S, collect(t) AS T CALL algo.maxFlow({sourceNodes:S, targetNodes:T, relationshipTypes:['SF'], capacityProperty:'cap'}) YIELD edges, edgeFlows, maxFlow RETURN [e IN edges | e.eid], edgeFlows, maxFlow
Q_META MATCH (m:Meta) RETURN m.snapshot_id, m.seed_sha256 LIMIT 1
Q_MODELLED_LIFTS MATCH (l:Node:Lift) WHERE l.facility_id IN $fids RETURN collect(DISTINCT l.facility_id)
Q_NETWORK_SPOF MATCH (l:Node:Lift) WHERE l.od_cut_base > 0 WITH l.facility_id AS fid, head(collect(l)) AS l RETURN fid, l.name, l.station_id, l.od_cut_base, l.od_cut_off ORDER BY l.od_cut_base DESC, fid
Q_NEWLY_LAST MATCH (p:Node:Platform)-[n:DEPENDS_NOW]->(l:Lift) WHERE NOT (p)-[:DEPENDS_ON {dir:n.dir}]->(l) RETURN l.facility_id, l.name, l.station_id, collect(DISTINCT p.stop_id + ' ' + n.dir)
Q_PAIR_OVERLAP MATCH (x:Outage)-[:OF]->(a:Lift {facility_id:$a}) MATCH (y:Outage)-[:OF]->(b:Lift {facility_id:$b}) WHERE x.start y.start THEN x.start ELSE y.start END, CASE WHEN x.end < y.end THEN x.end ELSE y.end END])[0..20]
Q_PLATFORM_CUTS MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id AS stop, head(collect(p.mincuts_board)), head(collect(p.mincuts_exit)), head(collect(p.rel_counts_board)), head(collect(p.rel_counts_exit)), head(collect(p.rel_relevant_board)), head(collect(p.rel_relevant_exit)) ORDER BY stop
Q_PRIORITY MATCH (p:Node:Platform)-[d:DEPENDS_NOW]->(l:Lift) OPTIONAL MATCH (p)-[o:DEPENDS_ON]->(l) WHERE o.dir = d.dir WITH l, p, d, count(o) AS before WITH l, count(DISTINCT p.stop_id + d.dir) AS deps, sum(p.deps_day) AS departures, toInteger(sum(CASE WHEN before = 0 THEN 1 ELSE 0 END)) AS newly OPTIONAL MATCH (l)-[:CLOSED_IN]->(i:Incident) WITH l, deps, departures, newly, count(DISTINCT i) AS incidents RETURN l.facility_id, l.name, l.station_id, coalesce(l.closed, false), deps, newly, departures, incidents
Q_REACHES_FROM MATCH (a:Station {uid:$sid})-[r:REACHES]->(b:Station) RETURN b.uid, b.name, r.base_s ORDER BY r.base_s, b.uid
Q_REOPEN_LIFTS MATCH (i:Lift)-[r:SF_CLOSED]->(o) WHERE NOT i.facility_id IN $fids CREATE (i)-[c:SF]->(o) SET c = properties(r), i.closed = false DELETE r RETURN count(c)
Q_ROUTE_CHECK_RIDE UNWIND $pairs AS p MATCH (a:Platform {uid:p[0]})-[e:RIDE]->(b:Platform {uid:p[1]}) RETURN a.uid, b.uid, e.wt
Q_ROUTE_CHECK_SF UNWIND $pairs AS p MATCH (a:Node {uid:p[0]})-[e:SF]->(b:Node {uid:p[1]}) RETURN a.uid, b.uid, e.wt
Q_ROUTE_EDGES MATCH (a)-[e:SF|RIDE]->(b) RETURN a.uid, b.uid, e.eid, type(e), e.kind, e.wt, e.facility_id, e.routes
Q_ROUTE_NODES MATCH (n) WHERE n:Node OR n:Platform RETURN n.uid, labels(n), n.name, n.station_id, n.platform_name, n.short_name
Q_SET_PLATFORM_NOW MATCH (p:Node:Platform) WHERE p.stop_id = $stop SET p.k_board = $kb, p.k_exit = $ke, p.at_grade_board_now = $ab, p.at_grade_exit_now = $ae, p.cut_board_now = $cb, p.cut_exit_now = $ce
Q_STATION MATCH (s:Station {uid:$sid}) RETURN s.uid, s.name, s.cx, s.has_pathways, s.has_street
Q_STATION_EDGES MATCH (a:Node {cx:$cx})-[e]->(b:Node) WHERE type(e) IN ['SF','SF_CLOSED','STEP'] RETURN e.eid, a.uid, b.uid, type(e), e.kind, e.facility_id, e.pathway_ids
Q_STATION_NODES MATCH (n:Node {cx:$cx}) RETURN n.uid, labels(n), n.name, n.platform_name, n.facility_id, n.unit, coalesce(n.closed, false), n.stop_id, n.wheelchair_boarding
Q_STATION_PLATFORMS MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id AS stop, collect(p.uid), head(collect(p.name)), head(collect(p.platform_name)), min(p.k_board), min(p.k_exit), min(p.k_board_base), min(p.k_exit_base), head(collect(p.at_grade_board)), head(collect(p.at_grade_exit)), head(collect(p.at_grade_board_now)), head(collect(p.at_grade_exit_now)), head(collect(p.cut_board_now)), head(collect(p.cut_exit_now)), coalesce(head(collect(p.no_accessible_entrance)), false) ORDER BY stop
Q_STATION_SEARCH CALL db.idx.fulltext.queryNodes('Station', $q) YIELD node, score RETURN node.uid, node.name, node.has_pathways, node.has_street, score ORDER BY score DESC LIMIT 5
Q_STATION_UNMODELLED_LIFTS MATCH (l:Lift) WHERE l.station_id = $sid AND NOT l:Node RETURN l.facility_id, l.name ORDER BY l.facility_id
Q_WRITE_INCIDENTS UNWIND $rows AS r CREATE (i:Incident) SET i = r.p WITH i, r UNWIND r.fids AS fid MATCH (l:Lift {facility_id:fid}) MERGE (l)-[:CLOSED_IN]->(i)

Architecture

graph LR
  GTFS[MBTA GTFS + alerts] --> Ingest[ingest/build_snapshot.py] --> Snap[(snapshot JSONL)] --> Seed[seed.py] --> FDB[(FalkorDB 4.22)]
  FDB --> Tools[8 tools] --> API[FastAPI /v1] --> Web[website]
  Tools --> MCP[MCP server /mcp] --> Agent[agent: LLM chain or template]
  V3[MBTA V3 alerts] -.->|LIVE_FEED=on| Poller[live poller] --> FDB
  MCP --> Clients[any MCP client]
  FDB --> Mirror[scripts/build_mirror.py] --> Web

Run it yourself

Requirements: Python 3.14 (3.12+ works), Node 24, a FalkorDB 4.22 instance (Docker falkordb/falkordb-server:v4.22.0, or FalkorDB Cloud).

python -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt     # Windows: .venv\Scripts\python
.venv/bin/python dev.py doctor                               # checks Python, FalkorDB and .env key names
.venv/bin/python -m lastlift.seed --verify                   # load the committed snapshot into FalkorDB
.venv/bin/python -m uvicorn lastlift.main:app --port 8000    # API, MCP at /mcp/, agent at /api/ask
cd web && npm ci && npm run dev                              # website (VITE_API_BASE=http://localhost:8000)

No API key is needed: without an LLM key the agent answers through its deterministic template. The website shows the frozen demo alerts by default; its Live toggle switches to the live scope, which follows the MBTA’s current alerts when the server runs with LIVE_FEED=on (polled every LIVE_POLL_S seconds; GET /v1/live reports the last poll and marks it stale after five minutes) and otherwise uses the latest committed alert harvest. Configuration comes from environment variables or a .env file one level above the repository (names in .env.example); secrets are never committed.

Reproduce every number: python scripts/analyze.py (the max-flow sweep in FalkorDB), python scripts/verify_claims.py (an independent NetworkX re-derivation), python scripts/eval.py (the validation), python scripts/readme_sync.py --check (this README against claims.json).

What we assume, and where the model can be wrong

  • Fare gates are passable for riders who need step-free access; only reaching the platform is modelled, not boarding the train; widths and surfaces are not in the data.
  • MBTA publishes no level data; the station drawings infer levels from lift and stair connections.
  • Where MBTA publishes no station layout (many commuter-rail stations), LastLift says it cannot assess.
  • Lift status comes from MBTA’s alerts, which can differ from a lift’s actual state. The judged demo uses alerts frozen on 15 Oct 2026.

We found defects in the published data

Five overrides correct the published layout, each with evidence (data/overrides.csv): centre platforms at Sullivan Square and Wellington that the data joined to the side platforms (which made them look reachable when they are not), two Quincy Adams pathways tagged with the wrong elevator, and a Copley entrance. Reported to MBTA’s developer contact: to be added by the author.

Decisions I made, and a correction I made

Written by the author.

Data, licence and attribution

Code: MIT. Data: MBTA GTFS (feed “Fall 2026, 2026-10-14T18:47:38+00:00, version D”) and MBTA V3 API alerts, Massachusetts Department of Transportation open data, used under the MassDOT Developers License Agreement. LastLift is unofficial and not affiliated with or endorsed by the MBTA or MassDOT.

AI assistance

This project was built with the help of an AI coding assistant. Design decisions and the final review are the author’s own. The validation key (validation/gold_live_author.csv) was labelled by the author, blind, from MBTA’s alert text only; a second label set made by the AI assistant (validation/gold_live.csv) is kept only to report labeller agreement.

Credits

The algo.maxFlow defect reproduction is credited to the Chokepoint project. Built on FalkorDB, FastAPI, FastMCP, NetworkX, React and Vite.

Feedback

Found a problem with a route or a station? Report an accessibility problem with LastLift (every field is optional). Prefer not to post publicly? Email [email protected]. How LastLift handles people’s experiences is set out in docs/ETHICS.md.

View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide

Configuration

{ "mcpServers": { "lastlift": { "url": "https://lastlift-api.onrender.com/mcp/" } } }