RFC-103: Momentum Layer — Read-Time Trending Across Saveable Entities¶
- Status: Draft — amended 2026-08-26 (Revision 2). The read-time primitive (§3), endpoint (§7) and config (§10) are superseded by Revision 2, which replaces the weekly fast/slow-EWMA ratio with a monthly, user-selectable trend window (
1M · 3M · 6M · 1Y, default 3M), amin_totalinclusion floor,velocity × volumeranking, and a corpus-latest-month anchor — driven by prod data showing the shipped weekly EWMA degenerates to a uniform velocity on a real back-catalog. Read Revision 2 alongside the original design. - Authors: Marko Dragoljevic
- Stakeholders: Consumer app (Home / discovery / library), enrichment layer, ranking
- Related PRDs:
docs/prd/PRD-042-home.md(Home / Learning Hub — Trending surfaces)docs/prd/PRD-041-consolidation.md(saved insights / resurfacing)- Related RFCs:
docs/rfc/RFC-088-enrichment-layer-architecture.md(enrichers produce the durable data)- Related UX specs:
docs/uxs/UXS-012-consumer-home.md- Related Documents:
src/podcast_scraper/enrichment/enrichers/temporal_velocity.pysrc/podcast_scraper/server/app_discover_view.py(ranking consumer)src/podcast_scraper/server/routes/app_discover.py,app_user_state.py(telemetry / saves)web/learning-player/src/components/TrendingTopics.vue
Abstract¶
"Trending" should apply to everything a user can save or that feeds recommendations — topics,
semantic clusters (tc:), storylines (thc:), people, episodes, shows, and insights — not just bare
topics. This RFC defines a Momentum Layer: one primitive — an EWMA over a per-entity weekly
event series, anchored to today, computed at read — applied uniformly across all those entities,
along two axes (velocity = rising, volume = most-active) and two event sources (content =
corpus mentions/appearances; engagement = saves/plays/opens).
It also fixes the layering. Today temporal_velocity bakes a now-anchored, monthly, topic-only
velocity into the enricher, read raw in two places. Instead: enrichers produce durable data; a
dedicated momentum capability derives + serves it to its two consumers — UI surfaces (via a
dedicated GET /api/app/trending) and recommendations (the discover ranker) — so "what's hot"
means one thing everywhere and is always relative to today.
Architecture Alignment: Preserves the RFC-088 split — enrichers do the expensive, durable aggregation on ingest; the momentum capability does the cheap, time-relative derivation per read.
Problem Statement¶
The current trending signal has four coupled problems:
- Stale.
velocity_last_over_6mois frozen at the enricher's run-timenow(_now_utc). As the calendar advances the anchor ages; keeping it current needs a re-run cron whose only job is re-anchoring time. - Wrong timescale. It is last month ÷ trailing 6 months**, monthly-bucketed — a slow seasonal signal, not "hot now" for a fast feed.
- Topic-only. Users save and rank episodes, insights, shows, people too; trending should cover every saveable entity and feed recommendations, not just the topic rail.
- No owning layer. Velocity is produced and half-consumed in the enricher, then read raw by the Trending rail and the discover ranker independently — no single capability owns "what's hot" for both UI and recommendations.
Use Cases:
- Trending across kinds — "heating up" topics, storylines, shows, episodes, and insights, each as of today, from one signal.
- Recommendations — the discover feed boosts episodes whose content is trending and the things a user's followed entities are trending, using the same momentum the UI shows.
- Deterministic e2e — the app's Playwright suite sees a stable momentum signal regardless of the date the tests run.
Goals¶
- One primitive, every saveable entity: topics /
tc:/thc:/ people / episodes / shows / insights all trend via the same EWMA momentum. - Two axes, two event sources: velocity + volume; content + engagement.
- Today-relative, read-time: momentum reflects
todayon every read; no cron just to re-anchor. - Clean layering: enrichers produce durable data; a momentum capability derives + serves it.
- One vocabulary for UI + recommendations + operator: a dedicated
GET /api/app/trending(consumer, per-user + corpus scope), aGET /api/corpus/trending(operator Dashboard global view across every kind), and a programmaticmomentum(entity)the ranker calls — one capability. - Fully config-driven: half-lives, blend weights (global + per-kind), heating-up threshold, and the engagement floor are all config, no hardcoded constants.
- Deterministic fixtures: a pinned reference
now+ seeded events make momentum byte-stable. - Backward compatible: keep
velocity_last_over_6moas a fallback during migration.
Constraints & Assumptions¶
Constraints:
- No daily cron required for correctness (freshness comes from read-time anchoring).
- Read-time derivation is cheap: O(entities × weeks) arithmetic, memoized per
(corpus, week, params). - Deterministic in CI/e2e (pinned
now+ seeded engagement; no wall-clock dependence). - Engagement aggregation exposes counts only (no per-user data in the corpus-wide signal).
Assumptions:
- Weekly granularity is the floor (fine for a fast feed, smooth vs daily noise; daily is a later option over the same series).
- Per-topic/person weekly counts over multi-year history are small (one int per entity per active week); engagement counts are similarly small.
Design & Implementation¶
1. Layering — the spine¶
Enrichers (RFC-088, on ingest) → durable weekly series (facts, no `now`)
│ content: per-topic / per-person mention counts
│ engagement: per-entity save/play/open counts (aggregated from telemetry)
▼
Momentum capability (server module, per read)
│ EWMA momentum + volume, anchored to configurable `now`
│ aggregation (groups + point-in-time entities), content⊕engagement blend, cache
├──────────────► Consumer UI: GET /api/app/trending (per-user + corpus scope)
├──────────────► Operator UI: GET /api/corpus/trending (Dashboard global view)
└──────────────► Recommendations: momentum(entity) (programmatic, the ranker)
- Enrichers only produce data.
temporal_velocity(content) emits per-topic/person weekly counts; a small engagement aggregator rolls telemetry (impressions/clicks/playback/favorites) into per-entity weekly counts. Neither embedsnowor a window. - The momentum capability owns derivation + serving. One module computes momentum from the series
against
now, aggregates, blends, caches, and exposes both the endpoint andmomentum(entity). - Two consumers, one source of "hot": UI surfaces and the discover ranker.
2. Event model — content ⊕ engagement per entity¶
Each entity's weekly event series is defined from two sources:
| Entity | Content events (corpus) | Engagement events (telemetry) |
|---|---|---|
topic / tc: / thc: / person |
mentions / appearances per week | follows + card opens |
| episode | Σ its topics' + people's mentions | plays, saves, queue-adds, discover clicks |
| show | Σ its episodes' content series | subscribes + plays of its episodes |
| insight | its topic's mentions | saves + opens |
Recurring entities (topics/people) have a native content series; point-in-time entities (episodes/insights/shows) derive their content series by aggregating the topics/people they contain — so everything rolls up from the same per-topic/person atom.
3. The primitive — EWMA momentum, read-time¶
Superseded by Revision 2 (2026-08-26). The weekly fast/slow-EWMA ratio below degenerates on a real back-catalog: weekly buckets are too sparse (most entities land in 1–2 weeks), so a single recent mention produces the maximum possible velocity and every such entity ties at the same value — the list becomes alphabetical noise. Revision 2 moves the trend computation to monthly buckets over a selectable window. Kept here for history.
def ewma_alpha(half_life_weeks: float) -> float:
return 1.0 - 0.5 ** (1.0 / half_life_weeks)
def momentum(series: list[int], fast_hl=3.0, slow_hl=12.0) -> tuple[float, float]:
fast = ewma(series, ewma_alpha(fast_hl))[-1] # recent level → volume axis
slow = ewma(series, ewma_alpha(slow_hl))[-1] # baseline level
velocity = round(fast / slow, 4) if slow > 0 else 0.0 # >1 rising, <1 cooling
return velocity, fast # (rising, recent volume)
seriesis zero-filled up to the reference week derived fromnow, so a silent entity's fast EWMA decays below its slow → it cools automatically as days pass ("changes with any given day").- Fast half-life ~3 weeks, slow ~12 weeks (config). Same formula for every entity + source.
- Velocity (rising) and volume (recent level) are the two axes the UI already plots.
4. Aggregation¶
def series_for(entity_id, weekly_by_topic_or_person, contained, members):
if entity_id.startswith(("topic:", "person:")):
return weekly_by_topic_or_person.get(entity_id, {})
if entity_id.startswith(("tc:", "thc:")): # cluster / storyline
return sum_weekly(series_for(m, ...) for m in members(entity_id))
return sum_weekly(series_for(c, ...) for c in contained(entity_id)) # episode/show/insight
Deriving (not storing) group/contained series keeps momentum consistent when clusters are re-derived or new episodes arrive — no recompute-on-recluster coupling.
5. Blend — content ⊕ engagement¶
Per-entity momentum blends the two sources' momenta with configurable weights:
score(entity) = w_content · momentum(content_series) + w_engagement · momentum(engagement_series)
Weights are per-kind config (start content-heavy where engagement is sparse — a small-audience app has thin engagement early; content is dense from day one). Each source keeps its own velocity + volume; the blend is exposed alongside the components so a surface can show either.
6. Corpus-wide vs per-user¶
- Content momentum is corpus-wide — one "what's hot" for everyone.
- Engagement momentum defaults to a corpus-wide aggregate (counts only; a configurable
min-count floor before an entity is shown, to avoid single-user identifiability), with a per-user
scope (
scope=mine— reuses the existing "your corpus" lens) for "your recent momentum", shipped in v1. The per-user scope has no min-count floor (it is the user's own data).
7. Dedicated endpoint — GET /api/app/trending¶
horizonparam realized + redefined by Revision 2. Thehorizon=week|month|quartertoggle below was never wired; Revision 2 ships it aswindow=1m|3m|6m|1y(default3m), backed by monthly counts, and adds amin_totalinclusion floor +velocity × volumeranking to the response contract.
GET /api/app/trending?kind=topic|cluster|storyline|person|episode|show|insight
&scope=corpus|mine &horizon=week|month|quarter &limit=N
→ [{ id, kind, label, velocity, volume, heating_up, series, components:{content,engagement} }]
- Owned by the momentum capability (derivation + blend + cache live in one place); memoized per
(corpus, as_of_week, params). - Why not extend
GET /api/app/corpus/enrichment: that endpoint is a thin envelope reader; trending needs derivation, blending, per-kind aggregation, scope, and caching — a distinct capability, not an envelope passthrough. - Operator global view: the same capability backs an operator surface — a corpus-scoped route
(
GET /api/corpus/trending) feeding a Dashboard "Trending (global)" panel in the operator viewer, showing momentum across every supported kind in one place (the operator's bird's-eye "what's hot corpus-wide"), with a per-kind sparkline (the weeklyseries) per row so the operator sees each entity's trajectory, not just its current momentum. Consumer app and operator viewer read one capability; only the route prefix (/api/appvs/api/corpus) and default scope differ.
8. Recommendations consumer¶
The discover ranker's SIGNAL_TREND_VELOCITY (app_discover_view.py
_topic_velocities → _trend_boost) generalizes to momentum(entity): boost an episode by the
momentum of its content and by the momentum of the user's followed entities (topic:/tc:/thc:/
person:). Same capability, programmatic call — the feed and the rail agree on "hot."
9. Fixture determinism¶
app-validation-corpus/v3 provides content weekly series (per-topic/person counts) and seeded
engagement events (playback/favorites/discover-clicks in the committed per-user state or a seed);
the e2e webServer pins APP_TRENDING_NOW to the corpus's latest week. Momentum for every kind is
then deterministic — no episode re-dating, audio-safe (only enrichment/seed JSON changes).
10. Configuration¶
Everything tunable lives under a momentum config block — global defaults with per-kind
overrides, no hardcoded constants. Proposed defaults (content-weighted for content-native entities,
balanced for consumption objects where engagement matters more; all overridable):
momentum:
ewma:
fast_half_life_weeks: 3 # recent level
slow_half_life_weeks: 12 # baseline level
heating_up:
velocity_threshold: 1.5 # τ — velocity ≥ τ ⇒ "rising"
min_total: 3 # sample-noise floor
blend: # score = w_content·content + w_engagement·engagement
default: { content: 0.70, engagement: 0.30 }
per_kind:
topic: { content: 0.85, engagement: 0.15 }
cluster: { content: 0.85, engagement: 0.15 }
storyline: { content: 0.85, engagement: 0.15 }
person: { content: 0.80, engagement: 0.20 }
episode: { content: 0.50, engagement: 0.50 }
show: { content: 0.60, engagement: 0.40 }
insight: { content: 0.60, engagement: 0.40 }
engagement:
min_events_corpus: 5 # corpus-wide identifiability floor (per-user scope: none)
horizons: # UI toggle presets → (fast_hl, slow_hl) in weeks
week: { fast: 1, slow: 4 }
month: { fast: 3, slow: 12 }
quarter: { fast: 8, slow: 26 }
Rationale for the defaults: content is dense from day one and engagement is sparse in a small-audience app, so content leads globally (0.70) and dominates for content-native kinds (topics/clusters/storylines/people ≈ 0.85); episodes/shows/insights are consumption objects where "what people are on" matters more, so engagement rises toward parity (episodes 0.50/0.50). These are starting points to A/B, not commitments — hence config.
Key Decisions¶
- Dedicated
GET /api/app/trending, not an extension of the enrichment endpoint — the momentum capability owns derivation + blend + cache; the enrichment endpoint stays a thin reader. - Enrichers produce data; the capability derives + serves — clean RFC-088 layering; the enricher
emits
now-free series, all time-relative math is read-time. - Content ⊕ engagement, both in v1, blended with per-kind weights — content gives a dense day-one signal; engagement adds "what people are on"; the blend degrades gracefully when engagement is sparse.
- One atom + aggregation for every entity — per-topic/person weekly counts; clusters, storylines, episodes, shows, insights are all aggregations.
- EWMA (fast/slow), read-time, anchored to
now— no hard window, no re-anchor cron; ~3wk/~12wk default half-lives.
Alternatives Considered¶
- Extend
GET /api/app/corpus/enrichment— Pros: fewer routes. Cons: fuses thin envelope reads with heavy derivation/blend/cache; two responsibilities in one handler. Rejected. - Pre-baked velocity + daily cron — Pros: minimal code. Cons: up-to-a-day stale; a cron whose only job is re-anchoring time; fixtures still need a pinned bake. Rejected.
- Content-momentum only (defer engagement) — Pros: no telemetry work. Cons: misses "what people are on." Not chosen — engagement is in v1, blended, so it can start at low weight.
- Store per-entity momentum in the enricher — Cons:
now-dependent (non-deterministic to commit) and recompute-on-recluster. Rejected in favour of stored atoms + read-time derivation.
Testing Strategy¶
Test Coverage:
- Unit (momentum):
ewma_alpha/momentum— flat → ~1.0; recent spike → rising; decay as the reference week advances with no new events; volume vs velocity. - Unit (aggregation + blend): cluster/storyline/episode/show/insight series = Σ members/contained; blend weights combine content + engagement as specified.
- Integration (endpoint):
GET /api/app/trendingperkind/scope;APP_TRENDING_NOWoverride makesheating_updeterministic; memoization key. - Integration (ranking): the discover ranker boosts the same entities the endpoint marks hot under
a pinned
now(one source of "hot" across rail + feed). - E2E: with
APP_TRENDING_NOWpinned, trending surfaces render for topics, storylines, shows, episodes, insights.
Test Data: app-validation-corpus/v3 content weekly series + seeded engagement; deterministic.
Migration Path¶
- Phase 1: enricher emits per-topic/person weekly content series (additive; monthly kept as fallback).
- Phase 2: engagement aggregator rolls telemetry (impressions/clicks/playback/favorites) into per-entity weekly counts.
- Phase 3: momentum capability +
GET /api/app/trending(consumer,scope=corpus|mine) +GET /api/corpus/trending(operator); discover ranker switches tomomentum(entity); all fall back tovelocity_last_over_6moif series absent. - Phase 4a: consumer player trending surfaces per entity (topics/storylines/shows/episodes/insights) — the richer surface, built first.
- Phase 4b: operator Dashboard global view (all kinds, per-kind sparklines).
- Phase 5: deprecate
velocity_last_over_6mo.
Resolved Decisions (were open questions)¶
- Blend weights — global default
0.70/0.30with per-kind overrides, all in config (§10). ✓ - Engagement min-count floor — configurable, default
min_events_corpus: 5(§10); no floor forscope=mine. ✓ - Per-user
scope=mine— shipped in v1 (§6). ✓ -
UI surfaces — consumer player everywhere needed (topic rail + storylines exist; shows/episodes/insights added) and an operator Dashboard global view across every supported kind (§7). ✓
-
Build order — consumer player first (the richer, more complex surface), then operator (admin). Operator global view includes per-kind sparklines (the weekly
seriesper row). ✓ - Week numbering — ISO-8601 weeks end-to-end for
as_of_week. ✓
Open Questions¶
- Consumer surface ordering within the player phase (topic rail + storylines exist; which of shows/episodes/insights lands next) — decided as we build.
Revision 2 (2026-08-26) — Trending window redesign: monthly, user-selectable, denoised¶
Supersedes: §3 (primitive), the horizon param in §7 (endpoint), the ewma/horizons blocks in
§10 (config), and Key Decision 5. Everything else in the RFC stands.
R2.0 — What triggered this¶
The momentum layer shipped and reached the device. On the real prod corpus — 678 episodes spanning
~18 months of publish dates, ingested over a short window — the Browse Topics/People tabs showed
every entity at the same 2.6× velocity, so the list collapsed to alphabetical order. This is not
a display bug; the read-time primitive (§3) is mis-fit for this data. All numbers below are from the
live prod API (/api/app/trending and /api/app/corpus/trending-topics), not a fixture.
R2.1 — Root causes (evidence-backed)¶
- Weekly buckets are too sparse. Over an 18-month back-catalog most topics/people appear in
1–2 ISO weeks.
velocity = fast_EWMA ÷ slow_EWMAof a single-spike series is a constant determined only by recency-from-anchor, not by the entity — so all singletons tie. Measured: of the top-50 topics, 49 hadtotal=1, allvelocity=2.5991, allvolume=0.13, noneheating_up. The corpus's actual themes (risk-management,systems-thinking, …,totalin the hundreds) were absent from the top-50 entirely — buried under one-off mentions. - The anchor is wall-clock
now, not the corpus.resolve_as_of_weekdefaults to today (as_of=2026-W35) while the newest content is2026-W33. Velocity is measured against a window whose last two weeks are always empty, so the frame slides forward daily while the content sits in the past — "trending vs now" for a historical archive. min_totalgates only the badge, not the list. Themin_total: 3floor (§10) is applied to theheating_upflag but not to inclusion or ranking (app_momentum.trending()returns every entity, sorted by velocity). So the noise floor exists and is simply not wired where it matters.- Velocity-only ranking over-rewards low-total spikes. Even in the saner monthly regime (below),
a
total=2topic whose mentions all fell in the last month scores the maximum velocity and outranks a genuinely risingtotal=8topic. Rising-ness without a volume co-factor is noise-first.
R2.2 — The data-driven comparison¶
Two trending computations run on the same prod corpus:
- Regime A —
/api/app/trending(shipped): weekly, fast 3wk / slow 12wk EWMA ratio → the uniform2.6×list above. - Regime B —
/api/app/corpus/trending-topics:velocity_last_over_6mo(monthly, last-month ÷ trailing-6-month average) with amin_totalfilter.
Regime B at min_total=3, sorted by coverage, reads like a real AI/tech corpus:
| topic | total | velocity (6mo) |
|---|---|---|
| ai-in-education | 14 | 0.67 (cooling) |
| vibe-coding | 11 | 1.2 (rising) |
| recursive-self-improvement | 8 | 2.0 (rising) |
| talent-density | 8 | 1.5 (rising) |
| ai-coding-agents | 7 | 1.5 (rising) |
min_total sensitivity (pool of 100 topics):
| min_total | qualifying | distinct velocities |
|---|---|---|
| 1 | 100 | 1 (all 6.0 — pure noise) |
| 2 | 100 | 9 |
| 3 | 100 | 9 |
| 5 | 31 | 7 |
Reading: monthly + a multi-month baseline produces differentiated, sensible signal where weekly
EWMA cannot; min_total=1 is literally all-6.0 noise while 3 still leaves 100+ topics with real
spread; and the genuinely big-and-rising topics (total=8, vel=2.0) sit below total=2/vel=6.0
artifacts under a velocity-only sort — confirming ranking must combine velocity with volume.
(This was written before the raw series was reachable; it now is — the head-to-head window simulation is in R2.10, which confirms 3M as the default.)
R2.3 — Revised design¶
- Monthly granularity. Trend velocity is computed over monthly counts (
monthly_counts, already emitted per entity), not weekly. Weekly sparsity is the direct cause of the degeneracy. - User-selectable window. A
window ∈ {1m, 3m, 6m, 1y}toggle.velocity(window)= mentions in the lastwindow÷ average per equal-length period over the trailing baseline (so1.0= flat,>1rising,<1cooling), computed at read-time frommonthly_counts. Default3m— the browse/catch-up cadence ("back from a few weeks away, what moved") is a quarter, not a week. All four windows are cheap reads off the existing 12-month monthly series;1ysits at its edge,18m/all-time would readcontent_series(full history) and is out of scope for the first cut. min_totalas an inclusion floor. Entities withtotal < min_total(over the selected window) are excluded from the list, not merely un-badged. Default 3, tunable.- Rank by
velocity × volume, not velocity alone, so a big-and-rising entity beats a tiny recent spike. Exact combiner (e.g.velocity · log1p(total)) is a config knob to tune, but "velocity × some monotone function of volume" is the decision. - Anchor to the corpus's latest content month, auto-derived as
max(month with any mention), not wall-clocknow.APP_TRENDING_NOWremains an override for tests/pinning. - Sparkline is display, not ranking. The row sparkline shows the full ~18-month history
(
content_series), independent of the selected trend window — the window changes the ordering, not the picture.
R2.4 — API¶
GET /api/app/trending?kind=…&scope=corpus|mine&window=1m|3m|6m|1y&limit=N
→ [{ id, kind, label, velocity, volume, total, heating_up, window,
series /* full-history sparkline */, components:{content,engagement} }]
windowdefaults to3m; unknown values 422 (not silently coerced).min_totalapplies server-side (config), not a client param — the floor is a product invariant, not a caller choice.
R2.5 — Config (replaces the ewma + horizons blocks in §10)¶
momentum:
trend:
default_window: 3m # 1m | 3m | 6m | 1y
windows: [1m, 3m, 6m, 1y] # the presets the UI exposes
granularity: month # month (weekly retained only for the sparkline atom)
min_total: 3 # INCLUSION floor (was badge-only) — sample-noise gate
rank: velocity_x_volume # velocity · log1p(total); volume co-factor, not velocity-only
anchor: corpus_latest_month # not wall-clock now; APP_TRENDING_NOW overrides for tests
heating_up:
velocity_threshold: 1.5 # τ — badge only; unchanged
The blend (§5) and engagement (§6) blocks are unchanged; content still leads for content-native kinds.
R2.6 — Frontend¶
A segmented control — 1M · 3M · 6M · 1Y — on the Browse Topics/People tabs (and Home "Rising
now"), defaulting to 3M, re-fetching trending?window=… on change. The ×N velocity badge shows
only when the entity clears the heating_up threshold; otherwise the row shows coverage (e.g.
"18 episodes") so a quiet window never renders a fake multiplier. Ships behind the same UXS-011/012
surfaces already documented for Browse.
R2.7 — Open questions (Revision 2)¶
- ~~Window numerator~~ — RESOLVED by the R2.10
prod-data run:
1mis unusable (0 topics clear the floor),3mis the sweet spot; 3m confirmed as the default. - Rank combiner —
velocity · log1p(total)holds up on real data (it correctly ranks a high-coverage rising person above a tiny spike), but thenew_entity_velocitycap (default 6.0) is hit often, so many no-prior entities tie at the cap and the volume co-factor breaks the ties. Candidate to revisit (lower cap, or a smoothed prior) — not blocking. - A second lens — the operator raised a possible shorter "what's hot this month" view distinct
from the catch-up window. Deferred; the
windowparam already makes it a preset, not new plumbing.
R2.10 — Prod-data window validation (2026-08-27)¶
Run against the live prod content_series (read-only pull of temporal_velocity.json from the
player VPS) — 5,948 topics + 2,070 people over a 19-month span (2025-02 → 2026-08). Simulated the
four windows with min_total=3 and the velocity × log1p(volume) ranking.
Topics — entities clearing the floor per window:
| window | qualifying | reads like |
|---|---|---|
| 1m | 0 | too tight — nothing has ≥3 mentions in a single recent month |
| 3m | 20 | ai-in-education (v4.9 / t8), recursive-self-improvement, ai-regulation, open-source-ai-models — real + differentiated |
| 6m | 66 | broader, still sensible |
| 1y | 103 | broadest; many pile at the velocity cap |
People — 1m yields only 7, and all cooling (a recurring host dips in any single month); 3m gives 82 with recognizable leaders (the NYT-Daily hosts at total 59, Trump 50, Musk 27).
Conclusions:
- 1m is unusable; 3m is the sweet spot — real signal, differentiated velocities, recognizable entities, not yet piling at the cap. 3m is confirmed as the default.
- The windows barely overlap (0–2 of the top-12 shared across any pair) — each is a genuinely different lens, which validates shipping all four as a user-selectable control rather than picking one.
- The
velocity × log1p(volume)ranking behaves as intended (high-coverage rise beats a thin spike; entities with real prior history can exceed the new-entity cap when they truly surge, e.g. a 12× person). The only wrinkle is the frequently-hitnew_entity_velocitycap — see R2.7.
R2.8 — Migration (extends the §Migration Path)¶
- Add
window-parameterized monthly velocity to the momentum capability, computed frommonthly_counts; keep the weeklyseriespurely for the sparkline. - Wire
min_totalto inclusion and switch ranking tovelocity × volume. - Switch the anchor to corpus-latest-month.
- Frontend segmented control (default 3M).
- Deploy; the empty-tabs
limit≤50fix (#1856, already merged) is orthogonal and already in prod.
R2.9 — Testing (extends §Testing Strategy)¶
- Unit:
velocity(window)over a hand-built monthly series for each of1m/3m/6m/1y(flat→1.0, recent-spike→rising, cooled→<1);min_totalexcludes sub-floor entities from the list (not just the badge);velocity × volumeorders atotal=8/vel=2.0entity above atotal=2/vel=6.0one; anchor derivesmax(content month)and ignores empty trailing months. - Fixture gap to close: the committed
app-validation-corpusreturns empty trending (notemporal_velocityspread), so it structurally cannot catch a ranking regression — R2 adds a small fixture with a deliberate multi-month monthly spread across a few entities so the window math, the floor, and the ranking are all asserted deterministically. (This is the same blind spot that let thelimit=60→422bug reach the device; see #1856.)
References¶
- Related RFC:
docs/rfc/RFC-088-enrichment-layer-architecture.md - Related PRD:
docs/prd/PRD-042-home.md,docs/prd/PRD-041-consolidation.md - Source Code:
src/podcast_scraper/enrichment/enrichers/temporal_velocity.py,src/podcast_scraper/server/app_discover_view.py,web/learning-player/src/components/TrendingTopics.vue