feat(linkedin-studio): RE-R1 — item-schema (B1) + triage-scorer (B2) as tested code behind CLI seam [skip-docs]

Lift the research engine's deterministic core out of agents/trend-spotter.md prose
into pure, tested TypeScript under scripts/trends/, behind a CLI seam the agent calls.

- B1 src/item.ts: TrendItem ingress envelope + normalizeItem/normalizeItems
  (required-field validation, topic normalize+dedupe via store's normalizeField,
  optional publishedAt ISO-validate). No id (store derives it); no store bridge
  (capturedAt injection is R2).
- B2 src/score.ts: per-mode weight consts mirroring the SSOT
  (references/trend-scoring-modes.md), composite (weighted sum, [1,10] guard),
  band (5-band map + exact SSOT action strings), triage (keep>=threshold, rank desc,
  annotate composite+band). Owns ONLY the arithmetic; the five judgment scores stay
  model-side.
- CLI normalize/score: JSON payload on STDIN, JSON to stdout (the existing --json
  output toggle is untouched); exit 2 on bad invocation, 0 otherwise.
- Wire trend-spotter.md to name 'src/cli.ts score' as the deterministic-step owner
  (prose pointer; the agent still supplies the five scores). Domain-general.
- Gate: TRENDS_TESTS_FLOOR 24->62; new unconditional Section 16g (score.ts both-mode
  weight-sets + trend-spotter scorer-pointer + non-vacuity self-test);
  ASSERT_BASELINE_FLOOR 84->87.

TDD: logic-RED proven (33/34 item+score fail on assertions, not module-not-found),
then GREEN (trends suite 62/62); CLI RED 2/4 -> GREEN 4/4. Full gate 102/0/0.
No store-schema change (SCHEMA_VERSION stays 1).

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 10:09:45 +02:00
commit 24775f4493
8 changed files with 793 additions and 10 deletions

View file

@ -0,0 +1,70 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
// 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));
function run(args: string[], input: string): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
input,
encoding: "utf8",
cwd: trendsDir,
});
return { status: res.status, stdout: res.stdout };
}
describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
describe("normalize (stdin JSON in, JSON out)", () => {
test("happy path: a JSON batch on stdin -> exit 0 + {items,errors} JSON", () => {
const batch = JSON.stringify([
{ source: "tavily", title: "Good", url: "https://example.com/a", topics: ["AI", "ai"] },
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["x"] }, // bad: empty title
]);
const { status, stdout } = run(["normalize"], batch);
assert.equal(status, 0);
const out = JSON.parse(stdout);
assert.equal(out.items.length, 1);
assert.deepEqual(out.items[0].topics, ["ai"]); // deduped + lowercased
assert.equal(out.errors.length, 1);
assert.equal(out.errors[0].index, 1);
});
test("bad invocation: unparseable stdin -> exit 2", () => {
const { status } = run(["normalize"], "not json at all");
assert.equal(status, 2);
});
});
describe("score (stdin JSON in, JSON out)", () => {
test("happy path: scored candidates on stdin -> exit 0 + {kept,dropped} JSON", () => {
const candidates = JSON.stringify([
{ id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0
{ id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0
]);
const { status, stdout } = run(["score", "--mode", "kortform", "--threshold", "4.0"], candidates);
assert.equal(status, 0);
const out = JSON.parse(stdout);
assert.deepEqual(
out.kept.map((k: { id: string }) => k.id),
["high"],
);
assert.equal(out.kept[0].composite, 8.0);
assert.equal(out.kept[0].band.priority, "Immediate");
assert.deepEqual(
out.dropped.map((d: { id: string }) => d.id),
["low"],
);
});
test("bad invocation: an unknown --mode -> exit 2", () => {
const candidates = JSON.stringify([
{ id: "x", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } },
]);
const { status } = run(["score", "--mode", "bogus"], candidates);
assert.equal(status, 2);
});
});
});

View file

