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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 20:38:55 +02:00
commit 1adb7f4361
6 changed files with 204 additions and 8 deletions

View file

@ -18,7 +18,7 @@
import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope } from "./score.js";
import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
import type { TrendVerdict, Actionability } from "./types.js";
@ -270,9 +270,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
* already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
* addTrendtrendId). `publishedAt`/`summary`/`score` are carried only when present (key
* omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
* into the persisted envelope here (judgment composite, via scoreEnvelope). On the capture
* path the dims are pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw;
* called DIRECTLY with bad dims it throws by contract (defense-in-depth SC2).
* into the persisted envelope here (judgment composite, via scoreEnvelope), then run through
* the F7 band-cap gate (`capForActionability`) so a candidate with no formulated reader-grip is
* persisted at most `High`, not `Immediate` (MR-F7, N7). On the capture path the dims are
* pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw; called DIRECTLY with
* bad dims it throws by contract (defense-in-depth SC2).
*/
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
return {
@ -283,7 +285,9 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
topics: [...item.topics],
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
...(item.summary !== undefined ? { summary: item.summary } : {}),
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
...(item.score !== undefined
? { score: capForActionability(scoreEnvelope(item.score.mode, item.score.dimensions), item.actionability) }
: {}),
// N6 proposal fields carried through to the store input (validated already; key omitted when absent).
...(item.angle !== undefined ? { angle: item.angle } : {}),
...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}),

View file

@ -128,6 +128,43 @@ export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): Tre
return { mode, dimensions, composite: c, priority: band(c).priority };
}
/**
* A structural view of the reader-grip signal the F7 gate reads the `formulated` boolean
* of types.ts `Actionability`, redeclared here so score.ts stays free of internal imports
* (types.ts imports TrendScore from here; importing back would form a cycle). The real
* `Actionability` is assignable to this by structure.
*/
export interface ActionabilitySignal {
formulated: boolean;
}
/** Index of the `High` band in the SSOT ordering (BANDS is priority-descending — lower index outranks). */
const HIGH_BAND_INDEX = BANDS.findIndex((b) => b.priority === "High");
/** True when `p` sits strictly above the `High` band — the only bands the F7 gate can cap down. */
function aboveHigh(p: Priority): boolean {
const i = BANDS.findIndex((b) => b.priority === p);
return i !== -1 && i < HIGH_BAND_INDEX;
}
/**
* F7 band-cap gate (MR-F7, N7). A candidate whose reader-grip is explicitly NOT formulated
* (`actionability.formulated === false`) cannot occupy the top `Immediate` band regardless of
* its composite its priority is capped to at most `High`. This is the ONE place the
* deterministic composite->band derivation is overridden, and only downward: composite,
* dimensions, and mode are left untouched (the real score stays etterprøvbar; only the priority
* label changes). Absent actionability (`undefined`) is left uncapped legacy / un-annotated
* records keep their derived band (the N6 fields are additive-optional, no migration). Pure; no
* KTG values here what counts as a formulated grip is the operator's reader-side judgment
* carried by the N6 field, which this gate merely enforces.
*/
export function capForActionability(score: TrendScore, actionability?: ActionabilitySignal): TrendScore {
if (actionability?.formulated === false && aboveHigh(score.priority)) {
return { ...score, priority: "High" };
}
return score;
}
export interface TriageOptions {
mode: ScoreMode;
threshold: number;

View file

@ -337,4 +337,37 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
assert.throws(() => itemToInput(item, "2026-06-24"));
});
});
// The F7 gate (score.ts capForActionability) is applied on the capture path so the PERSISTED
// priority reflects the reader-side verdict — an Immediate composite with no formulated grip
// is stored as High, not Immediate. Proven end-to-end through itemToInput (unit-tested purely
// in score.test.ts; here it must actually bite the store input).
describe("itemToInput — F7 band-cap gate (MR-F7, N7)", () => {
const immediateDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; // composite 8.15 -> Immediate
const mkScored = (extra: Partial<TrendItem>): TrendItem => ({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: immediateDims },
...extra,
});
test("Immediate score + actionability.formulated=false -> persisted priority capped to High", () => {
assert.equal(scoreEnvelope("kortform", immediateDims).priority, "Immediate"); // precondition
const input = itemToInput(mkScored({ actionability: { formulated: false } }), "2026-06-24");
assert.equal(input.score?.priority, "High");
assert.equal(input.score?.composite, scoreEnvelope("kortform", immediateDims).composite); // real score unchanged
});
test("Immediate score + actionability.formulated=true -> priority stays Immediate", () => {
const input = itemToInput(mkScored({ actionability: { formulated: true } }), "2026-06-24");
assert.equal(input.score?.priority, "Immediate");
});
test("Immediate score + no actionability -> uncapped (equals scoreEnvelope, legacy-safe)", () => {
const input = itemToInput(mkScored({}), "2026-06-24");
assert.deepEqual(input.score, scoreEnvelope("kortform", immediateDims));
});
});
});

View file

@ -9,6 +9,7 @@ import {
triage,
requiredDimensions,
scoreEnvelope,
capForActionability,
} from "../src/score.js";
const r1 = (x: number) => Math.round(x * 10) / 10;
@ -188,4 +189,58 @@ describe("trends scorer (RE-R1 / B2)", () => {
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
});
});
});