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
|
|
@ -8,8 +8,10 @@ import {
|
|||
briefSummary,
|
||||
defaultBriefDir,
|
||||
surfacedIds,
|
||||
temporalSignal,
|
||||
BRIEF_SCHEMA_VERSION,
|
||||
} from "../src/brief.js";
|
||||
import { SCHEMA_VERSION } from "../src/types.js";
|
||||
import type { TrendRecord, TrendStore } from "../src/types.js";
|
||||
|
||||
const TODAY = "2026-06-24";
|
||||
|
|
@ -93,13 +95,15 @@ describe("rankForBrief — grouping (SC1)", () => {
|
|||
});
|
||||
|
||||
describe("rankForBrief — within-group total order (SC1)", () => {
|
||||
test("effectiveDate desc orders before title", () => {
|
||||
test("effectiveDate desc orders before title (both neutral so the RE-R3d temporal key ties)", () => {
|
||||
// RE-R3d: both entries are neutral (surfaced 0, ageDays 4-6 > firstMoverDays 2), so the temporal
|
||||
// key ties and effectiveDate is the deciding key; titles disagree with date to isolate effectiveDate.
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-18", capturedAt: "2026-06-18" }),
|
||||
mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const r = rankForBrief(store, ["a", "b"], TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Bravo", "Alpha"]);
|
||||
});
|
||||
test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
|
||||
const store = mkStore([
|
||||
|
|
@ -242,24 +246,25 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
|||
const pillars = ["AI", "gov"];
|
||||
|
||||
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
|
||||
// neutral age (4d > firstMoverDays 2) so the RE-R3d temporal overlay adds no badge — isolates the score token.
|
||||
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: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"),
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Immediate (kortform) · Pillarer: AI, gov"),
|
||||
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"),
|
||||
"unscored top-entry meta line must be unchanged",
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Pillarer: AI, gov"),
|
||||
"unscored neutral top-entry meta line carries no score and no temporal token",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -286,20 +291,21 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
|||
});
|
||||
|
||||
test("briefSummary names the band (no mode) on a scored top", () => {
|
||||
// neutral age (4d) so the RE-R3d first-mover marker stays off — isolates the band token.
|
||||
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: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`);
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 4d)."), `summary should carry the band: ${s}`);
|
||||
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
|
||||
});
|
||||
|
||||
test("briefSummary omits the band token on an unscored top", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · 4d)."), `unscored summary should omit the band: ${s}`);
|
||||
});
|
||||
|
||||
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
|
||||
|
|
@ -314,11 +320,11 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
|||
|
||||
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
|
||||
assert.ok(!s.includes("· ·"), "no empty priority slot");
|
||||
assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`);
|
||||
assert.ok(s.includes("Topp: «Solo» (AI · 4d)."), `single-pillar unscored summary: ${s}`);
|
||||
assert.ok(!s.includes("\n"));
|
||||
});
|
||||
|
||||
|
|
@ -328,8 +334,8 @@ describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
|||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
|
||||
assert.ok(
|
||||
md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"),
|
||||
"the ranking descriptor must match the pinned RE-R3a string verbatim",
|
||||
md.includes("ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays 7"),
|
||||
"the ranking descriptor must carry the RE-R3d temporal key (RE-R3a base + RE-R3d)",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -398,20 +404,20 @@ describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
|
|||
const md = renderBrief(rankForBrief(s, pillars, TODAY));
|
||||
assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks");
|
||||
});
|
||||
test("RED: · sett Nx appears only when surfacedCount >= 2 (prior-day count)", () => {
|
||||
test("· sett Nx appears only when surfacedCount >= 2 (warming badge, RE-R3b contract preserved by RE-R3d)", () => {
|
||||
const s = mkStore([
|
||||
mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 3 }),
|
||||
mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2 }),
|
||||
mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(s, pillars, TODAY));
|
||||
assert.ok(md.includes("· sett 3x"), "surfacedCount 3 → · sett 3x");
|
||||
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker");
|
||||
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x");
|
||||
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)");
|
||||
});
|
||||
test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => {
|
||||
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
|
||||
assert.match(
|
||||
md,
|
||||
/\nranking: composite desc, then pillar-overlap desc, then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/,
|
||||
/\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -432,3 +438,162 @@ describe("RE-R3b — surfacedIds (D7, Phase B)", () => {
|
|||
assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5");
|
||||
});
|
||||
});
|
||||
|
||||
// ── RE-R3d: temporal overlay (first-mover + saturation) ──
|
||||
|
||||
describe("temporalSignal — first-mover detection (RE-R3d / SC1)", () => {
|
||||
const opts = { firstMoverDays: 2, saturationAt: 3 };
|
||||
test("recent + unsurfaced is first-mover (rank 3); undefined surfacedCount counts as 0", () => {
|
||||
for (const a of [0, 1, 2]) {
|
||||
const s = temporalSignal(a, 0, opts);
|
||||
assert.equal(s.tier, "first-mover", `ageDays ${a} surfaced 0`);
|
||||
assert.equal(s.firstMover, true);
|
||||
assert.equal(s.rank, 3);
|
||||
}
|
||||
assert.equal(temporalSignal(1, undefined, opts).tier, "first-mover");
|
||||
});
|
||||
test("past the window is neutral (rank 2)", () => {
|
||||
const s = temporalSignal(3, 0, opts);
|
||||
assert.equal(s.tier, "neutral");
|
||||
assert.equal(s.firstMover, false);
|
||||
assert.equal(s.rank, 2);
|
||||
});
|
||||
test("recent but already surfaced is NOT first-mover (warming)", () => {
|
||||
const s = temporalSignal(1, 1, opts);
|
||||
assert.equal(s.tier, "warming");
|
||||
assert.equal(s.firstMover, false);
|
||||
});
|
||||
test("future publishedAt (negative ageDays) is NOT first-mover (>=0 guard)", () => {
|
||||
const s = temporalSignal(-1, 0, opts);
|
||||
assert.equal(s.tier, "neutral");
|
||||
assert.equal(s.firstMover, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporalSignal — saturation grading + clamp (RE-R3d / SC2, SC9)", () => {
|
||||
const opts = { firstMoverDays: 2, saturationAt: 3 };
|
||||
test("surfacings >= saturationAt is saturated (rank 0, inclusive)", () => {
|
||||
for (const c of [3, 4]) {
|
||||
const s = temporalSignal(5, c, opts);
|
||||
assert.equal(s.tier, "saturated", `surfaced ${c}`);
|
||||
assert.equal(s.rank, 0);
|
||||
assert.equal(s.surfacings, c);
|
||||
}
|
||||
});
|
||||
test("1..saturationAt-1 is warming (rank 1)", () => {
|
||||
for (const c of [1, 2]) {
|
||||
const s = temporalSignal(5, c, opts);
|
||||
assert.equal(s.tier, "warming", `surfaced ${c}`);
|
||||
assert.equal(s.rank, 1);
|
||||
}
|
||||
});
|
||||
test("surfaced 0 (not recent) is neutral (rank 2)", () => {
|
||||
assert.equal(temporalSignal(5, 0, opts).tier, "neutral");
|
||||
});
|
||||
test("defensive clamp: saturationAt 0 does NOT mark every non-first-mover saturated", () => {
|
||||
assert.equal(temporalSignal(5, 5, { firstMoverDays: 2, saturationAt: 0 }).tier, "saturated");
|
||||
assert.equal(temporalSignal(5, 0, { firstMoverDays: 2, saturationAt: 0 }).tier, "neutral");
|
||||
});
|
||||
test("pure: same inputs -> same output", () => {
|
||||
assert.deepEqual(temporalSignal(2, 1, opts), temporalSignal(2, 1, opts));
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — temporal overlay re-orders within tier, composite dominates (RE-R3d / SC3)", () => {
|
||||
const pillars = ["a", "b"];
|
||||
// DISAGREEMENT fixture: temporal.rank and effectiveDate-desc disagree, so the new key is what decides.
|
||||
// (surfacedCount correlates with age, so a naive first-mover-vs-saturated fixture would already be ordered
|
||||
// correctly by the existing effectiveDate key — that test would pass WITHOUT the feature.)
|
||||
const store = mkStore([
|
||||
// A: neutral (surfaced 0, ageDays 5 > firstMoverDays), OLDER date, composite 7.0
|
||||
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-19", capturedAt: "2026-06-19", score: mkScore(7.0, "High") }),
|
||||
// B: warming (surfaced 2), NEWER date, composite 7.0
|
||||
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2, score: mkScore(7.0, "High") }),
|
||||
// Z: saturated (surfaced 4) but HIGHER composite 8.5
|
||||
mkTrend({ title: "Z", url: "https://e/z", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", surfacedCount: 4, score: mkScore(8.5, "Immediate") }),
|
||||
]);
|
||||
test("[Z, A, B]: composite primary (Z); then temporal (A neutral > B warming) over newer-date B", () => {
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
|
||||
});
|
||||
test("total order: deterministic regardless of store insertion order", () => {
|
||||
const reordered = mkStore([store.trends[2], store.trends[0], store.trends[1]]);
|
||||
assert.deepEqual(rankForBrief(reordered, pillars, TODAY).topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBrief — temporal badges + the >=2 boundary (RE-R3d / SC4)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
const render = (p: Parameters<typeof mkTrend>[0]): string => renderBrief(rankForBrief(mkStore([mkTrend(p)]), pillars, TODAY));
|
||||
test("first-mover entry carries · 🥇 først ute", () => {
|
||||
const md = render({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" });
|
||||
assert.ok(md.includes("· 🥇 først ute"), "ageDays 1 + unsurfaced -> first-mover badge");
|
||||
});
|
||||
test("saturated entry carries · 🔁 mettet (3x)", () => {
|
||||
const md = render({ title: "Sat", url: "https://e/sat", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 3 });
|
||||
assert.ok(md.includes("· 🔁 mettet (3x)"), "surfacedCount 3 -> saturated badge");
|
||||
});
|
||||
test("warming entry with surfacedCount 2 carries · sett 2x", () => {
|
||||
const md = render({ title: "Warm", url: "https://e/warm", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 2 });
|
||||
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 -> warming badge (>=2)");
|
||||
});
|
||||
test("warming entry with surfacedCount 1 carries NO badge (preserves the R3b >=2 contract)", () => {
|
||||
const md = render({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 1 });
|
||||
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 -> no marker");
|
||||
assert.ok(!md.includes("mettet"), "surfacedCount 1 is not saturated");
|
||||
});
|
||||
test("neutral entry carries none of the temporal badges", () => {
|
||||
const md = render({ title: "Neu", url: "https://e/neu", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" });
|
||||
assert.ok(!md.includes("først ute") && !md.includes("mettet") && !md.includes("· sett "), "ageDays 4 + unsurfaced -> neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("briefSummary — first-mover marker (RE-R3d / SC5)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
test("first-mover top carries · 🥇 først ute and stays regex-safe", () => {
|
||||
const s = briefSummary(rankForBrief(mkStore([
|
||||
mkTrend({ title: "Fresh", url: "https://e/f", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(9.0, "Immediate") }),
|
||||
]), pillars, TODAY));
|
||||
assert.ok(s.includes("· 🥇 først ute"), `first-mover summary marker: ${s}`);
|
||||
assert.ok(!s.includes('"') && !s.includes("\n"), "summary stays single-line, no double-quote");
|
||||
});
|
||||
test("non-first-mover top omits the marker", () => {
|
||||
const s = briefSummary(rankForBrief(mkStore([
|
||||
mkTrend({ title: "Old", url: "https://e/o", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||||
]), pillars, TODAY));
|
||||
assert.ok(!s.includes("først ute"), `neutral top, no marker: ${s}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => {
|
||||
test("ranking does not mutate score.composite; schema versions unchanged", () => {
|
||||
const t = mkTrend({ title: "X", url: "https://e/x", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(8.0, "Immediate") });
|
||||
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(SCHEMA_VERSION, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day re-run idempotency)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
test("a first-mover trend already surfaced TODAY stays first-mover (today excluded from the prior count)", () => {
|
||||
// surfacedCount 1 but lastSurfacedAt === today -> prior-day count 0 -> still first-mover, so a
|
||||
// same-day re-render (which loads the post-mark count) is byte-identical to the first run (RE-R3c SC7).
|
||||
const s = mkStore([
|
||||
mkTrend({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: TODAY }),
|
||||
]);
|
||||
const e = rankForBrief(s, pillars, TODAY).topMatches[0];
|
||||
assert.equal(e.temporal.tier, "first-mover", "today's own surfacing must not demote it out of first-mover");
|
||||
assert.equal(e.temporal.surfacings, 0, "prior-day surfacings exclude today");
|
||||
});
|
||||
test("the same count surfaced on a PRIOR day is warming (today not excluded)", () => {
|
||||
const s = mkStore([
|
||||
mkTrend({ title: "W", url: "https://e/w", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: "2026-06-23" }),
|
||||
]);
|
||||
const e = rankForBrief(s, pillars, TODAY).topMatches[0];
|
||||
assert.equal(e.temporal.tier, "warming", "a prior-day surfacing counts");
|
||||
assert.equal(e.temporal.surfacings, 1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -600,3 +600,72 @@ describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends CLI — brief temporal flags (RE-R3d / SC6)", () => {
|
||||
// brief is flag-driven; spawn into a temp store/out so the real per-user data dir is never touched.
|
||||
function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
cwd: trendsDir,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout };
|
||||
}
|
||||
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
|
||||
function seedStore(trends: unknown[]): string {
|
||||
const store = join(mkdtempSync(join(tmpdir(), "trends-r3d-")), "trends.json");
|
||||
writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends }));
|
||||
return store;
|
||||
}
|
||||
// --no-mark so sequential runs on the same store do NOT accumulate surfacedCount (which would
|
||||
// confound the threshold assertions); tier badges live in the .md body, not in --json.
|
||||
function briefMd(args: string[]): { status: number | null; md: string } {
|
||||
const out = mkdtempSync(join(tmpdir(), "r3d-out-"));
|
||||
const { status, stdout } = runBrief([...args, "--out", out, "--no-mark", "--json"]);
|
||||
const path = status === 0 ? (JSON.parse(stdout).path as string) : "";
|
||||
return { status, md: path ? readFileSync(path, "utf8") : "" };
|
||||
}
|
||||
|
||||
test("--saturation-at 2 escalates a surfacedCount-2 trend from warming (sett 2x) to saturated (mettet 2x)", () => {
|
||||
const store = seedStore([
|
||||
{ id: "s", title: "Seen", url: "https://e/s", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"], surfacedCount: 2 },
|
||||
]);
|
||||
try {
|
||||
const dflt = briefMd(["--pillars", "ai", "--store", store]);
|
||||
assert.equal(dflt.status, 0);
|
||||
assert.ok(dflt.md.includes("· sett 2x"), "default saturationAt 3 -> surfacedCount 2 is warming");
|
||||
const tuned = briefMd(["--pillars", "ai", "--store", store, "--saturation-at", "2"]);
|
||||
assert.equal(tuned.status, 0);
|
||||
assert.ok(tuned.md.includes("· 🔁 mettet (2x)"), "--saturation-at 2 -> surfacedCount 2 is saturated");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("--first-mover-days 1 drops a 2-day-old unsurfaced trend out of first-mover", () => {
|
||||
const store = seedStore([
|
||||
{ id: "f", title: "Fresh", url: "https://e/f", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] },
|
||||
]);
|
||||
try {
|
||||
const dflt = briefMd(["--pillars", "ai", "--store", store]);
|
||||
assert.ok(dflt.md.includes("· 🥇 først ute"), "default firstMoverDays 2 -> a 2-day unsurfaced trend is first-mover");
|
||||
const tuned = briefMd(["--pillars", "ai", "--store", store, "--first-mover-days", "1"]);
|
||||
assert.ok(!tuned.md.includes("først ute"), "--first-mover-days 1 -> a 2-day trend is neutral");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid threshold flags -> exit 2", () => {
|
||||
const store = seedStore([]);
|
||||
try {
|
||||
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "x"]).status, 2, "--first-mover-days x");
|
||||
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "-1"]).status, 2, "--first-mover-days -1");
|
||||
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "0"]).status, 2, "--saturation-at 0");
|
||||
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "x"]).status, 2, "--saturation-at x");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue