feat(discovery): add SelectPriorityWithTrace + SelectMultiHopWithTrace siblings on MOLSRelayPolicy
cognitive committed
Apr 30, 2026 at 04:23 UTC
b5ce160e0e4ff7ee2f7357d9c163da81dca7ffad
3 files changed
+495
-23
keyless_tls
new
+1
@@ -0,0 +1 @@
1
+Subproject commit 7733f8366abc1c88dff35b42ca6791c2068f9de2
portal/discovery/mols.go
+256
-23
@@ -297,23 +297,58 @@ func (p MOLSRelayPolicy) rankRelayPool(autoPool []RelayState, localAddress strin
297
return autoURLs
298
}
299
300
-func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
300
+// SelectPriorityWithTrace is the telemetry-instrumented sibling of
301
+// SelectPriority. It returns the same ordered relay list plus a SelectionTrace
302
+// that captures pool statistics, eligibility classification, and the scoring
303
+// parameters used for this specific call. The returned OutputURLs slice is
304
+// byte-identical to what SelectPriority returns for the same inputs.
305
+//
306
+// Banned relays are recorded in SelectionTrace.Suppressed / Reasons with
307
+// reason "banned" even though SelectAggregate removes them before further
308
+// processing. Explicit relays are not included in Ranked (they bypass MOLS
309
+// scoring entirely). PoolFallback reflects the fallback count before the
310
+// minimum-active-node promotion step.
311
+func (p MOLSRelayPolicy) SelectPriorityWithTrace(states []RelayState, cs ClientState) ([]string, SelectionTrace) {
312
+ start := time.Now()
313
+ now := start.UTC()
314
+
315
+ trace := SelectionTrace{
316
+ Timestamp: start,
317
+ ClientHash: hashToGF64(cs.LocalAddress),
318
+ Mode: "priority",
319
+ PoolTotal: len(states),
320
+ Reasons: make(map[string]string),
321
+ }
322
+
323
+ // Record banned relays before SelectAggregate strips them.
324
+ for _, state := range states {
325
+ if state.Banned {
326
+ url := state.Descriptor.APIHTTPSAddr
327
+ trace.Suppressed = append(trace.Suppressed, url)
328
+ trace.Reasons[url] = "banned"
329
+ }
330
+ }
331
+
332
selected := p.SelectAggregate(states)
333
if len(selected) == 0 {
303
- return nil
334
+ trace.SelectionTook = time.Since(start)
335
+ return nil, trace
336
}
337
306
- now := time.Now().UTC()
338
explicit := make([]string, 0)
339
autoPool := make([]RelayState, 0, len(selected))
340
for _, state := range selected {
341
relayURL := state.Descriptor.APIHTTPSAddr
311
- if slices.Contains(clientState.ExplicitRelayURLs, relayURL) {
342
+ if slices.Contains(cs.ExplicitRelayURLs, relayURL) {
343
if state.hasObservedDescriptor() && state.Descriptor.ExpiresAt.After(now) {
313
- if clientState.RequireUDP && !state.Descriptor.SupportsUDP {
344
+ if cs.RequireUDP && !state.Descriptor.SupportsUDP {
345
+ trace.Suppressed = append(trace.Suppressed, relayURL)
346
+ trace.Reasons[relayURL] = "require_udp"
347
continue
348
}
316
- if clientState.RequireTCP && !state.Descriptor.SupportsTCP {
349
+ if cs.RequireTCP && !state.Descriptor.SupportsTCP {
350
+ trace.Suppressed = append(trace.Suppressed, relayURL)
351
+ trace.Reasons[relayURL] = "require_tcp"
352
continue
353
}
354
}
@@ -323,63 +358,261 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
358
359
if state.hasObservedDescriptor() {
360
if !state.Descriptor.ExpiresAt.After(now) {
361
+ trace.Suppressed = append(trace.Suppressed, relayURL)
362
+ trace.Reasons[relayURL] = "expired"
363
continue
364
}
328
- if clientState.RequireUDP && !state.Descriptor.SupportsUDP {
365
+ if cs.RequireUDP && !state.Descriptor.SupportsUDP {
366
+ trace.Suppressed = append(trace.Suppressed, relayURL)
367
+ trace.Reasons[relayURL] = "require_udp"
368
continue
369
}
331
- if clientState.RequireTCP && !state.Descriptor.SupportsTCP {
370
+ if cs.RequireTCP && !state.Descriptor.SupportsTCP {
371
+ trace.Suppressed = append(trace.Suppressed, relayURL)
372
+ trace.Reasons[relayURL] = "require_tcp"
373
continue
374
}
375
}
376
if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) {
377
+ trace.Suppressed = append(trace.Suppressed, relayURL)
378
+ trace.Reasons[relayURL] = "suppressed"
379
continue
380
}
381
autoPool = append(autoPool, state)
382
}
383
341
- autoURLs := p.rankRelayPool(autoPool, clientState.LocalAddress)
342
- maxActiveRelays := clientState.MaxActiveRelays
384
+ // Compute pool statistics before promotion.
385
+ avgRTT, cv := molsRTTStats(autoPool)
386
+ trace.AvgRTT = avgRTT
387
+ trace.CV = cv
388
+ congested := avgRTT > molsCongestionRTTThreshold
389
+ nonLinear := cv > molsCVThreshold
390
+ trace.Congested = congested
391
+ trace.NonLinear = nonLinear
392
+
393
+ m1, m2 := molsBaseM1, molsBaseM2
394
+ if nonLinear {
395
+ m1, m2 = molsVariantM1, molsVariantM2
396
+ }
397
+ trace.M1, trace.M2 = m1, m2
398
+
399
+ // Replicate the partition+promotion logic from rankRelayPool to determine
400
+ // which relays remain as fallbacks after the minimum-active-node promotion
401
+ // step. Demoted=true only for relays that stay in the fallback section after
402
+ // promotion (i.e., were not promoted to meet molsMinActiveNodes).
403
+ trActive := make([]RelayState, 0, len(autoPool))
404
+ trFallbacks := make([]RelayState, 0)
405
+ for _, state := range autoPool {
406
+ if isRelayFallback(state) {
407
+ trFallbacks = append(trFallbacks, state)
408
+ } else {
409
+ trActive = append(trActive, state)
410
+ }
411
+ }
412
+ // PoolFallback is counted before promotion (reflects raw slow-relay count).
413
+ trace.PoolEligible = len(autoPool)
414
+ trace.PoolFallback = len(trFallbacks)
415
+ if len(trActive) < molsMinActiveNodes && len(trFallbacks) > 0 {
416
+ promote := min(molsMinActiveNodes-len(trActive), len(trFallbacks))
417
+ trFallbacks = trFallbacks[promote:]
418
+ }
419
+
420
+ // Build a set of relay URLs that remain demoted (survive as fallbacks after promotion).
421
+ demotedURLs := make(map[string]bool, len(trFallbacks))
422
+ for _, s := range trFallbacks {
423
+ demotedURLs[s.Descriptor.APIHTTPSAddr] = true
424
+ }
425
+
426
+ // Build Ranked entries for all candidates in the auto pool.
427
+ ingressIdx := hashToGF64(cs.LocalAddress)
428
+ for _, state := range autoPool {
429
+ candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
430
+ var score int
431
+ if congested {
432
+ score = molsCongestionScore(ingressIdx, candidateIdx, m1, m2)
433
+ } else {
434
+ score = molsScore(ingressIdx, candidateIdx, m1, m2)
435
+ }
436
+ trace.Ranked = append(trace.Ranked, TraceEntry{
437
+ URL: state.Descriptor.APIHTTPSAddr,
438
+ Score: score,
439
+ Confirmed: state.Confirmed,
440
+ RTT: state.DiscoveryRTT,
441
+ Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr],
442
+ })
443
+ }
444
+
445
+ autoURLs := p.rankRelayPool(autoPool, cs.LocalAddress)
446
+ maxActiveRelays := cs.MaxActiveRelays
447
if maxActiveRelays <= 0 {
448
maxActiveRelays = defaultMaxActiveRelays
449
}
450
if len(autoURLs) > maxActiveRelays {
451
autoURLs = autoURLs[:maxActiveRelays]
452
}
349
- return append(explicit, autoURLs...)
453
+ result := append(explicit, autoURLs...)
454
+ trace.OutputURLs = result
455
+ trace.SelectionTook = time.Since(start)
456
+ return result, trace
457
}
458
352
-func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientState) []string {
353
- if clientState.MultiHopDepth <= 1 {
354
- return nil
459
+// SelectPriority returns the ordered list of relay URLs for a client using the
460
+// MOLS policy. It delegates to SelectPriorityWithTrace and discards the trace.
461
+func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
462
+ out, _ := p.SelectPriorityWithTrace(states, clientState)
463
+ return out
464
+}
465
+
466
+// SelectMultiHopWithTrace is the telemetry-instrumented sibling of
467
+// SelectMultiHop. It returns the same ordered relay list plus a SelectionTrace.
468
+// The returned OutputURLs slice is byte-identical to what SelectMultiHop
469
+// returns for the same inputs.
470
+//
471
+// Relays excluded by eligibility gates (no descriptor, expired, no overlay
472
+// peer, UDP/TCP mismatch, suppressed, banned) are recorded in
473
+// SelectionTrace.Suppressed / Reasons. PoolFallback reflects the fallback count
474
+// before the minimum-active-node promotion step.
475
+func (p MOLSRelayPolicy) SelectMultiHopWithTrace(states []RelayState, cs ClientState) ([]string, SelectionTrace) {
476
+ start := time.Now()
477
+ now := start.UTC()
478
+
479
+ trace := SelectionTrace{
480
+ Timestamp: start,
481
+ ClientHash: hashToGF64(cs.LocalAddress),
482
+ Mode: "multihop",
483
+ PoolTotal: len(states),
484
+ Reasons: make(map[string]string),
485
+ }
486
+
487
+ if cs.MultiHopDepth <= 1 {
488
+ trace.SelectionTook = time.Since(start)
489
+ return nil, trace
490
+ }
491
+
492
+ // Record banned relays before SelectAggregate strips them.
493
+ for _, state := range states {
494
+ if state.Banned {
495
+ url := state.Descriptor.APIHTTPSAddr
496
+ trace.Suppressed = append(trace.Suppressed, url)
497
+ trace.Reasons[url] = "banned"
498
+ }
499
}
500
501
selected := p.SelectAggregate(states)
502
if len(selected) == 0 {
359
- return nil
503
+ trace.SelectionTook = time.Since(start)
504
+ return nil, trace
505
}
506
362
- now := time.Now().UTC()
507
autoPool := make([]RelayState, 0, len(selected))
508
for _, state := range selected {
365
- if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
509
+ relayURL := state.Descriptor.APIHTTPSAddr
510
+ if cs.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
511
+ trace.Suppressed = append(trace.Suppressed, relayURL)
512
+ trace.Reasons[relayURL] = "require_udp"
513
+ continue
514
+ }
515
+ if cs.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
516
+ trace.Suppressed = append(trace.Suppressed, relayURL)
517
+ trace.Reasons[relayURL] = "require_tcp"
518
+ continue
519
+ }
520
+ if !state.hasObservedDescriptor() {
521
+ trace.Suppressed = append(trace.Suppressed, relayURL)
522
+ trace.Reasons[relayURL] = "no_descriptor"
523
continue
524
}
368
- if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
525
+ if !state.Descriptor.ExpiresAt.After(now) {
526
+ trace.Suppressed = append(trace.Suppressed, relayURL)
527
+ trace.Reasons[relayURL] = "expired"
528
continue
529
}
371
- if !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
530
+ if !state.Descriptor.HasOverlayPeer() {
531
+ trace.Suppressed = append(trace.Suppressed, relayURL)
532
+ trace.Reasons[relayURL] = "no_overlay_peer"
533
continue
534
}
535
if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) {
536
+ trace.Suppressed = append(trace.Suppressed, relayURL)
537
+ trace.Reasons[relayURL] = "suppressed"
538
continue
539
}
540
autoPool = append(autoPool, state)
541
}
542
380
- multiHop := p.rankRelayPool(autoPool, clientState.LocalAddress)
381
- if len(multiHop) > clientState.MultiHopDepth {
382
- multiHop = multiHop[:clientState.MultiHopDepth]
543
+ // Compute pool statistics before promotion.
544
+ avgRTT, cv := molsRTTStats(autoPool)
545
+ trace.AvgRTT = avgRTT
546
+ trace.CV = cv
547
+ congested := avgRTT > molsCongestionRTTThreshold
548
+ nonLinear := cv > molsCVThreshold
549
+ trace.Congested = congested
550
+ trace.NonLinear = nonLinear
551
+
552
+ m1, m2 := molsBaseM1, molsBaseM2
553
+ if nonLinear {
554
+ m1, m2 = molsVariantM1, molsVariantM2
555
+ }
556
+ trace.M1, trace.M2 = m1, m2
557
+
558
+ // Replicate the partition+promotion logic from rankRelayPool to determine
559
+ // which relays remain as fallbacks after the minimum-active-node promotion
560
+ // step. Demoted=true only for relays that stay in the fallback section after
561
+ // promotion (i.e., were not promoted to meet molsMinActiveNodes).
562
+ mhActive := make([]RelayState, 0, len(autoPool))
563
+ mhFallbacks := make([]RelayState, 0)
564
+ for _, state := range autoPool {
565
+ if isRelayFallback(state) {
566
+ mhFallbacks = append(mhFallbacks, state)
567
+ } else {
568
+ mhActive = append(mhActive, state)
569
+ }
570
+ }
571
+ // PoolFallback is counted before promotion (reflects raw slow-relay count).
572
+ trace.PoolEligible = len(autoPool)
573
+ trace.PoolFallback = len(mhFallbacks)
574
+ if len(mhActive) < molsMinActiveNodes && len(mhFallbacks) > 0 {
575
+ promote := min(molsMinActiveNodes-len(mhActive), len(mhFallbacks))
576
+ mhFallbacks = mhFallbacks[promote:]
577
+ }
578
+
579
+ // Build a set of relay URLs that remain demoted (survive as fallbacks after promotion).
580
+ mhDemotedURLs := make(map[string]bool, len(mhFallbacks))
581
+ for _, s := range mhFallbacks {
582
+ mhDemotedURLs[s.Descriptor.APIHTTPSAddr] = true
583
+ }
584
+
585
+ // Build Ranked entries for all candidates in the auto pool.
586
+ ingressIdx := hashToGF64(cs.LocalAddress)
587
+ for _, state := range autoPool {
588
+ candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
589
+ var score int
590
+ if congested {
591
+ score = molsCongestionScore(ingressIdx, candidateIdx, m1, m2)
592
+ } else {
593
+ score = molsScore(ingressIdx, candidateIdx, m1, m2)
594
+ }
595
+ trace.Ranked = append(trace.Ranked, TraceEntry{
596
+ URL: state.Descriptor.APIHTTPSAddr,
597
+ Score: score,
598
+ Confirmed: state.Confirmed,
599
+ RTT: state.DiscoveryRTT,
600
+ Demoted: mhDemotedURLs[state.Descriptor.APIHTTPSAddr],
601
+ })
602
+ }
603
+
604
+ multiHop := p.rankRelayPool(autoPool, cs.LocalAddress)
605
+ if len(multiHop) > cs.MultiHopDepth {
606
+ multiHop = multiHop[:cs.MultiHopDepth]
607
}
384
- return multiHop
608
+ trace.OutputURLs = multiHop
609
+ trace.SelectionTook = time.Since(start)
610
+ return multiHop, trace
611
+}
612
+
613
+// SelectMultiHop returns the ordered list of relay URLs for multi-hop routing.
614
+// It delegates to SelectMultiHopWithTrace and discards the trace.
615
+func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientState) []string {
616
+ out, _ := p.SelectMultiHopWithTrace(states, clientState)
617
+ return out
618
}
portal/discovery/mols_test.go
+238
@@ -603,3 +603,241 @@ func TestMOLSRTTStatsEmpty(t *testing.T) {
603
t.Fatalf("molsRTTStats(nil) = (%v, %v), want (0, 0)", mean, cv)
604
}
605
}
606
+
607
+// overlayPolicyRelayState returns a confirmed relay state whose descriptor
608
+// satisfies HasOverlayPeer() — required for SelectMultiHop eligibility.
609
+func overlayPolicyRelayState(t *testing.T, relayURL string) RelayState {
610
+ t.Helper()
611
+ state := confirmedPolicyRelayState(t, relayURL)
612
+ state.Descriptor.SupportsOverlay = true
613
+ state.Descriptor.WireGuardPublicKey = "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTA=" // non-empty placeholder
614
+ state.Descriptor.WireGuardPort = 51820
615
+ return state
616
+}
617
+
618
+// selectionCase is a shared table row for TestMOLSWithTraceByteEqualToLegacy.
619
+type selectionCase struct {
620
+ name string
621
+ states []RelayState
622
+ cs ClientState
623
+}
624
+
625
+// assertByteEqual verifies that legacy and withTrace slices are identical and
626
+// that trace.OutputURLs matches legacy. It also checks mode and PoolTotal.
627
+func assertByteEqual(t *testing.T, mode string, states []RelayState, legacy []string, withTrace []string, trace SelectionTrace) {
628
+ t.Helper()
629
+ if len(legacy) != len(withTrace) {
630
+ t.Fatalf("return-value length mismatch: legacy=%d withTrace=%d", len(legacy), len(withTrace))
631
+ }
632
+ for i := range legacy {
633
+ if legacy[i] != withTrace[i] {
634
+ t.Fatalf("return-value[%d]: legacy=%q withTrace=%q", i, legacy[i], withTrace[i])
635
+ }
636
+ }
637
+ if len(legacy) != len(trace.OutputURLs) {
638
+ t.Fatalf("OutputURLs length mismatch: legacy=%d trace=%d", len(legacy), len(trace.OutputURLs))
639
+ }
640
+ for i := range legacy {
641
+ if legacy[i] != trace.OutputURLs[i] {
642
+ t.Fatalf("OutputURLs[%d]: legacy=%q trace=%q", i, legacy[i], trace.OutputURLs[i])
643
+ }
644
+ }
645
+ if trace.Mode != mode {
646
+ t.Fatalf("Mode = %q, want %q", trace.Mode, mode)
647
+ }
648
+ if trace.PoolTotal != len(states) {
649
+ t.Fatalf("PoolTotal = %d, want %d", trace.PoolTotal, len(states))
650
+ }
651
+}
652
+
653
+// TestMOLSWithTraceByteEqualToLegacy asserts that for every test scenario the
654
+// WithTrace variants produce OutputURLs that are byte-identical to the
655
+// corresponding legacy methods. This is Phase 1 acceptance criterion #1
656
+// ("Golden no-behavior-change").
657
+//
658
+// Priority scenarios mirror the existing TestMOLSSelectPriority* inputs.
659
+// MultiHop scenarios are fresh (no pre-existing TestMOLSSelectMultiHop* exist)
660
+// and cover the main eligibility branches.
661
+func TestMOLSWithTraceByteEqualToLegacy(t *testing.T) {
662
+ policy := MOLSRelayPolicy{}
663
+
664
+ t.Run("priority", func(t *testing.T) {
665
+ explicitURL := "https://relay-explicit.example"
666
+ relayA := "https://relay-a.example"
667
+ relayB := "https://relay-b.example"
668
+
669
+ tenRelays := make([]RelayState, 10)
670
+ for i := range tenRelays {
671
+ tenRelays[i] = confirmedPolicyRelayState(t, fmt.Sprintf("https://relay-%d.example", i))
672
+ }
673
+
674
+ healthy1 := confirmedPolicyRelayState(t, "https://relay-healthy-1.example")
675
+ healthy1.DiscoveryRTT = 100 * time.Millisecond
676
+ healthy1.DiscoveryRTTAt = time.Now()
677
+
678
+ healthy2 := confirmedPolicyRelayState(t, "https://relay-healthy-2.example")
679
+ healthy2.DiscoveryRTT = 150 * time.Millisecond
680
+ healthy2.DiscoveryRTTAt = time.Now()
681
+
682
+ fallback := confirmedPolicyRelayState(t, "https://relay-fallback.example")
683
+ fallback.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond
684
+ fallback.DiscoveryRTTAt = time.Now()
685
+
686
+ fallback1 := confirmedPolicyRelayState(t, "https://relay-fallback-1.example")
687
+ fallback1.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond
688
+ fallback1.DiscoveryRTTAt = time.Now()
689
+ fallback2 := confirmedPolicyRelayState(t, "https://relay-fallback-2.example")
690
+ fallback2.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond
691
+ fallback2.DiscoveryRTTAt = time.Now()
692
+
693
+ r1 := confirmedPolicyRelayState(t, "https://relay-one.example")
694
+ r2 := confirmedPolicyRelayState(t, "https://relay-two.example")
695
+ rttHigh := molsCongestionRTTThreshold + 100*time.Millisecond
696
+ r1c := r1
697
+ r1c.DiscoveryRTT = rttHigh
698
+ r1c.DiscoveryRTTAt = time.Now()
699
+ r2c := r2
700
+ r2c.DiscoveryRTT = rttHigh
701
+ r2c.DiscoveryRTTAt = time.Now()
702
+
703
+ r1v := confirmedPolicyRelayState(t, "https://relay-one.example")
704
+ r1v.DiscoveryRTT = 100 * time.Millisecond
705
+ r1v.DiscoveryRTTAt = time.Now()
706
+ r2v := confirmedPolicyRelayState(t, "https://relay-two.example")
707
+ r2v.DiscoveryRTT = 400 * time.Millisecond
708
+ r2v.DiscoveryRTTAt = time.Now()
709
+
710
+ rAlpha := confirmedPolicyRelayState(t, "https://relay-alpha.example")
711
+ rBeta := confirmedPolicyRelayState(t, "https://relay-beta.example")
712
+ rGamma := confirmedPolicyRelayState(t, "https://relay-gamma.example")
713
+
714
+ expired := confirmedPolicyRelayState(t, "https://relay-expired.example")
715
+ expired.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute)
716
+
717
+ expExplicit := confirmedPolicyRelayState(t, "https://relay-explicit-expired.example")
718
+ expExplicit.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute)
719
+
720
+ backoff := confirmedPolicyRelayState(t, "https://relay-backoff.example")
721
+ backoff.suppressActiveUntil = time.Now().UTC().Add(time.Minute)
722
+
723
+ discBackoff := confirmedPolicyRelayState(t, "https://relay-discovery-backoff.example")
724
+ discBackoff.nextDiscoveryRefreshAt = time.Now().UTC().Add(time.Minute)
725
+
726
+ cases := []selectionCase{
727
+ {name: "nil_pool", states: nil, cs: ClientState{}},
728
+ {
729
+ name: "explicit_outside_auto_limit",
730
+ states: []RelayState{
731
+ bootstrapPolicyRelayState(explicitURL),
732
+ confirmedPolicyRelayState(t, relayA),
733
+ confirmedPolicyRelayState(t, relayB),
734
+ },
735
+ cs: ClientState{ExplicitRelayURLs: []string{explicitURL}, MaxActiveRelays: 1},
736
+ },
737
+ {
738
+ name: "deterministic_fixed_address",
739
+ states: []RelayState{
740
+ confirmedPolicyRelayState(t, "https://relay-a.example"),
741
+ confirmedPolicyRelayState(t, "https://relay-b.example"),
742
+ confirmedPolicyRelayState(t, "https://relay-c.example"),
743
+ },
744
+ cs: ClientState{LocalAddress: "0x1234abcd"},
745
+ },
746
+ {name: "fallback_relays_demoted", states: []RelayState{fallback, healthy1, healthy2}, cs: ClientState{}},
747
+ {name: "min_active_nodes_promotes_fallback", states: []RelayState{fallback1, fallback2}, cs: ClientState{}},
748
+ {name: "congestion_switch", states: []RelayState{r1c, r2c}, cs: ClientState{LocalAddress: "ingress-test"}},
749
+ {name: "variant_grid_high_cv", states: []RelayState{r1v, r2v}, cs: ClientState{LocalAddress: "ingress-cv"}},
750
+ {name: "different_ingress_addresses", states: []RelayState{rAlpha, rBeta, rGamma}, cs: ClientState{LocalAddress: "0xabc"}},
751
+ {name: "max_active_relays_cap", states: tenRelays, cs: ClientState{MaxActiveRelays: 3}},
752
+ {name: "zero_max_active_uses_default", states: tenRelays, cs: ClientState{MaxActiveRelays: 0}},
753
+ {name: "skip_expired_auto_relay", states: []RelayState{expired}, cs: ClientState{}},
754
+ {
755
+ name: "keep_expired_explicit_relay",
756
+ states: []RelayState{expExplicit},
757
+ cs: ClientState{ExplicitRelayURLs: []string{expExplicit.Descriptor.APIHTTPSAddr}},
758
+ },
759
+ {name: "skip_auto_relay_in_backoff", states: []RelayState{backoff}, cs: ClientState{}},
760
+ {name: "keep_discovery_backoff_relay", states: []RelayState{discBackoff}, cs: ClientState{}},
761
+ {name: "keep_unobserved_seed", states: []RelayState{bootstrapPolicyRelayState("https://relay-seed.example")}, cs: ClientState{}},
762
+ {name: "normal_mode_no_rtt", states: []RelayState{r1, r2}, cs: ClientState{LocalAddress: "ingress-test"}},
763
+ }
764
+
765
+ for _, tc := range cases {
766
+ t.Run(tc.name, func(t *testing.T) {
767
+ legacy := policy.SelectPriority(tc.states, tc.cs)
768
+ withTrace, trace := policy.SelectPriorityWithTrace(tc.states, tc.cs)
769
+ assertByteEqual(t, "priority", tc.states, legacy, withTrace, trace)
770
+ // min_active_nodes_promotes_fallback: both fallbacks are promoted
771
+ // into the active section, so no Ranked entry should be Demoted.
772
+ if tc.name == "min_active_nodes_promotes_fallback" {
773
+ for i, entry := range trace.Ranked {
774
+ if entry.Demoted {
775
+ t.Errorf("Ranked[%d] (%q): Demoted=true but relay was promoted to active; want false", i, entry.URL)
776
+ }
777
+ }
778
+ }
779
+ // fallback_relays_demoted: the fallback relay (healthy1/healthy2 present,
780
+ // so no promotion occurs) must appear as Demoted=true in Ranked.
781
+ if tc.name == "fallback_relays_demoted" {
782
+ const fallbackURL = "https://relay-fallback.example"
783
+ found := false
784
+ for i, entry := range trace.Ranked {
785
+ if entry.URL == fallbackURL {
786
+ found = true
787
+ if !entry.Demoted {
788
+ t.Errorf("Ranked[%d] (%q): Demoted=false but relay stays in fallback section; want true", i, entry.URL)
789
+ }
790
+ }
791
+ }
792
+ if !found {
793
+ t.Errorf("fallback relay %q not found in trace.Ranked", fallbackURL)
794
+ }
795
+ }
796
+ })
797
+ }
798
+ })
799
+
800
+ t.Run("multihop", func(t *testing.T) {
801
+ ovA := overlayPolicyRelayState(t, "https://mh-relay-a.example")
802
+ ovB := overlayPolicyRelayState(t, "https://mh-relay-b.example")
803
+ ovC := overlayPolicyRelayState(t, "https://mh-relay-c.example")
804
+
805
+ // noDescRelay: hasObservedDescriptor()==false (LastSeenAt zero).
806
+ noDescRelay := newRelayState("https://mh-nodesc.example")
807
+
808
+ bannedRelay := confirmedPolicyRelayState(t, "https://mh-banned.example")
809
+ bannedRelay.Banned = true
810
+
811
+ suppressedRelay := overlayPolicyRelayState(t, "https://mh-suppressed.example")
812
+ suppressedRelay.suppressActiveUntil = time.Now().UTC().Add(time.Minute)
813
+
814
+ // noOverlayRelay: hasObservedDescriptor()==true but HasOverlayPeer()==false.
815
+ noOverlayRelay := confirmedPolicyRelayState(t, "https://mh-no-overlay.example")
816
+
817
+ expiredRelay := overlayPolicyRelayState(t, "https://mh-expired.example")
818
+ expiredRelay.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute)
819
+
820
+ cases := []selectionCase{
821
+ {name: "depth_zero_returns_nil", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 0}},
822
+ {name: "depth_one_returns_nil", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 1}},
823
+ {name: "nil_pool", states: nil, cs: ClientState{MultiHopDepth: 2}},
824
+ {name: "empty_pool_after_aggregate", states: []RelayState{bannedRelay}, cs: ClientState{MultiHopDepth: 2}},
825
+ {name: "eligible_pool_depth_2", states: []RelayState{ovA, ovB, ovC}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-1"}},
826
+ {name: "eligible_pool_depth_3", states: []RelayState{ovA, ovB, ovC}, cs: ClientState{MultiHopDepth: 3, LocalAddress: "client-2"}},
827
+ {name: "depth_exceeds_pool_size", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 5, LocalAddress: "client-3"}},
828
+ {name: "skip_no_descriptor", states: []RelayState{noDescRelay, ovA}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-4"}},
829
+ {name: "skip_expired", states: []RelayState{expiredRelay, ovB}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-5"}},
830
+ {name: "skip_no_overlay_peer", states: []RelayState{noOverlayRelay, ovC}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-6"}},
831
+ {name: "skip_suppressed", states: []RelayState{suppressedRelay, ovA}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-7"}},
832
+ {name: "all_ineligible_returns_nil", states: []RelayState{expiredRelay, noDescRelay, noOverlayRelay}, cs: ClientState{MultiHopDepth: 2}},
833
+ }
834
+
835
+ for _, tc := range cases {
836
+ t.Run(tc.name, func(t *testing.T) {
837
+ legacy := policy.SelectMultiHop(tc.states, tc.cs)
838
+ withTrace, trace := policy.SelectMultiHopWithTrace(tc.states, tc.cs)
839
+ assertByteEqual(t, "multihop", tc.states, legacy, withTrace, trace)
840
+ })
841
+ }
842
+ })
843
+}