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:
parent
63506f7d5c
commit
e2ad190dda
15 changed files with 1723 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue