linkedin-studio/docs/research-engine/plan-re-r1.md
Kjell Tore Guttormsen 0e95ca8cce docs(linkedin-studio): RE-R1 brief + plan — lift research-engine item-schema (B1) + triage-scorer (B2) to tested code (light-Voyage hardened)
Research engine lifted to Tier-1 (operator 2026-06-24): the daily workflow
rests on a steady stream of topic suggestions, and research is the only
subsystem whose core logic (poll/score/digest) is ungated prose in
agents/trend-spotter.md rather than tested code.

RE-R1 (rung-2, slice 1): B1 canonical item envelope + normalizer
(scripts/trends/src/item.ts) and B2 deterministic triage-scorer
(score.ts — composite/band/triage, owns only the arithmetic; the five
1-10 dimension scores stay model judgment), plus a stdin/JSON CLI seam,
tests, a trend-spotter prose pointer, and gate-floor bumps. No
store-schema change (SCHEMA_VERSION stays 1). The visible morning-brief
stream (B3 + surfacing) is R2.

Light-Voyage hardened: scope-guardian ALIGNED; plan-critic blocker + 6
majors folded — incl. TrendItem does NOT map directly onto TrendInput
(capturedAt vs publishedAt → store bridge deferred to R2), CLI reads
stdin (no --json overload), gate Section 16g before the anti-erosion
Section 18, ASSERT floor recount-not-pinned, band thresholds + action
strings drift-pinned, wiring grep literal src/cli.ts score.

No code touched yet — awaiting operator go-gate on the plan.

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

110 lines
13 KiB
Markdown
Raw Permalink 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.

