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
401 lines
36 KiB
Markdown
401 lines
36 KiB
Markdown
# 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).
|