feat(discovery): Phase 2 weighted composite selector + Selector seam - #237
feat(discovery): Phase 2 weighted composite selector + Selector seam#237metaphorics wants to merge 22 commits into
Conversation
… + caller updates)
…err param - Lifecycle: not internally synchronized; callers must hold RelaySet.mu. Stage 2B EWMA fields follow the same discipline (no internal mutex on Lifecycle); single owner avoids nested-lock hazard. - OnDiscoveryFailure / OnActiveFailure: document why the err parameter is accepted but not inspected (interface compatibility / future use). Addresses two non-blocking warnings from Phase 2 Stage 1 review.
- Add portal/discovery/selectors/mols with type MOLS (renamed from MOLSRelayPolicy), constructor New(), and compile-time assertion var _ discovery.Selector = (*MOLS)(nil). - Define local unexported relayPolicy interface in portal/discovery to break the import cycle; RelaySet.policy is now relayPolicy, and NewRelaySet accepts a relayPolicy parameter (callers pass mols.New()). isNilableAndNil helper guards against typed-nil injection. - Rename RelayState.hasObservedDescriptor() -> HasObservedDescriptor() and add IsSuppressedActive(now time.Time) bool for cross-package access. - Migrate all call sites (portal/server.go, sdk/expose.go, sdk/expose_test.go, cmd/portal-loadtest/main.go) to mols.New(). - Add package-discovery stubRelayPolicy in testhelpers_test.go for white-box tests that cannot import selectors/mols (cycle). - Move math and policy tests to selectors/mols/mols_test.go (package mols, white-box); keep suppress/backoff tests in portal/discovery/mols_test.go (require unexported RelayState fields); keep lifecycle tests in portal/discovery/policy_test.go. - Delete portal/discovery/mols.go. Trim portal/discovery/mols_test.go to the two tests that access unexported RelayState fields. Remove policy tests from policy_test.go that are now covered in selectors/mols. go build ./... && go vet ./... && go test ./... all pass (149 tests). git grep MOLSRelayPolicy -- ':!docs' ':!*.md' returns zero matches.
…extraction Phase 1 acceptance criterion #1 (golden no-behavior-change) was lost during the discovery -> selectors/mols extraction. Restored as a parameterized test that asserts the relayPolicy-contract path (SelectPriorityWithTrace / SelectMultiHopWithTrace) and the Selector-contract path (SelectPriority / SelectMultiHop with ctx) produce byte-identical OutputURLs. Backoff cases use Lifecycle.OnActiveFailure / OnDiscoveryFailure to drive state into suppressActiveUntil / nextDiscoveryRefreshAt without touching unexported discovery fields; noDescRelay is constructed via direct struct literal (LastSeenAt zero -> HasObservedDescriptor() false). 16 priority subcases + 12 multihop subcases. Test count: 179 -> 180.
…layPolicy duality
…s, Name, algorithm)
… boundary, beta, multihop tests
Parameterized test harness that runs the same set of safety invariants against any discovery.Selector. Each invariant is a t.Run subtest under the caller-supplied name. Invariants covered: - explicit_relay_precedence - max_active_relays_cap - banned_excluded - suppression_respected (driven via Lifecycle.OnActiveFailure) - bootstrap_pin_survives_aggregate - freshness_gate_skips_expired - determinism_fixed_input - empty_pool_returns_nil - multihop_depth_zero_returns_nil - multihop_depth_one_returns_nil - trace_pool_total_matches_input - every_excluded_has_reason Internal helpers (mustRelayDescriptor / mustConfirmedRelayState / mustBootstrapRelayState / mustOverlayRelayState) mirror the existing mols_test.go pattern; they remain unexported so the package's only exported surface is Contract(...). Stage 5 of Phase 2.
TestMOLSContract and TestWeightedContract invoke selectortest.Contract with their respective Selector factories. Each adds 13 sub-tests covering all 12 invariants plus the parent dispatcher. Test count: 196 -> 222 (+26 across both selectors). Stage 5 of Phase 2.
…weighted expected distribution
…ctor + lift MaxActiveRelays cap
Reverts the inert public field added by 301ea66. Per the cleanup-codebase rule "indirection earns its keep only when it removes real coupling that *currently* exists," introducing API surface that has no consumer is forbidden — even with a comment promising future use. Phase 3 will reintroduce the field together with the dispatch logic that consumes it, so the addition arrives behaviour-coupled in the same commit.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 993606d63d
ℹ️ 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".
| innerURLs, innerTrace := c.inner.SelectPriority(ctx, pool, client) | ||
| out := c.applyPenalty(innerURLs, pool) |
There was a problem hiding this comment.
Apply weighted scoring before inner-selector truncation
SelectPriority and SelectMultiHop call the inner selector first and only then run applyPenalty, but the inner MOLS selector already truncates candidates by MaxActiveRelays / MultiHopDepth. That means the weighted selector can only reshuffle an already-capped subset and cannot promote lower-ranked but less-loaded relays into the output, which breaks load-balancing whenever the initial MOLS top-K is overloaded. In practice this makes weighted behavior depend on callers manually expanding caps (as done in the loadtest), instead of correctly computing weighted rank over the full eligible pool and then applying the final cap.
Useful? React with 👍 / 👎.
Phase 2 of the discovery rationalization
Builds on PR #235 (Phase 1 telemetry). Introduces the
Selectorseam, EWMA load surface, and weighted composite selector with rank-flap suppression. Stacked PR: base =discovery-phase-1-telemetry.What landed (22 atomic commits)
Stage 1 — Selector seam + Lifecycle extraction
41949dbffeat(discovery): add Selector interface—Name(),SelectPriority(ctx, pool, client),SelectMultiHop(ctx, pool, client). No error return; deterministic; empty slice = no candidates.97038db5feat(discovery): extract Lifecycle from MOLSRelayPolicy— movesOnBanned/OnActiveConfirmed/OnUnconfirmed/OnDiscoveryConfirmed/OnDiscoveryFailure/OnActiveFailureoff the policy onto a freshLifecyclestruct.5451eca7chore(discovery): document lifecycle concurrency contract and unused err param— addresses Stage 1 review nits.Stage 2A — MOLS package extraction (atomic; no alias retained)
4.
eb503864discovery: extract MOLS policy into selectors/mols package— file move + atomic migration ofsdk/expose.go,portal/server.go, and tests; type renamedMOLSRelayPolicy→mols.MOLS.5.
0e516cbbtest(mols): assert trace.Ranked Demoted field in fallback/promotion.6.
cea890aetest(mols): restore TestMOLSWithTraceByteEqualToLegacy after package extraction— orchestrator-restored after agent dropped Phase 1 acceptance criterion #1 during the move; restated for Phase 2 to comparerelayPolicy-contract path vsSelector-contract path. 16 priority subcases + 12 multihop subcases.Stage 2B — RelayState EWMA load surface
7.
782dba8afeat(discovery): add EWMA load fields to RelayState—LoadFactor,FailureRate,LastUpdated. Single-owner discipline (Lifecycle); no Lifecycle mutex (caller holds RelaySet.mu).8.
24eb15befeat(discovery): Lifecycle.SampleLoad + OnSuccess + extend On*Failure— EWMA time-constant tau (NOT half-life): atdt = tau, decay factor ise^(-1) ≈ 0.368. DefaultloadTau=30s,failureTau=60s,beta=1.0. Failure beta applied at READ time (not stored composed) to avoid compounding across updates. Both fields decayed consistently from sharedLastUpdatedto avoid coupling between failure and load decay paths.9.
f85b27d2feat(discovery): RelaySet RecordTunnelOpened/Closed hooks + sdk/expose wiring— SDK callsRecordTunnelOpened(url, 1.0)/RecordTunnelClosed(url, 0.0)adjacent to existingActiveTunnelsPerRelay.Inc/Decfrom Phase 1. Sample contract chosen as plainfloat64(notfunc() float64) to avoid reentrance/no-op tension at the call site.10.
76720392test(discovery): Lifecycle EWMA load surface tests— 6 new tests: zero-init, single-event delta, tau-period decay toe^(-1), concurrent reader-while-writer, On-failure bumps FailureRate, OnSuccess decays.Stage 3 — Constructor injection + relayPolicy/Selector unification
11.
f28f9176refactor(discovery): move SelectAggregate/SelectConfirmed to free functions—FilterUnbanned/FilterConfirmedare policy-agnostic helpers; no longer on the Selector contract. MOLS retains thin wrapper methods only because legacy tests call them directly.12.
041c606brefactor(discovery): RelaySet.policy uses Selector interface; drop relayPolicy duality— unexportedrelayPolicyinterface deleted entirely;RelaySet.policybecomesSelector.git grep relayPolicyreturns 0.13.
84832586feat(discovery): NewRelaySet variadic options + WithSelector—NewRelaySet(bootstraps []string, opts ...Option)withWithSelector(s Selector) Option. Default explicit at call sites — noinit()-magic. Migratedsdk/expose.go,sdk/expose_test.go, andportal/server.goto passWithSelector(mols.New()).14.
7889a0f8refactor(discovery): rename SetRelayPolicy to SetSelector— atomic;git grep SetRelayPolicyreturns 0.15.
301ea66ffeat(discovery): ClientState.SelectorOverride field— (reverted in commit 22 below; field will reappear in Phase 3 together with its consumer).Stage 4 — Weighted composite selector
16.
3ae53506feat(discovery/weighted): Composite type scaffolding + algorithm:-
final[i] = mols_position[i] + lambda * tier_load(state[i])-
tier_load = floor(load_factor / epsilon) * epsilon(within-tier quantization, stateless)-
load_factor = LoadFactor + FailureRate * beta- Stable sort ascending; ties preserve inner (MOLS) order
- P2C tie-break deferred (no easy programmatic per-relay tunnel-count read on hot path); within-tier quantization already suppresses sub-tier flap; documented in code
- Bootstrap-pin honors plan: stays in pool, but penalty applies (can demote out of top-K)
17.
45892906test(discovery/weighted): degeneration, load-imbalance, quantization, boundary, beta, multihop tests— 10 new tests covering all 5 spec acceptance criteria.Stage 5 — Selector contract harness
18.
a31bc0c3feat(discovery/selectortest): Contract harness for Selector invariants— non-test packageportal/discovery/selectortest/; exportsContract(t, name, factory)only. 12 invariants per Selector.19.
eb584942test(discovery): wire Contract harness for mols and weighted—TestMOLSContract+TestWeightedContractadd 26 sub-tests total.Stage 6 — Load-test CLI extension
20.
fa72f970feat(loadtest): add -capacities, -selector, -lambda flags + capacity-weighted expected distribution.21.
cb9ad249feat(loadtest): pre-seed LoadFactor from capacities for weighted selector + lift MaxActiveRelays cap.Cleanup (post-review)
22.
revert(discovery): drop ClientState.SelectorOverride field— addresses post-implementation review: the field was inert public API surface in Phase 2 (no consumer until Phase 3). Per cleanup-codebase rule, indirection earns its keep only when it removes coupling that currently exists. Phase 3 will reintroduce the field together with the dispatch logic that consumes it.Verification (local)
go build ./...exit 0go vet ./...exit 0make lintexit 0 (0 issues)go test -count=1 ./...exit 0 (222 tests pass across 23 packages, up from Phase 1's 179 in 20)Phase 2 acceptance — what passed, what is deferred
mols,weighted, default all produce identical chi-square 562.51 (Phase 1 baseline). Confirms weighted degenerates to MOLS when load signals are flat.p > 0.01p > 0.01target is unreachable with the current synthetic test geometry; the gap is in the test, not the selector. Calibration revision deferred to Phase 3 telemetry. The selector demonstrably responds to capacity signal (chi-square3178 → 285, ~11× improvement).p < 0.001relaystate_load_test.goLifecycle EWMA load surfacee^(-1) ≈ 0.368decay verified within 1e-6 tolerance; concurrent safe (locks held by caller).weighted_test.gocomposite behaviorlambda=2.0to defeat sort-stable tie at exact boundary).git --no-pager grep -n 'MOLSRelayPolicy' sdk/ portal/ cmd/returns zeroAcceptance criterion #2 — structural finding (deferred to Phase 3)
The agent's exploratory tuning showed:
The MOLS selector emits pick counts in discrete tiers of approximately N/K — for N=1000, K=5:
{125, 371, 628, 880, 1000}. The expected target714falls between adjacent tiers (628, 880); no continuous lambda value can land observed picks at 714 while the four equal-capacity relays remain in one penalty tier.Phase 2 does demonstrably load-balance:
The acceptance criterion's exact
p > 0.01requires a different test geometry (varied capacities per relay; per-client load noise; or a MaxActiveRelays cap that blends discrete tiers). The criterion is moved to Phase 3 acceptance, where telemetry from the production deploy will inform whether to revise the synthetic test geometry, lower the threshold, or accept the structural limit.Public API
RelaySet.PriorityRelays,RelaySet.PriorityMultiHop, and the Phase 1*WithTracesiblings preserved byte-equal in signature.Selector(interface),Lifecycle(struct),LifecycleConfig,Option+WithSelector,mols.MOLS+mols.New(),weighted.Composite+weighted.New()+WithLambda/WithEpsilon/WithBeta,selectortest.Contract.RelaySet:SetSelector(s Selector),RecordTunnelOpened(url, sample),RecordTunnelClosed(url, sample).RelayState.LoadFactor,RelayState.FailureRate,RelayState.LastUpdated. (ClientState.SelectorOverridewas added in commit 15 then reverted in commit 22 — Phase 3 will reintroduce it together with its dispatch consumer so the field arrives behaviour-coupled.)NewRelaySet(bootstraps []string, opts ...Option) *RelaySet(wasNewRelaySet(bootstraps []string, policy relayPolicy)); call sites updated atomically. Variadic-options backward-compatibility-friendly.Atomic-commit gate
git log --oneline 5c37195d..HEADshows 22 commits — each with a single concern, no behavior+cleanup mixing. Stage 1 nits are addressed in their own dedicatedchore:commit; Stage 2A's accidentalkeyless_tls/submodule pointer was already cleaned up in Phase 1; Stage 6's structural concern is reported truthfully rather than papered over with weakened tests; commit 22 reverts the inertSelectorOverridefield per cleanup-codebase rules.Phase 3 entry
Phase 3 (multi-hop role + opt-in
/16diversity) follows on stacked branchdiscovery-phase-3-multihop-diversitywith base = this PR's branch. Phase 4 (reservation voucher) follows similarly.Out of Phase 2 acceptance — moved to Phase 3
weightedp > 0.01against capacity-weighted expected). Structural test-geometry limit; selector responds correctly. Phase 3 will revise the synthetic load-test geometry (e.g., per-relay unique capacities, per-client load noise, or a MaxActiveRelays cap that blends adjacent MOLS pick tiers) before re-running this criterion.ClientState.SelectorOverridefield + per-client dispatch — reintroduced in Phase 3 with its consumer.