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:
+12
-56
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user