From d11a20dcbbe6bd3e9ee76f91811998de02f8273c Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 24 Jul 2026 16:51:44 -0700 Subject: [PATCH] routing: add virtual clock and background traffic to the simulator In this commit, we close the biggest fidelity gap the simulator had: hidden liquidity only moved when the sender's own payments moved it, so knowledge never went stale and evidence-based routing strategies were unbeatable by construction. Real networks keep churning between a node's sends. Two additions, both opt-in per scenario file. A virtual clock advances simulated time between payments (payment_gap_sec) and per attempt (attempt_sec); the mission control stack behind the lnd baseline is switched onto the same settable clock, so decay half-lives operate over simulated rather than wall-clock time, and candidate routers can read the current time through the new Now() method on SimNetworkView. A background traffic engine executes seeded payments between random node pairs in each gap: naive fee-optimizing senders route along the cheapest public path with no knowledge of hidden balances, retrying around failed edges a bounded number of times, so only the payments that genuinely clear move liquidity, hop by hop, with per-channel conservation. The traffic sequence depends only on its seed, so competing routers face the identical exogenous process, and scenario files without the new sections reproduce their previous results bit for bit. The corpus generator grows a --drift flag that scales traffic volume with network size, and the code-mode reflection prompt now describes the drifting environment neutrally, flagging that the champions' hard liquidity bounds were learned in a static world, without prescribing whether time-awareness is the answer -- that is exp-008's question to settle. --- cmd/routesim/main.go | 24 +++ routing/sim_router.go | 27 ++- routing/sim_run.go | 120 +++++++++++- routing/sim_traffic.go | 353 ++++++++++++++++++++++++++++++++++++ routing/sim_traffic_test.go | 176 ++++++++++++++++++ simulation/gen_scenarios.py | 29 ++- simulation/run_gepa_code.py | 19 +- 7 files changed, 736 insertions(+), 12 deletions(-) create mode 100644 routing/sim_traffic.go create mode 100644 routing/sim_traffic_test.go diff --git a/cmd/routesim/main.go b/cmd/routesim/main.go index 189828d7d..901228f6d 100644 --- a/cmd/routesim/main.go +++ b/cmd/routesim/main.go @@ -34,6 +34,14 @@ type scenarioFile struct { // Source is the node all payments originate from. Source string `json:"source"` + // Clock enables virtual time: simulated seconds pass between + // payments and attempts, so decay half-lives actually operate. + Clock *routing.SimClockParams `json:"clock,omitempty"` + + // BackgroundTraffic enables exogenous seeded payments that move + // hidden liquidity between the scenario payments. + BackgroundTraffic *routing.SimTrafficParams `json:"background_traffic,omitempty"` + // Scenarios are executed in order against a shared mission control. Scenarios []routing.SimScenario `json:"scenarios"` } @@ -52,6 +60,11 @@ type aggregate struct { TotalFeeMsat uint64 `json:"total_fee_msat"` FeePPMOnSuccess float64 `json:"fee_ppm_on_success"` AmtSuccessMsat uint64 `json:"amt_success_msat"` + + // BgPaymentsSent and BgPaymentsSettled report the background traffic + // volume when the traffic model is enabled. + BgPaymentsSent int `json:"bg_payments_sent,omitempty"` + BgPaymentsSettled int `json:"bg_payments_settled,omitempty"` } type output struct { @@ -160,6 +173,16 @@ func main() { } defer runner.Close() + if scenFile.Clock != nil { + runner.SetVirtualClock(scenFile.Clock) + } + if scenFile.BackgroundTraffic != nil { + err := runner.SetBackgroundTraffic(scenFile.BackgroundTraffic) + if err != nil { + fatalf("unable to enable traffic: %v", err) + } + } + switch *router { case "lnd": case "candidate": @@ -193,6 +216,7 @@ func main() { } agg := &out.Aggregate + agg.BgPaymentsSent, agg.BgPaymentsSettled = runner.TrafficStats() if agg.NumScenarios > 0 { agg.SuccessRate = float64(agg.NumSuccesses) / float64(agg.NumScenarios) diff --git a/routing/sim_router.go b/routing/sim_router.go index bf7c00869..554b67881 100644 --- a/routing/sim_router.go +++ b/routing/sim_router.go @@ -3,6 +3,7 @@ package routing import ( "context" "math" + "time" "github.com/lightningnetwork/lnd/fn/v2" graphdb "github.com/lightningnetwork/lnd/graph/db" @@ -49,17 +50,37 @@ type SimPaymentSpec struct { } // SimNetworkView is the read-only public surface a router sees: the gossip -// graph for path queries. It intentionally hides the concrete graph type so -// that candidate implementations cannot reach the hidden balances or mutate -// liquidity — the same information asymmetry a real sender faces. +// graph for path queries plus the current time. It intentionally hides the +// concrete graph type so that candidate implementations cannot reach the +// hidden balances or mutate liquidity — the same information asymmetry a +// real sender faces. type SimNetworkView interface { Graph GraphSessionFactory + + // Now returns the current time as the router experiences it. Under a + // virtual clock this advances between payments and attempts, and + // hidden liquidity drifts with it when background traffic is enabled. + Now() time.Time } // simGossipView wraps a SimGraph, exposing only the SimNetworkView surface. type simGossipView struct { g *SimGraph + + // now is the runner's time source; nil falls back to the wall clock. + now func() time.Time +} + +// Now returns the current simulation time. +// +// NOTE: Part of the SimNetworkView interface. +func (v *simGossipView) Now() time.Time { + if v.now == nil { + return time.Now() + } + + return v.now() } func (v *simGossipView) ForEachNodeDirectedChannel(ctx context.Context, diff --git a/routing/sim_run.go b/routing/sim_run.go index 7228a8b83..fd7c4606c 100644 --- a/routing/sim_run.go +++ b/routing/sim_run.go @@ -6,6 +6,7 @@ import ( "path/filepath" "time" + "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" @@ -170,18 +171,55 @@ type SimScenarioResult struct { Error string `json:"error,omitempty"` } +// SimClockParams configures the virtual clock. Without one the simulation +// runs on the wall clock, where a whole batch finishes in well under any +// decay half-life; with one, simulated time passes between payments and +// attempts so that time-based logic (mission control decay, candidate +// staleness handling) actually operates. +type SimClockParams struct { + // StartUnix anchors the virtual clock; a fixed value keeps runs + // reproducible. Zero selects a fixed default epoch. + StartUnix int64 `json:"start_unix"` + + // PaymentGapSec is how much virtual time passes before each scenario + // payment, the window in which background traffic moves liquidity. + PaymentGapSec float64 `json:"payment_gap_sec"` + + // AttemptSec is how much virtual time each htlc attempt consumes. + AttemptSec float64 `json:"attempt_sec"` +} + +// simDefaultClockStart is the fixed virtual epoch used when a clock section +// doesn't pin one, chosen arbitrarily but deterministically. +const simDefaultClockStart int64 = 1_750_000_000 + // SimRunner runs payment scenarios against a simulated network with a // persistent mission control, mirroring the control loop of a real node. type SimRunner struct { graph *SimGraph source route.Vertex mc *MissionControl + mcc *MissionController params *SimParams // routerFactory builds the routing strategy under test, once per // payment. Defaults to the lnd production stack. routerFactory SimRouterFactory + // clk is the time source routers observe. It is the wall clock + // unless a virtual clock is configured. + clk clock.Clock + + // virtualClk is the settable clock behind clk when virtual time is + // enabled, nil otherwise. + virtualClk *clock.TestClock + + // clockParams holds the virtual time step sizes. + clockParams SimClockParams + + // traffic is the background traffic engine, nil when disabled. + traffic *simTraffic + cleanup func() } @@ -241,7 +279,9 @@ func NewSimRunner(graph *SimGraph, params *SimParams, source route.Vertex, graph: graph, source: source, mc: mc, + mcc: mcController, params: params, + clk: clock.NewDefaultClock(), cleanup: cleanup, } @@ -264,6 +304,75 @@ func (r *SimRunner) SetRouterFactory(factory SimRouterFactory) { r.routerFactory = factory } +// SetVirtualClock switches the runner (and the mission control stack behind +// the lnd baseline) onto a settable virtual clock, so that decay half-lives +// and other time-based logic operate over simulated time rather than the +// microseconds a batch takes on the wall clock. +func (r *SimRunner) SetVirtualClock(params *SimClockParams) { + start := params.StartUnix + if start == 0 { + start = simDefaultClockStart + } + + r.clockParams = *params + r.virtualClk = clock.NewTestClock(time.Unix(start, 0)) + r.clk = r.virtualClk + + // Mission control instances share the controller's config, so + // swapping the clock here reaches every namespace. + r.mcc.cfg.clock = r.virtualClk +} + +// SetBackgroundTraffic enables the exogenous traffic model: before each +// scenario payment, the configured number of seeded background payments +// execute between random node pairs, moving hidden liquidity the way other +// people's payments do on a live network. +func (r *SimRunner) SetBackgroundTraffic(params *SimTrafficParams) error { + traffic, err := newSimTraffic(r.graph, params) + if err != nil { + return err + } + r.traffic = traffic + + return nil +} + +// TrafficStats reports how many background payments were sent and settled. +func (r *SimRunner) TrafficStats() (sent, settled int) { + if r.traffic == nil { + return 0, 0 + } + + return r.traffic.Sent, r.traffic.Settled +} + +// advanceGap moves virtual time forward by the payment gap and lets the +// background traffic use that window. +func (r *SimRunner) advanceGap() { + if r.virtualClk != nil && r.clockParams.PaymentGapSec > 0 { + r.virtualClk.SetTime(r.virtualClk.Now().Add( + time.Duration(r.clockParams.PaymentGapSec * + float64(time.Second)), + )) + } + + if r.traffic != nil { + r.traffic.run() + } +} + +// advanceAttempt moves virtual time forward by one attempt's duration. +func (r *SimRunner) advanceAttempt() { + if r.virtualClk == nil || r.clockParams.AttemptSec <= 0 { + return + } + + r.virtualClk.SetTime(r.virtualClk.Now().Add( + time.Duration(r.clockParams.AttemptSec * + float64(time.Second)), + )) +} + // Close releases the runner's resources. func (r *SimRunner) Close() { r.cleanup() @@ -307,6 +416,11 @@ func (g *SimGraph) ResolveNode(ref string) (route.Vertex, error) { func (r *SimRunner) RunScenario(s *SimScenario) (*SimScenarioResult, error) { result := &SimScenarioResult{Scenario: *s} + // Let virtual time pass and background traffic move liquidity before + // this payment starts, the way a live network keeps churning between + // a node's own sends. + r.advanceGap() + source := r.source target, err := r.graph.ResolveNode(s.Target) if err != nil { @@ -329,7 +443,7 @@ func (r *SimRunner) RunScenario(s *SimScenario) (*SimScenarioResult, error) { // view wrapper hides the concrete graph so that a candidate router // cannot reach the hidden balances. router, err := r.routerFactory( - &simGossipView{g: r.graph}, source, + &simGossipView{g: r.graph, now: r.clk.Now}, source, r.graph.LocalBalances(source), spec, ) if err != nil { @@ -358,6 +472,10 @@ func (r *SimRunner) RunScenario(s *SimScenario) (*SimScenarioResult, error) { attemptID := nextAttemptID nextAttemptID++ + // Each attempt consumes virtual time: htlcs take real seconds + // to resolve on a live network. + r.advanceAttempt() + htlcResult, err := r.graph.SendHtlc(rt) if err != nil { result.Error = fmt.Sprintf("malformed route: %v", err) diff --git a/routing/sim_traffic.go b/routing/sim_traffic.go new file mode 100644 index 000000000..5189b20d5 --- /dev/null +++ b/routing/sim_traffic.go @@ -0,0 +1,353 @@ +package routing + +import ( + "container/heap" + "fmt" + "math" + "math/rand" + "sort" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" +) + +// trafficFinalCltvDelta is the final cltv delta background senders use, +// matching the value the candidate contract documents. +const trafficFinalCltvDelta = 40 + +// trafficMaxHops caps the route length of background payments. +const trafficMaxHops = 8 + +// SimTrafficParams configures the exogenous background traffic model: seeded +// payments between random node pairs that move hidden liquidity between the +// scenario payments, the way other people's payments do on the real network. +type SimTrafficParams struct { + // PaymentsPerGap is how many background payments are executed before + // each scenario payment. + PaymentsPerGap int `json:"payments_per_gap"` + + // MinAmtMsat and MaxAmtMsat bound the payment amounts, drawn + // log-uniformly so small payments dominate, as they do in practice. + MinAmtMsat uint64 `json:"min_amt_msat"` + MaxAmtMsat uint64 `json:"max_amt_msat"` + + // RouteAttempts is how many alternative routes a background sender + // tries before giving up on a payment. Defaults to 3. + RouteAttempts int `json:"route_attempts"` + + // Seed makes the traffic sequence reproducible. The pair and amount + // choices depend only on the seed, so two runs against the same + // scenario file face the same exogenous process. + Seed int64 `json:"seed"` +} + +// simTraffic executes background payments directly against the hidden +// balances of a SimGraph. Background senders behave like naive fee-optimizing +// nodes: they route along the cheapest path the public gossip view allows and +// have no knowledge of hidden liquidity, so some of their payments fail, and +// only the settled ones move balances. +type simTraffic struct { + graph *SimGraph + params SimTrafficParams + rng *rand.Rand + + // nodes is the sorted node list, fixed at construction so that pair + // selection is deterministic for a given seed. + nodes []route.Vertex + + // Sent and Settled count background payments for reporting. + Sent int + Settled int +} + +// newSimTraffic builds a traffic engine over the given graph. +func newSimTraffic(graph *SimGraph, params *SimTrafficParams) (*simTraffic, + error) { + + if params.PaymentsPerGap <= 0 { + return nil, fmt.Errorf("payments_per_gap must be positive") + } + if params.MinAmtMsat == 0 || params.MaxAmtMsat < params.MinAmtMsat { + return nil, fmt.Errorf("invalid traffic amount range [%d, %d]", + params.MinAmtMsat, params.MaxAmtMsat) + } + if params.RouteAttempts <= 0 { + params.RouteAttempts = 3 + } + + nodes := make([]route.Vertex, 0, len(graph.nodes)) + for v := range graph.nodes { + nodes = append(nodes, v) + } + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].String() < nodes[j].String() + }) + + if len(nodes) < 2 { + return nil, fmt.Errorf("traffic needs at least two nodes") + } + + return &simTraffic{ + graph: graph, + params: *params, + rng: rand.New(rand.NewSource(params.Seed)), + nodes: nodes, + }, nil +} + +// run executes one gap's worth of background payments. +func (t *simTraffic) run() { + for i := 0; i < t.params.PaymentsPerGap; i++ { + t.sendOne() + } +} + +// sendOne attempts a single background payment between a random pair. +func (t *simTraffic) sendOne() { + sender := t.nodes[t.rng.Intn(len(t.nodes))] + receiver := t.nodes[t.rng.Intn(len(t.nodes))] + if sender == receiver { + return + } + + // Draw the amount log-uniformly from the configured range. + logMin := math.Log(float64(t.params.MinAmtMsat)) + logMax := math.Log(float64(t.params.MaxAmtMsat)) + amt := lnwire.MilliSatoshi(math.Exp( + logMin + t.rng.Float64()*(logMax-logMin), + )) + + t.Sent++ + + // A naive sender: cheapest path first, blacklist the failing edge + // and retry a couple of times, then give up. + blacklist := make(map[trafficEdgeKey]struct{}) + for attempt := 0; attempt < t.params.RouteAttempts; attempt++ { + rt := t.findRoute(sender, receiver, amt, blacklist) + if rt == nil { + return + } + + result, err := t.graph.SendHtlc(rt) + if err != nil { + return + } + + if result.Failure == nil { + t.Settled++ + return + } + + // Blacklist the directed edge that failed so the retry + // explores a different corridor. + idx := getNodeIndexSim(rt, result.FailureSource) + if idx == nil || *idx >= len(rt.Hops) { + return + } + blacklist[trafficEdgeKey{ + from: result.FailureSource, + chanID: rt.Hops[*idx].ChannelID, + }] = struct{}{} + } +} + +// trafficEdgeKey identifies a directed channel edge for blacklisting. +type trafficEdgeKey struct { + from route.Vertex + chanID uint64 +} + +// trafficPathNode is the per-node state of the backward Dijkstra search. +type trafficPathNode struct { + // amtIn is the amount that must arrive at this node for the payment + // amount to reach the receiver, i.e. amount plus downstream fees. + amtIn lnwire.MilliSatoshi + + // expiryIn is the cltv expiry that must arrive at this node. + expiryIn uint32 + + // hops is the number of channels between this node and the receiver. + hops int + + // nextChan and nextNode point one step toward the receiver. + nextChan uint64 + nextNode route.Vertex +} + +// trafficHeapItem is a priority queue entry for the search. +type trafficHeapItem struct { + node route.Vertex + amtIn lnwire.MilliSatoshi +} + +type trafficHeap []trafficHeapItem + +func (h trafficHeap) Len() int { return len(h) } + +func (h trafficHeap) Less(i, j int) bool { return h[i].amtIn < h[j].amtIn } + +func (h trafficHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *trafficHeap) Push(value any) { + *h = append(*h, value.(trafficHeapItem)) +} + +func (h *trafficHeap) Pop() any { + old := *h + item := old[len(old)-1] + *h = old[:len(old)-1] + + return item +} + +// findRoute runs a backward cheapest-fee Dijkstra from the receiver and +// builds a well-formed route, or returns nil if no usable path exists. The +// search only uses public knowledge: policies and capacities, never hidden +// balances. +func (t *simTraffic) findRoute(sender, receiver route.Vertex, + amt lnwire.MilliSatoshi, + blacklist map[trafficEdgeKey]struct{}) *route.Route { + + states := map[route.Vertex]*trafficPathNode{ + receiver: { + amtIn: amt, + expiryIn: trafficFinalCltvDelta, + }, + } + settled := make(map[route.Vertex]struct{}) + + pq := &trafficHeap{{node: receiver, amtIn: amt}} + heap.Init(pq) + + for pq.Len() > 0 { + item := heap.Pop(pq).(trafficHeapItem) + if _, done := settled[item.node]; done { + continue + } + settled[item.node] = struct{}{} + + if item.node == sender { + break + } + + state := states[item.node] + if state.hops >= trafficMaxHops { + continue + } + + node := t.graph.nodes[item.node] + for _, channel := range node.channels { + // u would forward INTO item.node over this channel. + u := channel.otherEnd(item.node).owner + if _, done := settled[u]; done { + continue + } + + key := trafficEdgeKey{from: u, chanID: channel.ID} + if _, banned := blacklist[key]; banned { + continue + } + + policy := &channel.end(u).policy + if !trafficEdgeUsable( + policy, channel, state.amtIn, + ) { + continue + } + + amtIn := state.amtIn + policy.fee(state.amtIn) + existing, seen := states[u] + if seen && existing.amtIn <= amtIn { + continue + } + + states[u] = &trafficPathNode{ + amtIn: amtIn, + expiryIn: state.expiryIn + + uint32(policy.TimeLockDelta), + hops: state.hops + 1, + nextChan: channel.ID, + nextNode: item.node, + } + heap.Push(pq, trafficHeapItem{node: u, amtIn: amtIn}) + } + } + + if _, reached := settled[sender]; !reached { + return nil + } + + return t.buildRoute(sender, receiver, amt, states) +} + +// trafficEdgeUsable applies the public policy and capacity filters for +// forwarding the given amount. +func trafficEdgeUsable(policy *SimPolicy, channel *SimChannel, + amt lnwire.MilliSatoshi) bool { + + if policy.Disabled { + return false + } + if amt < policy.MinHTLCMsat { + return false + } + if policy.MaxHTLCMsat != 0 && amt > policy.MaxHTLCMsat { + return false + } + + capMsat := lnwire.NewMSatFromSatoshis(channel.Capacity) + + return amt <= capMsat +} + +// buildRoute walks the next pointers from sender to receiver and assembles a +// route with the amount and expiry accumulation SendHtlc expects. +func (t *simTraffic) buildRoute(sender, receiver route.Vertex, + amt lnwire.MilliSatoshi, + states map[route.Vertex]*trafficPathNode) *route.Route { + + var hops []*route.Hop + + current := sender + for current != receiver { + state := states[current] + + // Hop semantics: AmtToForward is the amount the hop's node + // forwards ONWARD, i.e. the amount that must arrive at the + // node two steps downstream. For the final hop it is the + // payment amount, which is exactly what the receiver's state + // holds. Same shape for the expiry. + amtToForward := amt + outgoingTimeLock := uint32(trafficFinalCltvDelta) + if state.nextNode != receiver { + afterNext := states[states[state.nextNode].nextNode] + amtToForward = afterNext.amtIn + outgoingTimeLock = afterNext.expiryIn + } + + hops = append(hops, &route.Hop{ + PubKeyBytes: state.nextNode, + ChannelID: state.nextChan, + AmtToForward: amtToForward, + OutgoingTimeLock: outgoingTimeLock, + }) + + current = state.nextNode + } + + if len(hops) == 0 { + return nil + } + + // The route total is what the sender puts on its first channel: the + // amount that must arrive at the first hop's node. For a direct + // channel this is the payment amount itself, via the receiver state. + first := states[states[sender].nextNode] + + return &route.Route{ + TotalAmount: first.amtIn, + TotalTimeLock: first.expiryIn, + SourcePubKey: sender, + Hops: hops, + } +} diff --git a/routing/sim_traffic_test.go b/routing/sim_traffic_test.go new file mode 100644 index 000000000..d16312fde --- /dev/null +++ b/routing/sim_traffic_test.go @@ -0,0 +1,176 @@ +package routing + +import ( + "testing" + "time" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// trafficTestGraph builds a well-connected network with assigned liquidity +// for the traffic tests. +func trafficTestGraph(t *testing.T, liquiditySeed int64) *SimGraph { + t.Helper() + + graph, err := GenerateSimGraph(&SimTopologySpec{ + Type: "smallworld", + NumNodes: 40, + ChannelSizeSat: 1_000_000, + Seed: 11, + AvgDegree: 6, + }) + require.NoError(t, err) + + require.NoError(t, graph.AssignLiquidity( + LiquidityBimodal, liquiditySeed, + )) + + return graph +} + +// balanceSnapshot captures every directional balance of the graph. +func balanceSnapshot(g *SimGraph) map[uint64][2]lnwire.MilliSatoshi { + snapshot := make(map[uint64][2]lnwire.MilliSatoshi) + for id, channel := range g.channels { + snapshot[id] = [2]lnwire.MilliSatoshi{ + channel.ends[0].balance, + channel.ends[1].balance, + } + } + + return snapshot +} + +// TestSimTrafficMovesLiquidity asserts that background traffic settles +// payments, moves hidden balances, and conserves per-channel totals. +func TestSimTrafficMovesLiquidity(t *testing.T) { + t.Parallel() + + graph := trafficTestGraph(t, 3) + before := balanceSnapshot(graph) + + traffic, err := newSimTraffic(graph, &SimTrafficParams{ + PaymentsPerGap: 50, + MinAmtMsat: 100_000, + MaxAmtMsat: 50_000_000, + Seed: 7, + }) + require.NoError(t, err) + + traffic.run() + + require.Positive(t, traffic.Sent) + require.Positive(t, traffic.Settled, "no background payment settled") + + after := balanceSnapshot(graph) + + var moved int + for id, endsBefore := range before { + endsAfter := after[id] + + // Per-channel conservation: the two ends always sum to the + // same total, HTLCs only shift balance across a channel. + require.Equal( + t, + endsBefore[0]+endsBefore[1], + endsAfter[0]+endsAfter[1], + "channel %d total changed", id, + ) + + if endsBefore != endsAfter { + moved++ + } + } + + require.Positive(t, moved, "traffic moved no balances") +} + +// TestSimTrafficDeterminism asserts that the same seed produces the same +// traffic effects and a different seed does not. +func TestSimTrafficDeterminism(t *testing.T) { + t.Parallel() + + run := func(trafficSeed int64) map[uint64][2]lnwire.MilliSatoshi { + graph := trafficTestGraph(t, 3) + + traffic, err := newSimTraffic(graph, &SimTrafficParams{ + PaymentsPerGap: 30, + MinAmtMsat: 100_000, + MaxAmtMsat: 20_000_000, + Seed: trafficSeed, + }) + require.NoError(t, err) + + traffic.run() + + return balanceSnapshot(graph) + } + + require.Equal(t, run(1), run(1), "same seed diverged") + require.NotEqual(t, run(1), run(2), "different seeds agreed") +} + +// TestSimVirtualClock asserts that the virtual clock advances between +// payments and attempts, is visible to routers through the view, and reaches +// the mission control stack. +func TestSimVirtualClock(t *testing.T) { + t.Parallel() + + graph := trafficTestGraph(t, 5) + + source, err := graph.ResolveNode("1") + require.NoError(t, err) + + runner, err := NewSimRunner( + graph, DefaultSimParams(), source, t.TempDir(), + ) + require.NoError(t, err) + defer runner.Close() + + start := int64(1_800_000_000) + runner.SetVirtualClock(&SimClockParams{ + StartUnix: start, + PaymentGapSec: 600, + AttemptSec: 1, + }) + + // The mission control stack must observe the virtual clock, not the + // wall clock. + require.Equal( + t, time.Unix(start, 0), runner.mcc.cfg.clock.Now(), + "mission control still on the wall clock", + ) + + // Capture the time the router observes through its view on each + // payment. + var observed []time.Time + baseFactory := runner.routerFactory + runner.SetRouterFactory(func(view SimNetworkView, src route.Vertex, + localBalances map[uint64]lnwire.MilliSatoshi, + spec *SimPaymentSpec) (SimRouter, error) { + + observed = append(observed, view.Now()) + return baseFactory(view, src, localBalances, spec) + }) + + for _, target := range []string{"10", "20"} { + _, err := runner.RunScenario(&SimScenario{ + Target: target, + AmtMsat: 1_000_000, + MaxParts: 2, + }) + require.NoError(t, err) + } + + require.Len(t, observed, 2) + + // The first payment starts one gap after the epoch, and the second + // starts at least another gap later (plus attempt seconds). + firstGap := observed[0].Sub(time.Unix(start, 0)) + require.Equal(t, 600*time.Second, firstGap) + + secondGap := observed[1].Sub(observed[0]) + require.GreaterOrEqual(t, secondGap, 600*time.Second) +} diff --git a/simulation/gen_scenarios.py b/simulation/gen_scenarios.py index d0b39c35d..12cde8351 100644 --- a/simulation/gen_scenarios.py +++ b/simulation/gen_scenarios.py @@ -32,7 +32,7 @@ TOPOLOGIES = [ LIQUIDITY_MODELS = ["bimodal", "bimodal", "uniform"] -def gen_example(rng: random.Random) -> dict: +def gen_example(rng: random.Random, drift: bool = False) -> dict: topology = dict(rng.choice(TOPOLOGIES)) topology["seed"] = rng.randrange(1, 2**31) @@ -56,7 +56,7 @@ def gen_example(rng: random.Random) -> dict: "max_parts": max_parts, }) - return { + example = { "topology": topology, "liquidity_model": rng.choice(LIQUIDITY_MODELS), "liquidity_seed": rng.randrange(1, 2**31), @@ -64,6 +64,25 @@ def gen_example(rng: random.Random) -> dict: "scenarios": scenarios, } + if drift: + # Virtual time passes between payments and background senders move + # hidden liquidity in the gaps: ten minutes per gap, with traffic + # volume scaled to the network size so knowledge genuinely goes + # stale between a node's own sends. Amounts are log-uniform from + # dust up to half a channel. + example["clock"] = { + "payment_gap_sec": 600, + "attempt_sec": 1, + } + example["background_traffic"] = { + "payments_per_gap": max(10, num_nodes // 10), + "min_amt_msat": max(1_000, cap_msat // 1_000), + "max_amt_msat": cap_msat // 2, + "seed": rng.randrange(1, 2**31), + } + + return example + def main() -> None: parser = argparse.ArgumentParser() @@ -75,6 +94,10 @@ def main() -> None: parser.add_argument("--hard", action="store_true", help="bimodal-only, small-channel topologies with " "headroom (drop easy scale-free nets)") + parser.add_argument("--drift", action="store_true", + help="enable the virtual clock and background " + "traffic so liquidity drifts between payments " + "(exp-008)") args = parser.parse_args() global TOPOLOGIES, LIQUIDITY_MODELS @@ -99,7 +122,7 @@ def main() -> None: split_dir = out / split split_dir.mkdir(parents=True, exist_ok=True) for i in range(count): - example = gen_example(rng) + example = gen_example(rng, drift=args.drift) path = split_dir / f"example_{i:03d}.json" path.write_text(json.dumps(example, indent=2)) print(f"{split}: {count} examples in {split_dir}") diff --git a/simulation/run_gepa_code.py b/simulation/run_gepa_code.py index 893c24063..07d61d217 100644 --- a/simulation/run_gepa_code.py +++ b/simulation/run_gepa_code.py @@ -58,6 +58,13 @@ Environment truths worth exploiting: remaining amount. - Payments per scenario batch run sequentially and liquidity persists, so knowledge from earlier payments in the batch transfers. +- THE NETWORK KEEPS MOVING BETWEEN YOUR PAYMENTS: scenario files may + enable background traffic, where other participants' payments shift + hidden liquidity in the (virtual) minutes between your payments, and a + virtual clock, readable as view.Now(), advances between payments and + attempts. In such environments, what you learned about a channel k + payments ago may no longer hold. Whether and how to account for the + age of evidence is entirely your design choice. The current seed is a cheapest-path Dijkstra with failure blacklisting and halving splits. Known weaknesses to consider: it ignores capacity when @@ -71,11 +78,13 @@ simulation/champions/), worth building on rather than rediscovering: - An explicit BIMODAL PRIOR over amount/capacity works: near-certain for tiny amounts (decaying exponential low mode), a logistic cliff as the amount approaches capacity, floors/caps around [0.005, 0.985]. -- Per-directed-channel liquidity BELIEFS beat time-decayed penalties: - track lower-OK (largest amount proven to pass) and upper-fail - (smallest proven to fail) bounds plus a confidence-weighted point - estimate; return ~0.995 below lower-OK, ~0 above upper-fail, blend - with the prior in between. Evidence counts, not wall-clock decay. +- Per-directed-channel liquidity BELIEFS work well: track lower-OK + (largest amount proven to pass) and upper-fail (smallest proven to + fail) bounds plus a confidence-weighted point estimate; return ~0.995 + below lower-OK, ~0 above upper-fail, blend with the prior in between. + (Caveat: this insight was learned in environments with NO background + traffic, where old evidence never went stale. Its hard bounds may or + may not survive in a drifting network.) - Retry-at-lower-amount on a failed channel (a lower-retry factor) outperforms permanently blacklisting it. - Keep the implementation LEAN: past ~800 lines, edits stop compiling