Skip to content

Commit c779623

Browse files
committed
bring in more recency while widening the time window a little at the same time
1 parent aae8cc9 commit c779623

7 files changed

Lines changed: 79 additions & 6 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ FEEDGEN_RANKER="graph"
6868
# FEEDGEN_CANDIDATE_WINDOW_HOURS=24 # a candidate's like must be this recent
6969
# FEEDGEN_FRESHNESS_HOURS=24 # drop posts created older than this
7070
# FEEDGEN_HALF_LIFE_HOURS=6 # post-age time-decay half-life
71+
# FEEDGEN_CANDIDATE_RECENCY_HALFLIFE_HOURS=12 # like-recency half-life at candidate selection (0=off)
7172
# FEEDGEN_SMOOTHING=0.5 # path-count exponent (boosts multi-path posts)
7273
# FEEDGEN_POPULARITY_PENALTY=0.3 # score /= likeCount^penalty
7374
# FEEDGEN_CURATOR_BRANCHING_POWER=1 # normalization (0 = off)

src/algos/for-you.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88
ensureViewerBackfilled,
99
backfillSeedColikers,
1010
} from '../ranker/backfill'
11-
import { cacheRankedList, getRankedList, rankedListKey, getSeen } from '../redis'
11+
import {
12+
cacheRankedList,
13+
recacheRankedList,
14+
getRankedList,
15+
rankedListKey,
16+
getSeen,
17+
} from '../redis'
1218

