feat(linkedin-studio): N17 — baseline-motor (median + variansbånd + minimum-N-refusal) [skip-docs]

Every reading now leads with "vs your own baseline", and no verdict is given
when N is too small to carry one.

- stats.ts: median + medianAbsoluteDeviation (robust pair; mean/stddev stay for
  the alert engine, which wants outlier sensitivity), rollingBaseline with a
  10-post positional window, median ± 1·MAD band floored at 0, and a typed
  insufficient-data refusal below MIN_BASELINE_N=5. readAgainstBaseline returns
  above/within/below-band, or no-verdict when the baseline was refused.
- baselineByGroup + buildBaselineBlock: per-format/per-pillar baselines, each
  judged on its own N; the reported period is excluded from its own baseline and
  compared on its median, not its mean.
- queue-join.ts (new): read-only date join supplying format/pillar from the post
  queue. Every ambiguity resolves to unlabelled, an entry labels at most one
  post, and a missing/broken queue degrades to no labels.
- weekly/monthly reports attach the block unconditionally (refusal included);
  optional in the types, so pre-N17 reports load unchanged.
- CLI: report output leads with the baseline; new `baseline [--by format|pillar]`
  verb with coverage reporting.
- report.md leads with baseline framing and prints the code's reading rather
  than judging the band by eye; WoW loses to the baseline on disagreement.
  analyze.md Step 2a tests whether the drop is real before diagnosing it.

TDD: 58 analytics tests written red first (144 -> 202). test-runner Section 16x,
23 unconditional checks + self-test (247 -> 270; anti-erosion floor 228 -> 251).
tsc clean. All suites green: trends 300, brain 134, editions 72,
specifics-bank 45, contract-gate 33, hooks 191, tests 35, render 60.

Also closes the OKF phase-4 scope follow-up in docs/okf-ingestion/plan.md §8
(coord round 2026-07-25): phase 4 tracks the contract, parse is in scope, and
our claim on read_concept/navigate_bundle is withdrawn as unnecessary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 20:50:45 +02:00
commit e2ad190dda
15 changed files with 1723 additions and 14 deletions

View file

@ -178,4 +178,38 @@ describe("generateMonthlyReport", () => {
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.month, "2026-03");
});
/** Baseline framing (N17) — same contract as the weekly report. */
test("attaches a baseline built only from earlier months", () => {
const root = setupTestRoot([
createPost("2026-01-05", 100, 4),
createPost("2026-01-12", 110, 4),
createPost("2026-02-02", 120, 4),
createPost("2026-02-09", 130, 4),
createPost("2026-02-16", 140, 4),
// In-month outlier: must not feed the baseline it is compared against.
createPost("2026-03-03", 99999, 9),
]);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.baseline, "A generated monthly report must carry a baseline block");
assert.equal(report.baseline!.historyBefore, "2026-03");
assert.equal(report.baseline!.impressions.status, "ok");
if (report.baseline!.impressions.status !== "ok") return;
assert.equal(report.baseline!.impressions.n, 5);
assert.equal(report.baseline!.impressions.median, 120);
});
test("refuses a monthly baseline verdict on thin history", () => {
const root = setupTestRoot([
createPost("2026-02-02", 120, 4),
createPost("2026-03-03", 500, 9),
]);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.baseline);
assert.equal(report.baseline!.impressions.status, "insufficient-data");
});
});

View file

