diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index 7fa1705..b44a872 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -348,6 +348,11 @@ not-yet-surfaced trend is ranked up as a **first-mover** (`· 🥇 først ute`) one is ranked down as **saturated** (`· 🔁 mettet`) — computed at render time from the publish/capture dates + the seen-log, with no new capture step. Tune with `--first-mover-days N` / `--saturation-at N`. +Each brief also **records the trend ids it showed** (frontmatter `surfaced:`) and renders a +**day-over-day diff** — a `## 🆕 Nytt siden sist` section listing what is new since the most recent +prior brief (plus a ` N nye siden sist` marker on the one-line summary) — no new capture step; the +polling/capture path above is unchanged (RE-R3e). + The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic, from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 26a26bf..febef8b 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -61,7 +61,10 @@ # wiring guard (RE-R3d: scripts/trends/src/brief.ts has 'export function temporalSignal' AND the cmp # key 'b.temporal.rank' AND the '"first-mover"' tier, scripts/trends/src/cli.ts exposes the # 'first-mover-days' AND 'saturation-at' brief flags, with a non-vacuity self-test) in Section 16m; -# the assertion-count anti-erosion floor (SC6) in Section 18. All +# the trends-brief-history/diff wiring guard (RE-R3e: scripts/trends/src/brief.ts has +# 'export function diffSurfaced' AND 'parseSurfacedFrontmatter' AND the 'Nytt siden sist' section AND +# the 'surfaced: ' frontmatter emit, scripts/trends/src/cli.ts wires 'selectPriorBriefFile', with a +# non-vacuity self-test) in Section 16n; the assertion-count anti-erosion floor (SC6) in Section 18. All # are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh @@ -713,7 +716,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - TRENDS_TESTS_FLOOR=216 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) + TRENDS_TESTS_FLOOR=245 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) + RE-R3e: brief +27, cli +2 (brief history + diff) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else @@ -1440,6 +1443,72 @@ fi echo "" +# --- Section 16n: Trends Brief History / Diff (research-engine RE-R3e) --- +echo "--- Trends Brief History / Diff ---" + +# RE-R3e adds the day-over-day brief diff: a 'surfaced:' frontmatter record + three pure helpers +# (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) in brief.ts, wired at the cli.ts +# edge, rendering a 'Nytt siden sist' section. Five literals must hold, grepped EXACT (grep -F), +# deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u — the section's emoji is NEVER +# grepped, only its ASCII tail 'Nytt siden sist'): +# (1) brief.ts exports the diff, by 'export function diffSurfaced'; +# (2) brief.ts parses the prior record, by 'parseSurfacedFrontmatter'; +# (3) brief.ts renders the section, by 'Nytt siden sist' (the ASCII header tail); +# (4) cli.ts wires prior-discovery, by 'selectPriorBriefFile'; +# (5) brief.ts emits the membership record, by 'surfaced: ' (the frontmatter line). +# Non-vacuity self-test mirrors Section 16m. Placed after Section 16m / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +DIFF_FN_LIT='export function diffSurfaced' +DIFF_PARSE_LIT='parseSurfacedFrontmatter' +DIFF_SECTION_LIT='Nytt siden sist' +DIFF_SELECT_LIT='selectPriorBriefFile' +DIFF_SURFACED_LIT='surfaced: ' + +I16N_SELFTEST_OK=1 +if ! echo 'a wired diff declares: export function diffSurfaced(currentIds, priorIds)' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-diff probe was not detected" +fi +if echo 'an unwired module computes no day-over-day diff at all' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief-diff pointer" +fi +if [ "$I16N_SELFTEST_OK" -eq 1 ]; then + pass "trends-brief-diff self-test: the diff pointer is detected, the no-diff form rejected" +else + fail "trends-brief-diff self-test failed — the brief-diff lint is vacuous or over-eager" +fi + +if grep -qF "$DIFF_FN_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts exports the day-over-day diff ('$DIFF_FN_LIT')" +else + fail "brief.ts has no diff — add '$DIFF_FN_LIT' (RE-R3e brief history)" +fi + +if grep -qF "$DIFF_PARSE_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts parses the prior surfaced record ('$DIFF_PARSE_LIT')" +else + fail "brief.ts cannot read a prior brief — add '$DIFF_PARSE_LIT' (RE-R3e)" +fi + +if grep -qF "$DIFF_SECTION_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts renders the diff section ('$DIFF_SECTION_LIT')" +else + fail "brief.ts has no diff section — add '$DIFF_SECTION_LIT' (RE-R3e section header)" +fi + +if grep -qF "$DIFF_SELECT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts wires prior-brief discovery ('$DIFF_SELECT_LIT')" +else + fail "cli.ts does not discover the prior brief — add '$DIFF_SELECT_LIT' (RE-R3e wiring)" +fi + +if grep -qF "$DIFF_SURFACED_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts emits the surfaced: membership record ('$DIFF_SURFACED_LIT')" +else + fail "brief.ts records no membership — add the 'surfaced:' frontmatter line (RE-R3e)" +fi + +echo "" + # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count @@ -1465,12 +1534,15 @@ echo "" # run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111; +6 for RE-R3d's six # UNCONDITIONAL Section-16m checks (temporal self-test + brief.ts temporalSignal grep + brief.ts # b.temporal.rank cmp grep + brief.ts "first-mover" tier grep + cli.ts first-mover-days flag grep + -# cli.ts saturation-at flag grep) = 117. +# cli.ts saturation-at flag grep) = 117; +6 for RE-R3e's six UNCONDITIONAL Section-16n checks +# (brief-diff self-test + brief.ts diffSurfaced grep + brief.ts parseSurfacedFrontmatter grep + +# brief.ts 'Nytt siden sist' section grep + cli.ts selectPriorBriefFile wiring grep + brief.ts +# 'surfaced: ' frontmatter grep) = 123. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=117 +ASSERT_BASELINE_FLOOR=123 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index 9284d8f..3b9fa30 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -173,6 +173,31 @@ tier, never overriding relevance. Prior-day surfacings exclude today (via `lastS same-day re-render is byte-identical. Tune per run with `--first-mover-days N` / `--saturation-at N` (the scheduled nightly run uses the defaults). +## Brief history + diff (RE-R3e) + +Each brief records the trend ids it showed into its own frontmatter — one `surfaced: ` line += `surfacedIds(ranking)` (the cohort the brief surfaced). This bumps the **artifact** schema +`BRIEF_SCHEMA_VERSION` **1 → 2**; the store's `SCHEMA_VERSION` stays **4** (no store field — the +membership lives in the artifact, the diff is derived at the CLI edge). + +When a brief is written, the CLI discovers the most recent **prior** dated file +(`selectPriorBriefFile`: the greatest `YYYY-MM-DD.md` strictly `< today`, so a same-day re-run diffs +against the true previous day and stays byte-identical), parses its `surfaced:` line +(`parseSurfacedFrontmatter`, degrading to "empty prior" on any absent/blank/malformed/pre-R3e file), +and computes the symmetric set difference (`diffSurfaced`): + +- **added** — in today, not in the prior brief: the headline "what's new", rendered with titles + resolved from the ranking under a `## 🆕 Nytt siden sist ()` section that **leads** the + ranked list. +- **carried** / **dropped** — in both / in the prior only: a one-line count (`N båret over, M ikke + vist i dag`). Framed "ikke vist i dag" (not "resolved") — a count cannot prove why a trend left. + +A ` N nye siden sist.` marker is appended to the one-line `summary:` the SessionStart hook surfaces, +so the delta shows **without opening the file** (no hook change). The three helpers are **pure** +(string/array in, value out); the directory + file reads live at the `cli.ts` edge, so `brief.ts` +stays fs-free. `--no-mark` is unaffected (it governs only the store seen-log; `surfaced:` always +records what the brief showed). + ## Tests ```bash diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index 14e9297..3bbe4d8 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -20,7 +20,7 @@ import { defaultStorePath, effectiveStatus } from "./store.js"; 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; +export const BRIEF_SCHEMA_VERSION = 2; /** * The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND @@ -201,7 +201,12 @@ export function rankForBrief( * One line, no embedded double-quote, no newline, so the hook's extractYaml regex * (^summary: *"?([^"\n]*)"?) captures it whole. */ -export function briefSummary(ranking: BriefRanking): string { +export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string { + // RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND + // something is new (suppressed on the first brief / when nothing changed). Digits + ASCII + // + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole. + const delta = + diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : ""; const fresh = ranking.totals.fresh; if (fresh > 0) { const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; @@ -210,9 +215,9 @@ export function briefSummary(ranking: BriefRanking): string { const band = top.trend.score ? ` · ${top.trend.score.priority}` : ""; // 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 `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`; } - return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`; + return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`; } /** ` · ()` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */ @@ -256,15 +261,21 @@ function renderBulletEntry(e: BriefEntry): string { * summary, store stats, ranking descriptor, schemaVersion) + a three-section body. * All three section headers are always emitted (stable structure → determinism). */ -export function renderBrief(ranking: BriefRanking): string { +export function renderBrief( + ranking: BriefRanking, + diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] }, +): string { const { totals } = ranking; const lines: string[] = []; lines.push("---"); lines.push(`date: ${ranking.today}`); - lines.push(`summary: ${briefSummary(ranking)}`); + lines.push(`summary: ${briefSummary(ranking, diff)}`); lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); 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`); + // RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always + // emitted (even blank for an empty store); independent of --no-mark (a property of the render). + lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`); lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); lines.push("---"); lines.push(""); @@ -275,6 +286,27 @@ export function renderBrief(ranking: BriefRanking): string { ); lines.push(""); + // RE-R3e: the day-over-day delta leads, then the full ranked list below. + lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); + if (diff.priorDate === null) { + lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); + } else if (diff.added.length === 0) { + lines.push( + `_Ingenting nytt siden ${diff.priorDate}._`, + `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, + "", + ); + } else { + const byId = new Map( + [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), + ); + for (const id of diff.added) { + const e = byId.get(id); + if (e) lines.push(renderBulletEntry(e)); + } + lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } + lines.push("## 🎯 Topp-treff (2+ pillarer)"); if (ranking.topMatches.length === 0) { lines.push("_Ingen i dag._", ""); @@ -306,6 +338,61 @@ export function surfacedIds(ranking: BriefRanking): string[] { return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id); } +/** + * RE-R3e — the day-over-day diff of a brief's surfaced cohort against the most recent + * prior brief. `priorDate` null ⇒ no prior (the first brief / a fresh data dir). + * `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set + * difference (an id is in exactly one), each order-stable. Pure: the id lists + the + * prior date are injected by the CLI edge (no fs/clock here — like `today`/`pillars`). + */ +export interface BriefDiff { + priorDate: string | null; + added: string[]; + carried: string[]; + dropped: string[]; +} + +/** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */ +export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff { + const prior = new Set(priorIds); + const cur = new Set(currentIds); + return { + priorDate, + added: currentIds.filter((id) => !prior.has(id)), + carried: currentIds.filter((id) => prior.has(id)), + dropped: priorIds.filter((id) => !cur.has(id)), + }; +} + +/** + * Extract a brief's `surfaced:` id list from its full text via one line-anchored regex + * (the hook's `extractYaml` idiom). Absent / blank / malformed → [] (a pre-R3e or + * hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex. + */ +export function parseSurfacedFrontmatter(md: string): string[] { + const m = md.match(/^surfaced: *([^\n]*)/m); + if (!m) return []; + return m[1] + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md` + * strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any + * future-dated file, so a same-day re-run diffs against the true previous day (determinism). + */ +export function selectPriorBriefFile(filenames: string[], today: string): string | null { + const todayFile = `${today}.md`; + return ( + filenames + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) + .sort() + .pop() ?? null + ); +} + /** * Default brief directory under the per-user data dir, DERIVED from * defaultStorePath() so root resolution lives in exactly one place: diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index c0001ee..d864419 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -48,7 +48,7 @@ * `schedule` adds no new exit code: an autonomy install never RUNS the system mutation. */ -import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -68,7 +68,16 @@ import type { TrendStatus } from "./types.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { triage } from "./score.js"; import type { ScoreMode } from "./score.js"; -import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js"; +import { + rankForBrief, + renderBrief, + briefSummary, + defaultBriefDir, + surfacedIds, + diffSurfaced, + parseSurfacedFrontmatter, + selectPriorBriefFile, +} from "./brief.js"; import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js"; import type { ScheduleSpec } from "./schedule.js"; @@ -337,7 +346,26 @@ function main(): void { 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, firstMoverDays, saturationAt }); - const md = renderBrief(ranking); + // RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort + // against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the + // empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free. + const todayIds = surfacedIds(ranking); + let priorIds: string[] = []; + let priorDate: string | null = null; + try { + if (existsSync(outDir)) { + const priorFile = selectPriorBriefFile(readdirSync(outDir), day); + if (priorFile) { + priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); + priorDate = priorFile.slice(0, 10); + } + } + } catch { + priorIds = []; + priorDate = null; + } + const diff = diffSurfaced(todayIds, priorIds, priorDate); + const md = renderBrief(ranking, diff); const path = join(outDir, `${day}.md`); mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, "utf8"); @@ -347,12 +375,33 @@ function main(): void { const mark = flags["no-mark"] !== "true"; const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, store); - const summary = briefSummary(ranking); // SAME source the frontmatter carries + const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker) if (asJson) { - console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary, marked }, null, 2)); + console.log( + JSON.stringify( + { + path, + date: ranking.today, + totals: ranking.totals, + summary, + marked, + diff: { + priorDate: diff.priorDate, + added: diff.added.length, + carried: diff.carried.length, + dropped: diff.dropped.length, + }, + }, + null, + 2, + ), + ); return; } - console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)`); + const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : ""; + console.log( + `Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`, + ); return; } diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts index 2b783d6..37108c9 100644 --- a/scripts/trends/tests/brief.test.ts +++ b/scripts/trends/tests/brief.test.ts @@ -8,6 +8,9 @@ import { briefSummary, defaultBriefDir, surfacedIds, + diffSurfaced, + parseSurfacedFrontmatter, + selectPriorBriefFile, temporalSignal, BRIEF_SCHEMA_VERSION, } from "../src/brief.js"; @@ -571,7 +574,7 @@ describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => { const before = t.score!.composite; rankForBrief(mkStore([t]), ["AI"], TODAY); assert.equal(t.score!.composite, before, "rankForBrief must not mutate the stored composite"); - assert.equal(BRIEF_SCHEMA_VERSION, 1); + assert.equal(BRIEF_SCHEMA_VERSION, 2); // RE-R3e: artifact frontmatter gained surfaced: (1->2); store SCHEMA_VERSION stays 4 assert.equal(SCHEMA_VERSION, 4); }); }); @@ -597,3 +600,135 @@ describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day assert.equal(e.temporal.surfacings, 1); }); }); + +// --- RE-R3e: brief history + day-over-day diff --- + +describe("diffSurfaced — partitions + order + empty prior (RE-R3e / SC1)", () => { + test("symmetric set difference, order-stable", () => { + const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25"); + assert.deepEqual(d, { priorDate: "2026-06-25", added: ["a"], carried: ["b", "c"], dropped: ["d"] }); + }); + test("empty prior ⇒ everything added, nothing dropped", () => { + const d = diffSurfaced(["a", "b"], [], null); + assert.deepEqual(d, { priorDate: null, added: ["a", "b"], carried: [], dropped: [] }); + }); + test("the three partitions are mutually disjoint (cross-partition exclusivity)", () => { + const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25"); + const all = [...d.added, ...d.carried, ...d.dropped]; + assert.equal(new Set(all).size, all.length, "no id appears in two buckets"); + }); + test("pure: same inputs → same output", () => { + assert.deepEqual(diffSurfaced(["x"], ["y"], "2026-06-20"), diffSurfaced(["x"], ["y"], "2026-06-20")); + }); +}); + +describe("parseSurfacedFrontmatter — read + degrade (RE-R3e / SC2)", () => { + const block = (surfaced: string) => + `---\ndate: 2026-06-26\nsummary: hi\nsurfaced: ${surfaced}\nstore: { trends: 3 }\nschemaVersion: 2\n---\nbody`; + test("parses a csv of ids", () => { + assert.deepEqual(parseSurfacedFrontmatter(block("1a2b,3c4d,5e6f")), ["1a2b", "3c4d", "5e6f"]); + }); + test("trims whitespace around ids", () => { + assert.deepEqual(parseSurfacedFrontmatter(block(" a , b ,c ")), ["a", "b", "c"]); + }); + test("a blank surfaced: line → []", () => { + assert.deepEqual(parseSurfacedFrontmatter(block("")), []); + }); + test("an absent surfaced: line (a pre-R3e brief) → []", () => { + assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: hi\nschemaVersion: 1\n---\nbody`), []); + }); + test("line-anchored: a summary: with commas does not leak in", () => { + assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: a,b,c\nschemaVersion: 1\n---`), []); + }); + test("never throws on malformed input", () => { + assert.doesNotThrow(() => parseSurfacedFrontmatter("not a brief at all")); + }); +}); + +describe("selectPriorBriefFile — strict-prior selection (RE-R3e / SC3)", () => { + const files = ["2026-06-24.md", "2026-06-25.md", "2026-06-26.md", "README.md", "2026-06-30.md"]; + test("greatest dated file strictly < today (excludes today + future, ignores non-dated)", () => { + assert.equal(selectPriorBriefFile(files, "2026-06-26"), "2026-06-25.md"); + }); + test("no file < today → null", () => { + assert.equal(selectPriorBriefFile(["2026-06-26.md", "2026-06-30.md"], "2026-06-26"), null); + }); + test("empty list → null", () => { + assert.equal(selectPriorBriefFile([], "2026-06-26"), null); + }); +}); + +describe("renderBrief/briefSummary — surfaced: + diff section + marker (RE-R3e / SC4–SC8)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(7.0, "High") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + const ids = surfacedIds(r); + const emptyDiff = { priorDate: null, added: [], carried: [], dropped: [] }; + + test("SC4: one surfaced: line = surfacedIds(r).join(','), before schemaVersion: 2", () => { + const md = renderBrief(r, emptyDiff); + const m = md.match(/^surfaced: (.*)$/m); + assert.ok(m, "a surfaced: frontmatter line exists"); + assert.equal(m![1], ids.join(",")); + assert.ok(md.indexOf("\nsurfaced:") < md.indexOf("\nschemaVersion:"), "surfaced: precedes schemaVersion:"); + assert.match(md, /\nschemaVersion: 2\n/); + }); + test("SC4: round-trips via parseSurfacedFrontmatter", () => { + assert.deepEqual(parseSurfacedFrontmatter(renderBrief(r, emptyDiff)), ids); + }); + test("SC4: an empty store → a blank surfaced: line", () => { + const empty = rankForBrief(mkStore([]), pillars, TODAY); + assert.match(renderBrief(empty, emptyDiff), /\nsurfaced: \n/); + }); + + test("SC5: section header present + precedes Topp-treff (delta leads)", () => { + const md = renderBrief(r, emptyDiff); + assert.ok(md.includes("## 🆕 Nytt siden sist"), "section header present"); + assert.ok(md.indexOf("## 🆕 Nytt siden sist") < md.indexOf("## 🎯 Topp-treff"), "delta precedes the ranked list"); + }); + test("SC5: first brief with added → 'Første brief — alt nedenfor er nytt'", () => { + assert.ok(renderBrief(r, { priorDate: null, added: ids, carried: [], dropped: [] }).includes("Første brief — alt nedenfor er nytt")); + }); + test("SC5: empty first brief → 'Første brief.'", () => { + assert.ok(renderBrief(r, emptyDiff).includes("_Første brief._")); + }); + test("SC5: prior + added → the added title + the carried/dropped tally", () => { + const md = renderBrief(r, { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: ["gone"] }); + assert.ok(md.includes("Alpha"), "the added entry's title is rendered"); + assert.ok(md.includes("1 båret over, 1 ikke vist i dag"), "the carried/dropped tally"); + }); + test("SC5: prior + no added → 'Ingenting nytt siden ' + tally", () => { + const md = renderBrief(r, { priorDate: "2026-06-23", added: [], carried: ids, dropped: [] }); + assert.ok(md.includes("Ingenting nytt siden 2026-06-23")); + assert.ok(md.includes("båret over")); + }); + + test("SC6: marker ' N nye siden sist.' when prior + added", () => { + const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x", "y"], carried: [], dropped: [] }); + assert.ok(s.endsWith(" 2 nye siden sist."), `marker appended: ${s}`); + }); + test("SC6: no marker on the first brief or when nothing new", () => { + assert.ok(!briefSummary(r, { priorDate: null, added: ["x"], carried: [], dropped: [] }).includes("nye siden sist")); + assert.ok(!briefSummary(r, { priorDate: "2026-06-25", added: [], carried: [], dropped: [] }).includes("nye siden sist")); + }); + test("SC6: briefSummary(r) === briefSummary(r, emptyDiff) (keeps the :166 summary-equality test green)", () => { + assert.equal(briefSummary(r), briefSummary(r, emptyDiff)); + }); + test("SC6: the marker carries no quote/newline", () => { + const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x"], carried: [], dropped: [] }); + assert.ok(!s.includes('"') && !s.includes("\n")); + }); + + test("SC7: BRIEF_SCHEMA_VERSION === 2; SCHEMA_VERSION === 4", () => { + assert.equal(BRIEF_SCHEMA_VERSION, 2); + assert.equal(SCHEMA_VERSION, 4); + }); + + test("SC8: same (r, diff) → byte-identical render", () => { + const d = { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: [] }; + assert.equal(renderBrief(r, d), renderBrief(r, d)); + }); +}); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts index ba8642f..6397ac6 100644 --- a/scripts/trends/tests/cli.test.ts +++ b/scripts/trends/tests/cli.test.ts @@ -2,7 +2,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -669,3 +669,74 @@ describe("trends CLI — brief temporal flags (RE-R3d / SC6)", () => { } }); }); + +describe("trends CLI — brief history + day-over-day diff (RE-R3e / SC9)", () => { + const fixture = () => { + const dir = mkdtempSync(join(tmpdir(), "trends-r3e-")); + return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") }; + }; + // freshIso keeps the seeds inside the freshness window regardless of when the test runs. + const fresh = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + const seedOnPillar = (store: string, title: string, url: string): void => { + const batch = JSON.stringify([ + { + source: "tavily", + title, + url, + topics: ["ai", "gov"], + publishedAt: fresh, + score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } }, + }, + ]); + run(["capture", "--store", store], batch); + }; + const briefJson = (store: string, out: string) => + JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout); + + test("two-day (rename-real-write): day-2 shows the new trend; --json carries the diff", () => { + const { dir, store, out } = fixture(); + try { + // Day 1 — two on-pillar trends. The brief records its surfaced cohort + a null-prior diff. + seedOnPillar(store, "Alpha", "https://e/a"); + seedOnPillar(store, "Beta", "https://e/b"); + const o1 = briefJson(store, out); + assert.equal(o1.diff.priorDate, null, "first brief has no prior (empty dir)"); + assert.match(readFileSync(o1.path, "utf8"), /^surfaced: .+/m, "day-1 records a non-empty surfaced: line"); + + // Rename the REAL day-1 brief to a fixed past date — its surfaced: ids are genuine store + // ids, so the day-2 diff's carried/added are id-exact, clock-free (no today() advance). + const prior = "2026-06-20"; + renameSync(o1.path, join(out, `${prior}.md`)); + + // Day 2 — capture one NEW on-pillar trend, re-run. It is the sole "added". + seedOnPillar(store, "Gamma", "https://e/g"); + const o2 = briefJson(store, out); + assert.equal(o2.diff.priorDate, prior, "day-2 discovers the renamed strict-prior"); + assert.ok(o2.diff.added >= 1, "at least the new trend is added"); + assert.equal(o2.diff.carried, 2, "the two day-1 trends carry over"); + assert.equal(o2.diff.dropped, 0, "nothing dropped"); + const md2 = readFileSync(o2.path, "utf8"); + assert.ok(md2.includes(`## 🆕 Nytt siden sist (${prior})`), "the diff section names the prior date"); + assert.ok(md2.includes("Gamma"), "the new trend's title is in the Nytt-siden-sist section"); + + // The non-JSON console line appends the delta marker. + const console2 = run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store], "").stdout; + assert.ok(console2.includes("nye siden sist"), `console line carries the delta: ${console2}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a first run (empty dir) → diff.priorDate === null, no Nytt-siden-sist date", () => { + const { dir, store, out } = fixture(); + try { + seedOnPillar(store, "Solo", "https://e/s"); + const o = briefJson(store, out); + assert.equal(o.diff.priorDate, null); + const md = readFileSync(o.path, "utf8"); + assert.ok(md.includes("Første brief"), "a first brief says 'Første brief'"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});