diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fc10d..2c979d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Auto-gull — the gold corpus grows on its own (A2-F2)** — Step 8 lock now appends the locked edition to `ingest/published/` tagged `provenance=published`, so the corpus the chronicle profile is distilled from grows one edition at a time (the author's own locked editions, never the engine's drafts). No manual curation. - Domain-general throughout: languages/formats come from the user's own corpus, never hardcoded. test-runner 140 → 146 (Section 16o: 6 unconditional voice-chain greps + self-test; anti-erosion floor 123 → 129). +### Added — randsone gates: gate the feed-facing surfaces (N9 / C-4, C-6, C-7, C-9) + +- **Randsone fact + language gate (C-4/C-9)** — `/linkedin:newsletter` Step 9 (post-lock) now runs a **scoped** `fact-reviewer` (F1 verifiable claims + F3 number attribution) + `language-reviewer` (L1 repetition + L2 anglicisms/register) pass over the four feed-facing surfaces the reader actually meets — the **delingstekst hook**, the **samle post**, the **carousel slide text**, and the **edition title** — before the hook-conversion binary. Until now only the hook's click-worth was judged; its truth and language, the samle post, the carousel slides and the title reached the feed ungated. Placed at Step 9 because that is the first phase where all four surfaces exist (carousel is authored in Step 7.5; delingstekst/samle from Step 8), and Step 9 already operates on post-lock distribution copy — so the gate revises distribution surfaces + re-renders, never the locked body; a defect tracing into the locked body/canonical title is a pivot. +- **Edition title-strength gate (C-9)** — the edition's own title now faces a strength gate at Step 9 (binding on the distribution title, advisory on the locked canonical title), not only the hook's two lines. +- **Deterministic post-factcheck fact-recheck (C-6)** — a new invariant after Step 5: any later fold-in (5.5/6/6.5/7) whose diff touches a sentence with **numbers, names, or quotes** fires a mandatory **scoped `fact-reviewer`** mini-recheck of exactly those sentences — a deterministic trigger, **not a judgment call**, replacing the old "re-run if it felt substantive" discretion and closing the pivot-heuristic blind spot (a swapped statistic under the +20 % threshold never trips `/linkedin:pivot`, but does trip this). +- **WebFetch on both fact agents (C-7)** — `fact-checker` and `fact-reviewer` gain `WebFetch`: quote precision (F2) and exact-figure attribution (F3) require the verbatim source text in context, not a search snippet. +- test-runner 146 → 154 (Section 16p: 8 unconditional randsone-gate greps + self-test; anti-erosion floor 129 → 137). Domain-general; no KTG-specific values hardcoded. + ### 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). diff --git a/agents/fact-checker.md b/agents/fact-checker.md index 7dad227..e605c06 100644 --- a/agents/fact-checker.md +++ b/agents/fact-checker.md @@ -16,7 +16,7 @@ description: | "is this accurate", "verify numbers", "back this up", "factual correctness". model: opus color: brown -tools: ["Read", "WebSearch"] +tools: ["Read", "WebSearch", "WebFetch"] --- # Fact Checker Agent @@ -89,6 +89,14 @@ Always run the contradiction sweep (5) — a claim that survives a deliberate search for counter-evidence is far stronger than one that merely matched a confirming page. +**Fetch the verbatim source for quotes and figures (WebFetch).** A search +*snippet* is enough to locate a source but not to confirm a **quotation** or an +**exact figure** — for those, `WebFetch` the located page and read the original +wording in context. A quote is verified only against the source's verbatim text +(«Vi» vs «Vi i Nav» is a precision failure even when the gist matches); a figure +is verified only against the number as the source actually states it. Never grade +quote precision or number attribution off a snippet alone. + ### Step 3: Assess Source Credibility For each source, evaluate: is it primary or secondary, who published it, how diff --git a/agents/fact-reviewer.md b/agents/fact-reviewer.md index d211cb1..b4a4e0a 100644 --- a/agents/fact-reviewer.md +++ b/agents/fact-reviewer.md @@ -22,7 +22,7 @@ description: | "number attribution", "headless review", "fact-reviewer". model: opus color: gold -tools: ["Read", "WebSearch"] +tools: ["Read", "WebSearch", "WebFetch"] --- # Fact Reviewer Agent @@ -211,6 +211,12 @@ source's exact wording and attribution and compare verbatim (F2). For figures, trace to a named source and confirm the source's precision matches the draft's (F3/F4 — "around a third" does not verify "37 %"). +**Fetch the verbatim source, do not grade off a snippet (WebFetch).** F2 (quote +precision) and F3 (number attribution) cannot be settled from a search *snippet* — +`WebFetch` the located source and read the original wording/number in context, then +compare verbatim. A quote or figure marked 🟢 must have been checked against the +source's actual text, never a preview line. + ### Step 3 — Score on the five dimensions Score each claim 0–100 across the five dimensions. A contradicted claim is 🔴 diff --git a/commands/newsletter.md b/commands/newsletter.md index e063190..5f9d900 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -1169,6 +1169,21 @@ Gate: [PASS — zero unresolved 🔴] (else REWORK/BLOCK with the open claims) Next: Step 5.5 — Editorial review (craft gate, BEFORE the persona sweep). ``` +> **Post-factcheck fact-invariant (Endring N9 / C-6) — deterministic recheck.** +> Once this sweep clears, the article's facts are gated — but every later edit can +> silently reintroduce an unchecked fact: an editorial fold-in (5.5), a persona +> tightening (6), a headless fold-in (6.5), an annotation pass (7). So the +> re-verification trigger is **deterministic — not a judgment call:** if a fold-in's +> diff touches any sentence containing **numbers, names, or quotes**, it fires a +> mandatory **scoped fact-reviewer** mini-recheck of *exactly those changed +> sentences* (`subagent_type: linkedin-studio:fact-reviewer`, inputs limited to the +> touched sentences + the sources they cite) before the phase advances. This +> replaces the old "re-run only if it felt substantive" discretion and closes the +> pivot-heuristic blind spot: a swapped statistic that adds no section and stays +> under the +20 % word-count threshold never trips `/linkedin:pivot`, but it *does* +> trip this trigger. A verified fact stays verified only if every number, name, or +> quote that changes after the gate is re-checked. `[GATE]` + ## Step 5.5: Editorial review — craft gate, BEFORE the persona sweep The fact-checked draft now faces the **editor**: a single `editorial-reviewer` @@ -1441,6 +1456,10 @@ command runs — see `commands/headless-review.md` for the full cold contract): The editor (this session) holds the pen; never paste a reviewer's direction as copy. If the fold-in was substantive, re-run the affected archetype on v(n+1). All of this happens **before** lock, so the body is never reopened post-lock. + **Post-factcheck fact-invariant applies (C-6):** independently of the substantive + judgment, if any folded edit's diff touches a sentence with **numbers, names, or + quotes**, fire the deterministic **scoped fact-reviewer** mini-recheck on those + sentences (see the invariant after Step 5) before advancing. 7. **Persist + checkpoint state.** Record the run in `edition-state.json` → `articles.NN.headlessReview` (`frozenDraft`, per-reviewer `{reportPath, @@ -1497,6 +1516,10 @@ editor is satisfied with the in-session draft. It does not gate lock. editor's manual annotation pass. Fold any resulting edits back **by tightening** (the Step 4 rule still holds) — then, if the edit was substantive, re-run the affected sweep (Step 5 or 6) before proceeding. + **Post-factcheck fact-invariant applies (C-6):** any annotation edit whose diff + touches a sentence with **numbers, names, or quotes** fires the deterministic + **scoped fact-reviewer** mini-recheck on those sentences (see the invariant after + Step 5), regardless of whether the edit felt substantive. 4. **Persist.** Set `currentPhase: "annotation"` in `edition-state.json` and write an "annotation rendered (optional) → next: visual assets" line to @@ -1778,51 +1801,104 @@ Next: Step 9 — Hook / conversion gate (post-lock). ## Step 9: Hook / conversion gate — "would YOU click?" (AFTER lock) -The locked edition still has to earn the click. The **distribution text** — the -delingstekst hook that fronts the post in the feed — now faces a final binary -gate: would the primær persona, scrolling, actually stop and open this? This is -the post-lock conversion sweep, distinct from the pre-lock resonance sweep -(Step 6): it judges the **hook only**, binary, never the body. +The locked edition still has to earn the feed. Before it ships, the +**feed-facing surfaces** the reader actually meets — the **delingstekst hook**, +the **samle post**, the **carousel slide text**, and the **edition title** — +face two final gates: a **randsone fact + language gate** (so nothing false or +clumsy reaches the feed in the most-read, most-requoted copy — C-4/C-9) and the +**hook conversion binary** (would the primær persona, scrolling, actually stop +and open this?). Both operate on **distribution copy only** — the post-lock +sweep, distinct from the pre-lock resonance sweep (Step 6), never the locked +body. Until N9 only the hook's click-worth was judged; the hook's *truth and +language*, the samle post, the carousel slides and the title reached the feed +ungated. -> **Order assertion (enforced).** This hook-gate (Step 9) runs AFTER lock -> (Step 8) — it operates on the distribution text of an already-locked edition -> and must never reopen the locked body. If the hook fails, you revise the -> *delingstekst* (and re-render via Step 8's `build-linkedin.mjs`), not the -> edition text. This ordering is the inverse of the resonance sweep, which runs -> BEFORE lock — keeping the two apart is what fixed the Seres process. `[GATE]` +> **Order assertion (enforced).** This gate (Step 9) runs AFTER lock (Step 8) — it +> operates on the **distribution copy** of an already-locked edition and must never +> reopen the locked body. Both the randsone fact/language gate and the hook binary +> revise only distribution surfaces (delingstekst, samle, carousel text, +> distribution title) and re-render via Step 8's `build-linkedin.mjs` / +> `build-carousel.mjs` — never the edition text. A defect that traces into the +> **locked article body or its canonical title** is a pivot (STOP → +> `/linkedin:pivot`), not an in-place edit. This ordering is the inverse of the +> resonance sweep, which runs BEFORE lock — keeping the two apart is what fixed the +> Seres process. `[GATE]` + +**Why Step 9 (placement, documented per plan N9).** The randsone gate lives here, +not earlier, because Step 9 is the **first phase where all four surfaces exist**: +the carousel slide text is authored in Step 7.5 (after the last content gate 6.5), +and the delingstekst + samle post exist only from Step 8. Step 9 already operates on +post-lock distribution copy, so gating and revising these surfaces here respects the +lock invariant without breaking phase order. **Procedure:** -1. **Take the distribution hook** — the first two lines of the edition's entry - in `linkedin/edition-delingstekst.md` (and the `samle` hook, if shipping the - collected post). This is what the reader sees before "…see more". +1. **Collect the four distribution surfaces.** These are what the reader meets in + the feed — and until N9 only the first was judged, and only for click-worth: + - the **delingstekst hook** — the first two lines of the edition's entry in + `linkedin/edition-delingstekst.md` (what the reader sees before "…see more"); + - the **samle post** — the `## Samle` section, if shipping the collected post; + - the **carousel slide text** — `linkedin/NN/carousel.md`, for a carousel-format + edition (authored in Step 7.5, after the last content gate — so it has never + been fact- or language-checked); + - the **edition title** — the edition's own headline (its distribution / feed + title), never strength-gated before now. -2. **Run `persona-reviewer` in conversion mode** (`subagent_type: - linkedin-studio:persona-reviewer`) for the **primær** persona - only, from THIS command layer in the foreground. Pass - **`mode: konverter`** (the after-lock, hook-only mode — NOT resonans). The - agent returns a single binary verdict, **JA / NEI**, on «would YOU click?» — - no axis scoring, no flags, no rewritten copy (principle: the jury judges, - the editor writes). +2. **Randsone fact + language gate (scoped — C-4/C-9).** Run two scoped reviewers + over *only* these surfaces (never the locked body), issued from THIS command + layer in the foreground, `subagent_type` namespaced: + - `linkedin-studio:fact-reviewer` — **F1** (verifiable claims) + **F3** (number + attribution) on any figure, named case, or quotation appearing in the four + surfaces. A hook or slide that asserts a number the body never sourced is a 🔴. + (WebFetch the source for any quoted or exact figure — C-7.) + - `linkedin-studio:language-reviewer` — **L1** (repetition) + **L2** (anglicisms + / stiff register), graded against `edition-state.language`. The hook is the + most-requoted line the edition has; a clumsy or anglicised hook is an L-BLOCK. + Pass each reviewer only the four surfaces + the sources they cite + `language`. + This is a **scoped mini-pass**, not a re-review of the article. `[GATE]` -3. **Gate on the binary.** +3. **Title-strength gate (C-9).** The edition's own title was never strength-gated — + only the hook's two lines were. Judge the **title-strength** of the **edition + title** here: does it earn the open on its own, or is it a generic label? The + strongest candidate becomes the distribution title. The verdict is **binding on + the distribution title** (sharpen a weak one in place) and **advisory on the + canonical (locked) title** — changing that is a body edit → STOP, run + `/linkedin:pivot`; never edit the locked title in place. + +4. **Resolve the randsone gate.** + - **Fact 🔴 / L-BLOCK on a distribution surface** → revise that surface **by + tightening** (hook, samle, carousel text, or distribution title — all + distribution copy), **re-render** the affected artifact (POST.html via Step 8's + `build-linkedin.mjs`; carousel via `build-carousel.mjs`), and re-run the scoped + pass on what changed. The locked body stays frozen. Loop until clean. + - **A defect that traces into the locked body or canonical title** → it is a + pivot: STOP, run `/linkedin:pivot`. The randsone gate never reopens the locked + body in place — the same invariant as the conversion loop. `[GATE]` + +5. **Run the hook conversion binary** (`subagent_type: + linkedin-studio:persona-reviewer`, **`mode: konverter`** — the after-lock, + hook-only mode, NOT resonans) for the **primær** persona only. The agent returns + a single binary verdict, **JA / NEI**, on «would YOU click?» — no axis scoring, + no flags, no rewritten copy (the jury judges, the editor writes). - **JA** → the hook converts. Proceed to Step 10. - - **NEI** → the hook does not earn the click. Revise the **delingstekst hook - only** (sharpen the krok by tightening — close the gap, hold the body - frozen), **re-render POST.html** via Step 8's `build-linkedin.mjs` (the - body stays locked; only the distribution copy changed), and re-run the + - **NEI** → revise the **delingstekst hook only** (sharpen the krok by tightening, + body frozen), **re-render POST.html** via `build-linkedin.mjs`, and re-run the conversion check. Loop until JA. `[GATE]` -4. **Persist.** Record the conversion verdict in - `edition-state.json` → `articles.NN.personaSweep.conversion`, - and set `currentPhase: "hook-conversion-gate"`. +6. **Persist.** Record the randsone-gate result in `edition-state.json` → + `articles.NN.randsoneGate` (per-surface fact/language verdicts + title-strength + + what was revised) and the conversion verdict in + `articles.NN.personaSweep.conversion`; set `currentPhase: "hook-conversion-gate"`. ``` -Hook / conversion gate (post-lock). -- Primær persona: mode: konverter (hook only) -- Verdict: JA — hook converts (else: NEI — revise delingstekst hook, re-render, re-check) +Randsone + conversion gate (post-lock, distribution copy only). +- Surfaces gated: delingstekst hook · samle post · carousel slide text · edition title +- Fact (F1/F3): 🔴 → resolved (or pivot if it traces to the body) +- Language (L1/L2): BLOCK → tightened +- Title-strength: (distribution title sharpened; canonical-title weakness → pivot) +- Conversion (primær, konverter): JA (else: NEI — revise hook, re-render, re-check) - Body: untouched (locked in Step 8) -Gate: [PASS — JA] (else loop on the distribution hook) +Gate: [PASS — randsone clean + JA] (else loop on distribution copy, or pivot) Next: Step 10 — Scheduling. ``` diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index d227a82..0583d4f 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -64,7 +64,10 @@ # the trends-brief-history/diff wiring guard (RE-R3e: scripts/trends/src/brief.ts has # 'export function diffSurfaced' AND 'parseSurfacedFrontmatter' AND the 'Nytt siden sist' section AND # the 'surfaced: ' frontmatter emit, scripts/trends/src/cli.ts wires 'selectPriorBriefFile', with a -# non-vacuity self-test) in Section 16n; the assertion-count anti-erosion floor (SC6) in Section 18. All +# non-vacuity self-test) in Section 16n; the randsone-gates guard (N9: newsletter.md Step 9 runs a scoped +# fact-reviewer + language-reviewer pass over the carousel slide text AND edition title, gates title-strength, +# declares the deterministic 'numbers, names, or quotes' recheck trigger, and both fact agents carry WebFetch, +# with a non-vacuity self-test) in Section 16p; the assertion-count anti-erosion floor (SC6) in Section 18. All # are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh @@ -1594,6 +1597,100 @@ fi echo "" +# --- Section 16p: Randsone-Gates - feed-facing surfaces + deterministic fact-recheck + WebFetch (N9 / C-4,C-6,C-7,C-9) --- +echo "--- Randsone Gates (N9) ---" + +# N9 closes the border-zone gap: the hook (delingstekst), the samle post, the carousel slide text and the +# edition title reach the feed today with NO fact/language gate (Step 5/6.5 run on the article BODY; +# carousel is authored in Step 7.5 AFTER the last content gate; Step 9 judged only the hook's click-worth). +# Four invariants must hold in the .md surfaces: +# (C-4/C-9) Step 9's post-lock gate is extended with a scoped fact-reviewer + language-reviewer pass over +# all four distribution surfaces (delingstekst hook, samle post, carousel slide text, edition +# title candidates). Step 9 is the first phase where all four exist, and it already operates on +# post-lock distribution copy the gate may revise without reopening the locked body. +# (C-6) any post-factcheck fold-in whose diff touches a sentence with numbers, names or quotes fires a +# mandatory scoped fact-reviewer mini-recheck - a DETERMINISTIC trigger ("not a judgment call"), +# which also covers the pivot-heuristic blind spot (a swapped number no >20% rule would catch). +# (C-7) both fact agents (fact-checker, fact-reviewer) carry WebFetch - quote precision needs the +# verbatim source text, not just a search snippet. +# The randsone predicate is compound (both reviewers AND both new surfaces), so a non-vacuity self-test +# guards it (mirrors Section 16o). All checks are UNCONDITIONAL .md greps -> they raise the deps-absent floor. +NL_N9="commands/newsletter.md" +# Slice the Step 9 block (## Step 9: up to ## Step 10:) so the gate literals are scoped to that phase. +STEP9_BLOCK=$(awk '/^## Step 9:/{f=1} /^## Step 10:/{f=0} f' "$NL_N9") + +randsone_gated() { # $1 = text; wired iff BOTH reviewers AND BOTH new surfaces are named + echo "$1" | grep -qF "fact-reviewer" \ + && echo "$1" | grep -qF "language-reviewer" \ + && echo "$1" | grep -qF "carousel slide text" \ + && echo "$1" | grep -qF "edition title" +} + +RZ_SELFTEST_OK=1 +if ! randsone_gated "scoped fact-reviewer + language-reviewer over the carousel slide text and edition title candidates"; then + RZ_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired randsone-gate probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if randsone_gated "$probe"; then + RZ_SELFTEST_OK=0; echo " false-positive FAIL: under-wired randsone-gate probe accepted -> $probe" + fi +done <<'NEGATIVE16P' +language-reviewer over the carousel slide text and edition title but no fact reviewer here +fact-reviewer + language-reviewer over the edition title only, no carousel surface +fact-reviewer + language-reviewer over the carousel slide text only, no title surface +NEGATIVE16P +if [ "$RZ_SELFTEST_OK" -eq 1 ]; then + pass "randsone-gate self-test: predicate needs BOTH fact+language reviewers AND both new surfaces (1 accepted, 3 under-wired rejected)" +else + fail "randsone-gate self-test failed - the randsone lint is vacuous or over-eager" +fi + +# (C-4/C-9) Step 9 gates all four distribution surfaces through fact + language reviewers +if randsone_gated "$STEP9_BLOCK"; then + pass "newsletter.md Step 9 runs the scoped fact-reviewer + language-reviewer randsone gate over carousel + title (C-4/C-9)" +else + fail "newsletter.md Step 9 missing the randsone fact+language gate - hook/samle/carousel/title still reach the feed ungated (C-4/C-9)" +fi + +# (C-9) the edition's own title now faces a strength gate at Step 9 (today only the hook's two lines were judged) +if echo "$STEP9_BLOCK" | grep -qF "title-strength"; then + pass "newsletter.md Step 9 gates the edition title-strength, not only the hook (C-9)" +else + fail "newsletter.md Step 9 missing the title-strength gate - the edition title still ships unjudged (C-9)" +fi + +# (C-6) deterministic post-factcheck fact-recheck trigger (nouns + mechanism + determinism marker) +if grep -qF "numbers, names, or quotes" "$NL_N9"; then + pass "newsletter.md declares the deterministic recheck trigger nouns ('numbers, names, or quotes', C-6)" +else + fail "newsletter.md missing the recheck trigger nouns 'numbers, names, or quotes' (C-6)" +fi +if grep -qF "scoped fact-reviewer" "$NL_N9"; then + pass "newsletter.md routes the post-factcheck recheck to a scoped fact-reviewer (C-6)" +else + fail "newsletter.md missing 'scoped fact-reviewer' - the recheck has no mechanism (C-6)" +fi +if grep -qF "not a judgment call" "$NL_N9"; then + pass "newsletter.md marks the recheck trigger deterministic ('not a judgment call', C-6)" +else + fail "newsletter.md missing the determinism marker 'not a judgment call' - the trigger is still discretionary (C-6)" +fi + +# (C-7) both fact agents carry WebFetch (verbatim source text for quote precision) +if grep -qF "WebFetch" agents/fact-checker.md; then + pass "fact-checker.md carries WebFetch (verbatim source text for quote precision, C-7)" +else + fail "fact-checker.md missing WebFetch in tools - quote precision has no verbatim-source fetch (C-7)" +fi +if grep -qF "WebFetch" agents/fact-reviewer.md; then + pass "fact-reviewer.md carries WebFetch (verbatim source text for quote precision, C-7)" +else + fail "fact-reviewer.md missing WebFetch in tools - cold re-verification has no verbatim-source fetch (C-7)" +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 @@ -1625,12 +1722,15 @@ echo "" # 'surfaced: ' frontmatter grep) = 123; +6 for N8's six UNCONDITIONAL Section-16o checks (voice-chain # self-test + voice-trainer chronicle-voice-profile grep + newsletter chronicle-voice-profile grep + # newsletter chronicle-voice-drift-log read grep + newsletter Step-1 language-resolve grep + newsletter -# Step-8 auto-gull compound grep) = 129. +# Step-8 auto-gull compound grep) = 129; +8 for N9's eight UNCONDITIONAL Section-16p checks (randsone-gate +# self-test + Step-9 randsone_gated compound grep + Step-9 title-strength grep + newsletter C-6 trigger-nouns +# grep + newsletter C-6 scoped-fact-reviewer grep + newsletter C-6 determinism-marker grep + fact-checker +# WebFetch grep + fact-reviewer WebFetch grep) = 137. # 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=129 +ASSERT_BASELINE_FLOOR=137 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"