feat: Thompson sampling relay selection + count++ bug fix - #54
feat: Thompson sampling relay selection + count++ bug fix#54alltheseas wants to merge 5 commits into
Conversation
The post-increment `count++` returned the old value before incrementing, so `selectionCount` was always set to 0 and `maxRelaysPerUser` was never enforced. Use pre-addition instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- sampleBeta(): Beta distribution sampling (Jöhnk + Marsaglia-Tsang gamma) - createThompsonScore(): factory returning a stochastic scoring function with Beta priors, latency discount, and optional popularity weighting - selectRelaysPerAuthor(): per-author top-N relay selection (suited for stochastic scoring, unlike the greedy set-cover in selectOptimalRelays) - filterRelaysPerAuthor RxJS operator for the per-author path - filterOptimalRelays now accepts optional score parameter - Tests for all new functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-samples Beta scores once per relay, returning a deterministic score function safe for selectOptimalRelays' iterative re-evaluation. Each instantiation explores a different relay configuration (Thompson exploration); within a run, scores are stable. Essential for browser clients where Chrome's ~30 WebSocket connection limit makes connection-budget-aware greedy selection the right architecture for the main feed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- P1: Cap returned relays per user to maxRelaysPerUser in selectOptimalRelays output (not just pool pruning) - P2: Pre-compute scores before sorting in greedy comparator to avoid stochastic score instability from multiple calls per sort pass - P2: OutboxModel.getKey now includes score function in cache key - P2: createFixedThompsonScore lazily samples and caches unknown relays instead of falling back to non-deterministic rng() - P2: selectRelaysPerAuthor deduplicates relay arrays to prevent inflated popularity counts and duplicate slot consumption Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e key hash-sum hashes functions by toString(), so closures with different captured state (e.g. different Thompson seeds) produce identical keys. Replace opts.score with an explicit opts.scoreId string that callers set to distinguish scorer configurations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Numbers update from nostrability/outbox benchmarksThe motivation table cites:
The 84-89% at 1yr was inflated by a phase2 cache bug in the benchmark framework (lossy serialization stored the union of event IDs across all relays, inflating S2+ verification recall). This was fixed in nostrability/outbox#34. Corrected numbers (from 793 benchmark runs, 10-run variance study, We benchmarked Welshman+Thompson and FD+Thompson directly. We haven't benchmarked "greedy+Thompson" as a specific combination, but Thompson's gains should be comparable regardless of the base algorithm:
Suggested replacement for the motivation table:
*Estimated from Welshman+Thompson (39%) and FD+Thompson (37%) benchmarks — greedy+Thompson not yet directly benchmarked. Thompson finds ~30% more events than stochastic baselines at 1yr (10-run validated, 6 profiles). Per-profile gains range 0 to +15pp depending on relay graph diversity. The 7d numbers (84% baseline, 84-92% with Thompson) are correct — at 7d most relays retain events, so the gain is modest (+5-8% relative). See nostrability/outbox#35 for the corrected benchmark data and methodology. The PR code itself looks correct — this is just a docs fix for the motivation section. |
Summary
count++post-increment bug inselectOptimalRelays—maxRelaysPerUserwas never enforced (pool pruning + output capping)sampleBeta(),createThompsonScore(),createFixedThompsonScore()selectRelaysPerAuthor()for per-author top-N relay selection (profile views, event lookups)filterRelaysPerAuthorRxJS operator and forwardscorethroughfilterOptimalRelaysscoreIdstring instead of unhashable closure referenceThis PR was developed with agentic assistance, informed by the nostrability/outbox benchmarks — a cross-client relay selection study covering 20+ algorithms, 12 profiles, and 6 time windows with real event verification. The benchmark tested proxy implementations of each algorithm; this PR's code has not been run through the benchmark suite directly.
Why upgrade: what Thompson sampling buys you
The nostrability benchmarks show that static relay selection (greedy set-cover) achieves only ~16% event recall at 1 year — relays that "should" have events often don't due to retention policies, downtime, or silent write failures. Thompson sampling learns from actual delivery:
Greedy+Thompson alone yields only +3pp because greedy concentrates on popular relays that prune old events — Thompson can't fix that structural bias.
The full upgrade path for the main feed is: greedy (~16%) → stochastic scoring (~24%, +8pp) → stochastic + Thompson (+9pp more in paired benchmarks, ~30% → ~39%). This PR provides the Thompson scoring primitives; switching from greedy to stochastic scoring is a separate change to
selectOptimalRelays.selectRelaysPerAuthor(for profile views / event lookups) uses per-author top-N selection with Thompson scoring — architecturally similar to FD+Thompson (~25% → ~37% in benchmarks), but not yet directly benchmarked with applesauce's implementation.The improvement comes from treating relay selection as a multi-armed bandit problem: track which relays actually deliver events, feed that back into selection via Beta distribution sampling. Cold start (no history) = uniform random, equivalent to current behavior. After 3-5 sessions, relay picks converge toward relays that reliably return events.
Web SDK design: browser connection limits
Chrome caps WebSocket connections at ~30 total (~6 per host). This is the hard constraint that drives the API design in this PR:
For main feed queries →
createFixedThompsonScore+selectOptimalRelaysThe greedy set-cover in
selectOptimalRelaysdirectly optimizes "best coverage within N connections" — exactly what browser clients need.createFixedThompsonScorepre-samples Beta scores once per relay, then returns a deterministic score function safe for the greedy loop's iterative re-evaluation:Cap at 20-25 to leave headroom for DMs, notifications, profile lookups.
For profile views / event lookups →
createThompsonScore+selectRelaysPerAuthorPer-author selection is suited for bounded, short-lived queries (3 relays per profile view). The stochastic scoring is fine here because each author's relays are scored in a single pass:
Limitations
createThompsonScoreis NOT safe forselectOptimalRelays— the greedy loop re-evaluates all relays every iteration, so stochastic scores produce unstable rankings. UsecreateFixedThompsonScoreinstead.selectRelaysPerAuthorcan require 100+ distinct relays for large follow graphs — do not use for the main feed in browser environments. Use it only for bounded queries.OutboxModelwith a customscoremust provide ascoreIdstring to prevent cache collisions (hash-sumcannot distinguish closures with different captured state).priors. This PR provides the scoring math; the persistence layer is app-specific.Test plan
pnpm --filter applesauce-core test)count++regression test:maxRelaysPerUser=2caps returned relays at 2 even when 3+ are globally selectedsampleBetadistribution tests: uniform for α=β=1, correct skew for asymmetric paramscreateFixedThompsonScore: deterministic within a run, distinct across seeds, lazily caches unknown relaysselectRelaysPerAuthor: handles deduplication, tie-breaking, empty relay listsOutboxModel.getKey: differentscoreIdvalues produce different cache keys🤖 Generated with Claude Code