linkedin-studio/docs/research-engine/brief-re-r3d.md
Kjell Tore Guttormsen 5aa7187243 docs(linkedin-studio): RE-R3d brief + plan — temporal overlay (first-mover + saturation), light-Voyage hardened
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 11:43:22 +02:00

401 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 SC1SC10 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).