linkedin-studio/docs/research-engine/brief-re-r3a.md
Kjell Tore Guttormsen 4d3b9f4711 docs(linkedin-studio): RE-R3a brief + plan — persist relevance score + rank morning brief on it (light-Voyage hardened)
R3 slice 1 (research-deepening): persist a 4-field TrendScore {mode, dimensions,
composite, priority} on the store record (schema v2->v3, additive lossless), computed
by the already-built score.ts (composite+band, one owner), threaded item->store, and
rank rankForBrief on composite first + surface band+mode in renderBrief.

Go-gate confirmed (operator "Go"): D1 4-field envelope · D2 composite primary within
bucket · D3 first-sight only · D4 one slice · D6 mode shown per body entry.

Light-Voyage: scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR)
/ plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded. Headline fold: the RED
proof is now explicitly two-phase (logic-RED for store/brief/cli; stub-first then
assertion-RED for score/item, since a missing named import throws at module-load under
Node16 ESM, not on assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 13:43:02 +02:00

357 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Brief — RE-R3a: persist the relevance score + rank the morning brief on it
> **Slice:** RE-R3a (research-engine rung-2, R3 slice 1 — research-*deepening*). R3 ("deepen the
> research engine") is an **arc** of 5 open hulls (substrate §1: autonomous trigger · freshness-as-seen-log ·
> relevance/saturation/status scoring · brief history+diff · A1A4 fan-out). R3a takes the first: the
> **relevance** half of hull 5 (and the remainder of hull 3 — "the store schema lacks fields a brief ranks
> on"). It persists the composite relevance score the `trend-spotter` agent ALREADY computes, onto the store
> record, and makes `rankForBrief` order on it.
> **Predecessor:** RE-R1 (`score.ts`, B2 triage-scorer — built, tested, deterministic) + RE-R2a (`capture`
> bridge + `publishedAt`, schema v1→v2) + RE-R2b (`brief.ts` dated artifact + surfacing). R2b explicitly
> deferred this in its §4: *"the B2 triage scorer stays out of the brief path — its output isn't persisted on
> records yet — that's R3."* R3a is exactly that R3 step.
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (schema fields a brief ranks on:
> relevance/...) + (5) (relevance scoring) + §B2 ("scoring/filtering as a gate before expensive work — the
> output is the rank key") + §A2 ("curate/score before synthesis — the writer sees ranked material").
> **TDD-order:** RED tests land before code — but as **two phases** (light-Voyage BLOCKER fold): the
> store/brief/cli tests are true logic-RED against the pre-edit code (they build fixtures inline, import no new
> symbol); the score/item tests reference not-yet-existing `score.ts` exports, so under Node16 ESM a missing
> named import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed as
> the first GREEN-prep sub-step. See plan Step 1.
## 1. Operator decision context (2026-06-24)
The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en jevn
strøm av gode forslag til tema å skrive om."* RE-R2 made the stream **visible** (a dated morning brief surfaced
at session-start). R2b ranks that brief on **pillar-overlap + recency only** — a coarse proxy for "good topic
to write about." The actual relevance judgment (audience pull, timing, angle potential, authority, depth) lives
in the five 110 dimension scores the `trend-spotter` agent produces in Step 2 and pipes to the `score` CLI —
and is then **thrown away** before the trend reaches the store (Step 4.5 builds a *separate*, score-free capture
batch). R3a stops discarding it: persist the composite + band on the record, and rank the brief on composite
first. **The slice the operator chose** ("scoring inn i briefen", 2026-06-24) — the highest-leverage next step
on the core need (better-ordered suggestions), built on already-shipped-but-dormant code (`score.ts` is tested
and unused on records). The bigger R3 arcs (autonomous trigger / seen-log / saturation+status / A1A4 fan-out)
stay later slices.
**Go-gate resolutions — CONFIRMED (operator "Go", 2026-06-24; baked into the plan):** **D1** persist the
**4-field** `TrendScore { mode, dimensions, composite, priority }` (composite+priority to rank/display, mode to
disambiguate the instrument, dimensions for audit + lossless re-weight). **D2** composite is the **primary
within-bucket sort** (buckets still assigned by overlap+freshness; composite orders *inside* a bucket). **D3**
score is **first-sight** (set on add, never updated on re-capture — matches the store's provenance discipline;
re-score-on-recapture pairs with the R3b seen-log/status slice). **D4** ship persist+rank as **one** slice (the
operator named the visible payoff; splitting would land an invisible schema-only cut like R2a).
## 2. The gap — grounded in code
- **The score the agent computes never reaches the store.** `trend-spotter.md` scores each candidate's five
dimensions and pipes them to the `score` CLI (`agents/trend-spotter.md:134-140`), which returns
`{composite, band}` per candidate (`score.ts:110-122` `triage`). But Step 4.5's capture batch
(`agents/trend-spotter.md:291-298`) is **built separately and carries no score**`source/title/url/topics/
publishedAt/summary` only. `TrendItem` (`item.ts:22-39`) and `TrendRecord` (`types.ts:26-48`) have **no score
field**. The relevance judgment is recomputed for the digest and discarded for persistence.
- **`score.ts` is built, tested, deterministic — and unconsumed on records.** It exports `composite()`
(`score.ts:77-88`) and `band()` (`score.ts:91-97`) as pure functions, pinned to the SSOT
(`references/trend-scoring-modes.md`, by `score.test.ts:12-30` weights + the band-string assertions). Nothing
persists their output. `TrendRecord`'s own doc-comment anticipates the field: *"can gain fields (relevance
score, first-mover timing, status) in a later slice"* (`types.ts:21-23`).
- **The brief ranks on a proxy.** `rankForBrief` sorts each bucket `overlap desc → effectiveDate desc →
title asc → url asc` (`brief.ts:94-104`). Overlap (a hard pillar count) is *part* of what the composite
already weights (Pillar Fit 30 %, `trend-scoring-modes.md:43`), but the composite also captures audience/
timing/angle/authority — signal the brief currently can't see. `brief.ts`'s own header already names this as
the next slice: *"A persisted relevance/saturation score … (R3)"* (`brief.ts:11-14`).
## 3. Scope — what is IN (RE-R3a)
### S-score — `scripts/trends/src/score.ts` (EDIT)
- **`export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority:
Priority }`** — the persist-ready envelope. Lives in `score.ts` (the score domain owns it); `types.ts` imports
it (one-way: `score.ts` imports nothing — verified leaf, `:1-17` — so no cycle).
- **`export function requiredDimensions(mode: ScoreMode): string[]`** — `Object.keys(WEIGHTS[mode])`
(`score.ts:37-40`). **Contract: ordered** — the keys come back in the SSOT weight-literal order (kortform
`["pillar","audience","timing","angle","authority"]`, long-form `["pillar","depth","angle","authority",
"currency"]`, `score.ts:20-35`); SC1 deep-equals that ordered array, and `score.test` pins the order so a
silent SSOT reorder fails loudly. `normalizeItem` consumes it as a **set** (membership), which is order-safe
either way. `WEIGHTS` stays private; the keys are exposed via this function.
- **`export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore`** — composes the
existing pure functions: `const c = composite(dimensions, mode); return { mode, dimensions, composite: c,
priority: band(c).priority }`. **No new arithmetic** — `composite()`+`band()` stay the single owners (SSOT
discipline). It throws (via `composite`, `score.ts:83`) on an out-of-range dimension — that is its
**contract**, exercised directly by SC1/SC2; on the capture path it is unreachable because `normalizeItem`
pre-validates (below).
### S-types — `scripts/trends/src/types.ts` (EDIT)
- `import type { TrendScore } from "./score.js";`
- `TrendRecord` gains **`score?: TrendScore;`** (optional — pre-R3a records simply lack it; the `add` manual
path and unscored adopters omit it). Doc-comment updated to mark `score` as the now-realized field the
`:21-23` note anticipated.
- **`SCHEMA_VERSION = 2 → 3`** (`types.ts:62`). The bump is the only schema signal; the record shape change is
additive-optional, so the migration is the version-stamp alone (below).
### S-store — `scripts/trends/src/store.ts` (EDIT)
- `TrendInput` (`store.ts:26-35`) gains **`score?: TrendScore;`** (imported from `score.js`).
- `addTrend` (`store.ts:120-140`): on a **new** record, persist `score` first-sight via the existing
conditional-spread idiom (`...(input.score !== undefined ? { score: input.score } : {})`, mirroring
`publishedAt`/`summary` `:134,136`). On a **duplicate**, score is **NOT** updated (D3 — first-sight, like
`source`/`capturedAt`/first `publishedAt`); topics still union (`:124-126`, unchanged). `AddResult` is
unchanged (no new flag).
- `loadStore` migrate comment (`:79-84`): extend to *"v1→v2→v3 are all purely additive-optional (an old record
is already a valid record that simply lacks the optional field), so the migration is the version stamp alone —
records pass through untouched."* **No code change** to the migration logic (`Math.max(onDisk, SCHEMA_VERSION)`
`:87` already does v2→v3 correctly; `saveStore` `JSON.stringify` `:95` preserves the `score` field — no field
stripping); only `SCHEMA_VERSION` (in `types.ts`) and the comment move.
### S-item — `scripts/trends/src/item.ts` (EDIT)
- `TrendItem` (`item.ts:22-39`) gains **`score?: { mode: ScoreMode; dimensions: DimensionScores };`** — the
ingress envelope carries the agent's *judgment* (five scores + mode), **not** a precomputed composite (the
store computes it, so the composite has one owner). `import type { ScoreMode, DimensionScores } from
"./score.js"` + `import { requiredDimensions } from "./score.js"`.
- `normalizeItem` (`:86-119`): if `r.score` present, **validate structurally** (returns a structured error into
`errors[]`, never throws — the existing discipline, like the `publishedAt` ISO check `:99-106`): `score` is a
**non-array** object; `mode ∈ {kortform, long-form}`; `dimensions` is a **non-array** object; **each key in
`requiredDimensions(mode)` is present and a number in [1,10]**. On any failure → `errors.push("invalid score:
…")`. On success carry the **validated** `score = { mode, dimensions }` forward (the validated dimensions
object, not raw `r.score.dimensions`). Absent/null/invalid → key omitted. This guarantees the *capture path*
(`cli.ts:246-254`: `normalizeItems` → `itemToInput`) never reaches `composite` with bad dims.
- `itemToInput` (`:129-139`): if `item.score` present → add `score: scoreEnvelope(item.score.mode,
item.score.dimensions)` to the returned `TrendInput` (conditional spread, key omitted when absent). The
item→store bridge is the natural place to turn judgment into the persisted envelope. `itemToInput` is a public
function: called directly (e.g. in a test) with unvalidated dims it **throws by contract** (defense-in-depth);
the no-throw guarantee is a property of the *capture path*, not of `itemToInput` in isolation (§5).
### S-brief — `scripts/trends/src/brief.ts` (EDIT)
- `rankForBrief` (`:72-114`): **composite becomes the primary within-bucket sort key** (D2). The comparator
(`:94-98`) gains a leading term:
`(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || <existing overlap desc → effectiveDate
desc → title asc → url asc>`. **Sentinel `-1`, not `-Infinity`** — composite is a weighted sum of [1,10]
dims so it is always ≥ 1.0 (min = 1×Σweights = 1.0, verified); `-1` sorts every unscored record below every
scored one and subtracts cleanly (`-Infinity - -Infinity = NaN` would corrupt the comparator). **Buckets are
UNCHANGED** — assignment stays `overlap≥2 & fresh` / `overlap==1 & fresh` / `!fresh` (`:100-104`); composite
only re-orders *within* a bucket. Total order preserved: the `(title,url)` pair is unique per store (it is the
dedupe id, `store.ts:66-68`), so the final `url asc` tie-break makes the order insertion-independent even for
equal composites.
- `renderBrief` (`:152-191`): surface the band **and mode** where a record is scored (so a reader can tell a
kortform "High" from a long-form "High" — the two are different instruments). **Pinned line shapes:**
- Top-entry meta line (`renderTopEntry`, `:135`), scored:
`- Kilde: <source> · Publisert: <date> (<age>d) · <priority> (<mode>) · Pillarer: <matched>`
(the `· <priority> (<mode>)` token sits between `(<age>d)` and `· Pillarer`); **unscored: unchanged** (no
token).
- Bullet line (`renderBulletEntry`, `:144`), scored:
`- **<title>** — «<matched>» · <date> (<age>d) · <priority> (<mode>) · 🔗 <url>`
(token **before** `· 🔗`); **unscored: unchanged**.
- The `ranking:` frontmatter descriptor (`:160`) → the **exact** string
`composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>`
(pinned verbatim; `brief.test` asserts it byte-for-byte).
- `briefSummary` (`:122-130`): the top mention names the **band only** (mode stays a body-entry detail to keep
the one-line headline clean) — fresh>0 with a **scored** top → `… Topp: «<title>» (<pillar> · <priority> ·
<age>d).`; fresh>0 with an **unscored** top → `… Topp: «<title>» (<pillar> · <age>d).` (no token). **Still one
line, no `"`, no `\n`** — the `extractYaml` contract (`brief.ts:118-120`) holds; the band strings
(`Immediate`/`High`/…) are bare words.
- `BRIEF_SCHEMA_VERSION` stays **1** (no frontmatter *field* added/removed; the surfacing hook still reads
`date`+`summary`; only the `ranking:` descriptor *string* and body content change). Bumping is an Open Q (§8),
not required for correctness.
### S-cli — `scripts/trends/src/cli.ts` (EDIT, doc-only behavior)
- The `capture` branch (`:243-269`) folds via `itemToInput` (`:254`) — so once `item.ts` threads `score`,
capture **automatically** persists it with **no logic change**. Update only the header doc-comment
(`:15-21`) to note capture now persists an optional relevance score. The `score` CLI (`:218-241`, the digest
path) and the `add` manual path (`:123-147`, score-free) are unchanged. *(Capture's `{added, merged,
duplicates, errors}` tally is left unchanged — a `scored` count is an Open-Q nice-to-have, §8.)*
### Wiring (D-default — WIRE, mirrors R2a/R2b Open Q#1)
- `agents/trend-spotter.md` (EDIT): Step 4.5's capture batch (`:291-298`) gains a per-item **`"score": {"mode":
"kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same
five judgment scores the agent computed in Step 2 (`:134`), carried into capture so the store persists them and
the brief ranks on them. Prose explains the carry ("don't discard the Step-2 scores — fold them into the
capture batch"). Mode defaults `kortform`; `long-form` when invoked from `/linkedin:newsletter` (long-form
dims `pillar/depth/angle/authority/currency`). Domain-general (dimensions are the rubric's, pillars are the
user's config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. **Verified
non-vacuous:** `agents/trend-spotter.md` does NOT currently contain the literal `"dimensions"`, so the
Section 16j grep passes only after the wire is added.
- `scripts/trends/README.md` (EDIT): document the item `score` field (judgment in), the persisted `TrendScore`
(composite/priority out), and that the brief now ranks on composite.
- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the `tests N` line
reported after Steps 16, **append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`). Add
**Section 16j** ("Trends Score Wiring", RE-R3a) **after Section 16i's closing `echo ""` (~`:1171`), before the
Section 18 block (`:1173`)** (16i is the last 16x before anti-erosion; file order 17→16g→16h→16i→18,
`:947/:1014/:1078/:1122/:1173`). Mirror 16i's shape: **unconditional**, deps-absent-safe `grep -qF` + a
non-vacuity self-test emitting **one** pass/fail (so the count is exact) — (1) self-test; (2) `export
interface TrendScore` in `score.ts`; (3) `score?: TrendScore` in `types.ts`; (4) `"dimensions"` in
`agents/trend-spotter.md`; (5) `score?.composite` in `brief.ts`. **5 unconditional emitters → bump
`ASSERT_BASELINE_FLOOR` 94 → exactly 99** (`:1193`; "live recount" is the safety net, but the expected value
is the pinned 94 + 5 = 99). Update the header-enumeration **prose chain** by inserting the 16j clause between
the 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow.
## 4. Non-goals — what is OUT (deferred)
- **Re-score on re-capture** (refresh the score when a trend is re-seen) — **R3b**. R3a is first-sight only
(D3). Re-score pairs naturally with the seen-log/status slice (the Timing dimension decays, so a refresh is a
real improvement — but it expands `addTrend`'s mutation surface and wants the status/lifecycle model alongside).
- **Mode-segmented / mode-normalized ranking** — OUT. R3a ranks **all** records by composite regardless of mode;
a kortform composite and a long-form composite are different instruments (different dimensions,
`trend-scoring-modes.md:50,68`), so the ranking is **mode-blind by design for R3a**. This is acceptable because
(a) almost all records are `kortform` (the default), and (b) the body entry line **shows the mode** (`<priority>
(<mode>)`) so the operator can see when two adjacent entries were scored on different instruments. A
mode-segmented brief (separate sections per mode) or a `--mode` filter is a later refinement.
- **Saturation / status (acted/skipped) / first-mover-as-a-field** (the rest of hull 5) — **R3b+**. R3a does the
**relevance** half of hull 5 only.
- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3 later**. No scheduler enters the repo.
- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — **R3 later**.
- **Brief history surfacing / diff ("yesterday vs today", hull 7)** — **R3 later**.
- **Research-deepening A1A4** (plan → isolated parallel workers → gap loop → curate), adapter sub-agents, MCP
fetch fan-out — **R3 later** (the big slice).
- **A new `score` field in the `add` manual CLI path** — OUT. `add` stays the raw, score-free manual path; only
the normalizing `capture` path carries scores.
- **`BRIEF_SCHEMA_VERSION` bump** — OUT by default (no frontmatter field changes); Open Q#5.
- **New source file / new agent / new command** — none. R3a is all edits to the six existing `src/*.ts` +
one agent + README + gate. Counts stay 27/19/29.
## 5. Boundaries / invariants (must hold)
- **TDD iron law (two-phase RED):** the failing tests land **BEFORE** the implementation. `store`/`brief`/`cli`
tests are true logic-RED against the pre-edit code (inline fixtures, no new import). `score`/`item` tests
reference new `score.ts` exports → under Node16 ESM a missing named import throws at module-load, so they are
RED against **non-throwing stubs** landed first (the stubs return wrong-but-present values; the value
assertions then fail). The plan records the RED proof in two phases (Step 1); it does NOT claim a single
"all five fail on assertion before any code" run.
- **One composite owner:** `composite()` + `band()` (`score.ts`) stay the sole arithmetic; `scoreEnvelope`
*composes* them, never re-derives. The agent supplies judgment, the code computes the composite (SSOT
discipline, `score.test.ts:12-30` pins the weights/bands).
- **Purity:** `scoreEnvelope`/`requiredDimensions`/`rankForBrief`/`renderBrief` touch no fs, no clock, no env,
no AI. All fs stays at the CLI edge.
- **No throw on the capture path (not "everywhere"):** `normalizeItem` fully validates the score before
`itemToInput`, so the capture loop (`cli.ts:246-258`) never reaches `composite` with bad dims and never
crashes (a bad score → `errors[]`). `itemToInput`/`scoreEnvelope`/`composite` called **directly** with bad
dims throw by contract — that is the defense-in-depth boundary, asserted (SC2), not a leak.
- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (the composite sort is a
total order via the unique `(title,url)` final tie-break; `-1` sentinel for unscored is deterministic).
- **Lossless additive migration (both directions):** a v2 store loads as v3 with records **untouched** (no
`score` invented); round-trip writes `schemaVersion: 3`; a v3 store is idempotent; a v3 store's new optional
`score` field **survives a load+resave** (no field stripping, `JSON.stringify` `store.ts:95`). Mirrors the R2a
v1→v2 proof (`store.test.ts:403-476`) + a new field-preservation case.
- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**
(analytics fresh-clone-crash invariant) — R3a touches neither the hook nor the frontmatter schema, so the
zero-tsx surfacing is unchanged. (No hook test added; the existing hook suite must still pass untouched as a
regression sanity.)
- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` edit carries the rubric's
dimension names + the user's pillars, **no vendor/sector tokens**.
- **No SSOT change:** `references/trend-scoring-modes.md` (weights/bands/actions) untouched; `score.ts` mirrors
it exactly as today.
- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched; the brief recomputes
overlap as before (`queryByTopic` NOT refactored).
- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` —
R3a adds *no* source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`.
- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed.
## 6. Success criteria (testable)
- **SC1 (score envelope)** — `requiredDimensions("kortform")` **deep-equals (ordered)** `["pillar","audience",
"timing","angle","authority"]`; `requiredDimensions("long-form")` deep-equals `["pillar","depth","angle",
"authority","currency"]` (the `WEIGHTS` literal order, `score.ts:20-35`), and `score.test` pins the order so a
SSOT reorder fails. `scoreEnvelope("kortform", {pillar:8,audience:7,timing:9,angle:6,authority:5})` returns
`{ mode:"kortform", dimensions:<the five>, composite: composite(dims,"kortform"), priority: band(composite).
priority }` — composite/priority equal the existing functions' output byte-for-byte (one owner); a bad
dimension makes `scoreEnvelope` throw (via `composite`).
- **SC2 (item validation + bridge + the throw contract)** — `normalizeItem` on an item with a valid `score`
carries the **validated** dims; with a bad `mode`, a missing dimension, a dimension out of [1,10], a non-object
`score`, or an **array** `dimensions` → `{ ok:false, errors:["invalid score: …"] }` (structured, **never
throws**); absent `score` → key omitted. `itemToInput(validItemWithScore, capturedAt)` returns a `TrendInput`
whose `score` is `scoreEnvelope(mode, dimensions)` (composite/priority computed); without a score → no `score`
key; **`itemToInput` called directly with an out-of-range dim throws** (the defense-in-depth contract).
- **SC3 (first-sight persist)** — `addTrend(store, inputWithScore)` on a **new** title+url persists `score` on
the record; re-`addTrend` of the same title+url with a **different** score does **NOT** change the stored
score (first-sight, D3) while topics still union; an input **without** a score adds a score-free record.
- **SC4 (migration v2→v3, both directions)** — a `schemaVersion:2` store with records lacking `score` loads as
**v3**, records intact, **no `score` invented**; round-trip `loadStore→saveStore` writes `schemaVersion:3`; a
v3 store with `score` on records loads idempotent; **a v3 store's `score` field survives load+resave** (field
preservation of a new optional field — not covered by the mirrored v1→v2 block). Mirrors
`store.test.ts:403-476`, **retitled `(RE-R3a / score v2→v3)` with every `schemaVersion` assertion literal
flipped `2`→`3`.**
- **SC5 (brief ranks on composite)** — within a bucket, `rankForBrief` orders **composite desc** first
(a composite-9 record ahead of a composite-6 record at the **same overlap**); an **unscored** record sorts
**after** every scored record in its bucket (the `-1` sentinel) and then by the existing keys; buckets are
unchanged (still overlap+freshness); the order is a **total order** (same-title/diff-url, both unscored →
fixed by `url asc`); same input → byte-identical brief (determinism).
- **SC6 (render surfaces band + mode)** — `renderBrief` emits the **full pinned line shapes** (§3): a scored
top-entry shows `· <priority> (<mode>)` between `(<age>d)` and `· Pillarer`; a scored bullet shows `·
<priority> (<mode>)` before `· 🔗`; an **unscored** entry renders the **unchanged** line (no token) — both
asserted as **full lines, not substrings**. `briefSummary` names the band (no mode) on a scored top, omits the
token on an unscored top, and stays one line with no `"`/`\n` **even when the top title contains a guillemet/
quote** (the only new code path touching the summary). The `ranking:` frontmatter descriptor equals the pinned
string verbatim. A store whose only fresh match is a **single-pillar unscored** record → `briefSummary` renders
with no `· <priority>` token, one line.
- **SC7 (CLI persists score end-to-end)** — `echo '[{…,"score":{"mode":"kortform","dimensions":{…valid…}}}]'
| … capture --store <tmp>` then `… list --store <tmp> --json` shows the record carrying a `score` with the
computed composite/priority; a batch with one **bad** score → that item in `errors[]`, the valid ones added,
**exit 0** (the run isn't failed).
- **SC8 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the
bumped `TRENDS_TESTS_FLOOR`; new **Section 16j** green (`TrendScore` in `score.ts`, `score?: TrendScore` in
`types.ts`, `"dimensions"` in `trend-spotter.md`, `score?.composite` in `brief.ts`, non-vacuity self-test);
`ASSERT_BASELINE_FLOOR` = **99** (94 + 5); Section 17 de-niche green; counts 27/19/29.
## 7. Verification
**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new Section 16j
self-test + greps pass; `ASSERT_BASELINE_FLOOR` = 99; Section 17 de-niche green; ref/agent/command counts
unchanged. **Regression sanity:** `node --test hooks/scripts/__tests__/` → still green untouched (R3a touches no
hook; adds no hook test).
**Behavioural (manual):**
1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>",
"score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}},
{"source":"tavily","title":"B","url":"https://e/b","topics":["ai","gov"],"publishedAt":"<~2d ago>",
"score":{"mode":"kortform","dimensions":{"pillar":6,"audience":5,"timing":6,"angle":5,"authority":5}}}]'
| node --import tsx src/cli.ts capture --store /tmp/r3a.json` — both overlap-2 & fresh, A scored higher.
2. `node --import tsx src/cli.ts list --store /tmp/r3a.json --json` → confirm both records carry `score`
with computed composite/priority.
3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3a.json --out /tmp/r3a-brief --json` →
confirm **A precedes B** in `topMatches` (higher composite, same overlap+freshness), the entry line shows
`· <priority> (kortform)`, and the `summary` names A with its band.
4. Append a bad-score item (`"timing":99`) to the batch and re-`capture` → confirm it lands in `errors[]`,
the valid items still added, exit 0.
## 8. Open questions for the go-gate — RESOLVED
D1D4 confirmed by the operator ("Go", 2026-06-24): **D1** 4-field `TrendScore`; **D2** composite primary within
bucket; **D3** first-sight; **D4** one slice (data-then-visible commit order within it). Two residual cosmetics,
both baked to the recommended default:
- **D5 — `BRIEF_SCHEMA_VERSION` 1→2?** No (no frontmatter field added/removed; the hook reads only
`date`+`summary`). Re-open only if the artifact should self-announce the ranking change.
- **D6 — mode in the per-entry render?** YES (folded from plan-critic #3): the body entry shows `<priority>
(<mode>)`; the summary shows the band only. This makes the mode-blind ranking honest (the reader can see the
instrument).
## 9. Light-Voyage review — folded
Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED**
(every SC1SC8 traces to a step; zero creep; all §4 non-goals held; counts 27/19/29 verified live; "no new
source file" verified — exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added; 0 findings).
**brief-reviewer: PROCEED_WITH_RISKS** (all four load-bearing claims — score.ts-is-a-leaf/no-cycle, composite ≥
1.0, version-stamp-only migration, single-owner arithmetic — verified TRUE; 6 MINOR). **plan-critic: REVISE**
(1 BLOCKER, 4 MAJOR, 4 MINOR). All findings folded; see `plan-re-r3a.md §Plan-critic — folded` for per-step
resolution. Headlines:
- **[BLOCKER, folded]** the "all five test files fail on assertion after Step 1" RED claim is false for
`score`/`item` under Node16 ESM (a missing named import throws at module-load, not on assertion). → RED is now
**explicitly two-phase**: logic-RED for `store`/`brief`/`cli` against pre-edit code; stub-first then
assertion-RED for `score`/`item` (§5; plan Step 1; the header blockquote).
- **[MAJOR, folded]** the no-throw guarantee was overstated ("unreachable" — but `itemToInput` is public and
throws on direct bad-dim calls). → reworded **path-specific** (no throw on the capture path; direct calls throw
by contract); SC2 asserts both (§5, §6).
- **[MAJOR, folded]** mode-mixing was waved away and "mode shown per entry" contradicted the render spec (which
only showed priority). → the render now shows `<priority> (<mode>)` per body entry (D6); §4 states mode-blind
ranking is accepted for R3a with the mode visible; SC6 asserts the full line incl. mode.
- **[MAJOR, folded]** `requiredDimensions` order contract was ambiguous (SC1 hard-coded arrays vs membership
use). → pinned **ordered** (SC1 deep-equals the SSOT-order array; `score.test` pins order; `normalizeItem` uses
membership) (§3 S-score, SC1).
- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` "~99" was not pinned. → pinned **99** (94 + 5 unconditional 16j
emitters; self-test emits one pass/fail like 16i) (§3 wiring, SC8).
- **[MINOR, folded]** SC4 ref `:403-471` stale + pointed at v2 assertions → `:403-476` + "flip every
`schemaVersion` literal 2→3" note (SC4). **[MINOR, folded]** R1 SSOT-pin cite was the doc-comment → now
`score.test.ts:12-30` (§2, §5; plan R1). **[MINOR, folded]** bullet `· <priority>` placement was substring-only
→ full pinned line shape, priority+mode before `🔗`, asserted as a full line (§3, SC6). **[MINOR, folded]**
three diverging `ranking:` descriptor strings → one verbatim target, asserted byte-for-byte (§3, SC6). **[MINOR,
folded]** unscored single-match-top summary path untested → added as an SC6 case. **[MINOR, folded]**
`normalizeItem` non-array object case understated → "non-array" added to both object checks + SC2. **[MINOR,
folded]** header-chain line-ref tightened to the 16i clause `:46-49` / Section-18 `:49`. **[MINOR, folded]** R9
DAG now lists the three new one-way `score.ts ←` edges. **[MINOR, folded]** SC4 forward-compat /
score-survives-round-trip added. **[MINOR, folded]** SC6 quote-safety regression (scored top title with a
guillemet) added.