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"); }); }); }); });