linkedin-studio/scripts/trends/tests/score.test.ts
Kjell Tore Guttormsen 1adb7f4361 feat(linkedin-studio): N7 — trend->newsletter-bro (sourceTrendId + Step 1-inntak + auto-act) + F7 band-cap-gate [skip-docs]
Del 2 (broen): commands/newsletter.md Step 1 trend-intake pre-fills brief
(angle/targetLevel/key-points/source-URLs) from a /linkedin:trends candidate via
the trends CLI; sourceTrendId persisted at Step 1.5; Step 10 auto-act flips the
source trend to acted (closes the discovery->production loop deterministically);
Step 2 external fact-package intake path (kilde-så-draft with finished research).
edition-state.template.json: per-article sourceTrendId (additive-optional, no
schemaVersion bump). A1-8: grep -ci trend commands/newsletter.md 0 -> 18.

Del 2.5 (F7 gate, TDD): score.ts capForActionability — a candidate whose reader-grip
is explicitly not formulated (actionability.formulated=false) caps to at most High
regardless of composite. Pure; overrides the composite->band derivation downward only
(composite/dimensions/mode preserved, score stays etterprøvbar); absent actionability
left uncapped (legacy-safe). Wired into the item.ts capture path so the PERSISTED
priority is gated; store carries the envelope verbatim (re-capture safe). No KTG values
hardcoded — the N6 field carries the verdict, the gate only enforces it.

Suites (all green): trends 276/0 (was 266, +10), test-runner 139/0, brain 134/0,
hooks 140/0, tests 35/0, render 60/0; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
2026-07-23 20:38:55 +02:00

246 lines
11 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,
capForActionability,
} 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));
});
});
// MR-F7 (N7): a candidate whose reader-grip is explicitly NOT formulated cannot occupy the
// top `Immediate` band regardless of composite — the gate caps it to at most `High`. The
// reader-side verdict lives in the N6 `actionability` field; the gate only enforces it, it
// never invents a value. Absent actionability is left uncapped (additive-optional, no migration).
describe("capForActionability — F7 band-cap gate (MR-F7)", () => {
const immediateDims = { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 }; // composite 8.0 -> Immediate
const highDims = { pillar: 6, audience: 6, timing: 6, angle: 6, authority: 6 }; // composite 6.0 -> High
const mediumDims = { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }; // composite 5.0 -> Medium
test("Immediate + no reader-grip (formulated:false) -> capped to High, regardless of composite", () => {
const env = scoreEnvelope("kortform", immediateDims);
assert.equal(env.priority, "Immediate"); // precondition: composite 8.0 lands Immediate
assert.equal(capForActionability(env, { formulated: false }).priority, "High");
});
test("Immediate + reader-grip formulated (true) -> stays Immediate", () => {
const env = scoreEnvelope("kortform", immediateDims);
assert.equal(capForActionability(env, { formulated: true }).priority, "Immediate");
});
test("Immediate + actionability undefined -> uncapped (legacy / un-annotated records not penalized)", () => {
const env = scoreEnvelope("kortform", immediateDims);
assert.equal(capForActionability(env, undefined).priority, "Immediate");
});
test("already High + formulated:false -> stays High (caps TO High, never below)", () => {
const env = scoreEnvelope("kortform", highDims);
assert.equal(env.priority, "High");
assert.equal(capForActionability(env, { formulated: false }).priority, "High");
});
test("Medium + formulated:false -> untouched (gate bites only the top band)", () => {
const env = scoreEnvelope("kortform", mediumDims);
assert.equal(env.priority, "Medium");
assert.equal(capForActionability(env, { formulated: false }).priority, "Medium");
});
test("caps priority only — composite/dimensions/mode preserved (score stays etterprøvbar)", () => {
const dims = { pillar: 9, depth: 9, angle: 9, authority: 9, currency: 9 }; // 9.0 -> Immediate
const env = scoreEnvelope("long-form", dims);
const gated = capForActionability(env, { formulated: false });
assert.equal(gated.priority, "High");
assert.equal(gated.composite, env.composite); // real score unchanged
assert.deepEqual(gated.dimensions, env.dimensions);
assert.equal(gated.mode, env.mode);
});
test("does not mutate the input envelope (returns a fresh object)", () => {
const env = scoreEnvelope("kortform", immediateDims);
capForActionability(env, { formulated: false });
assert.equal(env.priority, "Immediate"); // original untouched
});
});
});