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:
parent
c40b937856
commit
b185db9a12
10 changed files with 661 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue