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