From 29dbdadc8b36f3abbf56a0911cf27076699dcaac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Dec 2025 14:46:36 +0000 Subject: [PATCH] feat(relay-selection): integrate performance scoring into relay selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace applesauce's selectOptimalRelays with custom implementation that: - Uses relay performance scores from RelayScoreboard - Incorporates health status from RelayLiveness - Weights coverage (60%) + performance (40%) × health multiplier Algorithm: Greedy set-cover that prefers fast, reliable relays while maintaining optimal coverage across all users. --- src/services/relay-scoreboard.test.ts | 2 +- src/services/relay-selection.ts | 182 ++++++++++++++++++++++++-- 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/services/relay-scoreboard.test.ts b/src/services/relay-scoreboard.test.ts index 0cebada..02d1757 100644 --- a/src/services/relay-scoreboard.test.ts +++ b/src/services/relay-scoreboard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { calculateRelayScore, calculateAdaptiveTimeout, diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 93aaed0..2cc21c7 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -16,17 +16,178 @@ import { firstValueFrom, timeout as rxTimeout, of } from "rxjs"; import { catchError } from "rxjs/operators"; import type { IEventStore } from "applesauce-core/event-store"; import { getInboxes, getOutboxes } from "applesauce-core/helpers"; -import { selectOptimalRelays } from "applesauce-core/helpers"; import { addressLoader, AGGREGATOR_RELAYS } from "./loaders"; import { normalizeRelayURL } from "@/lib/relay-url"; import liveness from "./relay-liveness"; import relayListCache from "./relay-list-cache"; +import relayScoreboard from "./relay-scoreboard"; import type { RelaySelectionResult, RelaySelectionReasoning, RelaySelectionOptions, } from "@/types/relay-selection"; +/** + * Custom scoring function for relay selection + * Combines coverage efficiency with performance scoring and health status + * + * @param relay - Relay URL + * @param coverage - Number of uncovered users this relay covers + * @param popularity - Total number of users using this relay + * @returns Score for relay selection (higher = better) + */ +function scoreRelay( + relay: string, + coverage: number, + popularity: number, +): number { + // Base score: coverage efficiency (how many uncovered users per total users) + // This is the default applesauce behavior + const coverageScore = coverage / Math.max(1, popularity); + + // Performance score from scoreboard (0-10, normalized to 0-1) + const perfScore = relayScoreboard.getScore(relay) / 10; + + // Health multiplier from liveness (penalize unhealthy relays) + let healthMultiplier = 1.0; + try { + const healthyRelays = liveness.healthy; + const isHealthy = healthyRelays.includes(relay); + healthMultiplier = isHealthy ? 1.0 : 0.3; + } catch { + // If liveness check fails, assume healthy + healthMultiplier = 1.0; + } + + // Combined score: + // - Coverage is weighted higher (60%) because we need the events + // - Performance helps break ties and prefer faster relays (40%) + // - Health multiplier penalizes but doesn't exclude unhealthy relays + return (coverageScore * 0.6 + perfScore * 0.4) * healthMultiplier; +} + +interface ScoredSelectOptions { + maxConnections: number; + maxRelaysPerUser?: number; +} + +/** + * Custom relay selection algorithm with performance scoring + * + * This is a greedy set-cover algorithm that selects relays to maximize + * user coverage while preferring relays with better performance scores. + * + * Algorithm: + * 1. Build a map of which relays cover which users + * 2. Calculate popularity (total users) for each relay + * 3. While we have uncovered users and haven't hit maxConnections: + * a. For each unselected relay, calculate coverage of still-uncovered users + * b. Score each relay using scoreRelay(relay, coverage, popularity) + * c. Select the highest-scoring relay + * d. Mark covered users as satisfied + * e. If maxRelaysPerUser set, remove satisfied users from remaining relays + * 4. Return filtered pointers with only selected relays + */ +function selectOptimalRelaysWithScoring( + users: ProfilePointer[], + options: ScoredSelectOptions, +): ProfilePointer[] { + const { maxConnections, maxRelaysPerUser } = options; + + // Build relay → users map and popularity counts + const relayToUsers = new Map>(); + const relayPopularity = new Map(); + + for (const user of users) { + for (const relay of user.relays || []) { + if (!relayToUsers.has(relay)) { + relayToUsers.set(relay, new Set()); + relayPopularity.set(relay, 0); + } + relayToUsers.get(relay)!.add(user.pubkey); + relayPopularity.set(relay, (relayPopularity.get(relay) || 0) + 1); + } + } + + // Track state + const selectedRelays = new Set(); + const coveredUsers = new Set(); + const userRelayCounts = new Map(); // Track relays per user + + // Initialize user relay counts + for (const user of users) { + userRelayCounts.set(user.pubkey, 0); + } + + // Greedy selection loop + while (selectedRelays.size < maxConnections) { + let bestRelay: string | null = null; + let bestScore = -Infinity; + + // Score each unselected relay + for (const [relay, allUsersOnRelay] of relayToUsers) { + if (selectedRelays.has(relay)) continue; + + // Calculate coverage: users on this relay that aren't yet covered + // (or haven't hit maxRelaysPerUser) + let coverage = 0; + for (const pubkey of allUsersOnRelay) { + if (maxRelaysPerUser) { + const currentCount = userRelayCounts.get(pubkey) || 0; + if (currentCount < maxRelaysPerUser) { + coverage++; + } + } else if (!coveredUsers.has(pubkey)) { + coverage++; + } + } + + // Skip relays with no marginal coverage + if (coverage === 0) continue; + + // Score this relay + const popularity = relayPopularity.get(relay) || 1; + const score = scoreRelay(relay, coverage, popularity); + + if (score > bestScore) { + bestScore = score; + bestRelay = relay; + } + } + + // No more useful relays + if (!bestRelay) break; + + // Select this relay + selectedRelays.add(bestRelay); + + // Update coverage tracking + const usersOnBestRelay = relayToUsers.get(bestRelay) || new Set(); + for (const pubkey of usersOnBestRelay) { + if (maxRelaysPerUser) { + const currentCount = userRelayCounts.get(pubkey) || 0; + if (currentCount < maxRelaysPerUser) { + userRelayCounts.set(pubkey, currentCount + 1); + if (currentCount + 1 >= maxRelaysPerUser) { + coveredUsers.add(pubkey); + } + } + } else { + coveredUsers.add(pubkey); + } + } + + // Check if all users are covered + if (coveredUsers.size === users.length) break; + } + + // Filter pointers to only include selected relays + return users.map((user) => ({ + ...user, + relays: (user.relays || []).filter((relay) => selectedRelays.has(relay)), + })); +} + /** * Fetches a kind:10002 relay list event for a pubkey with timeout * @@ -500,13 +661,16 @@ export async function selectRelaysForFilter( maxRelays - authorRelayBudget, ); - // Select from each group independently - const selectedAuthors = selectOptimalRelays(processedAuthorPointers, { - maxConnections: authorRelayBudget, - maxRelaysPerUser, - }); + // Select from each group independently using custom scoring + const selectedAuthors = selectOptimalRelaysWithScoring( + processedAuthorPointers, + { + maxConnections: authorRelayBudget, + maxRelaysPerUser, + }, + ); - const selectedPTags = selectOptimalRelays(processedPTagPointers, { + const selectedPTags = selectOptimalRelaysWithScoring(processedPTagPointers, { maxConnections: pTagRelayBudget, maxRelaysPerUser, }); @@ -518,8 +682,8 @@ export async function selectRelaysForFilter( `${selectedPTags.flatMap((p) => p.relays).length} read relays from ${pTags.length} p-tags`, ); } else { - // Optimize relay selection for efficient coverage - selectedPointers = selectOptimalRelays(allPointers, { + // Optimize relay selection for efficient coverage using custom scoring + selectedPointers = selectOptimalRelaysWithScoring(allPointers, { maxConnections: maxRelays, maxRelaysPerUser, });