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
This commit is contained in:
2026-07-15 14:33:46 -07:00
committed by Hop
parent bd7fbf004e
commit 186c166cd1
4 changed files with 327 additions and 59 deletions
+12 -56
View File
@@ -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)
+6 -3
View File
@@ -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() {
<div className="feed-meta card">
<div className="feed-meta-title">For You</div>
<div className="feed-meta-body">
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.
</div>
</div>
)}
+89
View File
@@ -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> = {},
): 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),
);
});
});
+220
View File
@@ -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<string, number>, 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<string, number>();
const nodeCounts = new Map<string, number>();
const formatCounts = new Map<string, number>();
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;
}