linkedin-studio/scripts/trends/tests/score.test.ts
Kjell Tore Guttormsen e169c78710 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
2026-06-24 14:05:27 +02:00

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