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
357 lines
31 KiB
Markdown
357 lines
31 KiB
Markdown
# 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 · A1–A4 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 1–10 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 / A1–A4 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 1–6, **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 A1–A4** (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
|
||
|
||
D1–D4 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 SC1–SC8 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.
|