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

@ -18,7 +18,7 @@ import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js";
@ -41,7 +41,7 @@ export interface AddResult {
store: TrendStore;
/** true iff a new trend was appended (false = duplicate title+url). */
added: boolean;
/** true iff an existing duplicate gained new topic tags via union. */
/** true iff an existing duplicate was mutated — topic tags unioned and/or its score refreshed (RE-R3b). */
merged: boolean;
}
@ -79,11 +79,12 @@ export function emptyStore(): TrendStore {
export function loadStore(path: string): TrendStore {
if (!existsSync(path)) return emptyStore();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3 are
// all purely additive-optional (an old record is already a valid v3 record that simply
// lacks the optional publishedAt [v2] / score [v3]), so the migration is the version
// stamp alone — records pass through untouched (lossless + idempotent for any
// well-formed store; a new optional field survives JSON.stringify on resave).
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3→v4 are
// all purely additive-optional (an old record is already a valid v4 record that simply
// lacks the optional publishedAt [v2] / score [v3] / status+surfacedCount+lastSurfacedAt
// [v4]), so the migration is the version stamp alone — records pass through untouched
// (lossless + idempotent for any well-formed store; new optional fields survive
// JSON.stringify on resave).
// A string / NaN / absent version coerces to the current version (never crashes); the
// non-array `trends` coercion below is unchanged and out of the losslessness claim.
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
@ -127,7 +128,16 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
if (existing) {
const { topics, changed } = unionTopics(existing.topics, input.topics);
existing.topics = topics;
return { store, added: false, merged: changed };
let mutated = changed;
// RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh
// judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge
// on an identical re-score. Provenance (source/capturedAt/publishedAt) and lifecycle
// (status/surfacedCount/lastSurfacedAt) are untouched.
if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) {
existing.score = input.score;
mutated = true;
}
return { store, added: false, merged: mutated };
}
const trend: TrendRecord = {
id,
@ -144,6 +154,53 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
return { store, added: true, merged: false };
}
// ── RE-R3b lifecycle helpers (the trend's life AFTER first capture) ──
/** The record's lifecycle status, defaulting absent → "new" (the single reader of that convention). Pure. */
export function effectiveStatus(t: TrendRecord): TrendStatus {
return t.status ?? "new";
}
/**
* Set a trend's lifecycle status by id (the act/skip/reset verbs). Mutates the matched
* record in place and returns the same store; an unknown id is a no-op reported as
* { found: false } (never throws). Pure (no fs).
*/
export function setStatus(
store: TrendStore,
id: string,
status: TrendStatus,
): { store: TrendStore; found: boolean } {
const t = store.trends.find((x) => x.id === id);
if (!t) return { store, found: false };
t.status = status;
return { store, found: true };
}
/**
* Record that the given trends were surfaced in a brief on `today` (the seen-log, B4).
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the
* same day's brief does not double-count. Increments surfacedCount (absent 0) and stamps
* lastSurfacedAt; returns how many records were actually incremented. Pure `today` is
* injected by the caller (the CLI edge), like the store's capturedAt.
*/
export function markSurfaced(
store: TrendStore,
ids: string[],
today: string,
): { store: TrendStore; marked: number } {
const wanted = new Set(ids);
let marked = 0;
for (const t of store.trends) {
if (!wanted.has(t.id)) continue;
if (t.lastSurfacedAt === today) continue; // per-day idempotent
t.surfacedCount = (t.surfacedCount ?? 0) + 1;
t.lastSurfacedAt = today;
marked++;
}
return { store, marked };
}
/**
* Trends whose topics overlap the query, ranked by overlap (desc) then recency
* (capturedAt desc). Topic matching is case-insensitive. Non-matches are