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

@ -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}`
);
});
});