feat(discovery): Phase 3 multi-hop role separation + opt-in /16 diversity - #239
feat(discovery): Phase 3 multi-hop role separation + opt-in /16 diversity#239metaphorics wants to merge 5 commits into
Conversation
…ity_relaxed_total metric
buildCandidates previously extended the inner selector's candidate list
by appending pool entries not in innerURLs. The raw pool contains
banned, expired, and suppressed relays — exactly the entries the inner
selector excluded during eligibility filtering. With AnonymityGrade
enabled this could surface a banned relay as the third hop in a
diversity walk when only the banned relay had a distinct Subnet16.
Fix: replace pool []RelayState with ranked []TraceEntry as the extras
source. MOLS populates trace.Ranked exclusively from autoPool
(post-eligibility-filter), so every entry in Ranked is an eligible
relay. The extras sort remains by URL for determinism.
Add TestDiversityBannedRelayExcludedFromExtras: pool of 2 eligible
relays sharing Subnet16 "10.1" plus 1 banned relay with Subnet16
"10.2", AnonymityGrade=true, depth=3. Asserts:
- banned URL never appears in output
- output length == 2 (only eligible relays returned after relaxation)
- portal_discovery_diversity_relaxed_total{reason=anonymity_grade}
increments by exactly 1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: efb5664683
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| extras = append(extras, u) | ||
| } | ||
| } | ||
| sort.Strings(extras) |
There was a problem hiding this comment.
Preserve inner ranking when choosing diversity extras
After a diversity rejection, replacement hops are taken from extras, but this code sorts those URLs lexicographically instead of keeping the inner selector’s priority order. In multi-hop mode with weighted.New(...) wrapped by diversity, this can select a higher-load relay purely because its URL sorts earlier, which undermines the weighted policy exactly when replacements are needed (e.g., subnet/family collision filtering).
Useful? React with 👍 / 👎.
| // Attempt 1: full constraints (role-separation + AnonymityGrade if set). | ||
| out, ok := walk(candidates, stateByURL, client) | ||
| if ok { |
There was a problem hiding this comment.
Bypass diversity wrapper when all constraints are disabled
The wrapper still rewrites SelectMultiHop output even when DisableDiversityRoles=true and AnonymityGrade=false, because it always runs walk(...) over innerURLs + extras. If an inner selector intentionally returns a trimmed list shorter than MultiHopDepth, this path backfills from trace.Ranked and changes routing despite both diversity controls being off, violating the expected no-op semantics of disabling all diversity constraints.
Useful? React with 👍 / 👎.
Phase 3 of the discovery rationalization
Builds on PR #237 (Phase 2 weighted composite + Selector seam). Adds multi-hop role separation (default-on) and opt-in
/16+ family diversity via a wrap-arounddiversity.Diversityselector. Stacked PR: base =discovery-phase-2-weighted-selector.Plan-deferred caveat (read this)
Phase 3's plan explicitly stated detailed design was deferred until Phase 2 telemetry validates the gap each phase is meant to close. This PR ships Phase 3 ahead of that data per user direction ("all four phases now"). The two open questions Phase 2 telemetry was meant to answer remain open:
/16 + familyconstraint force pool relaxation given typical pool sizes? (If always, the opt-in flag is misleading.)Phase 3 ships the mechanism; the necessity is to be validated by Phase 1+2 telemetry once production data accumulates.
What landed (5 atomic commits)
35b4b8cefeat(types): add Family + Subnet16 fields to RelayDescriptor— advisory unsigned fields.Family string(operator-family bucket key),Subnet16 string(/16 prefix bucket key). Empty = no constraint contribution.CanonicalBytesdeliberately unchanged so existing signed descriptors verify byte-identically.a6b63f5dfeat(discovery): add ClientState.DisableDiversityRoles + AnonymityGrade fields— inverted-default field name (zero ClientState gets role separation on). Documented why the inversion exists: most call sites use zero-value ClientState today, and the safe default for routing is "no duplicate hops."04e6b68dfeat(discovery/diversity): selector wrapper + portal_discovery_diversity_relaxed_total metric— new packageportal/discovery/selectors/diversity/.Diversity.SelectPriorityis passthrough (no diversity work for single-hop);Diversity.SelectMultiHopwalks inner-ranked URLs, applies role+anonymity dedup, relaxes on shortfall. New counterportal_discovery_diversity_relaxed_total{reason}with cardinality bounded to two reason values:anonymity_grade,role_separation.47d24227test(discovery/diversity): unit tests + contract wiring + load-test extension— 7 unit/contract tests +selectortest.Contractwiring againstdiversity.New(mols.New())+ new-anonymityand-anonymity-collideflags oncmd/portal-loadtest/main.go.efb56646fix(discovery/diversity): use trace.Ranked for extras, not raw pool— eligibility-leak fix:buildCandidatesoriginally used the rawpool(which includes banned/expired/suppressed relays) as the source of "extras" when the inner output ran short. Switched totrace.Ranked(eligible-only). AddedTestDiversityBannedRelayExcludedFromExtrasas a discriminating test (would fail on the original code).Verification (local)
go build ./...exit 0go vet ./...exit 0make lintexit 0 (0 issues)go test -count=1 ./...exit 0 (243 tests pass across 24 packages, up from Phase 2's 222 in 23)go mod verifycleanPhase 3 acceptance — runtime evidence
go run ./cmd/portal-loadtest -clients 100 -relays 5 -multi-hop 3 -anonymity(each relay assigned a unique synthetic Subnet16):go run ./cmd/portal-loadtest -clients 100 -relays 5 -multi-hop 3 -anonymity -anonymity-collide(all 5 relays forced into the same Subnet16 — should trigger 100% relaxation):Both runs match plan acceptance:
Public API
RelaySet.PriorityRelays,RelaySet.PriorityMultiHop, and the Phase 1*WithTracesiblings preserved byte-equal.diversity.Diversity(Selector wrapper),diversity.New,diversity.Option.RelayDescriptor.Family,RelayDescriptor.Subnet16,ClientState.DisableDiversityRoles,ClientState.AnonymityGrade.discovery.DiversityRelaxedTotal(counter-vec; labelreason ∈ {anonymity_grade, role_separation}).RelayDescriptor.CanonicalBytesunchanged — Family + Subnet16 are advisory fields; legacy signed descriptors continue to verify.Algorithm sketch (the actual implemented one)
Out of scope (deferred to Phase 4)