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 { hasPublishablePostContent } from '@/lib/posts/content-policy';
|
||||||
import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination';
|
import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination';
|
||||||
import { mapSwarmPostToPost } from '@/lib/swarm/feed-post';
|
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 POST_MAX_LENGTH = 600;
|
||||||
const CURATION_WINDOW_HOURS = 72;
|
|
||||||
const CURATION_SEED_MULTIPLIER = 5;
|
const CURATION_SEED_MULTIPLIER = 5;
|
||||||
const CURATION_SEED_CAP = 200;
|
const CURATION_SEED_CAP = 200;
|
||||||
|
|
||||||
@@ -777,58 +781,13 @@ export async function GET(request: Request) {
|
|||||||
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
|
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const eligiblePosts = swarmPosts
|
||||||
const rankedPosts = swarmPosts
|
.filter((post) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id));
|
||||||
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
const rankedPosts = rankCuratedFeed(eligiblePosts, { limit });
|
||||||
.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);
|
|
||||||
|
|
||||||
console.log('[Curated Feed] After ranking:', {
|
console.log('[Curated Feed] After ranking:', {
|
||||||
swarmPostsCount: swarmPosts.length,
|
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,
|
rankedPostsCount: rankedPosts.length,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -1066,14 +1025,11 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
posts: feedPosts || [],
|
posts: feedPosts || [],
|
||||||
meta: type === 'curated' ? {
|
meta: type === 'curated' ? {
|
||||||
algorithm: 'curated-v1',
|
algorithm: 'curated-v2-diversity',
|
||||||
windowHours: CURATION_WINDOW_HOURS,
|
windowHours: CURATED_FEED_WINDOW_HOURS,
|
||||||
seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP),
|
seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP),
|
||||||
weights: {
|
weights: {
|
||||||
engagement: 1.4,
|
...CURATED_FEED_WEIGHTS,
|
||||||
recency: 1.1,
|
|
||||||
followBoost: 0.9,
|
|
||||||
selfBoost: 0.5,
|
|
||||||
},
|
},
|
||||||
} : undefined,
|
} : undefined,
|
||||||
nextCursor: (feedPosts?.length === limit)
|
nextCursor: (feedPosts?.length === limit)
|
||||||
|
|||||||
+6
-3
@@ -37,8 +37,11 @@ export default function Home() {
|
|||||||
weights: {
|
weights: {
|
||||||
engagement: number;
|
engagement: number;
|
||||||
recency: number;
|
recency: number;
|
||||||
followBoost: number;
|
authorRepeat: number;
|
||||||
selfBoost: number;
|
nodeRepeat: number;
|
||||||
|
formatRepeat: number;
|
||||||
|
consecutiveAuthor: number;
|
||||||
|
consecutiveNode: number;
|
||||||
};
|
};
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
@@ -264,7 +267,7 @@ export default function Home() {
|
|||||||
<div className="feed-meta card">
|
<div className="feed-meta card">
|
||||||
<div className="feed-meta-title">For You</div>
|
<div className="feed-meta-title">For You</div>
|
||||||
<div className="feed-meta-body">
|
<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>
|
||||||
</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