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