diff --git a/CHANGELOG.md b/CHANGELOG.md index eb685e2..d6984fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,40 @@ failure. For the trust model and the evaluation harness behind those calls, see ## [Unreleased] +### Changed +- **Requires trafilatura >= 2.2**, which fixes the nested-emphasis serializer bug tearsheet + reported as [adbar/trafilatura#882](https://github.com/adbar/trafilatura/issues/882) + (released 2026-07-31, closed 2026-07-31). The `#882` canary test — which pinned the BROKEN + behavior on purpose so an upgrade would fail loudly — fired exactly as designed and is now + flipped to pin the corrected serialization as a permanent regression guard. +- **Adoption was measured, not assumed.** Replaying all 194 cached pages under both versions: + 77 extract more, 42 less, 75 identical, corpus total **+2.89%**, zero catastrophic losses. + 2.2.0 repairs three of the five silent-omission failures the stub guard was built on — + `sbir.gov/awards` (421 → 3,155 chars; it previously returned only the "official website of + the United States government" banner with no award data), `atlas.nomic.ai/pricing` + (259 → 1,661) and the NOAA SBIR archive. The stub guard now fires on 2 of 194 pages + (was 5 of 162) because the corpus got better, not because the guard got weaker. +- **Stub-guard calibration table recalibrated** against 2.2.0. Thresholds unchanged; the two + remaining fires still bracket the 10% floor tightly (8.1% / 9.7%). The old "do not raise + past ~570 (eur-lex)" lower bound no longer binds — eur-lex now extracts 6,167 chars. + +### Fixed +- **`article_peripheral` probation fixture regenerated** (`scripts/gen_article_peripheral_fixture.py` + is now the reproducible source). It padded its article body with 14 byte-identical `
` tags;
+ 2.2.0 deduplicates identical blocks, collapsing the body 1,973 → 166 chars and tripping the
+ stub guard — while the real page it models moved the opposite way (+642%). Its tests were also
+ strengthened: the old body assertion checked only `"Join Forces"`, which the page *title*
+ satisfies, so a 92% body collapse passed CI unnoticed. It now pins real body sentences, total
+ guard silence, and that silence comes from the cluster floor rather than a toothless page.
+
+### Known issues
+- **The collapsed-column guard has no real-page coverage under 2.2.0.** Quo is just as lossy
+ (still 4 of 24 figures, 1 of 3 plan names) but emits the repeated cell once instead of three
+ times, so the repetition signature the guard keys on is gone — on quo and on every other page
+ in the cache replay. The dropped-price guard still catches quo, so the page is not silent.
+ The quo pin was narrowed deliberately and the substantive loss pinned separately rather than
+ the assertion being deleted quietly.
+
## [0.1.5] — 2026-07-24
### Added
diff --git a/README.md b/README.md
index 7b8d213..1d5b8b8 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ no telemetry.
> *tearsheet (n.): a page torn from a publication and filed as proof it ran.*
-**Trust status:** qualified for heavy usage 2026-07-16 — 247 tests, a falsifiable live
+**Trust status:** qualified for heavy usage 2026-07-16 — 252 tests, a falsifiable live
eval harness (verdict GREEN), and zero fabrications across the tool's entire recorded
history. Its documented failure mode is *omission*, and the guards exist to make every
omission loud. See [Trust](#trust).
@@ -111,7 +111,10 @@ a research tool for reading the public web — not for evading paywalls or bot d
clean while the figures never made it. Measured 2026-07-14 on real pages — `quo.com/pricing`
kept 4 of 24 prices and flattened a 3-column matrix into repeated rows (`Unlimited* /
Unlimited* / Unlimited*`), while `heyrosie.com/pricing` came through perfectly. So the failure
- is site-shaped, not universal, and you cannot eyeball it from the output alone.
+ is site-shaped, not universal, and you cannot eyeball it from the output alone. Quo still
+ loses the same 20 of 24 figures under trafilatura 2.2.0, but emits that repeated cell once
+ instead of three times, so the **collapsed-column** guard no longer fires on it — the
+ dropped-price guard is what catches the page today.
Since v0.1.2 the tool says so itself: a `warning:` line reports dropped prices and collapsed
columns, and a cookie banner is reported as a `consent/cookie wall` instead of being served as
the page. **Never quote figures from a warned extraction — re-run with `--raw` / `raw=true`.**
@@ -143,12 +146,13 @@ a research tool for reading the public web — not for evading paywalls or bot d
below every guard's floor by design (a one-figure floor would warn on every blog footer).
The rule is procedural: any figure you are going to quote gets `--raw` or independent
verification.
-- **Emphasis mangling (upstream)**: trafilatura 2.1.0's markdown serializer displaces
- nested-emphasis words (`word` mid-sentence) onto the next
- paragraph and can drop characters around inline `` (observed: "i.e." → "e.").
- Facts survive; verbatim quotes should be re-verified against the source before reuse.
- Pinned by `tests/test_content.py::TestKnownUpstreamManglingDocumented` (fails when
- upstream fixes it). Reported: [adbar/trafilatura#882](https://github.com/adbar/trafilatura/issues/882).
+- **Emphasis mangling (upstream) — FIXED, now a regression pin.** trafilatura ≤ 2.1.0
+ displaced nested-emphasis words (`word` mid-sentence) onto the
+ next paragraph and dropped characters around inline `` ("i.e." → "e."). Reported as
+ [adbar/trafilatura#882](https://github.com/adbar/trafilatura/issues/882), fixed in
+ trafilatura 2.2.0, verified downstream 2026-08-11 — tearsheet now requires `>=2.2`.
+ `tests/test_content.py::TestNestedEmphasisSerializedInPlace` pinned the broken behavior
+ as a canary, failed on cue when the fix landed, and now pins the correct serialization.
- Bot-walled sites (eCFR, DoD, Cloudflare in strict mode) are reported as
`blocked by bot protection …` — deliberately not evaded; use the site's official API.
- Wikipedia extractions can include maintenance-hatnote table noise ("This article
@@ -161,7 +165,7 @@ a research tool for reading the public web — not for evading paywalls or bot d
"Can it be trusted for heavy usage?" is a measurement here, not a feeling.
-- **Offline suite (247 tests, runs in the gate)**: guard boundary pins, cache-poisoning
+- **Offline suite (252 tests, runs in the gate)**: guard boundary pins, cache-poisoning
regressions, truncation honesty, charset torture, structure torture, adversarial
robustness — enforced fully offline by a loopback-only socket guard. The five REAL
pages that defined the tool's probation (quo, smith.ai, dialpad, heyrosie, a LinkedIn
@@ -195,7 +199,7 @@ for figures you'll quote, treat a suspiciously small extraction of a rich page a
.venv/bin/ruff check src tests && .venv/bin/mypy && .venv/bin/python -m pytest
```
-TDD throughout; the default suite (247 tests) runs entirely offline — `httpx.MockTransport`,
+TDD throughout; the default suite (252 tests) runs entirely offline — `httpx.MockTransport`,
fixture HTML, and a conftest socket guard that fails any test reaching for a non-loopback
address. Extras: `pytest -m playwright` (real chromium, local server), `pytest -m live`
(real network). The live trust evaluation lives in `evals/` (see [Trust](#trust)).
diff --git a/evals/reports/2026-08-11/report.md b/evals/reports/2026-08-11/report.md
new file mode 100644
index 0000000..accb1e5
--- /dev/null
+++ b/evals/reports/2026-08-11/report.md
@@ -0,0 +1,73 @@
+# Tearsheet Trust Report — 2026-08-11 13:13
+
+**VERDICT: GREEN**
+
+- tearsheet 0.1.5 · trafilatura 2.2.0 · httpx 0.28.1 · lxml 6.1.1 · python 3.14.2
+- corpus 2026-07-16.2 (sha fe68ddd7bb2c) · 40 items scored · burst: 45 scrapes across 3 domains: {'ok': 40, 'honest_refusal': 5, 'FAIL': 0, 'exception': 0}
+
+## Gates
+
+| gate | status | detail |
+|---|---|---|
+| fabrication (0 tolerated) | PASS | 0 pages fabricated figures |
+| guard calibration (0 regressions) | PASS | known-bad warns, known-good silent |
+| silent omission (<=1) | PASS | 1 pages omitted without warning |
+| minor omissions within retention floor (<=3) | PASS | tailscale: ['$5'] missing (80% retained — within the document |
+| unhandled exceptions (0) | PASS | 0 |
+| cache poison (0) | PASS | none served |
+| timeouts (<=2) | PASS | 0 |
+| baseline drift (0) | PASS | stable |
+| hard failures (<=1 beyond the above) | PASS | wiki_idaho: SILENT omission: ['$74,900'] |
+
+## Items
+
+| id | category | status | detail |
+|---|---|---|---|
+| quo | pricing_grid | warn-correct | warning riding with content; 23 figures flagged |
+| slack | pricing_grid | warn-correct | warning riding with content; 7 figures flagged |
+| notion | pricing_grid | warn-correct | warning riding with content; 3 figures flagged |
+| zapier | pricing_grid | warn-correct | warning riding with content; 9 figures flagged |
+| tailscale | pricing_grid | minor-omission | ['$5'] missing (80% retained — within the documented retention floor) |
+| zoom | pricing_tabbed | warn-correct | warning riding with content; 6 figures flagged |
+| ringcentral | pricing_tabbed | pass | 4/4 figures shown |
+| heyrosie | plan_cards | pass | 5/5 figures shown |
+| calendly | plan_cards | pass | 3/5 figures shown |
+| basecamp | plan_cards | pass | 3/3 figures shown |
+| hetzner | pricing_non_usd | pass | clean |
+| scaleway | pricing_non_usd | warn-correct | warning riding with content; 5 figures flagged |
+| giffgaff | pricing_non_usd | pass | clean |
+| linkedin_fp | article_peripheral | pass | 0/2 figures shown |
+| rfc9110 | pinned_immutable | pass | clean |
+| rfc2616_txt | pinned_immutable | pass | clean |
+| gutenberg_alice | pinned_immutable | pass | clean |
+| w3c_css2 | pinned_immutable | pass | clean |
+| gh_raw_readme | pinned_immutable | pass | clean |
+| wiki_idaho | reference_table | **FAIL** | SILENT omission: ['$74,900'] |
+| fed_h15 | data_table | pass | clean |
+| pg_essay | control_prose | pass | clean |
+| py_docs | control_docs | pass | clean |
+| fastapi_docs | control_docs | pass | clean |
+| guardian | consent_heavy | pass | content served (3951 chars) |
+| lemonde | consent_heavy | pass | content served (3788 chars) |
+| zeit | consent_heavy | pass | content served (3987 chars) |
+| ecfr | botwall | walled | honest refusal |
+| dodcio | botwall | walled | honest refusal |
+| crunchbase | botwall | walled | honest refusal |
+| todomvc | spa_shell | documented-limit | tiny output (300 chars) — the known custom-mount class |
+| aozora | charset_shift_jis | pass | clean |
+| wiki_ja | charset_utf8_ja | pass | clean |
+| gh_api | json_endpoint | pass | JSON pretty-printed |
+| berkshire_pdf | pdf_figures | pass | pdf text extracted; 3 money figures visible |
+| irs_p15 | pdf_tables | pass | pdf text extracted; 7 money figures visible |
+| map_uv | map | pass | 50 urls mapped |
+| map_fastapi | map | pass | 50 urls mapped |
+| crawl_ruff | crawl | pass | crawl: docs.astral.sh pages: 5 errors: 0 skipped(robots/dupe/type): 0 |
+| search_smoke | search | pass | results returned |
+
+## Reading this report
+
+- `pass` — figures/content verified against the independent oracle.
+- `warn-correct` — the tool flagged its own extraction; the warning was warranted.
+- `walled` — honest refusal (bot wall / consent wall / HTTP error reported as such).
+- `FAIL` — a trust property was violated; see evidence/ for the raw bytes.
+- Every item's raw evidence is under `evidence/ tags.
+trafilatura 2.2.0 deduplicates identical blocks, so the body collapsed to a single
+paragraph (1,973 -> 166 chars) and the page began tripping the stub guard — while the
+REAL LinkedIn page it models went the other way (1,827 -> 13,560 chars, +642%).
+The synthetic had stopped modelling reality. This rebuild uses varied prose, which is
+what a real article has, and re-verifies the original calibration contract.
+"""
+
+import gzip
+import sys
+from pathlib import Path
+
+BODY = [
+ "The announcement details a merger between two infrastructure companies and what it "
+ "means for self-hosted deployments across the industry.",
+ "Both firms built their reputations on operator tooling rather than end-user products, "
+ "which is why the combination surprised analysts who had expected a platform vendor to "
+ "make the first move in this segment.",
+ "Customers running on-premise clusters have spent the week asking the only question "
+ "that matters to them, which is whether the existing support contracts survive the "
+ "transition intact or get renegotiated at the next renewal window.",
+ "Leadership addressed that directly in a call with partners, saying the support "
+ "organisations will merge slowly and that no contract will be reopened before its "
+ "scheduled term ends.",
+ "The engineering blog post accompanying the news is unusually candid about the parts "
+ "of the integration that remain unsolved, including two overlapping control planes "
+ "that cannot simply be collapsed into one another.",
+ "Migration tooling is promised for the second half of next year, though the post stops "
+ "short of committing to a date and notes that the schedule depends on how many "
+ "customers are running the older agent.",
+ "Competitors have already begun courting the installed base, with at least two vendors "
+ "publishing migration guides within days of the announcement.",
+ "That reaction is familiar to anyone who watched the previous consolidation wave in "
+ "this space, where the acquiring company lost roughly a third of the smaller firm's "
+ "customers inside eighteen months.",
+ "What makes this case different, according to people close to the deal, is that the "
+ "two products barely overlap in the workloads they were designed for.",
+ "One was built for long-running batch jobs on hardware the operator owns; the other "
+ "grew out of a scheduler for short-lived tasks in leased capacity.",
+ "Merging them means reconciling two scheduling models that make opposite assumptions "
+ "about how long a node is expected to live, and that is a genuinely hard problem "
+ "rather than a matter of shipping a compatibility shim.",
+ "The teams have said they will keep both schedulers alive indefinitely rather than "
+ "force a migration, which is the outcome most of the operator community was hoping "
+ "to hear.",
+ "Independent maintainers who package these tools for the major distributions have "
+ "asked for clarity on the licence, since one project carries a copyleft obligation "
+ "the other does not.",
+ "No answer has been given on that point yet, and it is the thread most likely to "
+ "generate friction over the coming months.",
+]
+
+# Peripheral feed cards: real dollar figures in related-content, which correct
+# main-content extraction excludes. Four distinct figures, deliberately spaced so no
+# 1,500-char window of visible text holds four — the v0.1.3 cluster-arming contract.
+FILLER = [
+ "Another author shares a long reflection on the state of the industry and its hiring "
+ "pipeline, with anecdotes from conference hallways and customer calls that never quite "
+ "resolve into a thesis.",
+ "A second commentator responds at length about procurement cycles, arguing that the "
+ "purchasing process shapes the architecture far more than any engineering preference "
+ "ever does.",
+ "A third post walks through a capacity planning exercise in detail, complete with the "
+ "spreadsheet assumptions that the author admits were wrong by a wide margin.",
+ "Someone else recounts a postmortem from a regional outage, noting that the review "
+ "found no single cause and that the team stopped looking for one.",
+ "A researcher summarises a preprint on scheduling fairness, then spends most of the "
+ "post explaining why the benchmark does not reflect production conditions.",
+]
+
+CARDS = [
+ "Community groups halted $130 billion of projects this quarter.",
+ "Token prices crashed from $17 per million down to just $2.",
+ "Big Tech's $8 trillion bet is reshaping electricity costs.",
+]
+
+
+def build() -> bytes:
+ body = "".join(f" {p} {lead} {pad} tags; 2.2.0 deduplicates identical blocks, so the body collapsed from 1,973 to 166
+chars and the page started tripping the STUB guard. Meanwhile the REAL page it models
+moved the opposite way (1,827 -> 13,560 chars, +642%), so the synthetic had stopped
+modelling reality in both magnitude and direction. The rebuild uses varied prose, which
+is what a real article has, and re-verifies every clause of the contract above.
"""
import gzip
@@ -26,7 +34,7 @@
import pytest
-from tearsheet.content import assess_extraction, extract_content
+from tearsheet.content import _MONEY, assess_extraction, extract_content, html_to_text
PROBATION = Path(__file__).parent / "fixtures" / "probation"
@@ -51,7 +59,31 @@ class TestQuoPricing:
def test_the_original_failure_still_warns(self) -> None:
price, column, wall = price_warned(load("quo_pricing"))
assert price, "quo dropped 20/24 prices — the guard MUST fire"
- assert column, "quo collapsed its 3-column matrix — the guard MUST fire"
+
+ def test_quo_is_still_substantively_broken(self) -> None:
+ """The failure is unchanged; only the COLUMN SIGNATURE moved (2026-08-11).
+
+ Through trafilatura 2.1.0 quo's 3-column grid flattened into runs of identical
+ consecutive rows (`Unlimited* / Unlimited* / Unlimited*`), which is what
+ _has_collapsed_columns keys on, so both the price AND column guards fired.
+ 2.2.0 emits that cell ONCE instead of three times: the page is just as lossy
+ (still 4 of 24 figures, still 1 of 3 plan names) but the repetition the guard
+ detects is gone, so only the price guard fires now.
+
+ The column assertion was retired here rather than deleted quietly: a full-cache
+ replay (194 pages, both versions) showed the collapsed-column guard now fires on
+ NO page it used to catch, i.e. it has zero real-page coverage under 2.2.0. That
+ gap is tracked as its own work item; this test pins the substantive loss so the
+ page can never go silent while the signature question is open.
+ """
+ extracted = extract_content(load("quo_pricing"))
+ assert extracted is not None
+ page_figures = set(_MONEY.findall(html_to_text(load("quo_pricing"))))
+ kept = page_figures & set(_MONEY.findall(extracted.markdown))
+ assert len(page_figures) >= 20, "fixture must still carry the full price grid"
+ assert len(kept) / len(page_figures) < 0.5, "quo must still read as a major drop"
+ plans = [name for name in ("Starter", "Business", "Scale") if name in extracted.markdown]
+ assert len(plans) < 3, "plan-name attribution is still lossy"
class TestSmithAiPricing:
@@ -89,10 +121,56 @@ def test_the_false_positive_stays_dead(self) -> None:
assert not price, "peripheral real figures must not trip the guard (v0.1.3 fix)"
assert not wall
+ def test_the_page_is_completely_silent(self) -> None:
+ """No warning of ANY kind — this is the false-positive fixture.
+
+ The old assertions only checked the price and column guards, so when the body
+ collapsed under trafilatura 2.2.0 the page began emitting a STUB warning and
+ every test still passed. Assert total silence so any future guard can't quietly
+ start firing on the one page that must never warn.
+ """
+ html = load("article_peripheral")
+ quality = assess_extraction(html, extract_content(html))
+ assert quality.warnings == [], f"FP fixture must stay silent, got: {quality.warnings}"
+ assert not quality.consent_wall and not quality.block_wall
+
def test_the_article_body_still_extracts(self) -> None:
+ """Pin the BODY, not just the headline.
+
+ The previous version asserted only `"Join Forces" in markdown` — which the page
+ TITLE satisfies. That is why a 92% body collapse (1,973 -> 166 chars under 2.2.0)
+ passed CI unnoticed. These assertions require real body sentences.
+ """
extracted = extract_content(load("article_peripheral"))
assert extracted is not None
- assert "Join Forces" in extracted.markdown
+ assert len(extracted.markdown) > 1_500, "the article body must survive extraction"
+ assert "surprised analysts" in extracted.markdown
+ assert "copyleft obligation" in extracted.markdown
+
+ def test_peripheral_figures_are_excluded_but_present_on_the_page(self) -> None:
+ """The whole point of the fixture: real figures exist, correct extraction drops
+ them (they are related-content), and the guard stays quiet about it."""
+ html = load("article_peripheral")
+ extracted = extract_content(html)
+ assert extracted is not None
+ on_page = set(_MONEY.findall(html_to_text(html)))
+ assert len(on_page) == 4, f"fixture must carry 4 distinct figures, got {on_page}"
+ assert not (on_page & set(_MONEY.findall(extracted.markdown)))
+
+ def test_it_genuinely_exercises_the_arming_logic(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Silence must come from the CLUSTER FLOOR, not from the page being toothless.
+
+ Drop the floor to 1 and the guard must fire — otherwise this fixture would pass
+ the FP test for the wrong reason and stop protecting anything.
+ """
+ import tearsheet.content as content_module
+
+ monkeypatch.setattr(content_module, "_MONEY_MIN_CLUSTERED", 1)
+ html = load("article_peripheral")
+ quality = assess_extraction(html, extract_content(html))
+ assert any("price" in w for w in quality.warnings)
@pytest.mark.parametrize(
Two Companies Join Forces
{body}