DEV Community

Amit Kumar Gupta
Amit Kumar Gupta

Posted on

Slack is a property of the hand-off, not the flight

Building a cascade-containment agent where the graph decides and the model only talks.


The thing that kept bothering me

When a flight is delayed, the obvious mental model is that the flight has a problem. Weather at the airport, a technical issue, a late passenger.

But that's mostly not where delay comes from. Eurocontrol's CODA Digest measured that 44.6% of European delay minutes in Q2 2023 were knock-on — reactionary delay, not primary. In the US, the DOT's Air Travel Consumer Report for September 2025 lists late-arriving aircraft as the single largest cause.

Knock-on delay isn't a new problem happening. It's an old problem arriving.

The reason is structural: an airline flies the same aircraft on four to six legs a day, and the same crew across a subset of those legs. Flight two is waiting on flight one. The dependency is real, it's physical, and it's a graph.

What made this click for me was the Indian regulator's language after IndiGo's December 2025 collapse — 2,507 cancellations, 1,852 delays, more than three lakh passengers stranded over three days, a ₹22.2 crore penalty. The PIB's finding was that the rosters had "maximised crew utilisation", cutting roster buffers and increasing reliance on dead-heading, tail swaps and minimal recovery time.

Maximised utilisation means minimised slack. The schedule was efficient right up until it wasn't.


The modelling decision that made everything else easy

My first instinct was to put a slack property on each flight. That's wrong, and seeing why is the whole project.

Slack doesn't belong to a flight. It belongs to the gap between two flights:

slack_min = scheduled_ground_time − minimum_legal_turnaround
Enter fullscreen mode Exit fullscreen mode

A flight isn't fragile. A hand-off is fragile. So the model has two edge types doing all the work:

Edge Meaning Properties
NEXT_LEG same aircraft, immediately following leg min_turn, sched_ground, slack_min
CREW_NEXT same crew, immediately following leg min_rest, sched_ground, slack_min

Once slack lives on the edge, cascade propagation becomes something you can write down in one query. Delay spills from flight a to flight b exactly when it exceeds the buffer between them:

MATCH (a:Flight)-[e:NEXT_LEG|CREW_NEXT]->(b:Flight)
WHERE a.status = 'SCHEDULED' AND b.status = 'SCHEDULED'
  AND (a.delay_min - e.slack_min) > b.delay_min
WITH b, max(a.delay_min - e.slack_min) AS new_delay
SET b.delay_min = new_delay,
    b.proj_dep  = b.sched_dep + new_delay,
    b.proj_arr  = b.sched_arr + new_delay
RETURN count(b) AS changed
Enter fullscreen mode Exit fullscreen mode

Run it until it returns changed = 0. That's Bellman-Ford relaxation to a fixpoint — in the database, not in application code.

The max() is doing something subtle and necessary. A flight has two inbound dependencies: its aircraft and its crew. It takes the worse of them, because it waits for both. One aggregation, and that rule is expressed exactly once.


Why "why is this flight late?" is the real feature

Here's the query I'd show a judge if I only had one:

MATCH p = (root:Flight {id: $root_id})-[:NEXT_LEG|CREW_NEXT*1..8]->(target:Flight {id: $target_id})
RETURN [n IN nodes(p) | n.id]                AS chain,
       [r IN relationships(p) | type(r)]     AS hop_types,
       [r IN relationships(p) | r.slack_min] AS slack_at_each_hop,
       length(p)                             AS hops
ORDER BY hops ASC
LIMIT 1
Enter fullscreen mode Exit fullscreen mode

It answers "why is this flight late?" with a path: 6E101 was fogbound → handed its aircraft to 6E117 across a turn with 5 minutes of slack → whose crew carried it to 6E131 across 8 minutes of slack.

No model wrote that sentence. It's a traversal. If the explanation is wrong, the graph is wrong, and I can go look at the graph.

This is the part I think is genuinely underrated about graph databases in agent systems. Everyone reaches for them as a retrieval layer — fetch some context, stuff it in a prompt. But a traversal result is an explanation, structurally. You get provenance for free because the answer is literally the path you walked.


Proof by zero rows

The agent can propose actions: cancel a pair of flights, swap in a spare aircraft, swap in reserve crew. Before any of them can be committed, four invariants must hold. Each is a query that must return nothing:

  • INV-1 — no crew's duty window exceeds its limit
  • INV-2 — no turnaround is shorter than the minimum
  • INV-3 — tail continuity: an aircraft's arrival airport equals its next departure airport
  • INV-4 — every flight has a terminal status