@ -0,0 +1,182 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { normalizeItem, normalizeItems } from "../src/item.js";
import { normalizeField } from "../src/store.js";
describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => {
test("a well-formed raw item normalizes to a canonical item (string fields verbatim)", () => {
const raw = {
source: "tavily",
title: "OpenAI ships a new reasoning model",
url: "https://example.com/Article-Path",
topics: ["ai", "reasoning"],
summary: "A short summary.",
};
const res = normalizeItem(raw);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.source, "tavily");
assert.equal(res.item.title, "OpenAI ships a new reasoning model"); // verbatim, case preserved
assert.equal(res.item.url, "https://example.com/Article-Path"); // verbatim, case-sensitive path
assert.equal(res.item.summary, "A short summary.");
assert.deepEqual(res.item.topics, ["ai", "reasoning"]);
});
test("topics are normalized (lowercase + whitespace) and deduped, order-stable", () => {
const res = normalizeItem({
source: "manual",
title: "T",
url: "https://example.com/t",
topics: ["AI", " Machine Learning ", "ai", "Machine Learning"],
});
assert.equal(res.ok, true);
if (!res.ok) return;
// "AI"/"ai" dedupe -> "ai"; " Machine Learning "/"Machine Learning" dedupe -> "machine learning"
assert.deepEqual(res.item.topics, ["ai", "machine learning"]);
// each topic equals store.normalizeField of the raw form (the same normalization)
assert.equal(res.item.topics[1], normalizeField(" Machine Learning "));
});
test("the canonical item carries NO id (the store derives it via addTrend->trendId)", () => {
const res = normalizeItem({
source: "tavily",
title: "No id here",
url: "https://example.com/x",
topics: ["x"],
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal((res.item as Record<string, unknown>).id, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "id"), false);
});
test("summary is optional — absent -> no summary key", () => {
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t", topics: ["x"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("summary" in res.item, false);
});
test("topics absent -> empty topics array", () => {
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t" });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.topics, []);
});
});
describe("normalizeItem — required-field validation", () => {
for (const field of ["source", "title", "url"] as const) {
test(`missing ${field} -> {ok:false} naming the field`, () => {
const base: Record<string, unknown> = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
};
delete base[field];
const res = normalizeItem(base);
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(
res.errors.some((e) => e.includes(field)),
`error should name ${field}: ${res.errors.join("; ")}`,
);
});
test(`empty/whitespace ${field} -> {ok:false} naming the field (no silent partial)`, () => {
const base: Record<string, unknown> = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
};
base[field] = " ";
const res = normalizeItem(base);
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes(field)));
});
}
test("a non-object raw -> {ok:false}", () => {
const res = normalizeItem("not an object" as unknown);
assert.equal(res.ok, false);
});
});
describe("normalizeItem — publishedAt", () => {
test("present and valid ISO date -> kept", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "2026-06-20",
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.publishedAt, "2026-06-20");
});
test("absent -> undefined (no key)", () => {
const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["x"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.publishedAt, undefined);
assert.equal("publishedAt" in res.item, false);
});
test("present but invalid -> {ok:false} naming publishedAt", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "not-a-date",
});
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("publishedAt")));
});
test("present but impossible calendar date -> {ok:false}", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "2026-13-45",
});
assert.equal(res.ok, false);
});
});
describe("normalizeItems — batch partition", () => {
test("partitions a batch into {items, errors} with error indices", () => {
const raw = [
{ source: "tavily", title: "Good A", url: "https://example.com/a", topics: ["x"] },
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["y"] }, // bad: empty title
{ source: "manual", title: "Good C", url: "https://example.com/c", topics: ["z", "z"] },
];
const { items, errors } = normalizeItems(raw);
assert.equal(items.length, 2);
assert.equal(errors.length, 1);
assert.equal(errors[0].index, 1);
assert.ok(errors[0].errors.some((e) => e.includes("title")));
assert.deepEqual(
items.map((i) => i.title),
["Good A", "Good C"],
);
assert.deepEqual(items[1].topics, ["z"]); // deduped
});
test("an empty batch -> empty partition", () => {
const { items, errors } = normalizeItems([]);
assert.deepEqual(items, []);
assert.deepEqual(errors, []);
});
});
});

View file

