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.