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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue