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
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
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 48–72h");
|
||
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
|
||
});
|
||
});
|
||
});
|