feat(linkedin-studio): N17 — baseline-motor (median + variansbånd + minimum-N-refusal) [skip-docs]

Every reading now leads with "vs your own baseline", and no verdict is given
when N is too small to carry one.

- stats.ts: median + medianAbsoluteDeviation (robust pair; mean/stddev stay for
  the alert engine, which wants outlier sensitivity), rollingBaseline with a
  10-post positional window, median ± 1·MAD band floored at 0, and a typed
  insufficient-data refusal below MIN_BASELINE_N=5. readAgainstBaseline returns
  above/within/below-band, or no-verdict when the baseline was refused.
- baselineByGroup + buildBaselineBlock: per-format/per-pillar baselines, each
  judged on its own N; the reported period is excluded from its own baseline and
  compared on its median, not its mean.
- queue-join.ts (new): read-only date join supplying format/pillar from the post
  queue. Every ambiguity resolves to unlabelled, an entry labels at most one
  post, and a missing/broken queue degrades to no labels.
- weekly/monthly reports attach the block unconditionally (refusal included);
  optional in the types, so pre-N17 reports load unchanged.
- CLI: report output leads with the baseline; new `baseline [--by format|pillar]`
  verb with coverage reporting.
- report.md leads with baseline framing and prints the code's reading rather
  than judging the band by eye; WoW loses to the baseline on disagreement.
  analyze.md Step 2a tests whether the drop is real before diagnosing it.

TDD: 58 analytics tests written red first (144 -> 202). test-runner Section 16x,
23 unconditional checks + self-test (247 -> 270; anti-erosion floor 228 -> 251).
tsc clean. All suites green: trends 300, brain 134, editions 72,
specifics-bank 45, contract-gate 33, hooks 191, tests 35, render 60.

Also closes the OKF phase-4 scope follow-up in docs/okf-ingestion/plan.md §8
(coord round 2026-07-25): phase 4 tracks the contract, parse is in scope, and
our claim on read_concept/navigate_bundle is withdrawn as unnecessary.

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 20:50:45 +02:00
commit e2ad190dda
15 changed files with 1723 additions and 14 deletions

View file

