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
335 lines
30 KiB
Markdown
335 lines
30 KiB
Markdown
# Plan — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b)
|
||
|
||
> **Brief:** `docs/research-engine/brief-re-r3d.md`. **Slice:** RE-R3d (research-engine rung-2 — R3 slice **(b)**:
|
||
> the live temporal overlay). Closes the rest of hull #3 — **first-mover** + **saturation** (relevance ✅ R3a;
|
||
> status ✅ R3b; `angle` already a scored dimension). **Zero new files** — pure EDITs.
|
||
> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)**
|
||
> `temporalSignal` is a NEW named export of the EXISTING `brief.ts`; Node16 ESM throws a missing named import at
|
||
> module-load (every `brief.test.ts` test would error, not assert), so land a **non-throwing stub** first
|
||
> (`temporalSignal → {tier:"fresh",…}` + the type exports + `BriefEntry.temporal` populated by the stub in
|
||
> `rankForBrief`), then record value-assertion RED against it (the constant "fresh" stub fails the
|
||
> first-mover/saturated/ordering/token assertions); **(B)** the CLI flag tests are value-RED against the existing
|
||
> `brief` handler (the new flags are silently ignored by `parseFlags` today → tuned-threshold behaviour unchanged).
|
||
> Then GREEN: real `temporalSignal` + `RankOptions` knobs + `cmp` key → render tokens + summary marker + descriptor
|
||
> → `cli.ts` flags → wire `trend-spotter.md` (prose) + SSOT note + README → gate floors + Section 16m →
|
||
> behavioural → land.
|
||
> **Counts recounted live at land, never pinned/guessed.**
|
||
> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SB1 derived-at-brief (no schema bump) · SB2
|
||
> refine recency within the composite tier (composite stays PRIMARY). Go-gate D1–D10 baked to recommended defaults
|
||
> (brief §8).
|
||
> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in
|
||
> §Plan-critic — folded before the code commit.
|
||
|
||
## Goal
|
||
|
||
Make the morning brief read the **temporal axis** R3b logs but the ranking ignores. Two derived signals, computed
|
||
**at brief time** from already-persisted fields (no new store field, no schema bump): **first-mover** (recent AND
|
||
never surfaced on a prior day — "you'd be early") ranks a trend up; **saturation** (surfaced on `>= saturationAt`
|
||
prior days — "you keep seeing this and not acting") ranks it down. The R3a relevance composite stays the PRIMARY
|
||
sort key (SB2); the overlay is a new `cmp` key **after** pillar-overlap, **before** `effectiveDate`, re-ordering
|
||
only WITHIN a (composite, overlap) tier. It also promotes the R3b `· sett Nx` display hint into a graded badge set
|
||
and surfaces a first-mover marker on the one-line summary. **No schema change** (`SCHEMA_VERSION` 4 /
|
||
`BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference/module/file**; `score.ts`/`store.ts`/
|
||
`types.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` untouched.
|
||
|
||
## Files touched (exhaustive — for scope-guardian)
|
||
|
||
| File | Change | SC |
|
||
|---|---|---|
|
||
| `scripts/trends/src/brief.ts` | **EDIT** — add `TemporalTier`/`TemporalSignal` exports + pure `temporalSignal(ageDays, surfacedCount, {firstMoverDays, saturationAt})`; `RankOptions` gains `firstMoverDays?`(def 2)/`saturationAt?`(def 3); `BriefEntry` gains `temporal`; `rankForBrief` threads the opts + populates `temporal`; `cmp` gains `b.temporal.rank - a.temporal.rank` after overlap, before effectiveDate; `surfacedToken`→`temporalToken` (badges); `briefSummary` first-mover marker; `ranking:` descriptor names the new key | SC1–SC5, SC7, SC9 |
|
||
| `scripts/trends/src/cli.ts` | **EDIT** — `brief … [--first-mover-days N] [--saturation-at N]` (mirror `--fresh-days` at `:312-317`, into `rankForBrief` opts at `:323`); usage line (`:110`) + header synopsis (`:14`) note the overlay; **`schedule` untouched** (nightly uses defaults) | SC6 |
|
||
| `scripts/trends/tests/brief.test.ts` | **EDIT** — `temporalSignal` unit incl. clamp + future-date + neutral cases (SC1/SC2/SC9); **disagreement** ordering fixture (SC3); render badges (SC4) + **4 touched assertions**: `:407` `sett 3x`→`mettet (3x)`, `:408` unchanged (≥2 gate), `:325-331`+`:410-416` descriptor segment, `:96-102` re-based to a shared tier; summary marker (SC5); determinism (SC7); no-score-mutation (SC8) | SC1–SC5, SC7–SC9 |
|
||
| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `brief --first-mover-days N --saturation-at N` changes tiers vs defaults; absent → defaults 2/3; `--first-mover-days -1`/`x`, `--saturation-at 0`/`x` → exit 2 | SC6 |
|
||
| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change. Listed to assert they are *not* in scope (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite stays green untouched). | — |
|
||
| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now applies a live temporal overlay (first-mover↑/saturated↓) at rank time, derived from dates + the seen-log; capture path unchanged. Domain-general. | — |
|
||
| `references/trend-scoring-modes.md` | **EDIT (one-line note, "Consumers")** — the brief applies a within-composite-tier temporal overlay (RE-R3d); **no** weight/band/formula change. | — |
|
||
| `scripts/trends/README.md` | **EDIT** — new `## Temporal overlay (RE-R3d)` between the R3c scheduler section (`:135-154`) and `## Tests` (`:155`): first-mover/saturation, derived-not-stored, the flags + defaults, the `cmp` integration, the badges | — |
|
||
| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (`:713`, 192)→recount + breakdown comment; NEW unconditional **Section 16m** between 16l's `echo ""` (`:1374`) and Section 18 (`:1376`); `ASSERT_BASELINE_FLOOR` (`:1403`) 111→**117**; header-enum chain (`:57-62`) + Section-18 floor-history narration (`:1376-1402`, ends "= 111") | SC10 |
|
||
| `docs/research-engine/{brief,plan}-re-r3d.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
|
||
| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 117, gate total; schema unchanged v4; **correct the stale :709/:1329 line-cites to live :713/:1403**). *Land bookkeeping, LOCAL-ONLY.* | — |
|
||
|
||
**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change;
|
||
`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run uses default
|
||
thresholds; no regression surface) · the SessionStart hook + its tests (R3d changes no frontmatter *field*; the
|
||
summary's new marker is regex-safe; no hook change/test) · `config/*` · `commands/*` (29) · `agents/*` count (19
|
||
— `trend-spotter.md` is a prose EDIT) · `references/*` count (27 — `trend-scoring-modes.md` is an EDIT) ·
|
||
`algorithm-signals-reference.md` (cited for grounding, not edited) · `.gitignore` (no new artifact) ·
|
||
`BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4).
|
||
|
||
## Step 1 — (RED, two phases) failing tests across brief/cli
|
||
|
||
**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports):
|
||
- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs):
|
||
in `brief.ts` — add `export type TemporalTier`, `export interface TemporalSignal`, and `export function
|
||
temporalSignal(): TemporalSignal { return { tier: "neutral", firstMover: false, surfacings: 0, rank: 2 }; }` (a
|
||
constant stub ignoring its args); add `temporal: TemporalSignal` to `BriefEntry` and populate it in `rankForBrief`
|
||
via the stub. The `cmp` key + `temporalToken` + the summary marker are **NOT** added yet (Steps 2–3).
|
||
- `brief.test.ts` (value-RED against the stub):
|
||
- **unit (SC1/SC2/SC9)** — `temporalSignal(1,0,{firstMoverDays:2,saturationAt:3})` expects
|
||
`{tier:"first-mover",rank:3}` (stub returns "neutral" → RED); `(5,3,…)`→`saturated`; `(5,2,…)`→`warming`;
|
||
`(5,0,…)`→`neutral`; `(3,0,…)`→`neutral` (past window); `(-1,0,…)`→`neutral` (future-date `>=0` guard); the
|
||
clamp case `(5,5,{…,saturationAt:0})`→`saturated` via `at=1`.
|
||
- **ordering (SC3) — DISAGREEMENT fixture** (folded — plan-critic M1: `surfacedCount` correlates with age, so a
|
||
naive first-mover-vs-saturated fixture is *already* ordered by the existing `effectiveDate`-desc key → GREEN in
|
||
Phase A, vacuous in GREEN). Three same-overlap entries: **A** `neutral` (surfaced 0, **older** date ≈5d,
|
||
composite 7.0); **B** `warming` (surfaced 2, **newer** date ≈1d, composite 7.0); **Z** `saturated` (surfaced 4,
|
||
composite **8.5**). Expect `topMatches.map(e=>e.trend.title) === [Z, A, B]`. Phase A (stub: constant rank, no
|
||
temporal key) yields `[Z, B, A]` (`effectiveDate`-desc decides A vs B) → RED; GREEN (Step 2 inserts
|
||
`temporal.rank`) flips A above the newer B, proving the key.
|
||
- **render (SC4)** — `renderBrief(...)` expects `· 🥇 først ute` on the first-mover entry, `· 🔁 mettet (3x)` on a
|
||
`surfacedCount 3` entry, `· sett 2x` on `surfacedCount 2`, **no badge** on `surfacedCount 1`, none on a
|
||
`neutral` entry (stub render still emits the old `surfacedToken` → RED). **Four existing assertions touched**
|
||
(folded — brief-reviewer MEDIUM-2/3): (1) `:407` `· sett 3x` → `· 🔁 mettet (3x)` (its surfacedCount-3 trend is
|
||
now saturated); (2) `:408` `!md.includes("sett 1x")` **stays unchanged** (the ≥2 badge gate keeps
|
||
surfacedCount-1 markerless); (3) `:325-331` + (4) the regex `:410-416` (the `ranking:` descriptor) gain `then
|
||
temporal (first-mover↑/saturated↓), `; and the `effectiveDate`-isolation test `:96-102` is **re-based** so both
|
||
entries share a `neutral` tier (else the new key, not `effectiveDate`, silently decides it — coverage erosion).
|
||
- **summary (SC5)** — `briefSummary(...)` with a first-mover top expects `· 🥇 først ute` in the headline (stub →
|
||
no marker → RED); assert the summary has no `"` and no `\n`.
|
||
- **no-score-mutation (SC8)** — capture a store's `score.composite` values, run `rankForBrief` (pure — no
|
||
mutation) and assert unchanged; assert `BRIEF_SCHEMA_VERSION === 1`, `SCHEMA_VERSION === 4`.
|
||
|
||
**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the flags are parsed-but-unused
|
||
today):
|
||
- `cli.test.ts` — `brief --pillars ai --first-mover-days 1 --saturation-at 2 --store <seeded> --out <tmp>`:
|
||
today `parseFlags` stores the flags but the `brief` handler ignores them → the rendered tiers match the defaults
|
||
→ RED. **The tier badges live in the written `.md` body, not in `--json`** (which returns only
|
||
`{path,date,totals,summary,marked}`, `cli.ts:336`), so the assertion **`readFileSync(path)`** and checks the
|
||
badge (a `surfacedCount 2` trend renders `· 🔁 mettet (2x)` at `--saturation-at 2`, but `· sett 2x` at the
|
||
default) — folded — brief-reviewer LOW-5; the first-mover marker is also observable via the `--json` `summary`.
|
||
`--first-mover-days x` / `--saturation-at 0` today are ignored (exit 0) → RED against the expected `usage` exit 2.
|
||
|
||
**RED proof (record in commit, two phases):** Phase A — after the non-throwing `temporalSignal` stub lands,
|
||
`(cd scripts/trends && npm test)` fails the unit/ordering/render/summary cases on **value** assertions against the
|
||
constant-"fresh" stub (not module-not-found). Phase B — the `cli.test` flag cases fail on value/exit assertions
|
||
against the flag-ignoring handler. The plan does **not** claim a single "everything fails before any code" run.
|
||
|
||
## Step 2 — (GREEN) `brief.ts` — the real signal + ranking integration
|
||
|
||
Replace the Phase-A stub with the real, pure implementation:
|
||
- `export type TemporalTier = "first-mover" | "fresh" | "warming" | "saturated";`
|
||
- `export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number; }`
|
||
- `export function temporalSignal(ageDays, surfacedCount, opts): TemporalSignal`:
|
||
```ts
|
||
const surfacings = surfacedCount ?? 0;
|
||
const at = Math.max(1, opts.saturationAt); // defensive clamp (plan-critic m3)
|
||
const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; // >=0 guard (m5)
|
||
const tier: TemporalTier = firstMover ? "first-mover"
|
||
: surfacings >= at ? "saturated"
|
||
: surfacings >= 1 ? "warming" : "neutral";
|
||
const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0;
|
||
return { tier, firstMover, surfacings, rank };
|
||
```
|
||
Pure (no clock/fs/env). Branches disjoint + total (`firstMover` requires `surfacings === 0`; `saturated`/
|
||
`warming` both require `surfacings >= 1`).
|
||
- `RankOptions` (`:51-54`) gains `firstMoverDays?: number` + `saturationAt?: number`. `rankForBrief` (`:78`) reads
|
||
`const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;` and populates
|
||
`BriefEntry.temporal = temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })` at the
|
||
push site (`:93`).
|
||
- `cmp` (`:102-107`): insert `b.temporal.rank - a.temporal.rank ||` **after** `b.overlap - a.overlap ||` and
|
||
**before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays the first key (SB2). The chain stays
|
||
a total order. The bucketing (`isFresh`/`freshDays`, `:109-113`) is **unchanged**.
|
||
Make the Phase-A unit + ordering cases green.
|
||
|
||
## Step 3 — (GREEN) `brief.ts` — render badges + summary marker + descriptor
|
||
|
||
- Replace `surfacedToken` (`:154-157`) with `temporalToken(e: BriefEntry): string`:
|
||
```ts
|
||
const t = e.temporal;
|
||
if (t.tier === "first-mover") return " · 🥇 først ute";
|
||
if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`;
|
||
if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; // preserves the R3b ≥2 badge contract
|
||
return ""; // neutral, or warming with surfacings 1
|
||
```
|
||
Replace the `surfacedToken(e)` call in `renderTopEntry` (`:162`) and `renderBulletEntry` (`:171`) with
|
||
`temporalToken(e)`. (`scoreToken` untouched.)
|
||
- `briefSummary` (`:131-141`): compute `const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";` and emit
|
||
`(${pillar}${band}${fm} · ${top.ageDays}d)`. The marker carries no `"`/`\n` (hook-regex invariant, `:128-130`).
|
||
- `renderBrief`'s `ranking:` descriptor (`:187`): insert `then temporal (first-mover↑/saturated↓), ` between
|
||
`pillar-overlap desc, ` and `then publishedAt desc`.
|
||
Make the Phase-A render + summary cases green.
|
||
|
||
## Step 4 — (GREEN) `cli.ts` — the two `brief` threshold flags
|
||
|
||
After the `--fresh-days` block (`:313-317`), add (mirroring its idiom):
|
||
```ts
|
||
let firstMoverDays = 2;
|
||
if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") {
|
||
const n = Number.parseInt(flags["first-mover-days"], 10);
|
||
if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer");
|
||
firstMoverDays = n;
|
||
}
|
||
let saturationAt = 3;
|
||
if (flags["saturation-at"] && flags["saturation-at"] !== "true") {
|
||
const n = Number.parseInt(flags["saturation-at"], 10);
|
||
if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer");
|
||
saturationAt = n;
|
||
}
|
||
```
|
||
Pass into the rank call (`:323`): `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })`.
|
||
Update the `brief` usage line (`:110`) + header synopsis (`:14`) to list the two flags + a one-line note that the
|
||
brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. **No new exit code** (0/2). The
|
||
`schedule` branch (`:343-417`) is **untouched** (nightly run uses defaults). Make the Phase-B `cli.test` cases green.
|
||
|
||
## Step 5 — wire `trend-spotter.md` (prose) + SSOT note + README
|
||
|
||
- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now applies a **live
|
||
temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived**
|
||
from the publish/capture dates + the seen-log — no new capture step; the polling/capture path is unchanged.
|
||
Domain-general (Section 17).
|
||
- `references/trend-scoring-modes.md` — under "Consumers", add ONE line: the morning brief applies a brief-time
|
||
temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement; this does **not**
|
||
change the capture-time weights above. (No weight/band/formula edit.)
|
||
- `scripts/trends/README.md` — add `## Temporal overlay (RE-R3d)` between `:154` and `## Tests` (`:155`): the
|
||
first-mover/saturation definitions (self-surfacing, not market-coverage), the **derived-not-stored** boundary
|
||
(no schema bump), the `--first-mover-days`(2)/`--saturation-at`(3) flags, the `cmp` integration (composite stays
|
||
primary), the badge set (`🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` / none).
|
||
|
||
## Step 6 — gate: floors + new unconditional Section 16m
|
||
|
||
In `scripts/test-runner.sh`:
|
||
- Set `TRENDS_TESTS_FLOOR` (`:713`, currently **192**) to the **`tests N`** line reported by `(cd scripts/trends &&
|
||
npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays inside the deps guard. **Append**
|
||
`+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline breakdown comment.
|
||
- Add **Section 16m** ("Trends Temporal Overlay", RE-R3d), mirroring Section 16l (unconditional, deps-absent-safe,
|
||
pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the badge emoji are NEVER grepped; the shell must
|
||
stay ASCII-clean for bash 3.2 `set -u`). **Placement (verified live):** between Section 16l's trailing `echo ""`
|
||
(`:1374`) and the Section 18 header (`:1376`) — anti-erosion stays last. Six **unconditional** checks, the
|
||
self-test emitting **one** pass/fail like 16l:
|
||
(1) a non-vacuity self-test (a probe carrying `temporalSignal` accepted, one without rejected);
|
||
(2) `grep -qF 'export function temporalSignal' scripts/trends/src/brief.ts`;
|
||
(3) `grep -qF 'b.temporal.rank' scripts/trends/src/brief.ts` (the cmp integration);
|
||
(4) `grep -qF '"first-mover"' scripts/trends/src/brief.ts` (the tier literal);
|
||
(5) `grep -qF 'first-mover-days' scripts/trends/src/cli.ts` (the flag);
|
||
(6) `grep -qF 'saturation-at' scripts/trends/src/cli.ts` (the flag).
|
||
- Bump `ASSERT_BASELINE_FLOOR` (**`:1403`**, currently **111**) → **exactly 117** (111 + the 6 new unconditional
|
||
16m emitters; the self-test emits one pass/fail like 16l, so 117 is deterministic — "live recount" is the safety
|
||
net, not a guess). Insert the 16m clause into the **header-enumeration prose chain (`:57-62`)** before "…the
|
||
assertion-count anti-erosion floor (SC6) in Section 18", preserving sentence flow. **Append** the RE-R3d (→117)
|
||
narration to the **Section-18 floor-history comment** (`:1376-1402`, which ends "= 111").
|
||
- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3d adds no hook test). It must
|
||
still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land.
|
||
|
||
## Step 7 — behavioural verification
|
||
|
||
`(cd scripts/trends && npm install)` if needed, then run brief §7's five behavioural steps: seed a store with a
|
||
fresh+unsurfaced, a warming (`surfacedCount` 2), and a saturated (`surfacedCount` 4) trend at the **same**
|
||
composite; `brief --pillars … --out /tmp/r3d-mb --store /tmp/r3d.json` → the fresh+unsurfaced sorts first with
|
||
`· 🥇 først ute`, the saturated last with `· 🔁 mettet (4x)`, the `ranking:` descriptor names the temporal key;
|
||
re-run with `--first-mover-days 0 --saturation-at 2` → first-mover badge gone, `surfacedCount 2` escalates to
|
||
`mettet (2x)`; `--first-mover-days x` → exit 2; confirm the seeded `score.composite` values are unchanged after the
|
||
brief (only `surfacedCount`/`lastSurfacedAt` advance). Run full `bash scripts/test-runner.sh` → `FAIL=0`
|
||
(`ASSERT_BASELINE_FLOOR` 117, trends ≥ new floor, Section 16m green, Section 17 de-niche green, counts 29/19/27);
|
||
run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression); confirm `schedule.test`/
|
||
`run-daily.test` still green (R3c untouched).
|
||
|
||
## Step 8 — land
|
||
|
||
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 117, gate total;
|
||
schema unchanged v4; **correct the stale :709/:1329 cites to live :713/:1403**). Commit order (house style):
|
||
**(1)** docs commit `docs/research-engine/{brief,plan}-re-r3d.md` (no suffix, tracked); **(2)** code commit —
|
||
`brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + `agents/trend-spotter.md` +
|
||
`references/trend-scoring-modes.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]`
|
||
(D10: single code commit — the overlay is one coherent feature). Push freely (window lifted; gitleaks at commit;
|
||
`origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev).
|
||
|
||
## Verification (testable)
|
||
|
||
| SC | Check | Command | Expected |
|
||
|---|---|---|---|
|
||
| — | RED Phase A | `(cd scripts/trends && npm test)` after the `temporalSignal` stub | unit/ordering/render/summary cases fail on **value** assertions vs the constant-"fresh" stub (not module-not-found) |
|
||
| — | RED Phase B | `npm test` (cli.test) before the flag impl | `--first-mover-days`/`--saturation-at` cases fail on value/exit vs the flag-ignoring handler |
|
||
| SC1 | first-mover detection | `npm test` (brief.test) | `(≤firstMoverDays, surfaced 0)` → first-mover; `(>window,0)`/`(≤window,≥1)` → not |
|
||
| SC2 | saturation grading | `npm test` (brief.test) | `surfacings ≥ saturationAt` → saturated; `1..at-1` → warming; `0` → fresh (inclusive `>=`) |
|
||
| SC3 | ranking — within-tier re-order, composite dominates (DISAGREE fixture) | `npm test` (brief.test) | `[Z(8.5,sat), A(7.0,neutral,older), B(7.0,warming,newer)]`; temporal key flips A above newer B; Phase A `[Z,B,A]`→RED; total order |
|
||
| SC4 | render badges + ≥2 boundary | `npm test` (brief.test) | `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming≥2) / none (warming 1, neutral); `:407` updated, `:408` stays green |
|
||
| SC5 | summary first-mover marker | `npm test` (brief.test) | first-mover top → `· 🥇 først ute` in headline; else absent; no `"`/`\n` |
|
||
| SC6 | CLI flags | `npm test` (cli.test) | flags change tiers vs defaults; defaults 2/3 when absent; bad values → exit 2 |
|
||
| SC7 | determinism | `npm test` (brief.test) | same `(store,pillars,today,freshDays,firstMoverDays,saturationAt)` → byte-identical `.md` |
|
||
| SC8 | no schema / no score mutation | `npm test` (brief.test) | `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `score.composite` unchanged after a brief |
|
||
| SC9 | purity | `npm test` (brief.test) | `temporalSignal` stable over a grid; first-mover ⊆ recent∧unsurfaced; saturated ⇔ `surfacings≥saturationAt` |
|
||
| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16m green; `ASSERT_BASELINE_FLOOR`=117; Section 17; counts 29/19/27; hook suite green |
|
||
|
||
## Risks
|
||
|
||
- **R1 — changing `cmp` re-orders existing brief output → silently breaks downstream expectations.** *Mitigated:*
|
||
composite stays the PRIMARY key (SB2) — the overlay only re-orders WITHIN a (composite, overlap) tier; the
|
||
bucketing is unchanged; SC3 pins the exact order; SC7 pins byte-determinism; the hook reads only date+summary
|
||
(unaffected).
|
||
- **R2 — the `surfacedToken`→`temporalToken` promotion breaks pinned R3b/descriptor assertions.**
|
||
*Mitigated/expected (folded — all three reviewers):* the warming badge is gated at `surfacings >= 2`, so
|
||
`brief.test.ts:408` (`!md.includes("sett 1x")`) stays green and the R3b ≥2 contract is preserved exactly; Step 1
|
||
enumerates the **four** touched assertions (`:407` `sett 3x`→`mettet (3x)`; `:325-331`+`:410-416` descriptor) and
|
||
re-bases the coverage-eroded `:96-102`. `surfacedCount 2` still renders `· sett 2x`.
|
||
- **R3 — emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16m greps ONLY ASCII literals
|
||
(`temporalSignal`, `b.temporal.rank`, `"first-mover"`, `first-mover-days`, `saturation-at`); the emoji live only
|
||
in `brief.ts` source + rendered output, asserted by the TS tests, never by the shell gate.
|
||
- **R4 — the summary marker breaks the SessionStart extractYaml regex.** *Mitigated:* `🥇 først ute` carries no
|
||
`"` and no `\n`; SC5 asserts the invariant; the hook suite is a land-time regression check.
|
||
- **R5 — saturation framing overclaims (reads as market-coverage).** *Accepted/honest:* R3d's saturation is
|
||
**self-surfacing** (our seen-log), a proxy for a closing/ignored window — NOT external coverage (that is slice
|
||
e, AI polling). The README + badge wording say "seen N×", not "covered online"; the brief §4 non-goal states the
|
||
boundary. No salesmanship.
|
||
- **R6 — first-mover default (2) too tight / saturationAt default (3) arbitrary.** *Mitigated:* both are
|
||
CLI-tunable (`--first-mover-days`/`--saturation-at`), documented as deliberate defaults (like `freshDays` 7),
|
||
grounded in the SSOT timing band (`<24-72h`) + the existing `sett Nx` `>=2` hint; D1/D2 are operator go-gate
|
||
knobs.
|
||
- **R7 — float/`-1` composite sentinel interaction with the new integer key.** *Mitigated:* the temporal key is a
|
||
separate `||` term (a small integer diff); it never touches the `score?.composite ?? -1` term; the comparator
|
||
stays a sum-free short-circuit chain (no NaN risk).
|
||
- **R8 — editing `trend-spotter.md` / the SSOT trips the de-niche guard (Section 17).** *Mitigated:* the added
|
||
prose + the SSOT note carry only generic overlay wording; pillars/topics stay config; Section 17 runs in the
|
||
gate.
|
||
- **R9 — gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16m is pure `grep`/self-test on
|
||
tracked source (`brief.ts` + `cli.ts`; no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps
|
||
guard.
|
||
- **R10 — STATE's pinned floor line-cites (`:709`/`:1329`) are stale (live `:713`/`:1403`).** *Mitigated:* caught
|
||
at brief time (the line numbers drifted when R3c added Section 16l + the floor-history narration); the plan cites
|
||
live values; Step 8 corrects STATE.
|
||
- **R11 — a temporal ordering test that passes WITHOUT the feature (false RED / vacuous GREEN).** *Mitigated
|
||
(folded — plan-critic M1):* `surfacedCount` correlates with age, so a naive first-mover-vs-saturated fixture is
|
||
already ordered by `effectiveDate`-desc. The SC3 fixture is built to force the temporal key and `effectiveDate`
|
||
to **disagree** (older-`neutral` A vs newer-`warming` B at equal composite), so it is RED in Phase A and the new
|
||
key is provably what decides in GREEN; the coverage-eroded `:96-102` is re-based to a shared tier.
|
||
|
||
## Plan-critic — folded
|
||
|
||
Three Opus reviewers ran on the brief + this plan, each verifying against live code; they **converged on the same
|
||
two defects**. Verdicts: **scope-guardian MIXED** (0 hard creep; both confirmed decisions honored; every SC traces
|
||
to a step; the floor/line cites verified live); **brief-reviewer PROCEED_WITH_RISKS** (all seven RED-premise/
|
||
correctness claims HOLD; the gap was GREEN-completeness — 1 of 4 breaking assertions listed); **plan-critic
|
||
APPROVE_WITH_NOTES 78/B** (floor arithmetic, line-cites, grep sentinels, cmp total-order, two-phase-RED structure
|
||
all verified correct). Per-finding resolution (full headline list in `brief-re-r3d.md §9`):
|
||
|
||
- **[MAJOR — all three] warming badge fired at `>=1`, but live `surfacedToken` fires at `>=2` (`brief.ts:156`) +
|
||
`brief.test.ts:408` pins `!sett 1x`.** ✅ Step 3 gates the warming badge at `surfacings >= 2` (R3b ≥2 contract
|
||
preserved exactly; `:408` unchanged); the warming *tier* still demotes in `rank`. SC4 gains the surfacedCount-1
|
||
no-badge boundary. The "preserves the hint" wording is corrected.
|
||
- **[MAJOR — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not RED + vacuous** (`surfacedCount`
|
||
correlates with age → `effectiveDate`-desc already orders first-mover-vs-saturated). ✅ Step 1 SC3 fixture forces
|
||
temporal↔date **disagreement** (older-`neutral` A vs newer-`warming` B, equal composite; expect `[Z,A,B]`, Phase
|
||
A `[Z,B,A]`→RED); `:96-102` re-based to a shared tier (R11).
|
||
- **[MEDIUM — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks `:325-331` + `:410-416`.** ✅ Step 1
|
||
+ the Files table enumerate all four touched assertions, not one.
|
||
- **[MINOR — plan-critic m3] `temporalSignal` undefensive vs `saturationAt < 1`.** ✅ Step 2 clamps `const at =
|
||
Math.max(1, opts.saturationAt)` inside the pure function (the CLI guard alone is insufficient — the function is a
|
||
public, gate-grepped export). SC2 clamp case added.
|
||
- **[MINOR — plan-critic m4] the "fresh" tier was a misnomer** (collides with `freshDays`; a 30-day unsurfaced
|
||
trend is not "fresh"). ✅ renamed **`neutral`** throughout (Steps 1–3, SCs); the gate sentinel greps `"first-mover"`
|
||
(unaffected). The in-bucket effect (unsurfaced ranks above seen-and-skipped within `olderMatched`) documented in
|
||
brief §3 as intended.
|
||
- **[MINOR — plan-critic m5] future `publishedAt` (ageDays < 0) became a first-mover "act now" headline.** ✅ Step 2
|
||
adds the `ageDays >= 0` guard (future → `neutral`). SC1 gains the `(-1,0)` case.
|
||
- **[MINOR — plan-critic m6 / brief-reviewer] nightly run locked to default thresholds** (the primary saturation
|
||
consumer). ✅ reframed in brief §4 as a known limitation, not a "later nicety."
|
||
- **[LOW — brief-reviewer LOW-5] SC6 tier assertions can't read tiers from `--json`** (it omits the body badges).
|
||
✅ Step 1 Phase B reads the written `.md` via `readFileSync(path)`; the first-mover marker is also in the `--json`
|
||
`summary`.
|
||
- **[LOW — all three] long-form `angle` cite `:34` → `:32`** (`:34` = `currency`; substance holds). ✅ brief §0/§2
|
||
corrected.
|
||
|
||
**Verified correct (no change needed):** the floor arithmetic (16m = 1 self-test + 5 greps = 6 → `ASSERT_BASELINE_
|
||
FLOOR` 111→117), all live cites (`:713`/`:1403`/`:1374`/`:1376`/`:57-62`), the six ASCII grep sentinels match the
|
||
literals Steps 2–4 write, the `cmp` insertion preserves a total order with no NaN risk, and the two-phase RED
|
||
structurally avoids a module-load ERROR. **This slice is authored + landed in-session (driftsmodell), not as a
|
||
headless autonomous run, so per-step revert/halt clauses are not needed** (R1/R2/R3a/R3b had none either).
|