routing: add an experimental interval router behind a config flag - #11048
routing: add an experimental interval router behind a config flag#11048Roasbeef wants to merge 9 commits into
Conversation
In this commit, we point both release note entries at the request that carries this work, lightningnetwork#11048.
In this commit, we point both release note entries at the request that carries this work, lightningnetwork#11048.
In this commit, we let a payment session hear what became of the routes it handed out. Today the payment lifecycle reports every attempt to mission control and to nothing else, which is enough while the only view of network liquidity in the system is the node wide one mission control owns. A session that carries a view of its own has no way to see the results of its own work, so we give it one. The new PaymentResultReporter interface is optional. The lifecycle type asserts on it after it has finished talking to mission control, and a session that does not implement it never hears a thing, which is exactly what the session lnd ships expects. Nothing about the existing flow moves: mission control still decides whether a failure is terminal, and the reporting happens after that decision rather than instead of it. The third method is the one worth explaining. A session that tracks the attempts it produced needs to know when no further outcome is coming, because a route it returned may never have reached the switch at all, in which case nothing else would ever tell it so. The lifecycle calls it on the way out, from a defer beside the one that stops the shard goroutines. We also lift the two additional edge helpers off the session and onto the edge map itself, so that a second session implementation can embed the map and pick up both interface methods without copying them.
In this commit, we add the thing the router reasons over. Mission control remembers a penalty per node pair and lets it fade on a half life. This remembers an amount range per directed channel: the largest amount it has watched pass, the smallest it has watched fail, and an estimate in between, with a confidence and a classification saying which side of the bimodal liquidity distribution the channel appears to sit on. Three kinds of observation write to it. A probe is what we learn when a failure comes back from a node further along the route than a given hop, which proves that hop forwarded. A failure drops the upper bound. A settlement is different in kind from both: the balance really has moved, so the forward interval slides down by the settled amount and the reverse interval slides up by the same. Every observation writes both directions, because liquidity sitting on one side of a funding output is not sitting on the other, and nothing in mission control makes that inference. The probability model on top is a branch table ordered by how much the evidence proves, from a bound we watched hold down to a bimodal prior we have nothing behind. The prior expresses its scale as a fraction of capacity rather than as an absolute number of millisatoshis, which is what lets it mean the same thing on channels that differ in size by orders of magnitude. There is no clock anywhere in the file. Two properties are worth calling out because they are load bearing rather than incidental. A belief seeded in from durable storage is marked as restored, and the model then refuses to let either of its bounds speak with certainty: this model has no clock, so an amount it calls impossible is never attempted, and the attempt is the only thing that could correct a bound that has gone stale. And the store carries an overlay of the amounts we currently have committed in flight, kept apart from the beliefs and never persisted, because it records what we are doing to the network rather than what we believe about it.
In this commit, we add the search that turns the beliefs into a route. It walks backwards from the target the same way lnd's own path finder does, and it reuses the machinery that makes that walk correct: the edge unifier that picks a policy per node pair, the bandwidth hints that speak for our own channels, the fee and time lock limits, the onion payload accounting, and the feature validation of every node it would route through. Two things differ. The cost of a hop is the negative log of its probability plus terms for fee, depth and how much of a channel the payment would fill, rather than a fee divided through by a probability. Additivity is what makes the search tractable, and it is far gentler on a route the model is merely unsure about. The other difference is that a node keeps a bounded set of Pareto-incomparable labels rather than a single best distance. Because the search runs backwards and fees accrue as it goes, a route that is cheaper but carries a larger amount is genuinely incomparable to one that is dearer and carries less, since the larger amount may be refused further upstream. The units of the fee term decide whether a fee budget can reach the search at all, which is worth stating plainly because it is easy to get wrong. Converting a fee at a rate proportional to the amount reads as a willingness to pay a fifth of the payment for one factor of e in reliability, and no budget anybody sets is within reach of that, so the term never binds. A payment carrying a budget therefore prices fees against the budget itself, half of what remains, bounded at both ends and absolute rather than relative, so the ceiling tightens as the payment grows. A payment with no budget has nothing to derive a rate from and falls back to the amount relative form. Two smaller rules follow from the same concern. A node keeps the cheapest label it holds whatever that label scores, but only when the payment has a budget that could bind, since a slot spent on price is a slot wasted for a payment choosing on reliability alone. And the search is bounded on hops, labels per node and total expansions, because it is more expensive than a single distance Dijkstra by construction.
🔴 PR Severity: CRITICAL
🔴 Critical (8 files)
🟠 High (13 files)
🟡 Medium (1 file)
🟢 Low (12 files)
AnalysisThis PR introduces a large new "interval routing" liquidity-belief system spanning To override, add a |
In this commit, we add the payment session that owns route selection and splitting together. The session lnd ships does not choose a shard size: it asks path finding for the whole remaining amount and halves it when nothing comes back, so every split is a reaction to a failure and every shard is a power-of-two fraction. This one enumerates a ladder of candidate shard sizes, finds a route for each, and keeps the best pairing of the two, which means it can split before it has failed at all and can pick a shard sized to fit just under a bound it discovered two attempts ago. The ladder draws on four sources: the whole remaining amount and the smallest shard that could still finish the payment, the amounts this payment has already proven do not fit divided down until they do, the even divisions, and the halving chain. The second is what makes the ladder a function of the beliefs rather than of the amount alone, and it is enumerated early because every rung costs a full search and the ladder is capped. The session layers its own experience over the node wide store. A channel it watched refuse an amount is discounted by how much smaller the retry is, on a ladder that replaces blacklisting and waiting for a penalty to decay. A channel that rejected an HTLC on policy grounds is stepped around for the rest of the payment, because the policy we hold for it is stale and a retry would be built from the same stale policy. None of that outlives the payment, while everything written to the store does. Two things the session does that the amounts alone would not tell you. It counts the HTLCs it already has in flight when pricing a hop, since a channel holding some of ours must have had that much plus the new shard available when we last looked, and it releases those holds on three separate triggers so that one can never outlive its HTLC. And when a failure cannot be attributed to a hop, it narrows the suspects by elimination rather than penalizing the whole route: one suspect left is a certainty, none at all is a contradiction, and several share the blame in proportion to how many there are. Payments the router does not serve are handed to the stock session instead, so that turning it on cannot make a payment unroutable that would otherwise have gone through. Today that means payments to blinded paths, where there is no directed channel to key a belief on.
In this commit, we make the interval router selectable and leave it off. The new routerrpc.router option takes either default or interval, and defaults to default, so a node that says nothing about routing keeps exactly the stack it had before. With the flag off nothing in the new code is reached at all: the server builds the same session source it always did, and the seam the lifecycle grew is a type assertion the stock session does not satisfy. The interval source wraps the stock one rather than replacing it, so the payments the interval router does not serve still get a session that can route them. Both options are documented in the sample config, along with the conditions under which the second of them does anything.
In this commit, we let the router keep what it has learned across a restart. Everything it knows about the network is gathered by attempting payments, so a cold start is not merely slower, it is paid for in failed HTLCs until the picture is rebuilt. A new table holds one direction of one channel per row, the store loads what was written down when the node starts, and it writes changes back as it goes. We flush on a ticker rather than writing through. One attempt writes both directions of every hop it touched, so writing through would put a handful of database round trips between an HTLC failing and the next route being chosen, which is the one path in the router a user waits on. Nothing here has to survive a crash to stay correct either: a belief that never reached disk is one the router rediscovers on its next attempt, at the cost of that attempt. The cadence has a knob of its own, so that tuning it does not mean tuning mission control's as well. What comes back is deliberately not what went in. A belief read from disk is marked restored and its confidence halved, and the probability model then refuses to let either of its bounds speak with certainty. That matters more here than the same problem would in mission control, because this model has no clock: a bound moves only when evidence arrives, and an amount the model calls impossible is never attempted, so the evidence that would correct a stale bound could never turn up. Two choices in the schema are worth calling out. Confidence is stored in parts per million rather than as a float, because nothing else in this schema has a floating point column and we would rather not be the first to find out how the two dialects differ on one. And a short channel id of eight zero bytes is reserved for a belief about a node pair as a whole, which is the shape the router needs when a pair has several channels and an observation cannot say which of them carried the payment. Persistence is only available on a node running the native SQL backend. Everywhere else the store runs exactly as it did, memory only, with no dirty tracking and no goroutine.
In this commit, we give the router somewhere to put evidence it cannot trust. An unreadable onion error, or one that no node on the route claims, leaves several hops that could each have been the one to refuse. Writing an upper bound on all of them would be a claim of certainty about channels that may be perfectly healthy, and a bound in this model is permanent until evidence moves it: an amount it calls impossible is never attempted, so a wrong bound can never be corrected. Ambiguous evidence goes into a quarantine instead, held per directed channel alongside the interval but deliberately outside it. It keeps the smallest amount such a failure has named and how much corroboration stands behind it, where a failure naming two suspects contributes half of what one naming a single suspect would, so the more ways a failure could be explained the more of them it takes to convict. While it sits there it prices as a discount on the amount it named and never as an impossibility. Enough agreement promotes it into an ordinary upper bound. What may release a suspicion is the subtle part, and it is narrower than it first appears. When a hop reports a failure, the router writes a lower bound on every hop before it, because forwarding is what carried the payment that far. That inference holds exactly when the report names the right hop. When blame arrives shifted downstream, which is one of the ways a real network lies, the guilty channel sits before the reported index and collects a lower bound saying it carried the amount it had just refused. A quarantine that accepted lower bounds as proof of innocence would let that channel out of every suspicion it belonged in and pile the blame onto its innocent neighbours. So the router keeps a separate record of what it has watched actually move, written by nothing but a settlement, and only that clears a suspicion. The lower bound keeps every other job it has, in path finding and in bound maintenance, and loses only a standing that was never really its to give. Three smaller decisions come with it. A settlement proves the direction it moved over and no other, so the reverse direction is left alone. Neither the quarantine nor the record of settlements is written to disk, since a suspicion restored from a previous run is one nothing since could have cleared, and a settlement from before a restart says nothing about a failure observed after it. And there is exactly one place anything enters the quarantine, with a config field on it, so that a node can switch the whole mechanism off and get the payment-local handling it had before.
In this commit, we exercise the interval router against real nodes. Only the sender runs it, so the test also covers routing through peers that do not, which is the situation any node turning this on would be in. The payment worth testing is the second one. We drain the middle hop, watch a large payment fail there, and then send a small one without resetting anything and without waiting. The stock router would have penalized the node pair on that failure and would need the penalty to decay before it would offer the pair again. The interval router recorded an amount rather than a verdict, so every smaller amount over the same channel stayed routable and the recovery costs one attempt. That is the behaviour the whole design is for, and the one thing about it an integration test can show that a unit test cannot.
In this commit, we write down what the router does and why, for a contributor who knows lnd and has never seen this line of work. The algorithm inverts the question path finding asks: rather than testing whether the graph can carry an amount fixed before the search began, it asks what the network will accept and picks the amount and the route together. Everything else follows from that, so the document leads with it. The rest covers the belief itself, the bimodal prior and why its scale is a fraction of capacity rather than a number of satoshis, the label setting search and why a node has to keep more than one answer, how the shard ladder is priced and where its exchange rate between fees and reliability comes from, the trust boundary the quarantine draws, and how all of it coexists with mission control, which keeps running untouched. The limitations section is the part worth reading twice. It states the blinded path fallback and what finishing it would take, why a bound restored from disk is deliberately weaker than one we just watched hold, why an ambiguous failure is recorded against a node pair rather than a channel, what the search costs against a single Dijkstra run, and that the constants in the model were selected by a search rather than derived, with some of them surely fitted to the simulator that produced them. The release notes gain an entry under functional enhancements and one under database, since the router brings a table with it.
0da8d1d to
e0ced40
Compare
In this PR, we add a second routing engine to lnd, selectable with
routerrpc.router=intervaland off by default. It replaces the way lndlearns from payment results, and it leaves everything else (the payment
lifecycle, HTLC dispatch, mission control's history and RPCs) exactly as
it is today.
How it improves on the current algorithm
Today, lnd learns from a failed payment by penalizing a node pair. Mission
control records the failure, a half-life timer fades the penalty, and path
finding avoids the pair until enough time has passed. The amount is chosen
before the search begins: path finding answers "can the graph carry X," and
when the answer is no, the payment loop halves X and searches again.
The interval router keeps a different memory. For each channel, in each
direction, it tracks a liquidity interval: the largest amount it has
watched succeed, the smallest amount it has watched fail, and an estimate
in between. Three practical differences follow from that one change.
First, a failure records an amount, not a verdict. A channel that just
refused 400,000 sats is still the obvious way to send 40,000, so the
router keeps using it below the bound instead of routing around it for
every amount. There is no clock anywhere in the model, because a bound
does not need to be forgiven; it needs to be contradicted by a newer
observation.
Second, every observation teaches both directions. Liquidity that is
missing on one side of a funding output sits on the other side, so a
failure toward a peer is also evidence about the path back. Mission
control never draws that inference, and it is free information: in our
measurements it accounts for roughly half of the reduction in payment
attempts.
Third, splitting is planned rather than reactive. For each route request
the session builds a ladder of candidate shard sizes, including sizes
derived from amounts this payment has already proven do not fit, finds a
route for each rung, and takes the best pairing. The halving loop
discovers a workable size by failing toward it; the interval router
computes one from the bounds it already holds.
The gains show up where knowledge matters. On a simulated snapshot of the
real graph (12,161 nodes, real channel policies), the interval router
completes payments with 2.5 attempts on average where stock lnd needs
19.8, at a higher success rate. Under realistic error attribution, where
a fraction of failures are unreadable, stock lnd's give-up rate more than
doubles while the interval router's success holds flat: penalizing a
whole route in both directions for an error nobody can read turns out to
be the single most expensive habit in the current design. And because the
search prices fees against the payment's actual remaining budget, the fee
limit can bind during path finding rather than being discovered when a
route is rejected.
How it was developed
The design was not sketched on a whiteboard; it was found by search, then
rebuilt by hand.
We first built a payment simulator into this codebase: hidden per-channel
balances, real forwarding checks, seeded graphs, and a router interface
that owns both route selection and splitting. We then ran an evolutionary
search (LLM-driven program synthesis, scored only by simulated payment
outcomes) over whole routing algorithms, with lnd's production stack as
the baseline. Across dozens of independent runs and more than thirty
controlled experiments, every winning candidate converged on the same
paradigm: drop the per-pair penalties, drop time decay, and keep
per-direction liquidity intervals instead.
The code in this branch is not machine-written. We distilled the paradigm
into a hand-written implementation inside lnd's real payment lifecycle,
then spent six adversarial benchmark rounds trying to break it. Those
rounds caught real bugs (a fee term whose units meant no budget could
ever bind, a classifier that read a budget's remainder as its existence,
and a subtle case where misattributed failures manufactured false
evidence of a channel's health), and each fix was validated against the
same battery before it stayed. The final configuration beats stock lnd on
all fourteen simulated benchmark tiers with zero losses, holds its
margins under lnd's default fee limits, and reproduces its lead on an
externally generated graph whose balance distribution none of our tools
ever fit.
Every number above comes from a simulator, and the limitations section
below says so plainly. The simulator, corpora, and experiment writeups
live in a research fork and are deliberately not part of this PR; this
branch carries only the router, its tests, and its documentation.
What is in the branch
In dependency order, which is also the order to review:
out. The lifecycle reported to mission control and nowhere else; a session
with beliefs of its own needs the same stream. Optional, so the stock session
does not implement it and nothing changes with the flag off.
scale is a fraction of capacity rather than a fixed number of millisatoshis,
which is what lets one set of constants work across channel sizes.
per node, because a route that is cheaper but carries a larger amount is not
comparable to one that is dearer and carries less.
payment shapes the router does not serve.
liquidity_intervalstable.boundary that decides what may release one. This is the part with the most
argument behind it and the least measured gain; the two sections below say
why it is here anyway.
Evidence
The design was arrived at by evolutionary search against an in-process payment
simulator, and then validated against lnd's production stack on held-out
scenarios. What the branch rests on:
across clean, degraded, split, atomic and mainnet-derived scenario
families, with the paired confidence interval excluding zero on every
one. The score behind these comparisons is a payment objective: success
rate, minus small penalties for extra attempts and for fees paid.
corpora fresh, thirty files per family, the leads over stock lnd come
back larger than the original tiers had shown: the hard family lands between +0.16 and +0.26 of
objective, every one at p below 2e-3, and mainnet at +0.096. The collapse that
degraded attribution induces in stock lnd reproduces on the unseen files too,
which is the part that was most worth checking, since it is the finding the
whole design leans on.
fee limit
lnrpc.CalculateFeeLimitgives them by default, which is the limitevery RPC payment actually carries.
router holds parity with the best routers the search produced, which is the
closest thing available to an out-of-sample check on a topology nobody here
chose.
payment that settles, one that fails on liquidity at the middle hop, and a
smaller one that settles immediately afterwards with nothing reset, which is
the behaviour the whole design is for.
What this is not
Stated plainly, because the evidence above is easy to over-read.
Everything above was measured in simulation. No number here comes from
mainnet. The simulator's liquidity is drawn from a generator, and while the
mainnet-derived tiers use real topology and real policies, their balances are
synthetic. That is the single largest caveat on the whole branch.
The scenarios are ones we chose. Thirteen paired tiers moving the
liquidity family, the amount family and the graph itself did not change the
ordering, which is evidence against overfitting rather than proof of its
absence.
One interaction was found and explained, and the fix rests on the mechanism
rather than on a measured gain. A tier mixing unreadable errors with shifted
blame behaved worse than either ingredient alone. The cause is the
quarantine's trust boundary: a failure that names the wrong hop makes the
router write a lower bound on the channel that actually refused, and the
quarantine was reading that bound as proof of innocence, letting the culprit
out of every suspicion and concentrating the blame on its neighbours. Two
things establish that. Ablating the ingredients separates them cleanly, since
neither unreadable errors nor shifted blame does this alone. And ground truth
counters name the victims: on the mixed tier 9.6% of promoted bounds landed on
channels that had never failed, against 0.0% when only unreadable errors were
present.
What does not survive is the size of it. On thirty freshly generated files of
that family the objective-level effect is indistinguishable from zero, in both
directions, and the ten files it was first seen on could not have pinned an
effect that small against a per-file spread reaching 0.29. So we keep the fix
on the grounds that it stops the router accepting evidence that misattribution
manufactures, and we do not claim it buys anything measurable. Its cost is
likewise not measurable at thirty files, which is the other half of why it
stays.
The quarantine is severable. It sits behind
DisableQuarantineandcan be switched off, or dropped by reverting its commits, without
touching anything else.
The beliefs are simulator-shaped in one specific way. Several constants in
the probability model were selected by a search against a generator whose
liquidity distribution the constants then came to fit. The mechanism is what
transfers; the constants are the part to be suspicious of.
Known limitations
degraded mainnet the router sits 0.021 of objective below the best of them,
with the interval spanning -0.042 to +0.001 at thirty files: the direction has
held everywhere it has been measured, and it has never been separated from
zero. What the other router has there, and this one does not, is that
degrading attribution costs it no success at all, 0.557 either way. We have
not reproduced that invariance and we do not claim it.
path there is no channel to key a belief on, and an error from inside one
names nothing further in. The fallback is transparent: the session handed out
is the one the stock source would have produced.
router did not choose their routes and cannot say which channels they sit on.
channel, since non-strict forwarding means the evidence cannot name one.
router is off by default.
Reviewing this
The flag is off by default, and with it off none of the new code is constructed:
the server builds the same session source it always did, and the lifecycle seam
is a type assertion the stock session does not satisfy. The commits build and
pass tests individually.
docs/interval_routing.mdexplains the algorithm for someone who has not seenthis line of work, and is the place to start.