feat(linkedin-studio): N16 — out-of-network-andel + patterns-oppdatering + boundary-map [skip-docs]

Reach-splitten (in/out-of-network) er native i LinkedIns post-analytics siden juni
2026, men vises som PROSENT og finnes ikke i CSV-eksporten. Planen antok to
manuelle antall; verifiseringen viste prosent, så modellen er ett felt —
outOfNetworkPct — og in-network er komplementet.

- parseOptionalPercent: egen parser, ikke parseOptionalCount. Komma er desimal
  (36,5 -> 36.5, aldri 365), og verdi >100 avvises: i én kolonne kan ikke et
  absolutt antall skilles fra en andel, så svaret er unknown, ikke en gjetning.
  Blank/ikke-numerisk/negativ -> unknown; ekte 0 beholdes.
- Ett lagret halvpart, kryssjekket: In-network godtas og lagres som komplement;
  et transkribert par som ikke summerer til ~100 (±1 avrunding) forkastes som
  unknown i stedet for å bli halvveis trodd.
- weightedOutOfNetworkPct: impressions-vektet roll-up (avgOutOfNetworkPct, uke +
  måned). Flatt snitt lar en 50-visnings-post slå en på 10 000; poster uten
  avlesning ekskluderes, og null vekt gir undefined — aldri 0, aldri NaN.
- Reach inngår ALDRI i engagementRate (distribusjon != engasjement). Rapporten
  leser den som akvisisjon (ut) vs resonans (inn), og sier «ikke ført for denne
  perioden» framfor å estimere. En reach-innsikt går inn i N15s do-next-kanal.
- Step 7c (A2-F11): rapporten tilbyr diff mot brukerens engagement-patterns.md
  med eksplisitt go — aldri stille skriving, aldri inn i den shippede malen.