@ -38,7 +38,34 @@ Use AskUserQuestion to understand the situation:
## Step 2: Gather Data
If imported analytics data exists (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report.
**Step 2a — first, establish whether there is a problem at all.** This command is
invoked because something *feels* wrong, and the operator's own framing ("my reach
dropped") is a hypothesis, not a measurement. Diagnosing a drop that never happened
sends them chasing a phantom and teaches them to distrust their own numbers.
If imported analytics exist, generate the current report and read its `baseline`
block (see `/linkedin:report` Step 4):
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report
```
Read `baseline.period.<metric>.reading` and lead the diagnosis with it:
- **`within-band`** — the numbers are inside the operator's own normal range. Say so
before going further: *"Your last posts are inside your normal range (median X,
normal range YZ). This looks like ordinary variation rather than a drop."* Then ask
whether they still want the diagnostic pass. Often the honest answer is that nothing
is wrong and the fix is to keep publishing — do not manufacture a diagnosis to
justify the command having been run.
- **`below-band`** — the drop is real and measured. Proceed, and use the band figures
as the size of the problem instead of a remembered percentage.
- **`no-verdict`** — print `baseline.<metric>.reason` and say plainly that there is not
enough history to tell a drop from variation. Continue on **self-report only**, and
label the diagnosis as provisional. Never upgrade a refusal into a verdict because
the operator sounds worried.
If imported analytics exist (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report.
Based on their answer, ask relevant follow-up questions:
@ -150,7 +177,11 @@ Run through this checklist:
## Step 5: Reach Drop Severity Assessment
Based on how much reach dropped:
Based on how much reach dropped. **Measure the drop against the baseline band from
Step 2a, not against the single best week the operator remembers** — a percentage
computed from a remembered peak is a comparison with an outlier, and it will class
ordinary variation as suppression. When the band says `within-band`, the severity is
"normal fluctuation" regardless of what the percentage says.
### Down <25%
**Diagnosis:** Normal fluctuation

View file

@ -142,10 +142,41 @@ The report contains:
- **week**: ISO week identifier
- **generatedAt**: ISO timestamp when the report was generated
- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), optional `avgOutOfNetworkPct` (manual entry only — the impressions-weighted out-of-network share), `avgEngagementRate`, `avgImpressionsPerPost`
- **baseline**: The operator's own normal, from posts published **before** this week —
`window`, `required`, `historyBefore`, plus `impressions` and `engagementRate` (each
either `{status: "ok", n, median, mad, band}` or
`{status: "insufficient-data", n, required, reason}`), and `period` carrying this
week's own median per metric with a `reading` of `above-band` / `within-band` /
`below-band` / `no-verdict`. **This is what the report leads with (Step 6).**
- **topPerformers** / **underperformers**: Best / weakest posts by engagement rate
- **trends**: Week-over-week change — `impressionsTrend`, `engagementTrend`, `comparedTo`, `percentChange`
- **alerts**: Anomalies and significant events (intra-week + week-over-week)
**Week-over-week vs baseline — which to trust.** `trends.percentChange` compares two
adjacent weeks, so at two or three posts a week it swings wildly on sample composition
alone; `baseline` compares against the operator's own spread. When the two disagree —
a +38% week that reads `within-band`**the baseline wins**, and say so explicitly:
the percentage is real arithmetic on a sample too small to carry a conclusion.
## Step 4b: Per-format and per-pillar baselines (optional, when the queue has labels)
Format and pillar are not analytics data — they live in the post queue — so a separate
surface joins them:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" baseline
```
It prints an overall baseline plus one per format and per pillar, and reports its own
coverage (how many posts it could not label). Use it to answer "is carousel actually
working for me?" — but respect the per-group refusals: **each group is judged on its
own N**, so a format used twice gets no verdict even when the overall history is long.
That refusal is the honest answer to a question the operator will still want answered;
do not fill the gap with the overall baseline as a proxy for the group.
Skip this step when the join reports no labels at all — say the grouped view needs
queue entries rather than presenting an empty table.
## Step 5: Run Trend Analysis
Get additional context with trend analysis:
@ -253,12 +284,55 @@ Your post on [topic] achieved 12,500 impressions — a personal best!
## Step 6: Present Formatted Report
**The report LEADS with the operator's own baseline, and the absolute numbers come
second.** At a normal publishing cadence a single week holds two or three posts, so
an absolute total answers no question on its own — "1,240 impressions" is only
information once you know whether that is ordinary for this operator. Present the
baseline block first, every time, and never reorder these two sections.
**Do not compute the verdict yourself.** The report JSON's `baseline` block already
carries it, decided in code: `baseline.period.<metric>.reading` is one of
`above-band` / `within-band` / `below-band` / `no-verdict`. Print what it says.
Judging by eye whether a number sits inside the band is the over-read this block
exists to prevent — a value 7% above the median looks like a trend and is not one.
Format the data into a readable report using this template:
```
# LinkedIn Performance Report
## Week {week}
### 📐 Vs Your Own Baseline
Baseline = your last {baseline.window} posts published **before** this {week/month}
(n={baseline.impressions.n}). Median and normal range, not mean — one viral post
must not redefine "normal".
| Metric | This week (median) | Your baseline | Normal range | Reads as |
|--------|--------------------|---------------|--------------|----------|
| Impressions/post | {baseline.period.impressions.median} | {baseline.impressions.median} | {band.low}{band.high} | {reading} |
| Engagement rate | {baseline.period.engagementRate.median}% | {baseline.engagementRate.median}% | {band.low}{band.high}% | {reading} |
Render `reading` in words, not as the raw token:
- `above-band`**above your normal range** — a real move up, worth explaining
- `below-band`**below your normal range** — a real move down, worth diagnosing
- `within-band`**inside your normal range** — normal variation, not a trend.
Say this plainly and do not dress it up as momentum in either direction.
- `no-verdict` → see the refusal rule below
**When `baseline.<metric>.status` is `insufficient-data`: refuse the verdict.** Print
`baseline.<metric>.reason` verbatim and state that this {week/month}'s numbers are
reported **without** a verdict. Do not substitute a week-over-week percentage as a
stand-in, do not call it a trend, and do not soften the refusal into a hedge like
"early signs suggest" — with fewer than {baseline.required} prior posts there is no
signal to be early about. The absolute numbers below are still shown; only the
verdict is withheld. An operator who has just started publishing gets this refusal
for several weeks, and that is the correct output, not a failure of the report.
**If the report JSON has no `baseline` key at all**, it was generated before
baselines existed — say so, and offer to re-run Step 3 rather than reading the
absolutes as if they had been framed.
### 📊 Key Metrics
| Metric | Total | Average per Post | vs. Last Week |