From a7f29983d82ba7ee28b8ae47fcc3c06d1cbba6b7 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:38:26 -0500 Subject: [PATCH] feat(routing-policy): native-Go preset engine so the TinyGo wasm-visor can run presets (no wazero) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in routing-policy presets (rotating-bw, latency-adaptive, elastic-mux, probe-and-prune, adaptive, app-mux, geo-avoid, transport-diverse, trust-tiered, time-of-day) could only run on the NATIVE visor: "preset:" loads the embedded bundle.wasm and evaluates it via wazero. A wasm-visor is itself TinyGo wasm and cannot host wazero (wasm-in-wasm), so it never evaluated presets — its router was built with a nil DialHook. Extract the preset decide/tick logic into ONE pure-Go package, pkg/router/policy/preset (stdlib-only, no unsafe/JSON-ABI/wazero/starlark), as the single source of truth, and compile it into BOTH paths: - docs/examples/routing-policies/wasm/bundle/main.go becomes a thin wasm ABI shim over the preset package (JSON wire types + alloc/free/decide_route/on_tick glue only); bundle.wasm rebuilt from it. The native visor still runs this bundle via wazero, byte-identically. - pkg/router/policy/presethook adapts the preset package to the router.DialHook / RouteSelectingHook / RotationHook interfaces WITHOUT wazero, TinyGo-safe (imports only pkg/router + preset). cmd/wasm-visor wires it as the router's DialHook, selected by the ?routing_policy= query param (unset ⇒ nil hook ⇒ unchanged no-policy behavior). Byte-identical guarantee (gate-1) extended to the native path: a new parity_test drives the SAME inputs through the wazero bundle and the native preset package and asserts identical decisions — field-for-field on decide for every preset, and step-for-step over a multi-tick on_tick sequence for the stateful controllers (EWMA smoothing, AIMD peak/idle, probe state machine). The existing wazero end-to-end presets_test and the bundle's native main_test still pass unchanged. Verified: go build . (root), go test ./pkg/router/policy/..., GOOS=js GOARCH=wasm build of ./cmd/wasm-visor, and a full TinyGo-fork build of ./cmd/wasm-visor (the committed embed lane) all pass. Router-integration note: the conditional presets (geo-avoid, transport-diverse) read per-hop geo/transport-kind metadata; on the wasm-visor these come from a presethook.Provider, which is nil today (NopProvider), so those two defer until a wasm-side metadata provider is wired — a follow-up. trust-tiered and all the shape/tick presets work with no provider. --- cmd/wasm-visor/main.go | 46 + .../routing-policies/wasm/bundle/go.mod | 11 +- .../routing-policies/wasm/bundle/main.go | 1671 ++--------------- pkg/router/policy/preset/names.go | 36 + pkg/router/policy/preset/preset.go | 468 +++++ pkg/router/policy/preset/preset_test.go | 89 + pkg/router/policy/preset/tick.go | 732 ++++++++ pkg/router/policy/presethook/presethook.go | 240 +++ .../policy/presethook/presethook_test.go | 83 + pkg/router/policy/wasm/presets/bundle.wasm | Bin 551798 -> 551439 bytes pkg/router/policy/wasm/presets/parity_test.go | 288 +++ 11 files changed, 2122 insertions(+), 1542 deletions(-) create mode 100644 pkg/router/policy/preset/names.go create mode 100644 pkg/router/policy/preset/preset.go create mode 100644 pkg/router/policy/preset/preset_test.go create mode 100644 pkg/router/policy/preset/tick.go create mode 100644 pkg/router/policy/presethook/presethook.go create mode 100644 pkg/router/policy/presethook/presethook_test.go create mode 100644 pkg/router/policy/wasm/presets/parity_test.go diff --git a/cmd/wasm-visor/main.go b/cmd/wasm-visor/main.go index fc24a100c4..a53475ee5d 100644 --- a/cmd/wasm-visor/main.go +++ b/cmd/wasm-visor/main.go @@ -78,6 +78,8 @@ import ( "github.com/skycoin/skywire/pkg/logging" "github.com/skycoin/skywire/pkg/rfclient" "github.com/skycoin/skywire/pkg/router" + "github.com/skycoin/skywire/pkg/router/policy/preset" + "github.com/skycoin/skywire/pkg/router/policy/presethook" "github.com/skycoin/skywire/pkg/routing" "github.com/skycoin/skywire/pkg/skyenv" "github.com/skycoin/skywire/pkg/transport" @@ -149,6 +151,36 @@ func pageHTTPS() bool { return loc.Get("protocol").String() == "https:" } +// wasmRoutingPolicyPreset reads the routing-policy preset name from the page URL +// (?routing_policy=), validating it against the built-in preset set. It +// returns "" (no policy) when the param is absent, empty, or names an unknown +// preset — so the default is unchanged no-policy behavior and a typo can't +// silently apply the app-mux fallback. This is the wasm-visor's analog of the +// native visor's routing.policy config field. +func wasmRoutingPolicyPreset() string { + loc := js.Global().Get("location") + if !loc.Truthy() { + return "" + } + search := loc.Get("search") + if !search.Truthy() { + return "" + } + usp := js.Global().Get("URLSearchParams") + if !usp.Truthy() { + return "" + } + v := usp.New(search.String()).Call("get", "routing_policy") + if !v.Truthy() { + return "" + } + name := v.String() + if !preset.Has(name) { + return "" + } + return name +} + func main() { ctx = context.Background() // One binary, several roles (see shell_js.go / tpviz_js.go): the tab loads @@ -554,6 +586,19 @@ func bootEdge(skHex, seedPKHex, seedWSURL, discDmsgAddr, cfgOverrideJSON string) // svc.MinHops is 1 (origination enabled; a direct transport still downgrades to // a 0-intermediate-hop path). vlog("router: New + serve…") + // Routing policy: a browser leaf can't host wazero (wasm-in-wasm), so it + // can't run the embedded bundle.wasm the native visor evaluates for + // "preset:". Instead it runs the SAME preset decide/tick logic compiled + // in as native Go (pkg/router/policy/preset, the single source of truth) via + // presethook, wired here as the router's DialHook — the one integration point + // (pkg/visor/init_router.go builds the wazero-backed equivalent on native). + // Opt-in and configurable: the preset name comes from the ?routing_policy= + // query param; unset ⇒ nil DialHook ⇒ unchanged (no-policy) behavior. + var dialHook router.DialHook + if name := wasmRoutingPolicyPreset(); name != "" { + dialHook = presethook.New(name, nil) + vlog(fmt.Sprintf("router: routing policy preset %q active (native-Go preset engine)", name)) + } r, err := visorcore.BuildRouter(ctx, visorcore.RouterDeps{ DmsgC: dmsgC, PubKey: pk, @@ -563,6 +608,7 @@ func bootEdge(skHex, seedPKHex, seedWSURL, discDmsgAddr, cfgOverrideJSON string) RouteGroupDialer: rgDialer, SetupNodes: svc.RouteSetupNodes, MinHops: svc.MinHops, + DialHook: dialHook, // Route-setup hook: on an app dial (min_hops=1, no --existing-tp) the // router races this against the route-finder to create a DIRECT transport // to the peer — the browser edge's missing half (a native visor registers diff --git a/docs/examples/routing-policies/wasm/bundle/go.mod b/docs/examples/routing-policies/wasm/bundle/go.mod index f3bb35fbcb..fab0c989d7 100644 --- a/docs/examples/routing-policies/wasm/bundle/go.mod +++ b/docs/examples/routing-policies/wasm/bundle/go.mod @@ -1,3 +1,12 @@ module skywire-routing-policy-bundle -go 1.21 +go 1.26.4 + +// The preset decide/tick logic is the single source of truth in the main +// skywire module (pkg/router/policy/preset); this bundle is a thin wasm ABI +// shim over it. The replace points at the repo root so `tinygo build` compiles +// the in-tree package. preset imports only the Go stdlib, so no go.sum / external +// downloads are pulled into this module. +require github.com/skycoin/skywire v0.0.0 + +replace github.com/skycoin/skywire => ../../../../.. diff --git a/docs/examples/routing-policies/wasm/bundle/main.go b/docs/examples/routing-policies/wasm/bundle/main.go index e7941668d2..a13863fb25 100644 --- a/docs/examples/routing-policies/wasm/bundle/main.go +++ b/docs/examples/routing-policies/wasm/bundle/main.go @@ -8,28 +8,39 @@ // runtime is paid once; each extra preset adds only its own few KB of // logic. // +// This file is now a THIN SHIM. The preset decide/tick logic itself lives +// ONCE, as pure Go, in github.com/skycoin/skywire/pkg/router/policy/preset +// (the single source of truth). This shim keeps only the wasm ABI: the +// JSON wire types, the alloc/free/read/write linear-memory glue, and the +// //export decide_route / on_tick entry points. Each entry point unmarshals +// the wire input, converts it to the shared preset types, calls into the +// preset package, and marshals the result back out. The NATIVE visor runs +// the compiled bundle.wasm via wazero; the WASM (browser/TinyGo) visor +// compiles the SAME preset package in directly (it cannot host wazero — +// wasm-in-wasm), so both paths make byte-identical decisions. +// // This is the module embedded at pkg/router/policy/wasm/presets/ // bundle.wasm and selected by config as "preset:" (see // pkg/visor/policy_loader.go). The per-preset standalone examples next // to this dir (app-mux/, rotating-bw/) stay as pedagogical single-preset // modules; this one is their union. // -// Build with TinyGo: -// -// cd docs/examples/routing-policies/wasm/bundle -// tinygo build -target=wasi -no-debug -opt=2 -o bundle.wasm . +// Build with TinyGo (from this directory): // -// then copy it over pkg/router/policy/wasm/presets/bundle.wasm. +// tinygo build -target=wasi -no-debug -opt=2 \ +// -o ../../../../pkg/router/policy/wasm/presets/bundle.wasm . package main import ( "encoding/json" - "sort" - "strings" "unsafe" + + "github.com/skycoin/skywire/pkg/router/policy/preset" ) -// Wire types — kept in sync with pkg/router/policy/wasm/abi.go. +// Wire types — kept in sync with pkg/router/policy/wasm/abi.go. These are the +// JSON envelopes the host (wazero) marshals across linear memory; the guest +// converts them to/from the pure preset.* types. type candidateWire struct { Hops []string `json:"hops"` @@ -99,6 +110,13 @@ type rotationActionWire struct { PromoteFromStandby []int `json:"promote_from_standby,omitempty"` } +// engine holds the adaptive tick controllers' per-transport_id state for this +// module instance. One package-global instance mirrors the bundle's original +// package-global tick state (the host instantiates one wazero module per policy +// load, so this is created fresh per load); the native visor constructs its own +// preset.Engine per evaluator the same way. +var engine = preset.New() + // Required: host-driven memory management. //export alloc @@ -133,1595 +151,166 @@ func decideRoute(inPtr, inLen uint32) uint64 { if err := json.Unmarshal(readInput(inPtr, inLen), &input); err != nil { return 0 } - var spec routeSpecWire - switch input.Preset { - case "rotating-bw": - spec = decideRotatingBW(input.Ctx) - case "latency-adaptive": - spec = decideLatencyAdaptive(input.Ctx) - case "elastic-mux": - spec = decideElasticMux(input.Ctx) - case "probe-and-prune": - spec = decideProbeAndPrune(input.Ctx) - case "adaptive": - spec = decideAdaptive(input.Ctx) - case "app-mux": - spec = decideAppMux(input.Ctx) - case "geo-avoid": - spec = decideGeoAvoid(input.Ctx, input.Candidates) - case "transport-diverse": - spec = decideTransportDiverse(input.Ctx, input.Candidates) - case "trust-tiered": - spec = decideTrustTiered(input.Ctx, input.Candidates) - case "time-of-day": - spec = decideTimeOfDay(input.Ctx) - default: - // Empty / unknown preset: fall back to the per-app app-mux - // behavior so a bare bundle still does something sensible. - spec = decideAppMux(input.Ctx) - } - out, err := json.Marshal(spec) + spec := preset.Decide(input.Preset, ctxToPreset(input.Ctx), candsToPreset(input.Candidates)) + out, err := json.Marshal(specToWire(spec)) if err != nil { return 0 } return writeOutput(out) } -// decideAppMux is the verbatim app-mux preset logic: per-app static mux -// + min_hops; latency-sensitive apps stay single-route, bandwidth apps -// get parallel legs. -func decideAppMux(ctx routingContextWire) routeSpecWire { - switch ctx.App { - case "vpn-client": - return routeSpecWire{Mux: 4, MinHops: 2} - case "skychat": - // Chat is latency-sensitive — single route, lowest mux. - return routeSpecWire{Mux: 1} - default: - // Everything else: visor defaults (empty spec). - return routeSpecWire{} - } -} - -// decideRotatingBW is the rotating-bw (privacy) preset: targetMux active legs -// over multi-hop with byte load spread EQUALLY across them (traffic-analysis -// resistance — no single relay sees a large fraction of the flow), plus one -// extra warm-standby leg so on_tick can rotate the active set every 90s -// WITHOUT a tear-and-rebuild dip (see tickRotatingBW). -func decideRotatingBW(ctx routingContextWire) routeSpecWire { - // rotating-bw is OPT-IN per app (proxy/vpn/skynet start --routing-policy, or - // visor app arg routing-policy). Apply it to WHATEVER app/session it is - // active for — do NOT gate on the built-in binary names. A named proxy - // session dials under its SESSION name (e.g. "g8"), not "skysocks-client", - // so the old `switch ctx.App { case "skysocks-client", ... }` silently made - // the policy a NO-OP for every custom-named session (empty spec → no mux, no - // min_hops, no rotation — the reason the rotation never fired live). Only - // skip genuinely latency-sensitive apps, where a multi-hop mux is the wrong - // shape. - switch ctx.App { - case "skychat", "skychat-client": - return routeSpecWire{Mux: 1} - } - // min_hops=2 already says "no direct transport" (direct is 0 intermediates); - // the visor treats min_hops>=2 as an implicit avoid_direct so the dial flows - // to the overlay path where rotation can act. Without it the mux/distribution - // is silently dropped by the direct-dial fast path. - return routeSpecWire{ - // targetMux active + 1 warm standby (see tickRotatingBW). - Mux: targetMux + 1, - MinHops: 2, - RotationIntervalSeconds: 90, - // Equal (round-robin) byte spread across the active legs — the privacy - // property. "auto"/latency-weighting pins bytes to the fastest leg, - // defeating the spread; the policy sets this to override the muxMode - // default. - Distribution: "round-robin", - } -} - -// decideLatencyAdaptive is the latency-adaptive preset's decide logic: an -// ASYMMETRIC spec for bandwidth/proxy apps — a single lean, direct-ok -// upstream leg (uploads are small) paired with a 4-way multi-hop -// downstream fan-out (bulk downloads). RotationIntervalSeconds=30 keeps -// on_tick firing so the downstream set can be re-evaluated and the -// slowest leg evicted; Distribution "auto" lets the host weight bytes -// toward the faster legs. Non-target apps get the empty spec (defaults). -func decideLatencyAdaptive(ctx routingContextWire) routeSpecWire { - switch ctx.App { - case "vpn-client", "skysocks-client", "skynet-client": - // Symmetric mux=4: the on_tick evict-slowest logic acts on the - // route group's forward legs (rg.tps) — the only legs the tick - // hook sees (reverse-only legs live in rg.rvs and are invisible - // to on_tick). An asymmetric 1-up/4-down shape would put the 4 - // adaptive legs on the reverse side where on_tick can't manage - // them, making the eviction a no-op. Symmetric mux gives on_tick - // 4 real legs to converge over. (The lean-upstream refinement - // awaits a router change exposing reverse legs to the tick hook.) - // - // Mux is targetMux+1 (5): one leg beyond the 4-wide active target is a - // WARM RESERVE the on_tick controller parks on standby, so evicting the - // slowest leg is a hot-swap (promote the spare, demote the outlier) with - // no width dip — the no-dip discipline shared with rotating-bw. - return routeSpecWire{ - Mux: 5, - MinHops: 2, - RotationIntervalSeconds: 30, - Distribution: "auto", - } - } - return routeSpecWire{} -} - -// decideElasticMux is the elastic-mux preset's decide logic. It starts -// deliberately MODEST — a 2-way mux over multi-hop — and lets the on_tick -// AIMD controller grow or shrink the leg count to match observed load. -// Unlike the static-mux presets (which pin a fixed width), the initial -// Mux here is only a seed: RotationIntervalSeconds=20 makes on_tick fire -// often enough to react to load swings, and Distribution "auto" lets the -// host weight bytes toward the faster legs while the count floats. -// Non-target apps get the empty spec (defaults). -func decideElasticMux(ctx routingContextWire) routeSpecWire { - switch ctx.App { - case "vpn-client", "skysocks-client", "skynet-client": - return routeSpecWire{ - Mux: 2, - MinHops: 2, - RotationIntervalSeconds: 20, - Distribution: "auto", - } - } - return routeSpecWire{} -} - -// decideProbeAndPrune is the probe-and-prune preset's decide logic. It -// holds a steady 3-way mux over multi-hop as the "established" set that -// on_tick continuously refines: every 30s the tick controller adds one -// speculative leg over a fresh path, watches it for a few ticks, and -// keeps it only if it beats the current worst leg (else discards it), so -// the established set drifts toward lower latency without ever growing -// past its target. Distribution "auto" weights bytes toward the faster -// legs meanwhile. Non-target apps get the empty spec (defaults). -func decideProbeAndPrune(ctx routingContextWire) routeSpecWire { - switch ctx.App { - case "vpn-client", "skysocks-client", "skynet-client": - return routeSpecWire{ - Mux: 3, - MinHops: 2, - RotationIntervalSeconds: 30, - Distribution: "auto", - } - } - return routeSpecWire{} -} - -// decideAdaptive is the COMPOSITE "adaptive" preset's decide logic — the -// intended converged default. It returns a sensible middle spec that the -// tickAdaptive controller then steers along all three performance -// dimensions at once: a 3-way disjoint multi-hop mux (min_hops=2), latency- -// weighted byte distribution ("auto"), re-evaluated every 20s so on_tick -// fires often enough to react to load swings, latency drift, and probe -// results. The Mux of 3 is only the STARTING width — tickAdaptive's AIMD -// dimension grows it toward the cap under load and shrinks it back toward -// the floor when idle. Non-target apps get the empty spec (defaults). -func decideAdaptive(ctx routingContextWire) routeSpecWire { - switch ctx.App { - case "vpn-client", "skysocks-client", "skynet-client": - // NOTE: no MinHops here on purpose. min-hops is a PRIVACY CONSTRAINT the - // operator owns (session --min-hops / visor config), not a knob for the - // performance policy to impose. Leaving it 0 means "inherit" — the - // router's EffectiveMinHops takes the strictest of the operator's floor - // and this spec, so adaptive optimizes WITHIN the operator's chosen - // floor: with the default floor it can use a fast low-hop path; with a - // privacy floor set it fans the mux out over multi-hop. Hardcoding - // MinHops=2 forced every dial onto slow multi-hop legs even when a fast - // direct path existed — measurably worse, not better. - return routeSpecWire{ - Mux: adaptDecideMux, - RotationIntervalSeconds: 20, - Distribution: "auto", - } - } - return routeSpecWire{} -} - -// targetMux is the mux size the rotating-bw policy aims to maintain. -// Kept in sync with the Mux value returned from decideRotatingBW so -// on_tick can reason about "are we at target?" -const targetMux = 4 - -// --- conditional presets: constrain WHICH path is chosen, by route metadata --- -// -// Unlike the performance presets (which return a mux/min-hops SHAPE and let the -// host pick candidates), these pick a specific forward candidate via -// spec.Chosen so the selected route provably honors a CONSTRAINT expressed over -// the candidate's own metadata — the per-hop country (hops_geo), the per-hop -// transport types (transport_kinds), the hop PKs (hops) — or the wall clock -// (now_unix_nano). They are decide-time only (no on_tick): the constraint is a -// property of the chosen route, not something that drifts over the session. -// Parameters come from cli_overrides so the operator configures the constraint -// (avoid_geo / trusted_pks / business_hours) without a new preset. - -// splitSet parses a comma/space-separated override value into a lowercased -// membership set. Empty input yields an empty (never-nil) set. -func splitSet(s string) map[string]bool { - out := map[string]bool{} - for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) { - if f != "" { - out[strings.ToLower(f)] = true - } - } - return out -} - -// decideGeoAvoid picks the lowest-latency forward candidate whose hops transit -// NONE of the blocked countries in cli_overrides["avoid_geo"] (comma-separated -// ISO country codes). Constraint: the chosen route never passes through a -// blocked geography. If no candidate is clean (or none are offered) it returns -// the empty spec, deferring to the router rather than forcing a violating path. -func decideGeoAvoid(ctx routingContextWire, cands []candidateWire) routeSpecWire { - blocked := splitSet(ctx.CLIOverrides["avoid_geo"]) - if len(blocked) == 0 || len(cands) == 0 { - return routeSpecWire{} // nothing to enforce - } - var best *candidateWire - for i := range cands { - if candidateTransitsBlockedGeo(cands[i], blocked) { - continue - } - if best == nil || cands[i].EstLatencyMs < best.EstLatencyMs { - c := cands[i] - best = &c - } - } - if best == nil { - return routeSpecWire{} // every candidate violates; let the router decide - } - return routeSpecWire{Chosen: best, MinHops: 2} -} - -// candidateTransitsBlockedGeo reports whether any of the candidate's per-hop -// countries is in the blocked set. -func candidateTransitsBlockedGeo(c candidateWire, blocked map[string]bool) bool { - for _, g := range c.HopsGeo { - if blocked[strings.ToLower(g)] { - return true - } - } - return false -} - -// decideTransportDiverse picks the forward candidate whose hops span the MOST -// distinct transport types (ties broken by lower latency). Constraint: the -// chosen route maximizes transport-type diversity, so no single transport -// technology failing (e.g. all-stcpr under a firewall change) takes the whole -// path down. Empty spec when no candidates are offered. -func decideTransportDiverse(ctx routingContextWire, cands []candidateWire) routeSpecWire { - var best *candidateWire - bestDiversity := -1 - for i := range cands { - d := distinctCount(cands[i].TransportKinds) - switch { - case d > bestDiversity: - c := cands[i] - best, bestDiversity = &c, d - case d == bestDiversity && best != nil && cands[i].EstLatencyMs < best.EstLatencyMs: - c := cands[i] - best = &c - } - } - if best == nil { - return routeSpecWire{} - } - // A mux of 2 over the diverse path keeps a spare leg on a different carrier. - return routeSpecWire{Chosen: best, Mux: 2, MinHops: 2} -} - -// distinctCount counts unique (case-folded) entries in a slice. -func distinctCount(xs []string) int { - seen := map[string]bool{} - for _, x := range xs { - seen[strings.ToLower(x)] = true - } - return len(seen) -} - -// decideTrustTiered prefers routes that transit ONLY trusted intermediaries -// (cli_overrides["trusted_pks"], comma-separated hop PKs). It is TIERED: a -// fully-trusted candidate wins outright (lowest latency among them); if none is -// fully trusted it falls back to the candidate with the MOST trusted hops, so -// the route is as trusted as the topology allows rather than failing closed. -// Empty spec when no trust set is configured or no candidates are offered. -func decideTrustTiered(ctx routingContextWire, cands []candidateWire) routeSpecWire { - trusted := splitSet(ctx.CLIOverrides["trusted_pks"]) - if len(trusted) == 0 || len(cands) == 0 { - return routeSpecWire{} - } - var bestFull, bestPartial *candidateWire - bestPartialScore := -1 - for i := range cands { - n := trustedHopCount(cands[i], trusted) - if len(cands[i].Hops) > 0 && n == len(cands[i].Hops) { - if bestFull == nil || cands[i].EstLatencyMs < bestFull.EstLatencyMs { - c := cands[i] - bestFull = &c - } - } - if n > bestPartialScore { - c := cands[i] - bestPartial, bestPartialScore = &c, n - } - } - if bestFull != nil { - return routeSpecWire{Chosen: bestFull, MinHops: 2} - } - if bestPartial != nil { - return routeSpecWire{Chosen: bestPartial, MinHops: 2} - } - return routeSpecWire{} -} - -// trustedHopCount counts how many of a candidate's hops are in the trusted set. -func trustedHopCount(c candidateWire, trusted map[string]bool) int { - n := 0 - for _, h := range c.Hops { - if trusted[strings.ToLower(h)] { - n++ - } - } - return n -} - -// decideTimeOfDay switches the route SHAPE by wall-clock hour (UTC), derived -// from ctx.now_unix_nano — no clock import, just arithmetic on the host-stamped -// timestamp. During the configured business-hours window -// (cli_overrides["business_hours"] = "START-END", default "9-17") it returns a -// lean single-route shape (latency-sensitive daytime traffic); outside it, -// a wide privacy mux with byte-spread and rotation (bulk/overnight). Constraint: -// the emitted shape is a deterministic function of the hour. -func decideTimeOfDay(ctx routingContextWire) routeSpecWire { - startH, endH := parseHourRange(ctx.CLIOverrides["business_hours"], 9, 17) - if inHourRange(hourOfDayUTC(ctx.NowUnixNano), startH, endH) { - return routeSpecWire{Mux: 1} // business hours: lean + low-latency - } - // off-hours: privacy-wide mux (mirrors rotating-bw's shape) - return routeSpecWire{Mux: 4, MinHops: 2, Distribution: "round-robin", RotationIntervalSeconds: 90} -} - -// hourOfDayUTC returns the UTC hour (0-23) of a unix-nanosecond timestamp using -// only integer arithmetic (avoids a "time" import in the wasm guest). -func hourOfDayUTC(unixNano int64) int { - if unixNano <= 0 { - return 0 - } - secOfDay := (unixNano / 1e9) % 86400 - return int(secOfDay / 3600) -} - -// parseHourRange parses "START-END" (24h) from an override, falling back to the -// given defaults on empty/malformed input. -func parseHourRange(s string, defStart, defEnd int) (int, int) { - a, b, ok := strings.Cut(strings.TrimSpace(s), "-") - if !ok { - return defStart, defEnd - } - start, okA := atoiHour(a) - end, okB := atoiHour(b) - if !okA || !okB { - return defStart, defEnd - } - return start, end -} - -// atoiHour parses a 0-23 hour string; returns ok=false otherwise. -func atoiHour(s string) (int, bool) { - s = strings.TrimSpace(s) - if s == "" { - return 0, false - } - n := 0 - for _, r := range s { - if r < '0' || r > '9' { - return 0, false - } - n = n*10 + int(r-'0') - } - if n < 0 || n > 23 { - return 0, false - } - return n, true -} - -// inHourRange reports whether hour is within [start, end), handling a window -// that wraps past midnight (start > end, e.g. 22-6). -func inHourRange(hour, start, end int) bool { - if start <= end { - return hour >= start && hour < end - } - return hour >= start || hour < end // wraps midnight -} - //export on_tick func onTick(inPtr, inLen uint32) uint64 { var input tickInputWire if err := json.Unmarshal(readInput(inPtr, inLen), &input); err != nil { return 0 } - // rotating-bw, latency-adaptive, elastic-mux, probe-and-prune and the - // composite adaptive preset have tick logic; app-mux and unknown - // presets are static, so they take no rotation action. - switch input.Preset { - case "rotating-bw": - return tickRotatingBW(input) - case "latency-adaptive": - return tickLatencyAdaptive(input) - case "elastic-mux": - return tickElasticMux(input) - case "probe-and-prune": - return tickProbeAndPrune(input) - case "adaptive": - return tickAdaptive(input) - default: - return 0 - } -} - -// reliableMuxKind reports whether a transport type is reliable enough to ANCHOR -// a mux active set. stcpr/sudph/squicr/stcp are direct, well-behaved for -// sustained multiplexed throughput. webrtc (SCTP-over-DTLS), ws, wt and the dmsg -// relay are NOT — webrtc especially has poor sustained throughput and drops -// under load; a mux made entirely of webrtc legs collapses. They are still -// USABLE as bonus capacity (promoted for throughput), just never the anchor. -func reliableMuxKind(kind string) bool { - switch kind { - case "stcpr", "sudph", "squicr", "stcp": - return true - } - return false -} - -// tickRotatingBW manages the mux by STANDBY, never by dropping. Legs, once -// established, are never torn down — the tick only flips each leg's standby flag -// (setLegStandby: no teardown, no setup round-trip, in-flight bytes drain). The -// policy keeps ~targetMux legs ACTIVE, anchored on reliable transport types, and -// PARKS the rest on warm standby; it promotes a webrtc/other leg only when it -// needs the aggregate throughput (not enough reliable legs to reach targetMux). -// This is why an all-reliable anchor keeps the group alive even as fragile -// bonus legs come and go, and it spreads bytes across the reliable set over time -// for the privacy property — all with no throughput dip. One flag-flip per tick -// (the ABI applies one RotationAction). Decisions are on MEASUREMENT: leg Kind + -// standby state come from the host per-leg telemetry (hook.go). -func tickRotatingBW(input tickInputWire) uint64 { - var relAct, relSb, fragAct, fragSb []int - for _, l := range input.Legs { - if !l.Alive { - continue - } - rel := reliableMuxKind(l.Kind) - switch { - case l.Standby && rel: - relSb = append(relSb, l.Index) - case l.Standby: - fragSb = append(fragSb, l.Index) - case rel: - relAct = append(relAct, l.Index) - default: - fragAct = append(fragAct, l.Index) - } - } - active := len(relAct) + len(fragAct) - alive := active + len(relSb) + len(fragSb) - if alive == 0 { - return 0 - } - lo := func(s []int) int { - m := s[0] - for _, v := range s { - if v < m { - m = v - } - } - return m - } - hi := func(s []int) int { - m := s[0] - for _, v := range s { - if v > m { - m = v - } - } - return m - } - - // NEVER emit AddLeg / DropLegs here — leg GROWTH and death-replacement are the - // route group's own self-heal job (it tops up to the mux degree on any leg - // death; aliveLegCount counts standby legs, so parking one never triggers a - // re-grow). on_tick chasing the count with adds only fought self-heal and made - // a flapping webrtc leg thrash (drop→add→drop). on_tick's ONLY job is to pick - // which established legs are ACTIVE vs parked — pure standby flips, no churn. - var a rotationActionWire - switch { - case len(relAct) >= 1 && len(fragAct) > 0: - // A reliable leg is already carrying — PARK a fragile (webrtc) active leg - // so the reliable anchor(s) carry the stream and a webrtc drop can't - // disrupt it. Fragile legs stay warm on standby (never dropped), promoted - // only if the reliable anchor is lost (below). One per tick; repeats until - // the active set is reliable-only. - a = rotationActionWire{DemoteToStandby: []int{hi(fragAct)}} - case len(fragAct) > 0 && len(relSb) > 0: - // No reliable leg active yet, but a reliable spare exists — swap it in for - // a fragile one (promote reliable, park fragile). No drop, no dip. - a = rotationActionWire{PromoteFromStandby: []int{lo(relSb)}, DemoteToStandby: []int{hi(fragAct)}} - case len(relAct) < targetMux && len(relSb) > 0: - // Below the reliable-active target — promote a reliable spare. - a = rotationActionWire{PromoteFromStandby: []int{lo(relSb)}} - case len(relAct) == 0 && len(fragAct) == 0 && len(fragSb) > 0: - // No reliable legs at all and nothing active — promote a fragile spare so - // SOME leg carries (a slow/flaky leg beats a dead group). Bonus, not anchor. - a = rotationActionWire{PromoteFromStandby: []int{lo(fragSb)}} - case len(relAct) > targetMux: - // More reliable active than target — park the newest to settle at target. - a = rotationActionWire{DemoteToStandby: []int{hi(relAct)}} - case len(fragAct) == 0 && len(relSb) > 0 && len(relAct) > 0: - // Steady state, reliable-only active with a reliable spare: gentle crawl — - // rotate the reliable active set through the spare (spread bytes for - // privacy). No dip, no drop. - a = rotationActionWire{PromoteFromStandby: []int{lo(relSb)}, DemoteToStandby: []int{lo(relAct)}} - default: - return 0 - } - out, err := json.Marshal(a) + action := engine.OnTick(input.Preset, legsToPreset(input.Legs)) + out, err := json.Marshal(actionToWire(action)) if err != nil { return 0 } return writeOutput(out) } -// latAdaptEWMA holds the exponentially-weighted moving average of each -// leg's latency_ms, keyed by the leg's STABLE transport_id (not its -// index, which shifts as the tps[] slice compacts on drop/add). This is -// the per-leg state that lets tickLatencyAdaptive act on a smoothed -// signal rather than the raw instantaneous latency — on the live mesh a -// single leg's latency_ms jumps around wildly tick-to-tick (e.g. -// 75→245→13000→180), and evicting on the raw value chases that noise. -// Keys are pruned each tick to the current alive-or-present leg set so a -// dropped transport's entry doesn't linger. -var latAdaptEWMA = map[string]float64{} - -// latAdaptSeen is a scratch set reused each tick to record which -// transport_ids appear in the current leg snapshot, so stale keys can be -// pruned from latAdaptEWMA. Package-global (rather than tick-local) only -// to avoid a per-tick allocation; it is cleared at the top of every tick. -var latAdaptSeen = map[string]bool{} - -// latAdaptAlpha is the EWMA smoothing factor. 0.3 weights the newest -// sample at 30% and the running average at 70% — enough to track a -// genuine sustained latency shift within a few ticks while damping a -// lone transient spike to a fraction of its raw magnitude. -const latAdaptAlpha = 0.3 - -// tickLatencyAdaptive is the latency-adaptive rotation logic. It differs -// from rotating-bw in what it evicts and WHEN: -// -// rotating-bw drops the OLDEST alive leg every single tick — an -// unconditional churn that keeps the set drifting for -// traffic-analysis resistance. -// -// latency-adaptive instead drops the SLOWEST alive leg, and ONLY when -// that leg is a clear outlier — its SMOOTHED latency is at least 1.5x -// the median of the alive legs' smoothed latencies. This hysteresis -// band means: once the set has converged to a cluster of comparably- -// fast legs (worst < 1.5x median), NO leg qualifies and the policy -// holds — no churn. It converges toward a low-latency disjoint set and -// then stops thrashing, the opposite of rotating-bw's every-tick -// rotation. -// -// The eviction decision runs on an EWMA of each leg's latency, keyed by -// the leg's stable transport_id, rather than on the raw per-tick -// latency_ms. Raw latency on the live mesh is noisy — one leg can read -// 75ms one tick and 13000ms the next with no real change in its quality — -// and evicting on the instantaneous value chases that noise, tearing down -// a perfectly good leg on a transient spike. Smoothing filters those -// spikes so eviction fires only on a PERSISTENT latency difference. The -// stable transport_id key is what makes a per-leg average meaningful at -// all: the leg's index shifts when the slice compacts, so index-keyed -// state would smear one leg's history onto another after any drop. -// -// alive_count == 0 → no-op (nothing measured yet) -// alive_count < target_mux → add only (recover toward target; never -// shrink below it) -// alive_count >= target_mux && -// smoothed_worst > 0 && -// smoothed_worst >= -// 1.5 * smoothed_median → drop slowest + add + exclude its hops -// (evict the outlier; exclude its -// intermediates so the replacement differs) -// otherwise (converged) → no-op (hold; do not churn) -func tickLatencyAdaptive(input tickInputWire) uint64 { - const targetMux = 4 - - // Update the per-leg EWMA and record which transport_ids are present - // this tick so stale keys can be pruned afterward. - for k := range latAdaptSeen { - delete(latAdaptSeen, k) - } - for _, l := range input.Legs { - tid := l.TransportID - if tid == "" { - continue - } - latAdaptSeen[tid] = true - // Only fold in a real measurement; latency_ms==0 means "unknown" - // and must not drag the average toward zero. - if l.Alive && l.LatencyMs > 0 { - sample := float64(l.LatencyMs) - if prev, ok := latAdaptEWMA[tid]; ok { - latAdaptEWMA[tid] = latAdaptAlpha*sample + (1-latAdaptAlpha)*prev - } else { - // Seed with the first sample so the average starts on the - // leg's own latency rather than climbing from zero. - latAdaptEWMA[tid] = sample - } - } - } - // Prune EWMA keys for transport_ids no longer in the leg set so a - // dropped leg's history can't resurface if its index is later reused. - for tid := range latAdaptEWMA { - if !latAdaptSeen[tid] { - delete(latAdaptEWMA, tid) - } - } - - // Evict-slowest-outlier logic, run on the SMOOTHED latencies over the - // ACTIVE legs only — warm-standby spares aren't carrying traffic and - // aren't judged. Membership changes are standby flips (no teardown): the - // group runs targetMux active + a warm reserve, so evicting the outlier is - // a hot-swap (promote a spare, demote the outlier) with no width dip. - sets := classifyLegs(input.Legs) - worstIdx := -1 - worstSmoothed := -1.0 - var smoothed []float64 - for _, l := range input.Legs { - if !l.Alive || l.Standby { - continue - } - sm, ok := latAdaptEWMA[l.TransportID] - if !ok { - // No smoothed value yet (leg never reported a latency) — it - // can't be judged an outlier this tick. - continue - } - smoothed = append(smoothed, sm) - if sm > worstSmoothed { - worstSmoothed = sm - worstIdx = l.Index - } - } - // Below target width: recover by promoting a warm spare (no dip), or add a - // fresh leg when none is parked. Covers activeCount==0. - if sets.activeCount < targetMux { - return growActive(sets) - } - // Above target width (the warm reserve is still active): park the oldest as - // a standby spare so a later eviction can hot-swap without a dip. - if sets.activeCount > targetMux { - return shedActive(sets, sets.oldestActive) - } - // Need at least a couple of smoothed samples to compute a meaningful - // median; otherwise hold. - if len(smoothed) < 2 || worstIdx < 0 || worstSmoothed <= 0 { - return 0 - } - - // Median of the active legs' smoothed latencies. - sort.Float64s(smoothed) - median := medianSorted(smoothed) +// --- wire <-> preset conversions --- - // Hysteresis: only evict a clear outlier (smoothed worst >= 1.5x smoothed - // median). Hot-swap it out (promote a warm spare + demote the outlier) so - // the width never dips; the demoted leg stays warm for a later re-promote. - // Once the set has converged this is false for every leg, so we hold. - if median > 0 && worstSmoothed >= 1.5*median { - return swapActive(sets, worstIdx) +func ctxToPreset(c routingContextWire) preset.Context { + return preset.Context{ + App: c.App, + PeerPK: c.PeerPK, + Port: c.Port, + NowUnixNano: c.NowUnixNano, + CLIOverrides: c.CLIOverrides, + IsDirectDial: c.IsDirectDial, + TransportKind: c.TransportKind, + ReverseCandidates: candsToPreset(c.ReverseCandidates), } - // Converged: all active legs comparably fast — hold. - return 0 } -// --- elastic-mux: AIMD scaling of the mux leg count to load --- -// -// elastic-mux treats the mux width as a control variable, not a constant. -// Its on_tick runs a classic AIMD (additive-increase / multiplicative-… -// here just single-step-decrease) controller over the group's aggregate -// received throughput: -// -// SATURATED (throughput near the observed peak) → ADD one leg. Under -// real load more disjoint paths raise the aggregate the group can pull, -// so we grow — additively, one leg per tick — up to a hard cap. -// IDLE (throughput far below peak, for two consecutive ticks) → DROP one -// leg. The width bought under load is now wasted transport state and -// fleet bandwidth, so we release it — one leg at a time, down to a floor. -// Otherwise → hold. -// -// The controller drives its decisions off an EWMA of the per-tick -// received-byte DELTA (recv_bytes is a monotonic counter; the increment -// since last tick is this interval's throughput), NOT the raw delta. -// Raw per-tick byte deltas on the live mesh are extremely spiky — a bulk -// download arrives in bursts, so one tick reads near-zero and the next -// reads a full window — and an AIMD loop driven by the raw value would -// oscillate (grow, shrink, grow) chasing that jitter. Smoothing the -// delta with an EWMA (α=0.3) gives a stable load signal the controller -// can compare against a slowly-decaying running peak without thrashing. - -// elasticPrevRecv holds each leg's last-seen recv_bytes counter, keyed by -// the STABLE transport_id (index shifts as legs drop/add), so the next -// tick can compute that leg's byte delta. Stale keys are pruned each tick. -var elasticPrevRecv = map[string]uint64{} - -// elasticSeen is a scratch set (reused, cleared each tick) recording which -// transport_ids appear this tick, so stale elasticPrevRecv keys can be -// pruned. Package-global only to avoid a per-tick allocation. -var elasticSeen = map[string]bool{} - -// elasticThroughputEWMA is the smoothed aggregate received throughput -// (sum of alive legs' per-tick recv_bytes deltas), the load signal the -// AIMD controller acts on. elasticPeak is the running high-water mark of -// that smoothed signal, decayed a little each tick so a transient burst -// long past stops holding the bar artificially high. elasticIdleCount is -// the consecutive-idle tick counter (so a single quiet tick can't shrink -// the group). elasticSeeded guards the one-time seed on the first tick -// that observes non-zero throughput. -var ( - elasticThroughputEWMA float64 - elasticPeak float64 - elasticIdleCount int - elasticSeeded bool -) - -const ( - // elasticFloor / elasticCap bound the leg count AIMD may reach. - elasticFloor = 2 - elasticCap = 6 - // elasticAlpha is the EWMA smoothing factor for the throughput signal - // (newest delta weighted 30%, running average 70%). - elasticAlpha = 0.3 - // elasticPeakDecay bleeds the running peak down ~2%/tick so the - // saturation bar tracks a sustained drop in achievable throughput - // instead of being pinned forever by one historical burst. - elasticPeakDecay = 0.98 -) - -// tickElasticMux is the elastic-mux AIMD controller. See the block comment -// above for the load model and why the signal is a smoothed byte-delta. -// -// alive_count == 0 → no-op (nothing measured) -// smoothed >= 0.80*peak && alive < cap → add one leg (grow to load) -// smoothed < 0.25*peak for >=2 ticks -// && alive > floor → drop oldest leg (release) -// otherwise → hold -func tickElasticMux(input tickInputWire) uint64 { - // Compute this interval's aggregate received byte-delta over alive - // legs, refreshing each leg's prev-counter, and record presence for - // stale-key pruning. Also find the oldest (lowest-index) alive leg to - // release when shrinking, and count alive legs. - for k := range elasticSeen { - delete(elasticSeen, k) - } - var rawTotal float64 - // active/standby partition: only ACTIVE legs carry traffic and count - // toward the AIMD width; standby spares are the warm reserve a grow - // promotes back with no dip. - sets := classifyLegs(input.Legs) - for _, l := range input.Legs { - if !l.Alive { - continue - } - tid := l.TransportID - if tid == "" { - continue - } - elasticSeen[tid] = true - // Only fold an ACTIVE leg's non-negative delta into the load signal. - // Standby spares don't send; their counter is still tracked so it - // doesn't read as a reset when the leg is later re-promoted. - if prev, ok := elasticPrevRecv[tid]; ok && l.RecvBytes >= prev && !l.Standby { - rawTotal += float64(l.RecvBytes - prev) - } - elasticPrevRecv[tid] = l.RecvBytes - } - // Prune prev-counters for transport_ids no longer present. - for tid := range elasticPrevRecv { - if !elasticSeen[tid] { - delete(elasticPrevRecv, tid) - } - } - if sets.activeCount == 0 { - // Nothing active: promote a warm spare if one is parked, else hold. - if sets.promotable >= 0 { - return growActive(sets) - } - return 0 - } - - // Fold the raw delta into the smoothed signal and maintain the peak. - // Seed both on the first tick that sees real throughput so the signal - // starts on a genuine value rather than ramping up from zero. - if !elasticSeeded { - if rawTotal > 0 { - elasticThroughputEWMA = rawTotal - elasticPeak = rawTotal - elasticSeeded = true - } - // No load signal yet — hold and keep measuring. - return 0 - } - elasticThroughputEWMA = elasticAlpha*rawTotal + (1-elasticAlpha)*elasticThroughputEWMA - elasticPeak *= elasticPeakDecay - if elasticThroughputEWMA > elasticPeak { - elasticPeak = elasticThroughputEWMA +func candsToPreset(cs []candidateWire) []preset.Candidate { + if cs == nil { + return nil } - if elasticPeak <= 0 { - return 0 - } - - smoothed := elasticThroughputEWMA - // Track consecutive idle ticks independently of whether we can act on - // them, so the "two quiet ticks" requirement is about load, not width. - if smoothed < 0.25*elasticPeak { - elasticIdleCount++ - } else { - elasticIdleCount = 0 + out := make([]preset.Candidate, len(cs)) + for i, c := range cs { + out[i] = candToPreset(c) } + return out +} - // Additive increase: saturated and below the cap → grow one leg. Promote a - // warm spare (parked by an earlier shrink) with no dip when one exists; - // otherwise add a fresh leg under genuine load. - if smoothed >= 0.80*elasticPeak && sets.activeCount < elasticCap { - return growActive(sets) +func candToPreset(c candidateWire) preset.Candidate { + return preset.Candidate{ + Hops: c.Hops, + HopsGeo: c.HopsGeo, + EstLatencyMs: c.EstLatencyMs, + TransportKinds: c.TransportKinds, } - // Decrease: sustained idle and above the floor → release one leg by PARKING - // it as a warm standby (no teardown; re-promoted instantly when load - // returns), falling back to a real drop only once the standby pool is full. - // Reset the idle counter after acting so we step down gracefully (one leg - // every two idle ticks) rather than collapsing to the floor. - if elasticIdleCount >= 2 && sets.activeCount > elasticFloor { - elasticIdleCount = 0 - return shedActive(sets, sets.oldestActive) - } - // Hold. - return 0 } -// --- probe-and-prune: continuous explore/exploit over a fresh path --- -// -// probe-and-prune keeps a fixed-width "established" set (probeTarget legs) -// but never stops looking for a better path than the ones it holds. Its -// on_tick is a small state machine: -// -// EXPLORE: when idle and exactly at target width, add ONE speculative -// leg over a fresh path (the host picks the candidate; on_tick can only -// request an add). The next tick identifies that new leg by diffing the -// current transport_ids against the pre-add "known" set, and puts it on -// probation (probeTID / probeAge). -// OBSERVE: let the probe run probeObserveTicks ticks so its EWMA latency -// (same stable-transport_id-keyed smoothing latency-adaptive uses) is a -// real measurement, not first-packet noise. -// EXPLOIT: compare the probe's smoothed latency to the WORST (highest- -// EWMA) established leg. If the probe is better, it GRADUATES — drop the -// worst established leg, and the probe takes its place. If not, the -// experiment FAILED — drop the probe itself. Either way width returns to -// target and the cycle repeats. -// -// Net effect: the established set ratchets toward lower latency one path -// at a time, spending at most one extra leg's worth of transport state -// while a probe is in flight, and never growing past target permanently. - -// probeEWMA is the per-leg smoothed latency, keyed by stable transport_id -// (same rationale as latAdaptEWMA). probeSeen is the reused prune scratch -// set; probeAliveIdx maps transport_id → current leg index each tick so a -// DropLegs decision made about a transport can be translated to the index -// the host expects. prevKnownTIDs snapshots the established set just before -// an add so the following tick can diff out the newly-added probe leg. -var ( - probeEWMA = map[string]float64{} - probeSeen = map[string]bool{} - probeAliveIdx = map[string]int{} - prevKnownTIDs = map[string]bool{} -) - -// probeTID is the transport_id of the leg currently on probation ("" = -// none). probeAge counts ticks since the probe was adopted. probePending -// is set the tick we request the add, so the next tick knows to adopt the -// newly-appeared leg as the probe. -var ( - probeTID string - probeAge int - probePending bool -) - -const ( - // probeTarget is the established-set width probe-and-prune maintains - // (matches decideProbeAndPrune's Mux). A probe transiently makes it - // target+1. - probeTarget = 3 - // probeObserveTicks is how many ticks a probe runs before it is judged, - // so its EWMA latency reflects steady behavior, not startup noise. - probeObserveTicks = 3 - // probeAlpha is the latency EWMA smoothing factor (as in latency-…). - probeAlpha = 0.3 -) - -// tickProbeAndPrune is the probe-and-prune explore/exploit controller. See -// the block comment above for the state machine. -func tickProbeAndPrune(input tickInputWire) uint64 { - // Update per-leg latency EWMA, map transport_id → index, count alive, - // and record presence for stale-key pruning. - for k := range probeSeen { - delete(probeSeen, k) - } - for k := range probeAliveIdx { - delete(probeAliveIdx, k) - } - sets := classifyLegs(input.Legs) - activeCount := 0 - for _, l := range input.Legs { - tid := l.TransportID - if tid == "" { - continue - } - probeSeen[tid] = true - if !l.Alive { - continue - } - // Fold in only real measurements; latency_ms==0 means "unknown". EWMA - // tracks any alive leg (harmless for a standby spare). - if l.LatencyMs > 0 { - sample := float64(l.LatencyMs) - if prev, ok := probeEWMA[tid]; ok { - probeEWMA[tid] = probeAlpha*sample + (1-probeAlpha)*prev - } else { - probeEWMA[tid] = sample - } - } - // Only ACTIVE legs form the "established" set the probe machine - // manages; a warm standby (a previously graduated-out incumbent, - // parked not torn down) is neither an established leg nor the probe. - if l.Standby { - continue - } - activeCount++ - probeAliveIdx[tid] = l.Index +func legsToPreset(ls []legInfoWire) []preset.LegInfo { + if ls == nil { + return nil } - // Prune EWMA keys for transport_ids no longer present. - for tid := range probeEWMA { - if !probeSeen[tid] { - delete(probeEWMA, tid) + out := make([]preset.LegInfo, len(ls)) + for i, l := range ls { + out[i] = preset.LegInfo{ + Index: l.Index, + Kind: l.Kind, + TransportID: l.TransportID, + LatencyMs: l.LatencyMs, + Alive: l.Alive, + Standby: l.Standby, + SentBytes: l.SentBytes, + RecvBytes: l.RecvBytes, + Retransmits: l.Retransmits, + Hops: l.Hops, } } + return out +} - // Adopt: if we requested an add last tick, the new leg should now be - // present — it is the alive transport_id absent from the pre-add known - // set. Put it on probation. - if probePending { - probePending = false - for tid := range probeAliveIdx { - if !prevKnownTIDs[tid] { - probeTID = tid - probeAge = 0 - break - } - } +func specToWire(s preset.Spec) routeSpecWire { + w := routeSpecWire{ + Mux: s.Mux, + ForwardMux: s.ForwardMux, + ReverseMux: s.ReverseMux, + MinHops: s.MinHops, + ForwardMinHops: s.ForwardMinHops, + ReverseMinHops: s.ReverseMinHops, + Fallback: s.Fallback, + Distribution: s.Distribution, + RotationIntervalSeconds: s.RotationIntervalSeconds, } - - // Active probe: observe, then graduate-or-discard. - if probeTID != "" { - idx, alive := probeAliveIdx[probeTID] - if !alive { - // Probe died on its own — abandon the experiment. - probeTID = "" - probeAge = 0 - return 0 - } - probeAge++ - if probeAge < probeObserveTicks { - return 0 - } - // Judge: probe's smoothed latency vs the worst established leg. - probeSm, okProbe := probeEWMA[probeTID] - worstIdx := -1 - worstSm := -1.0 - for tid, i := range probeAliveIdx { - if tid == probeTID { - continue - } - sm, ok := probeEWMA[tid] - if !ok { - continue - } - if sm > worstSm { - worstSm = sm - worstIdx = i - } - } - if !okProbe || worstIdx < 0 { - // Not enough latency signal to judge yet — keep observing. - return 0 - } - probeTID = "" - if probeSm < worstSm { - // Probe graduates: PARK the worst established leg as a warm standby - // (no teardown — it re-promotes instantly if a leg later dies) - // rather than tearing it down; the probe stays and joins the - // established set next tick. Evict-by-demote, no capacity dip. - return shedActive(sets, worstIdx) - } - // Failed experiment: drop the SPECULATIVE probe leg (it was added fresh - // this cycle, so removing it just returns the group to target — no - // working leg is lost), keeping the incumbents. - return writeAction(rotationActionWire{DropLegs: []int{idx}}) + if s.Chosen != nil { + c := candToWire(*s.Chosen) + w.Chosen = &c } - - // Explore: no probe in flight and exactly at target ACTIVE width → snapshot - // the known set and request one speculative leg over a fresh path. - if !probePending && activeCount == probeTarget { - for k := range prevKnownTIDs { - delete(prevKnownTIDs, k) - } - for tid := range probeAliveIdx { - prevKnownTIDs[tid] = true - } - probePending = true - return writeAction(rotationActionWire{AddLeg: true}) + if s.ReverseChosen != nil { + c := candToWire(*s.ReverseChosen) + w.ReverseChosen = &c } - return 0 + return w } -// medianSorted returns the median of an already-sorted float slice (mean of -// the two middle elements for an even count). Shared by the latency-adaptive -// and adaptive controllers so the outlier test uses one definition. -func medianSorted(s []float64) float64 { - n := len(s) - if n == 0 { - return 0 - } - if n%2 == 1 { - return s[n/2] +func candToWire(c preset.Candidate) candidateWire { + return candidateWire{ + Hops: c.Hops, + HopsGeo: c.HopsGeo, + EstLatencyMs: c.EstLatencyMs, + TransportKinds: c.TransportKinds, } - return (s[n/2-1] + s[n/2]) / 2 } -// --- shared no-dip controller helpers ------------------------------------- -// -// Every adaptive controller manages an ACTIVE sending set drawn from a -// slightly larger warm pool. Membership changes are expressed as warm-standby -// flips, never teardowns: a demoted leg keeps its rules alive and re-promotes -// instantly, so the active width never dips during an eviction or a load -// swing. GROWTH of the underlying leg pool stays the route group's self-heal -// job (aliveLegCount counts standby, so parking never re-grows); on_tick only -// picks which legs are active. This is the rotating-bw discipline, factored -// out so latency-adaptive / elastic-mux / probe-and-prune / adaptive share it. - -// nodipStandbyMax caps the warm-standby pool a shed/park may hold before it -// falls back to a real teardown, so parked-but-unused legs don't accumulate. -const nodipStandbyMax = 2 - -// legSets is the per-tick active/standby partition of a route group's legs. -// active = alive && !standby (carrying traffic); standby = alive && standby -// (warm spares, promoted with no setup round-trip). -type legSets struct { - activeCount int - standbyCount int - oldestActive int // lowest-index active leg (the "oldest" to shed); -1 if none - promotable int // lowest-index standby leg (spare to promote); -1 if none -} - -// classifyLegs partitions the leg snapshot into active/standby counts and the -// two indices the controllers act on (oldest active to shed, lowest standby to -// promote). -func classifyLegs(legs []legInfoWire) legSets { - s := legSets{oldestActive: -1, promotable: -1} - for _, l := range legs { - if !l.Alive { - continue - } - if l.Standby { - s.standbyCount++ - if s.promotable == -1 || l.Index < s.promotable { - s.promotable = l.Index - } - continue - } - s.activeCount++ - if s.oldestActive == -1 || l.Index < s.oldestActive { - s.oldestActive = l.Index - } +func actionToWire(a preset.RotationAction) rotationActionWire { + return rotationActionWire{ + DropLegs: a.DropLegs, + AddLeg: a.AddLeg, + ExcludeHops: a.ExcludeHops, + DemoteToStandby: a.DemoteToStandby, + PromoteFromStandby: a.PromoteFromStandby, } - return s } -// growActive raises the active width by one with no setup dip when a warm -// spare is parked (instant promote); otherwise it requests a genuinely fresh -// leg (real capacity addition under load — not a tear-and-rebuild). -func growActive(s legSets) uint64 { - if s.promotable >= 0 { - return writeAction(rotationActionWire{PromoteFromStandby: []int{s.promotable}}) - } - return writeAction(rotationActionWire{AddLeg: true}) -} +// --- thin per-preset wrappers used by main_test.go (the conditional-preset +// constraint tests). They delegate to the shared preset package so the tests +// exercise the single source of truth through the wire types. --- -// swapActive evicts leg idx and fills its slot from the warm reserve in the -// SAME tick so the active width never dips (hot-swap). When no spare is parked -// it parks idx alone and lets a subsequent grow refill from the reserve — -// still no teardown, just a transient one-leg dip until self-heal/grow catches -// up. -func swapActive(s legSets, idx int) uint64 { - if s.promotable >= 0 { - return writeAction(rotationActionWire{ - PromoteFromStandby: []int{s.promotable}, - DemoteToStandby: []int{idx}, - }) - } - return writeAction(rotationActionWire{DemoteToStandby: []int{idx}}) +func decideGeoAvoid(ctx routingContextWire, cands []candidateWire) routeSpecWire { + return specToWire(preset.Decide("geo-avoid", ctxToPreset(ctx), candsToPreset(cands))) } -// shedActive removes leg idx from the active set with no capacity dip: parks it -// as a warm standby (instant re-promote) while the pool has room, else tears it -// down. Used by the idle-shrink / prune dimensions — the parked legs become the -// warm reserve a later grow promotes back. -func shedActive(s legSets, idx int) uint64 { - if s.standbyCount < nodipStandbyMax { - return writeAction(rotationActionWire{DemoteToStandby: []int{idx}}) - } - return writeAction(rotationActionWire{DropLegs: []int{idx}}) +func decideTransportDiverse(ctx routingContextWire, cands []candidateWire) routeSpecWire { + return specToWire(preset.Decide("transport-diverse", ctxToPreset(ctx), candsToPreset(cands))) } -// --- adaptive: the COMPOSITE performance default (size+membership+explore) --- -// -// adaptive is the intended converged default. It runs the three standalone -// performance controllers — elastic-mux (SIZE: AIMD the leg count to load), -// latency-adaptive (MEMBERSHIP: evict the slowest leg toward a low-latency -// set), and probe-and-prune (EXPLORE: speculatively try a fresh path and keep -// it only if it is better) — under ONE arbitrated on_tick that performs AT -// MOST ONE structural action (an add OR a drop) per tick. -// -// Why one action per tick: each sub-controller can independently want to add -// or drop a leg on the same tick (load says "grow", latency says "evict the -// outlier", the explorer says "probe"). Letting them all act would churn the -// route group — multiple simultaneous adds/drops tear legs up and down faster -// than the host can build them and faster than the EWMA signals can settle, -// which is exactly the thrash the smoothing was added to avoid. Arbitrating -// to a single action per tick lets each dimension make steady, observable -// progress: the group takes one deliberate step, the next tick re-measures on -// the settled state, and the highest-priority need is served first. -// -// Arbitration priority each tick (first match fires, then return): -// -// 1. aliveCount == 0 → hold (nothing measured yet). -// 2. RECOVER (aliveCount < floor) → add one leg. Safety -// first: a group starved below the floor is refilled before anything -// else is considered. -// 3. MEMBERSHIP (latency): worst alive leg's EWMA latency is a >=1.5x -// outlier over the alive-leg median → drop it + add + exclude its hops. -// Correctness of the set (drop a genuinely bad path) outranks sizing. -// 4. GROW (load): smoothed throughput >= 0.80*peak and aliveCount < cap → -// add one leg. Under real saturation more disjoint paths raise the -// aggregate the group can pull. -// 5. SHRINK (idle): smoothed throughput < 0.25*peak for >=2 consecutive -// ticks and aliveCount > floor → drop the oldest leg (release wasted -// width). Reset the idle counter so we step down one leg at a time. -// 6. EXPLORE: otherwise, when the set is stable (no probe in flight, -// aliveCount == target), every exploreEvery ticks start a probe — add a -// speculative leg, observe its EWMA latency adaptObserve ticks, then keep -// it (drop the worst established leg) only if it beats that worst, else -// prune the probe. This is probe-and-prune's explore/exploit loop. -// 7. else → hold. -// -// Rules 3/4/5 are gated on "no probe in flight": while an experiment is mid- -// flight (the group is transiently target+1 wide) a membership/grow/shrink -// action would corrupt the probe's accounting and defeat the one-step-at-a- -// time discipline, so structural changes pause until the probe graduates or -// is pruned. The RECOVER rule is never gated — a starved group is refilled -// regardless. The size target starts at the decide Mux (3) and grow/shrink -// move it within [floor, cap] so EXPLORE always probes at the current settled -// width. -// -// All three dimensions share ONE set of per-transport_id state, refreshed at -// the top of EVERY tick before arbitration: a latency EWMA (adaptLatEWMA), a -// received-byte-delta throughput EWMA + decaying peak (adaptThroughputEWMA / -// adaptPeak), the consecutive-idle counter (adaptIdleCount), and the probe -// state machine (adaptProbeTID / adaptProbeAge / adaptProbePending / -// adaptPrevKnown). Keys are pruned to the present leg set each tick so a -// dropped transport's history can't linger or smear onto a reused index. - -var ( - // adaptLatEWMA is the per-leg smoothed latency, keyed by stable - // transport_id (same rationale as latAdaptEWMA/probeEWMA). - adaptLatEWMA = map[string]float64{} - // adaptPrevRecv is each leg's last-seen recv_bytes counter, keyed by - // transport_id, so the next tick can compute its throughput delta. - adaptPrevRecv = map[string]uint64{} - // adaptSeen is the reused scratch set of transport_ids present this tick - // (for stale-key pruning); adaptAliveIdx maps transport_id → current leg - // index for alive legs so a DropLegs decision can be expressed as the - // index the host expects. adaptPrevKnown snapshots the established set - // just before a probe add so the next tick can diff out the new leg. - adaptSeen = map[string]bool{} - adaptAliveIdx = map[string]int{} - adaptPrevKnown = map[string]bool{} -) - -var ( - // adaptThroughputEWMA / adaptPeak are the smoothed aggregate received - // throughput and its slowly-decaying high-water mark (the AIMD load - // signal). adaptSeeded guards the one-time seed on the first tick with - // real throughput. adaptIdleCount is the consecutive-idle tick counter. - adaptThroughputEWMA float64 - adaptPeak float64 - adaptSeeded bool - adaptIdleCount int - // adaptTick counts ticks (drives the explore cadence). adaptTarget is the - // current steady width EXPLORE probes at; it starts at the decide Mux and - // grow/shrink move it within [floor, cap]. - adaptTick int - adaptTarget = adaptDecideMux - // Probe state machine (see probe-and-prune): adaptProbeTID is the leg on - // probation ("" = none), adaptProbeAge counts ticks since adoption, - // adaptProbePending is set the tick we request the speculative add. - adaptProbeTID string - adaptProbeAge int - adaptProbePending bool -) - -const ( - // adaptDecideMux is the starting mux width decideAdaptive returns and the - // initial size target. Start LEAN at 1: the router picks the single fastest - // available path, and the on_tick controller GROWS (up to adaptCap) only - // when that leg saturates. Starting wide (mux>1) forced every dial to drag - // traffic across disjoint sibling legs, which on a fleet with unequal legs - // (a fast direct/low-hop path next to slow multi-hop ones) is a throughput - // LOSS — the mux reorder buffer stalls on the slowest leg. Lean-start + - // grow-on-load keeps the common case fast and only pays for extra legs when - // there is load to justify them. - adaptDecideMux = 1 - // adaptFloor / adaptCap bound the leg count the size dimension may reach. - // Floor 1 so an unsaturated session stays at its single fast leg; resilience - // comes from grow-on-failure + warm standbys, not from always-on wide mux. - adaptFloor = 1 - adaptCap = 6 - // adaptAlpha is the EWMA smoothing factor for both the latency and the - // throughput signals (newest sample weighted 30%). - adaptAlpha = 0.3 - // adaptPeakDecay bleeds the throughput peak down ~2%/tick so the - // saturation bar tracks a sustained drop rather than one historical burst. - adaptPeakDecay = 0.98 - // adaptExploreEvery is the explore cadence: start a probe every N ticks - // when the set is stable. - adaptExploreEvery = 6 - // adaptObserve is how many ticks a probe runs before it is judged. - adaptObserve = 3 - // adaptStandbyMax caps the warm-standby pool. SHRINK parks an idle leg as a - // warm standby (rules kept alive, not sending) instead of tearing it down, - // so a later GROW / eviction can promote it with no setup-node round-trip - // and no capacity dip. Once the pool is full SHRINK really drops the leg. - adaptStandbyMax = 2 -) - -// tickAdaptive is the composite arbitrated controller. See the block comment -// above for the load/latency/explore model and the priority ordering. -func tickAdaptive(input tickInputWire) uint64 { - adaptTick++ - - // --- Phase 1: refresh the shared per-transport_id state (every tick, - // before arbitration). One pass computes the latency EWMA, the aggregate - // received byte-delta, the alive count / oldest-alive index / alive - // index map, and records presence for stale-key pruning. - for k := range adaptSeen { - delete(adaptSeen, k) - } - for k := range adaptAliveIdx { - delete(adaptAliveIdx, k) - } - var rawTotal float64 - aliveCount := 0 - standbyCount := 0 - oldestAliveIdx := -1 - promotableIdx := -1 - for _, l := range input.Legs { - tid := l.TransportID - if tid != "" { - adaptSeen[tid] = true - } - if !l.Alive { - continue - } - // A warm standby is alive (rules kept, kept-alive) but not sending: it - // is a spare, not part of the active sending width. Record it as - // promotable and skip the active-leg accounting below (adaptAliveIdx, - // EWMA, throughput) so the size/latency/explore dimensions reason only - // over the legs actually carrying traffic. - if l.Standby { - standbyCount++ - if promotableIdx == -1 || l.Index < promotableIdx { - promotableIdx = l.Index - } - continue - } - aliveCount++ - if oldestAliveIdx == -1 || l.Index < oldestAliveIdx { - oldestAliveIdx = l.Index - } - if tid == "" { - continue - } - adaptAliveIdx[tid] = l.Index - // Latency EWMA — fold in only real measurements (0 == unknown). - if l.LatencyMs > 0 { - sample := float64(l.LatencyMs) - if prev, ok := adaptLatEWMA[tid]; ok { - adaptLatEWMA[tid] = adaptAlpha*sample + (1-adaptAlpha)*prev - } else { - adaptLatEWMA[tid] = sample - } - } - // Throughput: accumulate this interval's received byte-delta. Only a - // non-negative delta is real; a counter that went backwards means a - // reset/new leg, not throughput. - if prev, ok := adaptPrevRecv[tid]; ok && l.RecvBytes >= prev { - rawTotal += float64(l.RecvBytes - prev) - } - adaptPrevRecv[tid] = l.RecvBytes - } - // Prune state for transport_ids no longer present. - for tid := range adaptLatEWMA { - if !adaptSeen[tid] { - delete(adaptLatEWMA, tid) - } - } - for tid := range adaptPrevRecv { - if !adaptSeen[tid] { - delete(adaptPrevRecv, tid) - } - } - - // Fold the raw delta into the smoothed throughput signal and maintain the - // decaying peak. Seed on the first tick with real throughput so the signal - // starts on a genuine value rather than ramping from zero. saturated/idle - // stay false until seeded, so the size dimension holds until load appears. - saturated, idle := false, false - if !adaptSeeded { - if rawTotal > 0 { - adaptThroughputEWMA = rawTotal - adaptPeak = rawTotal - adaptSeeded = true - } - } else { - adaptThroughputEWMA = adaptAlpha*rawTotal + (1-adaptAlpha)*adaptThroughputEWMA - adaptPeak *= adaptPeakDecay - if adaptThroughputEWMA > adaptPeak { - adaptPeak = adaptThroughputEWMA - } - if adaptPeak > 0 { - saturated = adaptThroughputEWMA >= 0.80*adaptPeak - idle = adaptThroughputEWMA < 0.25*adaptPeak - } - } - // Track consecutive idle ticks independently of whether we can act on them - // (the "two quiet ticks" requirement is about load, not width). - if idle { - adaptIdleCount++ - } else { - adaptIdleCount = 0 - } - - // --- Phase 2: arbitration — at most ONE structural action, by priority. - - // 1. Nothing active — promote a warm spare if one is parked, else nothing - // to act on. (All legs standby is degenerate but recoverable without setup.) - if aliveCount == 0 { - if promotableIdx >= 0 { - return writeAction(rotationActionWire{PromoteFromStandby: []int{promotableIdx}}) - } - return 0 - } - // 2. RECOVER: starved below the floor. Promote a warm standby instantly if - // one exists (no setup, no dip); else add a fresh leg. Never gated on a probe. - if aliveCount < adaptFloor { - if promotableIdx >= 0 { - return writeAction(rotationActionWire{PromoteFromStandby: []int{promotableIdx}}) - } - return writeAction(rotationActionWire{AddLeg: true}) - } - - // A probe transiently makes the group target+1 wide; while it is in flight - // the size/membership dimensions pause so they can't corrupt its accounting. - probeInFlight := adaptProbePending || adaptProbeTID != "" - if !probeInFlight { - // 3. MEMBERSHIP: evict the slowest-EWMA leg iff it is a >=1.5x-median - // outlier. If a warm spare is parked, HOT-SWAP: promote it and demote - // the outlier in the SAME tick — the width never dips and the promoted - // leg needs no setup-node round-trip. The demoted leg stays warm, so a - // transient degradation can be undone by promoting it back later. Only - // when the pool is empty do we fall back to a cold drop+add. - if idx, hops, ok := adaptWorstOutlier(input.Legs); ok { - if promotableIdx >= 0 { - return writeAction(rotationActionWire{ - PromoteFromStandby: []int{promotableIdx}, - DemoteToStandby: []int{idx}, - }) - } - return writeAction(rotationActionWire{ - DropLegs: []int{idx}, - AddLeg: true, - ExcludeHops: hops, - }) - } - // 4. GROW: saturated and below the cap. Promote a warm spare instantly - // if one is parked (no setup, no dip); else add one leg to load. - if saturated && aliveCount < adaptCap { - adaptTarget = aliveCount + 1 - if adaptTarget > adaptCap { - adaptTarget = adaptCap - } - if promotableIdx >= 0 { - return writeAction(rotationActionWire{PromoteFromStandby: []int{promotableIdx}}) - } - return writeAction(rotationActionWire{AddLeg: true}) - } - // 5. SHRINK: sustained idle and above the floor. Park the oldest leg as - // a WARM STANDBY (rules kept alive, ready to promote) rather than tear - // it down — until the pool is full, then really drop it. Reset the idle - // counter so we step down one leg per two idle ticks rather than - // collapsing to the floor at once. - if adaptIdleCount >= 2 && aliveCount > adaptFloor { - adaptIdleCount = 0 - adaptTarget = aliveCount - 1 - if adaptTarget < adaptFloor { - adaptTarget = adaptFloor - } - if standbyCount < adaptStandbyMax { - return writeAction(rotationActionWire{DemoteToStandby: []int{oldestAliveIdx}}) - } - return writeAction(rotationActionWire{DropLegs: []int{oldestAliveIdx}}) - } - } - - // 6/7. EXPLORE (probe/exploit machine) or hold. Hand the active/standby - // partition down so a graduating probe parks (not tears down) the incumbent - // it displaces. - sets := legSets{ - activeCount: aliveCount, - standbyCount: standbyCount, - oldestActive: oldestAliveIdx, - promotable: promotableIdx, - } - return adaptExplore(sets) +func decideTrustTiered(ctx routingContextWire, cands []candidateWire) routeSpecWire { + return specToWire(preset.Decide("trust-tiered", ctxToPreset(ctx), candsToPreset(cands))) } -// adaptWorstOutlier reports the index and hops of the slowest alive leg when -// its smoothed latency is a >=1.5x-median outlier over the alive legs' -// smoothed latencies (needs >=2 smoothed samples), else ok=false. This is -// latency-adaptive's eviction test, run over the shared adaptLatEWMA. -func adaptWorstOutlier(legs []legInfoWire) (int, []string, bool) { - worstIdx := -1 - worstSm := -1.0 - var worstHops []string - var smoothed []float64 - for _, l := range legs { - if !l.Alive || l.Standby { - continue - } - sm, ok := adaptLatEWMA[l.TransportID] - if !ok { - continue - } - smoothed = append(smoothed, sm) - if sm > worstSm { - worstSm = sm - worstIdx = l.Index - worstHops = l.Hops - } - } - if len(smoothed) < 2 || worstIdx < 0 || worstSm <= 0 { - return -1, nil, false - } - sort.Float64s(smoothed) - median := medianSorted(smoothed) - if median > 0 && worstSm >= 1.5*median { - return worstIdx, worstHops, true - } - return -1, nil, false +func decideTimeOfDay(ctx routingContextWire) routeSpecWire { + return specToWire(preset.Decide("time-of-day", ctxToPreset(ctx), nil)) } -// adaptExplore is the probe-and-prune explore/exploit machine over the shared -// probe state. It adopts a pending probe, observes it adaptObserve ticks, then -// keeps it (dropping the worst established leg) only if it beats that worst — -// else prunes the probe. When no probe is in flight and the set is stable at -// target, it starts a fresh probe on the explore cadence. -func adaptExplore(sets legSets) uint64 { - aliveCount := sets.activeCount - // Adopt: a probe add requested last tick should now be present — the alive - // transport_id absent from the pre-add known set. Put it on probation. - if adaptProbePending { - adaptProbePending = false - for tid := range adaptAliveIdx { - if !adaptPrevKnown[tid] { - adaptProbeTID = tid - adaptProbeAge = 0 - break - } - } - } - - // Active probe: observe, then graduate-or-discard. - if adaptProbeTID != "" { - idx, alive := adaptAliveIdx[adaptProbeTID] - if !alive { - // Probe died on its own — abandon the experiment. - adaptProbeTID = "" - adaptProbeAge = 0 - return 0 - } - adaptProbeAge++ - if adaptProbeAge < adaptObserve { - return 0 - } - // Judge: probe's smoothed latency vs the worst established leg. - probeSm, okProbe := adaptLatEWMA[adaptProbeTID] - worstIdx := -1 - worstSm := -1.0 - for tid, i := range adaptAliveIdx { - if tid == adaptProbeTID { - continue - } - sm, ok := adaptLatEWMA[tid] - if !ok { - continue - } - if sm > worstSm { - worstSm = sm - worstIdx = i - } - } - if !okProbe || worstIdx < 0 { - // Not enough latency signal to judge yet — keep observing. - return 0 - } - adaptProbeTID = "" - if probeSm < worstSm { - // Probe graduates: PARK the worst established leg as a warm standby - // (no teardown — it re-promotes instantly if a leg later dies) - // rather than tearing it down; the probe stays and joins the - // established set next tick. Evict-by-demote, no capacity dip. - return shedActive(sets, worstIdx) - } - // Failed experiment: drop the SPECULATIVE probe leg (added fresh this - // cycle, so removing it just returns the group to target — no working - // leg lost), keeping the incumbents. - return writeAction(rotationActionWire{DropLegs: []int{idx}}) - } - - // Explore: on the cadence, when stable at the target width, snapshot the - // known set and request one speculative leg over a fresh path. - if adaptTick%adaptExploreEvery == 0 && aliveCount == adaptTarget { - for k := range adaptPrevKnown { - delete(adaptPrevKnown, k) - } - for tid := range adaptAliveIdx { - adaptPrevKnown[tid] = true - } - adaptProbePending = true - return writeAction(rotationActionWire{AddLeg: true}) +// distinctCount counts unique (case-folded) entries — used by main_test.go to +// assert transport-diverse picked the most-diverse route. +func distinctCount(xs []string) int { + seen := map[string]bool{} + for _, x := range xs { + seen[toLower(x)] = true } - // Hold. - return 0 + return len(seen) } -// writeAction marshals a rotation action and packs it for the host. -func writeAction(action rotationActionWire) uint64 { - out, err := json.Marshal(action) - if err != nil { - return 0 +// toLower is a tiny ASCII lowercaser so distinctCount stays dependency-free. +func toLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } } - return writeOutput(out) + return string(b) } // main is required by the WASI target but isn't called by the host at diff --git a/pkg/router/policy/preset/names.go b/pkg/router/policy/preset/names.go new file mode 100644 index 0000000000..45fa6a3f6d --- /dev/null +++ b/pkg/router/policy/preset/names.go @@ -0,0 +1,36 @@ +// Package preset pkg/router/policy/preset/names.go c2-net-routing +// enumerates the built-in preset names Decide / Engine.OnTick +// dispatch on, so callers (the wasm-visor's config selection, the +// CLI, tests) can validate a requested preset without importing the +// wazero-side manifest. +package preset + +// Names returns the built-in preset names, in a stable order. Kept in sync +// with the switch in Decide (preset.go) and OnTick (tick.go) — the same set +// the wazero bundle's manifest.json lists. +func Names() []string { + return []string{ + "app-mux", + "rotating-bw", + "latency-adaptive", + "elastic-mux", + "probe-and-prune", + "adaptive", + "geo-avoid", + "transport-diverse", + "trust-tiered", + "time-of-day", + } +} + +// Has reports whether name is a built-in preset. Unknown names still Decide to +// the app-mux fallback (matching the bundle), but callers that want to reject +// typos up front use this. +func Has(name string) bool { + for _, n := range Names() { + if n == name { + return true + } + } + return false +} diff --git a/pkg/router/policy/preset/preset.go b/pkg/router/policy/preset/preset.go new file mode 100644 index 0000000000..b74722664b --- /dev/null +++ b/pkg/router/policy/preset/preset.go @@ -0,0 +1,468 @@ +// Package preset pkg/router/policy/preset/preset.go c2-net-routing +// is the SINGLE SOURCE OF TRUTH for the built-in routing-policy +// presets' decide/tick logic, as pure Go with no wasm ABI, no +// wazero, and no starlark. It carries the preset input/output +// types as plain Go structs and the per-preset decide functions +// plus the name dispatch (Decide) and the stateful tick engine +// (Engine.OnTick). +// +// Two callers compile this same logic in and both must agree +// byte-for-byte on every decision: +// +// - The TinyGo wasm bundle +// (docs/examples/routing-policies/wasm/bundle/main.go) is a +// thin JSON/pointer shim over this package; the compiled +// bundle.wasm is what the NATIVE visor runs via wazero. +// - The native-Go preset evaluator +// (pkg/router/policy/preset/evaluator.go, tinygo-tagged) calls +// this package directly so the WASM visor — which cannot host +// wazero (wasm-in-wasm) — runs the identical preset logic. +// +// The types here intentionally mirror the flat wire structs in +// pkg/router/policy/wasm/abi.go field-for-field, but as clean Go +// structs with no json tags and no dependency on the policy +// package (which pulls in starlark and cannot compile under +// TinyGo). Callers convert their own types to/from these. +package preset + +import "strings" + +// Candidate mirrors policy.Candidate / CandidateWire: a concrete +// route option the policy may pick or shape. +type Candidate struct { + Hops []string + HopsGeo []string + EstLatencyMs int + TransportKinds []string +} + +// Context mirrors policy.RoutingContext / RoutingContextWire: the +// per-dial context a decide function reads. NowUnixNano is a +// unix-nanosecond timestamp (not time.Time) so the wasm guest and +// the native path share one integer-arithmetic clock. +type Context struct { + App string + PeerPK string + Port uint16 + NowUnixNano int64 + CLIOverrides map[string]string + IsDirectDial bool + TransportKind string + ReverseCandidates []Candidate +} + +// LegInfo mirrors policy.LegInfo / LegInfoWire: a per-leg +// telemetry snapshot the tick engine reasons over. +type LegInfo struct { + Index int + Kind string + TransportID string + LatencyMs int + Alive bool + Standby bool + SentBytes uint64 + RecvBytes uint64 + Retransmits uint64 + Hops []string +} + +// Spec mirrors policy.RouteSpec / RouteSpecWire: the shape a +// decide function returns. A zero Spec means "use the visor +// default." +type Spec struct { + Chosen *Candidate + ReverseChosen *Candidate + Mux int + ForwardMux int + ReverseMux int + MinHops int + ForwardMinHops int + ReverseMinHops int + Fallback string + Distribution string + RotationIntervalSeconds int +} + +// RotationAction mirrors policy.RotationAction / RotationActionWire: +// the at-most-one structural change a tick returns. The zero value +// is "no-op this tick." +type RotationAction struct { + DropLegs []int + AddLeg bool + ExcludeHops []string + DemoteToStandby []int + PromoteFromStandby []int +} + +// Decide dispatches to the named preset's decide logic. The name +// is the preset selected by config ("preset:"); unknown or +// empty falls back to app-mux so a bare bundle still does something +// sensible — identical to the wasm bundle's default case. +func Decide(name string, ctx Context, cands []Candidate) Spec { + switch name { + case "rotating-bw": + return decideRotatingBW(ctx) + case "latency-adaptive": + return decideLatencyAdaptive(ctx) + case "elastic-mux": + return decideElasticMux(ctx) + case "probe-and-prune": + return decideProbeAndPrune(ctx) + case "adaptive": + return decideAdaptive(ctx) + case "app-mux": + return decideAppMux(ctx) + case "geo-avoid": + return decideGeoAvoid(ctx, cands) + case "transport-diverse": + return decideTransportDiverse(ctx, cands) + case "trust-tiered": + return decideTrustTiered(ctx, cands) + case "time-of-day": + return decideTimeOfDay(ctx) + default: + return decideAppMux(ctx) + } +} + +// decideAppMux is the verbatim app-mux preset logic: per-app static mux +// + min_hops; latency-sensitive apps stay single-route, bandwidth apps +// get parallel legs. +func decideAppMux(ctx Context) Spec { + switch ctx.App { + case "vpn-client": + return Spec{Mux: 4, MinHops: 2} + case "skychat": + // Chat is latency-sensitive — single route, lowest mux. + return Spec{Mux: 1} + default: + // Everything else: visor defaults (empty spec). + return Spec{} + } +} + +// targetMux is the mux size the rotating-bw policy aims to maintain. +// Kept in sync with the Mux value returned from decideRotatingBW so +// on_tick can reason about "are we at target?" +const targetMux = 4 + +// decideRotatingBW is the rotating-bw (privacy) preset: targetMux active legs +// over multi-hop with byte load spread EQUALLY across them (traffic-analysis +// resistance — no single relay sees a large fraction of the flow), plus one +// extra warm-standby leg so on_tick can rotate the active set every 90s +// WITHOUT a tear-and-rebuild dip (see tickRotatingBW). +func decideRotatingBW(ctx Context) Spec { + // rotating-bw is OPT-IN per app (proxy/vpn/skynet start --routing-policy, or + // visor app arg routing-policy). Apply it to WHATEVER app/session it is + // active for — do NOT gate on the built-in binary names. A named proxy + // session dials under its SESSION name (e.g. "g8"), not "skysocks-client", + // so the old `switch ctx.App { case "skysocks-client", ... }` silently made + // the policy a NO-OP for every custom-named session (empty spec → no mux, no + // min_hops, no rotation — the reason the rotation never fired live). Only + // skip genuinely latency-sensitive apps, where a multi-hop mux is the wrong + // shape. + switch ctx.App { + case "skychat", "skychat-client": + return Spec{Mux: 1} + } + // min_hops=2 already says "no direct transport" (direct is 0 intermediates); + // the visor treats min_hops>=2 as an implicit avoid_direct so the dial flows + // to the overlay path where rotation can act. Without it the mux/distribution + // is silently dropped by the direct-dial fast path. + return Spec{ + // targetMux active + 1 warm standby (see tickRotatingBW). + Mux: targetMux + 1, + MinHops: 2, + RotationIntervalSeconds: 90, + // Equal (round-robin) byte spread across the active legs — the privacy + // property. "auto"/latency-weighting pins bytes to the fastest leg, + // defeating the spread; the policy sets this to override the muxMode + // default. + Distribution: "round-robin", + } +} + +// decideLatencyAdaptive is the latency-adaptive preset's decide logic: a +// symmetric 4-wide multi-hop mux (plus one warm reserve) for bandwidth/proxy +// apps, re-evaluated every 30s so on_tick can evict the slowest leg; +// Distribution "auto" lets the host weight bytes toward the faster legs. +// Non-target apps get the empty spec (defaults). +func decideLatencyAdaptive(ctx Context) Spec { + switch ctx.App { + case "vpn-client", "skysocks-client", "skynet-client": + return Spec{ + Mux: 5, + MinHops: 2, + RotationIntervalSeconds: 30, + Distribution: "auto", + } + } + return Spec{} +} + +// decideElasticMux is the elastic-mux preset's decide logic. It starts +// deliberately MODEST — a 2-way mux over multi-hop — and lets the on_tick +// AIMD controller grow or shrink the leg count to match observed load. +func decideElasticMux(ctx Context) Spec { + switch ctx.App { + case "vpn-client", "skysocks-client", "skynet-client": + return Spec{ + Mux: 2, + MinHops: 2, + RotationIntervalSeconds: 20, + Distribution: "auto", + } + } + return Spec{} +} + +// decideProbeAndPrune is the probe-and-prune preset's decide logic. It holds a +// steady 3-way mux over multi-hop as the "established" set that on_tick +// continuously refines. +func decideProbeAndPrune(ctx Context) Spec { + switch ctx.App { + case "vpn-client", "skysocks-client", "skynet-client": + return Spec{ + Mux: 3, + MinHops: 2, + RotationIntervalSeconds: 30, + Distribution: "auto", + } + } + return Spec{} +} + +// decideAdaptive is the COMPOSITE "adaptive" preset's decide logic — the +// intended converged default. It returns a lean seed spec that tickAdaptive +// then steers along size/latency/explore at once. No MinHops here on purpose: +// min-hops is a privacy constraint the operator owns; leaving it 0 means +// "inherit" so adaptive optimizes within the operator's chosen floor. +func decideAdaptive(ctx Context) Spec { + switch ctx.App { + case "vpn-client", "skysocks-client", "skynet-client": + return Spec{ + Mux: adaptDecideMux, + RotationIntervalSeconds: 20, + Distribution: "auto", + } + } + return Spec{} +} + +// --- conditional presets: constrain WHICH path is chosen, by route metadata --- + +// splitSet parses a comma/space-separated override value into a lowercased +// membership set. Empty input yields an empty (never-nil) set. +func splitSet(s string) map[string]bool { + out := map[string]bool{} + for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) { + if f != "" { + out[strings.ToLower(f)] = true + } + } + return out +} + +// decideGeoAvoid picks the lowest-latency forward candidate whose hops transit +// NONE of the blocked countries in cli_overrides["avoid_geo"] (comma-separated +// ISO country codes). If no candidate is clean (or none are offered) it returns +// the empty spec, deferring to the router rather than forcing a violating path. +func decideGeoAvoid(ctx Context, cands []Candidate) Spec { + blocked := splitSet(ctx.CLIOverrides["avoid_geo"]) + if len(blocked) == 0 || len(cands) == 0 { + return Spec{} // nothing to enforce + } + var best *Candidate + for i := range cands { + if candidateTransitsBlockedGeo(cands[i], blocked) { + continue + } + if best == nil || cands[i].EstLatencyMs < best.EstLatencyMs { + c := cands[i] + best = &c + } + } + if best == nil { + return Spec{} // every candidate violates; let the router decide + } + return Spec{Chosen: best, MinHops: 2} +} + +// candidateTransitsBlockedGeo reports whether any of the candidate's per-hop +// countries is in the blocked set. +func candidateTransitsBlockedGeo(c Candidate, blocked map[string]bool) bool { + for _, g := range c.HopsGeo { + if blocked[strings.ToLower(g)] { + return true + } + } + return false +} + +// decideTransportDiverse picks the forward candidate whose hops span the MOST +// distinct transport types (ties broken by lower latency). Empty spec when no +// candidates are offered. +func decideTransportDiverse(_ Context, cands []Candidate) Spec { + var best *Candidate + bestDiversity := -1 + for i := range cands { + d := distinctCount(cands[i].TransportKinds) + switch { + case d > bestDiversity: + c := cands[i] + best, bestDiversity = &c, d + case d == bestDiversity && best != nil && cands[i].EstLatencyMs < best.EstLatencyMs: + c := cands[i] + best = &c + } + } + if best == nil { + return Spec{} + } + // A mux of 2 over the diverse path keeps a spare leg on a different carrier. + return Spec{Chosen: best, Mux: 2, MinHops: 2} +} + +// distinctCount counts unique (case-folded) entries in a slice. +func distinctCount(xs []string) int { + seen := map[string]bool{} + for _, x := range xs { + seen[strings.ToLower(x)] = true + } + return len(seen) +} + +// decideTrustTiered prefers routes that transit ONLY trusted intermediaries +// (cli_overrides["trusted_pks"], comma-separated hop PKs). Tiered: a +// fully-trusted candidate wins outright (lowest latency among them); else it +// falls back to the candidate with the MOST trusted hops. +func decideTrustTiered(ctx Context, cands []Candidate) Spec { + trusted := splitSet(ctx.CLIOverrides["trusted_pks"]) + if len(trusted) == 0 || len(cands) == 0 { + return Spec{} + } + var bestFull, bestPartial *Candidate + bestPartialScore := -1 + for i := range cands { + n := trustedHopCount(cands[i], trusted) + if len(cands[i].Hops) > 0 && n == len(cands[i].Hops) { + if bestFull == nil || cands[i].EstLatencyMs < bestFull.EstLatencyMs { + c := cands[i] + bestFull = &c + } + } + if n > bestPartialScore { + c := cands[i] + bestPartial, bestPartialScore = &c, n + } + } + if bestFull != nil { + return Spec{Chosen: bestFull, MinHops: 2} + } + if bestPartial != nil { + return Spec{Chosen: bestPartial, MinHops: 2} + } + return Spec{} +} + +// trustedHopCount counts how many of a candidate's hops are in the trusted set. +func trustedHopCount(c Candidate, trusted map[string]bool) int { + n := 0 + for _, h := range c.Hops { + if trusted[strings.ToLower(h)] { + n++ + } + } + return n +} + +// decideTimeOfDay switches the route SHAPE by wall-clock hour (UTC), derived +// from ctx.NowUnixNano. During the configured business-hours window +// (cli_overrides["business_hours"] = "START-END", default "9-17") it returns a +// lean single-route shape; outside it, a wide privacy mux with byte-spread and +// rotation. +func decideTimeOfDay(ctx Context) Spec { + startH, endH := parseHourRange(ctx.CLIOverrides["business_hours"], 9, 17) + if inHourRange(hourOfDayUTC(ctx.NowUnixNano), startH, endH) { + return Spec{Mux: 1} // business hours: lean + low-latency + } + // off-hours: privacy-wide mux (mirrors rotating-bw's shape) + return Spec{Mux: 4, MinHops: 2, Distribution: "round-robin", RotationIntervalSeconds: 90} +} + +// hourOfDayUTC returns the UTC hour (0-23) of a unix-nanosecond timestamp using +// only integer arithmetic (avoids a "time" import in the wasm guest). +func hourOfDayUTC(unixNano int64) int { + if unixNano <= 0 { + return 0 + } + secOfDay := (unixNano / 1e9) % 86400 + return int(secOfDay / 3600) +} + +// parseHourRange parses "START-END" (24h) from an override, falling back to the +// given defaults on empty/malformed input. +func parseHourRange(s string, defStart, defEnd int) (int, int) { + a, b, ok := strings.Cut(strings.TrimSpace(s), "-") + if !ok { + return defStart, defEnd + } + start, okA := atoiHour(a) + end, okB := atoiHour(b) + if !okA || !okB { + return defStart, defEnd + } + return start, end +} + +// atoiHour parses a 0-23 hour string; returns ok=false otherwise. +func atoiHour(s string) (int, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, false + } + n = n*10 + int(r-'0') + } + if n < 0 || n > 23 { + return 0, false + } + return n, true +} + +// inHourRange reports whether hour is within [start, end), handling a window +// that wraps past midnight (start > end, e.g. 22-6). +func inHourRange(hour, start, end int) bool { + if start <= end { + return hour >= start && hour < end + } + return hour >= start || hour < end // wraps midnight +} + +// medianSorted returns the median of an already-sorted float slice (mean of +// the two middle elements for an even count). +func medianSorted(s []float64) float64 { + n := len(s) + if n == 0 { + return 0 + } + if n%2 == 1 { + return s[n/2] + } + return (s[n/2-1] + s[n/2]) / 2 +} + +// reliableMuxKind reports whether a transport type is reliable enough to ANCHOR +// a mux active set. stcpr/sudph/squicr/stcp are direct, well-behaved for +// sustained multiplexed throughput; webrtc/ws/wt/dmsg are not. +func reliableMuxKind(kind string) bool { + switch kind { + case "stcpr", "sudph", "squicr", "stcp": + return true + } + return false +} diff --git a/pkg/router/policy/preset/preset_test.go b/pkg/router/policy/preset/preset_test.go new file mode 100644 index 0000000000..f46e53f73b --- /dev/null +++ b/pkg/router/policy/preset/preset_test.go @@ -0,0 +1,89 @@ +package preset + +import ( + "reflect" + "testing" +) + +func TestDecide_ShapePresets(t *testing.T) { + cases := []struct { + name string + ctx Context + want Spec + }{ + {"app-mux/vpn", Context{App: "vpn-client"}, Spec{Mux: 4, MinHops: 2}}, + {"app-mux/skychat", Context{App: "skychat"}, Spec{Mux: 1}}, + {"app-mux/other", Context{App: "x"}, Spec{}}, + {"rotating-bw", Context{App: "skysocks-client"}, Spec{Mux: 5, MinHops: 2, RotationIntervalSeconds: 90, Distribution: "round-robin"}}, + {"rotating-bw/chat", Context{App: "skychat"}, Spec{Mux: 1}}, + {"latency-adaptive", Context{App: "vpn-client"}, Spec{Mux: 5, MinHops: 2, RotationIntervalSeconds: 30, Distribution: "auto"}}, + {"elastic-mux", Context{App: "skynet-client"}, Spec{Mux: 2, MinHops: 2, RotationIntervalSeconds: 20, Distribution: "auto"}}, + {"probe-and-prune", Context{App: "skynet-client"}, Spec{Mux: 3, MinHops: 2, RotationIntervalSeconds: 30, Distribution: "auto"}}, + {"adaptive", Context{App: "vpn-client"}, Spec{Mux: 1, RotationIntervalSeconds: 20, Distribution: "auto"}}, + } + for _, tc := range cases { + presetName, _, _ := splitName(tc.name) + got := Decide(presetName, tc.ctx, nil) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: Decide=%+v want %+v", tc.name, got, tc.want) + } + } +} + +// splitName maps a "preset/variant" test label to the preset name. +func splitName(label string) (string, string, bool) { + for i := 0; i < len(label); i++ { + if label[i] == '/' { + return label[:i], label[i+1:], true + } + } + return label, "", false +} + +func TestDecide_GeoAvoid(t *testing.T) { + cands := []Candidate{ + {Hops: []string{"a"}, HopsGeo: []string{"US"}, EstLatencyMs: 10}, + {Hops: []string{"b"}, HopsGeo: []string{"DE"}, EstLatencyMs: 50}, + } + got := Decide("geo-avoid", Context{CLIOverrides: map[string]string{"avoid_geo": "US"}}, cands) + if got.Chosen == nil || got.Chosen.HopsGeo[0] != "DE" { + t.Fatalf("geo-avoid should pick the clean DE route; got %+v", got.Chosen) + } + // No clean candidate → defer. + got = Decide("geo-avoid", Context{CLIOverrides: map[string]string{"avoid_geo": "US,DE"}}, cands) + if got.Chosen != nil { + t.Fatalf("geo-avoid should defer when all violate; got %+v", got.Chosen) + } +} + +func TestDecide_TimeOfDay(t *testing.T) { + const h = int64(3600) * 1_000_000_000 + if got := Decide("time-of-day", Context{NowUnixNano: 11 * h}, nil); got.Mux != 1 { + t.Errorf("business hours should be lean (mux 1); got %+v", got) + } + off := Decide("time-of-day", Context{NowUnixNano: 3 * h}, nil) + if off.Mux != 4 || off.Distribution != "round-robin" || off.RotationIntervalSeconds == 0 { + t.Errorf("off-hours should be a wide rotating mux; got %+v", off) + } +} + +func TestEngine_OnTick_UnknownIsNoop(t *testing.T) { + e := New() + if got := e.OnTick("app-mux", []LegInfo{{Index: 0, Alive: true}}); !reflect.DeepEqual(got, RotationAction{}) { + t.Errorf("app-mux has no tick logic; want no-op, got %+v", got) + } +} + +func TestEngine_OnTick_RotatingBWParksFragile(t *testing.T) { + e := New() + // A reliable active leg + a fragile (webrtc) active leg → park the fragile. + legs := []LegInfo{ + {Index: 0, TransportID: "t0", Kind: "stcpr", Alive: true}, + {Index: 1, TransportID: "t1", Kind: "webrtc", Alive: true}, + } + got := e.OnTick("rotating-bw", legs) + want := RotationAction{DemoteToStandby: []int{1}} + if !reflect.DeepEqual(got, want) { + t.Errorf("rotating-bw should park the fragile active leg; got %+v want %+v", got, want) + } +} diff --git a/pkg/router/policy/preset/tick.go b/pkg/router/policy/preset/tick.go new file mode 100644 index 0000000000..77dbdac3e1 --- /dev/null +++ b/pkg/router/policy/preset/tick.go @@ -0,0 +1,732 @@ +// Package preset pkg/router/policy/preset/tick.go c2-net-routing +// carries the stateful on_tick controllers for the adaptive +// presets. In the wasm bundle these controllers keep their state +// in package globals (one module instance per policy load); here +// the same state lives on an Engine value so the native path gets +// one independent controller per evaluator instance. A single +// package-global Engine in the bundle shim reproduces the bundle's +// original global-state behavior exactly. +package preset + +import "sort" + +// Engine holds the per-transport_id smoothing state and probe/AIMD +// bookkeeping the adaptive tick controllers accumulate across +// ticks. Construct with New; each Engine is independent, so one +// route group's controller state never smears onto another's. +// +// Engine is NOT safe for concurrent use — the router serializes +// on_tick per route group, mirroring the single-threaded wazero +// module the bundle compiles to. +type Engine struct { + // latency-adaptive + latAdaptEWMA map[string]float64 + latAdaptSeen map[string]bool + + // elastic-mux + elasticPrevRecv map[string]uint64 + elasticSeen map[string]bool + elasticThroughputEWMA float64 + elasticPeak float64 + elasticIdleCount int + elasticSeeded bool + + // probe-and-prune + probeEWMA map[string]float64 + probeSeen map[string]bool + probeAliveIdx map[string]int + prevKnownTIDs map[string]bool + probeTID string + probeAge int + probePending bool + + // adaptive (composite) + adaptLatEWMA map[string]float64 + adaptPrevRecv map[string]uint64 + adaptSeen map[string]bool + adaptAliveIdx map[string]int + adaptPrevKnown map[string]bool + adaptThroughputEWMA float64 + adaptPeak float64 + adaptSeeded bool + adaptIdleCount int + adaptTick int + adaptTarget int + adaptProbeTID string + adaptProbeAge int + adaptProbePending bool +} + +// New returns an Engine with initialized state maps and the +// adaptive size target seeded to the decide mux, matching the +// bundle's package-global initializers. +func New() *Engine { + return &Engine{ + latAdaptEWMA: map[string]float64{}, + latAdaptSeen: map[string]bool{}, + elasticPrevRecv: map[string]uint64{}, + elasticSeen: map[string]bool{}, + probeEWMA: map[string]float64{}, + probeSeen: map[string]bool{}, + probeAliveIdx: map[string]int{}, + prevKnownTIDs: map[string]bool{}, + adaptLatEWMA: map[string]float64{}, + adaptPrevRecv: map[string]uint64{}, + adaptSeen: map[string]bool{}, + adaptAliveIdx: map[string]int{}, + adaptPrevKnown: map[string]bool{}, + adaptTarget: adaptDecideMux, + } +} + +// OnTick dispatches to the named preset's tick controller. Presets +// without tick logic (app-mux, the conditional presets, unknown) +// return the zero RotationAction (no-op) — identical to the +// bundle's on_tick default case. +func (e *Engine) OnTick(name string, legs []LegInfo) RotationAction { + switch name { + case "rotating-bw": + return e.tickRotatingBW(legs) + case "latency-adaptive": + return e.tickLatencyAdaptive(legs) + case "elastic-mux": + return e.tickElasticMux(legs) + case "probe-and-prune": + return e.tickProbeAndPrune(legs) + case "adaptive": + return e.tickAdaptive(legs) + default: + return RotationAction{} + } +} + +// --- shared no-dip controller helpers --- + +// nodipStandbyMax caps the warm-standby pool a shed/park may hold before it +// falls back to a real teardown. +const nodipStandbyMax = 2 + +// legSets is the per-tick active/standby partition of a route group's legs. +type legSets struct { + activeCount int + standbyCount int + oldestActive int + promotable int +} + +// classifyLegs partitions the leg snapshot into active/standby counts and the +// two indices the controllers act on. +func classifyLegs(legs []LegInfo) legSets { + s := legSets{oldestActive: -1, promotable: -1} + for _, l := range legs { + if !l.Alive { + continue + } + if l.Standby { + s.standbyCount++ + if s.promotable == -1 || l.Index < s.promotable { + s.promotable = l.Index + } + continue + } + s.activeCount++ + if s.oldestActive == -1 || l.Index < s.oldestActive { + s.oldestActive = l.Index + } + } + return s +} + +// growActive raises the active width by one with no setup dip when a warm +// spare is parked; otherwise requests a genuinely fresh leg. +func growActive(s legSets) RotationAction { + if s.promotable >= 0 { + return RotationAction{PromoteFromStandby: []int{s.promotable}} + } + return RotationAction{AddLeg: true} +} + +// swapActive evicts leg idx and fills its slot from the warm reserve in the +// same tick (hot-swap); parks idx alone when no spare is available. +func swapActive(s legSets, idx int) RotationAction { + if s.promotable >= 0 { + return RotationAction{ + PromoteFromStandby: []int{s.promotable}, + DemoteToStandby: []int{idx}, + } + } + return RotationAction{DemoteToStandby: []int{idx}} +} + +// shedActive removes leg idx from the active set with no capacity dip while the +// pool has room, else tears it down. +func shedActive(s legSets, idx int) RotationAction { + if s.standbyCount < nodipStandbyMax { + return RotationAction{DemoteToStandby: []int{idx}} + } + return RotationAction{DropLegs: []int{idx}} +} + +// --- rotating-bw --- + +func (e *Engine) tickRotatingBW(legs []LegInfo) RotationAction { + var relAct, relSb, fragAct, fragSb []int + for _, l := range legs { + if !l.Alive { + continue + } + rel := reliableMuxKind(l.Kind) + switch { + case l.Standby && rel: + relSb = append(relSb, l.Index) + case l.Standby: + fragSb = append(fragSb, l.Index) + case rel: + relAct = append(relAct, l.Index) + default: + fragAct = append(fragAct, l.Index) + } + } + active := len(relAct) + len(fragAct) + alive := active + len(relSb) + len(fragSb) + if alive == 0 { + return RotationAction{} + } + lo := func(s []int) int { + m := s[0] + for _, v := range s { + if v < m { + m = v + } + } + return m + } + hi := func(s []int) int { + m := s[0] + for _, v := range s { + if v > m { + m = v + } + } + return m + } + + switch { + case len(relAct) >= 1 && len(fragAct) > 0: + return RotationAction{DemoteToStandby: []int{hi(fragAct)}} + case len(fragAct) > 0 && len(relSb) > 0: + return RotationAction{PromoteFromStandby: []int{lo(relSb)}, DemoteToStandby: []int{hi(fragAct)}} + case len(relAct) < targetMux && len(relSb) > 0: + return RotationAction{PromoteFromStandby: []int{lo(relSb)}} + case len(relAct) == 0 && len(fragAct) == 0 && len(fragSb) > 0: + return RotationAction{PromoteFromStandby: []int{lo(fragSb)}} + case len(relAct) > targetMux: + return RotationAction{DemoteToStandby: []int{hi(relAct)}} + case len(fragAct) == 0 && len(relSb) > 0 && len(relAct) > 0: + return RotationAction{PromoteFromStandby: []int{lo(relSb)}, DemoteToStandby: []int{lo(relAct)}} + default: + return RotationAction{} + } +} + +// --- latency-adaptive --- + +const latAdaptAlpha = 0.3 + +func (e *Engine) tickLatencyAdaptive(legs []LegInfo) RotationAction { + const targetMux = 4 + + for k := range e.latAdaptSeen { + delete(e.latAdaptSeen, k) + } + for _, l := range legs { + tid := l.TransportID + if tid == "" { + continue + } + e.latAdaptSeen[tid] = true + if l.Alive && l.LatencyMs > 0 { + sample := float64(l.LatencyMs) + if prev, ok := e.latAdaptEWMA[tid]; ok { + e.latAdaptEWMA[tid] = latAdaptAlpha*sample + (1-latAdaptAlpha)*prev + } else { + e.latAdaptEWMA[tid] = sample + } + } + } + for tid := range e.latAdaptEWMA { + if !e.latAdaptSeen[tid] { + delete(e.latAdaptEWMA, tid) + } + } + + sets := classifyLegs(legs) + worstIdx := -1 + worstSmoothed := -1.0 + var smoothed []float64 + for _, l := range legs { + if !l.Alive || l.Standby { + continue + } + sm, ok := e.latAdaptEWMA[l.TransportID] + if !ok { + continue + } + smoothed = append(smoothed, sm) + if sm > worstSmoothed { + worstSmoothed = sm + worstIdx = l.Index + } + } + if sets.activeCount < targetMux { + return growActive(sets) + } + if sets.activeCount > targetMux { + return shedActive(sets, sets.oldestActive) + } + if len(smoothed) < 2 || worstIdx < 0 || worstSmoothed <= 0 { + return RotationAction{} + } + + sort.Float64s(smoothed) + median := medianSorted(smoothed) + + if median > 0 && worstSmoothed >= 1.5*median { + return swapActive(sets, worstIdx) + } + return RotationAction{} +} + +// --- elastic-mux --- + +const ( + elasticFloor = 2 + elasticCap = 6 + elasticAlpha = 0.3 + elasticPeakDecay = 0.98 +) + +func (e *Engine) tickElasticMux(legs []LegInfo) RotationAction { + for k := range e.elasticSeen { + delete(e.elasticSeen, k) + } + var rawTotal float64 + sets := classifyLegs(legs) + for _, l := range legs { + if !l.Alive { + continue + } + tid := l.TransportID + if tid == "" { + continue + } + e.elasticSeen[tid] = true + if prev, ok := e.elasticPrevRecv[tid]; ok && l.RecvBytes >= prev && !l.Standby { + rawTotal += float64(l.RecvBytes - prev) + } + e.elasticPrevRecv[tid] = l.RecvBytes + } + for tid := range e.elasticPrevRecv { + if !e.elasticSeen[tid] { + delete(e.elasticPrevRecv, tid) + } + } + if sets.activeCount == 0 { + if sets.promotable >= 0 { + return growActive(sets) + } + return RotationAction{} + } + + if !e.elasticSeeded { + if rawTotal > 0 { + e.elasticThroughputEWMA = rawTotal + e.elasticPeak = rawTotal + e.elasticSeeded = true + } + return RotationAction{} + } + e.elasticThroughputEWMA = elasticAlpha*rawTotal + (1-elasticAlpha)*e.elasticThroughputEWMA + e.elasticPeak *= elasticPeakDecay + if e.elasticThroughputEWMA > e.elasticPeak { + e.elasticPeak = e.elasticThroughputEWMA + } + if e.elasticPeak <= 0 { + return RotationAction{} + } + + smoothed := e.elasticThroughputEWMA + if smoothed < 0.25*e.elasticPeak { + e.elasticIdleCount++ + } else { + e.elasticIdleCount = 0 + } + + if smoothed >= 0.80*e.elasticPeak && sets.activeCount < elasticCap { + return growActive(sets) + } + if e.elasticIdleCount >= 2 && sets.activeCount > elasticFloor { + e.elasticIdleCount = 0 + return shedActive(sets, sets.oldestActive) + } + return RotationAction{} +} + +// --- probe-and-prune --- + +const ( + probeTarget = 3 + probeObserveTicks = 3 + probeAlpha = 0.3 +) + +func (e *Engine) tickProbeAndPrune(legs []LegInfo) RotationAction { + for k := range e.probeSeen { + delete(e.probeSeen, k) + } + for k := range e.probeAliveIdx { + delete(e.probeAliveIdx, k) + } + sets := classifyLegs(legs) + activeCount := 0 + for _, l := range legs { + tid := l.TransportID + if tid == "" { + continue + } + e.probeSeen[tid] = true + if !l.Alive { + continue + } + if l.LatencyMs > 0 { + sample := float64(l.LatencyMs) + if prev, ok := e.probeEWMA[tid]; ok { + e.probeEWMA[tid] = probeAlpha*sample + (1-probeAlpha)*prev + } else { + e.probeEWMA[tid] = sample + } + } + if l.Standby { + continue + } + activeCount++ + e.probeAliveIdx[tid] = l.Index + } + for tid := range e.probeEWMA { + if !e.probeSeen[tid] { + delete(e.probeEWMA, tid) + } + } + + if e.probePending { + e.probePending = false + for tid := range e.probeAliveIdx { + if !e.prevKnownTIDs[tid] { + e.probeTID = tid + e.probeAge = 0 + break + } + } + } + + if e.probeTID != "" { + idx, alive := e.probeAliveIdx[e.probeTID] + if !alive { + e.probeTID = "" + e.probeAge = 0 + return RotationAction{} + } + e.probeAge++ + if e.probeAge < probeObserveTicks { + return RotationAction{} + } + probeSm, okProbe := e.probeEWMA[e.probeTID] + worstIdx := -1 + worstSm := -1.0 + for tid, i := range e.probeAliveIdx { + if tid == e.probeTID { + continue + } + sm, ok := e.probeEWMA[tid] + if !ok { + continue + } + if sm > worstSm { + worstSm = sm + worstIdx = i + } + } + if !okProbe || worstIdx < 0 { + return RotationAction{} + } + e.probeTID = "" + if probeSm < worstSm { + return shedActive(sets, worstIdx) + } + return RotationAction{DropLegs: []int{idx}} + } + + if !e.probePending && activeCount == probeTarget { + for k := range e.prevKnownTIDs { + delete(e.prevKnownTIDs, k) + } + for tid := range e.probeAliveIdx { + e.prevKnownTIDs[tid] = true + } + e.probePending = true + return RotationAction{AddLeg: true} + } + return RotationAction{} +} + +// --- adaptive (composite) --- + +const ( + adaptDecideMux = 1 + adaptFloor = 1 + adaptCap = 6 + adaptAlpha = 0.3 + adaptPeakDecay = 0.98 + adaptExploreEvery = 6 + adaptObserve = 3 + adaptStandbyMax = 2 +) + +func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { + e.adaptTick++ + + for k := range e.adaptSeen { + delete(e.adaptSeen, k) + } + for k := range e.adaptAliveIdx { + delete(e.adaptAliveIdx, k) + } + var rawTotal float64 + aliveCount := 0 + standbyCount := 0 + oldestAliveIdx := -1 + promotableIdx := -1 + for _, l := range legs { + tid := l.TransportID + if tid != "" { + e.adaptSeen[tid] = true + } + if !l.Alive { + continue + } + if l.Standby { + standbyCount++ + if promotableIdx == -1 || l.Index < promotableIdx { + promotableIdx = l.Index + } + continue + } + aliveCount++ + if oldestAliveIdx == -1 || l.Index < oldestAliveIdx { + oldestAliveIdx = l.Index + } + if tid == "" { + continue + } + e.adaptAliveIdx[tid] = l.Index + if l.LatencyMs > 0 { + sample := float64(l.LatencyMs) + if prev, ok := e.adaptLatEWMA[tid]; ok { + e.adaptLatEWMA[tid] = adaptAlpha*sample + (1-adaptAlpha)*prev + } else { + e.adaptLatEWMA[tid] = sample + } + } + if prev, ok := e.adaptPrevRecv[tid]; ok && l.RecvBytes >= prev { + rawTotal += float64(l.RecvBytes - prev) + } + e.adaptPrevRecv[tid] = l.RecvBytes + } + for tid := range e.adaptLatEWMA { + if !e.adaptSeen[tid] { + delete(e.adaptLatEWMA, tid) + } + } + for tid := range e.adaptPrevRecv { + if !e.adaptSeen[tid] { + delete(e.adaptPrevRecv, tid) + } + } + + saturated, idle := false, false + if !e.adaptSeeded { + if rawTotal > 0 { + e.adaptThroughputEWMA = rawTotal + e.adaptPeak = rawTotal + e.adaptSeeded = true + } + } else { + e.adaptThroughputEWMA = adaptAlpha*rawTotal + (1-adaptAlpha)*e.adaptThroughputEWMA + e.adaptPeak *= adaptPeakDecay + if e.adaptThroughputEWMA > e.adaptPeak { + e.adaptPeak = e.adaptThroughputEWMA + } + if e.adaptPeak > 0 { + saturated = e.adaptThroughputEWMA >= 0.80*e.adaptPeak + idle = e.adaptThroughputEWMA < 0.25*e.adaptPeak + } + } + if idle { + e.adaptIdleCount++ + } else { + e.adaptIdleCount = 0 + } + + if aliveCount == 0 { + if promotableIdx >= 0 { + return RotationAction{PromoteFromStandby: []int{promotableIdx}} + } + return RotationAction{} + } + if aliveCount < adaptFloor { + if promotableIdx >= 0 { + return RotationAction{PromoteFromStandby: []int{promotableIdx}} + } + return RotationAction{AddLeg: true} + } + + probeInFlight := e.adaptProbePending || e.adaptProbeTID != "" + if !probeInFlight { + if idx, hops, ok := e.adaptWorstOutlier(legs); ok { + if promotableIdx >= 0 { + return RotationAction{ + PromoteFromStandby: []int{promotableIdx}, + DemoteToStandby: []int{idx}, + } + } + return RotationAction{ + DropLegs: []int{idx}, + AddLeg: true, + ExcludeHops: hops, + } + } + if saturated && aliveCount < adaptCap { + e.adaptTarget = aliveCount + 1 + if e.adaptTarget > adaptCap { + e.adaptTarget = adaptCap + } + if promotableIdx >= 0 { + return RotationAction{PromoteFromStandby: []int{promotableIdx}} + } + return RotationAction{AddLeg: true} + } + if e.adaptIdleCount >= 2 && aliveCount > adaptFloor { + e.adaptIdleCount = 0 + e.adaptTarget = aliveCount - 1 + if e.adaptTarget < adaptFloor { + e.adaptTarget = adaptFloor + } + if standbyCount < adaptStandbyMax { + return RotationAction{DemoteToStandby: []int{oldestAliveIdx}} + } + return RotationAction{DropLegs: []int{oldestAliveIdx}} + } + } + + sets := legSets{ + activeCount: aliveCount, + standbyCount: standbyCount, + oldestActive: oldestAliveIdx, + promotable: promotableIdx, + } + return e.adaptExplore(sets) +} + +func (e *Engine) adaptWorstOutlier(legs []LegInfo) (int, []string, bool) { + worstIdx := -1 + worstSm := -1.0 + var worstHops []string + var smoothed []float64 + for _, l := range legs { + if !l.Alive || l.Standby { + continue + } + sm, ok := e.adaptLatEWMA[l.TransportID] + if !ok { + continue + } + smoothed = append(smoothed, sm) + if sm > worstSm { + worstSm = sm + worstIdx = l.Index + worstHops = l.Hops + } + } + if len(smoothed) < 2 || worstIdx < 0 || worstSm <= 0 { + return -1, nil, false + } + sort.Float64s(smoothed) + median := medianSorted(smoothed) + if median > 0 && worstSm >= 1.5*median { + return worstIdx, worstHops, true + } + return -1, nil, false +} + +func (e *Engine) adaptExplore(sets legSets) RotationAction { + aliveCount := sets.activeCount + if e.adaptProbePending { + e.adaptProbePending = false + for tid := range e.adaptAliveIdx { + if !e.adaptPrevKnown[tid] { + e.adaptProbeTID = tid + e.adaptProbeAge = 0 + break + } + } + } + + if e.adaptProbeTID != "" { + idx, alive := e.adaptAliveIdx[e.adaptProbeTID] + if !alive { + e.adaptProbeTID = "" + e.adaptProbeAge = 0 + return RotationAction{} + } + e.adaptProbeAge++ + if e.adaptProbeAge < adaptObserve { + return RotationAction{} + } + probeSm, okProbe := e.adaptLatEWMA[e.adaptProbeTID] + worstIdx := -1 + worstSm := -1.0 + for tid, i := range e.adaptAliveIdx { + if tid == e.adaptProbeTID { + continue + } + sm, ok := e.adaptLatEWMA[tid] + if !ok { + continue + } + if sm > worstSm { + worstSm = sm + worstIdx = i + } + } + if !okProbe || worstIdx < 0 { + return RotationAction{} + } + e.adaptProbeTID = "" + if probeSm < worstSm { + return shedActive(sets, worstIdx) + } + return RotationAction{DropLegs: []int{idx}} + } + + if e.adaptTick%adaptExploreEvery == 0 && aliveCount == e.adaptTarget { + for k := range e.adaptPrevKnown { + delete(e.adaptPrevKnown, k) + } + for tid := range e.adaptAliveIdx { + e.adaptPrevKnown[tid] = true + } + e.adaptProbePending = true + return RotationAction{AddLeg: true} + } + return RotationAction{} +} diff --git a/pkg/router/policy/presethook/presethook.go b/pkg/router/policy/presethook/presethook.go new file mode 100644 index 0000000000..f83d24f9dc --- /dev/null +++ b/pkg/router/policy/presethook/presethook.go @@ -0,0 +1,240 @@ +// Package presethook pkg/router/policy/presethook/presethook.go c2-net-routing +// adapts a built-in routing-policy preset (pkg/router/policy/preset) to the +// router.DialHook / RouteSelectingHook / RotationHook interfaces WITHOUT the +// starlark evaluator or the wazero runtime. +// +// The native visor runs presets by loading the embedded bundle.wasm and +// evaluating it in wazero (pkg/router/policy/wasm/presets, wired through +// pkg/visor/policy_loader.go + pkg/router/policy.Hook). That path pulls in +// starlark and a host wasm runtime, neither of which compiles for the browser / +// TinyGo wasm-visor — and a wasm module cannot host wazero (wasm-in-wasm) +// anyway. This package is the wasm-visor's equivalent: it calls the SAME preset +// decide/tick logic (compiled in as native Go, the single source of truth) and +// projects the result onto the same router hook interfaces the policy.Hook +// implements, so the router integration point is identical. +// +// It imports only pkg/router (for the hook interface types, already compiled +// into the wasm-visor) and pkg/router/policy/preset (stdlib-only), so it is +// TinyGo-safe. The native visor keeps using the wazero path; this is selected +// on the wasm side (see cmd/wasm-visor). +package presethook + +import ( + "context" + + "github.com/skycoin/skywire/pkg/router" + "github.com/skycoin/skywire/pkg/router/policy/preset" +) + +// Provider supplies per-hop metadata the conditional presets read during route +// selection: the intermediary's country (geo-avoid) and transport kind +// (transport-diverse). The wasm-visor may pass nil (→ NopProvider), in which +// case geo/kind are unknown and those two presets defer rather than constrain; +// trust-tiered needs only the hop PKs and works regardless. Mirrors the +// enrichment pkg/router/policy.Hook does via its policy.Provider on native. +type Provider interface { + Geo(pk string) string // ISO country code, or "??" if unknown + Kind(pk string) string // transport kind ("stcpr"/…), or "" if unknown +} + +// NopProvider returns geo "??" and empty kind for every PK — the safe default +// when the wasm-visor has no metadata source wired. +func NopProvider() Provider { return nopProvider{} } + +type nopProvider struct{} + +func (nopProvider) Geo(string) string { return "??" } +func (nopProvider) Kind(string) string { return "" } + +// Hook adapts one preset to the router hook interfaces. Construct with New. It +// carries a preset.Engine so the stateful tick controllers (rotating-bw, +// latency-adaptive, elastic-mux, probe-and-prune, adaptive) accumulate their +// per-transport_id state across ticks exactly as the wazero module does. +// +// Not safe for concurrent Decide/OnTick across route groups sharing one Hook — +// construct one Hook per policy scope, as the router does per route group. +type Hook struct { + name string + engine *preset.Engine + provider Provider +} + +// New returns a Hook bound to the named preset. An unknown name still decides +// via the preset package's app-mux fallback (matching the bundle); callers that +// want to reject typos check preset.Has first. provider may be nil. +func New(name string, provider Provider) *Hook { + if provider == nil { + provider = NopProvider() + } + return &Hook{name: name, engine: preset.New(), provider: provider} +} + +// Name returns the preset this hook runs. +func (h *Hook) Name() string { return h.name } + +// BeforeDial implements router.DialHook: it maps the preset's decide-time route +// SHAPE (mux / min_hops / distribution / rotation) onto a DialAdjustment. The +// candidate-picking presets (geo-avoid etc.) return no shape here and act in +// SelectRoute instead. +func (h *Hook) BeforeDial(_ context.Context, info router.DialInfo) (router.DialAdjustment, error) { + spec := preset.Decide(h.name, dialInfoToCtx(info), nil) + // MinHops >= 2 (any direction) implies "no direct" — the direct path is a + // single 0-intermediate hop — so surface AvoidDirect, matching policy.Hook. + avoidDirect := spec.MinHops >= 2 || spec.ForwardMinHops >= 2 || spec.ReverseMinHops >= 2 + return router.DialAdjustment{ + MuxRoutes: spec.Mux, + ForwardMuxRoutes: spec.ForwardMux, + ReverseMuxRoutes: spec.ReverseMux, + MinHops: spec.MinHops, + ForwardMinHops: spec.ForwardMinHops, + ReverseMinHops: spec.ReverseMinHops, + Fallback: spec.Fallback, + RotationIntervalSeconds: spec.RotationIntervalSeconds, + AvoidDirect: avoidDirect, + Distribution: distributionFor(spec.Distribution), + }, nil +} + +// SelectRoute implements router.RouteSelectingHook: it enriches the router's +// bare candidates with per-hop geo/kind (via the Provider), runs the preset's +// decide over them, and translates the chosen candidate back to an index by hop +// equality — the same protocol policy.Hook uses. Presets that return no Chosen +// defer (index -1) to the router's built-in pick. +func (h *Hook) SelectRoute(_ context.Context, info router.DialInfo, forward, reverse []router.CandidateInfo) (router.RouteSelection, error) { + if len(forward) == 0 { + return router.RouteSelection{Chosen: -1, ReverseChosen: -1}, nil + } + fwd := h.enrich(forward) + rev := h.enrich(reverse) + ctx := dialInfoToCtx(info) + ctx.ReverseCandidates = rev + spec := preset.Decide(h.name, ctx, fwd) + if spec.Fallback == "drop" { + return router.RouteSelection{Drop: true, Chosen: -1, ReverseChosen: -1}, nil + } + sel := router.RouteSelection{Chosen: -1, ReverseChosen: -1, Distribution: distributionFor(spec.Distribution)} + if spec.Chosen != nil { + sel.Chosen = matchCandidate(fwd, *spec.Chosen) + } + if spec.ReverseChosen != nil { + sel.ReverseChosen = matchCandidate(rev, *spec.ReverseChosen) + } + return sel, nil +} + +// OnTick implements router.RotationHook: it runs the preset's stateful tick +// controller over the current leg snapshot and returns the structural action +// (promote/demote/add/drop) for the route group to apply. +func (h *Hook) OnTick(_ router.DialInfo, legs []router.LegInfo) router.RotationAction { + action := h.engine.OnTick(h.name, legsToPreset(legs)) + return router.RotationAction{ + DropLegs: action.DropLegs, + AddLeg: action.AddLeg, + ExcludeHops: action.ExcludeHops, + DemoteToStandby: action.DemoteToStandby, + PromoteFromStandby: action.PromoteFromStandby, + } +} + +// enrich converts router CandidateInfo to preset.Candidate, filling per-hop geo +// codes and the distinct transport kinds along the path from the Provider. +func (h *Hook) enrich(cs []router.CandidateInfo) []preset.Candidate { + if len(cs) == 0 { + return nil + } + out := make([]preset.Candidate, 0, len(cs)) + for _, c := range cs { + geo := make([]string, len(c.Hops)) + var kinds []string + seen := map[string]struct{}{} + for i, pk := range c.Hops { + geo[i] = h.provider.Geo(pk) + if k := h.provider.Kind(pk); k != "" { + if _, ok := seen[k]; !ok { + seen[k] = struct{}{} + kinds = append(kinds, k) + } + } + } + out = append(out, preset.Candidate{ + Hops: append([]string(nil), c.Hops...), + HopsGeo: geo, + EstLatencyMs: c.EstLatencyMs, + TransportKinds: kinds, + }) + } + return out +} + +func dialInfoToCtx(info router.DialInfo) preset.Context { + return preset.Context{ + App: info.AppName, + PeerPK: info.PeerPK.Hex(), + Port: uint16(info.RPort), + CLIOverrides: info.CLIOverrides, + IsDirectDial: info.IsDirectDial, + TransportKind: info.TransportKind, + } +} + +func legsToPreset(legs []router.LegInfo) []preset.LegInfo { + if len(legs) == 0 { + return nil + } + out := make([]preset.LegInfo, len(legs)) + for i, l := range legs { + out[i] = preset.LegInfo{ + Index: l.Index, + Kind: l.Kind, + TransportID: l.TransportID, + LatencyMs: l.LatencyMs, + Alive: l.Alive, + Standby: l.Standby, + SentBytes: l.SentBytes, + RecvBytes: l.RecvBytes, + Retransmits: l.Retransmits, + Hops: l.Hops, + } + } + return out +} + +// matchCandidate finds want in pool by hop equality (the stable, un-fabricable +// key), returning -1 when no candidate matches so the router keeps its own pick. +func matchCandidate(pool []preset.Candidate, want preset.Candidate) int { + for i := range pool { + if hopsEqual(pool[i].Hops, want.Hops) { + return i + } + } + return -1 +} + +func hopsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// distributionFor maps the descriptor strings the presets emit ("round-robin", +// "auto", "") onto a router.DistributionConfig. The presets only ever emit +// these; any other value maps to Unset (defer to the visor-wide muxMode). This +// is the minimal parse the wasm path needs — the native path's full descriptor +// grammar (pkg/router/policy.ParseDistribution) lives in the starlark-tainted +// policy package and isn't needed here. +func distributionFor(desc string) router.DistributionConfig { + switch desc { + case "round-robin": + return router.DistributionConfig{Mode: router.DistributionRoundRobin} + case "auto": + return router.DistributionConfig{Mode: router.DistributionAuto} + default: + return router.DistributionConfig{Mode: router.DistributionUnset} + } +} diff --git a/pkg/router/policy/presethook/presethook_test.go b/pkg/router/policy/presethook/presethook_test.go new file mode 100644 index 0000000000..563e6abc28 --- /dev/null +++ b/pkg/router/policy/presethook/presethook_test.go @@ -0,0 +1,83 @@ +package presethook + +import ( + "context" + "reflect" + "testing" + + "github.com/skycoin/skywire/pkg/router" +) + +func TestBeforeDial_RotatingBWShape(t *testing.T) { + h := New("rotating-bw", nil) + adj, err := h.BeforeDial(context.Background(), router.DialInfo{AppName: "skysocks-client"}) + if err != nil { + t.Fatalf("BeforeDial: %v", err) + } + want := router.DialAdjustment{ + MuxRoutes: 5, + MinHops: 2, + RotationIntervalSeconds: 90, + AvoidDirect: true, + Distribution: router.DistributionConfig{Mode: router.DistributionRoundRobin}, + } + if !reflect.DeepEqual(adj, want) { + t.Errorf("rotating-bw BeforeDial:\n got %+v\nwant %+v", adj, want) + } +} + +func TestBeforeDial_AppMuxOtherIsNoop(t *testing.T) { + h := New("app-mux", nil) + adj, err := h.BeforeDial(context.Background(), router.DialInfo{AppName: "unknown"}) + if err != nil { + t.Fatalf("BeforeDial: %v", err) + } + if !reflect.DeepEqual(adj, router.DialAdjustment{}) { + t.Errorf("app-mux for a non-target app must be a no-op adjustment; got %+v", adj) + } +} + +// staticProvider supplies fixed geo/kind for named hops. +type staticProvider struct { + geo map[string]string + kind map[string]string +} + +func (p staticProvider) Geo(pk string) string { + if g, ok := p.geo[pk]; ok { + return g + } + return "??" +} +func (p staticProvider) Kind(pk string) string { return p.kind[pk] } + +func TestSelectRoute_GeoAvoidPicksCleanRoute(t *testing.T) { + prov := staticProvider{geo: map[string]string{"a": "US", "b": "DE"}} + h := New("geo-avoid", prov) + fwd := []router.CandidateInfo{ + {Hops: []string{"a"}, EstLatencyMs: 10}, // US — blocked + {Hops: []string{"b"}, EstLatencyMs: 50}, // DE — clean + } + sel, err := h.SelectRoute(context.Background(), + router.DialInfo{AppName: "skysocks-client", CLIOverrides: map[string]string{"avoid_geo": "US"}}, + fwd, nil) + if err != nil { + t.Fatalf("SelectRoute: %v", err) + } + if sel.Chosen != 1 { + t.Fatalf("geo-avoid should choose the clean DE candidate (index 1); got %d", sel.Chosen) + } +} + +func TestOnTick_RotatingBWParksFragile(t *testing.T) { + h := New("rotating-bw", nil) + legs := []router.LegInfo{ + {Index: 0, TransportID: "t0", Kind: "stcpr", Alive: true}, + {Index: 1, TransportID: "t1", Kind: "webrtc", Alive: true}, + } + act := h.OnTick(router.DialInfo{AppName: "skysocks-client"}, legs) + want := router.RotationAction{DemoteToStandby: []int{1}} + if !reflect.DeepEqual(act, want) { + t.Errorf("OnTick rotating-bw: got %+v want %+v", act, want) + } +} diff --git a/pkg/router/policy/wasm/presets/bundle.wasm b/pkg/router/policy/wasm/presets/bundle.wasm index c0a844f3ff6916ed9b619540f419b65cfb03e5b3..9eaed2c38fd2e545d745e3b2595e5b2e715f48a2 100755 GIT binary patch delta 70433 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 bzwXjY!_^z!5Uwt}EL^QPux@>AQu_Y@BM_2@ delta 70774 zcmce<2bdJa^Dw;K6E5uqxE$bSm*Y6{k%NRAa#FGaf`FjpjGz+skVCS93p5}|Q~{B6 z=zyZ4qN1XLoC*kt5+sSBh^Q!URrSo=p5p)i`+e{GeEJ-ntE;Q4tE#KJXP++e-T2by z_4FS%f_Xe1_M}pIyY~TBxnE=e|C@^cc=~A=1lZJm(G+q1VN>~WrUw_WL` zjL1o>SSk3h@=S){-hmGd7&7pozGFu{GX5dYQXV#HWZ&^a1`P8&TbQ6~ETwkj)+=RJ z@bVsW^;x~D$D`)Ot>O?5*3fDhJJ0GBJ)U}s$6%&0)Z7!2%o%F#I~y#}DmLA0=f)XO zEiN3gb;Dz(48@in4>p2ioRb8QkPLT{o6ib-S z2QKIatIIuJF5Z4j%L+anRipg+vwE{>`S9OfPd)C5iYmuis(Gfz=TY)_p21s2d&~`I zgJ+}ag&#Pp$Aw`c%UiuNB!(L7R;0%h1zCCy_xKb;gQkXJVwnlZ--@Eb!l~W{Eb7)p zs-&5>E(YI;{)I!ZJ7zWng)L%-vVbi1mjdjdhHfYjiQ;*Ljp0F*ZeG-VfO!-Qw|_^U z<`!zv%shWl$G?g|SH`UyOO`Cii1D|Los+=zMh%P7j8tG>Uh6qaO~w&@+fFt*g2(@ zg4A7>-aQP7ebrwEgV=JLFvu^T7(7_+0~t*YZp->qM!&^iK=w)q3VDUP?zHKESYh*= zmTpD_Vj8SiAM1Zou_2om?Eg`jp!bcGVB<=?G4t6J|IU+pZg!6J>Z!PdS#3Y{bl2+jaOi z|B@c(@u`?^vR2ViX@=_KHuoRDpqoE=8hVi3>@3*>AL@^iJ)F1@++ICC3wyvESTG9q z!djUOIx5jm@ODEQRc6n*8ZqyZiXVy?*w*O zZW$OTv^+~K6&Sxy@oFV8R$Yw0DXywfCp=m+=LJ8^O%Hb{61<#SDe0fIgf7knt5;7K z8Qt`VV5b^dAQU(C4!B9us*<$czmxXc|BbZJA=NP?qKZuom(a;TY3&Z(1~1hp!(r|t zYi4HOk;a$*PUB$5n#sk(A6b){&##$kH~$;;ex~LjNCgkn458kSvUyCcGIn##o#9i( z#FsP7qg5b&=UO4XkFJ%;RsmvD)XV+B^C!{|T3Y-pITwJa2C>7Fg$RKhX75vXj z1qPAcI7ALKX`F@)`q#+`KGAsk|JrtVlaRJgH*wn@ZqkZx#_=WwbDNH42MX6Ty~N7f z!w$1o6li~v4`H~fqH*A1xT?0BSu|+Oi85mEsF#q~I;tyNRS{{1?o;hm1prDlOKgX7 zC(Hz$2bK`RJ%K{(Lz02fs6lM8jHTGeos^X9g9q2NNJ7E9yG1&Q#xN6p!IIcg;&u_D*_8j#~Ry$~ONmDXZUsWrNFGkh1zONcww=5R#T@`Tvll`7J|8 zx~e6TwD9$oGZp0h7HwyeiXjfQISE+dMB7|gH!AjhU;IDVNq~YhpB;EfIeWq!XCvUJDg{SMfNCG z+u;fu>jFExWn;I(4vXy0SJ>e!8;M1B=LhW0XJOb9Hh~j5#1>AvyCrKV1&A*aHc;|^ zxyK5NW=13l6_o@^Bnp%;T%wV3v0d>5M|CpD^uz2ZBj$gzsl(Cc2ywDX24z8*En~0-7$V_O!DSs|iw(AI9cc}G*0BuP)R8DwY@A7~NPCZj zm0t%Hfi{hU{GG&AutujGVpbZxjTSKLuufUPtP47YaQW6wY2|UBaPwcrq)XDlKH*BI z6xt_X4vhgQcPFDcSgCV5E|}Jxn-LD-I0FPG97X!GohuASxluA3cHzr}VgZ(-Z99Cn@+ykN!|pc%(-ScT-8b z%!FP$7`EAe|9%I)ikr=6d!NAikN4@tV&xvupIDdgVpw+ifv(u3e%~or#p>IbWAaA- zW)1{$J2ZdrF3iqp}%HJ<4E1aqmuD?0mR&DA1rj=#rBrn70P4F9}mfc=dy;hcx&Xm{*3B zfLWL~^kElKqRjST+Yrit;ZI|5eRvzE%V-=%>k+SG_N^z%2dfo?2WyO+49UW+BcF7E zP_?|#K|lHyi0Ksr1oZ;N@PY>mTqdv+iER~4&WT?}KbsX&EwV0OWLJwFL)AciQ^i@2 z=LXM=8HHVUAA45`jGi649JBLACk8u=n~Ko`xMTc)zvZp`Te@M8PbgVu z^n`!b=`yiooxKzPS!eB|(@Mau_SmzC*2j;nz+lMZ>62xHtlyVsSFopQw$*JfD z32vFX41=}-N{tCn%9~#vGi&9q!k{QW2ZO)zjS_fQoJOS|O`DeF;06An2**Hz7X6e$WtRmk3jjaDVN zgeI|a|LWr!5{?C@T}%|6ChL0ezBQ?lFqJ4BzyT@pwy-9_yq;mfx7N&a_>h|3yB4QD zcwy~G6(NYYsXV!G?7Hf12SlsQ>-S=BC& zmkUZDJ#Nbu7u%#@zi)MohAOrWV)cVlw;p#YhatB-wCzd6{fBJ>iaA9kHXs5F*dCC@ zxxs`TweDD@*N*X6<=~D2qOvLumev!4BMYn9)3T7#3L(_d#kB=IAsD^0FHmR1 z&Pnj^g`HD0uo8WWsPVm?7QDR58(jFxfJ}S+BQ`LFa6*7n#&G7s!GXOf82@Td?0)2{ z(`0{Q)qW*aYYAlq!O~U@ENf}~?2MTPbzyf#u_3$w84UN6rDB7oB3upYtUnFpasIkUif6%n#Ig-meAbF-ju(mSmbr$@;% z@6|_?^!G6+%zJ-^%Tl!b4}Q=A^ULnPmmx119#~~*!M!UJf(t*q&jFNx-0|~=1gPpq zwTQTvgWgK1!KMdN3YUD;g!VRHevA|@j5tuom1U)HAZXDLfqQ-t7%S}%#=%CkrsIJ z2nL0|qp!LHrRDYAu?~245X?RK90mtZl4aNMo70%eJe9)=f^AMU#l-AWrai*XPECx! zwH^sXi30;K8Hx)Be|xVBnOJD~g^t0L)6W1~Ou0xRuG9JS;NFW~6mFkhG!W#~i#>u9 z&dhZ1rDox0mtfKAvjeaqwlU68n`-A;kZ{LEy*e7!&r|14J1t4ZO#c2Q>MSiLJcI`b zNCnwxW^n2GScJauyvu(!uHXI7c+O8LvB=2n#K83r17Cb8ij|S2{uJ9v1=^Uqr2m4O z`+{#=9?cJK4PN^xGkDJrpmeuDG&T6<53TrRjQX|X#B^1!Y(eBsTv;cBa>3tzE*-Rf zq$%-T?dOb^jIMcCcVVqo*T|gs=Guc!DJjYhKdoc4f>(bcvm)i^SFq}5Kkva{{&g~V zD*f_si5AcPvX0#hHoeg^5_i?nc*1lrxb}w2T#3k{PygBjL0tHi5MB94TCniuGnmu* zHxdJw6aPEedBNZBap2Q}yZrlBthn+I!nf|N<9y!c#hqDVu;QP4oQg5n$JIY8W5qIm z(UH};zt&)C_U-rC<;9&?cXk~9OUgP+%1~X4b}D-h!%`Z1pWoVqsEI2Y`=CVSy*g{eio~BfGo5_oAJI09 z6=LJR!l3bG+4$>l_Pt|x!0sgu>m7_>D2hepNQR>mMlAD**P~f!Q6Y+<1t~U0vC7cH z7g4Oa(*sIKQO?Vd=HjqdqUp5Uh?x(^Fk}V;*MYD7^2+1>*cf~CTx=< znefjXRteiztjKVSV0~0EWCE$_kCoVBrzuJv@pNVO09O66GCS|~g6F>CyQ-`amKfET z7p*I+8d!GR;cL*!xlqe$U4w0M+uI~%bEB^upq z1SO@>N+QpY>*27bHDNEgKyX&9dU@;u!S^>~qunChK8uyj8A?I%YjgGjD-h4MU^O5( z*n(w2u63gYD^0_RY{iaZ#@SZvAr}BDD$%_S3nGAvZP*D1KbrZ^+p>GHNu_qIy}OYD z8XJhw^!7|;S^>Cu3aoh@*jO}?UhT>byRd2SC%dr#;obLMwu*sMXGsqx{akby2loiT zng<_~XxIbDK2J>R!Tx?GV;>W!C65us-X83AfG4K(WVJe@cgllKD+1_|Zo^3MOp)IW zGr?E#Z$}Yn&PfC-c0mc&i#?3T5O^9&XNW%FvpngrWN_~Td!!-Ytri*r_`~QZ0c*GR zgaVScti-0H({V`8N}01M4&jbaCOPjG!Fq&0`uUHh^H)cR~cW!LLV=6&LY&36}-KgvcmJWnyID+LkjZi8f_5RUl+6adb#0M&z0U;rxLfotiMD!^)EDtO{>*-PKE+q{hB6HbJ06b@>oswB}L7W-O?x_n)3we4h&R3CSM7Y{@Qy*r& z?HTC<4n*o3Y?zq#6lKbO~U(D z#9m&5}=mXETk(Ky|B=>ss#XTsdW8i}%%Sd94aQTB4WO-8w-NaAq1 z>|>s~C>B1(uHfRE_BhBhhkeLb2LkAz$KDGA=uSA%=O(fGeCK6hOlEC)!6h+lGRuyd zM#sI5K=rdjSZpcp2l{P&tlsW+}>>CEXg|f1gn4o zT>Lhj`N%B~AZ>xmP=Z$}V4XpNPc2}@2Ww(Y0mG9uv8#an;;duR`MYMYs!b(Z!%;9q zfIM;Ii8+w7kxk+H3Cse60*C8|$#Ud%hPlxQ61$eh%gotv%#gq8#u@ zN6bVzdJQgDp?@~pk%8+7SEm7m5M;?91KJGN;Y2( zL;?PjBQbA@sdHIPoS%CZvdZGbT-MtmDcPbe<}utfi`!4J+8CzIXG6%kKt{&);_NZZDbeTYg(dsk{R|rtkMmIy{mls2hIL<%U|BPA;TCMhYYgJ;MXU+CwPZ1S!ReDsy6uZuXGBI_!rtL4UZScemay$kC1TZOf}t5C zt_jwe9CP8qyMm>NzDwBwUbKlCHCx7dIjbEZ>joX4Nz2ZVTXXUHGB$;;kPSL6|J%kd z`B^p^dzt+#TmyzMVg(qgvo;d+cUQ1a9UC{!-n*?@$)0i0B*VSYa}0ed;ww8GyNbQU z7rjUTYOiJ;`GFTGzI-)n#%75Vt64f!w|-yEcDq`c_~^~GU?CQWtaYp(E3js)1F%@p zc!70wE21%OO?{C)#PM#b5g|oof%tJFD+i6@Hvzd3nXWHMGxV{S*wd5|`7(RGG8_%p z<+u*u>=`dt((*`f(zrFX1pq*}`ZCLihI+8B;11@?U`>eBEe!p}R{brkP6>>}qg&ZS zp+vFHZ)HzWzmIOGeYLe=dx(8bTZ-E|*n|>o@(bB4iR&w%GN2#x6;{dZhxlf~tHu2w z&8%~;+Wkz}#j28{nht|Cv28nS+#5W=;)4rb?kuu*vsawHh_}w|W^Z8^FTMsG?3B>< z>E!F65}>5w9@sWHC1{pghu>sTh@W-pEmq!bgw~N2_cpstlT~UTOK@9JQSSR76&yIY zClaIfGxP(Cv-{aZ4elg>fa(7syAL71_925~5_=kG$6EDc_8V5tIl$g>3UCD0PY2i} z1>1}|$Wq-lxG}eahuC-xiz2@IJL?QOV(-Vc9|dmQBd#B1W7!64&@q-5=YS`|?zqUt z(TFPiz|wW+_%*&RUrIa0&Ofu7RX3c1b7wdaGt@VH3Fb~pp}326uL+r~zt3)9j#Ie9)yI=mFEq=|L;6&E?gUyQCVQCMGJqR_AbCx1bH1AYoYJlK$)#B%x}(1 zg@c(m8361NAGN>|>!%jHSdP*zt0vt4L+nqr;{Bm%v(~&4)Em*77vGqmdK=qF-W%AK zFDU~e1ay-Tjwd}{6D~=?4V+@1Mshp8!yPzzT8i59A#M&X9nt1)z6QI!bT_BPChj3h zhz1?`Q<(o@M?M!Rb8jd9zT1)zsnCU!@zJjfr*-^W7k&|Ip6$vTyERF!R_)G%=pUz( zL~nR8hNN<+%83$HLIzP>W&XXPQ1m-NX?$O3758$yoJg?lVLU&SsG{Bp%E}(0P}~cL zp-}Gb$e=>d((RJNe+jR)C}vxU&9LT_TlIS7vmq0Vfns% z1zC)^Co0CqI^36+*BsdkEVzIeH|!xk8<=C^Lm=-qSn9+4C}SJM$w52|a+SgSaR{ak z=2O{D@#kQ^j_tJ841r8YW(?!yk*$XDS0VUq7zsFQ;BX#bzL1f@&c@!PXmomLa$lIa z$5Kb~cC>0cjpEDyw#QPTfxP0aqTLv7?hz4VcrWN>;27Q>f^B2?K?tUeCC=YCmTLrR z+c+4DV`9MwX@IRC;RWC#J@5#B7lH*7_-$5XEttsbF-Y!z6d)9dtB>+qNc=00@w;6B zu)qjF+e!Qp5d32ABoY$VPm_4K%Z(%hwJ9JbVFv0<;lr_Jo7p@wo@oZl;lK_)FsAV3 z2vqoD3a^7Y#5)z1qBEJKq0E4kEcpRm(=DOy-^k~9RA&8^4>RPn!Og#z^aP&{B}=Ar zbP|f}>AXHx>^EIfR%|ZdkJ6bM9_MQ!?*%m>w)iS2-pe>3I?dpJIY7x>*z-v~0HM73 zB#&`Pfx9T|@z_k>j=Gw~Nq>%-#d~A=lUe*Rd*CETABQXT4pgF0pE-OIRya0C($G7X z#}Sv8oyXggJNAGzavslcdjwlF7kado&ga$8FcJF~@UjTu`~pynK*CZB`Rk;hKYa#R z)tPjVT}ZSgizGfUWtn36B3=q1yu3(is?5dwQT1k4|F+IT65QjCmW z#{Xa)$-X_sU->>2MSYgOoJ`BSz*GkKx+&X@!M8(_d_&BUEem!Y;>z(yL z<|x8{10HA>i4)KB0WxE+%xM1t&m&||=@eOT=j96|s~EO{mk&8A&Zdh4V(SJT4INl- zZs6~@L@#4=$5Wf60BZUYAB&X=U*b7rJ)C%%Pr!`sTX+&>jM&D>Xg$4+-^79w+ijMu zSO|AYA!Aq}A5HvmVkhqf83JmgT3ue@BPsWtS4p#3qb+_j)0ulP8+~+L&~iTcHQ6*)?s2XX#&u8dj)=_66H>JqCpn(L zia95Fx-*5ek=b@qw&dUN%GlDXeu@`x9M3D?5$B7Ob{KY=_xyX)M3H}*H$cFzpC;=_ zOgST$>icIX--2_nvo813rr_E+X@52Up3~OzI( z?J7_UN+PfEhR7#YtDpHw7g+*&%kJ{4h)@CJk9nV7OC{an!O_xvYT&2DsN)?)hJ~?hAq9y zpLRG3v+l05OVw$OR5qo2llg_}%ORzCk8BFXLUrKq#B@bDE+e#~YND_}s zP<}*NZS_r22Do)-1Ncd*(u>wX#dM_{c79K~LL%*8x-uJ^+@GNgWwhl+M-ki&I~1nW z6vi8RuBcH~aqkh)K&xjeWVOGWsq8^m(+oMVIG-FCgagGmzk=Ls#gq%76JD;dUd>WQ zmcUL_&sNY@7l*Qy{?4f5k@$@(C=0OQvkDUH>lI|hB{_6DKRmULUKZ zG*F$x0WUfa6$2dE9zW={9E5zJnt~fDF{HW@bh?yQU9LjAqUE_tPq0j`=PG@ia*_{M zYAMNLTn&X3R=b+A+iz+rFGA&+wG`Kx=u}1nS{$pbJdGjL^ND$N6x!7MP)E6rnWyU# z`K+<^l($^EQ1eC&ly|^by4^s@Lu09bV_01ikGZK=3d!ir0ID`g3kTFYB0YZbt}aXX~}VE*F7_KG_)GM%32pge+L ze(RtN!tlYnm6aH>d*E3#gyrs$)3f&;})J(b505KxSmETfl#7Z^_W0%{alW$st5 z08uP&AD9{kDOy-%1^|WE^i_6ZW56&`+#aBWi}C%G8jxeX*iRYuj|Lxy)@^{*8V-a> zfbI@GtQ>-F)(ui-xoFbXxb6^T6!OH2LzJ(vb-_?&A2w_^OpzP;KEsu(BvCq!RI=Q* znMly7qvVj%M=S2tWv3uj^c^jw%D1DHLC(m?!h?}zim_u9a?1WRM)?H$dwZ2r;&a+@1HU}i9XXPtzJ7b~7SMwza=1)`Zb_xg`C`cB! zrYQyF-!3HS?u8x@pnezf3k&HK-g%`eVSTqBiw zAN8apM*2*p@;`<{(&xKb$_}STQs&TOs@OGKc@pu-o1^48rNoVM=P0jWL9@9^Jz%HD z=PDhXa-zebxyn^6e|Me~f<5Ld6ES^gzET9C_2N^YvYZwqBhnTqUDOpqZ!NCft;bu_ z7btg=1p0iT(%-fC;Nu8nMw&rb9sq7vHy0^%r)$PysfALPD3hID$i23D ziIgQxg|ZO=Toj6hA>=2E*On^NG0}9HGSg{Aqdl}tA)m+44?d^Nafe9={JIME?NFotYAHuz|q*K;Eq%D-)OhrNb4U9@5TI?o1~KSzohI?NN0nFgZl%dv#q$T zN_Q6@8rtY>ihCu65bUvC0{(fsQUzw#*r9ZEXhG$V?@-QRM-LW~F0_6rR7TLgVc@Go zQY+R{ZmDR~qrt732QE`2%q)@-%{(Y0ByMW#QI=<+fe5FlP*a|-LIv6nU=w*N3uO3l zkJ8dboy^9jZz@9rMbE?iD?CKT7kLz+8(ElxZ}un$AMwdL@Su(&_{@(Y`0kG)_z;jH zmz@Z{6{H+|JV+6IO-K>&!^z0;UQP^OAj;Z!nG(B)3$rpp*DEmxUolb}%2FC1yxp}AN8vzUrBWO%yv9MOI9@x7BetVBW;=>+8SN;J+m7PM z?I@1jj^gO;D30GBkbNKt>}({09Yr$OQG9r5N0AbC6rX3>QKW_)MS9p#q=+3wn%GgK ziXBZx!q{;njU7EGs=uv#9KZ*VL<4*gNfCS^NfCTANs$#W42s~ZN|^%)QUu>!QUtDz z+eJgU-J$d(kb7d+Gf*py>0c-cGUAUYJW7d%kJ$-yB|k-gyM zAZ4q7Q-*Y)EF=s?>;*4LM>+O_*GzlCOKMY*z2N1v$mU!1*8WI**=fs8ynqdY5FUBb z^@IeY6kG=pbw87;?W50>b?mYjc~H3r{Jn1+R2Ij=R7AmpFvp{5^*^K(kXriwbERn| z1m=2O6|aA(p!?Cv{z_@>GBBJn((0IWL~+kNNiVcK zs&vBI;;6F1?SQQICSMmDJ+u*e|7+y|>>&HN@;RGl{dQc5bX$rhL&lPX`sKU43E7io_bIL5lx!w25Vhka3cLZ>xJbSX%omU1iXNO9AZkW0p z?!_G{5jE?g@;tWt_#z1ltIj25n`<>7>ekgOuvif3&R3Nhk)bGB^M6-rk}KftKb52; z$3thIds%1W)y+ycxFz9JN4_Yr@E0{vY)MwLVqpn|zBaVJO;#Up$BtN8?NZe-)aM&% z>TGw{*w{Wvwz_Ai&*0wUd|4H@PAQq{y}-buGFA7T1A27Cu}l?hC-H%y-s|8^XO=lW zH4jHL6dq^dmUp6GtqQ^OeziNm`NRKDIJ3o%<FrWpt%Zd% z%d3wRKjY%`(hIrJip_@aRX8{hUslLb>5y_nj`|&LlR8&a?Pq*qN=0=cxzsADk-UW5k7BCSX)=`_nL`2k8KZMkw z>S}p$v##0bgHqYp-Q{? zZyKttBIuX|UrW*BPpkP|>T&eOTD~SKIYl09sy^nBl`uZnR7DXW8sw>~odVkOpUG44 z3V_woR6j!AN^7pdc3gC8uC8?Ik$Ah+TqU1DUJI2DSDtI3qCDK)LTwN)UHQ@Ie6Gt2 zR3xJXU(G9r@G7=a$2oxTcv-yIO1*%XOlzkmh!=KWV4V1@JxFtL_^Q+81wbuWrTh^yvVL!)Zw+Z``fkz?QGvqmmuJvZG2H z{+Leccyb)?x`mG8x5e8Jsfp3=ctYIpVrwULFk2*2I;-s~fRDK~iij}h-X48q0&RXe zKF0`g@|?~pZFsJCR;Nl5tqzkUnkPv#ql;S2MUf`s&90KIqq?a*9VPk#WLse;`^KCDgQSE-U`01(xqKtU2w^|+B-RKP%z+Cp~ zqgJ$ed{!S7AI6DokcHvbebgQf4nE|HAN#6p#mooP{&)k{@Zqq%MzziLlsE`ssi0 zw31KMtF4wIy}9gRwXvuLNS(zF ziro*XIW}d^J)~yamTsws)fP^_aKhsvSF+!Q#R8k&a#M#^<3VbcJ2H5i_b-hbi|+=j z4c&L%arn+Vj>JBqZPgm0&Tz#(P3*hF)K76vgTvLyHkt_|T#TEJP~Uc@k$668q)Kjx z&qo&PBwQ6%<54Oci~^wFN2{luCK)*MP*To{8mlfP1^WCrwTJ}4q({_sK&i|L>V446 zdUS#sPdqsPQMD{)y!@z|O)b8743r{5hMatBz$7)-T~nmEo|{Z;Z=IeDr#P@`&Q4L? zH&8UhH>bk=0%)HdQ2Q3sR=$2Gn`3D}?H6(LBFHv6Zf5y_60&!`TEY3?WAN=t3Bglc zRk1o>J%YdSf9;T54zk5dpCK(Zp+hZCzz1iPpf4?$(LQA z{_4&gO^or3R5=5mQQcd!WDglZyCA4;a|lGsE_0DA7_dm4fY^V%NQ&T=i`BN6Ua(kw zszif|OUSYk!47UlR%|utd;L~mRAQ`-MN?ZYU1oYT_rTV3V zCVDZgy3c{(hyxh3s<=lgIl4-%NhQ&1oF0#WcEKKtP|MI`@q;S%m~n?5hpbVHci|4v z6UF(pz^u?+(mHh@gx2JBYN0zfMCpdlt7ovpctM3*?zU}28#qI;FV=u79F6vy7sY!o zs`H)k5P$aBpuT|cwT+EdU%W4ANXkh7PS}JndP>t10XqbyE+Azy;cY#EfNnE0%riXo+woRg5>d? zR4MNjbt_4oYp+TsPP0_uU`&1mxEe(EQ|vC4j;TiPQd{8&cI{Fh1~{3!5ssL?8%znC z7(`9}no6ge3tv-bI0Yn?e6Oo{q~T%bS^S<0zWNK7=iqCwkG~G86vCHYSMLV6zr7B# zTqLUOQL`gq*KX)>re+q~)>-|tk8cF(v1laHItF7=VyZqK~3|)GFZ@U6>dh8F`!qoKrz!o607UBz$ z8l{wknCKGE+{JdN@(>-k*!}7~FzXNOhrv2%6U){7NF75TUWmjGFC>XKKLU#c zMse;VP#TePT_(UZ=F(~g8+u^ z7x~|+F&O_AR{UjQeG6Mkn4j$Lq;=K#JM|KjHa-n@b&(i;TFrz?^G>U0WoAWva|F%P znln`L_8F(7OarnsBT#J4qB{6^pa5d{@+Ln5`Qo&@A%F`V?jk%bw!EbziTHDB6X>MN zIS^e%B5+Ra20a&@qaB-7`g^q<^Vwnnr6;~6fxCosSwz7-Q#iO$i^P=kY8#}&2j^8h zp%cw7sBdGaUsPLSc=tsWzdkKSUR19+yiMErp_kM_bX`J1G!1cW4;&|DT~a~Dw z#b1}z^4itQaO29#`9Zzto-($q%MX%S>$|F&X|$WG4X=$d>hdF)4V!9wF$Dl7UsZQg zM{vQ#WBAMhVO~?qmBHtPl*92+-FY~TMhpHpged)|T~p_B2xERy>pJKVw2nWiTZ*BT zmC(|>e$`Omo&;Q>0hL(&XGj4_JKa#ToyIigX*bj-kSN-(V6px2>+fnG$CUp=tx|j;hx=z!oJ^%(8!)E6{(>_6AdCYwDYW4 zk=ijeY8T9n9`_Jj080>SVzmr0I~KIeYD>gwiKrJ}j?+qfFi3pbK&vi>mC|b4<9a&e zLp>=dyyC?@rL~06(SZl;2#`q*W|fZDa_EEe^8I5LRBV-? zp=(RbO3+Y`{+OWM3qh+y4JGYki5l8rY7(XDC21od^}I|SNzw*Fs&TTms6kT5IFg~!B&L_qc4M0_%V>Cl6(2h3L3e>qH?s+ zC>^HcXbn?{b@zuEmB@Ex)OB1~;8vto-tEfp2dELqy8sRf5Yvr-4EtR!wNPbb7 z$YzzP0yBX1H&)diC5n}+uJv^YP785uX2kUC)9jT2e6$G&EppG|m%T2Up4%W2#IyaS* zx4WtKAkv{sp4Ll|GE_`%uA!IGy4qap>~7^EH(;AJNW35!$Ae zMmo!CrG4P^MAC3}YwF3m)>@-CKH{Oa8oE=(Q*9-C9%`!@A%;XOekN&2T393T+yNG5 zb!n%?x{4ilN!IZ8TG=qfZ(bLz*ifTO3iZ2byPVExa{lP1t;VV=yK6JaxXbRL6W;lh zY*}l1YsqALM-2lB2{;_@qy6fBb^}fk$!qv+KdmRcEWE3~=DH;btJD3p^@%v^^02_% z{3|?WZUmQ#3&e^CwWh#V#~;+nf-?T&K@AT}tosLOU%PWpJ_~b@_OXK*&V-dbSSx;w zNOJ=@ruFI&$i@kA5Un~)>+O=9ysV3cYr`B+#6FQDqr#ZJWgxn;z{ILJ6=O8>D%#| zdqtSY+wu{uBT{9_BigUvWqoRbmQ>z8##UrsaQD)rzA$3b1d7Cm6Et$O+?t@ZaQnhh zSnVGL{eWm}e@uHqah&S+;kO7N)10F1#r>S6e5UPp*@4*k%g?m~ShD`Gmg%l)uoc=s zJ8`H8Lo}R0Sj5kAS?)yi6B6@ZbS1*()X_@v8+2T0^z5+47@WWgOrKN42#e z6owwNg#wtH9;hu|I3{KKm&dfDg!9*$<-R__;n-45l}~@+a1V}5#p>T-!?Wsy)`qXx zDqcUS`9;)8ttDG6I-k@AvlZes2qJ)v(qqRna4u7Nl>aY^6 z$}di7XwqA?ztxyaP}+p&eFxKtMYB$8Pq{@TJ$+}j$sFGJuR5!tH(wk&tI<8iHs`cC zm@Yb}-Pg+g8iRsArb8b_h{CTiPz(%epcH&~guYGypBlvvIxxt=8|`Yyhd*HPjrm@Z zYR&hOXMXx#>fa{kwQkk^yFSF;^I8%v=p*MP%>+Pbccr+-PQfI;du#s#1Q?MkkQ^%>(xfypD8L?>9ec6~%i$Xx)({8CT%j#6=?S zidNI1KONalxuONI{I4t8ClXAze}c)!8vUOUdmh@Y?08WbI_k!-ehU(+VK zb4F6{uAjBna74fUtZl?;6>ERd3NUQ?D=<>K8(L*_MS~Qluev~3Bnoaw`v_{ZmbkTm z{8=GGT>h)JnAGtBQTwLmoND#CslAJk{l97RLySpPY=4Z$i%)*j5^9y`3N?5R_K2E0 z3nJk|eL8NzGxTZH}e{Y$GQ{whMszd5nOE8! zk>H$tn}qygre^@G#hE(ps~1nw(V=S9VEW61VJ6lF%Zj>fEY zIb3g7qK#!n>ao%)$M1l^ddn4i59!@Szc4*UycMOdcNjFZ2LV0Qw8ls4bxHse0BnWm z6swP;jolpp){N7?cK{M2W|h+EjCNTmot#GzrS*5f@qeJSPUQciv_1>RJR@F5ts+jw z>nkvvoS>6S_k#p|Fs5rH>fIo;W+v)mT$HE-C0QradVI2e57yk5th*2SN&VDL(NT$r zK`Hvv7~V|Ldt%r%RVN|2B2}M@>56GOn)=owY5Ja$T{OzjM`MjG89Ev4w=#6szM?MP zDx)vN3SG+Tq;{S!tM6p3>01TxO;4NEXF&|bUV)pFZ|B@%PMw<=115$oxgSmqu9DDL zZRjcV9Rv839j+Sqj=_6|e#tNE!MD>vhK}*UN2UVuh73GOX&KGvA;;%FN&VJ-oqP`O z`gOWfQm&jn9z>(HtepO!J62NSdU?Gc4rOq8z0jFRx@w!9t-l8a)(_eGldejog)y}f zjanS4q`OyS2-e+|^+O2DsG`4w;U`t}`Xyk-RnARf@1fgej{V-Oj zmP-V--pXV=cYh7Laeq6vy>g!Koj|=MSC4TQKQPggrCsjiMgP&VM2L{+}IG+iSL6QTG+d{M% z=88uf=%^~K9~KT|Bjr_WGplIsyB1?MOeH&Pj3MOzLBSwg&@(?18}Qo zv8fMt5hItRx4GV|6paUde-6i&0{5tjMBnDRkKMASHP@MIHsP%dguI}IUJvY^{VnuM z4#f#nZaaNrRXU>R4L>Ib(zpi@h|=9kzJ!B3u<7B9MCk4H=TT?B++LT@y>?BW z@6mHy3X`vTRY(0GPUx6U`bG>hI_q^Dx>3{Kok^IBDqZBV_^ylYeoqi9SdF{t@N*;3 z^j0@LPb}@G~9ln0hd4!H$8|&hT5a~!$ci$+` zV!)msj3QlP^%$+c?V><6vc~BH5QW*}^u}0o|2Tc0g9_;iF-C z`J(nj-F55|m0}*%Pa({!kLnW~STw9LkLiD6C!-(ND+2nmya{MGvrVxHI39y59 z3nuGl=>7yIFVc50ogFH%*G>%qKv+ zVs8rzbh2?v&j1QQy7LUZAAQVk!wC^POiQvpnV~OpM@}!<#>|vlcV?!XrnhJ5?l*l& z#nhawH$zwxX6q$CT@*h@M~9cVK1U~)T*JBgGzUH6miOm^`2#hqujc9d-CoIqvgs*( zots0W?y*4H5+5$mN6>qg7_q&vo*+6d)ZH(XIu*-^Lksm6vG2#9(T_MkUEmE7$Y6?! z-A(l@F)ygUPJ)@}{|~|3Z;{?2lO%38_yh)s$xbrp2Lb69G-2Oeq)!2PZ7$Zky55sY z_I7uz(C3FECN);;Y0;j3K_6+q0~)?guTN0%*HOJH@$rfmd}bAiOsa8#e|o2y4!arSWUL*ee3?ni;`72k}`6mY$)$MBTvW7)vWGnd7VB7IWz^OV`% z9}Yi*bOzqc;9$XNV8VlUXmZ4DvJW8~zbtxx0jNTU?|lK{eTBIGh3?)5q4WCrU+S+A z8PGFBUc`I8(#wObF#apOb`<dlouqgaWcMl#&)OI`R`?Z-$dElN zF4CPxhHpocF9gWkT17^wE9!{ovFb5a97c|*S({E)Fe%+IsXulXf-e&CJ_yc$yA z=atR99DZLJQgUGa9K1M#3CP-}$luh8f`dory1)^S`NI>7OUFgQ`6T4wZ~ig(DPuT8 zkS7T6$;7&RKORVePoc#wJ8}R+DA;F*d+l%!IHpWb8(%c|J>CBUy!ih-9uWO5>d67j zva86BqQM;sZCm@j@Vm}q{FvnCRX!A80lMy|YmI0$nW5(81>n+)I|>*}tTXLT^#Wq> zd5Y^WxkZQ+#IM4yI^+3lGY=5j6I}+!V&w1+_`S*_@RLS4ypzAQ88(c(wTKiV6l4n$ z1BNFi$`c!w$RZLGH6EUb%ff@#Em&2L$M1n318xn7!WN~fhnYXZFCPM8RXyCi?nD*% z!CE_4EzZ^87ticm-Mr#N!_4oUXt-Gw%L4!`!u-z3#h+orY(O8_3%*T(Nd$Yv+F`CD;9fhz)TiE5q?6sI&Kq|*p~Fia*)MrhvzE&;SbK!?K5#)Jc( z5)OPy0DJ;M2?snS9Prrzgb)UE0Uc9GxQ!qsBFxLh03vZpooZ3=)5~tci!AKW6aS$MA< z1s2|8M}dWR+0g*-@J@_Fqirfs#^y_3GQ=I$M5>iwPe>y*iIR?p;qdqonKcrbRp*r} zdn&Waa233mRYCfB{UvgkH(=NqazL3_pV>s91h0$%jt zC<4)x(j|Tr53}*3M7WI}B_eF}C=nS7Eh@Ah?V{8>ID!8 zctH1%K$lC85)Qd3K|&p%QG&#}lhAEMsl;Aw6c0Clpc$e>1pK@^(w-8L@LW$O?6ndA zzCUiS1(5e}E!C0ta5I&nuviY-QCKSnFbZw<|6i?@;*}DBrIM55$zdSH?WN)}io=gY zZigQ!@xM;%e>|%JVo14O{)1^n+)};CC2*x?4z9_eEJz7wGAZG#9ZKNZ2_QsDIg5u9 z&f=kjy?7|@EFMbW+L1ai#l}cVkt9UIks_3EqzEN!DMImpBSt9Yh!ILSVuTWo7@>qC zMkqmI1O}1hO(Z0oHB5!h8m5G^hADw-7{(reb_v9qU~dv@#!5|uc8@2T65JLzlu%4C zR-6)NMN^3;*D%F(Td7bY%wC+72)C6FB_hlK$t6leh9VF}hL??i*Iq(Y%^3wHV(b+S zz8K_b(mmJh)dI`usvQO0bJ>ow%dssBgo36@iZ3Z4qALJ1a4kqjY? zqWDl24N%HiPn2+oN(oy`MpMF;k`#BOBqbavNeM?vQo@mvlyIaZC8U%D`Iq30cafFc z3=BcCKyowHcDR`m4mVT6=4OgJ%uES~nJM8gGbJ2mhD4yM$Khrwa=1C(Tbht?)^+Jn zqEW(G*OYM9H6r;Zs4FD&+}))a)j@U=D?mg6*emH z0%j5X4uuas>2CrFVB1^dp=~d2N|)kZ*WUhCgqv&Vq{J-P-JnkbuGGSQ0rm>M;w$k$ z_N^;*6J(tk9@p0gpZ>#5WuR5GANRr@XD^%spA+@L1}q0YZ-{n-ulO=HPGsCN4owxg zAElO>(-OtV3V02h!^8a^{A#ENH&`VNS(xpT*}Z7*bteOtj9U}7phjv-_^w4n8jK1? zzFs#2v`_Ow8148;f#l#7-Jly8$Xgjk>ODS>DAi3*fc@WB0=1RyM3GS0uV{^MiR_$2d0^0ezDI({-AIPI}1tZbvYSk|A0OF#3ToSIVysq zs*=qd=vFDByf|NFA_X|hB(wNo-NHR*sBZYG8nC~Ds_-t=fXPjP07^3;s9KL35LB)Q z->dc-mFmHlAiZ=a)uAQv^KqnpG&pf!=71&te#**HjAkT^AU-nu!XGweC0^`N7^@*B8_tR zSR?`#vM-99lybR5fwqO!kZz{<{2)7H!9SA)X}~Yfxl5f6Z&@OY^tS$}bYFRDT3k{d ze*`nqNC)X@Bo6hb0KNQicy9q>h7a$Fke3-x;_#FI`{B2p(9teY?F|7e)<6e<0LLk9 zpkG*$Wzij*j;f-_W6Jdi87LdWk)KQ1yc1~xpdQc?4uLuWAaJ|G$$!~mhbgY-QwptT z7Sdj~+w0gO3mo-&0J#?gMng}6p*NeApPwIx)YK6&9+1d$BNMJ-=fJv&H^aum?3Xg) z$3dJ;D2_*9KD43|dV>O&xAB+=kOW*)4ES}m4!(Hk-(*I7y6O-(0OfHA8tztP7;(13 z0Q0kKh@Or#qKtUd6I_lZ($N!&`~JQ7Y~M zM_oszIjb=al|&q132Grtss$B1LGWn?SRqj`HH3H^0Eq1XGvtqhApmSwmxTyWTbY4+ zR)Ccxr-d0QApTIqLCofp42%b0ts!}+0+2pVR`B}MP{>fOin5TJ!30Q(((Fazv#S=8 zeR3dx7FCd%@Ud+OpbTKaPZ9uYdth-!!IDfzQvf)+jXx?{L({Gt3!niCgKBMLjm4-R z=^2MOrnHNO7q_8JKzPZ~WVMXu;1EaRx{5f20Jaj={}TcPu&4im0CWY1Mps)75(xmA z^&tQPG=cyKkSEJZ5Fm6U@KYpmC}z6H7iV%`8mdbeP6;iHznPvIhy(Hg^TJfxO>j|w zJnZ(4z_gkQ_51<&{Ak1W+YGrcz{(Ap=7OSu)6xK@}Om{F$dbL$f?lUMAL5 zRr~>XD&&Ftzi`S3@4E1}ec(U}51-+9O3t)`-NJzs(gK7vE0iM)BPz5#^YE@5`FWXQnDk;872D~$k!z+NO8Np_TP(@rYlIPC^0~kP>HyPTc zkktZ?&cGgtaz#xtD7$btO-%yzsDUy~0PzT#*eDH3Hyf5B19h7U0>=X)D+AQ47qn?6 zEK`+u4VP>=_ycv|D-Yxj?u|{6Dw|YjxYW5`sifloH0ad?smD{KR?m$6wb262-tU%vC>q z{hB(?41V^^E0c$th0}h!2!avrY)=5Hg&DDK$>oBjKis+GjYH4O<=7_0X%hem0Rt#L zvy1_`3QuF>8u=8{R>Is7iN(4FrVCVz7k*hHM7%g;@gPL*G8I6SyG<^aAQ7dkY-a>? zqbx4^vY@b9q76fbt+x7sA3O#70{(y)@TL)ea1uBV{$k-T9sW?trNG`Y!bofB_eU!z z@j#)YQDw-W1F3L-7=TKFs>~M`VkX5JX#oTNfsg2?`_f@Ih-5U zQoqyJxq=2 zqb9DVH_Ac>exriDZ(`R720YwqIlB>6ul$j(F8KeVFy)aW1^9E}dC3SjrQk^4dT zn4_)OfyTJgFhUgj23xVOw-x(p?jyy%!dC1{ZN~)%SkybIzP|<~>O!FA|b{hLI%91cF2Yh?1Qq7%@Dov6uEn3nf8FA_RyQAN7og zHeh;=!>BvKj?>J`Yi#0OzMA0t^Hme+JN$u( z^z9s^2jYz+_DN9dKcqVn+@!flcAXp5e@pe>1ql@4+=cDlD(cQtz(igzF`-P!NBAo_EAndDEUyL9fT|ZJE*vDi`6@PgvJi4={GDw zV+TGgY4s3#>A)$YFtCew+NCa^IN;H|MB3H~e_-0y3;w{gt>gZ{hUPhcU_jP}%-T{Rpp$TnuxuI7%Cv?Tau`a-xH8nf@BYBgK^z`o9kGS2o?C751)T&9r9ZuBQ~K6d_oD7q(DF+IzQf2;stKhVX6a4WmPy zt9m0%0N;~qXmr*_2_`Dg%D{DkV3z9##Lk_yiT$XzKcRj!TRSq~I#Qp|k(xTvpP(|{ zLU>C9Y-No(*4^j}Ja1%0^m-r|SuwWr*`wvL6=Q$gamUBg9dmEOX_V)l^(9l-Ul=2K z+G|knrZkgjm-m)YMKvOPI=xZWh_xKLJ8cx9dD9tCbRjGbT27$c&>(0}yHSLdAJ>PL z(V5C)Sxrf;Z76*XWg;mvBi*K^nruxC*b+Zk%M6rVJh*q;GUe?HHuM1rIY5t4S!(Ook}{aUlg$4~1MR&=LyNj-?b} z;sOdZjEjRxv3wK!t2^y=lW%UWQJ~+Bd3pOzCvIzV2EH~s8yQPZ7%Z>5K@bDAFOu4qXE0JvRPyU(o#) z0c+2UK!_%9T{>JWsaQ&#RJ4P#&q%O?kOyQ3ArHt7sy-Xe4nl035Mt6q&MjfHq##I{ zr$LOukXnVPcHNPtVnXx;2U_3?t~iAmt^$)oDh~f6O8Fm2%KyxF`+uqZj})cHQliWJ z2oNfh6Gp5*yn|hv`l?a6Tq?(ii@LZl0e%=QRSWOCss;C5RiS-Xv%tQqT3FvzEvWCR z)@@g{cz$mw9pwFLTOs{RhcFF$(~lvRqJ*k)h)Y`=J>vQX3Hfbs3xnj@P_>jJch-Xa zu4Nqr$NYiKp_9xmiM^lOM93|K)y_a@u5|rd?z(re<5(9s zjwP*?zAtgM!KmX(3muQrN&&7Vj(^Q{y`2TA4|<+sQ!}z@IzBi&5N?^*XwF9)K9R$~6}aCrU9ga)iO zn2@$^wHXQVZmXT%DifS$*D4W&3E^~p?vZHey9dl?8V~zSc$Yx$(VBEPnXk{{s&>z; z`_IO1FStFv%a_~C(cBjl z-E^rB6ZQo?Hz_ysbsUuhPxX?dk}xwceg(!t3T7^zX1yh$z{!RWTp!SxMZxz!vC$Nj z$9Puc8bdmnDXvfvbHKJ1)J$*%w|5ID$~^{DzXz(hoSF7QM&`u8AC}Vo0GKy3%;-js zxKGbAE!enD`|G=dNM`0ZnNWZ$1zEtATrwa~E*aKd$Rz`g6=VUqa>+0# za>+0#%PGipSV=Luth9#WOgYBOg5hckRFic|I~0{v|7}zWt~18Mbp4VGmp3cHOaRzGIfHq46mq?Jd}riT2+WqwjAs@YRVt9!}>Cix0#^)*&`_^Z_`22mu? ziiN>QlK`6ITT;s&t6nzelb?#qFJW<2`JIeRk&h6eU}ukS>iCuB2qyN3X)xHIaP}o~ z8Z5%LUZ5lKTZ-kFm6Z_!F6PWxgJHqRt1!kX*20S^uEw&USSRCuVd)zA9AwkZP;r9- zJqt^lNoAneg1t_$RY9Od7ng2tEK=KJF?lMSMnEw?7M2iD4V4I}A(aTIGgKm=m_`dr z2&gl&f%nG??iqWu`(l>HxFuTOqx)+`N72SKN*^q@tM0} zYU6?;e~cDxU}7+oSG!^}1t-_L;u=@1Lv>>L6`$GsVLUB6elD!C%>3ab+f&%ODjk+A zBF^w&kL9z44_PuH*nsCFgsEEARRt$AFJKz3Ilr{k)mCeMZea#4NTOct4@p#Ljn3U( zV&5JHBCV5F)z|E-baqCaot@7NL@fZ-|nkS!fn2q zpj!AUlYh)t>G(S_wJg;@&#q)eQnL|D%~n7~@+0A=#Z+2EB^zA6YpqH5w3G0_lb| zVqQWF%phVPOw3Z4m?>fmDOL&YtLd<-Mt?3rvCR+a$?`3Yj%1ofDFS`XK{8GwE+hRUG8pH}P*f?B`kWy+fQyX{b+6{E__sf6oB0owbykeR zpY7SGHacNA82#}2ISsWiKz1$emMTpRRarx!Q72!U3`s;bSvQ1yw{lAW0lst@4N!yz zDr%rZ`8OT*D0Bx?M9>Q$X3rYbIdwC55y#^$caMR)rbC`j%AFzPh198%+#FBGv}nki z5s)`EN1kxUJ_fD0n^6D`(pKX+7Vf&*aCaGJ1**MQA(EPGI%<+moQ^s>aXPk9RCC;X9FM$T zf}|Wp`RQ0s25<2T9yb}g!es1en~dxe&^j5r#!bda zFxTwdS%Es4B=Ca4v|}~s8;X=fSSvESb29V!-G-~SDN3wm zFknMfcv!dPPc|Yrt0huG8kaR&NyH~rwsLHEz7ihA$EyYOcGfYd4xL3+bIf@_+G5s@;yQhgZ@kk)OI|(qN#;?B@Y|@ta6Blpyfj+;R>j5F`wHr!!}aX2_N5Wvv=p z7ETa=J{^4`4=3`|GUM7OT(kOfF!4#~=D&(;ntYkDT>$>lGfr43(%i#jB6C5Z`iVSz zjBN9?+>~A*YB016w#}xTew0B$5bbBrre7Q@=zl@1x9J7ru`=*;&8D`=RHy~cR}`ai zTW&|;z+u}C^a!ykd5xTpNjLF1%-qL>#)=YkNE$2pz~LCBZGljo=g+6kvBK(chw7I#88w~uaYN+cdu^Dw0$?)tZqb7FhMXn?|Mxm|9x+#Dn_ zCSr6Yk>@aA7>m3XN`BK&=ccVGrrn&OImkiwdL2YY8gPT+L_;K>#p5NTXo%Z zbz5}psxBU8Z?FZ|ob{d*2IsJR1#%2APO5T>ErNn`#dZ_oD$W&cV5A!pd)d+TaFWQK?SHS(ZD;x2&M^Hlgv{lPg<$FJd6FG%~8)Bhh79B$qv5}e@(3&25 z0>IH$$^%z7D(O_mS!$xw;3u}R+EtZfi_MPW?2lhso6+3OR)*cEJhgP%_@)M~NvFAH z3dh;0AsHP1xQApgGyx=GrA`4!)An7$+XxtlO%g|$kzpMJ9sb>p94%v34QR38@p!bH z2xuujsJ89NRC>uR%Ybg@-2at8KFyI5=_8K4#>>$k9(}l z3{9K@D+eK$Q(^_O(9*loit;iLhe$*FamiEK5` zk74gc|23Z)zA6q0qM;y*iL|@Yqz=d*2rd`GSVhb7pV?Ae`OomK*%^W;0!;ZGdu$%f zTE#+9D3u8g3%Lbs57IWyYXD*KUc!P_R2=dDD-bO@LUK;g`*C;I!##DKWgY7kc$Uf} zML;3+!7V|38*q;J8k>%E^hUyJlRoeft|DZuniBv-39hhbApou9D7n z%_`kOx0aD`2yL0a<hOFovAM#{UYE3dbI-?-#sL!*0!MlR%3 z>+4RN_&R!UntPQKO3dycIVL8-EpM=diqBNTiVsemQ0R{ON(3E&{F1d_4Kw5 z7To%D2VOh3#`Db@qYkm_r#xFa(&3mjgr&x9wBX$H@@8`(V0rwNbe?ONReKs`kNCw9 zo*kvkCelY~VZLhtEj1OP02ypXI_~L56Tg<8k%O9} z06qLSQ6kQ089W(@wyZL;Zkuv1WRUG7(D(s@#D-u*T9j9tCA5H43e5n!tZPafaOhGe z4SoSJoCA^5k7rcF1rSXVD}4{ej2eqqVBgZ*$EzEI?!;EMEi_=PA|Sr40R*%trY73AnyTg01(1e5)%X`*-|r8zV?oy3BGwO}X;&fc;h^ zkS0;3`&se|G_X#Hzd9rdHq_NyLI_JQ9_mTR(j$V?1)GD?#88A@$!CiutQzVf|JBlo zomyyJIy3K5;P;V7JL87R1Vhq%KOaE;H5_(Jk`}bI zSFm)to#SXU8R2jAx+J;wkSG0MSyVH1dfZOuWU}T!eW6C0- zypwd4S~?OM5cK>-6aNN2A^=A6W&;3z+4c%1jc2ax_A!lg>sL{h_!v+=iymo{X=cA0 zL9`5qW|fiLXaFe9Ih_&6sCz%_wI;Pdwvl+FNz};heQ9k9dD;G7+r0Gu{KWc<8Y0v^c2%#iVth+e-qhY=7W)%(YDm~-;Jv~!qq#^WY8 zLk?ENB)3;&hRkjXbA=E?V>~Eh(E2P|Sduqip#F{NlF-u}S*L{W+`NHy_|A1rG)?@q zVJpBVWE}#$NyWBwTceGa=IuK&orGK+vA#4%NFY|3ijp@I=b_0P%}fz;;rxTuRU3ez9Cc7IXN)m`s{;EXs|Ic8ei2TPgu$UCdbM#nFCQn8qY5v=P&rqg&}G+PWS=jXs2ZpBJ^{&=M1v)5r|?*yzY8{ZOGSr)o&G<={rrO)w@BJ`_DNEWN$?B6F8ly z(-aezaznOf(s?CvBwAEv3Ts+5XDiD_cC=Z^0|aj{mRYaj>}VOw9QRo24MZn9nmB`; z3GA+*%o}#&vWy|ypo?@ww(#m$)tH-G`EyZ?%#JnzrD0@$*;;y`$5wNBDr)ATWG*J) zg$`Nlvf>dQl6lPVZBf1xnU@InKK`_$Q{QFEhyWdz4iN|J;Va;>9p$yq9dq4X(4LUNgAB$z{wTG{U;5Z9D~_Y}AXwXeu~PqSYFYZij3PpJZTvuB%x)FVEX~ z1l`Qq@!H+&pRzCJwf(h{ zdG1A{qD`3|x!d+Y{{8S&LsuxV2Or}c>*5vyydAmj4x)1!__ZF`Ewj71)8HxypOJUs zLuKdvNM#vQpsB}x6; zRg&^yTitSO``nV^Elol9?b3Au|9sY@q-D6asEvkfMccw<gTM@}TA>qm7ZIx6w%Y8be8c;|y(a=V&M>z@FkdUL_`s za^tUyetA)8b%Vr9(Xn#8-ILtM_PVxEDMl(p{@(`U;z=_5O`Eu#arnPQ=LIvJL(~0W z%z3BfGuM961ngSqs%!IMKwpfgx3Zy6b^(F_?^{_)m~E7;hB zB6!L@e6m%fgl=XURqULEhjTRKUcbX01I8igB0GP8PgCOpl10-bkutEv9W<@>>MfM_ z8=L=^iH-hCj@G2mh;bO_vgTe7=Y7_~zZNaDv0Wh>na0m^-Ddi)MazNh1~VK@&9x6? z4|oXoTl-(i+jnhAPD0Y11#3?uC(6F$n+F@Ad7PaQ9@VL7b@eSxi%UiUF^T~lI94@7 zORt_p5Fg23on!n+(S=sz6oUAGN6teA{KG{H$e%X+=*Z#7LH}^ka)6%o6E(hOb!i$i zq-am>`BMtwf19lTHq}fg!Vjcr*sgwHW@>FljtbV7fDGMl5=;Pmxd0$Y<}Vj-5*8pZ z%-PFCcX2?kCU>Om55!O;qido+Du)a)j}(o%UitqWMqE!FULKhYFP`VSDtpEo!)<5V20QBNEbPaG31~PVYx^ zDARV|(4!uv_Q0Wx8s{5gH)-P#a7x7uyZ7T|7t5FEdfN{9|DLk9{RYq=?%*R-i+6%nS6V!cx&E&3w}J-`p(=pAu_vacL?(!NrqqVUM~EUcYUGV@tEI<^HiamK5$lGiW34T= z#SwEv$@_B3I3mU?FJuE2p~|L~Y?hqTfk7~75>FVs$J=2lyaJDVnA)rtOA^ar@I6=b0AFY{J7a(PZckv5B#+;^;UNNRMBu?wGyE z_LqoeT5BDoI9+?DMoWy|Qd~8oS_dg7#;@n#r8z$|!uh&~#@t!c+bG9z+7~8x;lDAV zEj%sX@a^QgZ+oh3p_m+)|CrE#cf|ik-bgY&KG8I{-3#FSRG#>s6?qlm3O9!+y}Qp% zR91fm{i2=wJl%pD7WsK~-zfNifpfy+=AjO*29wfU=?5^m+*HxDeWrLrf&Djf+Ntj? zI6Ebf-z=JF2lAWI8Q2y0s3!&)1CYX*K%C$Ulzh*FjL#NtaI`xK4LgP5*#a*{!Oh%P zy?o*n&Bf*lh&u>ZWmgh-(}y!XRjeipHYGy%;So;L|#QiU&w1!CRdGR%_l z0-MRt`BAB~TI9eGQmwX4$jN+nB-JV}msD3aUw(~@wtf}+9hZXq+b3sK8GT`n`X%V*{~w}~=y zbKgK?gjt*?>85BRbI?5k?jj{@&ilPT-`T|TPFN@KabEJ-(C z$#3DrJxn6S5fx&@J+)hetepo`0@O}cDP!&fL$B=f;06iky-eD)`8JIshV)WazltS( zyPb5ZS~E=)>DxKsxB!;V&Ww2Cc=5#3kE68d39nBb&!2+aay4<0a@#C~wX7w-7AJI# zHU0T43T-Kd^_{#O&xfQj<$)~4Gv_FnwYD{wrHrhi%NUZu@56+dEZyaeB?)cAhRh&I zq%f~E=?yt-T@WU@z$;X$?|J|y&hZ^4koNjHzO%yw5)@^vhp-l(;0^8O%md$qauokx z-gQveVici~wuiOYoXy&hIqrdjYrK#@F3h>G)+xL9y#kE(?tL$B1t5ZB#8_D9p16iO z*DA__R^OwD1CjVEEEi4U2Jw<^Z%KdkMl;6#3ADG?Fbv0&hhfGh4a0C&x^xJ`=v^&N zvJK7Nj&vKMm7lZ7(!z&EAciOmp>QdZLtin1ZOCE4yk!YAo2&R%^`waS z`#BoXaHo)r&o+@x&uxCcKxEJ{y-9DN7OuwitN8qs^+tgm!-^^(&GZ5vn)pHfrqa8T z9`@dy!g{~1#=N9%UyicXfHbVkLEF0Lo(unM>XPYnom(mD+)2^HQbLLWRtHkB?PB+nXX?X$Y*riR&HlR>A2^t!_K zwn{4cSPA|~!BycmANOP2oxCIkEB5&ur{lA*(CJ_JE2rC&+C}Viwj|b8Olp_CrD z{p{zb821el%r!oCrAI9mo8`<(^mn@b@9NqxYko3cI@4L2GE&mml>e;8LdRXK2L}2~ zmh;h3)*kkBN8OF-`Vtdci{BG}d`6QslmceDwvN)S<2;}}$bnG1jTsSB7N8v+rsoLC4O1eeX*Q9DLHNXNW0VaEooPE zyD8eu_8n<=*7PhpY2T1`C+!Q;u4D4sx=H`q0G{mw! zla&Eo=xHbNDU%*B7EY?Nsc9$tVAx{SHXi3P;`i`!xinzAVr-TxZf+w}m*iGs>i=+j z>mBgmANrO%+BLUH!TZtp1_F3L${T?Nqq(8mL-61xsFA?uG&jOWGSx;8lEP2~WMo2F>F>m1rXO zf}?;SfoJ#;!wO0U$OB9N+1^Pd_DIVAr|)8};qoa4!d7a80HoXas5dMCdKu%ZwoVpp z?K16yw!HRnGH(x*nW|ULlHuy~Ut;0Uk0*Hx5dYY}p=KkCis9H03ya3D9QsL11I+*@ zE&L>hF_>6EUF%dtsPkv4E$v!MD^2BE%THV0Et1FG>1qC_{_PWK-pTd-?KGbcsmXdH z`EiQOSk8C-?9ymBMP|%%+77-s^5UAzyL}_lWHhF@^O!~N&YurhPNm39+T%M4FkAzn z!*{5WCj7ob##oyHl=s+aGHtWS4=W0yeL+a;67N&&doW17VE>>oN+_5bj^n34w||s% zD3LWl+3YX_h`_hv-c=9b3a#@ z%&ebiw|@M(L;&#%6D`G$k5RaViPL20@cj<$0P>>i66GS0<>NayWo2ale8zw_O(R}q z>oA2OahRO%XypX~Ki0$=U9shqIxBWG1>WjxA}~>eC;|?~Z*!&%|6xvko3lxI)Df{H z;5_VE)FIEJ4k9``$MYzyIW(kuonMUQJ?%8ahf;FJ@*Yo$ww^8MN%1lEJ9AI~!Nu6`^dOjfu+z+iA-H|TZKf>60T+2|6Tb| z%1F^xDE<7jX`g6L8}n(7O-U`PUQ6Gb<1P0dzZblbZsQq%UPI)&%#?ATJw!p$3_`C| zL*a!Yn`rAg8iG90!QFA&YJBbv)_*yfw(8wTDk;HuAYprULOA)^QWRpdGFtb|%K zX8(UKS(d|4jD6Q~Uu%}bP-_MqkH{*E>>oJ|*RSHtcw&hfXgjhgYUy1$y}@=Lj+`NbV`4g#Pgx7^ z_bsq;ussSPv@p7GJ0zXuzP-O_IamhPsqHxsd|GPzJ=eTp)>IM6nfAu?SWVW-g6!NC z@s;;{{_*L}kqd3hVAvr0t}4|+y9zH@rmyKJU~(}9=}XHgMlUR_q(JXpLxFenDhhVW zT}weG)YTMt*w#_7VeT~)baFifJLTR$L0-;g3WDleDA+f5E5)K;T~@lCa`A=Qdb5%w zwlQZ#Sz_CD%r}m9s5}F!K;PzIPImc|9JED{Rbme1X!4O}^DLu1;~SmS%ly#8MT@QXv9%B+iuP@nk&mzr zjB(3*VZU(^%Pawsk26%EZ$6;F*dJ{rl_F||yV65Qs!&hEb7|`)AB#W{ahvCK_jgD4 zeDtnW*(HA-_YGrY&4tv^6%uizWu@VO>``x<9;s$K($e5Phx&W6^tmV0d1|2Gt zdq**s?-@`e1VSl>u_q`NBH}4V8}K=|6)?q8q&0=UnbNz6^t@?0T1i44#p=e?>^t9O z43C#%_gUe7NVq?}VB-)esp>+%+;U4eMH^`Z4}PY+T|MA^?rY78DI zOx=(+hJ5o|W%IGq{FQyC0rZAFMp4~+@hLiXbzvW58AEV=`BvF|Zf~V&t)v02gk!d* z2G`THRrWyE7%I(+qwL0crB^l~>jC5x2ciAfAK*;u)ss(!N-|(z8Y> zjKTB@X-heRms~(GZ5e!~SL3si0+urF3ua91+3>$%z#g_wT}RpBx3L4}Cm$~@-8cR1 zwKvA`RoT{e)RuK_xhswimE&m5z6ah>`&u0LWIKPSwk+PV@6hkmKG_jJkiGLgwevW< z|2?%&|605`+xTehJbP&L+Bn*>?@u1BO&y50WuN|L?HoJ(x-Fc}zVpr6d9&B=j3fOU z=Jz(LB}&%#MMtt%JX>3K`8F=U^tL!!&+iTV*4`aQhqt=&s4t)K<@bCUy@hsuKaP4R zAEv3jx5g1$B1I2npMAFWy7*8w{khtL*$@2ztv2K60IhyMj-%c0kE8!Z3%j$|KUaHQ zsu!*C4gR_>-{i}iefd^jzRQ>I^X2cU%$|9!_GElC`{MJpbK))8kDq6g!soKaw{*1c zwco0JwhW@CeYdu(A?(h&BaVjO9!DEWadeF1e*_|(|BA|eo4#9nYl%jlc%k+m@zU%I z->Yql@5)~H{n}^a-P!aX(DL@|!XGdqbF*7i?B4gMKd8Mwj#p+c`(f=@c<^mMtbLq& z8b7MNyi+LE>$YSYepDNoQu)I;S|QAG>T=4#?4v)by(WHV_WX}(n`r6R|C6D8tzg-8 zXB<8FjyU=wN*({1FZWXlU7w>A-Vaf#FGu|OM}7GX%9H%QqvPzWCu^SpvJd^3m#xdb z{^QztU262mdxd~~LqDngNok5kZ|k<$2x2qk?(E#Se#V6dsV)XlS97_{?=Z(pKM+Uz z0pbY5sCL$T&<*}>|M&^YDEmDge}is6jmOqdS1jNg6ycJb4AJjid5 z=8sdVucQ2Q-+D^D_HKTHHp+TS^*Nolb4iC=vU5xI^SU1Jhxgr;-BhZd)%CPLK6Y33 zF^;DU`_|N=9vjTQTB@JZxz0DbHk)3q|M$}#;_#3^rFUwY9H%^ScXoHVK5v7rsb8a@ zK>b=sy~1xb$4mLCT*pt8{#Ji{AEn-Sf@iDI>YKOR7IkmD?dF><8oGh{P&9*UYq=*t zwmR#r)L+%P*FUy9dqbsuWyh@Tag=1As?_H%NSB&mEuT%eB6|-IsqxDF7bxW%+-&` zzvcEnI9OlTca*z?sw0%q2eMbz>hsPXg<2+~psP8*il5#VD2Qk7uGP<)y`J-%`RR?i zPi^j?+?9Q?M#ptY*gr|BrdLz%;-`*QcDN>I`*M_0=dYj?HEs6gyD1Ox+w04tzN|pZ z8ohHoQSag%)#%kXt>18a)Z>OL+f=Xrb>|Tt5PXNSb35xF=-AHvJF|c2te>%JJyn5s zm@DOiY+Lr@zWRAIe{O&M z<3lfSws>e(^vJv13j`nuzhfsed&4F1*vehSosTj!`Vj%>K6^(`KfC6*U$dz_b>QAM<39W(Ar;Q-yW!+*?WX{yopo#E#EgZ zrT&${-UmMsM=#+X{dVu`IAes@o!aV?Va{N7; zxo7+STRs%-sYL1CC&vNw$fwZ`zVHXPe?HtqLx;xSH2PO@^vM|)4TO6D=!Nn3?B|~S zAKWNu6|lJbsc|n``59!%A8&X?xQBN=^wjm^Zra67|MnMe7z{T7^>gDJsyrA+B8hMh z4ek7TwY#$zvcGwI<)&-jydmnSy!qN2Z;HSAmVI+Z>a#Dpq2t=kn{U2uSE*xPe%2JFml-+JTL4LWu6P1(rx^;^oHsVv?1N7vUcD(#DJtoOzL EAG*_rSO5S3 diff --git a/pkg/router/policy/wasm/presets/parity_test.go b/pkg/router/policy/wasm/presets/parity_test.go new file mode 100644 index 0000000000..ce9dd53bd6 --- /dev/null +++ b/pkg/router/policy/wasm/presets/parity_test.go @@ -0,0 +1,288 @@ +package presets + +// parity_test.go is the gate-1 "byte-identical decisions" property extended to +// the NATIVE-Go preset path. The compiled bundle.wasm (run here via wazero) and +// the pure-Go pkg/router/policy/preset package are two compilations of ONE +// source of truth; the wasm-visor cannot host wazero (wasm-in-wasm) and so runs +// the native path instead. These tests assert the two agree — field-for-field +// on every decide, and step-for-step over a multi-tick on_tick sequence (the +// stateful controllers) — for representative inputs of every preset. If they +// ever diverge, the bundle was rebuilt from a different revision of the preset +// package than the one compiled into the visor, and this fails loudly. + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/skycoin/skywire/pkg/router/policy" + "github.com/skycoin/skywire/pkg/router/policy/preset" + policywasm "github.com/skycoin/skywire/pkg/router/policy/wasm" +) + +// unixNano builds a UTC time.Time from a unix-nanosecond value so time-of-day +// decide cases feed both paths the same clock. +func unixNano(ns int64) time.Time { return time.Unix(0, ns).UTC() } + +// normSpec is the comparable projection of a route-spec, built from either the +// wazero policy.RouteSpec or the native preset.Spec so the two can be compared +// directly (slices normalized nil==empty via the candidate projection). +type normSpec struct { + Chosen, ReverseChosen *normCand + Mux, ForwardMux, ReverseMux int + MinHops int + ForwardMinHops, ReverseMinHops int + Fallback, Distribution string + RotationIntervalSeconds int +} + +type normCand struct { + Hops []string + HopsGeo []string + EstLatencyMs int + TransportKinds []string +} + +func normCandFromPolicy(c *policy.Candidate) *normCand { + if c == nil { + return nil + } + return &normCand{Hops: nz(c.Hops), HopsGeo: nz(c.HopsGeo), EstLatencyMs: c.EstLatencyMs, TransportKinds: nz(c.TransportKinds)} +} + +func normCandFromPreset(c *preset.Candidate) *normCand { + if c == nil { + return nil + } + return &normCand{Hops: nz(c.Hops), HopsGeo: nz(c.HopsGeo), EstLatencyMs: c.EstLatencyMs, TransportKinds: nz(c.TransportKinds)} +} + +func specFromPolicy(s policy.RouteSpec) normSpec { + return normSpec{ + Chosen: normCandFromPolicy(s.Chosen), ReverseChosen: normCandFromPolicy(s.ReverseChosen), + Mux: s.Mux, ForwardMux: s.ForwardMux, ReverseMux: s.ReverseMux, + MinHops: s.MinHops, ForwardMinHops: s.ForwardMinHops, ReverseMinHops: s.ReverseMinHops, + Fallback: s.Fallback, Distribution: s.Distribution, RotationIntervalSeconds: s.RotationIntervalSeconds, + } +} + +func specFromPreset(s preset.Spec) normSpec { + return normSpec{ + Chosen: normCandFromPreset(s.Chosen), ReverseChosen: normCandFromPreset(s.ReverseChosen), + Mux: s.Mux, ForwardMux: s.ForwardMux, ReverseMux: s.ReverseMux, + MinHops: s.MinHops, ForwardMinHops: s.ForwardMinHops, ReverseMinHops: s.ReverseMinHops, + Fallback: s.Fallback, Distribution: s.Distribution, RotationIntervalSeconds: s.RotationIntervalSeconds, + } +} + +// normAct is the comparable projection of a rotation action (slices normalized +// nil==empty so the wazero JSON round-trip and the native path compare equal). +type normAct struct { + DropLegs []int + AddLeg bool + ExcludeHops []string + DemoteToStandby []int + PromoteFromStandby []int +} + +func actFromPolicy(a policy.RotationAction) normAct { + return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby)} +} + +func actFromPreset(a preset.RotationAction) normAct { + return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby)} +} + +func nz(s []string) []string { + if len(s) == 0 { + return nil + } + return s +} + +func nzi(s []int) []int { + if len(s) == 0 { + return nil + } + return s +} + +// decideCase is one representative decide input shared by both paths. +type decideCase struct { + name string + preset string + rctx policy.RoutingContext + cands []policy.Candidate +} + +func toPresetCtx(r policy.RoutingContext) preset.Context { + return preset.Context{ + App: r.App, PeerPK: r.PeerPK, Port: r.Port, NowUnixNano: r.Now.UnixNano(), + CLIOverrides: r.CLIOverrides, IsDirectDial: r.IsDirectDial, TransportKind: r.TransportKind, + ReverseCandidates: toPresetCands(r.ReverseCandidates), + } +} + +func toPresetCands(cs []policy.Candidate) []preset.Candidate { + if cs == nil { + return nil + } + out := make([]preset.Candidate, len(cs)) + for i, c := range cs { + out[i] = preset.Candidate{Hops: c.Hops, HopsGeo: c.HopsGeo, EstLatencyMs: c.EstLatencyMs, TransportKinds: c.TransportKinds} + } + return out +} + +// TestDecideParity_NativeMatchesWazero asserts preset.Decide == the wazero +// bundle's Decide for representative inputs of every preset. +func TestDecideParity_NativeMatchesWazero(t *testing.T) { + cands := []policy.Candidate{ + {Hops: []string{"a"}, HopsGeo: []string{"US"}, EstLatencyMs: 10, TransportKinds: []string{"stcpr"}}, + {Hops: []string{"b"}, HopsGeo: []string{"DE"}, EstLatencyMs: 50, TransportKinds: []string{"stcpr", "sudph"}}, + {Hops: []string{"trustA", "trustB"}, HopsGeo: []string{"FR", "NL"}, EstLatencyMs: 30, TransportKinds: []string{"sudph"}}, + } + const oneHour = int64(3600) * 1_000_000_000 + cases := []decideCase{ + {"app-mux/vpn", "app-mux", policy.RoutingContext{App: "vpn-client"}, nil}, + {"app-mux/skychat", "app-mux", policy.RoutingContext{App: "skychat"}, nil}, + {"app-mux/other", "app-mux", policy.RoutingContext{App: "unknown"}, nil}, + {"rotating-bw/proxy", "rotating-bw", policy.RoutingContext{App: "skysocks-client"}, nil}, + {"rotating-bw/chat", "rotating-bw", policy.RoutingContext{App: "skychat"}, nil}, + {"latency-adaptive", "latency-adaptive", policy.RoutingContext{App: "vpn-client"}, nil}, + {"latency-adaptive/other", "latency-adaptive", policy.RoutingContext{App: "skychat"}, nil}, + {"elastic-mux", "elastic-mux", policy.RoutingContext{App: "skysocks-client"}, nil}, + {"probe-and-prune", "probe-and-prune", policy.RoutingContext{App: "skynet-client"}, nil}, + {"adaptive", "adaptive", policy.RoutingContext{App: "vpn-client"}, nil}, + {"geo-avoid", "geo-avoid", policy.RoutingContext{App: "skysocks-client", CLIOverrides: map[string]string{"avoid_geo": "US"}}, cands}, + {"geo-avoid/noclean", "geo-avoid", policy.RoutingContext{App: "skysocks-client", CLIOverrides: map[string]string{"avoid_geo": "US,DE,FR"}}, cands}, + {"transport-diverse", "transport-diverse", policy.RoutingContext{App: "skysocks-client"}, cands}, + {"trust-tiered", "trust-tiered", policy.RoutingContext{App: "skysocks-client", CLIOverrides: map[string]string{"trusted_pks": "trustA,trustB"}}, cands}, + {"time-of-day/biz", "time-of-day", policy.RoutingContext{App: "skysocks-client", Now: unixNano(11 * oneHour)}, nil}, + {"time-of-day/off", "time-of-day", policy.RoutingContext{App: "skysocks-client", Now: unixNano(3 * oneHour)}, nil}, + {"time-of-day/window", "time-of-day", policy.RoutingContext{App: "skysocks-client", Now: unixNano(23 * oneHour), CLIOverrides: map[string]string{"business_hours": "22-6"}}, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + l, err := policywasm.NewLoaderBytes(tc.preset, Bundle(), policywasm.WithPreset(tc.preset)) + if err != nil { + t.Fatalf("NewLoaderBytes: %v", err) + } + defer l.Close() //nolint:errcheck + + wz, err := l.Decide(context.Background(), tc.rctx, tc.cands) + if err != nil { + t.Fatalf("wazero Decide: %v", err) + } + nat := preset.Decide(tc.preset, toPresetCtx(tc.rctx), toPresetCands(tc.cands)) + + if got, want := specFromPreset(nat), specFromPolicy(wz); !reflect.DeepEqual(got, want) { + t.Errorf("decide parity mismatch for %s:\n native: %+v\n wazero: %+v", tc.preset, got, want) + } + }) + } +} + +// tickCase is a preset plus a fixed sequence of leg snapshots fed to both the +// wazero on_tick and the native Engine.OnTick tick-by-tick. +type tickCase struct { + name string + preset string + steps [][]policy.LegInfo +} + +func toPresetLegs(ls []policy.LegInfo) []preset.LegInfo { + if ls == nil { + return nil + } + out := make([]preset.LegInfo, len(ls)) + for i, l := range ls { + out[i] = preset.LegInfo{ + Index: l.Index, Kind: l.Kind, TransportID: l.TransportID, LatencyMs: l.LatencyMs, + Alive: l.Alive, Standby: l.Standby, SentBytes: l.SentBytes, RecvBytes: l.RecvBytes, + Retransmits: l.Retransmits, Hops: l.Hops, + } + } + return out +} + +// TestTickParity_NativeMatchesWazero drives the SAME leg-snapshot sequence +// through the wazero on_tick and the native Engine.OnTick and asserts each +// step's RotationAction is identical — proving the stateful controllers +// (EWMA smoothing, AIMD peak/idle counters, probe state machine) evolve +// byte-identically across the two compilations. +func TestTickParity_NativeMatchesWazero(t *testing.T) { + leg := func(idx int, tid, kind string, lat int, alive, standby bool, recv uint64, hops ...string) policy.LegInfo { + return policy.LegInfo{Index: idx, TransportID: tid, Kind: kind, LatencyMs: lat, Alive: alive, Standby: standby, RecvBytes: recv, Hops: hops} + } + + // rotating-bw: a reliable + fragile active mix, converging toward reliable-only. + rbw := [][]policy.LegInfo{ + {leg(0, "t0", "stcpr", 50, true, false, 0), leg(1, "t1", "webrtc", 80, true, false, 0)}, + {leg(0, "t0", "stcpr", 50, true, false, 0), leg(1, "t1", "webrtc", 80, true, true, 0)}, + {leg(0, "t0", "stcpr", 50, true, false, 0)}, + } + // latency-adaptive: an outlier leg that should eventually be swapped. + la := [][]policy.LegInfo{ + {leg(0, "a", "stcpr", 40, true, false, 0), leg(1, "b", "stcpr", 45, true, false, 0), leg(2, "c", "stcpr", 50, true, false, 0), leg(3, "d", "stcpr", 900, true, false, 0)}, + {leg(0, "a", "stcpr", 42, true, false, 0), leg(1, "b", "stcpr", 44, true, false, 0), leg(2, "c", "stcpr", 51, true, false, 0), leg(3, "d", "stcpr", 950, true, false, 0)}, + {leg(0, "a", "stcpr", 41, true, false, 0), leg(1, "b", "stcpr", 46, true, false, 0), leg(2, "c", "stcpr", 49, true, false, 0), leg(3, "d", "stcpr", 980, true, false, 0)}, + } + // elastic-mux: growing then idle throughput. + em := [][]policy.LegInfo{ + {leg(0, "a", "stcpr", 30, true, false, 1000), leg(1, "b", "stcpr", 30, true, false, 1000)}, + {leg(0, "a", "stcpr", 30, true, false, 9000), leg(1, "b", "stcpr", 30, true, false, 9000)}, + {leg(0, "a", "stcpr", 30, true, false, 20000), leg(1, "b", "stcpr", 30, true, false, 20000)}, + {leg(0, "a", "stcpr", 30, true, false, 20001), leg(1, "b", "stcpr", 30, true, false, 20001)}, + {leg(0, "a", "stcpr", 30, true, false, 20001), leg(1, "b", "stcpr", 30, true, false, 20001)}, + {leg(0, "a", "stcpr", 30, true, false, 20001), leg(1, "b", "stcpr", 30, true, false, 20001)}, + } + // probe-and-prune: steady 3-wide established set to trigger an explore add. + pp := [][]policy.LegInfo{ + {leg(0, "a", "stcpr", 40, true, false, 0), leg(1, "b", "stcpr", 45, true, false, 0), leg(2, "c", "stcpr", 50, true, false, 0)}, + {leg(0, "a", "stcpr", 40, true, false, 0), leg(1, "b", "stcpr", 45, true, false, 0), leg(2, "c", "stcpr", 50, true, false, 0), leg(3, "d", "stcpr", 30, true, false, 0)}, + {leg(0, "a", "stcpr", 40, true, false, 0), leg(1, "b", "stcpr", 45, true, false, 0), leg(2, "c", "stcpr", 50, true, false, 0), leg(3, "d", "stcpr", 30, true, false, 0)}, + {leg(0, "a", "stcpr", 40, true, false, 0), leg(1, "b", "stcpr", 45, true, false, 0), leg(2, "c", "stcpr", 50, true, false, 0), leg(3, "d", "stcpr", 30, true, false, 0)}, + } + // adaptive: mixed load + latency to exercise the arbitration priority. + ad := [][]policy.LegInfo{ + {leg(0, "a", "stcpr", 40, true, false, 1000)}, + {leg(0, "a", "stcpr", 40, true, false, 9000)}, + {leg(0, "a", "stcpr", 40, true, false, 20000), leg(1, "b", "stcpr", 45, true, false, 20000)}, + {leg(0, "a", "stcpr", 41, true, false, 30000), leg(1, "b", "stcpr", 900, true, false, 30000)}, + {leg(0, "a", "stcpr", 42, true, false, 30001), leg(1, "b", "stcpr", 950, true, false, 30001)}, + {leg(0, "a", "stcpr", 43, true, false, 30001), leg(1, "b", "stcpr", 980, true, false, 30001)}, + } + + cases := []tickCase{ + {"rotating-bw", "rotating-bw", rbw}, + {"latency-adaptive", "latency-adaptive", la}, + {"elastic-mux", "elastic-mux", em}, + {"probe-and-prune", "probe-and-prune", pp}, + {"adaptive", "adaptive", ad}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + l, err := policywasm.NewLoaderBytes(tc.preset, Bundle(), policywasm.WithPreset(tc.preset)) + if err != nil { + t.Fatalf("NewLoaderBytes: %v", err) + } + defer l.Close() //nolint:errcheck + eng := preset.New() + + for i, legs := range tc.steps { + wz, err := l.OnTick(context.Background(), policy.RoutingContext{App: "skysocks-client"}, legs) + if err != nil { + t.Fatalf("wazero OnTick step %d: %v", i, err) + } + nat := eng.OnTick(tc.preset, toPresetLegs(legs)) + if got, want := actFromPreset(nat), actFromPolicy(wz); !reflect.DeepEqual(got, want) { + t.Errorf("tick parity mismatch %s step %d:\n native: %+v\n wazero: %+v", tc.preset, i, got, want) + } + } + }) + } +}