I want to dwell on INV-3, because my first design didn't have it.

Cancelling a delayed flight is the obvious containment move. It's also, often, catastrophically wrong — because cancelling a single mid-rotation leg leaves the aircraft in the wrong city for the rest of the day. The aircraft can't teleport. So cancellations have to be done in out-and-back pairs, or paired with an explicit ferry.

I only found this by writing the invariant down and watching plans fail it. That's the argument for expressing safety as absence-of-a-pattern rather than as application logic: absence of a matching path is the proof of safety, and a query that returns zero rows is much harder to fool yourself about than an if statement you forgot to write.


Forking the world before touching it

Every plan is evaluated on a GRAPH.COPY fork:

fork → apply the plan → re-propagate the cascade → check 4 invariants → score → DELETE fork
Enter fullscreen mode Exit fullscreen mode

FalkorDB's GRAPH.COPY clones nodes, relationships, properties, indexes and constraints, while the source stays readable and writable. The fork is deleted in a finally block — always, including when the work inside throws, which is its own test.

One bug worth confessing: relaxation is monotone. It only ever raises a delay. So my first version of the evaluation loop forked, applied a cancellation, re-ran relaxation — and every plan scored identically. Of course it did: cancelling a flight can't reduce a delay that has already propagated downstream. The fork has to reset to the published schedule and re-propagate from the disruption after the actions are applied. Obvious in hindsight; invisible until the scores came back suspiciously flat.


The constraint that shaped the architecture

I gave myself one rule, borrowed from the hackathon's own:

The graph decides. The model talks. A human confirms.

Concretely, in the MCP server, there are nine tools. Eight are read-only. The ninth is commit_plan, and it takes only a decision_id — never a plan body.

That signature is the whole safety argument. The model cannot invent an action, because there is no parameter through which an action could arrive. It can only point at something the graph already verified and recorded. The worst a confused model can do is point at the wrong decision, and even that is caught:

Every Decision is stamped with the graph revision it was verified against. If the live graph has moved since, commit refuses. A plan proven safe against one state of the world is not proven safe against another.


The test I'd keep if I could only keep one

tests/graph-off.test.ts
Enter fullscreen mode Exit fullscreen mode

It points the product at a dead database and asserts that every path fails. No cache, no default, no degraded mode, no in-memory fallback. It also greps the source for forbidden datastore imports.

If the product still works with the graph removed, the graph was decoration. This test is how I know it isn't.

It's also what let me add MongoDB for user accounts without weakening the claim. Accounts are the app shell; flights, edges, cascades, plans, decisions and receipts are the product. Dropping the Mongo database changes no number the product reports — and packages/graph is forbidden from importing mongoose, enforced by a linter rather than by my good intentions.


What I'd tell myself at the start

Write the oracle first. Before I had a database connection, I wrote a plain-TypeScript checker that validated the generated schedule against the same four invariants the Cypher enforces. It caught three real bugs immediately: a negative crew-rest slack (I'd tightened hand-offs to min_turn when min_rest is the longer requirement), a rotation overrunning the 23:00 day boundary, and over-eager crew chunking. An independent implementation of the same rule is worth more than another test of the same implementation.

Put every query in one file. All the Cypher lives in queries.ts — none inline in a route, a script or a test. It makes the "here are the queries this depends on" section of the README a copy-paste, and it makes the queries reviewable as a set.

Name your relationships. FalkorDB only existence-checks unnamed relationships. MATCH (a)-[e:NEXT_LEG]->(b) where you actually use e behaves correctly; drop the alias and your counts can quietly go wrong.


What this is and isn't

It's a decision-support prototype on synthetic data. Every flight, tail, crew member and passenger count is generated; only airport codes and coordinates are real.

It is explicitly not a replacement for operations solvers. Lufthansa's OPSD is a mature deployed system; INFORMS reported controllers accepted 26% of its suggestions, and around twenty developers spent four years mostly on data preparation. That 26% is a published figure. Why it's 26% is my hypothesis, not a finding — but it's the hypothesis the project is built around: an operations controller is personally accountable for the decision, and a recommendation you can't interrogate is a recommendation you have to take on faith.

So the position isn't "better plans". It's: see the whole chain, explain every suggestion as a path, and test it against hard constraints before anyone acts.

The containment selection is a scored heuristic with declared weights, not a proof of optimality. The invariants only cover what's modelled — bad input data can still produce a plan that passes verification. And duty_limit_min = 780 is a placeholder parameter, not a regulatory figure; reading the actual DGCA FDTL CAR is still on the list.


Top comments (0)