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 +}