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:
parent
5aa7187243
commit
2a8459c674
8 changed files with 474 additions and 43 deletions
|
|
@ -22,6 +22,25 @@ import type { TrendStore, TrendRecord } from "./types.js";
|
|||
/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */
|
||||
export const BRIEF_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND
|
||||
* never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior
|
||||
* days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt
|
||||
* -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go
|
||||
* stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted.
|
||||
*/
|
||||
export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated";
|
||||
export interface TemporalSignal {
|
||||
/** recent AND never surfaced on a prior day — "you'd be early". */
|
||||
firstMover: boolean;
|
||||
/** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */
|
||||
surfacings: number;
|
||||
/** the ordinal class (best -> worst opportunity). */
|
||||
tier: TemporalTier;
|
||||
/** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/** One ranked trend in the brief, with its pillar overlap + freshness. */
|
||||
export interface BriefEntry {
|
||||
trend: TrendRecord;
|
||||
|
|
@ -33,6 +52,8 @@ export interface BriefEntry {
|
|||
effectiveDate: string;
|
||||
/** Whole days from effectiveDate to the injected `today` (negative if future). */
|
||||
ageDays: number;
|
||||
/** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */
|
||||
temporal: TemporalSignal;
|
||||
}
|
||||
|
||||
/** The full ranking the brief renders from. */
|
||||
|
|
@ -51,6 +72,10 @@ export interface BriefRanking {
|
|||
export interface RankOptions {
|
||||
/** Freshness window in days (effectiveDate within N days of today). Default 7. */
|
||||
freshDays?: number;
|
||||
/** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */
|
||||
firstMoverDays?: number;
|
||||
/** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */
|
||||
saturationAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +87,32 @@ function ageDaysBetween(effectiveDate: string, today: string): number {
|
|||
return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age +
|
||||
* self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a
|
||||
* prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated =
|
||||
* surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal
|
||||
* baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated.
|
||||
*/
|
||||
export function temporalSignal(
|
||||
ageDays: number,
|
||||
surfacedCount: number | undefined,
|
||||
opts: { firstMoverDays: number; saturationAt: number },
|
||||
): TemporalSignal {
|
||||
const surfacings = surfacedCount ?? 0;
|
||||
const at = Math.max(1, opts.saturationAt);
|
||||
const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0;
|
||||
const tier: TemporalTier = firstMover
|
||||
? "first-mover"
|
||||
: surfacings >= at
|
||||
? "saturated"
|
||||
: surfacings >= 1
|
||||
? "warming"
|
||||
: "neutral";
|
||||
const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0;
|
||||
return { tier, firstMover, surfacings, rank };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the store against the user's pillars. Off-pillar trends (overlap 0) are
|
||||
* dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older
|
||||
|
|
@ -76,6 +127,8 @@ export function rankForBrief(
|
|||
opts: RankOptions = {},
|
||||
): BriefRanking {
|
||||
const freshDays = opts.freshDays ?? 7;
|
||||
const firstMoverDays = opts.firstMoverDays ?? 2;
|
||||
const saturationAt = opts.saturationAt ?? 3;
|
||||
const wantedLower = pillars.map((p) => p.toLowerCase());
|
||||
|
||||
const entries: BriefEntry[] = [];
|
||||
|
|
@ -90,18 +143,38 @@ export function rankForBrief(
|
|||
const overlap = matchedPillars.length;
|
||||
if (overlap === 0) continue; // off-pillar noise
|
||||
const effectiveDate = trend.publishedAt ?? trend.capturedAt;
|
||||
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
|
||||
const ageDays = ageDaysBetween(effectiveDate, today);
|
||||
// Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI
|
||||
// records today's surfacing, so on the first run of a day surfacedCount is already prior-day —
|
||||
// but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today),
|
||||
// so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes
|
||||
// "first-mover" (prior surfacings === 0) stable across the render → mark step.
|
||||
const surfaced = trend.surfacedCount ?? 0;
|
||||
const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced;
|
||||
entries.push({
|
||||
trend,
|
||||
overlap,
|
||||
matchedPillars,
|
||||
effectiveDate,
|
||||
ageDays,
|
||||
temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }),
|
||||
});
|
||||
}
|
||||
|
||||
// Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance
|
||||
// composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted
|
||||
// sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts
|
||||
// cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are
|
||||
// unchanged; composite only re-orders WITHIN a bucket. The existing overlap → effectiveDate
|
||||
// → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id).
|
||||
// unchanged; composite only re-orders WITHIN a bucket.
|
||||
// RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and
|
||||
// BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency
|
||||
// (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing
|
||||
// overlap → temporal → effectiveDate → title → url chain still gives a total order (the
|
||||
// (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk).
|
||||
const cmp = (a: BriefEntry, b: BriefEntry): number =>
|
||||
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
|
||||
b.overlap - a.overlap ||
|
||||
b.temporal.rank - a.temporal.rank ||
|
||||
b.effectiveDate.localeCompare(a.effectiveDate) ||
|
||||
a.trend.title.localeCompare(b.trend.title) ||
|
||||
a.trend.url.localeCompare(b.trend.url);
|
||||
|
|
@ -135,7 +208,9 @@ export function briefSummary(ranking: BriefRanking): string {
|
|||
const pillar = top.matchedPillars[0];
|
||||
// Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean.
|
||||
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band} · ${top.ageDays}d).`;
|
||||
// RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early").
|
||||
const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).`;
|
||||
}
|
||||
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
|
||||
}
|
||||
|
|
@ -147,19 +222,24 @@ function scoreToken(e: BriefEntry): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* ` · sett Nx` when surfacedCount>=2, else "" — the seen-log saturation HINT (RE-R3b). The count
|
||||
* is PRIOR-DAY: the brief renders before the CLI records today's surfacing, so it reads "shown on
|
||||
* N prior distinct days". Not the saturation SCORING of slice (b) nor the day-over-day diff of (d).
|
||||
* The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set.
|
||||
* `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx`
|
||||
* ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still
|
||||
* demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "".
|
||||
* The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing).
|
||||
*/
|
||||
function surfacedToken(e: BriefEntry): string {
|
||||
const c = e.trend.surfacedCount;
|
||||
return c && c >= 2 ? ` · sett ${c}x` : "";
|
||||
function temporalToken(e: BriefEntry): string {
|
||||
const t = e.temporal;
|
||||
if (t.tier === "first-mover") return " · 🥇 først ute";
|
||||
if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`;
|
||||
if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
||||
const lines = [
|
||||
`### ${n}. ${e.trend.title}`,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||||
];
|
||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||
lines.push(`- 🔗 ${e.trend.url}`);
|
||||
|
|
@ -168,7 +248,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
|
|||
}
|
||||
|
||||
function renderBulletEntry(e: BriefEntry): string {
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +264,7 @@ export function renderBrief(ranking: BriefRanking): string {
|
|||
lines.push(`date: ${ranking.today}`);
|
||||
lines.push(`summary: ${briefSummary(ranking)}`);
|
||||
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
||||
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue