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

@ -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"