From 8b2147242e7716ae1f6124be3458faa5b684c235 Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 21:28:24 -0400 Subject: [PATCH 1/5] Explore ranking: widen candidate pool, smooth time decay, org diversity cap, seeded daily nudge, popularity/org-history signals, ranking docs --- apps/web/src/actions/events.ts | 177 +++++++++++++++++++++++++++------ docs/ranking.md | 146 +++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 docs/ranking.md diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 241c098..51428fb 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, @@ -45,6 +46,61 @@ export interface FeedEvent { isSaved: boolean; } +// Upcoming events scored before slicing out the requested page — must be +// well above any realistic page size so personalization has real candidates +// to work with. See docs/ranking.md. +const CANDIDATE_POOL_SIZE = 100; + +// An event is "soon" if it's within this many days. The first page always +// includes at least SOON_QUOTA such events, even if their score is weak. +const SOON_WINDOW_DAYS = 1; +const SOON_QUOTA = 3; + +// 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]; +} + export async function getFeedEvents(params?: { search?: string; tags?: string[]; @@ -96,6 +152,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")]; @@ -157,7 +227,9 @@ export async function getFeedEvents(params?: { .where(and(...conditions)); const total = countResult?.count ?? 0; - // Fetch events with scoring + // Score a bounded pool of upcoming events, not just the requested page — + // otherwise personalization could never surface anything past the + // soonest `limit` events. See docs/ranking.md. const rawEvents = await db .select({ id: events.id, @@ -175,17 +247,13 @@ 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. + .limit(CANDIDATE_POOL_SIZE); + + // Enrich each event with tags, rsvp counts, friend attendance, user state, + // and a weighted relevance score (interests, timing, friends, org + // affinity, recency, popularity, a small daily nudge). Full breakdown, + // including the org-diversity cap and soon-event guarantee applied below: + // docs/ranking.md. const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = await Promise.all( rawEvents.map(async (event) => { // Get tags @@ -200,6 +268,18 @@ export async function getFeedEvents(params?: { .from(rsvps) .where(eq(rsvps.eventId, event.id)); + // Get view count for the popularity signal + const [viewCount] = await db + .select({ count: sql`count(*)::int` }) + .from(interactions) + .where( + and( + eq(interactions.itemId, event.id), + eq(interactions.itemType, "event"), + eq(interactions.interactionType, "view"), + ), + ); + // Get friends attending let friendsAttending: { id: string; displayName: string; avatarUrl: string | null }[] = []; if (friendIds.length > 0) { @@ -230,8 +310,7 @@ export async function getFeedEvents(params?: { // 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. + // Fraction of this event's tags that match your interests. const matchedTags = tagNames.filter((t) => myInterestTags.includes(t)).length; const interestRelevance = myInterestTags.length === 0 @@ -243,30 +322,41 @@ export async function getFeedEvents(params?: { 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; + // 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); - const orgAffinity = event.orgId && myOrgIds.has(event.orgId) ? 1.0 : 0.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?.count ?? 0) + 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; + 1.0 * recencyBoost + + POPULARITY_WEIGHT * popularityScore + + RANDOM_WEIGHT * randomNudge; return { id: event.id, @@ -288,15 +378,44 @@ export async function getFeedEvents(params?: { }), ); - // Sort by score descending; ties break by soonest event first, so - // refreshing Explore with no new data never reorders the feed. + // Sort by score descending; ties break by soonest first, so refreshing + // with no new data never reorders the feed. enriched.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a._rawDatetime.getTime() - b._rawDatetime.getTime(); }); + // Cap events per org — anything past the cap keeps its score order, just later. + const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); + + // Guarantee SOON_QUOTA imminent events on the first page by merging them + // in rather than overriding score order outright — this only backfills + // what score order left out. + let page = ranked.slice(offset, offset + limit); + if (offset === 0) { + const isSoon = (e: (typeof ranked)[number]) => + (e._rawDatetime.getTime() - Date.now()) / (1000 * 60 * 60 * 24) <= SOON_WINDOW_DAYS; + + const soonInPage = page.filter(isSoon).length; + if (soonInPage < SOON_QUOTA) { + const pageIds = new Set(page.map((e) => e.id)); + const missingSoon = ranked + .filter((e) => isSoon(e) && !pageIds.has(e.id)) + .slice(0, SOON_QUOTA - soonInPage); + + if (missingSoon.length > 0) { + const merged = [...page]; + const stride = Math.max(1, Math.floor(merged.length / (missingSoon.length + 1))); + missingSoon.forEach((event, i) => { + merged.splice(Math.min(merged.length, stride * (i + 1)), 0, event); + }); + page = merged.slice(0, limit); + } + } + } + return { - events: enriched.map(({ score: _score, _rawDatetime, ...event }) => event), + events: page.map(({ score: _score, _rawDatetime, ...event }) => event), total, }; } diff --git a/docs/ranking.md b/docs/ranking.md new file mode 100644 index 0000000..d906a0e --- /dev/null +++ b/docs/ranking.md @@ -0,0 +1,146 @@ +# 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 first pulls a bounded pool of the ~100 soonest upcoming events +(`CANDIDATE_POOL_SIZE`), scores all of them, sorts by score, and only then slices out the +requested page (`limit`/`offset`). 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. Widening the pool first lets a highly relevant +event further down the calendar outrank a less relevant one that merely happens sooner. The +size is a bound to keep the query cheap, not a hard limit on how far ranking can see — 100 +events is comfortably more than a demo dataset needs. + +## 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 page +(`offset === 0`) always includes at least `SOON_QUOTA` (3) events within `SOON_WINDOW_DAYS` +(1) day, even if their score wouldn't naturally place them there. + +This is a **merge**, not a score override: if the sorted first page already has enough soon +events, nothing changes. Otherwise the highest-scoring soon events missing from the page are +interleaved into it at evenly-spaced positions — 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. + +All the tunable constants above (`SOON_WINDOW_DAYS`, `SOON_QUOTA`, `ORG_DIVERSITY_CAP`, +`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. + +## Not implemented (deferred) + +- **Cursor-based pagination** — the API still uses offset/limit; Explore doesn't paginate + past the first page today, so this hasn't been needed yet. +- **Behavioral tag-weight blending & nightly aggregation job** — `user_preference_vectors` + exists in the schema but is never read or written. Blending onboarding interests with + interaction history needs a batch job — real added infrastructure, deliberately out of + scope for this MVP pass. Popularity above is the lightweight, no-batch-job alternative. +- **Similar Events / co-RSVP item-item similarity** — a separate feature, not part of + `getFeedEvents()`. +- **Position-bias correction** (downweighting previously-seen items) — would need + per-request interaction-log reads; not implemented. From f61da831be646adb40db0b06e5fdf66778d9b7bd Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 22:36:20 -0400 Subject: [PATCH 2/5] minor comment fixes --- apps/web/src/actions/events.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 51428fb..e7afa14 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -385,12 +385,10 @@ export async function getFeedEvents(params?: { return a._rawDatetime.getTime() - b._rawDatetime.getTime(); }); - // Cap events per org — anything past the cap keeps its score order, just later. + // Cap events per org const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); - // Guarantee SOON_QUOTA imminent events on the first page by merging them - // in rather than overriding score order outright — this only backfills - // what score order left out. + // Guarantee SOON_QUOTA imminent events on the first page let page = ranked.slice(offset, offset + limit); if (offset === 0) { const isSoon = (e: (typeof ranked)[number]) => From 7430f6c16e94237416766fb9ba1be009445753db Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 22:37:36 -0400 Subject: [PATCH 3/5] ranking description edits --- docs/ranking.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/ranking.md b/docs/ranking.md index d906a0e..be723cf 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -131,16 +131,3 @@ live at the top of `events.ts`. 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. - -## Not implemented (deferred) - -- **Cursor-based pagination** — the API still uses offset/limit; Explore doesn't paginate - past the first page today, so this hasn't been needed yet. -- **Behavioral tag-weight blending & nightly aggregation job** — `user_preference_vectors` - exists in the schema but is never read or written. Blending onboarding interests with - interaction history needs a batch job — real added infrastructure, deliberately out of - scope for this MVP pass. Popularity above is the lightweight, no-batch-job alternative. -- **Similar Events / co-RSVP item-item similarity** — a separate feature, not part of - `getFeedEvents()`. -- **Position-bias correction** (downweighting previously-seen items) — would need - per-request interaction-log reads; not implemented. From 1bbedac7a16bcdb79b1466277277c83e5507f5b9 Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Tue, 25 Aug 2026 18:00:27 -0400 Subject: [PATCH 4/5] Ranking window updates, pagination fixes, updated ranking description --- apps/web/src/actions/events.ts | 382 +++++++++++++++++++-------------- docs/ranking.md | 70 ++++-- 2 files changed, 268 insertions(+), 184 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index e7afa14..cf781cc 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -46,16 +46,25 @@ export interface FeedEvent { isSaved: boolean; } -// Upcoming events scored before slicing out the requested page — must be -// well above any realistic page size so personalization has real candidates -// to work with. See docs/ranking.md. -const CANDIDATE_POOL_SIZE = 100; - -// An event is "soon" if it's within this many days. The first page always -// includes at least SOON_QUOTA such events, even if their score is weak. +// 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; @@ -206,30 +215,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; - - // Score a bounded pool of upcoming events, not just the requested page — - // otherwise personalization could never surface anything past the - // soonest `limit` events. See docs/ranking.md. + // 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, @@ -247,136 +254,169 @@ export async function getFeedEvents(params?: { .leftJoin(organizations, eq(events.orgId, organizations.id)) .where(and(...conditions)) .orderBy(events.datetime) - .limit(CANDIDATE_POOL_SIZE); + .limit(CANDIDATE_POOL_SAFETY_VALVE); - // Enrich each event with tags, rsvp counts, friend attendance, user state, - // and a weighted relevance score (interests, timing, friends, org - // affinity, recency, popularity, a small daily nudge). Full breakdown, - // including the org-diversity cap and soon-event guarantee applied below: - // docs/ranking.md. - const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = await Promise.all( - rawEvents.map(async (event) => { - // Get tags - const tags = await db - .select({ tag: eventTags.tag }) + if (rawEvents.length === 0) { + return { events: [], total: 0 }; + } + + // 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; + + 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(eq(eventTags.eventId, event.id)); + .where(inArray(eventTags.eventId, candidateIds)), - // Get RSVP count - const [rsvpCount] = await db - .select({ count: sql`count(*)::int` }) + db + .select({ eventId: rsvps.eventId, count: sql`count(*)::int` }) .from(rsvps) - .where(eq(rsvps.eventId, event.id)); + .where(inArray(rsvps.eventId, candidateIds)) + .groupBy(rsvps.eventId), - // Get view count for the popularity signal - const [viewCount] = await db - .select({ count: sql`count(*)::int` }) + db + .select({ eventId: interactions.itemId, count: sql`count(*)::int` }) .from(interactions) .where( and( - eq(interactions.itemId, event.id), + inArray(interactions.itemId, candidateIds), eq(interactions.itemType, "event"), eq(interactions.interactionType, "view"), ), - ); - - // 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))); - } - - // Check if user has RSVP'd or saved - const [userRsvp] = await db - .select() + ) + .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 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?.count ?? 0) + 1) / Math.log(POPULARITY_VIEW_CAP + 1), - ); + .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]); + } - // 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: rsvpCount?.count ?? 0, - friendsAttending, - isRsvped: !!userRsvp, - isSaved: !!userSave, - score, - _rawDatetime: event.datetime, - }; - }), - ); + 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. @@ -388,30 +428,42 @@ export async function getFeedEvents(params?: { // Cap events per org const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); - // Guarantee SOON_QUOTA imminent events on the first page - let page = ranked.slice(offset, offset + limit); - if (offset === 0) { - const isSoon = (e: (typeof ranked)[number]) => - (e._rawDatetime.getTime() - Date.now()) / (1000 * 60 * 60 * 24) <= SOON_WINDOW_DAYS; - - const soonInPage = page.filter(isSoon).length; - if (soonInPage < SOON_QUOTA) { - const pageIds = new Set(page.map((e) => e.id)); - const missingSoon = ranked - .filter((e) => isSoon(e) && !pageIds.has(e.id)) - .slice(0, SOON_QUOTA - soonInPage); - - if (missingSoon.length > 0) { - const merged = [...page]; - const stride = Math.max(1, Math.floor(merged.length / (missingSoon.length + 1))); - missingSoon.forEach((event, i) => { - merged.splice(Math.min(merged.length, stride * (i + 1)), 0, event); - }); - page = merged.slice(0, limit); - } + // Guarantee SOON_QUOTA imminent events land within the first + // SOON_INJECTION_WINDOW positions. 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)); + const missingSoon = tail + .filter((e) => isSoon(e) && !frontIds.has(e.id)) + .slice(0, SOON_QUOTA - soonInFront); + + 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: page.map(({ score: _score, _rawDatetime, ...event }) => event), total, diff --git a/docs/ranking.md b/docs/ranking.md index be723cf..0ff8b43 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -90,14 +90,38 @@ by soonest event first. ## Candidate pool: why scoring needs more than one page -The DB query first pulls a bounded pool of the ~100 soonest upcoming events -(`CANDIDATE_POOL_SIZE`), scores all of them, sorts by score, and only then slices out the -requested page (`limit`/`offset`). 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. Widening the pool first lets a highly relevant -event further down the calendar outrank a less relevant one that merely happens sooner. The -size is a bound to keep the query cheap, not a hard limit on how far ranking can see — 100 -events is comfortably more than a demo dataset needs. +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 @@ -109,17 +133,25 @@ org can't dominate the top of the feed. Events without an org are never capped. 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 page -(`offset === 0`) always includes at least `SOON_QUOTA` (3) events within `SOON_WINDOW_DAYS` -(1) day, even if their score wouldn't naturally place them there. - -This is a **merge**, not a score override: if the sorted first page already has enough soon -events, nothing changes. Otherwise the highest-scoring soon events missing from the page are -interleaved into it at evenly-spaced positions — 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. - -All the tunable constants above (`SOON_WINDOW_DAYS`, `SOON_QUOTA`, `ORG_DIVERSITY_CAP`, +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. + +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`. From 800b4ee59bc71d646a5c299740bfa7f2379ea4ac Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 27 Aug 2026 21:08:41 -0400 Subject: [PATCH 5/5] fixed org cap vs soon event minimum conflict --- apps/web/src/actions/events.ts | 30 +++++++++++++++++++++++------- docs/ranking.md | 5 +++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 773acc4..40ed474 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -571,10 +571,10 @@ export async function getFeedEvents(params?: { const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); // Guarantee SOON_QUOTA imminent events land within the first - // SOON_INJECTION_WINDOW positions. 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. + // 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; @@ -587,9 +587,25 @@ export async function getFeedEvents(params?: { if (soonInFront < SOON_QUOTA) { const frontIds = new Set(front.map((e) => e.id)); - const missingSoon = tail - .filter((e) => isSoon(e) && !frontIds.has(e.id)) - .slice(0, SOON_QUOTA - soonInFront); + + // 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)); diff --git a/docs/ranking.md b/docs/ranking.md index 0ff8b43..58dbb2d 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -150,6 +150,11 @@ interleaved into it at evenly-spaced positions, removed from their original late 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`)