From 2a8459c674d046193609401bb7d1a9e13b404128 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 12:10:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20RE-R3d=20=E2=80=94=20t?= =?UTF-8?q?emporal=20overlay=20(first-mover=20+=20saturation)=20[skip-docs?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 slice (b): the rest of hull #3. The morning brief now reads the temporal axis the R3b seen-log records but the ranking ignored. Two DERIVED signals, computed at brief time from already-persisted fields (publishedAt/capturedAt -> ageDays, surfacedCount), never stored: - first-mover: recent (ageDays <= --first-mover-days, default 2) AND never surfaced on a prior day -> ranked up, badge "first ute". Future-dated (ageDays<0) excluded. - saturation: surfaced on >= --saturation-at (default 3) prior days -> ranked down, badge "mettet (Nx)". Self-surfacing (our seen-log), not market coverage. - warming (1..at-1) keeps the R3b "sett Nx" badge but only at >=2 (contract intact); neutral carries no badge. SB1 derived (no schema bump: SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1 untouched). SB2 the R3a relevance composite stays the PRIMARY sort key; the temporal rank is a new cmp key after pillar-overlap, before effectiveDate -> re-orders only WITHIN a (composite, overlap) tier. temporalSignal is pure (saturationAt clamped >=1). Prior-day surfacings exclude today (via lastSurfacedAt), so a same-day re-render is byte-identical (caught by the R3c run-daily SC7 regression; fixes a latent R3b prior-day imprecision too). brief CLI gains --first-mover-days / --saturation-at; schedule untouched (nightly uses defaults). Wiring: trend-spotter.md (prose), trend-scoring-modes.md (one-line consumer note), README (## Temporal overlay), gate Section 16m (+6 unconditional -> ASSERT floor 111->117), TRENDS_TESTS_FLOOR 192->216. Counts 29/19/27 unchanged. Zero new files. Gate: Passed 132 / Failed 0; trends 216/216; hook suite 139/139 untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD --- agents/trend-spotter.md | 5 + references/trend-scoring-modes.md | 5 + scripts/test-runner.sh | 80 ++++++++++- scripts/trends/README.md | 21 +++ scripts/trends/src/brief.ts | 106 +++++++++++++-- scripts/trends/src/cli.ts | 24 +++- scripts/trends/tests/brief.test.ts | 207 ++++++++++++++++++++++++++--- scripts/trends/tests/cli.test.ts | 69 ++++++++++ 8 files changed, 474 insertions(+), 43 deletions(-) diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index dfa8ce7..7fa1705 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -343,6 +343,11 @@ written to `/trends/morning-brief/YYYY-MM-DD.md` and ranks only on per (pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with `--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5. +The brief also applies a **derived temporal overlay** (RE-R3d): within a relevance tier, a fresh, +not-yet-surfaced trend is ranked up as a **first-mover** (`· 🥇 først ute`) and a repeatedly-surfaced +one is ranked down as **saturated** (`· 🔁 mettet`) — computed at render time from the publish/capture +dates + the seen-log, with no new capture step. Tune with `--first-mover-days N` / `--saturation-at N`. + The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic, from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above diff --git a/references/trend-scoring-modes.md b/references/trend-scoring-modes.md index 1044648..bbb6937 100644 --- a/references/trend-scoring-modes.md +++ b/references/trend-scoring-modes.md @@ -98,3 +98,8 @@ The same priority bands apply to both modes (the composite is on the same 0–10 instead of inlining a matrix (wired in research-engine slice 2b). - Any future research-engine pass that scores candidates before writing them to the trend store (`scripts/trends/`). + +**Note (RE-R3d):** the morning brief applies a *brief-time* **temporal overlay** (first-mover / +saturation, derived from the publish/capture dates + the seen-log) as a **within-composite-tier** +ranking refinement. It is a separate layer from this file — it does **not** change the capture-time +dimension weights, bands, or composite formula above. diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index f32764d..26a26bf 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -57,8 +57,11 @@ # Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts # emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts # exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses -# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the assertion-count -# anti-erosion floor (SC6) in Section 18. All +# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the trends-temporal-overlay +# wiring guard (RE-R3d: scripts/trends/src/brief.ts has 'export function temporalSignal' AND the cmp +# key 'b.temporal.rank' AND the '"first-mover"' tier, scripts/trends/src/cli.ts exposes the +# 'first-mover-days' AND 'saturation-at' brief flags, with a non-vacuity self-test) in Section 16m; +# the assertion-count anti-erosion floor (SC6) in Section 18. All # are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh @@ -710,7 +713,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - TRENDS_TESTS_FLOOR=192 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + TRENDS_TESTS_FLOOR=216 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else @@ -1373,6 +1376,70 @@ fi echo "" +# --- Section 16m: Trends Temporal Overlay (research-engine RE-R3d) --- +echo "--- Trends Temporal Overlay ---" + +# RE-R3d adds the DERIVED temporal overlay (first-mover + saturation) to the morning-brief ranking: +# a pure temporalSignal in brief.ts, a new cmp key, and two tunable CLI flags. Five literals must +# hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u): +# (1) brief.ts exports the signal, by 'export function temporalSignal'; +# (2) brief.ts ranks on it, by 'b.temporal.rank' (the cmp key); +# (3) brief.ts declares the first-mover tier, by '"first-mover"'; +# (4) cli.ts exposes the first-mover threshold flag, by 'first-mover-days'; +# (5) cli.ts exposes the saturation threshold flag, by 'saturation-at'. +# Non-vacuity self-test mirrors Section 16l. Placed after Section 16l / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +TEMP_SIGNAL_LIT='export function temporalSignal' +TEMP_RANK_LIT='b.temporal.rank' +TEMP_TIER_LIT='"first-mover"' +TEMP_FMDAYS_LIT='first-mover-days' +TEMP_SATAT_LIT='saturation-at' + +I16M_SELFTEST_OK=1 +if ! echo 'a wired overlay declares: export function temporalSignal(ageDays)' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired temporal-overlay probe was not detected" +fi +if echo 'an unwired module derives no temporal signal at all' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the temporal-overlay pointer" +fi +if [ "$I16M_SELFTEST_OK" -eq 1 ]; then + pass "trends-temporal self-test: the signal pointer is detected, the no-signal form rejected" +else + fail "trends-temporal self-test failed — the temporal-overlay lint is vacuous or over-eager" +fi + +if grep -qF "$TEMP_SIGNAL_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts derives the temporal signal ('$TEMP_SIGNAL_LIT')" +else + fail "brief.ts has no temporal signal — add '$TEMP_SIGNAL_LIT' (RE-R3d overlay)" +fi + +if grep -qF "$TEMP_RANK_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts ranks on the temporal overlay ('$TEMP_RANK_LIT')" +else + fail "brief.ts cmp does not use the temporal rank — add '$TEMP_RANK_LIT' (RE-R3d ranking)" +fi + +if grep -qF "$TEMP_TIER_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts declares the first-mover tier ('$TEMP_TIER_LIT')" +else + fail "brief.ts has no first-mover tier — add '$TEMP_TIER_LIT' (RE-R3d tiers)" +fi + +if grep -qF "$TEMP_FMDAYS_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the first-mover-days flag ('$TEMP_FMDAYS_LIT')" +else + fail "cli.ts has no first-mover-days flag — add '$TEMP_FMDAYS_LIT' (RE-R3d brief flag)" +fi + +if grep -qF "$TEMP_SATAT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the saturation-at flag ('$TEMP_SATAT_LIT')" +else + fail "cli.ts has no saturation-at flag — add '$TEMP_SATAT_LIT' (RE-R3d brief flag)" +fi + +echo "" + # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count @@ -1395,12 +1462,15 @@ echo "" # surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts # act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test # + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep + -# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111. +# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111; +6 for RE-R3d's six +# UNCONDITIONAL Section-16m checks (temporal self-test + brief.ts temporalSignal grep + brief.ts +# b.temporal.rank cmp grep + brief.ts "first-mover" tier grep + cli.ts first-mover-days flag grep + +# cli.ts saturation-at flag grep) = 117. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=111 +ASSERT_BASELINE_FLOOR=117 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index 1c340aa..9284d8f 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -152,6 +152,27 @@ new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idemp before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a brief-history diff is also a later slice. +## Temporal overlay (RE-R3d) + +The brief applies a **derived temporal overlay** when it ranks — two signals computed at render time +from already-persisted fields (`publishedAt`/`capturedAt` → age, `surfacedCount` → self-exposure), so +**nothing new is stored** (`SCHEMA_VERSION` stays 4) and the signal can never go stale: + +- **first-mover** — recent (`ageDays ≤ --first-mover-days`, default **2**) AND never surfaced on a + prior day. Ranked up; badge `· 🥇 først ute`. A future-dated trend (`ageDays < 0`) is excluded. +- **saturation** — surfaced on `≥ --saturation-at` (default **3**) prior distinct days. Ranked down; + badge `· 🔁 mettet (Nx)`. This is **self-surfacing** ("you keep seeing this") from OUR seen-log — not + market coverage (that needs external polling, a later slice). +- **warming** (surfaced 1..at-1) keeps the RE-R3b `· sett Nx` badge, but **only at ≥2** (that badge + contract is unchanged); **neutral** (no exposure signal) carries no badge. + +Ranking integration: the relevance composite (RE-R3a) stays the **primary** sort key; the temporal +rank (first-mover↑ / saturated↓) is a new key inserted **after** pillar-overlap and **before** the +`effectiveDate` recency tiebreaker — so the overlay only re-orders *within* a (composite, overlap) +tier, never overriding relevance. Prior-day surfacings exclude today (via `lastSurfacedAt`), so a +same-day re-render is byte-identical. Tune per run with `--first-mover-days N` / `--saturation-at N` +(the scheduled nightly run uses the defaults). + ## Tests ```bash diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index d2fc1c4..14e9297 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -22,6 +22,25 @@ import type { TrendStore, TrendRecord } from "./types.js"; /** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */ export const BRIEF_SCHEMA_VERSION = 1; +/** + * The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND + * never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior + * days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt + * -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go + * stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted. + */ +export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"; +export interface TemporalSignal { + /** recent AND never surfaced on a prior day — "you'd be early". */ + firstMover: boolean; + /** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */ + surfacings: number; + /** the ordinal class (best -> worst opportunity). */ + tier: TemporalTier; + /** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */ + rank: number; +} + /** One ranked trend in the brief, with its pillar overlap + freshness. */ export interface BriefEntry { trend: TrendRecord; @@ -33,6 +52,8 @@ export interface BriefEntry { effectiveDate: string; /** Whole days from effectiveDate to the injected `today` (negative if future). */ ageDays: number; + /** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */ + temporal: TemporalSignal; } /** The full ranking the brief renders from. */ @@ -51,6 +72,10 @@ export interface BriefRanking { export interface RankOptions { /** Freshness window in days (effectiveDate within N days of today). Default 7. */ freshDays?: number; + /** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */ + firstMoverDays?: number; + /** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */ + saturationAt?: number; } /** @@ -62,6 +87,32 @@ function ageDaysBetween(effectiveDate: string, today: string): number { return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000); } +/** + * The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age + + * self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a + * prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated = + * surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal + * baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated. + */ +export function temporalSignal( + ageDays: number, + surfacedCount: number | undefined, + opts: { firstMoverDays: number; saturationAt: number }, +): TemporalSignal { + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; + const tier: TemporalTier = firstMover + ? "first-mover" + : surfacings >= at + ? "saturated" + : surfacings >= 1 + ? "warming" + : "neutral"; + const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; + return { tier, firstMover, surfacings, rank }; +} + /** * Rank the store against the user's pillars. Off-pillar trends (overlap 0) are * dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older @@ -76,6 +127,8 @@ export function rankForBrief( opts: RankOptions = {}, ): BriefRanking { const freshDays = opts.freshDays ?? 7; + const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3; const wantedLower = pillars.map((p) => p.toLowerCase()); const entries: BriefEntry[] = []; @@ -90,18 +143,38 @@ export function rankForBrief( const overlap = matchedPillars.length; if (overlap === 0) continue; // off-pillar noise const effectiveDate = trend.publishedAt ?? trend.capturedAt; - entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) }); + const ageDays = ageDaysBetween(effectiveDate, today); + // Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI + // records today's surfacing, so on the first run of a day surfacedCount is already prior-day — + // but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today), + // so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes + // "first-mover" (prior surfacings === 0) stable across the render → mark step. + const surfaced = trend.surfacedCount ?? 0; + const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced; + entries.push({ + trend, + overlap, + matchedPillars, + effectiveDate, + ageDays, + temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }), + }); } // Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance // composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted // sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts // cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are - // unchanged; composite only re-orders WITHIN a bucket. The existing overlap → effectiveDate - // → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id). + // unchanged; composite only re-orders WITHIN a bucket. + // RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and + // BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency + // (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing + // overlap → temporal → effectiveDate → title → url chain still gives a total order (the + // (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk). const cmp = (a: BriefEntry, b: BriefEntry): number => (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || b.overlap - a.overlap || + b.temporal.rank - a.temporal.rank || b.effectiveDate.localeCompare(a.effectiveDate) || a.trend.title.localeCompare(b.trend.title) || a.trend.url.localeCompare(b.trend.url); @@ -135,7 +208,9 @@ export function briefSummary(ranking: BriefRanking): string { const pillar = top.matchedPillars[0]; // Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean. const band = top.trend.score ? ` · ${top.trend.score.priority}` : ""; - return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band} · ${top.ageDays}d).`; + // RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early"). + const fm = top.temporal.firstMover ? " · 🥇 først ute" : ""; + return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).`; } return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`; } @@ -147,19 +222,24 @@ function scoreToken(e: BriefEntry): string { } /** - * ` · sett Nx` when surfacedCount>=2, else "" — the seen-log saturation HINT (RE-R3b). The count - * is PRIOR-DAY: the brief renders before the CLI records today's surfacing, so it reads "shown on - * N prior distinct days". Not the saturation SCORING of slice (b) nor the day-over-day diff of (d). + * The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set. + * `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx` + * ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still + * demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "". + * The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing). */ -function surfacedToken(e: BriefEntry): string { - const c = e.trend.surfacedCount; - return c && c >= 2 ? ` · sett ${c}x` : ""; +function temporalToken(e: BriefEntry): string { + const t = e.temporal; + if (t.tier === "first-mover") return " · 🥇 først ute"; + if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; + if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; + return ""; } function renderTopEntry(e: BriefEntry, n: number): string[] { const lines = [ `### ${n}. ${e.trend.title}`, - `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, + `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, ]; if (e.trend.summary) lines.push(`- ${e.trend.summary}`); lines.push(`- 🔗 ${e.trend.url}`); @@ -168,7 +248,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] { } function renderBulletEntry(e: BriefEntry): string { - return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; + return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; } /** @@ -184,7 +264,7 @@ export function renderBrief(ranking: BriefRanking): string { lines.push(`date: ${ranking.today}`); lines.push(`summary: ${briefSummary(ranking)}`); lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); - lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`); + lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`); lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); lines.push("---"); lines.push(""); diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index 9e6fd13..c0001ee 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -11,7 +11,8 @@ * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] - * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--json] + * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] + * [--out ] [--no-mark] [--store ] [--json] * node --import tsx src/cli.ts schedule --pillars [--at HH:MM] [--fresh-days N] * [--platform auto|launchd|cron] [--install|--uninstall] [--store ] * @@ -22,7 +23,9 @@ * one owner) first-sight on the record so the morning brief ranks on it — and reasons over * accumulated history via `query`/`list`. `brief` (RE-R2b) * renders a dated, pillar-ranked morning brief over the store to a Markdown file the - * SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no + * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated + * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`. + * `add` is the MANUAL single-trend path (raw flags, no * normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status * (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent * `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score @@ -107,7 +110,7 @@ function usage(msg: string): never { " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + - " brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--json]\n" + + " brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out ] [--no-mark] [--store ] [--json]\n" + " schedule --pillars [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store ]", ); process.exit(2); @@ -315,12 +318,25 @@ function main(): void { if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); freshDays = n; } + // RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults). + let firstMoverDays = 2; + if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { + const n = Number.parseInt(flags["first-mover-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); + firstMoverDays = n; + } + let saturationAt = 3; + if (flags["saturation-at"] && flags["saturation-at"] !== "true") { + const n = Number.parseInt(flags["saturation-at"], 10); + if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); + saturationAt = n; + } // A bare `--out` yields the string "true" (parseFlags); the guard falls back to // defaultBriefDir() so it never writes to ./true. const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir(); const day = today(); // one wall-clock read for both the ranking and the filename const store = loadStore(storePath); // hoisted: also needed for the surfacing write below - const ranking = rankForBrief(store, pillars, day, { freshDays }); + const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt }); const md = renderBrief(ranking); const path = join(outDir, `${day}.md`); mkdirSync(outDir, { recursive: true }); diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts index 5756086..2b783d6 100644 --- a/scripts/trends/tests/brief.test.ts +++ b/scripts/trends/tests/brief.test.ts @@ -8,8 +8,10 @@ import { briefSummary, defaultBriefDir, surfacedIds, + temporalSignal, BRIEF_SCHEMA_VERSION, } from "../src/brief.js"; +import { SCHEMA_VERSION } from "../src/types.js"; import type { TrendRecord, TrendStore } from "../src/types.js"; const TODAY = "2026-06-24"; @@ -93,13 +95,15 @@ describe("rankForBrief — grouping (SC1)", () => { }); describe("rankForBrief — within-group total order (SC1)", () => { - test("effectiveDate desc orders before title", () => { + test("effectiveDate desc orders before title (both neutral so the RE-R3d temporal key ties)", () => { + // RE-R3d: both entries are neutral (surfaced 0, ageDays 4-6 > firstMoverDays 2), so the temporal + // key ties and effectiveDate is the deciding key; titles disagree with date to isolate effectiveDate. const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-18", capturedAt: "2026-06-18" }), mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), - mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), ]); const r = rankForBrief(store, ["a", "b"], TODAY); - assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Bravo", "Alpha"]); }); test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => { const store = mkStore([ @@ -242,24 +246,25 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => { const pillars = ["AI", "gov"]; test("scored top-entry meta line is the full pinned shape (· () between age and Pillarer)", () => { + // neutral age (4d > firstMoverDays 2) so the RE-R3d temporal overlay adds no badge — isolates the score token. const store = mkStore([ - mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), ]); const md = renderBrief(rankForBrief(store, pillars, TODAY)); assert.ok( - md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"), + md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Immediate (kortform) · Pillarer: AI, gov"), "scored top-entry meta line must carry · () between (d) and · Pillarer", ); }); test("unscored top-entry meta line is UNCHANGED (no token)", () => { const store = mkStore([ - mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), ]); const md = renderBrief(rankForBrief(store, pillars, TODAY)); assert.ok( - md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"), - "unscored top-entry meta line must be unchanged", + md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Pillarer: AI, gov"), + "unscored neutral top-entry meta line carries no score and no temporal token", ); }); @@ -286,20 +291,21 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => { }); test("briefSummary names the band (no mode) on a scored top", () => { + // neutral age (4d) so the RE-R3d first-mover marker stays off — isolates the band token. const store = mkStore([ - mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), ]); const s = briefSummary(rankForBrief(store, pillars, TODAY)); - assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`); + assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 4d)."), `summary should carry the band: ${s}`); assert.ok(!s.includes("kortform"), "summary must not carry the mode"); }); test("briefSummary omits the band token on an unscored top", () => { const store = mkStore([ - mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), ]); const s = briefSummary(rankForBrief(store, pillars, TODAY)); - assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`); + assert.ok(s.includes("Topp: «Alpha» (AI · 4d)."), `unscored summary should omit the band: ${s}`); }); test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => { @@ -314,11 +320,11 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => { test("single-pillar unscored top -> summary renders with no · token, one line", () => { const store = mkStore([ - mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), ]); const s = briefSummary(rankForBrief(store, ["AI"], TODAY)); assert.ok(!s.includes("· ·"), "no empty priority slot"); - assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`); + assert.ok(s.includes("Topp: «Solo» (AI · 4d)."), `single-pillar unscored summary: ${s}`); assert.ok(!s.includes("\n")); }); @@ -328,8 +334,8 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => { ]); const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 })); assert.ok( - md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"), - "the ranking descriptor must match the pinned RE-R3a string verbatim", + md.includes("ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays 7"), + "the ranking descriptor must carry the RE-R3d temporal key (RE-R3a base + RE-R3d)", ); }); @@ -398,20 +404,20 @@ describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => { const md = renderBrief(rankForBrief(s, pillars, TODAY)); assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks"); }); - test("RED: · sett Nx appears only when surfacedCount >= 2 (prior-day count)", () => { + test("· sett Nx appears only when surfacedCount >= 2 (warming badge, RE-R3b contract preserved by RE-R3d)", () => { const s = mkStore([ - mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 3 }), + mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2 }), mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }), ]); const md = renderBrief(rankForBrief(s, pillars, TODAY)); - assert.ok(md.includes("· sett 3x"), "surfacedCount 3 → · sett 3x"); - assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker"); + assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x"); + assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)"); }); test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => { const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY)); assert.match( md, - /\nranking: composite desc, then pillar-overlap desc, then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/, + /\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/, ); }); }); @@ -432,3 +438,162 @@ describe("RE-R3b — surfacedIds (D7, Phase B)", () => { assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5"); }); }); + +// ── RE-R3d: temporal overlay (first-mover + saturation) ── + +describe("temporalSignal — first-mover detection (RE-R3d / SC1)", () => { + const opts = { firstMoverDays: 2, saturationAt: 3 }; + test("recent + unsurfaced is first-mover (rank 3); undefined surfacedCount counts as 0", () => { + for (const a of [0, 1, 2]) { + const s = temporalSignal(a, 0, opts); + assert.equal(s.tier, "first-mover", `ageDays ${a} surfaced 0`); + assert.equal(s.firstMover, true); + assert.equal(s.rank, 3); + } + assert.equal(temporalSignal(1, undefined, opts).tier, "first-mover"); + }); + test("past the window is neutral (rank 2)", () => { + const s = temporalSignal(3, 0, opts); + assert.equal(s.tier, "neutral"); + assert.equal(s.firstMover, false); + assert.equal(s.rank, 2); + }); + test("recent but already surfaced is NOT first-mover (warming)", () => { + const s = temporalSignal(1, 1, opts); + assert.equal(s.tier, "warming"); + assert.equal(s.firstMover, false); + }); + test("future publishedAt (negative ageDays) is NOT first-mover (>=0 guard)", () => { + const s = temporalSignal(-1, 0, opts); + assert.equal(s.tier, "neutral"); + assert.equal(s.firstMover, false); + }); +}); + +describe("temporalSignal — saturation grading + clamp (RE-R3d / SC2, SC9)", () => { + const opts = { firstMoverDays: 2, saturationAt: 3 }; + test("surfacings >= saturationAt is saturated (rank 0, inclusive)", () => { + for (const c of [3, 4]) { + const s = temporalSignal(5, c, opts); + assert.equal(s.tier, "saturated", `surfaced ${c}`); + assert.equal(s.rank, 0); + assert.equal(s.surfacings, c); + } + }); + test("1..saturationAt-1 is warming (rank 1)", () => { + for (const c of [1, 2]) { + const s = temporalSignal(5, c, opts); + assert.equal(s.tier, "warming", `surfaced ${c}`); + assert.equal(s.rank, 1); + } + }); + test("surfaced 0 (not recent) is neutral (rank 2)", () => { + assert.equal(temporalSignal(5, 0, opts).tier, "neutral"); + }); + test("defensive clamp: saturationAt 0 does NOT mark every non-first-mover saturated", () => { + assert.equal(temporalSignal(5, 5, { firstMoverDays: 2, saturationAt: 0 }).tier, "saturated"); + assert.equal(temporalSignal(5, 0, { firstMoverDays: 2, saturationAt: 0 }).tier, "neutral"); + }); + test("pure: same inputs -> same output", () => { + assert.deepEqual(temporalSignal(2, 1, opts), temporalSignal(2, 1, opts)); + }); +}); + +describe("rankForBrief — temporal overlay re-orders within tier, composite dominates (RE-R3d / SC3)", () => { + const pillars = ["a", "b"]; + // DISAGREEMENT fixture: temporal.rank and effectiveDate-desc disagree, so the new key is what decides. + // (surfacedCount correlates with age, so a naive first-mover-vs-saturated fixture would already be ordered + // correctly by the existing effectiveDate key — that test would pass WITHOUT the feature.) + const store = mkStore([ + // A: neutral (surfaced 0, ageDays 5 > firstMoverDays), OLDER date, composite 7.0 + mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-19", capturedAt: "2026-06-19", score: mkScore(7.0, "High") }), + // B: warming (surfaced 2), NEWER date, composite 7.0 + mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2, score: mkScore(7.0, "High") }), + // Z: saturated (surfaced 4) but HIGHER composite 8.5 + mkTrend({ title: "Z", url: "https://e/z", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", surfacedCount: 4, score: mkScore(8.5, "Immediate") }), + ]); + test("[Z, A, B]: composite primary (Z); then temporal (A neutral > B warming) over newer-date B", () => { + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Z", "A", "B"]); + }); + test("total order: deterministic regardless of store insertion order", () => { + const reordered = mkStore([store.trends[2], store.trends[0], store.trends[1]]); + assert.deepEqual(rankForBrief(reordered, pillars, TODAY).topMatches.map((e) => e.trend.title), ["Z", "A", "B"]); + }); +}); + +describe("renderBrief — temporal badges + the >=2 boundary (RE-R3d / SC4)", () => { + const pillars = ["AI", "gov"]; + const render = (p: Parameters[0]): string => renderBrief(rankForBrief(mkStore([mkTrend(p)]), pillars, TODAY)); + test("first-mover entry carries · 🥇 først ute", () => { + const md = render({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }); + assert.ok(md.includes("· 🥇 først ute"), "ageDays 1 + unsurfaced -> first-mover badge"); + }); + test("saturated entry carries · 🔁 mettet (3x)", () => { + const md = render({ title: "Sat", url: "https://e/sat", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 3 }); + assert.ok(md.includes("· 🔁 mettet (3x)"), "surfacedCount 3 -> saturated badge"); + }); + test("warming entry with surfacedCount 2 carries · sett 2x", () => { + const md = render({ title: "Warm", url: "https://e/warm", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 2 }); + assert.ok(md.includes("· sett 2x"), "surfacedCount 2 -> warming badge (>=2)"); + }); + test("warming entry with surfacedCount 1 carries NO badge (preserves the R3b >=2 contract)", () => { + const md = render({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 1 }); + assert.ok(!md.includes("sett 1x"), "surfacedCount 1 -> no marker"); + assert.ok(!md.includes("mettet"), "surfacedCount 1 is not saturated"); + }); + test("neutral entry carries none of the temporal badges", () => { + const md = render({ title: "Neu", url: "https://e/neu", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }); + assert.ok(!md.includes("først ute") && !md.includes("mettet") && !md.includes("· sett "), "ageDays 4 + unsurfaced -> neutral"); + }); +}); + +describe("briefSummary — first-mover marker (RE-R3d / SC5)", () => { + const pillars = ["AI", "gov"]; + test("first-mover top carries · 🥇 først ute and stays regex-safe", () => { + const s = briefSummary(rankForBrief(mkStore([ + mkTrend({ title: "Fresh", url: "https://e/f", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(9.0, "Immediate") }), + ]), pillars, TODAY)); + assert.ok(s.includes("· 🥇 først ute"), `first-mover summary marker: ${s}`); + assert.ok(!s.includes('"') && !s.includes("\n"), "summary stays single-line, no double-quote"); + }); + test("non-first-mover top omits the marker", () => { + const s = briefSummary(rankForBrief(mkStore([ + mkTrend({ title: "Old", url: "https://e/o", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), + ]), pillars, TODAY)); + assert.ok(!s.includes("først ute"), `neutral top, no marker: ${s}`); + }); +}); + +describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => { + test("ranking does not mutate score.composite; schema versions unchanged", () => { + const t = mkTrend({ title: "X", url: "https://e/x", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(8.0, "Immediate") }); + const before = t.score!.composite; + rankForBrief(mkStore([t]), ["AI"], TODAY); + assert.equal(t.score!.composite, before, "rankForBrief must not mutate the stored composite"); + assert.equal(BRIEF_SCHEMA_VERSION, 1); + assert.equal(SCHEMA_VERSION, 4); + }); +}); + +describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day re-run idempotency)", () => { + const pillars = ["AI", "gov"]; + test("a first-mover trend already surfaced TODAY stays first-mover (today excluded from the prior count)", () => { + // surfacedCount 1 but lastSurfacedAt === today -> prior-day count 0 -> still first-mover, so a + // same-day re-render (which loads the post-mark count) is byte-identical to the first run (RE-R3c SC7). + const s = mkStore([ + mkTrend({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: TODAY }), + ]); + const e = rankForBrief(s, pillars, TODAY).topMatches[0]; + assert.equal(e.temporal.tier, "first-mover", "today's own surfacing must not demote it out of first-mover"); + assert.equal(e.temporal.surfacings, 0, "prior-day surfacings exclude today"); + }); + test("the same count surfaced on a PRIOR day is warming (today not excluded)", () => { + const s = mkStore([ + mkTrend({ title: "W", url: "https://e/w", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: "2026-06-23" }), + ]); + const e = rankForBrief(s, pillars, TODAY).topMatches[0]; + assert.equal(e.temporal.tier, "warming", "a prior-day surfacing counts"); + assert.equal(e.temporal.surfacings, 1); + }); +}); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts index 74bc7b6..ba8642f 100644 --- a/scripts/trends/tests/cli.test.ts +++ b/scripts/trends/tests/cli.test.ts @@ -600,3 +600,72 @@ describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print } }); }); + +describe("trends CLI — brief temporal flags (RE-R3d / SC6)", () => { + // brief is flag-driven; spawn into a temp store/out so the real per-user data dir is never touched. + function runBrief(args: string[], env: Record = {}): { status: number | null; stdout: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], { + input: "", + encoding: "utf8", + cwd: trendsDir, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout }; + } + const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + function seedStore(trends: unknown[]): string { + const store = join(mkdtempSync(join(tmpdir(), "trends-r3d-")), "trends.json"); + writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends })); + return store; + } + // --no-mark so sequential runs on the same store do NOT accumulate surfacedCount (which would + // confound the threshold assertions); tier badges live in the .md body, not in --json. + function briefMd(args: string[]): { status: number | null; md: string } { + const out = mkdtempSync(join(tmpdir(), "r3d-out-")); + const { status, stdout } = runBrief([...args, "--out", out, "--no-mark", "--json"]); + const path = status === 0 ? (JSON.parse(stdout).path as string) : ""; + return { status, md: path ? readFileSync(path, "utf8") : "" }; + } + + test("--saturation-at 2 escalates a surfacedCount-2 trend from warming (sett 2x) to saturated (mettet 2x)", () => { + const store = seedStore([ + { id: "s", title: "Seen", url: "https://e/s", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"], surfacedCount: 2 }, + ]); + try { + const dflt = briefMd(["--pillars", "ai", "--store", store]); + assert.equal(dflt.status, 0); + assert.ok(dflt.md.includes("· sett 2x"), "default saturationAt 3 -> surfacedCount 2 is warming"); + const tuned = briefMd(["--pillars", "ai", "--store", store, "--saturation-at", "2"]); + assert.equal(tuned.status, 0); + assert.ok(tuned.md.includes("· 🔁 mettet (2x)"), "--saturation-at 2 -> surfacedCount 2 is saturated"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("--first-mover-days 1 drops a 2-day-old unsurfaced trend out of first-mover", () => { + const store = seedStore([ + { id: "f", title: "Fresh", url: "https://e/f", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] }, + ]); + try { + const dflt = briefMd(["--pillars", "ai", "--store", store]); + assert.ok(dflt.md.includes("· 🥇 først ute"), "default firstMoverDays 2 -> a 2-day unsurfaced trend is first-mover"); + const tuned = briefMd(["--pillars", "ai", "--store", store, "--first-mover-days", "1"]); + assert.ok(!tuned.md.includes("først ute"), "--first-mover-days 1 -> a 2-day trend is neutral"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("invalid threshold flags -> exit 2", () => { + const store = seedStore([]); + try { + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "x"]).status, 2, "--first-mover-days x"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "-1"]).status, 2, "--first-mover-days -1"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "0"]).status, 2, "--saturation-at 0"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "x"]).status, 2, "--saturation-at x"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); +});