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

@ -69,6 +69,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`references/fold-in-loop.md` (A2-F9)** — the capture → classify → promote → enforce loop documented **domain-generally and in-tree**, so an adopter with no external writing contract has the whole loop (that absence "is not a gap" — the same mirror rule `editorial-reviewer` uses). Names the other four accumulation silos (specifics-bank, brain, voice drift log, A/B learnings) that Step 11 surfaces together, because a retro that empties one queue teaches the operator the others are empty too. references 28 → 29.
- Suites: test-runner 197 → 217 (Section 16u: 18 unconditional greps + a non-vacuity self-test on the capture predicate; the phase-coverage sweep now spans **17** phases; anti-erosion floor 179 → 198). All nine suites green.
### Added — the do-next contract: a measurement changes the next piece (N15 / A2-F10, A2-F12, A2-F13, A2-F14)
- **`recordDoNext()` in `state-updater.mjs`** — one channel from measurement to creation. `/linkedin:report`, `/linkedin:analyze`, an `/linkedin:ab-test` **Adopt** verdict and the `post-feedback-monitor`'s 48h learning each persist 23 directives (imperative + evidence pointer, tagged with their source) to `## Do-Next Directives` in the state file; `post`/`quick`/`batch`/`create` Step 0 and `newsletter` Step 1 read them before drafting. Before this every analytics surface ended in chat (`grep do.next` across the repo returned 0) and the next drafting session — a fresh context — never saw any of it.
- **Lifetime is replace-by-source + a 60-day age floor** — each writer supersedes only its own rows, so a new weekly report never evicts an A/B Adopt decision, and the section stays a live steering signal instead of a growing backlog. Deterministic, no AI in the write; `--record-do-next` exposes it as a CLI verb.
- **Previous-edition calibration (A2-F14)**`newsletter` Step 1 matches the previous edition to its measured numbers through the queue id (`drafts/queue.json``analytics/posts/*.json` by publish date, disambiguated on the hook preview), with an explicit **honest-miss** branch: an unmeasured edition is stated as unmeasured, never estimated.
- test-runner 213 → 232 (Section 16v: 15 unconditional do-next greps + self-test; anti-erosion floor 198 → 213). Two real bugs found by the tests themselves: a section regex missing `/m` (old rows were never superseded or pruned) and fixtures dated 7 months before their own `today`, which had made the replace-by-source test falsely green.
### Added — measure-truth: reach split + honest boundary map (N16 / D-3, A2-F11, E#9)
- **Out-of-network reach as an optional manual column (D-3)** — LinkedIn split a post's impressions into in-network and out-of-network in June 2026 (post analytics → Discovery, under the impressions count): the first native number that says whether a post **acquired** audience or only resonated with the audience it already had. It is shown as a **percentage split** and is **absent from the CSV export** (whether it will ever be exported is unverified), so it follows the saves pattern — add an `Out-of-network` (or `In-network`) column with the percentage read off that panel and `/linkedin:import` ingests it.
- **A separate percent parser, deliberately not the count parser**`parseOptionalPercent()` reads a comma as a **decimal** mark (`36,5` → 36.5; `parseOptionalCount`'s US-thousands rule would have said 365) and **refuses any value above 100**: in one column an absolute impression count and a share are indistinguishable, so the honest answer is unknown rather than a guess. Blank, non-numeric and negative stay unknown; a genuine `0` is kept (nothing left the network).
- **One stored half, cross-checked** — only `outOfNetworkPct` is stored, because the two halves describe one split and keeping both would let a record contradict itself. An `In-network` cell is stored as its complement, and a transcribed **pair that does not sum to ~100** (one point of rounding slack, since the UI rounds each half independently) is **discarded as unknown** instead of half-trusted.
- **Impressions-weighted roll-ups**`weightedOutOfNetworkPct()` feeds an optional `avgOutOfNetworkPct` on the weekly and monthly summaries. Weighted, because a flat mean lets a 50-impression post at 90 % outvote a 10,000-impression one at 20 %; posts without a reading are excluded rather than folded in as 0; zero total weight yields `undefined`, never 0 or NaN. Reach-free imports stay byte-identical to pre-N16 output, on screen and on disk.
- **Not part of the engagement rate** — reach is distribution, not engagement, so it never enters `engagementRate` (which stays comparable to every historical import). `/linkedin:report` renders the split as **acquisition (out) vs resonance (in)**, states coverage honestly, and says "not entered for this period" rather than estimating an unentered split. A reach reading routes into the N15 do-next channel so it shapes the next post instead of being admired in the report.
- **Operator-gated baseline updates (A2-F11)** — new `/linkedin:report` **Step 7c**: the report now offers a diff against the user's `audience-insights/engagement-patterns.md` baseline (the file `analytics-interpreter`, `strategy-advisor` and `/linkedin:audit` treat as tracked truth, and which nothing in the pipeline used to write, so it went stale silently). Old → new with the evidence, an explicit go via `AskUserQuestion`, changes labelled *provisional* when they rest on 12 posts, an append to the section's update log — and never a silent write, never into the plugin's shipped template.
- **Boundary map brought current (E#9)** — the README boundaries now state all three honestly: dwell **explicitly unmeasurable** (internal to LinkedIn, no count to transcribe, no API), saves partner-gated (`POST_SAVE` on `/memberCreatorPostAnalytics` from v202604), and reach native-but-not-exported with export status **unverified**.
- analytics suite 119 → 144 (25 new tests: percent contract, either-half resolution, split cross-check, weighted roll-up, backward-compat); test-runner 232 → 247 (Section 16w: 15 unconditional greps + self-test; anti-erosion floor 213 → 228); `tsc` clean.
### Built feedback (awaiting consumer-side proof)
- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).