Skip to content

Commit 9eb6bda

Browse files
committed
Fix For You feed: drop self-recs and space out same-author posts
Two ranking bugs reported by users, both in the shared finalize() back-half of the ranker: - Own posts appeared in the feed. In pure CF a taste-neighbor liking your post makes it a candidate, so you got recommended back to yourself. Thread the viewer DID into finalize() and drop posts whose author is the viewer. Applied across all three rankers (graph, CF, and cold-start popularity, which a seedless signed-in viewer also hits). - 2-3 posts by the same author in a row. perAuthorCap limited an author's total count but output was strictly score-sorted, so their posts clustered. Replace the cap-only loop with spaced round-robin: per-author buckets emitted highest-score-first, skipping any author seen within the last FEEDGEN_AUTHOR_MIN_GAP slots. Best-effort — the gap relaxes when only one author is left, so no content is dropped. New knob FEEDGEN_AUTHOR_MIN_GAP (default 3), no code change needed to tune. No API change; defaults live in code.
1 parent d3636ba commit 9eb6bda

7 files changed

Lines changed: 60 additions & 11 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,5 @@ FEEDGEN_RANKER="graph"
9292
# FEEDGEN_MAX_FEED_SIZE=1000 # cached ranked-list length
9393
# FEEDGEN_INCLUDE_REPLIES=false # true to include replies
9494
# FEEDGEN_PER_AUTHOR_CAP=3 # diversification: max posts per author
95+
# FEEDGEN_AUTHOR_MIN_GAP=3 # diversification: min slots between an author's posts (0 = off)
9596
# FEEDGEN_CACHE_TTL_SECONDS=600 # per-viewer ranked-list cache TTL

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export type RankingConfig = {
124124
includeReplies: boolean
125125
// diversification: max posts from a single author in the final list
126126
perAuthorCap: number
127+
// diversification: minimum number of slots between two posts by the same
128+
// author, so an author's capped posts are spread out instead of clustered.
129+
// Best-effort — relaxed when no other author is available (0 = off).
130+
authorMinGap: number
127131
// TTL of the cached per-viewer ranked list, in seconds
128132
cacheTtlSeconds: number
129133
// On a viewer's first request, how many of their most-recent likes to import

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const run = async () => {
4949
maxFeedSize: maybeInt(process.env.FEEDGEN_MAX_FEED_SIZE) ?? 1000,
5050
includeReplies: process.env.FEEDGEN_INCLUDE_REPLIES === 'true',
5151
perAuthorCap: maybeInt(process.env.FEEDGEN_PER_AUTHOR_CAP) ?? 3,
52+
authorMinGap: maybeInt(process.env.FEEDGEN_AUTHOR_MIN_GAP) ?? 3,
5253
cacheTtlSeconds: maybeInt(process.env.FEEDGEN_CACHE_TTL_SECONDS) ?? 600,
5354
inlineBackfillLimit:
5455
maybeInt(process.env.FEEDGEN_INLINE_BACKFILL_LIMIT) ?? 100,

src/ranker/collaborative.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export class CollaborativeFilterRanker implements Ranker {
124124
}
125125
if (rawScores.size === 0) return []
126126

127-
return finalize(ctx, rawScores, { applyPopularityPenalty: true, content })
127+
return finalize(ctx, rawScores, {
128+
applyPopularityPenalty: true,
129+
content,
130+
viewerDid,
131+
})
128132
}
129133
}

src/ranker/finalize.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export type FinalizeOptions = {
1313
// least one with the allowlist — undeclared posts always pass, so the feed
1414
// biases toward these languages without starving. Empty/undefined = off.
1515
languages?: string[]
16+
// the requesting viewer's DID. Their own authored posts are dropped so the
17+
// feed never recommends you back to yourself (a taste-neighbor liking your
18+
// post makes it a candidate). null/undefined for anonymous viewers.
19+
viewerDid?: string | null
1620
}
1721