@ -0,0 +1,163 @@
import { describe, test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadQueueEntries, labelPosts } from "../src/utils/queue-join.js";
import type { QueueEntry } from "../src/utils/queue-join.js";
/**
* Format and pillar are NOT analytics data they live in the post queue, which
* the operator's create surfaces write. The per-format / per-pillar baseline
* therefore rests on a JOIN, and a join that guesses is worse than no labels at
* all: a post filed under the wrong format would poison the very baseline it is
* being measured against. Every ambiguous case here resolves to UNLABELLED.
*/
describe("queue-join", () => {
let tempDir: string;
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
const post = (publishedDate: string, title = "Some post") => ({
publishedDate,
title,
metrics: { impressions: 1000, engagementRate: 5 },
});
const entry = (overrides: Partial<QueueEntry> & { scheduled_date: string }): QueueEntry => ({
id: overrides.id ?? `q-${overrides.scheduled_date}`,
scheduled_date: overrides.scheduled_date,
pillar: overrides.pillar ?? "ai-agents",
format: overrides.format ?? "text",
hook_preview: overrides.hook_preview,
});
describe("loadQueueEntries", () => {
test("should return an empty list when there is no queue file", () => {
tempDir = mkdtempSync(join(tmpdir(), "queue-join-"));
const result = loadQueueEntries(join(tempDir, "queue.json"));
assert.deepEqual(result, [], "A missing queue is unlabelled data, not an error");
});
test("should return an empty list for malformed JSON rather than throwing", () => {
tempDir = mkdtempSync(join(tmpdir(), "queue-join-"));
const file = join(tempDir, "queue.json");
writeFileSync(file, "{ not json", "utf8");
assert.deepEqual(loadQueueEntries(file), []);
});
test("should return an empty list when the file is not an array", () => {
tempDir = mkdtempSync(join(tmpdir(), "queue-join-"));
const file = join(tempDir, "queue.json");
writeFileSync(file, JSON.stringify({ entries: [] }), "utf8");
assert.deepEqual(loadQueueEntries(file), []);
});
test("should read entries and keep those missing pillar or format", () => {
tempDir = mkdtempSync(join(tmpdir(), "queue-join-"));
const file = join(tempDir, "queue.json");
writeFileSync(
file,
JSON.stringify([
{ id: "a", scheduled_date: "2026-07-01", pillar: "ai", format: "text" },
{ id: "b", scheduled_date: "2026-07-03" },
]),
"utf8"
);
const result = loadQueueEntries(file);
assert.equal(result.length, 2);
assert.equal(result[1].pillar, undefined, "A partial entry is data; dropping it would hide posts");
});
test("should skip rows with no scheduled_date, which cannot join on anything", () => {
tempDir = mkdtempSync(join(tmpdir(), "queue-join-"));
const file = join(tempDir, "queue.json");
writeFileSync(
file,
JSON.stringify([{ id: "a", pillar: "ai", format: "text" }, { id: "b", scheduled_date: "2026-07-03" }]),
"utf8"
);
assert.equal(loadQueueEntries(file).length, 1);
});
});
describe("labelPosts", () => {
test("should label a post whose publish date matches a queue entry exactly", () => {
const result = labelPosts([post("2026-07-08")], [entry({ scheduled_date: "2026-07-08", format: "carousel" })]);
assert.equal(result[0].format, "carousel");
assert.equal(result[0].pillar, "ai-agents");
});
test("should label through a small reschedule gap", () => {
// A queue entry is a PLAN; the operator publishing a day late is normal.
const result = labelPosts([post("2026-07-09")], [entry({ scheduled_date: "2026-07-08" })]);
assert.equal(result[0].format, "text");
});
test("should not label across a gap wider than two days", () => {
const result = labelPosts([post("2026-07-15")], [entry({ scheduled_date: "2026-07-08" })]);
assert.equal(result[0].format, undefined, "A week apart is a different post, not a reschedule");
assert.equal(result[0].pillar, undefined);
});
test("should prefer an exact date match over a near one", () => {
const result = labelPosts(
[post("2026-07-08")],
[entry({ scheduled_date: "2026-07-07", format: "video" }), entry({ scheduled_date: "2026-07-08", format: "text" })]
);
assert.equal(result[0].format, "text");
});
test("should leave a post unlabelled when two entries tie on the same date", () => {
const result = labelPosts(
[post("2026-07-08")],
[
entry({ id: "x", scheduled_date: "2026-07-08", format: "text" }),
entry({ id: "y", scheduled_date: "2026-07-08", format: "carousel" }),
]
);
assert.equal(result[0].format, undefined, "A coin-flip label would poison the format baseline");
});
test("should disambiguate same-date entries on the hook preview", () => {
const result = labelPosts(
[post("2026-07-08", "The thing nobody tells you about agent evals")],
[
entry({ id: "x", scheduled_date: "2026-07-08", format: "text", hook_preview: "Three ways to lose a week" }),
entry({
id: "y",
scheduled_date: "2026-07-08",
format: "carousel",
hook_preview: "The thing nobody tells you",
}),
]
);
assert.equal(result[0].format, "carousel", "The hook is the only honest tiebreaker available");
});
test("should leave the post unlabelled when the queue is empty", () => {
const result = labelPosts([post("2026-07-08")], []);
assert.equal(result[0].format, undefined);
});
test("should never consume one queue entry for two different posts", () => {
// Two posts, one entry: labelling both would double-count the entry's
// format and invent a second post of that format that never existed.
const result = labelPosts([post("2026-07-08"), post("2026-07-09")], [entry({ scheduled_date: "2026-07-08" })]);
const labelled = result.filter((r) => r.format !== undefined);
assert.equal(labelled.length, 1, "An entry labels at most one post");
assert.equal(labelled[0].publishedDate, "2026-07-08", "The exact match wins the entry");
});
test("should preserve every post, labelled or not", () => {
const posts = [post("2026-07-08"), post("2026-07-30")];
const result = labelPosts(posts, [entry({ scheduled_date: "2026-07-08" })]);
assert.equal(result.length, 2, "Unlabelled posts stay in the list; they are just not grouped");
});
});
});

