feat(linkedin-studio): RE-R3a — persist relevance score on the store record + rank the morning brief on it [skip-docs]

R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.

- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
  composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
  migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
  mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
  itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99

Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 14:05:27 +02:00
commit e169c78710
14 changed files with 829 additions and 40 deletions

View file

@ -13,8 +13,24 @@ import type { TrendRecord, TrendStore } from "../src/types.js";
const TODAY = "2026-06-24";
type TestScore = {
mode: "kortform" | "long-form";
dimensions: Record<string, number>;
composite: number;
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
};
function mkTrend(
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string },
p: {
title: string;
url: string;
topics: string[];
capturedAt: string;
publishedAt?: string;
source?: string;
summary?: string;
score?: TestScore;
},
): TrendRecord {
return {
id: p.title + "|" + p.url,
@ -25,8 +41,14 @@ function mkTrend(
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
topics: p.topics,
...(p.summary !== undefined ? { summary: p.summary } : {}),
...(p.score !== undefined ? { score: p.score } : {}),
};
}
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
}
function mkStore(trends: TrendRecord[]): TrendStore {
return { schemaVersion: 2, trends };
}
@ -159,6 +181,161 @@ describe("renderBrief + briefSummary (SC3)", () => {
});
});
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
const pillars = ["a", "b"];
test("higher composite sorts first at the same overlap + freshness", () => {
const store = mkStore([
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
});
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
const store = mkStore([
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
});
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
const store = mkStore([
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
});
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
const store = mkStore([
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
});
test("deterministic: identical scored input -> identical ranking order", () => {
const trends = [
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
];
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
});
});
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)", () => {
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") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · 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" }),
]);
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",
);
});
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
"scored bullet must carry · <priority> (<mode>) before · 🔗",
);
});
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
"unscored bullet must be unchanged",
);
});
test("briefSummary names the band (no mode) on a scored top", () => {
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") }),
]);
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("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" }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
});
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
const store = mkStore([
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
assert.ok(!s.includes("\n"), "summary must be a single line");
assert.ok(s.includes("Immediate"), "summary still carries the band");
});
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" }),
]);
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("\n"));
});
test("ranking: descriptor equals the exact pinned string", () => {
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") }),
]);
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",
);
});
test("deterministic: identical scored input -> identical bytes", () => {
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(6.0, "High") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
});
});
describe("defaultBriefDir", () => {
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA;

View file

@ -6,6 +6,8 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "no
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SCHEMA_VERSION } from "../src/types.js";
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
// `tsx` loader resolve regardless of the runner's cwd.
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
@ -97,7 +99,7 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
"tally must sum to the input size",
);
const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.schemaVersion, 2);
assert.equal(persisted.schemaVersion, SCHEMA_VERSION);
assert.equal(persisted.trends.length, 1);
assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
@ -157,6 +159,73 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
const { status } = run(["capture"], "");
assert.equal(status, 2);
});
// ── RE-R3a: capture persists the computed relevance score (SC7) ──
test("a valid per-item score -> record carries the computed composite/priority (read back via list --json)", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Scored capture",
url: "https://example.com/sc",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } },
},
]);
const cap = run(["capture", "--store", store, "--json"], batch);
assert.equal(cap.status, 0);
const summary = JSON.parse(cap.stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 0);
const ls = run(["list", "--store", store, "--json"], "");
assert.equal(ls.status, 0);
const rows = JSON.parse(ls.stdout);
assert.equal(rows.length, 1);
// 9*.30 + 8*.25 + 9*.20 + 7*.15 + 6*.10 = 8.15 -> round1 8.1 (8.15*10 = 81.4999… in IEEE-754) -> Immediate
assert.equal(rows[0].score.composite, 8.1);
assert.equal(rows[0].score.priority, "Immediate");
assert.equal(rows[0].score.mode, "kortform");
assert.deepEqual(rows[0].score.dimensions, { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 });
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("a batch with one bad score (timing:99) -> that item in errors[], the valid one added, exit 0", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Good scored",
url: "https://example.com/good",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 } },
},
{
source: "tavily",
title: "Bad scored",
url: "https://example.com/bad",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 } },
},
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0, "a bad score must not fail the run");
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1, "the valid scored item is added");
assert.equal(summary.errors.length, 1, "the bad-score item lands in errors[]");
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
2,
"tally must sum to the input size",
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
});
});

View file

@ -2,7 +2,9 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import type { TrendItem } from "../src/item.js";
import { normalizeField } from "../src/store.js";
import { scoreEnvelope } from "../src/score.js";
describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => {
@ -224,4 +226,115 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
assert.equal(input.capturedAt, "2026-06-24");
});
});
describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
const base = {
source: "tavily",
title: "Scored item",
url: "https://example.com/s",
topics: ["ai"],
};
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a valid kortform score -> carried with the validated dims", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
});
test("a valid long-form score -> carried with its five dims", () => {
const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
});
test("absent score -> key omitted", () => {
const res = normalizeItem(base);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("score" in res.item, false);
});
test("a bad mode -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
});
test("a missing dimension -> structured error (no throw)", () => {
const { authority, ...missing } = validDims;
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
assert.equal(lo.ok, false);
const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
assert.equal(hi.ok, false);
});
test("a non-object score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: "high" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array dimensions -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: [] });
assert.equal(res.ok, false);
});
});
describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: validDims },
};
const input = itemToInput(item, "2026-06-24");
assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
});
test("an unscored item -> no score key", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
};
const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
assert.equal("score" in input, false);
});
test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
};
assert.throws(() => itemToInput(item, "2026-06-24"));
});
});
});

