linkedin-studio/scripts/trends/tests/score.test.ts
Kjell Tore Guttormsen 24775f4493 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
2026-06-24 10:09:45 +02:00

145 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, []);
});
});
});