View file

@ -7,6 +7,15 @@ import {
percentChange,
deviationsFromMean,
weightedOutOfNetworkPct,
median,
medianAbsoluteDeviation,
rollingBaseline,
readAgainstBaseline,
baselineByGroup,
buildBaselineBlock,
BASELINE_WINDOW,
MIN_BASELINE_N,
BAND_K,
} from "../src/utils/stats.js";
describe("stats", () => {
@ -193,4 +202,381 @@ describe("stats", () => {
assert.equal(result, 41.7);
});
});
/**
* Baseline engine (N17). At ~2 posts/week every reading is small-N, so the
* engine exists to answer one question honestly: is this number different
* from the operator's own normal, or is it noise? Two disciplines are under
* test ROBUSTNESS (median/MAD, not mean/stddev, because one viral post
* would otherwise define "normal") and REFUSAL (below the minimum N there is
* no verdict at all, rather than a confident reading of nothing).
*/
describe("median", () => {
test("should return the middle value for an odd count", () => {
assert.equal(median([30, 10, 20]), 20, "Must sort internally, not trust input order");
});
test("should average the two middle values for an even count", () => {
assert.equal(median([10, 20, 30, 40]), 25);
});
test("should return undefined for an empty list, never 0", () => {
// Same discipline as weightedOutOfNetworkPct: absent data stays absent.
assert.equal(median([]), undefined);
});
test("should be unmoved by a single extreme outlier", () => {
// The whole reason baseline framing uses median: mean([100,110,120,130,10000])
// is 2092 — a "baseline" no ordinary post could ever reach.
assert.equal(median([100, 110, 120, 130, 10000]), 120);
assert.equal(mean([100, 110, 120, 130, 10000]), 2092);
});
});
describe("medianAbsoluteDeviation", () => {
test("should return the median of absolute deviations from the median", () => {
// median = 20; deviations = [10, 0, 10] → MAD = 10
assert.equal(medianAbsoluteDeviation([10, 20, 30]), 10);
});
test("should return 0 for identical values", () => {
assert.equal(medianAbsoluteDeviation([50, 50, 50]), 0);
});
test("should return undefined for an empty list", () => {
assert.equal(medianAbsoluteDeviation([]), undefined);
});
test("should stay small when one outlier inflates the standard deviation", () => {
// stddev is dragged to ~3950 by the outlier; MAD stays at 10, which is
// what an honest "normal spread" band needs.
const values = [100, 110, 120, 130, 10000];
assert.equal(medianAbsoluteDeviation(values), 10);
assert.ok(standardDeviation(values) > 1000, "stddev is outlier-dominated by contrast");
});
});
describe("rollingBaseline", () => {
test("should refuse a verdict below the minimum N", () => {
const result = rollingBaseline([100, 200, 300]);
assert.equal(result.status, "insufficient-data", "3 posts cannot support a verdict");
if (result.status !== "insufficient-data") return;
assert.equal(result.n, 3);
assert.equal(result.required, MIN_BASELINE_N);
assert.ok(result.reason.length > 0, "Refusal must carry an operator-facing reason");
});
test("should refuse on an empty history rather than report a zero baseline", () => {
const result = rollingBaseline([]);
assert.equal(result.status, "insufficient-data");
if (result.status !== "insufficient-data") return;
assert.equal(result.n, 0);
});
test("should produce a median and a band once the minimum N is met", () => {
// median = 120, MAD = 10 → band = [110, 130] at k=1
const result = rollingBaseline([100, 110, 120, 130, 140]);
assert.equal(result.status, "ok");
if (result.status !== "ok") return;
assert.equal(result.n, 5);
assert.equal(result.median, 120);
assert.equal(result.mad, 10);
assert.deepEqual(result.band, { low: 110, high: 130 });
});
test("should keep only the trailing window when history exceeds it", () => {
// 12 values oldest→newest; the window is the last 10, so the two oldest
// (1 and 2) must not pull the median down.
const values = [1, 2, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190];
const result = rollingBaseline(values);
assert.equal(result.status, "ok");
if (result.status !== "ok") return;
assert.equal(result.n, BASELINE_WINDOW);
assert.equal(result.window, BASELINE_WINDOW);
assert.equal(result.median, 145, "Median of the last 10, not of all 12");
});
test("should treat the input as oldest-first and never reorder it", () => {
// Descending input: if the implementation sorted by value it would keep
// the wrong ten. The last 10 here are 100 down to 10 → median 55.
const values = [200, 190, 100, 90, 80, 70, 60, 50, 40, 30, 20, 10];
const result = rollingBaseline(values);
assert.equal(result.status, "ok");
if (result.status !== "ok") return;
assert.equal(result.median, 55, "Window is positional (recency), the median is value-sorted");
});
test("should floor the band at 0 for a naturally non-negative metric", () => {
// median 20, MAD 15 → arithmetic low is 5; with a wider spread it would
// go negative, which is meaningless for impressions or engagement rate.
const result = rollingBaseline([0, 5, 20, 35, 90]);
assert.equal(result.status, "ok");
if (result.status !== "ok") return;
assert.ok(result.band.low >= 0, "A negative lower bound is not a readable floor");
});
test("should accept a configurable minimum N and window", () => {
const result = rollingBaseline([10, 20, 30], { minN: 3, window: 3 });
assert.equal(result.status, "ok", "The threshold is a documented default, not a hard law");
if (result.status !== "ok") return;
assert.equal(result.median, 20);
});
test("should widen the band with a larger k", () => {
const narrow = rollingBaseline([100, 110, 120, 130, 140]);
const wide = rollingBaseline([100, 110, 120, 130, 140], { k: 2 });
assert.equal(narrow.status, "ok");
assert.equal(wide.status, "ok");
if (narrow.status !== "ok" || wide.status !== "ok") return;
assert.equal(BAND_K, 1, "Default k is documented as 1");
assert.equal(wide.band.high, 140);
assert.ok(wide.band.high > narrow.band.high);
});
test("should give a zero-width band for a perfectly steady history", () => {
// MAD 0 is legitimate: the operator's numbers really are that steady.
const result = rollingBaseline([50, 50, 50, 50, 50]);
assert.equal(result.status, "ok");
if (result.status !== "ok") return;
assert.deepEqual(result.band, { low: 50, high: 50 });
assert.equal(result.mad, 0);
});
});
describe("readAgainstBaseline", () => {
const baseline = rollingBaseline([100, 110, 120, 130, 140]); // median 120, band [110,130]
test("should call a value inside the band normal variation, not a trend", () => {
// 128 is 6.7% above the median — the kind of number that reads as "up"
// week-over-week while being entirely inside the operator's own spread.
assert.equal(readAgainstBaseline(128, baseline), "within-band");
});
test("should call a value above the band above baseline", () => {
assert.equal(readAgainstBaseline(400, baseline), "above-band");
});
test("should call a value below the band below baseline", () => {
assert.equal(readAgainstBaseline(40, baseline), "below-band");
});
test("should treat the band edges as inside the band", () => {
assert.equal(readAgainstBaseline(110, baseline), "within-band");
assert.equal(readAgainstBaseline(130, baseline), "within-band");
});
test("should return no-verdict when the baseline itself was refused", () => {
const refused = rollingBaseline([100, 200]);
assert.equal(
readAgainstBaseline(5000, refused),
"no-verdict",
"A refused baseline must not be readable as a verdict by the caller"
);
});
});
describe("baselineByGroup", () => {
// Format and pillar are not stored in analytics (they live in the post
// queue), so the engine takes a label resolver rather than assuming a
// field — the same join the newsletter calibration uses.
const p = (group: string, impressions: number) => ({ group, impressions });
const items = [
...Array.from({ length: 6 }, (_, i) => p("text", 100 + i * 10)),
...Array.from({ length: 2 }, (_, i) => p("carousel", 900 + i * 100)),
];
test("should compute one baseline per group", () => {
const result = baselineByGroup(
items,
(it) => it.group,
(it) => it.impressions
);
assert.equal(result.size, 2);
assert.equal(result.get("text")?.status, "ok");
});
test("should refuse per group, not globally", () => {
// 8 posts in total would clear the threshold; carousel alone has 2 and
// must be refused on its own count. This is the failure the plan names:
// a verdict about a format the operator has barely used.
const result = baselineByGroup(
items,
(it) => it.group,
(it) => it.impressions
);
const carousel = result.get("carousel");
assert.equal(carousel?.status, "insufficient-data");
if (carousel?.status !== "insufficient-data") return;
assert.equal(carousel.n, 2);
});
test("should skip items whose label is undefined rather than bucket them", () => {
// An unlabelled post (no queue entry) is not a group called "undefined".
const mixed = [...items, { group: undefined as unknown as string, impressions: 5000 }];
const result = baselineByGroup(
mixed,
(it) => it.group,
(it) => it.impressions
);
assert.equal(result.size, 2, "No phantom group for unlabelled posts");
});
test("should skip items whose value is not a finite number", () => {
const withGap = [...items, p("text", NaN)];
const result = baselineByGroup(
withGap,
(it) => it.group,
(it) => it.impressions
);
const text = result.get("text");
assert.equal(text?.status, "ok");
if (text?.status !== "ok") return;
assert.equal(text.n, 6, "The NaN row must not count toward N");
});
});
describe("buildBaselineBlock", () => {
const post = (publishedDate: string, impressions: number, engagementRate = 5) => ({
publishedDate,
metrics: { impressions, engagementRate },
});
const history = [
post("2026-01-05", 100, 4.0),
post("2026-01-07", 110, 4.5),
post("2026-01-12", 120, 5.0),
post("2026-01-14", 130, 5.5),
post("2026-01-19", 140, 6.0),
];
test("should carry a baseline for both headline metrics", () => {
const block = buildBaselineBlock(history, "2026-W05");
assert.equal(block.impressions.status, "ok");
assert.equal(block.engagementRate.status, "ok");
if (block.impressions.status !== "ok") return;
assert.equal(block.impressions.median, 120);
});
test("should label the period whose posts are excluded", () => {
const block = buildBaselineBlock(history, "2026-W05");
assert.equal(block.historyBefore, "2026-W05");
assert.equal(block.window, BASELINE_WINDOW);
assert.equal(block.required, MIN_BASELINE_N);
});
test("should refuse both metrics when history is too short", () => {
const block = buildBaselineBlock(history.slice(0, 2), "2026-W05");
assert.equal(block.impressions.status, "insufficient-data");
assert.equal(block.engagementRate.status, "insufficient-data");
});
test("should order history by publish date before taking the window", () => {
// Same five posts shuffled, plus six older ones so the window bites. If
// the block trusted file order, the window would hold the wrong posts.
const older = [
post("2025-12-01", 5),
post("2025-12-03", 6),
post("2025-12-05", 7),
post("2025-12-08", 8),
post("2025-12-10", 9),
post("2025-12-12", 10),
];
const shuffled = [history[4], older[2], history[0], older[5], history[2], older[0],
history[1], older[4], history[3], older[1], older[3]];
const block = buildBaselineBlock(shuffled, "2026-W05");
assert.equal(block.impressions.status, "ok");
if (block.impressions.status !== "ok") return;
// Window = last 10 by DATE: drops 2025-12-01 (5), keeps 6..10 and 100..140.
// Median of [6,7,8,9,10,100,110,120,130,140] = (10+100)/2 = 55.
assert.equal(block.impressions.median, 55, "Window must follow publish date, not array order");
});
test("should return a block for an empty history rather than throwing", () => {
const block = buildBaselineBlock([], "2026-W05");
assert.equal(block.impressions.status, "insufficient-data");
if (block.impressions.status !== "insufficient-data") return;
assert.equal(block.impressions.n, 0);
});
/**
* The reading is computed HERE, in code, not left to whoever renders the
* report. A renderer eyeballing whether 128 sits inside [110, 130] is the
* over-read the whole engine exists to prevent.
*/
describe("period reading", () => {
test("should read the period's own median against the band", () => {
// Baseline median 120, band [110, 130]. Period median 125 → inside.
const block = buildBaselineBlock(history, "2026-W05", [
post("2026-02-02", 120),
post("2026-02-04", 130),
]);
assert.equal(block.period.impressions.median, 125);
assert.equal(block.period.impressions.reading, "within-band");
});
test("should compare the period MEDIAN, not its mean", () => {
// Mean of [100, 10000] is 5050 — "above-band" on one viral post. The
// median is 5050's honest counterpart: 5050 vs median 5050… no: the
// median of two values is their midpoint, so use three posts where the
// mean and median genuinely disagree: [100, 110, 10000] → mean 3403,
// median 110, which is inside the band. The band must not fire.
const block = buildBaselineBlock(history, "2026-W05", [
post("2026-02-02", 100),
post("2026-02-04", 110),
post("2026-02-06", 10000),
]);
assert.equal(block.period.impressions.median, 110);
assert.equal(
block.period.impressions.reading,
"within-band",
"One viral post must not turn an ordinary week into a trend"
);
});
test("should read a genuinely high period as above the band", () => {
const block = buildBaselineBlock(history, "2026-W05", [
post("2026-02-02", 900),
post("2026-02-04", 1100),
]);
assert.equal(block.period.impressions.reading, "above-band");
});
test("should read a genuinely low period as below the band", () => {
const block = buildBaselineBlock(history, "2026-W05", [
post("2026-02-02", 10),
post("2026-02-04", 20),
]);
assert.equal(block.period.impressions.reading, "below-band");
});
test("should give no verdict when the baseline was refused", () => {
const block = buildBaselineBlock(history.slice(0, 2), "2026-W05", [
post("2026-02-02", 99999),
]);
assert.equal(
block.period.impressions.reading,
"no-verdict",
"A thin baseline cannot license a verdict about a big number"
);
});
test("should give no verdict and no median for a period with no posts", () => {
const block = buildBaselineBlock(history, "2026-W05", []);
assert.equal(block.period.impressions.median, undefined);
assert.equal(block.period.impressions.reading, "no-verdict");
});
test("should read engagement rate independently of impressions", () => {
// Baseline engagement median 5.0, MAD 0.5 → band [4.5, 5.5].
// Impressions ordinary, engagement clearly above: the two readings must
// not be collapsed into one verdict about "the week".
const block = buildBaselineBlock(history, "2026-W05", [
post("2026-02-02", 120, 9.0),
post("2026-02-04", 120, 9.4),
]);
assert.equal(block.period.impressions.reading, "within-band");
assert.equal(block.period.engagementRate.reading, "above-band");
});
});
});
});