- Boundary-map (E#9): dwell eksplisitt umålbar, saves partner-gated, reach
  native men CSV-eksport uverifisert.
- Reach-frie importer er byte-identiske med før, på skjerm og på disk.

TDD: rødt bevist først (10 feilende), analytics 119 -> 144 tester, tsc ren.
test-runner 232 -> 247 (Section 16w, gulv 213 -> 228). Alle suiter grønne.
CHANGELOG: N15-oppføringen manglet og er backfilt sammen med N16.

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 15:56:04 +02:00
commit 63506f7d5c
21 changed files with 841 additions and 13 deletions

View file

@ -6,7 +6,7 @@ import {
loadAllPosts,
} from "./utils/storage.js";
import { detectAlerts } from "./utils/alerts.js";
import { mean, standardDeviation } from "./utils/stats.js";
import { mean, standardDeviation, weightedOutOfNetworkPct } from "./utils/stats.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
@ -30,6 +30,19 @@ function savesSuffix(saves?: number): string {
return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : "";
}
/**
* Per-post reach suffix. Empty string when the post carries no manual
* out-of-network share, so reach-free output stays identical to the pre-N16 CLI.
*/
function reachSuffix(outOfNetworkPct?: number): string {
return outOfNetworkPct !== undefined ? ` | ${outOfNetworkPct}% out-of-network` : "";
}
/** Round to one decimal — keeps the derived in-network half free of float noise. */
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
@ -90,6 +103,15 @@ async function handleImport(root: string, args: string[]) {
console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`);
}
// Same for the reach split when the CSV carried an Out-of-network (or
// In-network) column. Counting the posts that carry a reading makes partial
// coverage visible instead of implying the whole batch was transcribed.
const reachPosts = batch.posts.filter((p) => p.metrics.outOfNetworkPct !== undefined);
if (reachPosts.length > 0) {
const weighted = weightedOutOfNetworkPct(batch.posts);
console.log(`Reach entered: ${weighted}% out-of-network across ${reachPosts.length} post(s) (manual, impressions-weighted)`);
}
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
@ -144,6 +166,10 @@ async function handleReport(root: string, args: string[]) {
if (report.summary.totalSaves !== undefined) {
console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
if (report.summary.avgOutOfNetworkPct !== undefined) {
console.log(`Out-of-network: ${report.summary.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
console.log(`In-network: ${round1(100 - report.summary.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
}
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
@ -154,7 +180,7 @@ async function handleReport(root: string, args: string[]) {
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}
@ -165,7 +191,7 @@ async function handleReport(root: string, args: string[]) {
for (const post of report.underperformers.slice(0, 3)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}
@ -342,6 +368,10 @@ async function handleMonthlyReport(root: string, month: string) {
if (s.totalSaves !== undefined) {
console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
if (s.avgOutOfNetworkPct !== undefined) {
console.log(`Out-of-network: ${s.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
console.log(`In-network: ${round1(100 - s.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
}
console.log();
if (report.byWeek.length > 0) {
@ -359,7 +389,7 @@ async function handleMonthlyReport(root: string, month: string) {
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
}
console.log();
}

View file

@ -23,6 +23,24 @@ export interface PostMetrics {
// It is deliberately NOT folded into engagementRate (which stays comparable
// to historical, saves-free data) — saves is surfaced as its own signal.
saves?: number;
// `outOfNetworkPct` is OPTIONAL and manually entered — the share (0100) of a
// post's impressions that came from people who did NOT follow or connect with
// the author. LinkedIn shows the in-network/out-of-network split natively in
// post analytics (Discovery section, under Impressions; progressive global
// rollout from June 2026) as a PERCENTAGE split, and does NOT put it in the
// CSV export — so the ingest is a percent cell the user adds to the CSV, read
// off that panel (see csv-parser.ts).
//
// Only the out-of-network half is stored: in-network is its complement by
// definition (they describe one split), so keeping both would allow a
// self-contradicting record. A missing column, a blank cell, a non-numeric
// cell, a negative, or a value above 100 stays undefined — "unknown", never
// coerced to 0; a genuine 0 is kept as 0 (nothing left the network).
//
// NOT folded into engagementRate: reach is a distribution signal, not
// engagement. High out-of-network = the post acquired new audience; high
// in-network engagement = it deepened the existing one.
outOfNetworkPct?: number;
// NOTE: `dwell` remains absent and unmeasurable. Dwell time is internal to
// LinkedIn for organic posts — not exportable, no UI count to transcribe, no
// API. Do not fabricate a dwell field or surface.
@ -48,6 +66,10 @@ export interface WeeklyReport {
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
// Optional — present only when ≥1 post carries a manual out-of-network share.
// Impressions-WEIGHTED, so a small post with a high share cannot outvote a
// large one (see weightedOutOfNetworkPct).
avgOutOfNetworkPct?: number;
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
@ -122,6 +144,9 @@ export interface MonthlyReport {
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
// Optional — present only when ≥1 post carries a manual out-of-network share
// (impressions-weighted; see weightedOutOfNetworkPct).
avgOutOfNetworkPct?: number;
avgEngagementRate: number;
avgImpressionsPerPost: number;
};

View file

@ -84,6 +84,64 @@ function parseOptionalCount(value: string): number | undefined {
return parsed;
}
/**
* Rounding slack, in percentage points, allowed between the two halves of the
* reach split. LinkedIn rounds each half independently for display, so a
* transcribed "63% / 37%" can legitimately sum to 99 or 101.
*/
const REACH_SPLIT_TOLERANCE = 1;
/** Round to one decimal — the UI reading is itself a rounded percentage. */
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/**
* Parse an OPTIONAL manually-entered PERCENTAGE share (out-of-network reach).
* Distinct from parseOptionalCount in two ways that matter:
* - a share never carries a thousands separator, so a comma is always the
* DECIMAL mark here ("36,5" 36.5). parseOptionalCount's US-thousands rule
* would read that as 365.
* - a share above 100 is not a share. It is most likely an absolute
* impression count pasted into a percent column, and one column cannot tell
* a count from a share so the honest answer is unknown, never a guess.
* Otherwise the same contract as the saves field:
* - blank / absent / non-numeric / negative undefined ("unknown", never 0)
* - a genuine "0" 0 (nothing left the network)
* - "37%", "37 %", "37" 37
*/
function parseOptionalPercent(value: string): number | undefined {
if (!value) return undefined;
const cleaned = value.replace(/"/g, "").replace(/%/g, "").trim();
if (cleaned === "") return undefined;
const parsed = Number(cleaned.replace(/,/g, "."));
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return undefined;
return round1(parsed);
}
/**
* Reduce whichever halves of the reach split the user transcribed to the single
* stored value: the out-of-network share.
* - out-of-network only that value
* - in-network only its complement (they describe one split)
* - both, consistent the out-of-network reading
* - both, contradictory undefined. One cell is a misreading and we cannot
* tell which, so the record stays unknown rather than silently trusting one.
*/
function resolveOutOfNetworkPct(
outOfNetwork: number | undefined,
inNetwork: number | undefined
): number | undefined {
if (outOfNetwork !== undefined && inNetwork !== undefined) {
const sum = outOfNetwork + inNetwork;
return Math.abs(sum - 100) <= REACH_SPLIT_TOLERANCE ? outOfNetwork : undefined;
}
if (outOfNetwork !== undefined) return outOfNetwork;
if (inNetwork !== undefined) return round1(100 - inNetwork);
return undefined;
}
/**
* Normalizes date to YYYY-MM-DD format
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
@ -225,6 +283,19 @@ export function parseLinkedInCSV(
metrics.saves = saves;
}
// Optional manual-entry reach split: only when the user augmented this CSV
// with an Out-of-network (or In-network) column, read off the native
// Discovery panel in post analytics — LinkedIn does not export it. Either
// half is accepted and reduced to the out-of-network share; anything
// unreadable or self-contradicting stays undefined ("unknown", never 0).
const outOfNetworkPct = resolveOutOfNetworkPct(
parseOptionalPercent(findColumn(record, ["out-of-network", "out of network", "outofnetwork"])),
parseOptionalPercent(findColumn(record, ["in-network", "in network", "innetwork"]))
);
if (outOfNetworkPct !== undefined) {
metrics.outOfNetworkPct = outOfNetworkPct;
}
return {
id: generatePostId(title, date),
title,

View file

@ -1,6 +1,6 @@
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
import { mean } from "../utils/stats.js";
import { mean, weightedOutOfNetworkPct } from "../utils/stats.js";
import { detectAlerts } from "../utils/alerts.js";
import { getISOWeek } from "./weekly.js";
@ -34,6 +34,9 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
const totalSaves = savesPosts.length > 0
? savesPosts.reduce((s, p) => s + (p.metrics.saves ?? 0), 0)
: undefined;
// Optional out-of-network share: impressions-weighted, present only when ≥1
// post carries a reading — keeps reach-free months identical to pre-N16 output.
const avgOutOfNetworkPct = weightedOutOfNetworkPct(monthPosts);
const avgEngagementRate = totalPosts > 0
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
: 0;
@ -108,6 +111,7 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
totalShares,
totalClicks,
...(totalSaves !== undefined ? { totalSaves } : {}),
...(avgOutOfNetworkPct !== undefined ? { avgOutOfNetworkPct } : {}),
avgEngagementRate,
avgImpressionsPerPost,
},

View file

@ -1,5 +1,5 @@
import type { PostAnalytics, WeeklyReport } from "../models/types.js";
import { mean, trendDirection, percentChange } from "../utils/stats.js";
import { mean, trendDirection, percentChange, weightedOutOfNetworkPct } from "../utils/stats.js";
import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js";
import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js";
@ -173,6 +173,14 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week
report.summary.totalSaves = totalSaves;
}
// Same contract for the out-of-network share: impressions-weighted, and only
// present when at least one post carried a reading (reach-free reports stay
// byte-identical to pre-N16 output).
const avgOutOfNetworkPct = weightedOutOfNetworkPct(weekPosts);
if (avgOutOfNetworkPct !== undefined) {
report.summary.avgOutOfNetworkPct = avgOutOfNetworkPct;
}
// Calculate averages
const engagementRates = weekPosts.map(post => post.metrics.engagementRate);
report.summary.avgEngagementRate = mean(engagementRates);

View file

@ -50,6 +50,43 @@ export function percentChange(current: number, previous: number): number {
return ((current - previous) / previous) * 100;
}
/**
* Minimal shape needed to weight a reach share keeps this helper usable from
* both report builders without dragging in the full PostAnalytics record.
*/
interface ReachWeightable {
metrics: { impressions: number; outOfNetworkPct?: number };
}
/**
* Roll per-post out-of-network shares up to one number, WEIGHTED by impressions.
*
* The share is a fraction of a post's own impressions, so a flat mean would let
* a 50-impression post at 90% outvote a 10,000-impression post at 20%. Posts
* without a share are excluded entirely folding them in as 0 would invent
* data that was never entered.
*
* Returns undefined when no post carries a share, or when the posts that do
* carry one have no impressions to weight (a share of zero impressions has no
* meaning; 0 would be a fabricated reading and NaN a bug).
*/
export function weightedOutOfNetworkPct(posts: ReachWeightable[]): number | undefined {
let totalWeight = 0;
let weightedSum = 0;
let sawShare = false;
for (const post of posts) {
const pct = post.metrics.outOfNetworkPct;
if (pct === undefined) continue;
sawShare = true;
totalWeight += post.metrics.impressions;
weightedSum += post.metrics.impressions * pct;
}
if (!sawShare || totalWeight <= 0) return undefined;
return Math.round((weightedSum / totalWeight) * 10) / 10;
}
/**
* Calculate how many standard deviations a value is from the mean.
* Returns 0 if standard deviation is 0.

View file

@ -191,3 +191,189 @@ describe("Saves (manual-entry, optional)", () => {
);
});
});
/**
* Out-of-network reach (manual-entry, optional) N16.
*
* LinkedIn surfaces the in-network/out-of-network split natively in post
* analytics (Discovery section, under Impressions; progressive global rollout
* from June 2026) as a PERCENTAGE split not as two absolute counts, and not
* in the CSV export. So the ingest is a percent cell the operator transcribes,
* and the stored field is a single share: `outOfNetworkPct`. In-network is its
* complement by definition, so storing both halves would only invite a
* self-contradicting record.
*/
describe("Out-of-network reach (manual-entry, optional)", () => {
it("should parse an Out-of-network percent cell written with a % suffix", () => {
const filePath = join(fixturesDir, "reach-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
assert.equal(batch.postCount, 4, "Should have 4 posts");
assert.equal(
batch.posts[0].metrics.outOfNetworkPct,
37,
"'37%' must parse to the number 37"
);
});
it("should leave outOfNetworkPct undefined when the cell is blank (unknown != zero)", () => {
const filePath = join(fixturesDir, "reach-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
assert.equal(
batch.posts[1].metrics.outOfNetworkPct,
undefined,
"Blank Out-of-network cell must stay undefined, never coerced to 0"
);
});
it("should treat an explicit '0' as a genuine zero share (nothing left the network)", () => {
const filePath = join(fixturesDir, "reach-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
assert.equal(
batch.posts[2].metrics.outOfNetworkPct,
0,
"Explicit '0' is a real reading — must stay 0, not collapse to undefined"
);
});
it("should read a European decimal comma as a decimal, not a thousands separator", () => {
const filePath = join(fixturesDir, "reach-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
// A share never carries a thousands separator, so "36,5" is 36.5 percent.
// parseOptionalCount's US-thousands rule would read this as 365 — wrong here.
assert.equal(
batch.posts[3].metrics.outOfNetworkPct,
36.5,
"'36,5' must parse to 36.5 percent, never 365"
);
});
it("should leave outOfNetworkPct undefined for a standard export with no reach column (backward-compat)", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
for (const post of batch.posts) {
assert.equal(
post.metrics.outOfNetworkPct,
undefined,
"Existing CSV exports without a reach column must round-trip unchanged"
);
}
});
it("should leave outOfNetworkPct undefined for a non-numeric cell (unknown, never 0)", () => {
const filePath = join(fixturesDir, "reach-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
assert.equal(
batch.posts[0].metrics.outOfNetworkPct,
undefined,
"Non-numeric reach cell must stay undefined — never coerced to 0"
);
});
it("should refuse a value above 100 — a count and a share are undecidable in one column", () => {
const filePath = join(fixturesDir, "reach-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
// "1234" in a share column is almost certainly an absolute impression count.
// We cannot tell which, so the honest answer is unknown — never a guess.
assert.equal(
batch.posts[1].metrics.outOfNetworkPct,
undefined,
"A share above 100 must stay undefined, never stored as-is"
);
});
it("should leave outOfNetworkPct undefined for a negative cell", () => {
const filePath = join(fixturesDir, "reach-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
assert.equal(
batch.posts[2].metrics.outOfNetworkPct,
undefined,
"A negative share is not a real reading — must stay undefined"
);
});
it("should accept exactly 100 as a real reading (the boundary is inclusive)", () => {
const filePath = join(fixturesDir, "reach-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv");
assert.equal(
batch.posts[3].metrics.outOfNetworkPct,
100,
"100 percent out-of-network is possible and must be kept"
);
});
it("should derive outOfNetworkPct from an In-network column as its complement", () => {
const filePath = join(fixturesDir, "reach-in-network-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-in-network-export.csv");
// The operator transcribed the other half of the same split.
assert.equal(
batch.posts[0].metrics.outOfNetworkPct,
37,
"'In-network 63%' must store out-of-network 37"
);
assert.equal(
batch.posts[1].metrics.outOfNetworkPct,
undefined,
"A blank In-network cell leaves neither half known"
);
});
it("should keep the out-of-network half when both columns agree", () => {
const filePath = join(fixturesDir, "reach-both-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
assert.equal(
batch.posts[0].metrics.outOfNetworkPct,
37,
"63 + 37 = 100 is consistent — keep the out-of-network reading"
);
});
it("should refuse a contradictory split rather than pick a half", () => {
const filePath = join(fixturesDir, "reach-both-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
// 63 + 20 = 83. One of the two cells is a misreading and we cannot tell
// which, so the record stays unknown instead of silently trusting one.
assert.equal(
batch.posts[1].metrics.outOfNetworkPct,
undefined,
"A split that does not sum to ~100 must stay undefined"
);
});
it("should tolerate one point of rounding slack between the two halves", () => {
const filePath = join(fixturesDir, "reach-both-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-both-export.csv");
// 62 + 37 = 99: the UI rounds each half independently, so a one-point gap
// is rounding, not a misreading.
assert.equal(
batch.posts[2].metrics.outOfNetworkPct,
37,
"A 99 or 101 sum is rounding slack — keep the out-of-network reading"
);
});
it("should NOT fold out-of-network reach into engagementRate", () => {
const filePath = join(fixturesDir, "reach-export.csv");
const batch = parseLinkedInCSV(filePath, "reach-export.csv");
// Row 1: (100+30+15+200)/5000 * 100 = 6.9. Reach is a distribution signal,
// not engagement — it must not touch the rate.
const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100;
assert.ok(
Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01,
`engagementRate should exclude reach (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}`
);
});
});

View file

@ -0,0 +1,4 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network","Out-of-network"
"Both halves transcribed and they sum to 100 - consistent, out-of-network wins...",2026-02-28,2900,70,22,9,130,63%,37%
"Both halves transcribed but they contradict each other - refuse to guess which one is right...",2026-02-27,2800,70,22,9,130,63%,20%
"Both halves with rounding slack - 62 + 37 = 99 is within the one-point tolerance...",2026-02-26,2700,70,22,9,130,62%,37%
1 Content Date Impressions Reactions Comments Shares Clicks In-network Out-of-network
2 Both halves transcribed and they sum to 100 - consistent, out-of-network wins... 2026-02-28 2900 70 22 9 130 63% 37%
3 Both halves transcribed but they contradict each other - refuse to guess which one is right... 2026-02-27 2800 70 22 9 130 63% 20%
4 Both halves with rounding slack - 62 + 37 = 99 is within the one-point tolerance... 2026-02-26 2700 70 22 9 130 62% 37%

View file

@ -0,0 +1,5 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network"
"Non-numeric out-of-network cell - the user jotted a note, not a share; stays unknown...",2026-03-06,3500,70,22,9,130,n/a
"Above 100 - most likely an absolute impression count pasted into a share column; undecidable, so unknown...",2026-03-05,3400,70,22,9,130,1234
"Negative share - not a real reading; stays unknown...",2026-03-04,3300,70,22,9,130,-5
"Exactly 100 - a real reading: every impression came from outside the network...",2026-03-03,3200,70,22,9,130,100
1 Content Date Impressions Reactions Comments Shares Clicks Out-of-network
2 Non-numeric out-of-network cell - the user jotted a note, not a share; stays unknown... 2026-03-06 3500 70 22 9 130 n/a
3 Above 100 - most likely an absolute impression count pasted into a share column; undecidable, so unknown... 2026-03-05 3400 70 22 9 130 1234
4 Negative share - not a real reading; stays unknown... 2026-03-04 3300 70 22 9 130 -5
5 Exactly 100 - a real reading: every impression came from outside the network... 2026-03-03 3200 70 22 9 130 100

View file

@ -0,0 +1,5 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network"
"An out-of-network share the user read off the native Discovery panel, with a percent sign...",2026-03-10,5000,100,30,15,200,37%
"A post where the user left the Out-of-network cell blank - unknown, not zero...",2026-03-09,3000,60,20,8,120,
"Explicit zero out-of-network - a real reading: nothing left the network...",2026-03-08,4000,80,25,10,150,0
"A share written with a European decimal comma - 36,5 percent, not 365...",2026-03-07,2000,40,10,5,60,"36,5"
1 Content Date Impressions Reactions Comments Shares Clicks Out-of-network
2 An out-of-network share the user read off the native Discovery panel, with a percent sign... 2026-03-10 5000 100 30 15 200 37%
3 A post where the user left the Out-of-network cell blank - unknown, not zero... 2026-03-09 3000 60 20 8 120
4 Explicit zero out-of-network - a real reading: nothing left the network... 2026-03-08 4000 80 25 10 150 0
5 A share written with a European decimal comma - 36,5 percent, not 365... 2026-03-07 2000 40 10 5 60 36,5

View file

@ -0,0 +1,3 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network"
"The user transcribed the in-network half of the split instead - out-of-network is the complement...",2026-03-02,3100,70,22,9,130,63%
"In-network blank - neither half known...",2026-03-01,3000,60,20,8,120,
1 Content Date Impressions Reactions Comments Shares Clicks In-network
2 The user transcribed the in-network half of the split instead - out-of-network is the complement... 2026-03-02 3100 70 22 9 130 63%
3 In-network blank - neither half known... 2026-03-01 3000 60 20 8 120

View file

@ -104,6 +104,31 @@ describe("generateMonthlyReport", () => {
assert.equal(report.summary.totalSaves, undefined);
});
test("rolls out-of-network shares up as an impressions-weighted average", () => {
const withReach = (p: PostAnalytics, outOfNetworkPct: number): PostAnalytics => ({
...p,
metrics: { ...p.metrics, outOfNetworkPct },
});
const posts: PostAnalytics[] = [
withReach(createPost("2026-03-03", 10000, 3.0), 20),
withReach(createPost("2026-03-05", 1000, 4.0), 80),
createPost("2026-03-10", 5000, 3.5), // no share entered — must not dilute
];
const root = setupTestRoot(posts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(
report.summary.avgOutOfNetworkPct,
25.5,
"Should weight by impressions (25.5), not average the shares flat (50)"
);
});
test("leaves avgOutOfNetworkPct undefined for reach-free months (backward-compat)", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.avgOutOfNetworkPct, undefined);
});
test("generates weekly breakdown within month", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");

View file

@ -6,6 +6,7 @@ import {
trendDirection,
percentChange,
deviationsFromMean,
weightedOutOfNetworkPct,
} from "../src/utils/stats.js";
describe("stats", () => {
@ -136,4 +137,60 @@ describe("stats", () => {
assert.ok(Math.abs(result) < 0.01);
});
});
/**
* Out-of-network reach aggregate (N16). The share is per-post, so the only
* honest roll-up is impressions-weighted: a 50-impression post at 90% must
* not outvote a 10,000-impression post at 20%.
*/
describe("weightedOutOfNetworkPct", () => {
const post = (impressions: number, outOfNetworkPct?: number) => ({
metrics: { impressions, outOfNetworkPct },
});
test("should weight each share by that post's impressions", () => {
// (10000*20 + 1000*80) / 11000 = 25.45… — an unweighted mean would say 50.
const result = weightedOutOfNetworkPct([post(10000, 20), post(1000, 80)]);
assert.equal(result, 25.5, "Should be the impressions-weighted share, not the plain mean");
});
test("should exclude posts that carry no share from the weighting", () => {
// The 5000-impression post has no reading; folding it in as 0 would drag
// the answer to 17.5 and invent data that was never entered.
const result = weightedOutOfNetworkPct([
post(10000, 20),
post(1000, 80),
post(5000, undefined),
]);
assert.equal(result, 25.5, "Posts without a share must not dilute the aggregate");
});
test("should keep a genuine 0 share in the weighting", () => {
// (1000*0 + 1000*50) / 2000 = 25 — an explicit zero is data, not absence.
const result = weightedOutOfNetworkPct([post(1000, 0), post(1000, 50)]);
assert.equal(result, 25);
});
test("should return undefined when no post carries a share", () => {
const result = weightedOutOfNetworkPct([post(1000), post(2000)]);
assert.equal(result, undefined, "Absent data must stay absent, never 0");
});
test("should return undefined for an empty list", () => {
assert.equal(weightedOutOfNetworkPct([]), undefined);
});
test("should return undefined when the carrying posts have no impressions", () => {
// A share of zero impressions has no meaning, and the weights sum to 0 —
// returning 0 here would be a fabricated reading, and NaN a bug.
const result = weightedOutOfNetworkPct([post(0, 40)]);
assert.equal(result, undefined, "Zero total weight must yield undefined, never NaN or 0");
});
test("should round to one decimal (the UI reading is itself rounded)", () => {
// (3000*33.3 + 1000*66.7) / 4000 = 41.65 → 41.7
const result = weightedOutOfNetworkPct([post(3000, 33.3), post(1000, 66.7)]);
assert.equal(result, 41.7);
});
});
});

View file

@ -319,6 +319,57 @@ describe("weekly", () => {
assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field");
});
test("should roll out-of-network shares up as an impressions-weighted average", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "reach1",
publishedDate: "2026-01-12", // 2026-W03
metrics: { impressions: 10000, reactions: 500, comments: 100, shares: 50, clicks: 200, engagementRate: 8.5, outOfNetworkPct: 20 },
}),
createTestPost({
id: "reach2",
publishedDate: "2026-01-13", // 2026-W03
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, outOfNetworkPct: 80 },
}),
createTestPost({
id: "reach3",
publishedDate: "2026-01-14", // 2026-W03 — no share entered; must not dilute.
metrics: { impressions: 5000, reactions: 250, comments: 50, shares: 25, clicks: 100, engagementRate: 8.5 },
}),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(
report.summary.avgOutOfNetworkPct,
25.5,
"Should weight by impressions (25.5), not average the shares flat (50)"
);
});
test("should leave avgOutOfNetworkPct undefined when no post carries a share (backward-compat)", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({ id: "noreach1", publishedDate: "2026-01-12" }),
createTestPost({ id: "noreach2", publishedDate: "2026-01-13" }),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(
report.summary.avgOutOfNetworkPct,
undefined,
"Reach-free data must not introduce an avgOutOfNetworkPct field"
);
});
test("should identify top performers and underperformers", () => {
tempDir = setupTempDir();

View file

@ -2537,6 +2537,178 @@ fi
echo ""
# --- Section 16w: Measure-Truth - Reach + Boundary Map (N16 / D-3, A2-F11, E#9) ---
echo "--- Measure-Truth: Reach + Boundary Map (N16) ---"
# LinkedIn split a post's impressions into in-network and out-of-network in June
# 2026 - the first native number that says whether a post ACQUIRED audience or
# only resonated with the one already there. It is shown as a PERCENTAGE split in
# the Discovery panel and is absent from the CSV export, so the ingest is a manual
# percent column, exactly like saves. What is worth linting is the honesty of that
# ingest, because every failure mode here is silent:
# (D-3 contract) percent parsing refuses what it cannot know: unknown is never 0,
# a value above 100 is not a share (a count and a share are
# indistinguishable in one column), and a comma is a DECIMAL mark
# here - parseOptionalCount's US-thousands rule would read "36,5"
# as 365.
# (D-3 shape) one stored half, not two. The halves describe one split, so
# keeping both would let a record contradict itself; a transcribed
# pair is cross-checked and DISCARDED when it does not sum to ~100.
# (D-3 roll-up) the aggregate is impressions-WEIGHTED. A flat mean lets a
# 50-impression post at 90% outvote a 10,000-impression post.
# (D-3 boundary) reach never touches engagementRate - it is distribution, not
# engagement, and folding it in would break comparability with
# every historical import.
# (A2-F11) the report OFFERS a baseline diff and writes engagement-patterns.md
# only on an explicit operator go, to the per-user data dir - never
# silently, never into the plugin's shipped template.
# (E#9) the boundary map states what is measurable and what is not:
# dwell explicitly unmeasurable, saves partner-gated, reach native
# but CSV-export status UNVERIFIED. An unstated boundary gets
# quietly filled with an estimate.
CSVP_N16="scripts/analytics/src/parsers/csv-parser.ts"
TYPES_N16="scripts/analytics/src/models/types.ts"
STATS_N16="scripts/analytics/src/utils/stats.ts"
RPT_N16="commands/report.md"
IMP_N16="commands/import.md"
README_N16="README.md"
ADATA_N16="assets/analytics/README.md"
patterns_update_gated() { # $1 = text; gated iff it NAMES the baseline file, REQUIRES a go, and FORBIDS silent writing
echo "$1" | grep -qF "audience-insights/engagement-patterns.md" \
&& echo "$1" | grep -qF "AskUserQuestion" \
&& echo "$1" | grep -qF "never write it silently"
}
PU_SELFTEST_OK=1
if ! patterns_update_gated "read audience-insights/engagement-patterns.md, offer a diff and never write it silently - ask with AskUserQuestion first"; then
PU_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-gated patterns-update probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if patterns_update_gated "$probe"; then
PU_SELFTEST_OK=0; echo " false-positive FAIL: under-gated patterns-update probe accepted -> $probe"
fi
done <<'NEGATIVE16W'
update audience-insights/engagement-patterns.md from the report, asking with AskUserQuestion when unsure
read audience-insights/engagement-patterns.md and never write it silently, applying the diff directly
offer the diff with AskUserQuestion and never write it silently, file left unnamed
NEGATIVE16W
if [ "$PU_SELFTEST_OK" -eq 1 ]; then
pass "patterns-update self-test: predicate needs baseline file + explicit go + no-silent-write (1 accepted, 3 under-gated rejected)"
else
fail "patterns-update self-test failed - the N16 A2-F11 lint is vacuous or over-eager"
fi
# (D-3 contract) the percent ingest is its OWN parser, not saves' count parser
if grep -qF "function parseOptionalPercent" "$CSVP_N16" 2>/dev/null; then
pass "csv-parser has a dedicated percent parser for the reach share (D-3)"
else
fail "$CSVP_N16 has no parseOptionalPercent - a share is being parsed as a count (D-3)"
fi
# (D-3 contract) a share above 100 is refused, and a comma is a decimal mark
if grep -qF "parsed > 100" "$CSVP_N16" 2>/dev/null \
&& grep -qF 'cleaned.replace(/,/g, ".")' "$CSVP_N16" 2>/dev/null; then
pass "reach parsing refuses >100 and reads a comma as a decimal mark (never 36,5 -> 365)"
else
fail "$CSVP_N16 reach parsing accepts a non-share value or mis-reads a decimal comma (D-3)"
fi
# (D-3 shape) both halves reduce to ONE stored value, and a contradictory pair is dropped
if grep -qF "resolveOutOfNetworkPct" "$CSVP_N16" 2>/dev/null \
&& grep -qF "REACH_SPLIT_TOLERANCE" "$CSVP_N16" 2>/dev/null; then
pass "the reach split reduces to one stored half, with a bounded rounding tolerance (D-3)"
else
fail "$CSVP_N16 stores the reach split without a cross-check - a record can contradict itself (D-3)"
fi
# (D-3 shape) the field is optional in the type, and in-network is documented as derived
if grep -qF "outOfNetworkPct?: number" "$TYPES_N16" 2>/dev/null \
&& grep -qF "complement by" "$TYPES_N16" 2>/dev/null; then
pass "PostMetrics carries the reach share as optional, with in-network documented as its complement"
else
fail "$TYPES_N16 reach field is not optional, or stores both halves (D-3)"
fi
# (D-3 boundary) reach is kept out of the engagement rate
if grep -qF "NOT folded into engagementRate" "$TYPES_N16" 2>/dev/null; then
pass "reach is explicitly kept out of engagementRate (comparability with historical imports)"
else
fail "$TYPES_N16 does not state that reach stays out of engagementRate (D-3)"
fi
# (D-3 roll-up) the aggregate is weighted, and zero total weight yields unknown - not 0, not NaN
if grep -qF "export function weightedOutOfNetworkPct" "$STATS_N16" 2>/dev/null \
&& grep -qF "totalWeight <= 0" "$STATS_N16" 2>/dev/null; then
pass "the reach roll-up is impressions-weighted and returns unknown on zero weight (never 0/NaN)"
else
fail "$STATS_N16 has no weighted reach roll-up, or can emit 0/NaN for an unweighted set (D-3)"
fi
# (rendering) the import surface documents the manual column AND its partial coverage
if grep -qF "Reach entered:" "$IMP_N16" 2>/dev/null \
&& grep -qF "Out-of-network" "$IMP_N16" 2>/dev/null; then
pass "/linkedin:import documents the reach column and the coverage-aware output line"
else
fail "$IMP_N16 does not document the manual reach column (the field can never be filled)"
fi
# (rendering) the report shows the split, reads it as acquisition-vs-resonance, and refuses
# to invent it when it was not entered
if grep -qF "Reach Split" "$RPT_N16" 2>/dev/null \
&& grep -qF "not entered for this period" "$RPT_N16" 2>/dev/null \
&& grep -qF "never estimate it" "$RPT_N16" 2>/dev/null; then
pass "/linkedin:report renders the reach split with an explicit unknown branch (never estimated)"
else
fail "$RPT_N16 renders no reach split, or may estimate an unentered one (D-3)"
fi
# (do-next) a reach reading steers the next piece instead of being admired in the report
if grep -qF "Never persist a reach directive" "$RPT_N16" 2>/dev/null; then
pass "a reach insight routes into the do-next channel, and only when it was measured (N15 contract)"
else
fail "$RPT_N16 leaves the reach reading in the report - it never reaches the next draft"
fi
# (A2-F11) the baseline update is real, gated, and aimed at the user's own copy
if patterns_update_gated "$(cat "$RPT_N16" 2>/dev/null)"; then
pass "/linkedin:report offers an operator-gated engagement-patterns update (A2-F11)"
else
fail "report.md updates the patterns baseline silently, or not at all (A2-F11)"
fi
if grep -qF "never to the plugin's shipped" "$RPT_N16" 2>/dev/null; then
pass "the patterns update targets the per-user data dir, never the shipped template (A2-F11)"
else
fail "$RPT_N16 does not forbid writing the plugin's shipped patterns template (A2-F11)"
fi
# (E#9) the boundary map is current on all three metrics: reach, saves, dwell
if grep -qF "In-network vs out-of-network reach" "$README_N16" 2>/dev/null \
&& grep -qF "unverified" "$README_N16" 2>/dev/null; then
pass "boundary map states the reach split as native-but-not-exported, export status unverified (E#9)"
else
fail "$README_N16 boundary map does not cover the reach split honestly (E#9)"
fi
if grep -qF "Explicitly unmeasurable" "$README_N16" 2>/dev/null \
&& grep -qF "partner-gated" "$README_N16" 2>/dev/null; then
pass "boundary map keeps dwell explicitly unmeasurable and saves partner-gated (E#9)"
else
fail "$README_N16 boundary map lost the dwell/saves boundaries (E#9)"
fi
# (entry rules) the operator-facing rules cover either-half entry and the cross-check
if grep -qF "Either half works" "$ADATA_N16" 2>/dev/null \
&& grep -qF "Both halves are cross-checked" "$ADATA_N16" 2>/dev/null; then
pass "the data README documents either-half entry and the split cross-check"
else
fail "$ADATA_N16 does not document the reach entry rules (the column will be filled wrong)"
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
@ -2600,12 +2772,19 @@ echo ""
# analyze writer + ab-test Adopt writer + 48h-monitor writer + analytics-interpreter
# directive-shape grep + four create-surface reader greps + newsletter Step-1 queue-id/
# honest-miss compound grep + recordDoNext export grep + --record-do-next CLI verb grep +
# lifetime replace-by-source/age-floor grep + state-template section/scalar grep) = 213.
# lifetime replace-by-source/age-floor grep + state-template section/scalar grep) = 213;
# +15 for N16's fifteen UNCONDITIONAL Section-16w checks (patterns-update self-test +
# parseOptionalPercent grep + >100/decimal-comma refusal grep + resolve/tolerance
# cross-check grep + optional-field/complement grep + engagementRate-exclusion grep +
# 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.
# 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=213
ASSERT_BASELINE_FLOOR=228
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"