diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 4d00d52..40ed474 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -12,6 +12,7 @@ import { gt, ilike, inArray, + interactions, lt, ne, notifications, @@ -54,6 +55,68 @@ export interface FeedEvent { isSaved: boolean; } +// Default lookahead when no `dateRange` filter is given, rounded up to end +// of day. A calendar-distance bound, not a row count, so it never depends +// on how many other events happen to be scheduled sooner. See docs/ranking.md. +const CANDIDATE_HORIZON_DAYS = 14; + +// Defensive-only cap on result size within the horizon — not a ranking +// boundary, and not expected to bind at realistic campus-event scale. +const CANDIDATE_POOL_SAFETY_VALVE = 5000; + +// An event is "soon" if it's within this many days. At least SOON_QUOTA +// such events always land within the first SOON_INJECTION_WINDOW positions +// of the feed, even if their score is weak. +const SOON_WINDOW_DAYS = 1; +const SOON_QUOTA = 3; + +// Fixed window, independent of the request's `limit` — so the same +// underlying order results no matter what page size a given call uses. +const SOON_INJECTION_WINDOW = 20; + +// Max events from one org before the rest get pushed later in the ranking. +const ORG_DIVERSITY_CAP = 3; + +// Org affinity when you've RSVP'd to the org before but don't follow/belong +// to it (full affinity is 1.0). +const ORG_PAST_INTERACTION_AFFINITY = 0.5; + +// View count treated as "maximally popular" (log-scaled, caps at 1.0). +const POPULARITY_VIEW_CAP = 50; +const POPULARITY_WEIGHT = 0.5; + +// Small per-event nudge, seeded per user-per-day (not per request) so it +// varies the feed over time without ever reshuffling on refresh. +const RANDOM_WEIGHT = 0.5; + +// Deterministic pseudo-random value in [0, 1) for a seed string. +function seededRandom(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash << 5) - hash + seed.charCodeAt(i); + hash |= 0; + } + return (hash >>> 0) / 0xffffffff; +} + +function diversifyByOrg(list: T[], cap: number): T[] { + const counts = new Map(); + const primary: T[] = []; + const deferred: T[] = []; + for (const item of list) { + if (!item.orgId) { + primary.push(item); + continue; + } + const count = counts.get(item.orgId) ?? 0; + if (count < cap) { + counts.set(item.orgId, count + 1); + primary.push(item); + } else { + deferred.push(item); + } + } + return [...primary, ...deferred]; /** Accepted friendships are stored one-directional, so both columns are read. */ async function loadFriendIds(userId: string): Promise { const [outgoing, incoming] = await Promise.all([ @@ -209,6 +272,20 @@ export async function getFeedEvents(params?: { ...memberOrgRows.map((o) => o.orgId), ]); + // Orgs the user has RSVP'd to before but doesn't follow/belong to — a + // weaker org-affinity signal than myOrgIds. + const interactedOrgRows = await db + .select({ orgId: events.orgId }) + .from(rsvps) + .innerJoin(events, eq(rsvps.eventId, events.id)) + .where(eq(rsvps.userId, userId)); + const interactedOrgIds = new Set( + interactedOrgRows.map((r) => r.orgId).filter((id): id is string => id !== null), + ); + + // Day string (UTC) that seeds the random nudge — flips once a day. + const today = new Date().toISOString().slice(0, 10); + // Build base query conditions — only show published events in the feed const conditions = [gt(events.datetime, new Date()), eq(events.status, "published")]; @@ -249,28 +326,28 @@ export async function getFeedEvents(params?: { conditions.push(eq(events.locationId, params.locationId)); } - if (params?.dateRange) { - const now = new Date(); - let end: Date; - if (params.dateRange === "today") { - end = new Date(now); - end.setHours(23, 59, 59, 999); - } else if (params.dateRange === "week") { - end = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - } else { - end = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); - } - conditions.push(lt(events.datetime, end)); + // No explicit dateRange filter defaults to the candidate horizon, so + // there's one date-bounding path instead of a separate "no filter" + // branch — see docs/ranking.md. + const now = new Date(); + let dateRangeEnd: Date; + if (params?.dateRange === "today") { + dateRangeEnd = new Date(now); + dateRangeEnd.setHours(23, 59, 59, 999); + } else if (params?.dateRange === "week") { + dateRangeEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + } else if (params?.dateRange === "month") { + dateRangeEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + } else { + dateRangeEnd = new Date(now); + dateRangeEnd.setDate(dateRangeEnd.getDate() + CANDIDATE_HORIZON_DAYS); + dateRangeEnd.setHours(23, 59, 59, 999); } + conditions.push(lt(events.datetime, dateRangeEnd)); - // Get total count - const [countResult] = await db - .select({ count: sql`count(*)::int` }) - .from(events) - .where(and(...conditions)); - const total = countResult?.count ?? 0; - - // Fetch events with scoring + // Score every candidate within the horizon, not just the requested page — + // batched enrichment below keeps this cheap regardless of pool size. See + // docs/ranking.md. const rawEvents = await db .select({ id: events.id, @@ -288,119 +365,172 @@ export async function getFeedEvents(params?: { .leftJoin(organizations, eq(events.orgId, organizations.id)) .where(and(...conditions)) .orderBy(events.datetime) - .limit(limit) - .offset(offset); - - // Enrich each event with tags, rsvp counts, friend attendance, user state - // - // Ranking, in plain English: an event scores higher if (1) its tags match - // your interests, (2) it's happening soon, (3) friends of yours are - // attending, (4) it belongs to an org you follow or belong to, or (5) it - // was posted recently. Scores are deterministic — no randomness — so - // refreshing Explore without new data (RSVPs, new events, etc.) never - // reorders the feed. Ties break by soonest event first. - const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = await Promise.all( - rawEvents.map(async (event) => { - // Get tags - const tags = await db - .select({ tag: eventTags.tag }) - .from(eventTags) - .where(eq(eventTags.eventId, event.id)); + .limit(CANDIDATE_POOL_SAFETY_VALVE); - // Get RSVP count - const [rsvpCount] = await db - .select({ count: sql`count(*)::int` }) - .from(rsvps) - .where(eq(rsvps.eventId, event.id)); + if (rawEvents.length === 0) { + return { events: [], total: 0 }; + } - // Get friends attending - let friendsAttending: { id: string; displayName: string; avatarUrl: string | null }[] = []; - if (friendIds.length > 0) { - friendsAttending = await db - .select({ - id: users.id, - displayName: users.displayName, - avatarUrl: users.avatarUrl, - }) - .from(rsvps) - .innerJoin(users, eq(rsvps.userId, users.id)) - .where(and(eq(rsvps.eventId, event.id), inArray(rsvps.userId, friendIds))); - } + // Matches exactly what was fetched, so it's always consistent with what + // offset/limit can reach — unlike a separate unbounded COUNT(*). + const total = rawEvents.length; - // Check if user has RSVP'd or saved - const [userRsvp] = await db - .select() + if (rawEvents.length === CANDIDATE_POOL_SAFETY_VALVE) { + console.warn( + `getFeedEvents: candidate pool hit CANDIDATE_POOL_SAFETY_VALVE (${CANDIDATE_POOL_SAFETY_VALVE}); total may be an undercount.`, + ); + } + + const candidateIds = rawEvents.map((e) => e.id); + + // Batch every per-candidate lookup into one query each, instead of one + // query per candidate. Grouped in memory afterward by eventId/itemId. + const [tagRows, rsvpCountRows, viewCountRows, friendRsvpRows, userRsvpRows, userSaveRows] = + await Promise.all([ + db + .select({ eventId: eventTags.eventId, tag: eventTags.tag }) + .from(eventTags) + .where(inArray(eventTags.eventId, candidateIds)), + + db + .select({ eventId: rsvps.eventId, count: sql`count(*)::int` }) + .from(rsvps) + .where(inArray(rsvps.eventId, candidateIds)) + .groupBy(rsvps.eventId), + + db + .select({ eventId: interactions.itemId, count: sql`count(*)::int` }) + .from(interactions) + .where( + and( + inArray(interactions.itemId, candidateIds), + eq(interactions.itemType, "event"), + eq(interactions.interactionType, "view"), + ), + ) + .groupBy(interactions.itemId), + + friendIds.length === 0 + ? [] + : db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(and(inArray(rsvps.eventId, candidateIds), inArray(rsvps.userId, friendIds))), + + db + .select({ eventId: rsvps.eventId }) .from(rsvps) - .where(and(eq(rsvps.userId, userId), eq(rsvps.eventId, event.id))) - .limit(1); + .where(and(eq(rsvps.userId, userId), inArray(rsvps.eventId, candidateIds))), - const [userSave] = await db - .select() + db + .select({ eventId: savedEvents.eventId }) .from(savedEvents) - .where(and(eq(savedEvents.userId, userId), eq(savedEvents.eventId, event.id))) - .limit(1); - - // Score for sorting - const tagNames = tags.map((t) => t.tag); - - // Fraction of this event's tags that match the user's interests — how - // relevant is this event to you, not how much of your profile it covers. - const matchedTags = tagNames.filter((t) => myInterestTags.includes(t)).length; - const interestRelevance = - myInterestTags.length === 0 - ? 0.5 - : tagNames.length === 0 - ? 0 - : matchedTags / tagNames.length; - - const now = Date.now(); - const eventTime = event.datetime.getTime(); - const daysUntil = (eventTime - now) / (1000 * 60 * 60 * 24); - const timeProximity = - daysUntil <= 1 - ? 1.0 - : daysUntil <= 3 - ? 0.8 - : daysUntil <= 7 - ? 0.6 - : daysUntil <= 14 - ? 0.3 - : 0.1; - - const friendRsvpScore = Math.min(1.0, friendsAttending.length / 3.0); - - const orgAffinity = event.orgId && myOrgIds.has(event.orgId) ? 1.0 : 0.0; - - const hoursSinceCreated = (now - event.createdAt.getTime()) / (1000 * 60 * 60); - const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; - - const score = - 3.0 * interestRelevance + - 2.0 * timeProximity + - 4.0 * friendRsvpScore + - 1.0 * orgAffinity + - 1.0 * recencyBoost; - - return { - id: event.id, - title: event.title, - description: event.description, - orgId: event.orgId, - orgName: event.orgName, - datetime: formatEventDateTime(event.datetime), - location: event.locationName ?? "TBD", - tags: tagNames, - flyerUrl: event.flyerUrl, - rsvpCount: rsvpCount?.count ?? 0, - friendsAttending, - isRsvped: !!userRsvp, - isSaved: !!userSave, - score, - _rawDatetime: event.datetime, - }; - }), - ); + .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, candidateIds))), + ]); + const tagsByEvent = new Map(); + for (const row of tagRows) { + const list = tagsByEvent.get(row.eventId); + if (list) list.push(row.tag); + else tagsByEvent.set(row.eventId, [row.tag]); + } + + const rsvpCountByEvent = new Map(rsvpCountRows.map((r) => [r.eventId, r.count])); + const viewCountByEvent = new Map(viewCountRows.map((r) => [r.eventId, r.count])); + + const friendsByEvent = new Map< + string, + { id: string; displayName: string; avatarUrl: string | null }[] + >(); + for (const row of friendRsvpRows) { + const entry = { id: row.id, displayName: row.displayName, avatarUrl: row.avatarUrl }; + const list = friendsByEvent.get(row.eventId); + if (list) list.push(entry); + else friendsByEvent.set(row.eventId, [entry]); + } + + const userRsvpEventIds = new Set(userRsvpRows.map((r) => r.eventId)); + const userSaveEventIds = new Set(userSaveRows.map((r) => r.eventId)); + + // Enrich each event and compute its weighted relevance score. Full + // formula breakdown: docs/ranking.md. + const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = rawEvents.map((event) => { + const tagNames = tagsByEvent.get(event.id) ?? []; + const rsvpCount = rsvpCountByEvent.get(event.id) ?? 0; + const viewCount = viewCountByEvent.get(event.id) ?? 0; + const friendsAttending = friendsByEvent.get(event.id) ?? []; + + // Fraction of this event's tags that match your interests. + const matchedTags = tagNames.filter((t) => myInterestTags.includes(t)).length; + const interestRelevance = + myInterestTags.length === 0 ? 0.5 : tagNames.length === 0 ? 0 : matchedTags / tagNames.length; + + const now = Date.now(); + const eventTime = event.datetime.getTime(); + const daysUntil = (eventTime - now) / (1000 * 60 * 60 * 24); + // Half-life decay: 1.0 right now, halving every 4 days out. + const timeProximity = 2 ** (-daysUntil / 4); + + const friendRsvpScore = Math.min(1.0, friendsAttending.length / 3.0); + + // Full affinity if you follow/belong to the org, weaker if you've + // just RSVP'd to it before. + const orgAffinity = !event.orgId + ? 0 + : myOrgIds.has(event.orgId) + ? 1.0 + : interactedOrgIds.has(event.orgId) + ? ORG_PAST_INTERACTION_AFFINITY + : 0; + + const hoursSinceCreated = (now - event.createdAt.getTime()) / (1000 * 60 * 60); + const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; + + // Log-scaled view count, capped at 1.0 around POPULARITY_VIEW_CAP. + const popularityScore = Math.min( + 1.0, + Math.log(viewCount + 1) / Math.log(POPULARITY_VIEW_CAP + 1), + ); + + // Deterministic per user-per-day-per-event nudge — see RANDOM_WEIGHT. + const randomNudge = seededRandom(`${userId}:${today}:${event.id}`); + + const score = + 3.0 * interestRelevance + + 2.0 * timeProximity + + 4.0 * friendRsvpScore + + 1.0 * orgAffinity + + 1.0 * recencyBoost + + POPULARITY_WEIGHT * popularityScore + + RANDOM_WEIGHT * randomNudge; + + return { + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + datetime: formatEventDateTime(event.datetime), + location: event.locationName ?? "TBD", + tags: tagNames, + flyerUrl: event.flyerUrl, + rsvpCount, + friendsAttending, + isRsvped: userRsvpEventIds.has(event.id), + isSaved: userSaveEventIds.has(event.id), + score, + _rawDatetime: event.datetime, + }; + }); + + // Sort by score descending; ties break by soonest first, so refreshing + // with no new data never reorders the feed. /* * Attendees for the "N attending" control on each card. * @@ -437,6 +567,61 @@ export async function getFeedEvents(params?: { return a._rawDatetime.getTime() - b._rawDatetime.getTime(); }); + // Cap events per org + const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); + + // Guarantee SOON_QUOTA imminent events land within the first + // SOON_INJECTION_WINDOW positions, without exceeding ORG_DIVERSITY_CAP + // there. This finalizes ONE stable order over the whole pool before + // pagination, so every page is a plain slice of the same array — no + // event can be duplicated or dropped across pages. See docs/ranking.md. + const isSoon = (e: (typeof ranked)[number]) => + (e._rawDatetime.getTime() - Date.now()) / (1000 * 60 * 60 * 24) <= SOON_WINDOW_DAYS; + + const windowSize = Math.min(SOON_INJECTION_WINDOW, ranked.length); + const front = ranked.slice(0, windowSize); + const tail = ranked.slice(windowSize); + + let finalOrder = ranked; + const soonInFront = front.filter(isSoon).length; + + if (soonInFront < SOON_QUOTA) { + const frontIds = new Set(front.map((e) => e.id)); + + // Org counts already in front — a candidate whose org is already at + // the cap is skipped, so injection can't push an org past it. + const frontOrgCounts = new Map(); + for (const e of front) { + if (e.orgId) frontOrgCounts.set(e.orgId, (frontOrgCounts.get(e.orgId) ?? 0) + 1); + } + + const missingSoon: (typeof ranked)[number][] = []; + for (const e of tail) { + if (missingSoon.length >= SOON_QUOTA - soonInFront) break; + if (!isSoon(e) || frontIds.has(e.id)) continue; + if (e.orgId) { + const count = frontOrgCounts.get(e.orgId) ?? 0; + if (count >= ORG_DIVERSITY_CAP) continue; + frontOrgCounts.set(e.orgId, count + 1); + } + missingSoon.push(e); + } + + if (missingSoon.length > 0) { + const missingIds = new Set(missingSoon.map((e) => e.id)); + const mergedFront = [...front]; + const stride = Math.max(1, Math.floor(mergedFront.length / (missingSoon.length + 1))); + missingSoon.forEach((event, i) => { + mergedFront.splice(Math.min(mergedFront.length, stride * (i + 1)), 0, event); + }); + finalOrder = [...mergedFront, ...tail.filter((e) => !missingIds.has(e.id))]; + } + } + + // finalOrder.length === ranked.length always — pure reorder, nothing + // added or dropped. Pagination is a plain slice of this one stable order. + const page = finalOrder.slice(offset, offset + limit); + return { events: enriched.map(({ score: _score, _rawDatetime, ...event }) => ({ ...event, diff --git a/docs/ranking.md b/docs/ranking.md new file mode 100644 index 0000000..58dbb2d --- /dev/null +++ b/docs/ranking.md @@ -0,0 +1,170 @@ +# Explore feed ranking + +This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`) orders the Explore +feed. It's a plain SQL + in-memory weighted score — no ML, no external service. + +## The formula, in plain English + +Every upcoming event gets a score built from seven signals, each roughly between 0 and 1, +multiplied by a weight: + +| Signal | Weight | What it measures | +|---|---|---| +| Interest relevance | 3.0 | What fraction of this event's tags match your onboarding interests | +| Time proximity | 2.0 | How soon the event is — decays smoothly the further out it is | +| Friend RSVPs | 4.0 | How many of your friends are going (caps out at 3+) | +| Org affinity | 1.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before | +| Recency | 1.0 | Whether the event was posted in the last 1–3 days | +| Popularity | 0.5 | How many views the event has gotten, log-scaled | +| Random nudge | 0.5 | A small per-user-per-day nudge so the feed varies over time | + +``` +score = 3.0 × interest_relevance + + 2.0 × time_proximity + + 4.0 × friend_rsvp_score + + 1.0 × org_affinity + + 1.0 × recency_boost + + 0.5 × popularity_score + + 0.5 × random_nudge +``` + +Events are sorted by this score, highest first. Friend RSVPs carry the most weight — "people +you know are going" is the strongest signal on a campus app. Popularity and the random nudge +are deliberately small: they nudge the feed, they don't dominate it. + +### Interest relevance + +`matched tags / total tags on the event`. No interests set yet? Everyone gets a neutral 0.5 +instead of 0, so a user with no interests still sees a normal feed, not everything at the +bottom. + +### Time proximity — smooth decay + +``` +time_proximity = 2 ^ (-days_until / 4) +``` + +An event right now scores 1.0, halving every 4 days out (day 4 ≈ 0.5, day 14 ≈ 0.09, day 30 +≈ 0.004). This replaced an earlier bucketed version (`≤1 day = 1.0, ≤3 days = 0.8, …`) whose +score could visibly jump as an event crossed a bucket boundary. The smooth curve keeps the +same intuition — sooner is better, distant events fade but never hit zero — without the jump. + +### Friend RSVPs + +`min(1.0, friends attending / 3)`. Three or more friends going is treated as maximally +compelling; it doesn't climb further past that. + +### Org affinity — tiered + +- **1.0** if you follow or belong to the event's org. +- **0.5** if you don't, but you've RSVP'd to that org's events before + (`ORG_PAST_INTERACTION_AFFINITY` in `events.ts`) — a weaker signal of interest. +- **0** otherwise, or if the event has no org. + +### Recency boost + +1.0 if posted in the last 24 hours, 0.5 if posted in the last 3 days, 0 otherwise — surfaces +newly-posted events before other signals catch up. + +### Popularity + +``` +popularity_score = min(1.0, log(view_count + 1) / log(POPULARITY_VIEW_CAP + 1)) +``` + +Grows logarithmically with view count (logged via `interactions`, see +`apps/web/src/actions/interactions.ts`), capping at 1.0 around `POPULARITY_VIEW_CAP` (50) +views. It's a live per-request count, not a batch job, so it stays cheap. + +### Random nudge — seeded per user, per day + +``` +random_nudge = seededRandom(`${userId}:${today}:${event.id}`) +``` + +A deterministic hash of the user, the current UTC date, and the event ID, normalized to +`[0, 1)`. Not `Math.random()` — the same user looking at the same event on the same day +always gets the same nudge, so refreshing Explore never reshuffles it. The nudge changes +once a day, so events that would otherwise tie get some variety over time. Ties still break +by soonest event first. + +## Candidate pool: why scoring needs more than one page + +The DB query pulls every upcoming, published event matching the feed's filters — not just the +requested page (`limit`/`offset`) — scores all of them, sorts by score, and only then slices +out the requested page. If scoring only ever ran against the 20 events the caller asked for, +personalization would have nothing to work with — the soonest 20 would always be exactly +what's returned, just reshuffled. Scoring the full candidate set lets a highly relevant event +further down the calendar outrank a less relevant one that merely happens sooner, no matter +how many other events are scheduled in between. + +`total` is simply the size of that candidate set, so it always agrees with what `limit`/`offset` +can actually reach — there's no separate count that could promise more than pagination can +deliver. Enrichment (tags, rsvp/view counts, friend attendance, user state) is batched via +`inArray(...)` across the whole candidate set rather than queried per event, so a wider pool +doesn't multiply query count — it stays at a fixed handful of queries regardless of how many +candidates are scored. + +### Bounded by calendar distance, not row count + +When no explicit `dateRange` filter is applied, candidates are bounded to the next +`CANDIDATE_HORIZON_DAYS` (14) days, rounded up to the end of that day. This replaced an earlier +version that capped the candidate pool at a fixed row count (the soonest 100 events) — that +approach meant a highly relevant event could be excluded from ranking entirely just because 100 +*other* events happened to be scheduled sooner, regardless of how strong its interest/friend/org +signal was. A calendar-distance bound doesn't have that failure mode: every event within the +next two weeks is always a candidate, no matter how many other events fall before it. An +explicit `dateRange` param (`today`/`week`/`month`) overrides the default horizon with its own +narrower or wider window. + +Events further out than the horizon never enter ranking by default — this is an intentional +product choice (Explore surfaces what's happening soon, not the whole semester's calendar), not +a scale workaround. `CANDIDATE_POOL_SAFETY_VALVE` (5000) is a separate, purely defensive row +limit on top of the horizon, in case an unusually dense window ever produced a pathological +result size — it isn't expected to bind at realistic campus-event scale. + +## Org diversity cap + +After sorting by score, results are capped at `ORG_DIVERSITY_CAP` (3) events per org — once +an org hits 3, its remaining events are pushed later (not dropped), so one heavily-posting +org can't dominate the top of the feed. Events without an org are never capped. + +## Guaranteeing imminent events aren't buried + +Friend RSVPs (weight 4.0) can outweigh time proximity (weight 2.0), so an event with strong +social signal three weeks out could in principle outscore one happening tomorrow with no +friends attending yet. To keep "what's happening soon" reliably visible, the first +`SOON_INJECTION_WINDOW` (20) positions of the ranked, diversified feed always include at least +`SOON_QUOTA` (3) events within `SOON_WINDOW_DAYS` (1) day, even if their score wouldn't +naturally place them there. + +This window is a fixed size, independent of the `limit`/`offset` a given request happens to +use — the whole feed is reordered into one final, stable sequence first, and *then* sliced into +pages. That means two requests for the same feed with different page sizes see the same +underlying order, and no event can appear on two different pages or be silently dropped by the +guarantee. + +This is a **merge**, not a score override: if the front of the feed already has enough soon +events, nothing changes. Otherwise the highest-scoring soon events missing from that window are +interleaved into it at evenly-spaced positions, removed from their original later position — +everything else keeps its normal score order. It only backfills what score order left out, the +way feeds inject a freshness quota without letting it take over the whole ranking. + +The injection never breaks the org diversity cap above: a soon event whose org already has +`ORG_DIVERSITY_CAP` events in the front window is skipped, even if that means fewer than +`SOON_QUOTA` soon events end up there. "Max 3 per org near the top" holds regardless of the +soon-event guarantee — the two rules compose rather than one silently overriding the other. + +All the tunable constants above (`SOON_WINDOW_DAYS`, `SOON_QUOTA`, `SOON_INJECTION_WINDOW`, +`ORG_DIVERSITY_CAP`, `CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_SAFETY_VALVE`, +`POPULARITY_VIEW_CAP`, `POPULARITY_WEIGHT`, `RANDOM_WEIGHT`, `ORG_PAST_INTERACTION_AFFINITY`) +live at the top of `events.ts`. + +## Edge cases + +- **No interests**: `interest_relevance` defaults to 0.5 for every event. +- **No friends**: `friend_rsvp_score` is 0; the friends-attending lookup is skipped entirely. +- **No org follows/memberships/past RSVPs**: `org_affinity` is 0. + +None of these throw or produce an empty feed — a brand-new user with zero signals still gets +a full feed, ranked by time proximity, recency, popularity, and the random nudge alone.