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;
+}