Skip to content

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), a min_total inclusion floor, velocity × volume ranking, 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.py
  • src/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_6mo is frozen at the enricher's run-time now (_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:

  1. Trending across kinds — "heating up" topics, storylines, shows, episodes, and insights, each as of today, from one signal.
  2. 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.
  3. Deterministic e2e — the app's Playwright suite sees a stable momentum signal regardless of the date the tests run.

Goals

  1. One primitive, every saveable entity: topics / tc: / thc: / people / episodes / shows / insights all trend via the same EWMA momentum.
  2. Two axes, two event sources: velocity + volume; content + engagement.
  3. Today-relative, read-time: momentum reflects today on every read; no cron just to re-anchor.
  4. Clean layering: enrichers produce durable data; a momentum capability derives + serves it.
  5. One vocabulary for UI + recommendations + operator: a dedicated GET /api/app/trending (consumer, per-user + corpus scope), a GET /api/corpus/trending (operator Dashboard global view across every kind), and a programmatic momentum(entity) the ranker calls — one capability.
  6. Fully config-driven: half-lives, blend weights (global + per-kind), heating-up threshold, and the engagement floor are all config, no hardcoded constants.
  7. Deterministic fixtures: a pinned reference now + seeded events make momentum byte-stable.
  8. Backward compatible: keep velocity_last_over_6mo as 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 embeds now or 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 and momentum(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)
  • series is zero-filled up to the reference week derived from now, 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

horizon param realized + redefined by Revision 2. The horizon=week|month|quarter toggle below was never wired; Revision 2 ships it as window=1m|3m|6m|1y (default 3m), backed by monthly counts, and adds a min_total inclusion floor + velocity × volume ranking 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 weekly series) 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/app vs /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

  1. 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.
  2. Enrichers produce data; the capability derives + serves — clean RFC-088 layering; the enricher emits now-free series, all time-relative math is read-time.
  3. 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.
  4. One atom + aggregation for every entity — per-topic/person weekly counts; clusters, storylines, episodes, shows, insights are all aggregations.
  5. EWMA (fast/slow), read-time, anchored to now — no hard window, no re-anchor cron; ~3wk/~12wk default half-lives.

Alternatives Considered

  1. 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.
  2. 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.
  3. 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.
  4. 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/trending per kind/scope; APP_TRENDING_NOW override makes heating_up deterministic; 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_NOW pinned, trending surfaces render for topics, storylines, shows, episodes, insights.

Test Data: app-validation-corpus/v3 content weekly series + seeded engagement; deterministic.

Migration Path

  1. Phase 1: enricher emits per-topic/person weekly content series (additive; monthly kept as fallback).
  2. Phase 2: engagement aggregator rolls telemetry (impressions/clicks/playback/favorites) into per-entity weekly counts.
  3. Phase 3: momentum capability + GET /api/app/trending (consumer, scope=corpus|mine) + GET /api/corpus/trending (operator); discover ranker switches to momentum(entity); all fall back to velocity_last_over_6mo if series absent.
  4. Phase 4a: consumer player trending surfaces per entity (topics/storylines/shows/episodes/insights) — the richer surface, built first.
  5. Phase 4b: operator Dashboard global view (all kinds, per-kind sparklines).
  6. Phase 5: deprecate velocity_last_over_6mo.

Resolved Decisions (were open questions)

  1. Blend weights — global default 0.70/0.30 with per-kind overrides, all in config (§10). ✓
  2. Engagement min-count floor — configurable, default min_events_corpus: 5 (§10); no floor for scope=mine. ✓
  3. Per-user scope=mine — shipped in v1 (§6). ✓
  4. 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). ✓

  5. Build order — consumer player first (the richer, more complex surface), then operator (admin). Operator global view includes per-kind sparklines (the weekly series per row). ✓

  6. 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.

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)

  1. Weekly buckets are too sparse. Over an 18-month back-catalog most topics/people appear in 1–2 ISO weeks. velocity = fast_EWMA ÷ slow_EWMA of 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 had total=1, all velocity=2.5991, all volume=0.13, none heating_up. The corpus's actual themes (risk-management, systems-thinking, …, total in the hundreds) were absent from the top-50 entirely — buried under one-off mentions.
  2. The anchor is wall-clock now, not the corpus. resolve_as_of_week defaults to today (as_of=2026-W35) while the newest content is 2026-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.
  3. min_total gates only the badge, not the list. The min_total: 3 floor (§10) is applied to the heating_up flag 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.
  4. Velocity-only ranking over-rewards low-total spikes. Even in the saner monthly regime (below), a total=2 topic whose mentions all fell in the last month scores the maximum velocity and outranks a genuinely rising total=8 topic. 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 uniform 2.6× list above.
  • Regime B — /api/app/corpus/trending-topics: velocity_last_over_6mo (monthly, last-month ÷ trailing-6-month average) with a min_total filter.

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

  1. 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.
  2. User-selectable window. A window ∈ {1m, 3m, 6m, 1y} toggle. velocity(window) = mentions in the last window ÷ average per equal-length period over the trailing baseline (so 1.0 = flat, >1 rising, <1 cooling), computed at read-time from monthly_counts. Default 3m — 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; 1y sits at its edge, 18m/all-time would read content_series (full history) and is out of scope for the first cut.
  3. min_total as an inclusion floor. Entities with total < min_total (over the selected window) are excluded from the list, not merely un-badged. Default 3, tunable.
  4. 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.
  5. Anchor to the corpus's latest content month, auto-derived as max(month with any mention), not wall-clock now. APP_TRENDING_NOW remains an override for tests/pinning.
  6. 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} }]
  • window defaults to 3m; unknown values 422 (not silently coerced).
  • min_total applies 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: 1m is unusable (0 topics clear the floor), 3m is 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 the new_entity_velocity cap (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 window param 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:

  1. 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.
  2. 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.
  3. 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-hit new_entity_velocity cap — see R2.7.

R2.8 — Migration (extends the §Migration Path)

  1. Add window-parameterized monthly velocity to the momentum capability, computed from monthly_counts; keep the weekly series purely for the sparkline.
  2. Wire min_total to inclusion and switch ranking to velocity × volume.
  3. Switch the anchor to corpus-latest-month.
  4. Frontend segmented control (default 3M).
  5. Deploy; the empty-tabs limit≤50 fix (#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 of 1m/3m/6m/1y (flat→1.0, recent-spike→rising, cooled→<1); min_total excludes sub-floor entities from the list (not just the badge); velocity × volume orders a total=8/vel=2.0 entity above a total=2/vel=6.0 one; anchor derives max(content month) and ignores empty trailing months.
  • Fixture gap to close: the committed app-validation-corpus returns empty trending (no temporal_velocity spread), 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 the limit=60→422 bug 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