View file

@ -1,7 +1,15 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { KORTFORM_WEIGHTS, LONG_FORM_WEIGHTS, composite, band, triage } from "../src/score.js";
import {
KORTFORM_WEIGHTS,
LONG_FORM_WEIGHTS,
composite,
band,
triage,
requiredDimensions,
scoreEnvelope,
} from "../src/score.js";
const r1 = (x: number) => Math.round(x * 10) / 10;
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
@ -142,4 +150,42 @@ describe("trends scorer (RE-R1 / B2)", () => {
assert.deepEqual(dropped, []);
});
});
describe("requiredDimensions (RE-R3a / SC1)", () => {
test("kortform -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]);
});
test("long-form -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]);
});
test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => {
assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS));
assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS));
});
});
describe("scoreEnvelope (RE-R3a / SC1)", () => {
test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => {
const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 };
const env = scoreEnvelope("kortform", dims);
assert.equal(env.mode, "kortform");
assert.deepEqual(env.dimensions, dims);
assert.equal(env.composite, composite(dims, "kortform"));
assert.equal(env.priority, band(composite(dims, "kortform")).priority);
});
test("long-form envelope composes the long-form composite/band", () => {
const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const env = scoreEnvelope("long-form", dims);
assert.equal(env.composite, composite(dims, "long-form"));
assert.equal(env.priority, band(composite(dims, "long-form")).priority);
});
test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => {
const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 };
assert.throws(() => scoreEnvelope("kortform", dims));
});
});
});

View file

@ -275,6 +275,67 @@ describe("trends store", () => {
assert.equal(res2.merged, true, "topic union still reported");
assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance");
});
// ── RE-R3a: relevance score first-sight persistence (SC3) ──
const kortScore = {
mode: "kortform" as const,
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate" as const,
};
test("RED: persists score on a new record when present (first-sight)", () => {
const res = addTrend(emptyStore(), {
title: "Scored trend",
url: "https://example.com/sc",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
score: kortScore,
});
assert.deepEqual(res.store.trends[0].score, kortScore);
});
test("regression guard: omits score when absent (no undefined-valued key)", () => {
const res = addTrend(emptyStore(), {
title: "Unscored trend",
url: "https://example.com/us",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
});
assert.equal("score" in res.store.trends[0], false);
});
test("RED: re-capture keeps the first sighting's score (no overwrite), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
score: kortScore,
}).store;
const lowerScore = {
mode: "kortform" as const,
dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 },
composite: 5.6,
priority: "Medium" as const,
};
const res2 = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["a", "b"],
score: lowerScore,
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.deepEqual(res2.store.trends[0].score, kortScore, "first-sight score kept (D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
});
describe("queryByTopic", () => {
@ -429,7 +490,7 @@ describe("trends store", () => {
});
withFixture(v1, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 2, "v1 store must migrate to v2");
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
@ -438,22 +499,22 @@ describe("trends store", () => {
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:2 to disk", () => {
test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 2);
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("RED: a non-numeric schemaVersion is coerced to v2 (old code passes the string through)", () => {
test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => {
withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => {
assert.equal(loadStore(path).schemaVersion, 2);
assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
});
});
// ── GREEN-only regression guards: old code already returns the current version ──
test("regression guard: a v2 store loads as v2, idempotent (records + publishedAt intact)", () => {
test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
@ -470,7 +531,7 @@ describe("trends store", () => {
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 2);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].publishedAt, "2026-05-30");
});
});
@ -493,4 +554,114 @@ describe("trends store", () => {
});
});
});
describe("schema migration (RE-R3a / score v2→v3)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// ── genuinely RED: pre-bump SCHEMA_VERSION=2 → loadStore(v2).schemaVersion===2 ≠ 3 ──
test("RED: a v2 store (no score) loads stamped as v3, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "abc123",
title: "Old dated trend",
url: "https://example.com/o",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["ai"],
publishedAt: "2026-04-30",
},
],
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
assert.equal(s.trends[0].publishedAt, "2026-04-30");
assert.deepEqual(s.trends[0].topics, ["ai"]);
assert.equal("score" in s.trends[0], false, "migration must not invent a score");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:3 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 3);
});
});
test("RED: a v3 store with score on records loads idempotent", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Scored",
url: "https://example.com/s",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score: {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
},
},
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, 3);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
});
});
});
test("RED: a v3 store's score survives load → save → load (field preservation)", () => {
const score = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Survivor",
url: "https://example.com/sv",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score,
},
],
});
withFixture(v3, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)");
});
});
});
});