@ -0,0 +1,145 @@
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";
const r1 = (x: number) => Math.round(x * 10) / 10;
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
describe("trends scorer (RE-R1 / B2)", () => {
// SSOT: references/trend-scoring-modes.md — weights, band thresholds, and action
// strings are pinned here so silent drift in any of them fails loudly.
describe("pinned weights (SSOT)", () => {
test("kortform weights match the SSOT and sum to 1.0", () => {
assert.equal(KORTFORM_WEIGHTS.pillar, 0.3);
assert.equal(KORTFORM_WEIGHTS.audience, 0.25);
assert.equal(KORTFORM_WEIGHTS.timing, 0.2);
assert.equal(KORTFORM_WEIGHTS.angle, 0.15);
assert.equal(KORTFORM_WEIGHTS.authority, 0.1);
assert.equal(r1(sum(KORTFORM_WEIGHTS)), 1.0);
});
test("long-form weights match the SSOT and sum to 1.0", () => {
assert.equal(LONG_FORM_WEIGHTS.pillar, 0.3);
assert.equal(LONG_FORM_WEIGHTS.depth, 0.25);
assert.equal(LONG_FORM_WEIGHTS.angle, 0.2);
assert.equal(LONG_FORM_WEIGHTS.authority, 0.15);
assert.equal(LONG_FORM_WEIGHTS.currency, 0.1);
assert.equal(r1(sum(LONG_FORM_WEIGHTS)), 1.0);
});
});
describe("composite", () => {
test("all-tens -> exactly 10.0 (proves Sigma weights = 1.0) for both modes", () => {
const kort = { pillar: 10, audience: 10, timing: 10, angle: 10, authority: 10 };
const long = { pillar: 10, depth: 10, angle: 10, authority: 10, currency: 10 };
assert.equal(composite(kort, "kortform"), 10.0);
assert.equal(composite(long, "long-form"), 10.0);
});
test("asymmetric golden vector {10,8,6,4,2} in dimension order -> 7.0 for both modes", () => {
// 10*.30 + 8*.25 + 6*.20 + 4*.15 + 2*.10 = 3.0 + 2.0 + 1.2 + 0.6 + 0.2 = 7.0
const kort = { pillar: 10, audience: 8, timing: 6, angle: 4, authority: 2 };
const long = { pillar: 10, depth: 8, angle: 6, authority: 4, currency: 2 };
assert.equal(composite(kort, "kortform"), 7.0);
assert.equal(composite(long, "long-form"), 7.0);
});
test("a dimension below 1 throws", () => {
const kort = { pillar: 0, audience: 5, timing: 5, angle: 5, authority: 5 };
assert.throws(() => composite(kort, "kortform"), /range|1.*10|dimension/i);
});
test("a dimension above 10 throws", () => {
const kort = { pillar: 11, audience: 5, timing: 5, angle: 5, authority: 5 };
assert.throws(() => composite(kort, "kortform"));
});
test("a missing dimension throws", () => {
const kort = { pillar: 5, audience: 5, timing: 5, angle: 5 }; // authority missing
assert.throws(() => composite(kort as Record<string, number>, "kortform"));
});
});
describe("band — boundaries + exact SSOT action strings", () => {
test("8.0 -> Immediate", () => {
const b = band(8.0);
assert.equal(b.priority, "Immediate");
assert.equal(b.kortformAction, "Draft within 24h");
assert.equal(b.longformAction, "Promote to the edition backlog now");
});
test("6.0 -> High", () => {
const b = band(6.0);
assert.equal(b.priority, "High");
assert.equal(b.kortformAction, "Publish within 4872h");
assert.equal(b.longformAction, "Strong edition candidate — schedule it");
});
test("4.0 -> Medium", () => {
const b = band(4.0);
assert.equal(b.priority, "Medium");
assert.equal(b.kortformAction, "Add to this week's calendar");
assert.equal(b.longformAction, "Hold as a backlog candidate, revisit");
});
test("2.0 -> Low", () => {
const b = band(2.0);
assert.equal(b.priority, "Low");
assert.equal(b.kortformAction, "Note, skip for now");
assert.equal(b.longformAction, "Park unless the angle sharpens");
});
test("below 2.0 -> Skip", () => {
const b = band(1.9);
assert.equal(b.priority, "Skip");
assert.equal(b.kortformAction, "Off positioning");
assert.equal(b.longformAction, "Off positioning");
});
test("just below a boundary lands in the lower band (7.9->High, 5.9->Medium, 3.9->Low)", () => {
assert.equal(band(7.9).priority, "High");
assert.equal(band(5.9).priority, "Medium");
assert.equal(band(3.9).priority, "Low");
});
});
describe("triage", () => {
const candidates = [
{ id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0
{ id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0
{ id: "mid", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } }, // 5.0
{ id: "below", scores: { pillar: 3, audience: 3, timing: 3, angle: 3, authority: 3 } }, // 3.0
];
test("keeps composite >= threshold, drops below, ranks kept composite-desc, annotates", () => {
const { kept, dropped } = triage(candidates, { mode: "kortform", threshold: 4.0 });
assert.deepEqual(
kept.map((k) => k.id),
["high", "mid"],
); // 8.0, 5.0 desc; both >= 4.0
assert.deepEqual(
dropped.map((d) => d.id).sort(),
["below", "low"],
); // 3.0, 2.0 < 4.0
assert.equal(kept[0].composite, 8.0);
assert.equal(kept[0].band.priority, "Immediate");
assert.equal(kept[1].composite, 5.0);
assert.equal(kept[1].band.priority, "Medium");
});
test("threshold is inclusive (composite == threshold is kept)", () => {
const { kept } = triage(candidates, { mode: "kortform", threshold: 5.0 });
assert.deepEqual(
kept.map((k) => k.id),
["high", "mid"],
); // mid == 5.0 kept
});
test("an empty candidate list -> empty kept/dropped", () => {
const { kept, dropped } = triage([], { mode: "kortform", threshold: 4.0 });
assert.deepEqual(kept, []);
assert.deepEqual(dropped, []);
});
});
});