feat(linkedin-studio): RE-R3d — temporal overlay (first-mover + saturation) [skip-docs]

R3 slice (b): the rest of hull #3. The morning brief now reads the temporal axis
the R3b seen-log records but the ranking ignored. Two DERIVED signals, computed at
brief time from already-persisted fields (publishedAt/capturedAt -> ageDays,
surfacedCount), never stored:

- first-mover: recent (ageDays <= --first-mover-days, default 2) AND never surfaced
  on a prior day -> ranked up, badge "first ute". Future-dated (ageDays<0) excluded.
- saturation: surfaced on >= --saturation-at (default 3) prior days -> ranked down,
  badge "mettet (Nx)". Self-surfacing (our seen-log), not market coverage.
- warming (1..at-1) keeps the R3b "sett Nx" badge but only at >=2 (contract intact);
  neutral carries no badge.

SB1 derived (no schema bump: SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1 untouched).
SB2 the R3a relevance composite stays the PRIMARY sort key; the temporal rank is a
new cmp key after pillar-overlap, before effectiveDate -> re-orders only WITHIN a
(composite, overlap) tier. temporalSignal is pure (saturationAt clamped >=1).

Prior-day surfacings exclude today (via lastSurfacedAt), so a same-day re-render is
byte-identical (caught by the R3c run-daily SC7 regression; fixes a latent R3b
prior-day imprecision too). brief CLI gains --first-mover-days / --saturation-at;
schedule untouched (nightly uses defaults).

Wiring: trend-spotter.md (prose), trend-scoring-modes.md (one-line consumer note),
README (## Temporal overlay), gate Section 16m (+6 unconditional -> ASSERT floor
111->117), TRENDS_TESTS_FLOOR 192->216. Counts 29/19/27 unchanged. Zero new files.

Gate: Passed 132 / Failed 0; trends 216/216; hook suite 139/139 untouched.

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 12:10:42 +02:00
commit 2a8459c674
8 changed files with 474 additions and 43 deletions

View file

@ -11,7 +11,8 @@
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
* [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
*
@ -22,7 +23,9 @@
* one owner) first-sight on the record so the morning brief ranks on it and reasons over
* accumulated history via `query`/`list`. `brief` (RE-R2b)
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
* SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated
* down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`.
* `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status
* (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
@ -107,7 +110,7 @@ function usage(msg: string): never {
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
);
process.exit(2);
@ -315,12 +318,25 @@ function main(): void {
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n;
}
// RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults).
let firstMoverDays = 2;
if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") {
const n = Number.parseInt(flags["first-mover-days"], 10);
if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer");
firstMoverDays = n;
}
let saturationAt = 3;
if (flags["saturation-at"] && flags["saturation-at"] !== "true") {
const n = Number.parseInt(flags["saturation-at"], 10);
if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer");
saturationAt = n;
}
// A bare `--out` yields the string "true" (parseFlags); the guard falls back to
// defaultBriefDir() so it never writes to ./true.
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir();
const day = today(); // one wall-clock read for both the ranking and the filename
const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
const ranking = rankForBrief(store, pillars, day, { freshDays });
const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt });
const md = renderBrief(ranking);
const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true });