From 5aa7187243ef94d0a8ac39f18e8967f7d37af557 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 11:43:22 +0200 Subject: [PATCH] =?UTF-8?q?docs(linkedin-studio):=20RE-R3d=20brief=20+=20p?= =?UTF-8?q?lan=20=E2=80=94=20temporal=20overlay=20(first-mover=20+=20satur?= =?UTF-8?q?ation),=20light-Voyage=20hardened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 slice (b): the rest of hull #3. Two derived brief-time signals — first-mover (recent + unsurfaced -> ranked up) and saturation (surfaced >= N prior days -> ranked down) — computed from already-persisted fields. SB1 derived (no schema bump, SCHEMA_VERSION stays 4); SB2 R3a composite stays the primary sort key, the overlay is a within-tier cmp refinement. Zero new source/test files; counts 29/19/27; ASSERT floor 111 -> 117. Three Opus reviewers (scope-guardian / brief-reviewer / plan-critic) folded: warming badge gated at >=2 (preserves the R3b contract), disagreement ordering fixture (true RED), saturationAt clamp, ageDays>=0 guard, fresh->neutral rename, SSOT one-line note, nightly-thresholds known limitation, cite fixes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD --- docs/research-engine/brief-re-r3d.md | 401 +++++++++++++++++++++++++++ docs/research-engine/plan-re-r3d.md | 335 ++++++++++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 docs/research-engine/brief-re-r3d.md create mode 100644 docs/research-engine/plan-re-r3d.md diff --git a/docs/research-engine/brief-re-r3d.md b/docs/research-engine/brief-re-r3d.md new file mode 100644 index 0000000..47bf7f1 --- /dev/null +++ b/docs/research-engine/brief-re-r3d.md @@ -0,0 +1,401 @@ +# Brief — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Slice:** RE-R3d (research-engine rung-2, R3 slice **(b)** in the operator's `(a)→(c)→(b)→(d)→(e)` +> sequence — the doc is numbered r3d by creation order, the concept is slice (b)). It closes the **rest of +> hull #3** (substrate §1): the store-schema fields a brief *ranks on* were `relevance` (✅ R3a) · `status` +> (✅ R3b) · **first-mover** · **saturation** · `angle`. `angle` is **already** a scored dimension +> (`score.dimensions.angle`, weight 15 %/20 % — `score.ts:24`/`:32`), folded into the R3a composite; so the +> rest of #3 is exactly **saturation + first-mover**, confirming §5's framing. +> **Predecessor:** RE-R3a (persisted relevance composite — the PRIMARY within-bucket rank key, `brief.ts:102`) +> + RE-R3b (the seen-log: per-day-idempotent `surfacedCount`/`lastSurfacedAt`, `types.ts:71-78`, whose comment +> names this slice — *"the temporal foundation slices (c)+(b) read"*) + RE-R3c (the autonomous trigger that +> makes `surfacedCount` **accumulate day-over-day without the operator running anything** — the dependency that +> makes saturation a real signal, the load-bearing reason (b) follows (c)). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (*"store-schema mangler felt en brief +> rangerer på (relevance/first-mover/saturation/angle/status)"*) + §B4 (*"freshness-vindu … + dedup-state +> (append-only seen-log → ikke re-overflate samme sak)"* — the seen-log IS the saturation substrate). R3d turns +> the seen-log + the publish/capture dates into a **live temporal overlay** the brief ranks on. +> **The core decision (operator-confirmed, AskUserQuestion 2026-06-26 — baked):** +> - **SB1 — derived at brief time, NOT a stored field.** first-mover/saturation are pure functions of fields the +> store ALREADY persists (`publishedAt`/`capturedAt` → age; `surfacedCount` → repeat-exposure) + the injected +> `today` — exactly like `ageDays`/`effectiveDate` are derived on `BriefEntry`, never stored. **No +> `SCHEMA_VERSION` bump (stays 4); no `BRIEF_SCHEMA_VERSION` bump (stays 1).** The signal recomputes every run, +> so it can never go stale (a stored field would, as `surfacedCount` grows). Respects the discipline that an +> *avledbar* value is not persisted (`ageDays` is derived; `score` is persisted only because it is a frozen +> model judgment — `types.ts:54-64`). +> - **SB2 — refine recency WITHIN the composite tier; do not override it.** The R3a relevance composite stays +> the PRIMARY sort key (`brief.ts:103`). The temporal signal enters `cmp` as a NEW key **after** pillar-overlap, +> **before** `effectiveDate` — a richer recency class than the raw date it sits in front of. A +> saturated-but-higher-composite trend still outranks a fresh-but-lower one (composite dominates across tiers); +> the overlay only re-orders *within* the same (composite, overlap) tier. Honors *"felt en brief rangerer på"* +> without shadowing the SSOT-pinned relevance score. +> **TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c):** Phase A — `temporalSignal` is a NEW +> named export of the EXISTING `brief.ts`; under Node16 ESM a missing named import throws at module-load (every +> `brief.test.ts` test would error, not assert), so land a **non-throwing stub** (`temporalSignal → {tier:"fresh", +> …}`, `BriefEntry.temporal` populated by it) FIRST, then record value-assertion RED against it (the stub returns +> "fresh" always → the first-mover/saturated/ordering/token assertions fail on values, true assertion-RED). Phase +> B — the CLI flag tests are value-RED against the existing `brief` handler (the new flags are silently ignored by +> `parseFlags` today → tuned-threshold behaviour is unchanged → RED). See plan Step 1. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3c built the deterministic spine, the trend's life +after capture, **and** the autonomy that runs it: item-schema + triage (R1) → capture bridge (R2a) → dated +morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + +re-score (R3b) → autonomous trigger + headless entry (R3c). The brief now **regenerates itself every morning** +(R3c), so `surfacedCount` accumulates day-over-day on its own — but the brief still treats a trend the operator +has seen surfaced for five straight days **identically** to one captured an hour ago, as long as their frozen +relevance composites tie. The temporal axis the seen-log records is **logged but unread by the ranking**. + +R3d closes the **rest of hull #3** — the **first-mover** and **saturation** signals — which the operator chose as +slice **(b)** of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) +→ (d) → (e)"*). It is sequenced **after** R3c for a load-bearing reason: saturation reads `surfacedCount`, and +`surfacedCount` only accumulates *autonomously* because R3c now fires the brief daily. (c) makes (b) meaningful; +without the daily trigger, the seen-log would only grow on the days the operator happened to ask for a brief. + +**The concrete value — keeping a frozen composite honest over time.** R3a *froze* the relevance composite at first +capture (`types.ts:54-59`: *"first-sight, never updated on re-capture"*). A trend captured fresh scores +`timing` 9 ("you'd be among first") → a high composite → "Immediate" → sorts top (`brief.ts:103`). Six days and +five surfacings later it is stale and over-exposed, yet the **frozen** composite still says "Immediate" and still +sorts top. R3d is the **live temporal overlay** that demotes it — *without mutating the stored composite* (which +stays the SSOT-pinned, re-score-on-re-capture value R3b owns). `surfacedToken` (`brief.ts:154`) is already a +*hint* of this — the `· sett Nx` marker explicitly labelled *"Not the saturation SCORING of slice (b)"*. R3d +promotes that placeholder hint into the real, ranked signal. + +## 2. The gap — grounded in code + +- **The seen-log is written but never ranked on (hull 3, saturation).** R3b records `surfacedCount`/ + `lastSurfacedAt` per trend (`store.ts:187-202`, `types.ts:71-78`) and renders a display hint (`brief.ts:154-157`, + the `· sett Nx` token at `>=2`), but `rankForBrief`'s comparator (`brief.ts:102-107`) **never reads it** — sort + order is `composite → overlap → effectiveDate → title → url`. A trend surfaced ten times sorts identically to + one surfaced zero times at the same composite+overlap. +- **Recency is read coarsely (hull 3, first-mover).** The comparator's only temporal key is `effectiveDate` + (`brief.ts:105`) — a raw date tiebreaker. There is no notion of *"this is genuinely fresh AND uncrowded — you'd + be first"* vs *"this is just the most recent of a stale set"*. The capture-time `timing` dimension + (`score.ts:23`, kortform 20 %) captures a first-mover *judgment*, but it is **frozen at capture** (R3a) — it + cannot reflect that the same trend is no longer fresh a week later. +- **`angle` is already covered (hull 3, no work needed).** Hull #3 lists `angle` among the rank fields, but + `angle` is one of the five scored dimensions (`score.ts:24` kortform 15 %, `:32` long-form 20 %), persisted in + `score.dimensions` (R3a) and already folded into the composite the brief ranks on. No separate field is needed; + §5's *"saturation + first-mover (resten av #3)"* is exact. +- **The brief's `ranking:` frontmatter would misrepresent itself.** The descriptor line (`brief.ts:187`) states + the exact sort; once the overlay enters `cmp`, that line must name the new key or the artifact lies about its + own ordering. + +## 3. Scope — what is IN (RE-R3d) + +**Zero new source/test files** (the two tracked slice docs aside). R3d is pure EDITs: the derived signal lives beside `ageDaysBetween` in `brief.ts` (the module +that already owns the derived-temporal ranking logic — surgical, no new module to wire into the gate), its unit +tests in `brief.test.ts`, its CLI flags in `cli.ts`/`cli.test.ts`, plus the wiring docs + gate. + +### S-signal — `scripts/trends/src/brief.ts` (EDIT) — the derived temporal overlay + +- **`export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"`** — the four ordinal classes + of the temporal axis (best → worst opportunity). **`neutral`** (the draft called this "fresh" — renamed: a tier + named "fresh" collided with the `freshDays` bucketing concept AND mislabelled a 30-day-old-but-unsurfaced trend + as "fresh"; `neutral` is honestly "no exposure signal, no recency edge" — folded, all three reviewers). +- **`export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number }`** + — `firstMover` = recent AND unsurfaced; `surfacings` = prior-day surfacings (`surfacedCount ?? 0`, the + self-exposure level); `rank` = the descending sort integer (`first-mover` 3 > `neutral` 2 > `warming` 1 > + `saturated` 0). +- **`export function temporalSignal(ageDays: number, surfacedCount: number | undefined, opts: { firstMoverDays: + number; saturationAt: number }): TemporalSignal`** — pure (no clock/fs/env; takes the already-computed + `ageDays` + the persisted `surfacedCount` + the injected thresholds). Logic: + - `surfacings = surfacedCount ?? 0`; `const at = Math.max(1, opts.saturationAt)` — a **defensive clamp**: a + direct caller (the function is a public export the gate greps for) passing `saturationAt 0` would otherwise + make `surfacings >= 0` always true → every non-first-mover trend "saturated". The CLI also guards `< 1`, but + the pure function must not trust its caller (folded — plan-critic m3). + - `firstMover = ageDays >= 0 && ageDays <= firstMoverDays && surfacings === 0` — recent AND never surfaced on a + prior day. The **`ageDays >= 0`** guard keeps a *future*-dated trend (data glitch) OUT of the "act now, you're + early" headline (folded — plan-critic m5). The window maps to the SSOT "<24-72h, you would be among first" + band, evaluated on the LIVE date rather than frozen at capture. + - tier: `first-mover` if `firstMover`; else `saturated` if `surfacings >= at`; else `warming` if `surfacings >= + 1`; else `neutral`. (`firstMover` requires `surfacings === 0`; `saturated`/`warming` both require `surfacings + >= 1` — so first-mover can collide with neither; the four branches are disjoint and total.) + - `rank` derived from tier as above. +- **`RankOptions` gains two knobs** (`brief.ts:51-54`): `firstMoverDays?: number` (default **2**) + + `saturationAt?: number` (default **3** — the existing `· sett Nx` hint fires at `>=2`, so default 3 keeps + `surfacedCount 2` a "warming · sett 2x" FYI and escalates `>=3` to "saturated"). Defaults documented like + `freshDays`'s default 7. +- **`BriefEntry` gains `temporal: TemporalSignal`** (`brief.ts:26-36`) — populated in `rankForBrief` right after + `ageDays` is computed: `temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })`. +- **`rankForBrief` threads the two new opts** (`brief.ts:78`): `const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3;`. The bucketing (`isFresh`/`freshDays`) is **unchanged** — + saturation does NOT move a trend between top/single/older buckets; it only re-orders *within* a bucket via the + comparator (a soft signal, not a filter — staleness still owns the older bucket via `freshDays`). **Note** the + intended in-bucket effect this exposes (folded — plan-critic m4): inside `olderMatched`, a `neutral` + (unsurfaced) trend ranks *above* a `warming`/`saturated` one — "you have not been shown this stale item yet" + ranks above "you have seen and skipped this stale item N times." That is the saturation signal working, not a + bug; the `neutral` (not "fresh") name keeps it honest. +- **`cmp` gains the temporal key (SB2)** (`brief.ts:102-107`): insert `b.temporal.rank - a.temporal.rank` **after** + `b.overlap - a.overlap` and **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays + PRIMARY; the chain stays a total order (`rank` is an integer; ties fall through to the unchanged + `effectiveDate → title → url` tail, whose `(title,url)` pair is the unique id). +- **`temporalToken` replaces `surfacedToken`** (`brief.ts:154-157`): the R3b placeholder hint is promoted to the + ranked signal's badge — `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → + `· sett Nx` **only when `surfacings >= 2`** (this **exactly preserves the R3b ≥2 badge contract**: the live + `surfacedToken` fires only at `c >= 2`, `brief.ts:156`, and `brief.test.ts:408` pins `!md.includes("sett 1x")`; + the warming *tier* still demotes a surfaced-once trend in `rank`, but its *badge* stays suppressed at 1 — folded, + all three reviewers: the draft's "preserves the hint" was inaccurate because warming covers `>= 1`; gating the + badge at `>= 2` makes it true); `neutral` → `""`. Used in both `renderTopEntry` (`brief.ts:162`) and + `renderBulletEntry` (`brief.ts:171`), replacing the `surfacedToken(e)` call. (The `scoreToken` is untouched.) +- **`briefSummary` carries the first-mover marker** (`brief.ts:131-141`): when the top entry is a first-mover, + append `· 🥇 først ute` inside the headline parens (`(${pillar}${band} · 🥇 først ute · ${top.ageDays}d)`) — + the one-line surfacing the SessionStart hook shows then says *"act now, you're early"*. The marker carries **no + double-quote and no newline**, so the hook's `^summary: *"?([^"\n]*)"?` regex still captures it whole + (`brief.ts:128-130` invariant preserved). +- **`renderBrief`'s `ranking:` descriptor names the new key** (`brief.ts:187`): *"composite desc, then + pillar-overlap desc, **then temporal (first-mover↑/saturated↓)**, then publishedAt desc (capturedAt fallback); + freshDays N; excludes acted/skipped"* — so the artifact self-documents its true sort. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — two `brief` threshold flags + +- **`brief … [--first-mover-days N] [--saturation-at N]`** (mirror `--fresh-days`, `cli.ts:312-317`): + `--first-mover-days` parses a **non-negative** integer (default 2; bad → `usage` exit 2); `--saturation-at` + parses a **positive** integer (default 3; `< 1` → `usage` exit 2, since `saturationAt 0` would mark every + surfaced trend saturated). Both passed into `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, + saturationAt })` (`cli.ts:323`). +- **Usage + header synopsis** (`cli.ts:14`, `:110`): add the two optional flags to the `brief` line + a one-line + header note that the brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. +- **`schedule` is untouched** — the nightly run uses the **defaults** (2/3). Baking tunable thresholds into the + schedule artifact is OUT for R3d (keeps `schedule.ts`/`run-daily.sh` and the R3c tests untouched — no + regression surface). + +### Wiring (D-default — WIRE, mirrors R3a/R3b/R3c) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the agent's polling/capture path is + unchanged. Domain-general (no vendor/sector token). +- `references/trend-scoring-modes.md` (SSOT, EDIT — **one-line note only**): under "Consumers", note that the + morning brief applies a *brief-time* temporal overlay (first-mover/saturation, RE-R3d) as a + **within-composite-tier** ranking refinement, and that **this does not change the capture-time weights** above. + The five-dimension capture-scoring math is **untouched** (R3d changes no weight, no band, no formula) — the note + prevents the SSOT from being misread as the *whole* ranking story (verifiseringsplikt; honesty). +- `scripts/trends/README.md` (EDIT): add a `## Temporal overlay (RE-R3d)` section between the R3c scheduler + section (`README.md:135-154`) and `## Tests` (`:155`): the first-mover/saturation definitions, the + **derived-not-stored** boundary (no schema bump), the `--first-mover-days`/`--saturation-at` flags + defaults, + the `cmp` integration (composite stays primary), and the badge set. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (**:713**, currently **192**) to the `tests N` line + reported after the suite runs, **append** `+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline + breakdown comment. Add **Section 16m** ("Trends Temporal Overlay", RE-R3d) **between Section 16l's closing + `echo ""` (`:1374`) and the Section 18 header (`:1376`)** (anti-erosion must stay last). Mirror 16l's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). + **6 emitters** (all on tracked source, no `tsx`, all literals **ASCII** — test-runner.sh must stay ASCII-only, + so the badge sentinels grep the ASCII tier/flag literals, never the emoji): (1) self-test; (2) `export function + temporalSignal` in `brief.ts`; (3) the cmp key `b.temporal.rank` in `brief.ts`; (4) the tier literal + `"first-mover"` in `brief.ts`; (5) the flag key `first-mover-days` in `cli.ts`; (6) the flag key `saturation-at` + in `cli.ts`. **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 111 → exactly 117** (**:1403**; "live + recount" is the safety net; the expected value is the pinned 111 + 6). Insert the 16m clause into the + **header-enumeration prose chain (`:57-62`)** before "…the assertion-count anti-erosion floor (SC6) in Section + 18", and **append the R3d (→117) narration** to the Section-18 floor-history comment (`:1376-1402`, which ends + "= 111"). + +## 4. Non-goals — what is OUT (deferred) + +- **A stored `saturation`/`firstMover` field + schema bump** — OUT (SB1). The signals are **derived** each run; + persisting an *avledbar* value would go stale as `surfacedCount` grows and would violate the + `ageDays`-is-derived discipline. `SCHEMA_VERSION` stays **4**; `types.ts` is **untouched**. +- **Market/external saturation** (how crowded the topic is *across the web* — how many others have posted) — OUT, + needs external polling = **slice (e)** (the AI fan-out). R3d's saturation is **self-surfacing** only: a proxy + for *"you keep seeing this and not acting → the window is closing for you"*, derived from OUR seen-log. The + brief is honest about which it measures (the README + the badge wording say "seen N×", not "covered N× online"). +- **Mutating / re-scoring the relevance composite** — OUT. The R3a composite stays frozen + PRIMARY (SB2). R3d + adds a SEPARATE sort key; it never recomputes, decays, or overwrites `score.composite`. The displayed composite + is always the stored value. +- **Saturation as a filter (auto-dropping / hiding saturated trends)** — OUT. Saturation **demotes within a + bucket**; it never removes a trend. Dropping is owned by `status` (acted/skipped, R3b) and the `freshDays` + staleness window (the older bucket); R3d's overlay is a soft *re-ordering* signal, not a gate. +- **Tunable thresholds in the scheduled run** — OUT, and a **known limitation** (not merely a later nicety — + folded — plan-critic m6 / brief-reviewer). `brief` gets `--first-mover-days`/`--saturation-at` for an + interactive/tuned run; the **nightly `schedule` run is the primary saturation consumer** (it is what makes + `surfacedCount` accumulate, §1) yet is locked to the **defaults** (2/3) — the operator cannot retune the signal + *where it actually fires* without re-running `schedule`. Accepted for R3d to keep `schedule.ts`/`run-daily.sh` + + the R3c tests untouched (no regression surface); baking the two flags into the schedule artifact is a small, + clearly-scoped follow-up. +- **Brief history / day-over-day diff** ("what changed since yesterday" — hull 7) — **slice (d)**. +- **A new module / new test file** — none. The signal lives in `brief.ts`; its tests in `brief.test.ts`. (No + pathguard surface either — all EDITs.) +- **New agent / new command / new reference doc** — none. R3d EDITs `brief.ts` + `cli.ts` + their tests + one + agent (prose) + the SSOT (one note) + README + gate. Counts stay **29/19/27**. `BRIEF_SCHEMA_VERSION` stays + **1** (the frontmatter *fields* are unchanged — only the `ranking:` descriptor string + body badge tokens + change; R3a/R3b added tokens without bumping it either — `brief.ts:23`). + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — `temporalSignal` is a + new named export of the existing `brief.ts`; Node16 ESM throws a missing named import at module-load, so land a + non-throwing **stub** first (returns `{tier:"fresh",firstMover:false,surfacings:0,rank:2}`; `BriefEntry.temporal` + populated by it), THEN record value-assertion RED (the stub's constant "fresh" fails the first-mover/saturated/ + ordering/token assertions). Phase B — the CLI flag tests are value-RED against the existing handler (the new + flags are silently ignored today). The plan does **not** claim a single "everything fails before any code" run. +- **`temporalSignal` is pure** (no clock, no fs, no env, no AI): every input is injected (`ageDays` already + computed, `surfacedCount` from the record, thresholds from the CLI edge). Mirrors `ageDaysBetween`/`renderBrief` + purity. Same inputs → same output. +- **Determinism of the brief:** given `(store, pillars, today, freshDays, firstMoverDays, saturationAt)` the + rendered `.md` is **byte-identical** (R2b/R3a/R3b/R3c proved the byte-determinism of the rest; the overlay adds + only a pure, injected-threshold sort key + deterministic tokens). +- **Composite stays PRIMARY + frozen (SB2 + R3a):** `cmp`'s first key is unchanged (`b.trend.score?.composite ?? + -1`); the temporal key sits *after* overlap. A saturated higher-composite trend still outranks a fresh + lower-composite one — the overlay re-orders only WITHIN a (composite, overlap) tier. The stored + `score.composite` is never read for mutation, only for sorting. +- **No schema / SSOT-math change:** `types.ts` untouched (`SCHEMA_VERSION` 4); `brief.ts`'s + `BRIEF_SCHEMA_VERSION` stays 1; `score.ts`/`store.ts`/`item.ts`/`schedule.ts` untouched. The SSOT + (`trend-scoring-modes.md`) gets a **one-line consumer note** only — **no weight, band, or formula changes** (the + capture-scoring instrument is unchanged; the overlay is a separate brief-time layer). +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only (`session-start.mjs` per R3c brief + §2). The summary's new `· 🥇 først ute` marker carries no double-quote/newline, so the extractYaml regex still + captures it whole. R3d touches neither the hook nor the frontmatter field set; the hook suite must still pass + untouched (regression sanity; R3d adds no hook test). +- **ASCII-only gate literals:** `scripts/test-runner.sh` must stay ASCII (a multibyte char crashes bash 3.2 under + `set -u`). The Section-16m sentinels grep the **ASCII** tier/flag literals (`"first-mover"`, `first-mover-days`, + `saturation-at`, `b.temporal.rank`, `export function temporalSignal`) — **never** the emoji badges (which live + only in `brief.ts` source + rendered output, asserted by the TS tests, not by the shell gate). +- **Domain-general:** no hard-coded user/repo path, no vendor/sector token in any edit. The tier labels + + Norwegian badge wording (`først ute`, `mettet`, `sett Nx`) are domain-general UI copy (the brief's existing + language); pillars/topics remain config. Section 17 de-niche stays green. +- **Pathguard:** R3d adds **no new files** — every change is an EDIT of an existing file (write-allowed). (No + `.mjs`-under-`hooks/scripts/` surface, no new `scripts/` file.) +- **Counts** (refs/agents/commands 27/19/29) unchanged — **recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (first-mover detection)** — `temporalSignal(ageDays, surfacedCount, {firstMoverDays:2, saturationAt:3})`: + `(1, 0)`, `(1, undefined)`, `(2, 0)`, `(0, 0)` → `{tier:"first-mover", firstMover:true, rank:3}`; `(3, 0)` (past + the window) → NOT first-mover (`tier:"neutral", rank:2`); `(1, 1)` (recent but already surfaced) → NOT + first-mover (`tier:"warming", rank:1`); **`(-1, 0)`** (future publishedAt) → NOT first-mover (`tier:"neutral"` — + the `ageDays >= 0` guard). Pure: same inputs → same output. +- **SC2 (saturation grading + clamp)** — same thresholds: `(5, 3)` and `(5, 4)` → `{tier:"saturated", rank:0}`; + `(5, 2)` and `(5, 1)` → `{tier:"warming", rank:1}`; `(5, 0)` → `{tier:"neutral", rank:2}`. `surfacedCount === + saturationAt` is saturated (inclusive `>=`). **Defensive clamp:** `temporalSignal(5, 5, {…, saturationAt:0})` + classifies via the clamped `at=1` (NOT "every trend saturated") — the function does not trust an out-of-range + threshold. +- **SC3 (ranking — overlay re-orders within tier, composite dominates, temporal↔date DISAGREE)** — the fixture + **forces the temporal key and `effectiveDate` to disagree**, so the test is genuinely RED in Phase A and the new + key is what decides (folded — plan-critic M1: `surfacedCount` correlates with age, so a naive "first-mover vs + saturated" fixture would already be ordered correctly by the existing `effectiveDate`-desc key — a vacuous test). + Three entries, **same** overlap: **A** = `neutral` (surfaced 0, **older** date, e.g. 5d), composite 7.0; **B** = + `warming` (surfaced 2, **newer** date, e.g. 1d), composite 7.0; **Z** = `saturated` (surfaced 4), composite + **8.5**. Expected order **`[Z, A, B]`**: Z first (higher composite — PRIMARY, SB2); then A **above** B even + though B is newer — the temporal key (`neutral` rank 2 > `warming` rank 1) overrides the `effectiveDate`-desc + tiebreaker that would have put the newer B first. In Phase A (stub, constant rank) the order is `[Z, B, A]` + (effectiveDate decides A vs B) → RED. `cmp` remains a total order. +- **SC4 (render badges + the ≥2 badge boundary)** — `renderBrief`/the bullet path: a first-mover entry contains + `· 🥇 først ute`; a saturated entry (`surfacedCount 3`, default `saturationAt 3`) contains `· 🔁 mettet (3x)`; a + warming entry with `surfacedCount 2` contains `· sett 2x`; a warming entry with **`surfacedCount 1` contains NO + badge** (the preserved R3b ≥2 contract — `brief.test.ts:408` stays green, unchanged); a `neutral` entry contains + **none** of the three. The R3b `· sett 3x` assertion (`brief.test.ts:407`) is **updated** to `· 🔁 mettet (3x)` + (its surfacedCount-3 trend is now saturated). **Also updated** (folded — brief-reviewer MEDIUM-2): the two tests + pinning the `ranking:` descriptor verbatim (`brief.test.ts:325-331` and the regex `:410-416`) gain the new + `then temporal (first-mover↑/saturated↓), ` segment; and the `effectiveDate`-isolation test (`brief.test.ts: + 96-102`) is **re-based** so both entries share a temporal tier (both `neutral`) — else the new key, not + `effectiveDate`, would silently decide it (coverage erosion — folded — brief-reviewer MEDIUM-3). +- **SC5 (summary first-mover marker)** — when the top entry is a first-mover, `briefSummary` (and the frontmatter + `summary:` line) contains `· 🥇 først ute` inside the headline parens; when it is not, the marker is absent. The + summary contains **no** `"` and **no** `\n` (the hook-regex invariant). +- **SC6 (CLI flags)** — `brief --pillars ai --first-mover-days 1 --saturation-at 2 …` changes the tiers vs the + defaults (a 2-day-old trend is first-mover at default 2 but `fresh` at `--first-mover-days 1`; `surfacedCount 2` + is `warming` at default 3 but `saturated` at `--saturation-at 2`); absent flags use defaults 2/3; + `--first-mover-days -1` / `--first-mover-days x` / `--saturation-at 0` / `--saturation-at x` → `usage` exit 2. +- **SC7 (determinism)** — two `brief` runs with the same `(store, pillars, today, freshDays, firstMoverDays, + saturationAt)` → byte-identical `.md`. +- **SC8 (no schema / no score mutation)** — `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `types.ts` untouched; a + `brief` run does **not** change any record's `score.composite` (assert the store's scores are unchanged after a + brief, only `surfacedCount`/`lastSurfacedAt` move — the existing R3b behaviour). +- **SC9 (purity)** — `temporalSignal` reads no clock/fs/env; a property check over a grid of `(ageDays, + surfacedCount)` gives stable, threshold-consistent tiers (first-mover ⊆ recent∧unsurfaced; saturated ⇔ + `surfacings >= saturationAt`). +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16m** green (the six ASCII sentinels + non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **117** (111 + 6); Section 17 de-niche green; counts 29/19/27; the hook suite still + green untouched (`node --test hooks/scripts/__tests__/*.test.mjs`). + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16m self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 117; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3d touches no +hook). The R3c suite (`schedule.test.ts`/`run-daily.test.ts`) still green untouched (`schedule.ts`/`run-daily.sh` +not edited). +**Behavioural (manual):** +1. Seed a store with three trends sharing topics/pillars: one fresh+unsurfaced (`publishedAt` ~1d ago, + `surfacedCount` absent), one warming (`surfacedCount` 2), one saturated (`surfacedCount` 4) — same composite. +2. `node --import tsx src/cli.ts brief --pillars ai,gov --out /tmp/r3d-mb --store /tmp/r3d.json` → inspect the + `.md`: the fresh+unsurfaced entry sorts first with `· 🥇 først ute`; the saturated one sorts last with `· 🔁 + mettet (4x)`; the `ranking:` descriptor names the temporal key. +3. Re-run with `--first-mover-days 0 --saturation-at 2` → the first-mover badge disappears (0-day window) and the + `surfacedCount 2` entry escalates to `mettet (2x)`. +4. `--first-mover-days x` → exit 2 (`usage`); fs untouched. +5. Confirm the seeded records' `score.composite` values are unchanged after the brief (only `surfacedCount`/ + `lastSurfacedAt` advance) — the overlay never mutates the relevance score. + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **SB1** derived-at-brief +(no schema bump); **SB2** refine recency within the composite tier (composite stays primary). Residual decisions, +all baked to the recommended default — confirm or redirect with "Go": +- **D1 — `firstMoverDays` default `2`?** YES (rec). The tight end of the SSOT "<24-72h, you would be among first" + band; `--first-mover-days N` tunes it. Re-open only for a different default (e.g. 3 = the full 72h "early" + band). +- **D2 — `saturationAt` default `3`?** YES (rec). The existing `· sett Nx` hint fires at `>=2`, so default 3 + keeps `surfacedCount 2` an FYI ("warming · sett 2x") and escalates `>=3` to "saturated". `--saturation-at N` + tunes it. Re-open only for a different default. +- **D3 — four tiers (`first-mover`/`fresh`/`warming`/`saturated`)?** YES (rec). A first-mover top, a fresh + baseline, a warming FYI (preserves the R3b `sett Nx` hint), a saturated demotion. Drop only to collapse + warming into fresh (a 3-tier model) or to add a fifth class. +- **D4 — temporal key sits AFTER overlap, BEFORE effectiveDate in `cmp`?** YES (rec, = SB2). Composite then + overlap stay primary; the overlay is the coarse recency class, `effectiveDate` the fine tiebreaker beneath it. + Re-open only to move the key (e.g. before overlap — stronger overlay). +- **D5 — saturation NEVER moves a trend between top/single/older buckets (soft re-order only)?** YES (rec). A + soft signal; bucketing stays `overlap`+`freshDays`. Re-open only to let a saturated trend drop a bucket. +- **D6 — badges `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming) / none (fresh)?** YES (rec). Promotes the + R3b `sett Nx` hint into a graded set. Re-open for different wording/emoji (the gate sentinels are ASCII, so + emoji changes are test-only). +- **D7 — summary line carries `· 🥇 først ute` when the top is a first-mover?** YES (rec). The one-line surfacing + then signals "act now, you're early". Drop only to keep the summary minimal (no marker). +- **D8 — add a one-line overlay note to the SSOT (`trend-scoring-modes.md`)?** YES (rec). Honest cross-reference + so the SSOT is not misread as the whole ranking story; **no** weight/formula change. Drop only to document the + overlay solely in `brief.ts` + README + the brief's `ranking:` descriptor. +- **D9 — `schedule` untouched (nightly run uses default thresholds)?** YES (rec). Keeps `schedule.ts`/ + `run-daily.sh` + the R3c tests untouched (no regression surface). Re-open only to bake `--first-mover-days`/ + `--saturation-at` into the schedule artifact now. +- **D10 — commit split?** Docs commit first, then **one** code commit (rec) — the overlay (signal + flags + + wiring) is one coherent feature. Re-open only for a signal-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. They **converged on the same two +defects** (the strongest signal): **scope-guardian: MIXED** (0 hard creep, both confirmed decisions honored, +every SC1–SC10 traces to a step; 1 MAJOR + 1 line-cite + discretionary MINORs). **brief-reviewer: +PROCEED_WITH_RISKS** (all seven RED-premise/correctness claims HOLD; the risk is GREEN-completeness — the plan +listed 1 of 4 breaking test assertions; 1 MAJOR + 2 MEDIUM + 2 LOW). **plan-critic: APPROVE_WITH_NOTES, 78/B** (the +floor arithmetic, line-cites, grep sentinels, cmp total-order, and two-phase-RED structure all verified correct; +2 MAJOR + 4 MINOR). **All findings folded** (per-finding resolution in `plan-re-r3d.md §Plan-critic — folded`). +Headlines: + +- **[MAJOR, folded — all three] the warming badge fired at `surfacings >= 1`, but the live R3b `surfacedToken` + fires only at `>= 2`** (`brief.ts:156`), and `brief.test.ts:408` pins `!md.includes("sett 1x")`. The draft's + "preserves the R3b hint" was false (it broadened `>=2` to `>=1`). → `temporalToken`'s warming badge is gated at + **`surfacings >= 2`** (R3b contract preserved exactly; `:408` stays green); the warming *tier* still demotes + surfaced-once in `rank`. SC4 gains the `surfacedCount 1 → no badge` boundary. +- **[MAJOR, folded — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not genuinely RED + vacuous.** + `surfacedCount` correlates with age, so a "first-mover vs saturated" fixture is *already* ordered by the existing + `effectiveDate`-desc key — Phase A would be GREEN and GREEN proves nothing. → SC3's fixture now **forces + temporal↔date disagreement** (older-`neutral` A vs newer-`warming` B at equal composite; the temporal key, not + the date, must decide A>B). The coverage-eroded `effectiveDate`-isolation test (`:96-102`) is re-based to a + shared tier. +- **[MEDIUM, folded — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks two more pinned tests** + (`brief.test.ts:325-331` + the regex `:410-416`). → the test inventory (SC4 + plan Step 1) now enumerates **all + four** touch points, not one. +- **[MINOR, folded — plan-critic m3] `temporalSignal` was undefensive against `saturationAt < 1`.** → a + `Math.max(1, saturationAt)` clamp inside the pure function (the CLI guard is not enough — the function is a + public, gate-grepped export). SC2 gains a clamp case. +- **[MINOR, folded — plan-critic m4] the "fresh" tier name was a misnomer** (a 30-day-old unsurfaced trend is not + "fresh"; collides with `freshDays`). → renamed **`neutral`** ("no exposure signal"); the in-bucket effect + (unsurfaced ranks above seen-and-skipped within `olderMatched`) is documented as intended. +- **[MINOR, folded — plan-critic m5] a future `publishedAt` (ageDays < 0) became a first-mover** "act now" + headline. → the `ageDays >= 0` guard excludes it (it falls to `neutral`). SC1 gains the `(-1, 0)` case. +- **[MINOR, folded — plan-critic m6 / brief-reviewer] the nightly run (the primary saturation consumer) is locked + to default thresholds.** → reframed in §4 as a **known limitation**, not a "later nicety." +- **[LOW, folded — all three] long-form `angle` weight cite `:34` → `:32`** (`:34` is `currency`; the substance — + angle is a scored dimension in both modes — holds). diff --git a/docs/research-engine/plan-re-r3d.md b/docs/research-engine/plan-re-r3d.md new file mode 100644 index 0000000..a6a3181 --- /dev/null +++ b/docs/research-engine/plan-re-r3d.md @@ -0,0 +1,335 @@ +# Plan — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Brief:** `docs/research-engine/brief-re-r3d.md`. **Slice:** RE-R3d (research-engine rung-2 — R3 slice **(b)**: +> the live temporal overlay). Closes the rest of hull #3 — **first-mover** + **saturation** (relevance ✅ R3a; +> status ✅ R3b; `angle` already a scored dimension). **Zero new files** — pure EDITs. +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> `temporalSignal` is a NEW named export of the EXISTING `brief.ts`; Node16 ESM throws a missing named import at +> module-load (every `brief.test.ts` test would error, not assert), so land a **non-throwing stub** first +> (`temporalSignal → {tier:"fresh",…}` + the type exports + `BriefEntry.temporal` populated by the stub in +> `rankForBrief`), then record value-assertion RED against it (the constant "fresh" stub fails the +> first-mover/saturated/ordering/token assertions); **(B)** the CLI flag tests are value-RED against the existing +> `brief` handler (the new flags are silently ignored by `parseFlags` today → tuned-threshold behaviour unchanged). +> Then GREEN: real `temporalSignal` + `RankOptions` knobs + `cmp` key → render tokens + summary marker + descriptor +> → `cli.ts` flags → wire `trend-spotter.md` (prose) + SSOT note + README → gate floors + Section 16m → +> behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SB1 derived-at-brief (no schema bump) · SB2 +> refine recency within the composite tier (composite stays PRIMARY). Go-gate D1–D10 baked to recommended defaults +> (brief §8). +> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in +> §Plan-critic — folded before the code commit. + +## Goal + +Make the morning brief read the **temporal axis** R3b logs but the ranking ignores. Two derived signals, computed +**at brief time** from already-persisted fields (no new store field, no schema bump): **first-mover** (recent AND +never surfaced on a prior day — "you'd be early") ranks a trend up; **saturation** (surfaced on `>= saturationAt` +prior days — "you keep seeing this and not acting") ranks it down. The R3a relevance composite stays the PRIMARY +sort key (SB2); the overlay is a new `cmp` key **after** pillar-overlap, **before** `effectiveDate`, re-ordering +only WITHIN a (composite, overlap) tier. It also promotes the R3b `· sett Nx` display hint into a graded badge set +and surfaces a first-mover marker on the one-line summary. **No schema change** (`SCHEMA_VERSION` 4 / +`BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference/module/file**; `score.ts`/`store.ts`/ +`types.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` untouched. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **EDIT** — add `TemporalTier`/`TemporalSignal` exports + pure `temporalSignal(ageDays, surfacedCount, {firstMoverDays, saturationAt})`; `RankOptions` gains `firstMoverDays?`(def 2)/`saturationAt?`(def 3); `BriefEntry` gains `temporal`; `rankForBrief` threads the opts + populates `temporal`; `cmp` gains `b.temporal.rank - a.temporal.rank` after overlap, before effectiveDate; `surfacedToken`→`temporalToken` (badges); `briefSummary` first-mover marker; `ranking:` descriptor names the new key | SC1–SC5, SC7, SC9 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief … [--first-mover-days N] [--saturation-at N]` (mirror `--fresh-days` at `:312-317`, into `rankForBrief` opts at `:323`); usage line (`:110`) + header synopsis (`:14`) note the overlay; **`schedule` untouched** (nightly uses defaults) | SC6 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — `temporalSignal` unit incl. clamp + future-date + neutral cases (SC1/SC2/SC9); **disagreement** ordering fixture (SC3); render badges (SC4) + **4 touched assertions**: `:407` `sett 3x`→`mettet (3x)`, `:408` unchanged (≥2 gate), `:325-331`+`:410-416` descriptor segment, `:96-102` re-based to a shared tier; summary marker (SC5); determinism (SC7); no-score-mutation (SC8) | SC1–SC5, SC7–SC9 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `brief --first-mover-days N --saturation-at N` changes tiers vs defaults; absent → defaults 2/3; `--first-mover-days -1`/`x`, `--saturation-at 0`/`x` → exit 2 | SC6 | +| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change. Listed to assert they are *not* in scope (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite stays green untouched). | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now applies a live temporal overlay (first-mover↑/saturated↓) at rank time, derived from dates + the seen-log; capture path unchanged. Domain-general. | — | +| `references/trend-scoring-modes.md` | **EDIT (one-line note, "Consumers")** — the brief applies a within-composite-tier temporal overlay (RE-R3d); **no** weight/band/formula change. | — | +| `scripts/trends/README.md` | **EDIT** — new `## Temporal overlay (RE-R3d)` between the R3c scheduler section (`:135-154`) and `## Tests` (`:155`): first-mover/saturation, derived-not-stored, the flags + defaults, the `cmp` integration, the badges | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (`:713`, 192)→recount + breakdown comment; NEW unconditional **Section 16m** between 16l's `echo ""` (`:1374`) and Section 18 (`:1376`); `ASSERT_BASELINE_FLOOR` (`:1403`) 111→**117**; header-enum chain (`:57-62`) + Section-18 floor-history narration (`:1376-1402`, ends "= 111") | SC10 | +| `docs/research-engine/{brief,plan}-re-r3d.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 117, gate total; schema unchanged v4; **correct the stale :709/:1329 line-cites to live :713/:1403**). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change; +`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run uses default +thresholds; no regression surface) · the SessionStart hook + its tests (R3d changes no frontmatter *field*; the +summary's new marker is regex-safe; no hook change/test) · `config/*` · `commands/*` (29) · `agents/*` count (19 +— `trend-spotter.md` is a prose EDIT) · `references/*` count (27 — `trend-scoring-modes.md` is an EDIT) · +`algorithm-signals-reference.md` (cited for grounding, not edited) · `.gitignore` (no new artifact) · +`BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across brief/cli + +**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports): +- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs): + in `brief.ts` — add `export type TemporalTier`, `export interface TemporalSignal`, and `export function + temporalSignal(): TemporalSignal { return { tier: "neutral", firstMover: false, surfacings: 0, rank: 2 }; }` (a + constant stub ignoring its args); add `temporal: TemporalSignal` to `BriefEntry` and populate it in `rankForBrief` + via the stub. The `cmp` key + `temporalToken` + the summary marker are **NOT** added yet (Steps 2–3). +- `brief.test.ts` (value-RED against the stub): + - **unit (SC1/SC2/SC9)** — `temporalSignal(1,0,{firstMoverDays:2,saturationAt:3})` expects + `{tier:"first-mover",rank:3}` (stub returns "neutral" → RED); `(5,3,…)`→`saturated`; `(5,2,…)`→`warming`; + `(5,0,…)`→`neutral`; `(3,0,…)`→`neutral` (past window); `(-1,0,…)`→`neutral` (future-date `>=0` guard); the + clamp case `(5,5,{…,saturationAt:0})`→`saturated` via `at=1`. + - **ordering (SC3) — DISAGREEMENT fixture** (folded — plan-critic M1: `surfacedCount` correlates with age, so a + naive first-mover-vs-saturated fixture is *already* ordered by the existing `effectiveDate`-desc key → GREEN in + Phase A, vacuous in GREEN). Three same-overlap entries: **A** `neutral` (surfaced 0, **older** date ≈5d, + composite 7.0); **B** `warming` (surfaced 2, **newer** date ≈1d, composite 7.0); **Z** `saturated` (surfaced 4, + composite **8.5**). Expect `topMatches.map(e=>e.trend.title) === [Z, A, B]`. Phase A (stub: constant rank, no + temporal key) yields `[Z, B, A]` (`effectiveDate`-desc decides A vs B) → RED; GREEN (Step 2 inserts + `temporal.rank`) flips A above the newer B, proving the key. + - **render (SC4)** — `renderBrief(...)` expects `· 🥇 først ute` on the first-mover entry, `· 🔁 mettet (3x)` on a + `surfacedCount 3` entry, `· sett 2x` on `surfacedCount 2`, **no badge** on `surfacedCount 1`, none on a + `neutral` entry (stub render still emits the old `surfacedToken` → RED). **Four existing assertions touched** + (folded — brief-reviewer MEDIUM-2/3): (1) `:407` `· sett 3x` → `· 🔁 mettet (3x)` (its surfacedCount-3 trend is + now saturated); (2) `:408` `!md.includes("sett 1x")` **stays unchanged** (the ≥2 badge gate keeps + surfacedCount-1 markerless); (3) `:325-331` + (4) the regex `:410-416` (the `ranking:` descriptor) gain `then + temporal (first-mover↑/saturated↓), `; and the `effectiveDate`-isolation test `:96-102` is **re-based** so both + entries share a `neutral` tier (else the new key, not `effectiveDate`, silently decides it — coverage erosion). + - **summary (SC5)** — `briefSummary(...)` with a first-mover top expects `· 🥇 først ute` in the headline (stub → + no marker → RED); assert the summary has no `"` and no `\n`. + - **no-score-mutation (SC8)** — capture a store's `score.composite` values, run `rankForBrief` (pure — no + mutation) and assert unchanged; assert `BRIEF_SCHEMA_VERSION === 1`, `SCHEMA_VERSION === 4`. + +**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the flags are parsed-but-unused +today): +- `cli.test.ts` — `brief --pillars ai --first-mover-days 1 --saturation-at 2 --store --out `: + today `parseFlags` stores the flags but the `brief` handler ignores them → the rendered tiers match the defaults + → RED. **The tier badges live in the written `.md` body, not in `--json`** (which returns only + `{path,date,totals,summary,marked}`, `cli.ts:336`), so the assertion **`readFileSync(path)`** and checks the + badge (a `surfacedCount 2` trend renders `· 🔁 mettet (2x)` at `--saturation-at 2`, but `· sett 2x` at the + default) — folded — brief-reviewer LOW-5; the first-mover marker is also observable via the `--json` `summary`. + `--first-mover-days x` / `--saturation-at 0` today are ignored (exit 0) → RED against the expected `usage` exit 2. + +**RED proof (record in commit, two phases):** Phase A — after the non-throwing `temporalSignal` stub lands, +`(cd scripts/trends && npm test)` fails the unit/ordering/render/summary cases on **value** assertions against the +constant-"fresh" stub (not module-not-found). Phase B — the `cli.test` flag cases fail on value/exit assertions +against the flag-ignoring handler. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `brief.ts` — the real signal + ranking integration + +Replace the Phase-A stub with the real, pure implementation: +- `export type TemporalTier = "first-mover" | "fresh" | "warming" | "saturated";` +- `export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number; }` +- `export function temporalSignal(ageDays, surfacedCount, opts): TemporalSignal`: + ```ts + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); // defensive clamp (plan-critic m3) + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; // >=0 guard (m5) + 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 }; + ``` + Pure (no clock/fs/env). Branches disjoint + total (`firstMover` requires `surfacings === 0`; `saturated`/ + `warming` both require `surfacings >= 1`). +- `RankOptions` (`:51-54`) gains `firstMoverDays?: number` + `saturationAt?: number`. `rankForBrief` (`:78`) reads + `const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;` and populates + `BriefEntry.temporal = temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })` at the + push site (`:93`). +- `cmp` (`:102-107`): insert `b.temporal.rank - a.temporal.rank ||` **after** `b.overlap - a.overlap ||` and + **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays the first key (SB2). The chain stays + a total order. The bucketing (`isFresh`/`freshDays`, `:109-113`) is **unchanged**. +Make the Phase-A unit + ordering cases green. + +## Step 3 — (GREEN) `brief.ts` — render badges + summary marker + descriptor + +- Replace `surfacedToken` (`:154-157`) with `temporalToken(e: BriefEntry): string`: + ```ts + 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`; // preserves the R3b ≥2 badge contract + return ""; // neutral, or warming with surfacings 1 + ``` + Replace the `surfacedToken(e)` call in `renderTopEntry` (`:162`) and `renderBulletEntry` (`:171`) with + `temporalToken(e)`. (`scoreToken` untouched.) +- `briefSummary` (`:131-141`): compute `const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";` and emit + `(${pillar}${band}${fm} · ${top.ageDays}d)`. The marker carries no `"`/`\n` (hook-regex invariant, `:128-130`). +- `renderBrief`'s `ranking:` descriptor (`:187`): insert `then temporal (first-mover↑/saturated↓), ` between + `pillar-overlap desc, ` and `then publishedAt desc`. +Make the Phase-A render + summary cases green. + +## Step 4 — (GREEN) `cli.ts` — the two `brief` threshold flags + +After the `--fresh-days` block (`:313-317`), add (mirroring its idiom): +```ts +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; +} +``` +Pass into the rank call (`:323`): `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })`. +Update the `brief` usage line (`:110`) + header synopsis (`:14`) to list the two flags + a one-line note that the +brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. **No new exit code** (0/2). The +`schedule` branch (`:343-417`) is **untouched** (nightly run uses defaults). Make the Phase-B `cli.test` cases green. + +## Step 5 — wire `trend-spotter.md` (prose) + SSOT note + README + +- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the polling/capture path is unchanged. + Domain-general (Section 17). +- `references/trend-scoring-modes.md` — under "Consumers", add ONE line: the morning brief applies a brief-time + temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement; this does **not** + change the capture-time weights above. (No weight/band/formula edit.) +- `scripts/trends/README.md` — add `## Temporal overlay (RE-R3d)` between `:154` and `## Tests` (`:155`): the + first-mover/saturation definitions (self-surfacing, not market-coverage), the **derived-not-stored** boundary + (no schema bump), the `--first-mover-days`(2)/`--saturation-at`(3) flags, the `cmp` integration (composite stays + primary), the badge set (`🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` / none). + +## Step 6 — gate: floors + new unconditional Section 16m + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:713`, currently **192**) to the **`tests N`** line reported by `(cd scripts/trends && + npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays inside the deps guard. **Append** + `+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline breakdown comment. +- Add **Section 16m** ("Trends Temporal Overlay", RE-R3d), mirroring Section 16l (unconditional, deps-absent-safe, + pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the badge emoji are NEVER grepped; the shell must + stay ASCII-clean for bash 3.2 `set -u`). **Placement (verified live):** between Section 16l's trailing `echo ""` + (`:1374`) and the Section 18 header (`:1376`) — anti-erosion stays last. Six **unconditional** checks, the + self-test emitting **one** pass/fail like 16l: + (1) a non-vacuity self-test (a probe carrying `temporalSignal` accepted, one without rejected); + (2) `grep -qF 'export function temporalSignal' scripts/trends/src/brief.ts`; + (3) `grep -qF 'b.temporal.rank' scripts/trends/src/brief.ts` (the cmp integration); + (4) `grep -qF '"first-mover"' scripts/trends/src/brief.ts` (the tier literal); + (5) `grep -qF 'first-mover-days' scripts/trends/src/cli.ts` (the flag); + (6) `grep -qF 'saturation-at' scripts/trends/src/cli.ts` (the flag). +- Bump `ASSERT_BASELINE_FLOOR` (**`:1403`**, currently **111**) → **exactly 117** (111 + the 6 new unconditional + 16m emitters; the self-test emits one pass/fail like 16l, so 117 is deterministic — "live recount" is the safety + net, not a guess). Insert the 16m clause into the **header-enumeration prose chain (`:57-62`)** before "…the + assertion-count anti-erosion floor (SC6) in Section 18", preserving sentence flow. **Append** the RE-R3d (→117) + narration to the **Section-18 floor-history comment** (`:1376-1402`, which ends "= 111"). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3d adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's five behavioural steps: seed a store with a +fresh+unsurfaced, a warming (`surfacedCount` 2), and a saturated (`surfacedCount` 4) trend at the **same** +composite; `brief --pillars … --out /tmp/r3d-mb --store /tmp/r3d.json` → the fresh+unsurfaced sorts first with +`· 🥇 først ute`, the saturated last with `· 🔁 mettet (4x)`, the `ranking:` descriptor names the temporal key; +re-run with `--first-mover-days 0 --saturation-at 2` → first-mover badge gone, `surfacedCount 2` escalates to +`mettet (2x)`; `--first-mover-days x` → exit 2; confirm the seeded `score.composite` values are unchanged after the +brief (only `surfacedCount`/`lastSurfacedAt` advance). Run full `bash scripts/test-runner.sh` → `FAIL=0` +(`ASSERT_BASELINE_FLOOR` 117, trends ≥ new floor, Section 16m green, Section 17 de-niche green, counts 29/19/27); +run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression); confirm `schedule.test`/ +`run-daily.test` still green (R3c untouched). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 117, gate total; +schema unchanged v4; **correct the stale :709/:1329 cites to live :713/:1403**). Commit order (house style): +**(1)** docs commit `docs/research-engine/{brief,plan}-re-r3d.md` (no suffix, tracked); **(2)** code commit — +`brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + `agents/trend-spotter.md` + +`references/trend-scoring-modes.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` +(D10: single code commit — the overlay is one coherent feature). Push freely (window lifted; gitleaks at commit; +`origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` after the `temporalSignal` stub | unit/ordering/render/summary cases fail on **value** assertions vs the constant-"fresh" stub (not module-not-found) | +| — | RED Phase B | `npm test` (cli.test) before the flag impl | `--first-mover-days`/`--saturation-at` cases fail on value/exit vs the flag-ignoring handler | +| SC1 | first-mover detection | `npm test` (brief.test) | `(≤firstMoverDays, surfaced 0)` → first-mover; `(>window,0)`/`(≤window,≥1)` → not | +| SC2 | saturation grading | `npm test` (brief.test) | `surfacings ≥ saturationAt` → saturated; `1..at-1` → warming; `0` → fresh (inclusive `>=`) | +| SC3 | ranking — within-tier re-order, composite dominates (DISAGREE fixture) | `npm test` (brief.test) | `[Z(8.5,sat), A(7.0,neutral,older), B(7.0,warming,newer)]`; temporal key flips A above newer B; Phase A `[Z,B,A]`→RED; total order | +| SC4 | render badges + ≥2 boundary | `npm test` (brief.test) | `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming≥2) / none (warming 1, neutral); `:407` updated, `:408` stays green | +| SC5 | summary first-mover marker | `npm test` (brief.test) | first-mover top → `· 🥇 først ute` in headline; else absent; no `"`/`\n` | +| SC6 | CLI flags | `npm test` (cli.test) | flags change tiers vs defaults; defaults 2/3 when absent; bad values → exit 2 | +| SC7 | determinism | `npm test` (brief.test) | same `(store,pillars,today,freshDays,firstMoverDays,saturationAt)` → byte-identical `.md` | +| SC8 | no schema / no score mutation | `npm test` (brief.test) | `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `score.composite` unchanged after a brief | +| SC9 | purity | `npm test` (brief.test) | `temporalSignal` stable over a grid; first-mover ⊆ recent∧unsurfaced; saturated ⇔ `surfacings≥saturationAt` | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16m green; `ASSERT_BASELINE_FLOOR`=117; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — changing `cmp` re-orders existing brief output → silently breaks downstream expectations.** *Mitigated:* + composite stays the PRIMARY key (SB2) — the overlay only re-orders WITHIN a (composite, overlap) tier; the + bucketing is unchanged; SC3 pins the exact order; SC7 pins byte-determinism; the hook reads only date+summary + (unaffected). +- **R2 — the `surfacedToken`→`temporalToken` promotion breaks pinned R3b/descriptor assertions.** + *Mitigated/expected (folded — all three reviewers):* the warming badge is gated at `surfacings >= 2`, so + `brief.test.ts:408` (`!md.includes("sett 1x")`) stays green and the R3b ≥2 contract is preserved exactly; Step 1 + enumerates the **four** touched assertions (`:407` `sett 3x`→`mettet (3x)`; `:325-331`+`:410-416` descriptor) and + re-bases the coverage-eroded `:96-102`. `surfacedCount 2` still renders `· sett 2x`. +- **R3 — emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16m greps ONLY ASCII literals + (`temporalSignal`, `b.temporal.rank`, `"first-mover"`, `first-mover-days`, `saturation-at`); the emoji live only + in `brief.ts` source + rendered output, asserted by the TS tests, never by the shell gate. +- **R4 — the summary marker breaks the SessionStart extractYaml regex.** *Mitigated:* `🥇 først ute` carries no + `"` and no `\n`; SC5 asserts the invariant; the hook suite is a land-time regression check. +- **R5 — saturation framing overclaims (reads as market-coverage).** *Accepted/honest:* R3d's saturation is + **self-surfacing** (our seen-log), a proxy for a closing/ignored window — NOT external coverage (that is slice + e, AI polling). The README + badge wording say "seen N×", not "covered online"; the brief §4 non-goal states the + boundary. No salesmanship. +- **R6 — first-mover default (2) too tight / saturationAt default (3) arbitrary.** *Mitigated:* both are + CLI-tunable (`--first-mover-days`/`--saturation-at`), documented as deliberate defaults (like `freshDays` 7), + grounded in the SSOT timing band (`<24-72h`) + the existing `sett Nx` `>=2` hint; D1/D2 are operator go-gate + knobs. +- **R7 — float/`-1` composite sentinel interaction with the new integer key.** *Mitigated:* the temporal key is a + separate `||` term (a small integer diff); it never touches the `score?.composite ?? -1` term; the comparator + stays a sum-free short-circuit chain (no NaN risk). +- **R8 — editing `trend-spotter.md` / the SSOT trips the de-niche guard (Section 17).** *Mitigated:* the added + prose + the SSOT note carry only generic overlay wording; pillars/topics stay config; Section 17 runs in the + gate. +- **R9 — gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16m is pure `grep`/self-test on + tracked source (`brief.ts` + `cli.ts`; no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps + guard. +- **R10 — STATE's pinned floor line-cites (`:709`/`:1329`) are stale (live `:713`/`:1403`).** *Mitigated:* caught + at brief time (the line numbers drifted when R3c added Section 16l + the floor-history narration); the plan cites + live values; Step 8 corrects STATE. +- **R11 — a temporal ordering test that passes WITHOUT the feature (false RED / vacuous GREEN).** *Mitigated + (folded — plan-critic M1):* `surfacedCount` correlates with age, so a naive first-mover-vs-saturated fixture is + already ordered by `effectiveDate`-desc. The SC3 fixture is built to force the temporal key and `effectiveDate` + to **disagree** (older-`neutral` A vs newer-`warming` B at equal composite), so it is RED in Phase A and the new + key is provably what decides in GREEN; the coverage-eroded `:96-102` is re-based to a shared tier. + +## Plan-critic — folded + +Three Opus reviewers ran on the brief + this plan, each verifying against live code; they **converged on the same +two defects**. Verdicts: **scope-guardian MIXED** (0 hard creep; both confirmed decisions honored; every SC traces +to a step; the floor/line cites verified live); **brief-reviewer PROCEED_WITH_RISKS** (all seven RED-premise/ +correctness claims HOLD; the gap was GREEN-completeness — 1 of 4 breaking assertions listed); **plan-critic +APPROVE_WITH_NOTES 78/B** (floor arithmetic, line-cites, grep sentinels, cmp total-order, two-phase-RED structure +all verified correct). Per-finding resolution (full headline list in `brief-re-r3d.md §9`): + +- **[MAJOR — all three] warming badge fired at `>=1`, but live `surfacedToken` fires at `>=2` (`brief.ts:156`) + + `brief.test.ts:408` pins `!sett 1x`.** ✅ Step 3 gates the warming badge at `surfacings >= 2` (R3b ≥2 contract + preserved exactly; `:408` unchanged); the warming *tier* still demotes in `rank`. SC4 gains the surfacedCount-1 + no-badge boundary. The "preserves the hint" wording is corrected. +- **[MAJOR — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not RED + vacuous** (`surfacedCount` + correlates with age → `effectiveDate`-desc already orders first-mover-vs-saturated). ✅ Step 1 SC3 fixture forces + temporal↔date **disagreement** (older-`neutral` A vs newer-`warming` B, equal composite; expect `[Z,A,B]`, Phase + A `[Z,B,A]`→RED); `:96-102` re-based to a shared tier (R11). +- **[MEDIUM — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks `:325-331` + `:410-416`.** ✅ Step 1 + + the Files table enumerate all four touched assertions, not one. +- **[MINOR — plan-critic m3] `temporalSignal` undefensive vs `saturationAt < 1`.** ✅ Step 2 clamps `const at = + Math.max(1, opts.saturationAt)` inside the pure function (the CLI guard alone is insufficient — the function is a + public, gate-grepped export). SC2 clamp case added. +- **[MINOR — plan-critic m4] the "fresh" tier was a misnomer** (collides with `freshDays`; a 30-day unsurfaced + trend is not "fresh"). ✅ renamed **`neutral`** throughout (Steps 1–3, SCs); the gate sentinel greps `"first-mover"` + (unaffected). The in-bucket effect (unsurfaced ranks above seen-and-skipped within `olderMatched`) documented in + brief §3 as intended. +- **[MINOR — plan-critic m5] future `publishedAt` (ageDays < 0) became a first-mover "act now" headline.** ✅ Step 2 + adds the `ageDays >= 0` guard (future → `neutral`). SC1 gains the `(-1,0)` case. +- **[MINOR — plan-critic m6 / brief-reviewer] nightly run locked to default thresholds** (the primary saturation + consumer). ✅ reframed in brief §4 as a known limitation, not a "later nicety." +- **[LOW — brief-reviewer LOW-5] SC6 tier assertions can't read tiers from `--json`** (it omits the body badges). + ✅ Step 1 Phase B reads the written `.md` via `readFileSync(path)`; the first-mover marker is also in the `--json` + `summary`. +- **[LOW — all three] long-form `angle` cite `:34` → `:32`** (`:34` = `currency`; substance holds). ✅ brief §0/§2 + corrected. + +**Verified correct (no change needed):** the floor arithmetic (16m = 1 self-test + 5 greps = 6 → `ASSERT_BASELINE_ +FLOOR` 111→117), all live cites (`:713`/`:1403`/`:1374`/`:1376`/`:57-62`), the six ASCII grep sentinels match the +literals Steps 2–4 write, the `cmp` insertion preserves a total order with no NaN risk, and the two-phase RED +structurally avoids a module-load ERROR. **This slice is authored + landed in-session (driftsmodell), not as a +headless autonomous run, so per-step revert/halt clauses are not needed** (R1/R2/R3a/R3b had none either).