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

36 KiB
Raw Permalink Blame History

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.

  • 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.
  • 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; < 1usage 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 xusage 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.shFAIL=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.shFAIL=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).