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

13 KiB
Raw Permalink Blame History

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 NEWTrendItem ingress envelope + normalizeItem / normalizeItems (pure, validating; no id) SC1
scripts/trends/src/score.ts NEWScoreMode, 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 EDITTRENDS_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 addTrendtrendId; 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. scoretriage 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.shFAIL=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.