# Plan — RE-R1: item-schema (B1) + triage-scorer (B2) as tested code
> **Brief:** `docs/research-engine/brief-re-r1.md`. **Slice:** RE-R1 (research-engine rung-2, slice 1).
> **TDD-order:** RED (item + score tests as logic-RED) → GREEN (item.ts, score.ts) → GREEN (CLI + cli tests) → wire trend-spotter prose → gate floors → behavioural → land. **Counts recounted live at land, never pinned/guessed.**
> **Light-Voyage hardened:** scope-guardian ALIGNED; brief-reviewer + plan-critic findings folded (see §Plan-critic — folded).
## Goal
Move the research engine's deterministic core — the canonical item envelope (B1) and the composite/band/threshold arithmetic (B2) — out of `agents/trend-spotter.md` prose into pure, tested TypeScript under `scripts/trends/`, behind a CLI seam the agent and a future orchestrator call. No store-schema change; the five judgment scores stay with the model; wiring `normalizeItem` into the store is R2.
## Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
| `scripts/trends/src/item.ts` | **NEW**`TrendItem` ingress envelope + `normalizeItem` / `normalizeItems` (pure, validating; no `id`) | SC1 |
| `scripts/trends/src/score.ts` | **NEW**`ScoreMode`, per-mode weight consts (mirror SSOT), `composite`, `band`, `triage` | SC2, SC3, SC4 |
| `scripts/trends/src/cli.ts` | **EDIT** — add `normalize` + `score` subcommands (stdin JSON in, JSON out, exit 2 on bad invocation) | SC5 |
| `scripts/trends/tests/item.test.ts` | **NEW** — normalize: required-field errors, whitespace/topic dedupe, publishedAt validate, batch partition | SC1 |
| `scripts/trends/tests/score.test.ts` | **NEW** — golden composite (both modes: all-tens=10.0 + `{10,8,6,4,2}`=7.0 + weights-sum-1.0 + pinned weights), range guard, band boundaries + pinned action strings, triage gate/rank | SC2, SC3, SC4 |
| `scripts/trends/tests/cli.test.ts` | **NEW** — subprocess: `normalize`/`score` happy path (stdin→JSON) + exit-2 bad invocation | SC5 |
| `agents/trend-spotter.md` | **EDIT** — replace L124-137 "compute composite/bands yourself" with a pointer naming `src/cli.ts score` as the deterministic-step owner; domain-general | SC7 |
| `scripts/test-runner.sh` | **EDIT**`TRENDS_TESTS_FLOOR` 24→recount (stays inside deps guard); NEW unconditional Section **16g** (before Section 18); `ASSERT_BASELINE_FLOOR` 84→recount; header enumeration | SC6, SC7 |
| `docs/research-engine/{brief,plan}-re-r1.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
| `STATE.md` | **EDIT at land** — Telling-block reconcile (floors, gate total). *Land bookkeeping, not slice scope; LOCAL-ONLY.* | — |
**Not touched (scope fence):** `scripts/trends/src/{store.ts,types.ts}` (no schema change, `SCHEMA_VERSION` stays 1) · `references/trend-scoring-modes.md` (SSOT unchanged) · `references/*` (no new ref doc) · `config/trends-sources.template.md` (source list — not wired in R1) · `agents/*` count (19) · `commands/*` (29) · `hooks/**` · `.gitignore` (trends lines already present).
## Step 1 — (RED) failing tests for B1 + B2
Write `tests/item.test.ts` and `tests/score.test.ts` against not-yet-existing modules. Make them **logic-RED**, not import-RED, with a stub strategy split by assertion type:
- **arithmetic / "returns X" tests** → stub returns a *wrong constant* (so the equality assertion fails on value, not on a throw);
- **"should throw" validation tests** → stub returns a valid-looking value (so the `assert.throws` fails because nothing threw).
`item.test.ts`: well-formed raw → canonical item (fields verbatim; topics normalized+deduped); missing/empty `source|title|url``{ok:false}` naming the field; whitespace collapse via the same normalization as `store.normalizeField`; `publishedAt` present-and-ISO → kept, absent → undefined, present-and-invalid → `{ok:false}`; `normalizeItems` partitions a batch into `{items, errors}`; the canonical item carries **no `id`**.
`score.test.ts`: `composite` for both modes on (a) all-tens → exactly **10.0**, (b) the asymmetric vector `{10,8,6,4,2}` in dimension order → **7.0**; a dimension = 0 or 11 → throws; a **pinned-weights** assertion (each mode's five constants equal the SSOT values, Σ=1.0); `band` at 8.0 / 6.0 / 4.0 / 2.0 / below → correct priority + **the exact SSOT action string** (kortform + long-form); `triage` with threshold 4.0 → kept (≥4.0, sorted composite-desc, annotated composite+band) and dropped (<4.0).
**RED proof (record in commit):** `(cd scripts/trends && npm test)` → new cases fail with assertion errors (not module-not-found).
## Step 2 — (GREEN) implement `item.ts`
Implement the `TrendItem` ingress envelope `{source,title,url,publishedAt?,topics[],summary?}` + `normalizeItem`/`normalizeItems` to pass Step 1's item cases. Reuse `normalizeField` (import from `./store.js`) for whitespace; topic normalize + dedupe. `publishedAt` validated with a strict ISO-date check; **carried, not persisted** (comment: the *source's* publish date, forward-compat for B4 freshness — distinct from the store's `capturedAt`). **Do not derive or carry `id`** — the store owns it via `addTrend``trendId`; the envelope has no id field. **Do not wire to the store** — the item→`TrendInput` bridge (`capturedAt` injection) is R2.
## Step 3 — (GREEN) implement `score.ts`
Encode the two weight-sets as `const` records mirroring `trend-scoring-modes.md` (header comment: the SSOT is the human source, "ordering is the signal, not a measured coefficient", + the SSOT path). Implement `composite` (validate each dimension ∈[1,10], weighted sum), `band` (the five-range map → `{priority, kortformAction, longformAction}` using the **exact SSOT action strings**), and `triage` (`kept`/`dropped` + composite-desc sort + per-item composite/band annotation). Make Step 1's score cases green (incl. the pinned-weights + pinned-action-string assertions).
## Step 4 — (GREEN) CLI subcommands + `cli.test.ts`
Add `normalize` and `score` to `cli.ts`'s `main` dispatch. Both **read the JSON payload from stdin** (not a flag — the existing `--json` is an *output* toggle and must keep that meaning) and **print JSON to stdout**. `normalize` → canonical items / `{ok:false}` error entries. `score``triage` with `--mode`/`--threshold`, prints `{kept, dropped}`. Exit **2** on a malformed invocation (unparseable stdin, missing required flag) via the existing `usage()` path; **0** otherwise. Write `tests/cli.test.ts` (subprocess: spawn `node --import tsx src/cli.ts <sub>` with a piped stdin payload) covering a happy path + an exit-2 bad-invocation for each new subcommand.
## Step 5 — wire `trend-spotter.md` (prose pointer)
Replace the L124-137 "score 5 dims, take the weighted composite, apply the bands yourself" instruction with: the agent supplies the five 110 judgment scores, then pipes them to **`scripts/trends/src/cli.ts score`** (the deterministic owner of composite + bands + threshold). The replacement prose **must contain the literal `src/cli.ts score`** (the exact string Step 6's `grep -qF` matches). Keep it domain-general — no vendor/sector tokens (Section 17). The agent still owns mode selection and the qualitative scoring.
## Step 6 — gate: floors + new unconditional section
In `scripts/test-runner.sh`:
- Bump `TRENDS_TESTS_FLOOR` 24 → **live recount** after Steps 14 (24 + new item/score/cli cases). It **stays inside** the `if [ -x scripts/trends/node_modules/.bin/tsx ]` deps guard (conditional — do not hoist it out; that would break fresh-clone safety).
- Add **Section 16g** (label it 16g; place it **after Section 17 / before Section 18**, since Section 18 anti-erosion must run last). Three **unconditional**, deps-absent-safe checks (pure `grep`, no `tsx`): (1) `score.ts` encodes both `kortform` and `long-form` weight-sets; (2) `grep -qF "src/cli.ts score" agents/trend-spotter.md`; (3) a non-vacuity self-test for those greps (house pattern, per Sections 16c17).
- Bump `ASSERT_BASELINE_FLOOR` 84 → **live recount** (= 84 + the count of new unconditional `pass`/`fail` emitters in 16g; expected ~87 with the self-test, **recounted at land, not pinned**). Update the section-header enumeration comment.
## Step 7 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run (verified invocation form, not `npm run start`):
`echo '<3-item sample>' | node --import tsx src/cli.ts normalize` and
`echo '<scored sample>' | node --import tsx src/cli.ts score --mode kortform --threshold 4.0`;
confirm by hand that one ≥4.0 item is kept (correct band/action) and one <4.0 is dropped. Run full `bash scripts/test-runner.sh``FAIL=0`.
## Step 8 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, gate total). Commit order (house style: `feat … [skip-docs]` for code; plain for docs): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r1.md` (no suffix, tracked); **(2)** code commit `scripts/trends/*` + `agents/trend-spotter.md` + `scripts/test-runner.sh` with `[skip-docs]`. Check the push window (`date '+%u %H:%M'`); `origin` is the PUBLIC `open/` remote → **confirm with operator before push**. No version bump (additive; `v0.5.2` dev).
## Verification (testable)
| SC | Check | Command | Expected |
|---|---|---|---|
| — | RED proof | `(cd scripts/trends && npm test)` after Step 1 | new cases fail on assertion (logic-RED), not module-not-found |
| SC1 | normalize | `npm test` (item.test.ts) | required-field `{ok:false}` + dedupe + publishedAt + batch-partition cases green; no `id` on item |
| SC2 | composite | `npm test` (score.test.ts) | all-tens=10.0 both modes; `{10,8,6,4,2}`=7.0 both modes; pinned weights; range guard throws |
| SC3 | bands | `npm test` (score.test.ts) | 8.0/6.0/4.0/2.0 boundaries → correct priority + exact SSOT action string |
| SC4 | triage | `npm test` (score.test.ts) | kept ≥ threshold ranked desc + annotated; dropped below |
| SC5 | CLI | `npm test` (cli.test.ts) + manual stdin run | JSON out; exit 2 malformed invocation / 0 well-formed |
| SC6 | gate | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ new floor; ASSERT floor = live recount |
| SC7 | wiring + de-niche | Section 16g greps + Section 17 | `src/cli.ts score` present in trend-spotter.md; no vendor/sector tokens; counts 19/29/27 |
## Risks
- **R1 — SSOT/code drift.** Weights, band thresholds, AND the ten action strings now live in both `trend-scoring-modes.md` and `score.ts`. *Mitigated:* `score.test.ts` pins all three (weights + Σ=1.0 + thresholds + exact action strings) against the SSOT values, with an SSOT-path comment naming the markdown as the human source. (A markdown-table-parsing cross-check is deferred — out of scope.)
- **R2 — `publishedAt` carried but not persisted; `capturedAt` not on the item.** Could read as a dangling field. *Mitigated:* explicit comment (source publish-date, forward-compat for B4) + the brief's non-goal; the store bridge (capturedAt injection) is explicitly R2; the scorer does not depend on either.
- **R3 — editing `trend-spotter.md` could trip the de-niche guard.** *Mitigated:* Section 17 runs in the gate; replacement prose is pillar-driven and vendor/sector-free.
- **R4 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* the three new checks are pure `grep`/self-test on tracked source files (no `tsx`), so they are unconditional and safe; the arithmetic proof stays inside the deps-gated suite; `TRENDS_TESTS_FLOOR` stays inside the deps guard.
- **R5 — default threshold (4.0) may not match operator intent.** *Mitigated:* single config param; brief open question #1 settles it at the go-gate.
- **R6 — CLI `--json` semantic collision.** *Mitigated:* new subcommands take payload via **stdin**, leaving `--json` as the existing output toggle; cli.test.ts encodes the stdin contract.
## Plan-critic — folded
plan-critic returned **REVISE** (1 blocker, 6 majors, 4 minors); brief-reviewer **PROCEED_WITH_RISKS**; scope-guardian **ALIGNED**. Resolution, each verified against live code:
- **[BLOCKER] `TrendInput` shape mismatch** (`store.ts:26-33` requires `capturedAt`, no `publishedAt`). ✅ Step 2 no longer claims a direct map; the item→store bridge is deferred to R2; envelope carries no `id`.
- **[MAJOR] gate-section placement / Section-18-last** ✅ Step 6 pins **16g, before Section 18**.
- **[MAJOR] `ASSERT_BASELINE_FLOOR` hard-pinned 86** ✅ Step 6 now **live recount** (~+3 with the house self-test), not pinned.
- **[MAJOR] `TRENDS_TESTS_FLOOR` could be hoisted out of the deps guard** ✅ Step 6 states it stays conditional.
- **[MAJOR] CLI `--json` input/output overload** (`cli.ts:79` output toggle) ✅ Step 4 reads payload from **stdin**.
- **[MAJOR] band action-string drift unguarded** ✅ Step 1/3 + R1 pin the thresholds + action strings.
- **[MAJOR] Step 5/6 grep literal unpinned** ✅ pinned to `src/cli.ts score` in both steps.
- **[MINOR] RED-stub strategy** ✅ Step 1 splits stub by assertion type. **[MINOR] `npm run start` unverified** ✅ Step 7 uses `node --import tsx src/cli.ts`. **[MINOR] commit grouping** ✅ Step 8 fixes order. **[MINOR] empty folded sections** ✅ filled.
- **[scope-guardian MINOR] STATE.md + `config/trends-sources.template.md`** ✅ STATE.md added as a land-bookkeeping row; template added to the scope fence.
**scope-guardian — ALIGNED:** every SC1SC7 traces to a step; zero scope creep; every NON-goal respected.