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:
parent
b45fdad911
commit
63506f7d5c
21 changed files with 841 additions and 13 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue