linkedin-studio/scripts/analytics/tests/stats.test.ts
Kjell Tore Guttormsen e2ad190dda 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
2026-07-25 20:50:45 +02:00

582 lines
23 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import {
mean,
standardDeviation,
trendDirection,
percentChange,
deviationsFromMean,
weightedOutOfNetworkPct,
median,
medianAbsoluteDeviation,
rollingBaseline,
readAgainstBaseline,
baselineByGroup,
buildBaselineBlock,
BASELINE_WINDOW,
MIN_BASELINE_N,
BAND_K,
} from "../src/utils/stats.js";
describe("stats", () => {
describe("mean", () => {
test("should return mean of values", () => {
const result = mean([10, 20, 30]);
assert.equal(result, 20);
});
test("should return 0 for empty array", () => {
const result = mean([]);
assert.equal(result, 0);
});
test("should handle single value", () => {
const result = mean([42]);
assert.equal(result, 42);
});
});
describe("standardDeviation", () => {
test("should calculate correctly for known values", () => {
// For [2, 4, 4, 4, 5, 5, 7, 9]:
// Mean = 5
// Variance = ((2-5)^2 + (4-5)^2 + (4-5)^2 + (4-5)^2 + (5-5)^2 + (5-5)^2 + (7-5)^2 + (9-5)^2) / 8
// Variance = (9 + 1 + 1 + 1 + 0 + 0 + 4 + 16) / 8 = 32 / 8 = 4
// StdDev = 2
const result = standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]);
assert.equal(result, 2);
});
test("should return 0 for single value", () => {
const result = standardDeviation([5]);
assert.equal(result, 0);
});
test("should return 0 for empty array", () => {
const result = standardDeviation([]);
assert.equal(result, 0);
});
test("should handle uniform values", () => {
const result = standardDeviation([5, 5, 5, 5]);
assert.equal(result, 0);
});
});
describe("trendDirection", () => {
test("should detect up trend", () => {
const result = trendDirection(110, 100);
assert.equal(result, "up");
});
test("should detect down trend", () => {
const result = trendDirection(90, 100);
assert.equal(result, "down");
});
test("should detect stable trend", () => {
const result = trendDirection(103, 100);
assert.equal(result, "stable");
});
test("should use custom threshold", () => {
const result = trendDirection(103, 100, 10);
assert.equal(result, "stable");
});
test("should detect up with custom threshold", () => {
const result = trendDirection(112, 100, 10);
assert.equal(result, "up");
});
});
describe("percentChange", () => {
test("should calculate positive change correctly", () => {
const result = percentChange(110, 100);
assert.equal(result, 10);
});
test("should calculate negative change correctly", () => {
const result = percentChange(90, 100);
assert.equal(result, -10);
});
test("should handle zero previous value", () => {
const result = percentChange(100, 0);
assert.equal(result, 0);
});
test("should handle zero current value", () => {
const result = percentChange(0, 100);
assert.equal(result, -100);
});
test("should handle no change", () => {
const result = percentChange(100, 100);
assert.equal(result, 0);
});
});
describe("deviationsFromMean", () => {
test("should calculate correctly for value above mean", () => {
// Mean of [10, 20, 30] = 20
// StdDev = sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2) / 3) = sqrt((100 + 0 + 100) / 3) = sqrt(66.67) ≈ 8.165
// Deviations for 30 = (30 - 20) / 8.165 ≈ 1.225
const result = deviationsFromMean(30, [10, 20, 30]);
assert.ok(Math.abs(result - 1.225) < 0.01);
});
test("should calculate correctly for value below mean", () => {
const result = deviationsFromMean(10, [10, 20, 30]);
assert.ok(Math.abs(result + 1.225) < 0.01); // Negative deviation
});
test("should return 0 for uniform data", () => {
const result = deviationsFromMean(5, [5, 5, 5]);
assert.equal(result, 0);
});
test("should return 0 for single value", () => {
const result = deviationsFromMean(5, [5]);
assert.equal(result, 0);
});
test("should calculate for value at mean", () => {
const result = deviationsFromMean(20, [10, 20, 30]);
assert.ok(Math.abs(result) < 0.01);
});
});
/**
* Out-of-network reach aggregate (N16). The share is per-post, so the only
* honest roll-up is impressions-weighted: a 50-impression post at 90% must
* not outvote a 10,000-impression post at 20%.
*/
describe("weightedOutOfNetworkPct", () => {
const post = (impressions: number, outOfNetworkPct?: number) => ({
metrics: { impressions, outOfNetworkPct },
});
test("should weight each share by that post's impressions", () => {
// (10000*20 + 1000*80) / 11000 = 25.45… — an unweighted mean would say 50.
const result = weightedOutOfNetworkPct([post(10000, 20), post(1000, 80)]);
assert.equal(result, 25.5, "Should be the impressions-weighted share, not the plain mean");
});
test("should exclude posts that carry no share from the weighting", () => {
// The 5000-impression post has no reading; folding it in as 0 would drag
// the answer to 17.5 and invent data that was never entered.
const result = weightedOutOfNetworkPct([
post(10000, 20),
post(1000, 80),
post(5000, undefined),
]);
assert.equal(result, 25.5, "Posts without a share must not dilute the aggregate");
});
test("should keep a genuine 0 share in the weighting", () => {
// (1000*0 + 1000*50) / 2000 = 25 — an explicit zero is data, not absence.
const result = weightedOutOfNetworkPct([post(1000, 0), post(1000, 50)]);
assert.equal(result, 25);
});
test("should return undefined when no post carries a share", () => {
const result = weightedOutOfNetworkPct([post(1000), post(2000)]);
assert.equal(result, undefined, "Absent data must stay absent, never 0");
});
test("should return undefined for an empty list", () => {
assert.equal(weightedOutOfNetworkPct([]), undefined);
});
test("should return undefined when the carrying posts have no impressions", () => {
// A share of zero impressions has no meaning, and the weights sum to 0 —
// returning 0 here would be a fabricated reading, and NaN a bug.
const result = weightedOutOfNetworkPct([post(0, 40)]);
assert.equal(result, undefined, "Zero total weight must yield undefined, never NaN or 0");
});
test("should round to one decimal (the UI reading is itself rounded)", () => {
// (3000*33.3 + 1000*66.7) / 4000 = 41.65 → 41.7
const result = weightedOutOfNetworkPct([post(3000, 33.3), post(1000, 66.7)]);
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");
});
});
});
});