From 9a0d2ce7d175914cb546f7d16c3a737911dc2311 Mon Sep 17 00:00:00 2001 From: Nubenetes Bot Date: Sat, 20 Jun 2026 23:14:43 +0200 Subject: [PATCH] feat(v2): make "Rising this Quarter" a genuine sustained-momentum signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second Trending lane previously reused lane 1's scoring formula (just a softer half-life over a wider window), so it surfaced the same fresh/high-impact items as lane 1 โ€” effectively lane 1's leftovers under a "Sustained Momentum" label. Verified: both lanes showed items aged 2-6 days. Changes: - _select_lane now takes a score_fn so each lane drives off a different signal. - _fresh_score (lane 1): unchanged 21d-half-life freshness bias. - _sustained_score (lane 2): rewards proven staying power (presence in the 12-month digest window) and de-prioritises <7d items (lane 1's job). Lane 2 now surfaces matured pillars โ€” Prometheus, Flux, bpftrace, Backstage โ€” with zero overlap with lane 1. - Suppress the ๐Ÿ†• NEW pill in lane 2 (a "sustained" card flagged NEW is contradictory; freshness is lane 1's signal). - Anchor "now" to digest _meta.last_updated instead of wall-clock, so identical digest input renders identical cards (no age/ordering churn on re-renders). - Harden _select_lane against None/non-dict window entries (latent crash). Generator-only change; CI publisher (04.1) regenerates v2-docs. Co-Authored-By: Claude Opus 4.8 --- src/v2_optimizer.py | 67 +++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/src/v2_optimizer.py b/src/v2_optimizer.py index 5f86df50..939aee2f 100644 --- a/src/v2_optimizer.py +++ b/src/v2_optimizer.py @@ -1074,7 +1074,16 @@ class V2VisionEngine: # --- Trending v2: momentum-weighted, category-diverse selection --- # Score = impact_weight * recency_decay so the section actually rotates # with fresh items instead of pinning evergreen/foundational tools. + # Anchor "now" to the digest's analysis timestamp (not wall-clock) so + # identical digest input always renders identical cards โ€” re-renders on + # later days must not shuffle ages/NEW pills and churn the committed HTML. now = datetime.now(MADRID_TZ) + try: + raw_now = digest_data.get("_meta", {}).get("last_updated", "") + if raw_now: + now = datetime.fromisoformat(raw_now) + except Exception as e: + log_event(f"[WARN] trending now-anchor: {str(e)[:100]}") impact_weight = {"critical": 1.0, "high": 0.66, "medium": 0.4} impact_icons = {"critical": "๐Ÿ”ด", "high": "๐ŸŸก", "medium": "๐Ÿ”ต"} @@ -1104,20 +1113,18 @@ class V2VisionEngine: return f"{n / 1000:.1f}kโ˜…".replace(".0k", "k") return f"{n}โ˜…" - def _select_lane(window_key, half_life, count, exclude_urls): - # Score = impact_weight * recency_decay over the given window; an - # aggressive half-life surfaces fresh items ("Trending Now"), a soft - # one favours sustained importance ("Rising this Quarter"). + def _select_lane(window_key, count, exclude_urls, score_fn): + # Build a scored pool from the window's top-2 items per category, + # then apply a per-category diversity quota. The scoring policy is + # supplied by score_fn so each lane surfaces a different signal. pool = [] for cat_name, items in digest_data.get(window_key, {}).items(): - for item in items[:2]: - if item.get("url") in exclude_urls: + for item in (items or [])[:2]: + if not isinstance(item, dict) or item.get("url") in exclude_urls: continue d = _parse_day(item.get("date")) age_days = (now.date() - d).days if d else 999 - recency = 0.5 ** (max(age_days, 0) / half_life) - score = impact_weight.get(item.get("impact", "medium"), 0.4) * (0.35 + 0.65 * recency) - pool.append({**item, "digest_category": cat_name, "_age_days": age_days, "_score": score}) + pool.append({**item, "digest_category": cat_name, "_age_days": age_days, "_score": score_fn(item, age_days)}) pool.sort(key=lambda x: x["_score"], reverse=True) # Diversity quota: at most one card per category, then backfill. sel, used_cats, used = [], set(), set(exclude_urls) @@ -1139,7 +1146,7 @@ class V2VisionEngine: break return sel - def _render_cards(items): + def _render_cards(items, show_new=True): html = "" for item in items: impact = item.get("impact", "medium") @@ -1154,7 +1161,7 @@ class V2VisionEngine: meta = item.get("date", "") if metric: meta += f" ยท {metric}" - new_pill = ' ๐Ÿ†• NEW' if item.get("_age_days", 999) <= 7 else "" + new_pill = ' ๐Ÿ†• NEW' if show_new and item.get("_age_days", 999) <= 7 else "" html += ( f'\n' if rising_items: cards_html += '\n\n' cards_html += '