feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]

The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 01:08:43 +02:00
commit b185db9a12
10 changed files with 661 additions and 44 deletions

View file

@ -14,6 +14,9 @@ import {
queryByTopic,
history,
newestCaptureDate,
effectiveStatus,
setStatus,
markSurfaced,
} from "../src/store.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendStore } from "../src/types.js";
@ -307,7 +310,7 @@ describe("trends store", () => {
assert.equal("score" in res.store.trends[0], false);
});
test("RED: re-capture keeps the first sighting's score (no overwrite), unions topics", () => {
test("re-capture REFRESHES the score (last-wins, RE-R3b reverses R3a's D3), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
@ -333,7 +336,7 @@ describe("trends store", () => {
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.deepEqual(res2.store.trends[0].score, kortScore, "first-sight score kept (D3)");
assert.deepEqual(res2.store.trends[0].score, lowerScore, "score refreshed last-wins (RE-R3b reverses R3a's D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
});
@ -567,8 +570,9 @@ describe("trends store", () => {
}
};
// ── genuinely RED: pre-bump SCHEMA_VERSION=2 → loadStore(v2).schemaVersion===2 ≠ 3 ──
test("RED: a v2 store (no score) loads stamped as v3, records intact, no score invented", () => {
// RE-R3a v2→v3 block, reconciled to the v4 bump (RE-R3b): the stamped-version assertions track
// SCHEMA_VERSION (a v2 store now migrates to the current version), the v2 INPUT fixture stays literal.
test("a v2 store (no score) loads stamped as the current version, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
@ -585,7 +589,7 @@ describe("trends store", () => {
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3");
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v2 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
@ -595,15 +599,15 @@ describe("trends store", () => {
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:3 to disk", () => {
test("round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 3);
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("RED: a v3 store with score on records loads idempotent", () => {
test("a v3 store with score migrates to the current version, score preserved", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
@ -625,7 +629,7 @@ describe("trends store", () => {
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
@ -664,4 +668,187 @@ describe("trends store", () => {
});
});
});
// ── RE-R3b: re-score on re-capture (last-wins; A2) ──
describe("addTrend — re-score on re-capture (RE-R3b)", () => {
const mkScore = (composite: number, priority: string, timing = 5) => ({
mode: "kortform",
dimensions: { pillar: 5, audience: 5, timing, angle: 5, authority: 5 },
composite,
priority,
});
const seed = (score?: unknown) => ({
title: "Re-scored trend",
url: "https://example.com/rs",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
...(score !== undefined ? { score } : {}),
});
test("RED: a duplicate with a DIFFERENT score replaces the stored score (last-wins), merged:true", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, {
...seed(mkScore(4.0, "Medium", 3)),
capturedAt: "2026-06-08",
topics: ["ai", "rag"],
});
assert.equal(res.added, false);
assert.equal(res.merged, true, "a changed score (or new topics) → merged:true");
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score must be the fresh one");
assert.equal(res.store.trends[0].source, "tavily", "provenance source unchanged");
assert.equal(res.store.trends[0].capturedAt, "2026-06-01", "provenance capturedAt unchanged (first-sight)");
assert.deepEqual([...res.store.trends[0].topics].sort(), ["ai", "rag"], "topics still unioned");
});
test("RED: a re-capture with a BYTE-IDENTICAL score and no new topics → merged:false (no false-merge)", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(mkScore(8.1, "Immediate", 9)), capturedAt: "2026-06-09" });
assert.equal(res.added, false);
assert.equal(res.merged, false, "identical score + same topics → not a merge");
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9));
});
test("RED: a duplicate with NO score leaves the stored score unchanged", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(), capturedAt: "2026-06-10" });
assert.equal(res.added, false);
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9), "no input score → keep the stored one");
});
test("RED: re-scoring an ACTED trend updates the score but never resets status/surfacedCount", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
// Simulate a handled, surfaced record (status/surfacedCount are set by act/markSurfaced, not addTrend).
(store.trends[0] as Record<string, unknown>).status = "acted";
(store.trends[0] as Record<string, unknown>).surfacedCount = 3;
const res = addTrend(store, { ...seed(mkScore(4.0, "Medium", 3)), capturedAt: "2026-06-11" });
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score refreshed");
assert.equal((res.store.trends[0] as Record<string, unknown>).status, "acted", "status must NOT reset on re-score");
assert.equal((res.store.trends[0] as Record<string, unknown>).surfacedCount, 3, "surfacedCount must NOT change on re-score");
});
});
// ── RE-R3b: schema migration v3→v4 (additive-optional lifecycle fields) ──
describe("schema migration (RE-R3b / lifecycle v3→v4)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const scored = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
// ── genuinely RED while SCHEMA_VERSION=3: loadStore(v3).schemaVersion===3 ≠ 4 (hard-4 device) ──
test("RED: a v3 store (no lifecycle fields) loads stamped as v4, records intact, no field invented", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{ id: "x", title: "Scored", url: "https://example.com/s", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], score: scored },
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v3 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.deepEqual(s.trends[0].score, scored, "score intact");
assert.equal("status" in s.trends[0], false, "migration must not invent a status");
assert.equal("surfacedCount" in s.trends[0], false, "migration must not invent a surfacedCount");
assert.equal("lastSurfacedAt" in s.trends[0], false, "migration must not invent a lastSurfacedAt");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:4 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 3, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v4 store with lifecycle fields loads idempotent", () => {
const v4 = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
trends: [
{ id: "y", title: "Handled", url: "https://example.com/h", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "acted", surfacedCount: 3, lastSurfacedAt: "2026-06-25" },
],
});
withFixture(v4, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].status, "acted");
assert.equal(s.trends[0].surfacedCount, 3);
assert.equal(s.trends[0].lastSurfacedAt, "2026-06-25");
});
});
test("a v4 store's lifecycle fields survive load → save → load (field preservation)", () => {
const v4 = JSON.stringify({
schemaVersion: 4,
trends: [
{ id: "z", title: "Persist", url: "https://example.com/p", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "skipped", surfacedCount: 2, lastSurfacedAt: "2026-06-24" },
],
});
withFixture(v4, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.equal(second.trends[0].status, "skipped");
assert.equal(second.trends[0].surfacedCount, 2);
assert.equal(second.trends[0].lastSurfacedAt, "2026-06-24");
});
});
});
// ── RE-R3b: lifecycle functions (Phase B — RED against the stubs) ──
describe("lifecycle functions: effectiveStatus / setStatus / markSurfaced (RE-R3b)", () => {
const seedStore = () =>
addTrend(
addTrend(emptyStore(), { title: "A", url: "https://e/a", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"] }).store,
{ title: "B", url: "https://e/b", source: "tavily", capturedAt: "2026-06-02", topics: ["gov"] },
).store;
test("RED: effectiveStatus is the stored status, or 'new' when absent", () => {
assert.equal(effectiveStatus({ status: "acted" } as TrendRecord), "acted");
assert.equal(effectiveStatus({} as TrendRecord), "new");
});
test("RED: setStatus sets a present record's status (found:true); an absent id → found:false", () => {
const store = seedStore();
const idA = store.trends[0].id;
const res = setStatus(store, idA, "acted");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "acted");
assert.equal(setStatus(store, "missing-id", "skipped").found, false, "absent id → found:false (no throw)");
});
test("RED: markSurfaced increments + sets lastSurfacedAt; per-day idempotent; later day re-increments", () => {
const store = seedStore();
const idA = store.trends[0].id;
const r1 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r1.marked, 1);
assert.equal(store.trends[0].surfacedCount, 1);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-25");
assert.equal("surfacedCount" in store.trends[1], false, "an id not in the set is untouched");
const r2 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r2.marked, 0, "same-day re-mark is idempotent");
assert.equal(store.trends[0].surfacedCount, 1);
const r3 = markSurfaced(store, [idA], "2026-06-26");
assert.equal(r3.marked, 1, "a later day increments again");
assert.equal(store.trends[0].surfacedCount, 2);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-26");
});
});
});