1822
// Shared back half of every ranker: hydrate candidate metadata, apply
@@ -36,6 +40,7 @@ export const finalize = async (
3640
for (const [uri, raw] of rawScores) {
3741
const meta = metas.get(uri)
3842
if (!meta) continue // unhydratable (deleted/blocked) — drop
43+
if (opts.viewerDid && meta.author_did === opts.viewerDid) continue // no self-recs
3944
if (meta.is_adult) continue
4045
if (meta.is_reply && !cfg.includeReplies) continue // top-level posts only
4146
if (opts.content === 'image' && !meta.is_image) continue
@@ -62,15 +67,48 @@ export const finalize = async (
6267

6368
scored.sort((a, b) => b.score - a.score)
6469

65-
// Diversification: cap how many posts a single author can contribute.
66-
const perAuthor = new Map<string, number>()
67-
const out: string[] = []
70+
// Diversification: cap each author's total contribution AND space their posts
71+
// apart so the feed never shows a run of the same author. Bucket per author in
72+
// descending score order (scored is already sorted, so appending preserves
73+
// it), capped at perAuthorCap.
74+
const buckets = new Map<string, { uri: string; score: number }[]>()
6875
for (const item of scored) {
69-
const count = perAuthor.get(item.author) ?? 0
70-
if (count >= cfg.perAuthorCap) continue
71-
perAuthor.set(item.author, count + 1)
72-
out.push(item.uri)
73-
if (out.length >= cfg.maxFeedSize) break
76+
let bucket = buckets.get(item.author)
77+
if (!bucket) {
78+
bucket = []
79+
buckets.set(item.author, bucket)
80+
}
81+
if (bucket.length < cfg.perAuthorCap) bucket.push(item)
82+
}
83+
84+
// Emit by repeatedly taking the highest-scoring available post whose author
85+
// hasn't appeared within the last `authorMinGap` slots. If every remaining
86+
// author is inside that window (e.g. only one author is left), relax the gap
87+
// and take the best available anyway — spacing is best-effort and never a
88+
// reason to drop otherwise-eligible content.
89+
const heads = [...buckets.entries()].map(([author, items]) => ({
90+
author,
91+
items,
92+
ptr: 0,
93+
}))
94+
const lastSlot = new Map<string, number>() // author → slot of their last post
95+
const out: string[] = []
96+
while (out.length < cfg.maxFeedSize) {
97+
let best: (typeof heads)[number] | null = null
98+
let fallback: (typeof heads)[number] | null = null
99+
for (const h of heads) {
100+
if (h.ptr >= h.items.length) continue
101+
const score = h.items[h.ptr].score
102+
if (!fallback || score > fallback.items[fallback.ptr].score) fallback = h
103+
const last = lastSlot.get(h.author)
104+
if (last !== undefined && out.length - last <= cfg.authorMinGap) continue
105+
if (!best || score > best.items[best.ptr].score) best = h
106+
}
107+
const chosen = best ?? fallback
108+
if (!chosen) break // all buckets drained
109+
out.push(chosen.items[chosen.ptr].uri)
110+
chosen.ptr++
111+
lastSlot.set(chosen.author, out.length - 1)
74112
}
75113

76114
return out

src/ranker/graph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,6 @@ export class GraphRanker implements Ranker {
6161

6262
return raw.size === 0
6363
? []
64-
: finalize(ctx, raw, { applyPopularityPenalty: true, content })
64+
: finalize(ctx, raw, { applyPopularityPenalty: true, content, viewerDid })
6565
}
6666
}

src/ranker/popularity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { finalize } from './finalize'
1010
export class PopularityRanker implements Ranker {
1111
async rank(
1212
ctx: AppContext,
13-
_viewerDid: string | null,
13+
viewerDid: string | null,
1414
content: ContentFilter,
1515
languages: string[] = [],
1616
): Promise<string[]> {
@@ -42,6 +42,7 @@ export class PopularityRanker implements Ranker {
4242
applyPopularityPenalty: false,
4343
content,
4444
languages,
45+
viewerDid,
4546
})
4647
}
4748
}

0 commit comments

Comments
 (0)