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

@ -1,18 +1,28 @@
import { parseLinkedInCSV } from "./parsers/csv-parser.js";
import {
getAnalyticsRoot,
getDataRoot,
ensureDirectories,
saveBatch,
loadAllPosts,
} from "./utils/storage.js";
import { detectAlerts } from "./utils/alerts.js";
import { mean, standardDeviation, weightedOutOfNetworkPct } from "./utils/stats.js";
import {
mean,
standardDeviation,
weightedOutOfNetworkPct,
rollingBaseline,
baselineByGroup,
BASELINE_WINDOW,
MIN_BASELINE_N,
} from "./utils/stats.js";
import { loadQueueEntries, labelPosts } from "./utils/queue-join.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { RankableMetric } from "./models/types.js";
import type { RankableMetric, Baseline, BaselineBlock, BaselineReading } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
@ -43,6 +53,61 @@ function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Plain-language rendering of a band reading (N17). */
const READING_TEXT: Record<BaselineReading, string> = {
"above-band": "ABOVE your normal range",
"within-band": "inside your normal range (normal variation, not a trend)",
"below-band": "BELOW your normal range",
"no-verdict": "no verdict",
};
/**
* Print the baseline block FIRST, before absolute totals (N17).
*
* Ordering is the point: the operator's own normal is the only frame in which a
* weekly number means anything at ~2 posts/week, so it leads and the absolutes
* follow. On a refusal this prints the refusal the absolutes still get shown
* below, but with no verdict attached to them.
*/
function printBaseline(baseline: BaselineBlock | undefined, unit: "week" | "month") {
if (!baseline) return; // report generated before baselines existed
console.log(`Vs Your Own Baseline (last ${baseline.window} posts before this ${unit})`);
console.log("─────────────────────────────────────");
const line = (
label: string,
base: BaselineBlock["impressions"],
period: { median?: number; reading: BaselineReading },
fmt: (value: number) => string
) => {
if (base.status !== "ok") {
console.log(`${label} ${base.reason}`);
return;
}
const own = period.median !== undefined ? fmt(period.median) : "no posts";
console.log(
`${label} this ${unit} ${own} vs baseline ${fmt(base.median)} ` +
`(normal range ${fmt(base.band.low)}${fmt(base.band.high)}, n=${base.n})`
);
console.log(`${" ".repeat(label.length)}${READING_TEXT[period.reading]}`);
};
line(
"Impressions/post:",
baseline.impressions,
baseline.period.impressions,
(value) => Math.round(value).toLocaleString()
);
line(
"Engagement rate: ",
baseline.engagementRate,
baseline.period.engagementRate,
(value) => `${value.toFixed(2)}%`
);
console.log();
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
@ -53,9 +118,11 @@ Usage:
node --import tsx src/cli.ts report --month YYYY-MM Generate monthly report with MoM comparison
node --import tsx src/cli.ts trends [--period P] [--metric M] Show trends and alerts
node --import tsx src/cli.ts heatmap Day-of-week performance matrix
node --import tsx src/cli.ts baseline [--by format|pillar] Your own normal, per format and pillar
Options:
--week W ISO week (e.g., 2026-W05), defaults to current week
--by G Group the baseline by "format" or "pillar" (default: both)
--period P Time period: "week" | "month" | "quarter" | "all" (default: "month")
--metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
@ -155,6 +222,8 @@ async function handleReport(root: string, args: string[]) {
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
printBaseline(report.baseline, "week");
console.log("Summary");
console.log("─────────────────────────────────────");
console.log(`Total posts: ${report.summary.totalPosts}`);
@ -351,6 +420,8 @@ async function handleMonthlyReport(root: string, month: string) {
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
printBaseline(report.baseline, "month");
console.log("Summary");
console.log("─────────────────────────────────────");
const s = report.summary;
@ -470,6 +541,98 @@ async function handleHeatmap(root: string) {
}
}
/**
* Per-format and per-pillar baselines (N17).
*
* Format and pillar are not analytics data they come from the post queue via
* a date join (see queue-join.ts), so this surface states its own coverage: how
* many posts it could label, and how many it could not. Each group is judged on
* its own N, so a format the operator has used twice gets a refusal even when
* the overall history is long.
*/
async function handleBaseline(root: string, args: string[]) {
const by = parseOption(args, "--by");
if (by !== undefined && by !== "format" && by !== "pillar") {
console.error(`Unknown grouping: ${by}. Use "format" or "pillar".`);
process.exit(1);
}
const posts = loadAllPosts(root);
if (posts.length === 0) {
console.log("No analytics data imported yet — nothing to build a baseline from.");
return;
}
const queueFile = join(getDataRoot("drafts"), "queue.json");
const entries = loadQueueEntries(queueFile);
const labelled = labelPosts(
[...posts].sort((a, b) => a.publishedDate.localeCompare(b.publishedDate)),
entries
);
console.log("\nYour Own Baseline");
console.log("═════════════════════════════════════");
console.log(`Posts: ${posts.length}`);
console.log(`Window: last ${BASELINE_WINDOW} posts per group, ${MIN_BASELINE_N} required for a verdict`);
if (entries.length === 0) {
console.log(
`\nNo post queue at ${queueFile} — format and pillar are unknown, so only the\n` +
`overall baseline is available. Grouped baselines need queue entries written by\n` +
`the create commands.`
);
}
const groups: Array<"format" | "pillar"> = by ? [by] : ["format", "pillar"];
// Overall first: it is the one baseline that needs no join and can never be
// wrong about which bucket a post belongs to.
const overall = rollingBaseline(labelled.map((post) => post.metrics.impressions));
console.log("\nOverall (impressions/post)");
console.log("─────────────────────────────────────");
printGroupBaseline("all posts", overall);
for (const group of groups) {
const unlabelled = labelled.filter((post) => post[group] === undefined).length;
const byGroup = baselineByGroup(
labelled,
(post) => post[group],
(post) => post.metrics.impressions
);
console.log(`\nBy ${group} (impressions/post)`);
console.log("─────────────────────────────────────");
if (byGroup.size === 0) {
console.log(`No post carries a ${group} label — nothing to group.`);
} else {
for (const [name, baseline] of [...byGroup.entries()].sort(([a], [b]) => a.localeCompare(b))) {
printGroupBaseline(name, baseline);
}
}
if (unlabelled > 0) {
console.log(
`(${unlabelled} of ${labelled.length} posts have no ${group} label and are ` +
`excluded from the groups above — they are counted in Overall.)`
);
}
}
console.log();
}
/** One group's line — a median and band, or the refusal, never both. */
function printGroupBaseline(name: string, baseline: Baseline) {
const label = `${name}:`.padEnd(22);
if (baseline.status !== "ok") {
console.log(`${label}no verdict — ${baseline.n} post(s), ${baseline.required} required`);
return;
}
console.log(
`${label}median ${Math.round(baseline.median).toLocaleString()} ` +
`(normal range ${Math.round(baseline.band.low).toLocaleString()}` +
`${Math.round(baseline.band.high).toLocaleString()}, n=${baseline.n})`
);
}
async function main() {
const root = getAnalyticsRoot();
ensureDirectories(root);
@ -487,6 +650,9 @@ async function main() {
case "heatmap":
await handleHeatmap(root);
break;
case "baseline":
await handleBaseline(root, args);
break;
default:
printUsage();
process.exit(command ? 1 : 0);

View file

@ -73,6 +73,13 @@ export interface WeeklyReport {
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
/**
* The operator's own baseline, from posts published before this week (N17).
* Optional in the type because reports generated before N17 do not carry it
* a consumer must say "no baseline in this report" rather than assume one.
* Newly generated reports always carry it, refusal included.
*/
baseline?: BaselineBlock;
topPerformers: PostAnalytics[];
underperformers: PostAnalytics[];
trends: {
@ -89,6 +96,75 @@ export interface WeeklyReport {
export type TrendDirection = "up" | "down" | "stable";
/**
* A usable baseline: the operator's own recent normal for one metric, plus the
* spread around it. Robust by construction median and MAD, never mean and
* standard deviation, because a single viral post would otherwise define
* "normal" as a number no ordinary post can reach.
*/
export interface BaselineVerdict {
status: "ok";
n: number; // posts actually in the window (never a padded or assumed count)
window: number; // window size the baseline was computed with
median: number;
mad: number; // median absolute deviation — the spread the band is built from
band: { low: number; high: number }; // median ± k·MAD, lower bound floored at 0
}
/**
* The refusal. Below the minimum N a median moves further when one post enters
* than any real week-over-week change would move it, so there is no verdict to
* give and saying so is the feature. Carries the numbers the operator needs
* to know how far off a verdict is, never a value that could be misread as one.
*/
export interface BaselineRefusal {
status: "insufficient-data";
n: number;
required: number;
reason: string; // operator-facing sentence, safe to print verbatim
}
export type Baseline = BaselineVerdict | BaselineRefusal;
/**
* How one number reads against a baseline. `within-band` is the load-bearing
* value: it is the honest answer for a number that a week-over-week percentage
* would dress up as a trend. `no-verdict` propagates a refusal so a caller
* cannot accidentally read one.
*/
export type BaselineReading = "above-band" | "within-band" | "below-band" | "no-verdict";
/**
* Baseline block attached to a period report. Computed from posts published
* STRICTLY BEFORE the reported period, so "this week vs your baseline" is a
* real comparison rather than partly a comparison with itself.
*/
export interface BaselineBlock {
/** Window size used (posts, not days — see BASELINE_WINDOW). */
window: number;
/** Minimum N required for a verdict. */
required: number;
/**
* The reported period itself, whose posts are EXCLUDED from the baseline.
* The baseline is history strictly before it otherwise "this week vs your
* baseline" would be partly a comparison with itself.
*/
historyBefore: string;
impressions: Baseline;
engagementRate: Baseline;
/**
* The reported period read against the baseline above computed in code, so
* a renderer never has to decide for itself whether a number sits inside the
* band. `median` is the period's own median (undefined when it has no posts);
* `reading` is `no-verdict` whenever the baseline was refused or the period
* is empty.
*/
period: {
impressions: { median?: number; reading: BaselineReading };
engagementRate: { median?: number; reading: BaselineReading };
};
}
/**
* Metric keys that are always present and numeric safe for trend/alert ranking
* and `metrics[key]` index access. Excludes the optional, manually-entered
@ -150,6 +226,8 @@ export interface MonthlyReport {
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
/** Baseline from posts published before this month (N17) — see WeeklyReport. */
baseline?: BaselineBlock;
topPerformers: PostAnalytics[];
byWeek: {
week: string;

View file

@ -1,6 +1,6 @@
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
import { mean, weightedOutOfNetworkPct } from "../utils/stats.js";
import { mean, weightedOutOfNetworkPct, buildBaselineBlock } from "../utils/stats.js";
import { detectAlerts } from "../utils/alerts.js";
import { getISOWeek } from "./weekly.js";
@ -115,6 +115,14 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
avgEngagementRate,
avgImpressionsPerPost,
},
// Baseline from earlier months only — same contract as the weekly report:
// always present, refusal included. `startsWith(month)` above and the
// `< month` prefix compare below partition the posts with no overlap.
baseline: buildBaselineBlock(
allPosts.filter(p => p.publishedDate.slice(0, 7) < month),
month,
monthPosts
),
topPerformers,
byWeek,
trends,

View file

@ -1,5 +1,11 @@
import type { PostAnalytics, WeeklyReport } from "../models/types.js";
import { mean, trendDirection, percentChange, weightedOutOfNetworkPct } from "../utils/stats.js";
import {
mean,
trendDirection,
percentChange,
weightedOutOfNetworkPct,
buildBaselineBlock,
} from "../utils/stats.js";
import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js";
import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js";
@ -120,6 +126,13 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week
// Filter posts for target week
const weekPosts = getPostsForWeek(allPosts, targetWeek);
// Baseline: the operator's own normal from EARLIER weeks only. The week
// boundary is computed with the same expression as getPostsForWeek above, so
// every post is either in-week or prior — never both, never neither.
const priorPosts = allPosts.filter(
post => getISOWeek(new Date(post.publishedDate)) < targetWeek
);
// Initialize report structure
const report: WeeklyReport = {
week: targetWeek,
@ -134,6 +147,9 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week
avgEngagementRate: 0,
avgImpressionsPerPost: 0,
},
// Always present on a generated report, refusal included: an absent block
// cannot be told apart from a report made before baselines existed.
baseline: buildBaselineBlock(priorPosts, targetWeek, weekPosts),
topPerformers: [],
underperformers: [],
trends: {

View file

@ -0,0 +1,143 @@
import { existsSync, readFileSync } from "node:fs";
/**
* The post queue's shape, as written by `hooks/scripts/queue-manager.mjs`
* (`queueAdd`). Read-only here: analytics never writes the queue.
*
* Only the fields the join needs are typed. `pillar` and `format` are optional
* because an entry can predate them or be written by hand.
*/
export interface QueueEntry {
id: string;
scheduled_date: string;
pillar?: string;
format?: string;
hook_preview?: string;
}
/** How far a publish date may drift from its planned date and still join. */
const RESCHEDULE_TOLERANCE_DAYS = 2;
/**
* Read the post queue.
*
* Returns [] for a missing, malformed, or non-array file. The join supplies
* OPTIONAL grouping metadata, so a broken queue must degrade to "no labels"
* never take down a report the operator asked for. Rows without a
* `scheduled_date` are dropped: they have nothing to join on.
*/
export function loadQueueEntries(queueFile: string): QueueEntry[] {
if (!existsSync(queueFile)) return [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(queueFile, "utf8"));
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(row): row is QueueEntry =>
typeof row === "object" &&
row !== null &&
typeof (row as QueueEntry).scheduled_date === "string"
);
}
/** Whole days between two YYYY-MM-DD dates, absolute. */
function daysApart(a: string, b: string): number {
const ms = Math.abs(Date.parse(`${a}T00:00:00Z`) - Date.parse(`${b}T00:00:00Z`));
return Math.round(ms / 86_400_000);
}
/**
* Does this entry's hook look like the start of this post's title? The stored
* title is a truncated prefix of the post and the hook preview is a truncated
* prefix of the draft, so neither contains the other reliably compare on the
* shorter overlap, case-insensitively.
*/
function hookMatchesTitle(hook: string | undefined, title: string): boolean {
if (!hook) return false;
const a = hook.trim().toLowerCase();
const b = title.trim().toLowerCase();
if (a.length === 0) return false;
const overlap = Math.min(a.length, b.length, 40);
return a.slice(0, overlap) === b.slice(0, overlap);
}
/**
* Attach `pillar` and `format` to posts by joining the queue on publish date.
*
* The rules, in order. Every ambiguous case resolves to UNLABELLED a guessed label is
* worse than no label:
* 1. Exact date match wins over a near one.
* 2. Otherwise an entry within ±2 days joins (a plan the operator published a
* day late is the same post).
* 3. Two candidates at the same distance are disambiguated on the hook preview
* against the post title; if that does not resolve to exactly one, the post
* stays UNLABELLED.
* 4. An entry labels at most one post. Reusing it would invent a second post of
* that format, inflating the group's N with a post that never existed.
*
* Guessing is the failure mode to avoid: a post filed under the wrong format
* corrupts the baseline it is then measured against. Unlabelled posts are
* always kept in the returned list they simply do not enter a group.
*/
export function labelPosts<T extends { publishedDate: string; title: string }>(
posts: T[],
entries: QueueEntry[]
): Array<T & { pillar?: string; format?: string }> {
const claimed = new Set<QueueEntry>();
/** Best entry for this post, or undefined when nothing resolves it. */
const resolve = (post: T): QueueEntry | undefined => {
const candidates = entries.filter(
(candidate) =>
!claimed.has(candidate) &&
daysApart(candidate.scheduled_date, post.publishedDate) <= RESCHEDULE_TOLERANCE_DAYS
);
if (candidates.length === 0) return undefined;
// Closest first; an exact match is distance 0 and therefore always preferred.
const closest = Math.min(
...candidates.map((candidate) => daysApart(candidate.scheduled_date, post.publishedDate))
);
const tied = candidates.filter(
(candidate) => daysApart(candidate.scheduled_date, post.publishedDate) === closest
);
if (tied.length === 1) return tied[0];
const byHook = tied.filter((candidate) => hookMatchesTitle(candidate.hook_preview, post.title));
return byHook.length === 1 ? byHook[0] : undefined;
};
// Exact matches are resolved first so a same-day post cannot lose its entry
// to a neighbouring post that merely sits within tolerance.
const order = [...posts.keys()].sort((a, b) => {
const distance = (index: number) => {
const dates = entries.map((entry) => daysApart(entry.scheduled_date, posts[index].publishedDate));
return dates.length > 0 ? Math.min(...dates) : Number.POSITIVE_INFINITY;
};
return distance(a) - distance(b);
});
const labels = new Map<number, QueueEntry>();
for (const index of order) {
const match = resolve(posts[index]);
if (match) {
claimed.add(match);
labels.set(index, match);
}
}
return posts.map((post, index) => {
const match = labels.get(index);
return {
...post,
pillar: match?.pillar,
format: match?.format,
};
});
}

View file

@ -1,4 +1,9 @@
import type { TrendDirection } from "../models/types.js";
import type {
TrendDirection,
Baseline,
BaselineBlock,
BaselineReading,
} from "../models/types.js";
/**
* Calculate arithmetic mean of values.
@ -87,6 +92,267 @@ export function weightedOutOfNetworkPct(posts: ReachWeightable[]): number | unde
return Math.round((weightedSum / totalWeight) * 10) / 10;
}
/**
* Baseline window, in POSTS not days.
*
* A calendar window silently shrinks: at ~2 posts/week a 30-day window holds
* about 8 posts, but one slow month turns it into 3 without the operator seeing
* it happen. "Your last 10 posts" is a count they can verify, and at 2/week it
* spans roughly five weeks recent enough to reflect the current strategy,
* long enough that one viral post does not own the median.
*/
export const BASELINE_WINDOW = 10;
/**
* Minimum posts required before any verdict is given.
*
* With fewer than five values the median is the midpoint of at most two
* observations, so one new post can shift it further than any genuine
* week-over-week change and the MAD around it is degenerate. Any "up" or
* "down" read off such a baseline is a reading of noise. Five is the floor
* where median and MAD both stop moving on single-post entry; it is a
* documented default, overridable per call, not a law of statistics.
*/
export const MIN_BASELINE_N = 5;
/**
* Band half-width in MADs. At k=1 the band covers the middle of the operator's
* own spread, which is the question being asked ("is this number ordinary for
* me?"). Widen it per call when a metric is genuinely noisier.
*/
export const BAND_K = 1;
/** Round to one decimal — the same reporting precision as the reach aggregate. */
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/**
* Median of the values. Returns undefined for an empty list absent data stays
* absent, and 0 would be a fabricated baseline.
*
* Sorts a copy numerically: callers pass recency-ordered data, and mutating or
* trusting that order would silently corrupt both the caller's array and the
* result.
*/
export function median(values: number[]): number | undefined {
if (values.length === 0) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
/**
* Median absolute deviation median(|x median(x)|).
*
* The robust counterpart to standardDeviation above. Both exist on purpose:
* the alert engine WANTS outlier sensitivity (a spike is the signal it hunts),
* while baseline framing wants the opposite a normal-range band that one
* viral post cannot inflate. Returns undefined for an empty list; 0 for
* identical values is a real answer, not a missing one.
*/
export function medianAbsoluteDeviation(values: number[]): number | undefined {
const mid = median(values);
if (mid === undefined) return undefined;
return median(values.map((value) => Math.abs(value - mid)));
}
/**
* Build a rolling baseline from a metric's recent history.
*
* @param orderedValues - the metric, OLDEST FIRST. Order carries recency, so
* the window is positional (the last `window` entries); the median inside it
* is value-sorted. Non-finite entries are dropped before the window is taken,
* so a gap never occupies a slot that a real post could have filled.
* @param opts.window - override BASELINE_WINDOW
* @param opts.minN - override MIN_BASELINE_N
* @param opts.k - override BAND_K
*
* Returns either a verdict or an explicit refusal. There is no third state and
* no "provisional" number: a caller that cannot get a verdict must say so
* rather than print a median it was told not to trust.
*/
export function rollingBaseline(
orderedValues: number[],
opts: { window?: number; minN?: number; k?: number } = {}
): Baseline {
const window = opts.window ?? BASELINE_WINDOW;
const minN = opts.minN ?? MIN_BASELINE_N;
const k = opts.k ?? BAND_K;
const usable = orderedValues.filter((value) => Number.isFinite(value));
const inWindow = usable.slice(-window);
if (inWindow.length < minN) {
return {
status: "insufficient-data",
n: inWindow.length,
required: minN,
reason:
`Too little data for a verdict: ${inWindow.length} post(s) in the baseline ` +
`window, ${minN} required. A median built on fewer moves further when one ` +
`post enters than any real change would move it.`,
};
}
const mid = median(inWindow) as number;
const mad = medianAbsoluteDeviation(inWindow) as number;
return {
status: "ok",
n: inWindow.length,
window,
median: round1(mid),
mad: round1(mad),
band: {
// Impressions and engagement rate are non-negative by nature; an
// arithmetically negative lower bound is not a readable floor.
low: round1(Math.max(0, mid - k * mad)),
high: round1(mid + k * mad),
},
};
}
/**
* Read one value against a baseline.
*
* `within-band` is the answer that earns this function its place: a number
* 7% above the median reads as "up" in any week-over-week percentage while
* sitting entirely inside the operator's own ordinary spread. A refused
* baseline yields `no-verdict`, so the refusal cannot be lost at the call site.
*/
export function readAgainstBaseline(value: number, baseline: Baseline): BaselineReading {
if (baseline.status !== "ok") return "no-verdict";
// Edges count as inside: the band is the normal range, not an exclusive zone.
if (value > baseline.band.high) return "above-band";
if (value < baseline.band.low) return "below-band";
return "within-band";
}
/**
* One baseline per group the per-format and per-pillar view.
*
* Format and pillar are NOT stored in analytics (they live in the post queue,
* `drafts/queue.json`), so the label arrives through a resolver rather than
* being assumed as a field the same join the newsletter's previous-edition
* calibration performs. Items must be oldest-first; grouping preserves order.
*
* Each group is judged on ITS OWN count. Eight posts in total do not license a
* verdict about the two that were carousels that specific over-read is what
* the refusal exists to prevent.
*
* Items whose label is empty or undefined are skipped and never collected into a phantom group:
* an unlabelled post is missing metadata, not a format.
*/
export function baselineByGroup<T>(
items: T[],
keyOf: (item: T) => string | undefined,
valueOf: (item: T) => number,
opts: { window?: number; minN?: number; k?: number } = {}
): Map<string, Baseline> {
const grouped = new Map<string, number[]>();
for (const item of items) {
const key = keyOf(item);
if (key === undefined || key === null || key === "") continue;
const bucket = grouped.get(key);
if (bucket) {
bucket.push(valueOf(item));
} else {
grouped.set(key, [valueOf(item)]);
}
}
const result = new Map<string, Baseline>();
for (const [key, values] of grouped) {
result.set(key, rollingBaseline(values, opts));
}
return result;
}
/**
* Minimal shape a post needs to feed a baseline block keeps this usable from
* both report builders without importing the full record (the ReachWeightable
* pattern above).
*/
interface BaselineSourcePost {
publishedDate: string;
metrics: { impressions: number; engagementRate: number };
}
/**
* Build the baseline block a period report carries.
*
* @param priorPosts - posts published STRICTLY BEFORE the reported period. The
* caller filters, because "before this week" and "before this month" are its
* own comparison to make; passing the period's own posts would make the
* report partly a comparison with itself.
* @param periodLabel - the excluded period, recorded so a reader can see what
* the baseline is a baseline *for*.
* @param periodPosts - the reported period's own posts, read against the
* baseline. The comparison uses the period's MEDIAN, not its mean: comparing
* a mean against a median band would let one viral post turn an ordinary week
* into a "trend", which is the exact over-read this engine exists to stop.
*
* Sorts by publish date internally: storage order is file order, and the window
* is positional, so an unsorted input would silently window the wrong posts.
* Always returns a block a refusal is a result, not an error, and an omitted
* block would be indistinguishable from a report generated before this existed.
*/
export function buildBaselineBlock(
priorPosts: BaselineSourcePost[],
periodLabel: string,
periodPosts: BaselineSourcePost[] = [],
opts: { window?: number; minN?: number; k?: number } = {}
): BaselineBlock {
const ordered = [...priorPosts].sort((a, b) =>
a.publishedDate.localeCompare(b.publishedDate)
);
const impressions = rollingBaseline(
ordered.map((post) => post.metrics.impressions),
opts
);
const engagementRate = rollingBaseline(
ordered.map((post) => post.metrics.engagementRate),
opts
);
/** Period median vs its baseline; no median means nothing to read. */
const read = (
values: number[],
baseline: Baseline
): { median?: number; reading: BaselineReading } => {
const mid = median(values.filter((value) => Number.isFinite(value)));
if (mid === undefined) return { reading: "no-verdict" };
return { median: round1(mid), reading: readAgainstBaseline(mid, baseline) };
};
return {
window: opts.window ?? BASELINE_WINDOW,
required: opts.minN ?? MIN_BASELINE_N,
historyBefore: periodLabel,
impressions,
engagementRate,
period: {
impressions: read(
periodPosts.map((post) => post.metrics.impressions),
impressions
),
engagementRate: read(
periodPosts.map((post) => post.metrics.engagementRate),
engagementRate
),
},
};
}
/**
* Calculate how many standard deviations a value is from the mean.
* Returns 0 if standard deviation is 0.

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

View file

@ -2709,6 +2709,227 @@ fi
echo ""
# --- Section 16x: Baseline Engine - Median, Band, Refusal (N17 / E#5) ---
echo "--- Baseline Engine: median + band + minimum-N refusal (N17) ---"
# At a normal cadence a week holds two or three posts, so an absolute total answers
# nothing on its own and a week-over-week percentage answers worse than nothing: it
# swings on sample composition and reads as a trend. The engine's whole value is in
# two disciplines, and BOTH fail silently if eroded:
# (E#5 robust) median and MAD, never mean and stddev. One viral post would
# otherwise define "normal" as a number no ordinary post reaches -
# and the operator would read every ordinary week as a decline.
# (E#5 refusal) below the minimum N there is NO verdict. This is the check most
# likely to be "helpfully" removed later, because a refusal looks
# like missing functionality rather than the feature it is.
# (E#5 honesty) the baseline excludes the reported period (else the week is partly
# compared with itself), the band is floored at 0, an unlabelled post
# is never bucketed, and each group is judged on its OWN n.
# (rendering) the report LEADS with the baseline and prints the code's reading
# rather than eyeballing the band; the refusal is never softened
# into a hedge.
STATS_N17="scripts/analytics/src/utils/stats.ts"
TYPES_N17="scripts/analytics/src/models/types.ts"
QJOIN_N17="scripts/analytics/src/utils/queue-join.ts"
WEEKLY_N17="scripts/analytics/src/reports/weekly.ts"
MONTHLY_N17="scripts/analytics/src/reports/monthly.ts"
RPT_N17="commands/report.md"
ANA_N17="commands/analyze.md"
refusal_honest() { # $1 = text; honest iff it REFUSES a verdict, NAMES the threshold, and FORBIDS a substitute
echo "$1" | grep -qF "insufficient-data" \
&& echo "$1" | grep -qF "without" \
&& echo "$1" | grep -qF "do not call it a trend"
}
RF_SELFTEST_OK=1
if ! refusal_honest "when status is insufficient-data, report the numbers without a verdict and do not call it a trend"; then
RF_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-honest refusal probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if refusal_honest "$probe"; then
RF_SELFTEST_OK=0; echo " false-positive FAIL: under-specified refusal probe accepted -> $probe"
fi
done <<'NEGATIVE16X'
when data is thin, report the numbers without a verdict and do not call it a trend
status insufficient-data means show the week-over-week percentage without a verdict instead
insufficient-data: say the trend is early but real, without a firm verdict
NEGATIVE16X
if [ "$RF_SELFTEST_OK" -eq 1 ]; then
pass "refusal self-test: predicate needs the status token + no-verdict + no-trend-substitute (1 accepted, 3 under-specified rejected)"
else
fail "refusal self-test failed - the N17 refusal lint is vacuous or over-eager"
fi
# (E#5 robust) the robust primitives exist and are documented as the outlier-proof pair
if grep -qF "export function median" "$STATS_N17" 2>/dev/null \
&& grep -qF "export function medianAbsoluteDeviation" "$STATS_N17" 2>/dev/null; then
pass "stats exposes median + MAD, the outlier-robust pair the baseline is built on (E#5)"
else
fail "$STATS_N17 has no median/MAD - the baseline would rest on mean/stddev (E#5)"
fi
# (E#5 robust) the reason mean/stddev were NOT used is written down, so it survives a refactor
if grep -qF "one viral post" "$STATS_N17" 2>/dev/null; then
pass "stats records WHY the baseline is median-based (one viral post must not define normal)"
else
fail "$STATS_N17 does not state why median/MAD replace mean/stddev - the choice will be undone (E#5)"
fi
# (E#5 robust) absent data stays absent: an empty history is not a zero baseline
if grep -qF "number | undefined" "$STATS_N17" 2>/dev/null \
&& grep -qF "0 would be a fabricated baseline" "$STATS_N17" 2>/dev/null; then
pass "median returns unknown for an empty history, never 0 (same discipline as the reach roll-up)"
else
fail "$STATS_N17 may return 0 for an empty history - a fabricated baseline (E#5)"
fi
# (E#5 refusal) the threshold is a named constant with a stated rationale, and overridable
if grep -qF "export const MIN_BASELINE_N" "$STATS_N17" 2>/dev/null \
&& grep -qF "documented default, overridable per call" "$STATS_N17" 2>/dev/null; then
pass "the minimum-N threshold is a named, justified, overridable constant (E#5)"
else
fail "$STATS_N17 has no justified MIN_BASELINE_N - the threshold is a magic number (E#5)"
fi
# (E#5 refusal) the window is in POSTS, and the reason a calendar window was rejected is kept
if grep -qF "export const BASELINE_WINDOW" "$STATS_N17" 2>/dev/null \
&& grep -qF "silently shrinks" "$STATS_N17" 2>/dev/null; then
pass "the window is counted in posts, with the calendar-window failure mode documented"
else
fail "$STATS_N17 window is unnamed or undocumented - a calendar window will creep back in (E#5)"
fi
# (E#5 refusal) refusal is a first-class RESULT, not an exception or a null
if grep -qF '"insufficient-data"' "$TYPES_N17" 2>/dev/null \
&& grep -qF "BaselineRefusal" "$TYPES_N17" 2>/dev/null; then
pass "the refusal is a typed result the caller must handle, not a null or a throw (E#5)"
else
fail "$TYPES_N17 has no BaselineRefusal type - a refusal can be lost at the call site (E#5)"
fi
# (E#5 refusal) a refused baseline cannot be read as a verdict downstream
if grep -qF '"no-verdict"' "$TYPES_N17" 2>/dev/null \
&& grep -qF "readAgainstBaseline" "$STATS_N17" 2>/dev/null; then
pass "a refused baseline propagates as no-verdict through the reading function (E#5)"
else
fail "$STATS_N17 reading path can produce a verdict from a refused baseline (E#5)"
fi
# (E#5 honesty) the reported period is EXCLUDED from its own baseline
if grep -qF "STRICTLY BEFORE" "$STATS_N17" 2>/dev/null \
&& grep -qF "historyBefore" "$TYPES_N17" 2>/dev/null; then
pass "the baseline excludes the reported period, so the comparison is not partly with itself"
else
fail "$STATS_N17 does not exclude the reported period from its own baseline (E#5)"
fi
# (E#5 honesty) the period is compared on its MEDIAN, not its mean
if grep -qF "period's MEDIAN, not its mean" "$STATS_N17" 2>/dev/null; then
pass "the period is read against the band on its median (one viral post cannot fake a trend)"
else
fail "$STATS_N17 may compare a period mean against a median band (E#5)"
fi
# (E#5 honesty) the band cannot go negative on a non-negative metric
if grep -qF "Math.max(0, mid - k * mad)" "$STATS_N17" 2>/dev/null; then
pass "the band's lower bound is floored at 0 for naturally non-negative metrics"
else
fail "$STATS_N17 band can present a negative lower bound (E#5)"
fi
# (E#5 honesty) per-group refusal: each group judged on its own n, no phantom groups
if grep -qF "export function baselineByGroup" "$STATS_N17" 2>/dev/null \
&& grep -qF "judged on ITS OWN count" "$STATS_N17" 2>/dev/null; then
pass "grouped baselines are judged per group, so a barely-used format gets a refusal (E#5)"
else
fail "$STATS_N17 grouped baseline can borrow the overall n for a thin group (E#5)"
fi
# (E#5 honesty) an unlabelled post is skipped, never collected into a group called "undefined"
if grep -qF "never collected into a phantom group" "$STATS_N17" 2>/dev/null; then
pass "unlabelled posts are skipped rather than bucketed as a format that does not exist"
else
fail "$STATS_N17 may bucket unlabelled posts into a phantom group (E#5)"
fi
# (join) the format/pillar join refuses to guess, and never reuses one entry twice
if grep -qF "resolves to UNLABELLED" "$QJOIN_N17" 2>/dev/null \
&& grep -qF "labels at most one post" "$QJOIN_N17" 2>/dev/null; then
pass "the queue join leaves ambiguity unlabelled and never double-counts an entry (E#5)"
else
fail "$QJOIN_N17 join may guess a label or reuse an entry - it would poison the baseline (E#5)"
fi
# (join) a missing or broken queue degrades to "no labels", never takes the report down
if grep -qF "degrade to \"no labels\"" "$QJOIN_N17" 2>/dev/null; then
pass "a missing/malformed queue yields no labels instead of failing the report"
else
fail "$QJOIN_N17 does not document the degrade-to-unlabelled contract (E#5)"
fi
# (wiring) both period reports carry the block, refusal included - absence is ambiguous
if grep -qF "buildBaselineBlock" "$WEEKLY_N17" 2>/dev/null \
&& grep -qF "buildBaselineBlock" "$MONTHLY_N17" 2>/dev/null; then
pass "weekly and monthly reports both attach a baseline block (E#5)"
else
fail "a period report does not attach a baseline block - the framing is unavailable (E#5)"
fi
if grep -qF "refusal included" "$WEEKLY_N17" 2>/dev/null; then
pass "the weekly block is always present, refusal included (absent != pre-N17 report)"
else
fail "$WEEKLY_N17 may omit the baseline block, which is indistinguishable from an old report"
fi
# (rendering) the report LEADS with the baseline and does not compute the verdict itself
if grep -qF "LEADS with the operator's own baseline" "$RPT_N17" 2>/dev/null \
&& grep -qF "Do not compute the verdict yourself" "$RPT_N17" 2>/dev/null; then
pass "/linkedin:report leads with the baseline and prints the code's reading (E#5)"
else
fail "$RPT_N17 does not lead with the baseline, or judges the band by eye (E#5)"
fi
# (rendering) within-band is stated as normal variation, not dressed up as momentum
if grep -qF "normal variation" "$RPT_N17" 2>/dev/null \
&& grep -qF "not a trend" "$RPT_N17" 2>/dev/null; then
pass "an in-band week is reported as normal variation rather than as momentum"
else
fail "$RPT_N17 may present in-band variation as a trend (E#5)"
fi
# (rendering) the refusal is honest: named status, no verdict, no substitute metric
if refusal_honest "$(cat "$RPT_N17" 2>/dev/null)"; then
pass "/linkedin:report refuses the verdict on thin data without substituting a percentage (E#5)"
else
fail "report.md softens or skips the minimum-N refusal (E#5)"
fi
# (rendering) week-over-week loses to the baseline when they disagree
if grep -qF "the baseline wins" "$RPT_N17" 2>/dev/null; then
pass "report.md resolves a WoW-vs-baseline disagreement in the baseline's favour (E#5)"
else
fail "$RPT_N17 does not say which frame wins when WoW and baseline disagree (E#5)"
fi
# (rendering) the diagnostic surface verifies the problem is real BEFORE diagnosing it
if grep -qF "establish whether there is a problem at all" "$ANA_N17" 2>/dev/null \
&& grep -qF "within-band" "$ANA_N17" 2>/dev/null; then
pass "/linkedin:analyze checks the drop against the band before diagnosing it (E#5)"
else
fail "$ANA_N17 diagnoses a reported drop without first testing it against the baseline (E#5)"
fi
# (rendering) analyze refuses to upgrade a refusal into a diagnosis
if grep -qF "Never upgrade a refusal into a verdict" "$ANA_N17" 2>/dev/null; then
pass "analyze keeps a no-verdict provisional instead of diagnosing on self-report alone"
else
fail "$ANA_N17 may turn a no-verdict into a confident diagnosis (E#5)"
fi
echo ""
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -2779,12 +3000,22 @@ echo ""
# weighted roll-up/zero-weight grep + import reach-column grep + report reach-split/
# never-estimate compound grep + report reach do-next grep + A2-F11 gated-update
# compound grep + shipped-template write-ban grep + boundary-map reach/unverified grep +
# boundary-map dwell/saves grep + data-README entry-rules grep) = 228.
# boundary-map dwell/saves grep + data-README entry-rules grep) = 228; +23 for N17's
# twenty-three UNCONDITIONAL Section-16x checks (refusal self-test + median/MAD grep +
# why-median rationale grep + empty-history-unknown grep + MIN_BASELINE_N justified-
# constant grep + BASELINE_WINDOW posts-not-calendar grep + BaselineRefusal typed-result
# grep + no-verdict propagation grep + excludes-reported-period grep + period-median-not-
# mean grep + band-floored-at-0 grep + per-group own-n grep + no-phantom-group grep +
# join-unlabelled/no-reuse grep + join-degrade-to-no-labels grep + weekly+monthly block
# wiring grep + always-present/refusal-included grep + report leads-with-baseline/no-eyeball
# compound grep + normal-variation-not-trend grep + report refusal-honest compound grep +
# WoW-loses-to-baseline grep + analyze verify-before-diagnose grep + analyze never-upgrade-
# refusal grep) = 251.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
# clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=228
ASSERT_BASELINE_FLOOR=251
TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"