Skip to content

feat: coverage guard for page-builder one-pagers - #47

Merged
syswave-dev merged 8 commits into
mainfrom
feat/coverage-guard
Jul 31, 2026
Merged

feat: coverage guard for page-builder one-pagers#47
syswave-dev merged 8 commits into
mainfrom
feat/coverage-guard

Conversation

@syswave-dev

Copy link
Copy Markdown
Collaborator

Closes #46

What

Adds lib/coverage-guard.js and wires it into the default extraction path. It notices when an extraction kept only a sliver of the page and recovers the container that holds the body - effectively an auto-written select.content, using the same conversion path a recipe takes.

Why the failure is invisible today

Readability distributes a paragraph's score across its ancestors, divided down with depth. On a page-builder one-pager the preamble has its paragraphs directly in its container and wins the top candidate; the chapters split their prose across many accordion items three or four levels deeper, so their common ancestor only collects fractions. The chapters are not siblings of the top candidate either, so they are never appended. Result: well-formed markdown containing 3% of the page.

Trigger

All five conditions must hold:

  1. no recipe select.content, no forced static extractor, not the comments path
  2. cleaned body text >= 20,000 characters
  3. coverage (extract / body) < 10%
  4. a single container holds >= 90% of the body text
  5. the candidate is >= 3x larger and has >= 5 paragraphs

Condition 4 is the discriminator. It separates "one article split across siblings" from "many separate teasers", and it is what keeps listing pages out.

Calibration

85 live pages: the Hacker News front page, the p2-1 eval corpus, 12 server-rendered forum threads and comment-heavy blog posts, wikis, long single-document pages (GPL-3.0, RFC 9110, MDN, Python docs), shops, listing homepages, and four page-builder landing pages.

filter pages left
all 85
body >= 20k 17
+ coverage < 10% 2
+ dominant container 1
+ gain and prose 1

Forum threads: 0 of 12 fire, their coverage runs 17.7-258%. The nearest non-target miss is a listing homepage at 1.9% coverage, blocked by condition 4.

Threshold sensitivity: the first false positive appears at coverage < 20% (a forum thread at 19.8%) or at body >= 10k with coverage < 15% (a landing page at 11.7%). The shipped thresholds sit with roughly a factor of 2 in hand.

The sweep was re-run against the shipped code before opening this PR, with the gate model and the real pipeline cross-checked against each other on every page. They agree everywhere.

Safety

The guard can only grow a result, never shrink it - condition 5 requires a 3x gain, and anything that does not hold leaves today's output untouched. The whole block is wrapped in try/catch and degrades to current behaviour. Opt out with PULLMD_COVERAGE_GUARD=off. Every intervention carries source: coverage-guard and records the figures it acted on in metadata.extractorReason, so an operator can judge afterwards whether it was warranted.

Side effect worth noting: on the motivating page the recovered extract raises the quality score from 0.45 to 0.8, which puts it above the renderDecision threshold. The page needed no JavaScript, so this also removes a pointless Playwright round-trip.

Deliberately not changed

pickBest. Relaxing its heading gate would let Trafilatura's heading-less output win on these pages, push coverage to ~95% and silence the guard, trading a structured result for a flat one.

Tests

1035 passing, 0 failing (baseline before the branch: 995). The new tests cover the descent algorithm, the trigger thresholds, and the cases where the guard must stay silent: a structurally flat twin of the firing fixture with identical content, a page below the size floor that meets every other condition, a recipe select.content, forced static extractors, and the opt-out. Test fixtures are generated in-test from neutral prose.

Python sidecar tests pass, and the query-extract goldens are byte-identical - no re-capture needed.

Known limitation

emit('extracting', ...) still reports playwright when the guard fires on a rendered pass. That is a transient progress event only; the persisted source and extractorReason are correct.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J1rQhLmVXHnJvurKvfGgfg

syswave-dev and others added 8 commits July 29, 2026 21:04
Wires lib/coverage-guard.js (Task 1) into convertWithReadability: measure
body text length before Readability runs (it mutates the document), then
after pickBest chooses a candidate, re-attempt extraction from the dominant
container when coverage is suspiciously low. Adds integration tests against
a page-builder one-pager fixture built in-test.
- Renamed "stays silent on a small page" to "leaves an ordinary small article
  untouched" (body text ~600 chars, already below size and coverage floors)
- Added "stays silent below the body-size floor even when every other condition
  holds" (body 17,635 chars / 9.3% coverage / dominant container / 10.8x gain)
  to isolate the MIN_BODY_TEXT threshold

The new test fails when MIN_BODY_TEXT is lowered to 0, confirming it properly
discriminates on the body-size floor (verified via temporary patch).
- extractor=playwright no longer silently disables the guard: gate on the
  two static extractors it actually defers to (readability/trafilatura),
  not on any truthy extractor value
- a guard hit on the Playwright-rendered pass no longer loses its
  coverage-guard label: extractorReason now keeps both the render reason
  and the guard reason instead of the guard's markers being overwritten
- SITE-RECIPES.md and README/SKILL X-Source lists updated to include
  coverage-guard (and recipe-content, which was also missing)
- dropped a dangling reference to a gitignored design doc path
- findDominantContainer no longer recomputes a parent's textContent it
  already measured in the previous iteration (behavior unchanged)

Adds two regression tests (1033 -> 1035 passing, 0 failing).
…ecovered

Three findings from the review of this branch.

The dominance threshold is checked per descent level, not against the body,
so the shares multiply: in a six-level 91% chain the recovered container
holds 56% of the body, not the >= 90% the trigger description implies. The
sheddings are chrome siblings and dropping them is the point, but that is a
judgement for whoever reads the reason afterwards - so state the measured
share instead of leaving the threshold to be read as a guarantee.

containerLabel echoed a page-controlled id or class verbatim into a string
that is persisted in the extraction log and emitted as frontmatter. A 5000
character id produced a 5088 character reason. Capped at 60 characters.
(YAML stays valid either way - the reason always contains ": ", so the
frontmatter builder quotes and escapes it; verified with a crafted id.)

bodyTextLength walked the whole body on every default-path request. A
serialization is never shorter than the text inside it, so a document below
the size floor cannot reach it - skip the walk on those instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hh7VhsxbwhJJGbb3RpYzo2
@syswave-dev
syswave-dev merged commit 1b0f258 into main Jul 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extraction keeps only a sliver of page-builder one-pagers

1 participant