1319
const cfRanker: Ranker = new CollaborativeFilterRanker()
1420
const graphRanker: Ranker = new GraphRanker()
@@ -28,6 +34,7 @@ export const handler = async (
2834
feed: FeedDef,
2935
) => {
3036
const cacheKey = rankedListKey(feed.rkey, viewerDid)
37+
const offset = parseCursor(params.cursor)
3138

3239
let ranked = await getRankedList(ctx.redis, cacheKey)
3340
if (!ranked) {
@@ -47,9 +54,15 @@ export const handler = async (
4754
// (never blocks the skeleton response). On completion it invalidates this
4855
// viewer's cached lists so the next load reflects the denser graph.
4956
if (viewerDid) void backfillSeedColikers(ctx, viewerDid)
57+
} else if (offset === 0 && viewerDid) {
58+
// A no-cursor request is a refresh: re-demote posts seen since this snapshot
59+
// was built so the reload surfaces the next unseen posts. Cheap — reuses the
60+
// cached set and preserves the TTL, so the periodic recompute still brings in
61+
// genuinely-new graph content on schedule.
62+
ranked = await orderBySeen(ctx, viewerDid, ranked)
63+
await recacheRankedList(ctx.redis, cacheKey, ranked)
5064
}
5165

52-
const offset = parseCursor(params.cursor)
5366
const slice = ranked.slice(offset, offset + params.limit)
5467
const nextOffset = offset + slice.length
5568
const cursor = nextOffset < ranked.length ? String(nextOffset) : undefined

src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export type RankingConfig = {
9292
freshnessHours: number
9393
// exponential time-decay half-life on post age, in hours
9494
halfLifeHours: number
95+
// exponential decay half-life on a candidate's most-recent co-liker like, in
96+
// hours — applied at candidate SELECTION so fresh, lightly-corroborated posts
97+
// compete for the top-N instead of being cut before the post-age decay runs.
98+
// 0 = off (pure corroboration ordering).
99+
candidateRecencyHalfLifeHours: number
95100
// path-count exponent — boosts posts reached via many independent paths
96101
smoothing: number
97102
// popularity penalty exponent: score divided by likeCount^penalty

src/graph/csr-like-graph.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,16 @@ export class CsrLikeGraph implements ILikeGraph {
314314
)
315315
const scoreAcc = new Map<number, number>()
316316
const raters = new Map<number, number>()
317+
// most-recent co-liker-like time (tsMin) per candidate — drives the recency
318+
// weight applied at selection so fresh posts survive the top-N cut.
319+
const lastTs = new Map<number, number>()
317320
const cand: number[] = []
318321
const candRn: number[] = []
322+
const candTs: number[] = []
319323
for (const [c, wc] of curators) {
320324
cand.length = 0
321325
candRn.length = 0
326+
candTs.length = 0
322327
const seenPosts = new Set<number>()
323328
let rn = 0
324329
let stop = false
@@ -340,6 +345,7 @@ export class CsrLikeGraph implements ILikeGraph {
340345
seenPosts.add(post)
341346
cand.push(post)
342347
candRn.push(++rn)
348+
candTs.push(ts)
343349
if (++visits > budget) {
344350
stop = true
345351
break
@@ -361,6 +367,7 @@ export class CsrLikeGraph implements ILikeGraph {
361367
seenPosts.add(post)
362368
cand.push(post)
363369
candRn.push(++rn)
370+
candTs.push(ts)
364371
if (++visits > budget) break
365372
}
366373
}
@@ -375,15 +382,27 @@ export class CsrLikeGraph implements ILikeGraph {
375382
r.coraterDecay > 0 ? Math.pow(1 - r.coraterDecay, candRn[t] - 1) : 1
376383
scoreAcc.set(post, (scoreAcc.get(post) ?? 0) + norm * factor)
377384
raters.set(post, (raters.get(post) ?? 0) + 1)
385+
const prev = lastTs.get(post)
386+
if (prev === undefined || candTs[t] > prev) lastTs.set(post, candTs[t])
378387
}
379388
if (visits > budget) break
380389
}
381390

382-
// 5. eligibility + num_paths^smoothing, top maxCandidates
391+
// 5. eligibility + num_paths^smoothing, weighted by co-liker-like recency so
392+
// fresh (recently-liked) posts survive the top-maxCandidates cut instead of
393+
// being dropped before finalize's post-age decay can rank them.
394+
const recencyOn = r.candidateRecencyHalfLifeHours > 0
395+
const nowMin = toTsMin(Date.now())
396+
const halfLifeMin = r.candidateRecencyHalfLifeHours * 60
383397
const scored: { post: number; raw: number }[] = []
384398
for (const [post, acc] of scoreAcc) {
385399
if ((raters.get(post) ?? 0) < r.minEligibleRaters) continue
386-
scored.push({ post, raw: Math.pow(acc, r.smoothing) })
400+
let raw = Math.pow(acc, r.smoothing)
401+
if (recencyOn) {
402+
const ageMin = Math.max(0, nowMin - (lastTs.get(post) ?? nowMin))
403+
raw *= Math.pow(0.5, ageMin / halfLifeMin)
404+
}
405+
scored.push({ post, raw })
387406
}
388407
scored.sort((a, b) => b.raw - a.raw)
389408
const top = scored.slice(0, candidateLimit)

src/graph/like-graph.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,18 @@ export class LikeGraph implements ILikeGraph {
239239
)
240240
const scoreAcc = new Map<number, number>()
241241
const raters = new Map<number, number>()
242+
// most-recent co-liker-like time (tsMin) per candidate — drives the recency
243+
// weight applied at selection so fresh posts survive the top-N cut.
244+
const lastTs = new Map<number, number>()
242245
const cand: number[] = [] // postInt
243246
const candRn: number[] = [] // 1-based recency rank
247+
const candTs: number[] = [] // tsMin of the co-liker like
244248
for (const [c, wc] of curators) {
245249
const arr = this.fwd[c]
246250
if (!arr) continue
247251
cand.length = 0
248252
candRn.length = 0
253+
candTs.length = 0
249254
const seenPosts = new Set<number>()
250255
let rn = 0
251256
// walk recent-first (from the end); arr = [postInt, tsMin, …]
@@ -262,6 +267,7 @@ export class LikeGraph implements ILikeGraph {
262267
rn++
263268
cand.push(post)
264269
candRn.push(rn)
270+
candTs.push(ts)
265271
if (++visits > budget) break
266272
}
267273
const deg = cand.length
@@ -274,15 +280,27 @@ export class LikeGraph implements ILikeGraph {
274280
r.coraterDecay > 0 ? Math.pow(1 - r.coraterDecay, candRn[t] - 1) : 1
275281
scoreAcc.set(post, (scoreAcc.get(post) ?? 0) + norm * factor)
276282
raters.set(post, (raters.get(post) ?? 0) + 1)
283+
const prev = lastTs.get(post)
284+
if (prev === undefined || candTs[t] > prev) lastTs.set(post, candTs[t])
277285
}
278286
if (visits > budget) break
279287
}
280288

281-
// 5. eligibility + num_paths^smoothing, take top maxCandidates
289+
// 5. eligibility + num_paths^smoothing, weighted by co-liker-like recency so
290+
// fresh (recently-liked) posts survive the top-maxCandidates cut instead of
291+
// being dropped before finalize's post-age decay can rank them.
292+
const recencyOn = r.candidateRecencyHalfLifeHours > 0
293+
const nowMin = toTsMin(Date.now())
294+
const halfLifeMin = r.candidateRecencyHalfLifeHours * 60
282295
const scored: { post: number; raw: number }[] = []
283296
for (const [post, acc] of scoreAcc) {
284297
if ((raters.get(post) ?? 0) < r.minEligibleRaters) continue
285-
scored.push({ post, raw: Math.pow(acc, r.smoothing) })
298+
let raw = Math.pow(acc, r.smoothing)
299+
if (recencyOn) {
300+
const ageMin = Math.max(0, nowMin - (lastTs.get(post) ?? nowMin))
301+
raw *= Math.pow(0.5, ageMin / halfLifeMin)
302+
}
303+
scored.push({ post, raw })
286304
}
287305
scored.sort((a, b) => b.raw - a.raw)
288306
const top = scored.slice(0, candidateLimit)

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const run = async () => {
3030
maybeInt(process.env.FEEDGEN_CANDIDATE_WINDOW_HOURS) ?? 24,
3131
freshnessHours: maybeInt(process.env.FEEDGEN_FRESHNESS_HOURS) ?? 24,
3232
halfLifeHours: maybeInt(process.env.FEEDGEN_HALF_LIFE_HOURS) ?? 6,
33+
candidateRecencyHalfLifeHours:
34+
maybeFloat(process.env.FEEDGEN_CANDIDATE_RECENCY_HALFLIFE_HOURS) ?? 12,
3335
smoothing: maybeFloat(process.env.FEEDGEN_SMOOTHING) ?? 0.5,
3436
popularityPenalty:
3537
maybeFloat(process.env.FEEDGEN_POPULARITY_PENALTY) ?? 0.3,

src/redis.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ export const cacheRankedList = async (
2424
}
2525
}
2626

27+
// Overwrite an existing ranked list while preserving its TTL (SET … KEEPTTL).
28+
// Used to persist a re-ranked snapshot on refresh without postponing the
29+
// periodic recompute that the original expiry triggers.
30+
export const recacheRankedList = async (
31+
redis: Redis,
32+
key: string,
33+
uris: string[],
34+
): Promise<void> => {
35+
try {
36+
await redis.set(key, JSON.stringify(uris), 'KEEPTTL')
37+
} catch (err) {
38+
console.error('redis recache write failed', err)
39+
}
40+
}
41+
2742
export const getRankedList = async (
2843
redis: Redis,
2944
key: string,

0 commit comments

Comments
 (0)