From 186c166cd19d33744d4785f0ded99b93df06e11e Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 14:33:46 -0700 Subject: [PATCH] Rerank For You with author, node, and format diversity instead of a simple engagement-recency sort. Hop-State: A_06FPFCSQ2DF5YNZ6NCXG3K0 Hop-Proposal: R_06FPFCRWENXCMXSRAPCPCQG Hop-Task: T_06FPFBCVBZERFEC50S01FEG Hop-Attempt: AT_06FPFBCVBXT4X8XRB1RY13R --- src/app/api/posts/route.ts | 68 ++------- src/app/page.tsx | 9 +- src/lib/posts/curated-feed.test.ts | 89 ++++++++++++ src/lib/posts/curated-feed.ts | 220 +++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 src/lib/posts/curated-feed.test.ts create mode 100644 src/lib/posts/curated-feed.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 56b9ea7..293e531 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -11,9 +11,13 @@ import { isLocalNodeNsfw } from '@/lib/node/local-node'; import { hasPublishablePostContent } from '@/lib/posts/content-policy'; import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination'; import { mapSwarmPostToPost } from '@/lib/swarm/feed-post'; +import { + CURATED_FEED_WEIGHTS, + CURATED_FEED_WINDOW_HOURS, + rankCuratedFeed, +} from '@/lib/posts/curated-feed'; const POST_MAX_LENGTH = 600; -const CURATION_WINDOW_HOURS = 72; const CURATION_SEED_MULTIPLIER = 5; const CURATION_SEED_CAP = 200; @@ -777,58 +781,13 @@ export async function GET(request: Request) { blockedIds = new Set(blockRows.map(row => row.blockedUserId)); } - const now = Date.now(); - const rankedPosts = swarmPosts - .filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)) - .map((post: any) => { - const createdAt = new Date(post.createdAt).getTime(); - const ageHours = Math.max(0, (now - createdAt) / 3600000); - const engagement = (post.likesCount || 0) + (post.repostsCount || 0) * 2 + (post.repliesCount || 0) * 0.5; - const engagementScore = Math.log1p(Math.max(0, engagement)); - const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS); - - const score = engagementScore * 1.4 + recencyScore * 1.1; - - const reasons: string[] = []; - reasons.push(`From ${post.nodeDomain}`); - if (engagement >= 5) { - reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`); - } else if ((post.repliesCount || 0) > 0) { - reasons.push(`Active conversation: ${post.repliesCount} replies`); - } - if (ageHours <= 6) { - reasons.push('Posted recently'); - } else if (ageHours <= 24) { - reasons.push('Posted today'); - } - if (reasons.length === 1) { - reasons.push('New post'); - } - - return { - ...post, - feedMeta: { - score: Number(score.toFixed(3)), - reasons, - engagement: { - likes: post.likesCount || 0, - reposts: post.repostsCount || 0, - replies: post.repliesCount || 0, - }, - }, - }; - }) - .sort((a: any, b: any) => { - if (b.feedMeta.score !== a.feedMeta.score) { - return b.feedMeta.score - a.feedMeta.score; - } - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }) - .slice(0, limit); + const eligiblePosts = swarmPosts + .filter((post) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)); + const rankedPosts = rankCuratedFeed(eligiblePosts, { limit }); console.log('[Curated Feed] After ranking:', { swarmPostsCount: swarmPosts.length, - afterMuteFilter: swarmPosts.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)).length, + afterMuteFilter: eligiblePosts.length, rankedPostsCount: rankedPosts.length, limit, }); @@ -1066,14 +1025,11 @@ export async function GET(request: Request) { return NextResponse.json({ posts: feedPosts || [], meta: type === 'curated' ? { - algorithm: 'curated-v1', - windowHours: CURATION_WINDOW_HOURS, + algorithm: 'curated-v2-diversity', + windowHours: CURATED_FEED_WINDOW_HOURS, seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP), weights: { - engagement: 1.4, - recency: 1.1, - followBoost: 0.9, - selfBoost: 0.5, + ...CURATED_FEED_WEIGHTS, }, } : undefined, nextCursor: (feedPosts?.length === limit) diff --git a/src/app/page.tsx b/src/app/page.tsx index 01a1f68..6963202 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -37,8 +37,11 @@ export default function Home() { weights: { engagement: number; recency: number; - followBoost: number; - selfBoost: number; + authorRepeat: number; + nodeRepeat: number; + formatRepeat: number; + consecutiveAuthor: number; + consecutiveNode: number; }; } | null>(null); @@ -264,7 +267,7 @@ export default function Home() {
For You
- This feed highlights fresh posts and active discussions, with a boost for people you follow. It is designed to surface what matters without hiding your own activity. + This feed balances fresh posts and active discussions with a varied mix of people, communities, and post types.
)} diff --git a/src/lib/posts/curated-feed.test.ts b/src/lib/posts/curated-feed.test.ts new file mode 100644 index 0000000..328190a --- /dev/null +++ b/src/lib/posts/curated-feed.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import type { Post } from '@/lib/types'; +import { rankCuratedFeed } from './curated-feed'; + +const NOW = Date.parse('2026-07-15T20:00:00Z'); + +function post( + id: string, + author: string, + nodeDomain: string, + ageHours: number, + options: Partial = {}, +): Post { + return { + id, + content: id, + createdAt: new Date(NOW - ageHours * 3_600_000).toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author: { + id: `${nodeDomain}:${author}`, + handle: author, + displayName: author, + nodeDomain, + }, + nodeDomain, + ...options, + }; +} + +describe('rankCuratedFeed', () => { + it('spreads authors instead of preserving a mostly chronological run', () => { + const ranked = rankCuratedFeed([ + post('a-newest', 'alice', 'one.social', 0), + post('a-second', 'alice', 'one.social', 1), + post('a-third', 'alice', 'one.social', 2), + post('b', 'bob', 'one.social', 3), + post('c', 'carol', 'two.social', 4), + ], { now: NOW, limit: 5 }); + + expect(ranked[0].id).toBe('a-newest'); + expect(ranked.slice(0, 3).map((item) => item.author.handle)).toEqual(['alice', 'carol', 'bob']); + }); + + it('mixes nodes and formats when candidates have similar relevance', () => { + const ranked = rankCuratedFeed([ + post('one-text', 'alice', 'one.social', 0), + post('one-text-2', 'bob', 'one.social', 1), + post('two-media', 'carol', 'two.social', 2, { + media: [{ id: 'media', url: 'https://stuffbox.xyz/media' }], + }), + post('three-link', 'dave', 'three.social', 3, { + linkPreviewUrl: 'https://example.org/story', + }), + ], { now: NOW, limit: 4 }); + + expect(ranked.slice(0, 3).map((item) => item.nodeDomain)).toEqual([ + 'one.social', + 'two.social', + 'three.social', + ]); + expect(ranked[1].feedMeta.reasons).toContain('A different community'); + expect(ranked[1].feedMeta.reasons).toContain('A different kind of post'); + }); + + it('inserts a different voice between highly engaged posts from the same source', () => { + const ranked = rankCuratedFeed([ + post('viral-one', 'alice', 'one.social', 1, { likesCount: 100 }), + post('viral-two', 'alice', 'one.social', 2, { likesCount: 100 }), + post('quiet', 'bob', 'two.social', 4), + ], { now: NOW, limit: 3 }); + + expect(ranked.map((item) => item.id)).toEqual(['viral-one', 'quiet', 'viral-two']); + expect(ranked[2].feedMeta.diversity.authorPenalty).toBeGreaterThan(0); + }); + + it('is deterministic for the same candidate pool and time', () => { + const candidates = [ + post('a', 'alice', 'one.social', 1), + post('b', 'bob', 'two.social', 1), + post('c', 'carol', 'three.social', 1), + ]; + + expect(rankCuratedFeed(candidates, { now: NOW }).map((item) => item.id)).toEqual( + rankCuratedFeed(candidates, { now: NOW }).map((item) => item.id), + ); + }); +}); diff --git a/src/lib/posts/curated-feed.ts b/src/lib/posts/curated-feed.ts new file mode 100644 index 0000000..916b9c9 --- /dev/null +++ b/src/lib/posts/curated-feed.ts @@ -0,0 +1,220 @@ +import type { Post } from '@/lib/types'; + +export const CURATED_FEED_WINDOW_HOURS = 72; + +export const CURATED_FEED_WEIGHTS = { + engagement: 1, + recency: 0.45, + authorRepeat: 1.15, + nodeRepeat: 0.4, + formatRepeat: 0.18, + consecutiveAuthor: 0.85, + consecutiveNode: 0.25, +} as const; + +type CuratedFormat = 'repost' | 'media' | 'link' | 'text'; + +export interface CuratedFeedMeta { + score: number; + baseScore: number; + reasons: string[]; + engagement: { + likes: number; + reposts: number; + replies: number; + }; + diversity: { + authorPenalty: number; + nodePenalty: number; + formatPenalty: number; + adjacencyPenalty: number; + }; +} + +export type CuratedFeedPost = Post & { feedMeta: CuratedFeedMeta }; + +interface RankOptions { + limit?: number; + now?: number; + windowHours?: number; +} + +interface Candidate { + post: Post; + authorKey: string; + nodeKey: string; + format: CuratedFormat; + createdAt: number; + ageHours: number; + engagement: number; + baseScore: number; +} + +function normalizedHandle(handle: string): string { + return handle.trim().toLowerCase().replace(/^@/, '').split('@')[0]; +} + +function nodeKeyFor(post: Post): string { + return (post.nodeDomain || post.author.nodeDomain || 'local').trim().toLowerCase(); +} + +function authorKeyFor(post: Post): string { + return `${nodeKeyFor(post)}:${normalizedHandle(post.author.handle)}`; +} + +function formatFor(post: Post): CuratedFormat { + if (post.repostOf || post.repostOfId) return 'repost'; + if (post.media?.length) return 'media'; + if (post.linkPreviewUrl) return 'link'; + return 'text'; +} + +function increment(map: Map, key: string) { + map.set(key, (map.get(key) || 0) + 1); +} + +function roundScore(value: number): number { + return Number(value.toFixed(3)); +} + +function baseReasons(candidate: Candidate): string[] { + const post = candidate.post; + const reasons = [`From ${candidate.nodeKey}`]; + + if (candidate.engagement >= 5) { + reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`); + } else if ((post.repliesCount || 0) > 0) { + reasons.push(`Active conversation: ${post.repliesCount} replies`); + } + + if (candidate.ageHours <= 6) { + reasons.push('Posted recently'); + } else if (candidate.ageHours <= 24) { + reasons.push('Posted today'); + } + + return reasons; +} + +/** + * Rank a relevance-scored pool with a maximal-marginal-relevance style pass. + * Repeated authors, nodes, and formats receive progressively larger penalties, + * with extra protection against adjacent posts from the same source. + */ +export function rankCuratedFeed(posts: Post[], options: RankOptions = {}): CuratedFeedPost[] { + const now = options.now ?? Date.now(); + const limit = Math.max(0, options.limit ?? posts.length); + const windowHours = options.windowHours ?? CURATED_FEED_WINDOW_HOURS; + + const remaining: Candidate[] = posts.map((post) => { + const createdAt = new Date(post.createdAt).getTime(); + const ageHours = Number.isFinite(createdAt) + ? Math.max(0, (now - createdAt) / 3_600_000) + : windowHours; + const engagement = (post.likesCount || 0) + + (post.repostsCount || 0) * 2 + + (post.repliesCount || 0) * 0.75; + const engagementScore = Math.min(2.5, Math.log1p(Math.max(0, engagement))); + const recencyScore = Math.max(0, 1 - ageHours / windowHours); + + return { + post, + authorKey: authorKeyFor(post), + nodeKey: nodeKeyFor(post), + format: formatFor(post), + createdAt: Number.isFinite(createdAt) ? createdAt : 0, + ageHours, + engagement, + baseScore: engagementScore * CURATED_FEED_WEIGHTS.engagement + + recencyScore * CURATED_FEED_WEIGHTS.recency, + }; + }); + + const authorCounts = new Map(); + const nodeCounts = new Map(); + const formatCounts = new Map(); + const selected: CuratedFeedPost[] = []; + let previous: Candidate | null = null; + + while (remaining.length > 0 && selected.length < limit) { + let bestIndex = 0; + let bestScore = Number.NEGATIVE_INFINITY; + let bestDiversity = { + authorPenalty: 0, + nodePenalty: 0, + formatPenalty: 0, + adjacencyPenalty: 0, + }; + + remaining.forEach((candidate, index) => { + const authorRepeats = authorCounts.get(candidate.authorKey) || 0; + const nodeRepeats = nodeCounts.get(candidate.nodeKey) || 0; + const formatRepeats = formatCounts.get(candidate.format) || 0; + const authorPenalty = authorRepeats === 0 + ? 0 + : CURATED_FEED_WEIGHTS.authorRepeat * (1 + (authorRepeats - 1) * 0.35); + const nodePenalty = nodeRepeats * CURATED_FEED_WEIGHTS.nodeRepeat; + const formatPenalty = formatRepeats * CURATED_FEED_WEIGHTS.formatRepeat; + const adjacencyPenalty = previous + ? (previous.authorKey === candidate.authorKey ? CURATED_FEED_WEIGHTS.consecutiveAuthor : 0) + + (previous.nodeKey === candidate.nodeKey ? CURATED_FEED_WEIGHTS.consecutiveNode : 0) + : 0; + const score = candidate.baseScore - authorPenalty - nodePenalty - formatPenalty - adjacencyPenalty; + + const bestCandidate = remaining[bestIndex]; + if ( + score > bestScore + || (score === bestScore && candidate.baseScore > bestCandidate.baseScore) + || (score === bestScore && candidate.baseScore === bestCandidate.baseScore && candidate.createdAt > bestCandidate.createdAt) + || (score === bestScore && candidate.baseScore === bestCandidate.baseScore + && candidate.createdAt === bestCandidate.createdAt && candidate.post.id < bestCandidate.post.id) + ) { + bestIndex = index; + bestScore = score; + bestDiversity = { authorPenalty, nodePenalty, formatPenalty, adjacencyPenalty }; + } + }); + + const [winner] = remaining.splice(bestIndex, 1); + const reasons = baseReasons(winner); + if (selected.length > 0 && (authorCounts.get(winner.authorKey) || 0) === 0) { + reasons.push('A different voice'); + } + if (selected.length > 0 && (nodeCounts.get(winner.nodeKey) || 0) === 0) { + reasons.push('A different community'); + } + if (selected.length > 0 && (formatCounts.get(winner.format) || 0) === 0) { + reasons.push('A different kind of post'); + } + if (reasons.length === 1) { + reasons.push('Worth discovering'); + } + + selected.push({ + ...winner.post, + feedMeta: { + score: roundScore(bestScore), + baseScore: roundScore(winner.baseScore), + reasons, + engagement: { + likes: winner.post.likesCount || 0, + reposts: winner.post.repostsCount || 0, + replies: winner.post.repliesCount || 0, + }, + diversity: { + authorPenalty: roundScore(bestDiversity.authorPenalty), + nodePenalty: roundScore(bestDiversity.nodePenalty), + formatPenalty: roundScore(bestDiversity.formatPenalty), + adjacencyPenalty: roundScore(bestDiversity.adjacencyPenalty), + }, + }, + }); + + increment(authorCounts, winner.authorKey); + increment(nodeCounts, winner.nodeKey); + increment(formatCounts, winner.format); + previous = winner; + } + + return selected; +}