View file

@ -9,7 +9,7 @@ import {
getPostsForWeek,
generateWeeklyReport,
} from "../src/reports/weekly.js";
import { saveBatch, saveWeeklyReport } from "../src/utils/storage.js";
import { saveBatch, saveWeeklyReport, loadWeeklyReport } from "../src/utils/storage.js";
import type { PostAnalytics, AnalyticsBatch } from "../src/models/types.js";
// Helper function to create test post data
@ -662,4 +662,97 @@ describe("weekly", () => {
assert.match(report.week, /^\d{4}-W\d{2}$/);
});
});
/**
* Baseline framing (N17). The report's job is to answer "is this different
* from my own normal?" which requires a baseline built from history BEFORE
* the reported week, and an explicit refusal when that history is too thin.
*/
describe("generateWeeklyReport — baseline", () => {
// 2026-W03 is Mon 2026-01-12 .. Sun 2026-01-18.
function priorPost(day: string, impressions: number): PostAnalytics {
return createTestPost({
id: `prior-${day}`,
publishedDate: day,
metrics: { impressions, reactions: 5, comments: 1, shares: 0, clicks: 2, engagementRate: 4 },
});
}
test("should attach a baseline built only from earlier weeks", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
priorPost("2025-12-29", 120),
priorPost("2025-12-31", 130),
priorPost("2026-01-05", 140),
// In-week post, deliberately extreme: if it leaked into the baseline the
// median would move and the week would be compared against itself.
createTestPost({
id: "in-week",
publishedDate: "2026-01-14",
metrics: { impressions: 99999, reactions: 5, comments: 1, shares: 0, clicks: 2, engagementRate: 4 },
}),
];
saveBatch(tempDir, createTestBatch({ posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline, "A generated report must carry a baseline block");
assert.equal(report.baseline!.historyBefore, "2026-W03");
assert.equal(report.baseline!.impressions.status, "ok");
if (report.baseline!.impressions.status !== "ok") return;
assert.equal(report.baseline!.impressions.n, 5, "Only the five earlier posts feed it");
assert.equal(report.baseline!.impressions.median, 120, "The in-week outlier must not leak in");
});
test("should refuse a baseline verdict when prior history is too thin", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
createTestPost({ id: "in-week", publishedDate: "2026-01-14" }),
];
saveBatch(tempDir, createTestBatch({ posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline);
assert.equal(report.baseline!.impressions.status, "insufficient-data");
if (report.baseline!.impressions.status !== "insufficient-data") return;
assert.equal(report.baseline!.impressions.n, 2);
assert.equal(report.baseline!.impressions.required, 5);
});
test("should refuse rather than omit the block when there is no history at all", () => {
tempDir = setupTempDir();
saveBatch(tempDir, createTestBatch({
posts: [createTestPost({ id: "in-week", publishedDate: "2026-01-14" })],
}));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline, "An absent block is indistinguishable from a pre-N17 report");
assert.equal(report.baseline!.impressions.status, "insufficient-data");
});
test("should carry the baseline through to the saved report on disk", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
priorPost("2025-12-29", 120),
priorPost("2025-12-31", 130),
priorPost("2026-01-05", 140),
createTestPost({ id: "in-week", publishedDate: "2026-01-14" }),
];
saveBatch(tempDir, createTestBatch({ posts }));
generateWeeklyReport(tempDir, "2026-W03");
const reloaded = loadWeeklyReport(tempDir, "2026-W03");
assert.ok(reloaded?.baseline, "The block must survive the round-trip to disk");
assert.equal(reloaded!.baseline!.impressions.status, "ok");
});
});
});