Skip to content

feat: Thompson sampling relay selection + count++ bug fix - #54

Open
alltheseas wants to merge 5 commits into
hzrd149:masterfrom
alltheseas:feat/thompson-sampling-relay-selection
Open

feat: Thompson sampling relay selection + count++ bug fix#54
alltheseas wants to merge 5 commits into
hzrd149:masterfrom
alltheseas:feat/thompson-sampling-relay-selection

Conversation

@alltheseas

@alltheseas alltheseas commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fix count++ post-increment bug in selectOptimalRelaysmaxRelaysPerUser was never enforced (pool pruning + output capping)
  • Add Thompson sampling as a first-class scoring strategy: sampleBeta(), createThompsonScore(), createFixedThompsonScore()
  • Add selectRelaysPerAuthor() for per-author top-N relay selection (profile views, event lookups)
  • Add filterRelaysPerAuthor RxJS operator and forward score through filterOptimalRelays
  • Fix comparator instability — pre-compute scores before sorting in greedy set-cover
  • Fix OutboxModel cache key — use caller-provided scoreId string instead of unhashable closure reference

This 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:

Path 1yr recall Notes
Greedy set-cover (current applesauce) ~16% Static, no learning (without NIP-66)
Greedy + Thompson (main feed, with NIP-66) ~23% +3pp over greedy-with-NIP-66 baseline (~20%)

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

The greedy set-cover in selectOptimalRelays directly optimizes "best coverage within N connections" — exactly what browser clients need. createFixedThompsonScore pre-samples Beta scores once per relay, then returns a deterministic score function safe for the greedy loop's iterative re-evaluation:

const score = createFixedThompsonScore(allRelayUrls, {
  priors, latencies, rng, usePopularity: true,
});
// Each call explores a different relay configuration (Thompson exploration)
// Within a run, scores are stable for greedy set-cover
const result = selectOptimalRelays(users, { maxConnections: 25, score });

Cap at 20-25 to leave headroom for DMs, notifications, profile lookups.

For profile views / event lookups → createThompsonScore + selectRelaysPerAuthor

Per-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:

const score = createThompsonScore({ priors, latencies, rng });
const result = selectRelaysPerAuthor(users, { maxRelaysPerUser: 3, score });

Limitations

  • createThompsonScore is NOT safe for selectOptimalRelays — the greedy loop re-evaluates all relays every iteration, so stochastic scores produce unstable rankings. Use createFixedThompsonScore instead.
  • selectRelaysPerAuthor can require 100+ distinct relays for large follow graphs — do not use for the main feed in browser environments. Use it only for bounded queries.
  • Callers using OutboxModel with a custom score must provide a scoreId string to prevent cache collisions (hash-sum cannot distinguish closures with different captured state).
  • Learning requires persistence — callers must track per-relay delivery stats (successes/failures) and pass them as priors. This PR provides the scoring math; the persistence layer is app-specific.

Test plan

  • All 490 existing + new tests pass (pnpm --filter applesauce-core test)
  • count++ regression test: maxRelaysPerUser=2 caps returned relays at 2 even when 3+ are globally selected
  • sampleBeta distribution tests: uniform for α=β=1, correct skew for asymmetric params
  • createFixedThompsonScore: deterministic within a run, distinct across seeds, lazily caches unknown relays
  • selectRelaysPerAuthor: handles deduplication, tie-breaking, empty relay lists
  • OutboxModel.getKey: different scoreId values produce different cache keys
  • Snapshot exports updated for new public API surface

🤖 Generated with Claude Code

alltheseas and others added 5 commits March 5, 2026 10:25
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>
@changeset-bot

changeset-bot Bot commented Mar 5, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 4f3859c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@alltheseas

Copy link
Copy Markdown
Contributor Author

Numbers update from nostrability/outbox benchmarks

The motivation table cites:

Algorithm 1yr recall 7d recall
Greedy + Thompson (this PR) 84-89% 92%

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, --no-phase2-cache):

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:

Algorithm 1yr recall 7d recall Relative gain at 1yr
Greedy set-cover (current applesauce) 16% 84%
Welshman+Thompson (closest analog) 39% ± 2.7 SE 84-92% +30% vs stochastic baseline
FD+Thompson (per-author, no popularity weight) 37% ± 2.8 SE 84-92% +30% vs FD baseline

Suggested replacement for the motivation table:

Algorithm 1yr recall 7d recall
Greedy set-cover (current applesauce) 16% 84%
With Thompson (this PR) ~37-39%* 84-92%

*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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant