Skip to content

Releases: SerhiiKorniienko/bullshit-detector

v0.13.1 — bookkeeping moves from the model to the script

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 02 Aug 20:17

A number that can be computed is never typed. This release applies the repo's own rule one level deeper: everything a report states about its run is now generated from the data already on disk, rather than retyped by the model that produced it.

Nothing about the rules changed. The same content run before and after produces the same verdicts, the same claim count and the same score — a PATCH under the instrument rule.

Why

The wall-clock split this project had been optimising against turned out to be one run's self-report, and every number in it was wrong. Measured properly, from harness transcripts across 37 runs:

phase claimed measured
verification ~25% 49.4%
writing the report ~44% 9.9%
the gate ~6% 12.6%
thinking, across all phases not measured 60.6%

The same corpus said where the gate's time went: of 74 rejections, 50 were arithmetic the model had retyped by hand — a tally line disagreeing with its own table, a missing count, a missing wall time, per-claim arithmetic, the footer and the run record disagreeing about searches. None of it is judgement, and all of it is derivable.

What changed

tally.py --fix now writes:

  • the tally line, including creating one when it is absent — it could previously only rewrite a line that already existed, and a run shipped a report with no tally line at all
  • the run footer, from the run record and the table: wall clock recomputed from the two timestamps, searches from the query log, per claim from wall over a recounted M
  • the run record's derived countsclaims.extracted, claims.checked, claims.dropped_ambiguous, wall_seconds — creating the block when a run omits it entirely

It never invents. No record, or a record short of a field, and the footer is left alone with the missing field named. A finished timestamp that precedes started is reported rather than normalised away, because writing a negative span down would erase the defect the clock check exists to surface.

Writing to the run record is #44, deferred because a validator mutating its own oracle deserves its own decision. The line that resolves it is derived versus stateful: these four fields recompute from the report, so re-running changes nothing. An attempts counter would not, and stays unbuilt. The hazard named in that issue — render_report.py invoking the gate on every render — is closed structurally: record writes happen under --fix, and the renderer passes no flags.

A published page was under-counting its own claims

report-card's row pattern was ^\|\s*(\d+)\s*\|, which matches no row produced by the split-late rule. A report that split claim 24 into 24a/24b rendered with both halves missing from every count: the page showed 24 claims and no ❌ chip while the tally line beneath it read 26 claims and 1 false.

A fact-checking tool cannot publish a page whose own two counts disagree. The chips now reconcile against the tally line and warn on every disagreement — it refuses nothing, because a viewer must not become a validator, but it cannot be silent again.

Also

  • scripts/runprofile.py — reads Claude Code harness transcripts to report where a run's wall clock goes, phase by phase, with --timeline printing every event and the phase it was charged to. It reads timestamps the agent never sees, which is the difference between a measurement and a self-report.
  • bullshit-detector/claim@1 — a structured claims schema and tally.py --compose, shipped as infrastructure only. Nothing references it yet; a report without one validates exactly as before.
  • A new example on the same video as the 0.12.1 report, which makes the pair a direct illustration of why examples are filed by release: 60 claims against 46, 35 confirmed against 23, and more found wrong as well as more found right.

v0.13.0 — the report checks itself against the source

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 02 Aug 15:28

v0.13.0 — the report checks itself against the source

Fifteen issues. The theme is that a report's own claims about its sources are now
mechanically checkable — quotes against the transcript, figures against the evidence cell,
verdicts against their own prose — and that content arrives fenced as data rather than
instructions.

MINOR: the same content run before and after can produce a different report. M changes,
new checks refuse rows that previously passed, and three new rules move verdicts.

The report has to mean what it says

  • Quoted spans are verified against the source (#27). A quote in the claim column or the
    hype-signal list must be words the content actually contains; tally.py --source refuses the
    report otherwise. Ellipsis marks elision and matches each side in order, [bracketed]
    insertions are folded, smart quotes and dashes normalised. It caught a real misquote on its
    first live run — a report writing "equivalent to replacing" where the source said
    "equivalent of replacing".
  • Every factual row should carry the content's own words (#10). Measured at 46% across the
    published examples, 0% to 100%; the first run under the new rule came back at 100%.
  • A figure asserted with no figure in its evidence is flagged (#28) — warning, never
    blocking, because derived arithmetic is legitimate and this rubric mandates it.
  • A row rated gentler than false whose evidence says the claim was contradicted is flagged
    (#42). It fires on 8 rows across 12 published reports, all 🟠 — the "tool no longer calls
    anything false" drift, made countable.
  • Derived rows no longer count toward M (#36). A row exempted from carrying its own link,
    because its basis is a claim rather than a source, cannot also be counted as individually
    evidenced.

Content is data, not instructions

  • fetch-content fences everything it returns (#14) in <untrusted-content source=… contract=untrusted-content-contract:v1>. Forged closing tags are neutralised
    case-insensitively and whitespace-tolerantly, replaced in place with <neutralised-fence/>
    so the attempt survives as evidence, and counted in the header. The source attribute is
    escaped because the URL is attacker-influenced; control characters are stripped.
  • An injection attempt is a finding, not just a defence event. Everything else in the hype
    checklist is content overselling itself to people; this is content attempting to corrupt the
    audit of itself. Ceiling 9–10, no steelman — with an explicit guard against flagging security
    articles that merely discuss the topic.

Sourcing

  • Tier is topical, not global (#5). It travels with the source/subject pair: ask who would
    have to be wrong. Primary settles what; secondary settles what it means.
  • A recency axis, which the hierarchy never had. Accountability and freshness come apart
    constantly — a filing is tier 1 and two years stale, a company's own page is tier 4 and current.
    A claim fixing its own moment takes the primary record; a present-tense claim about a changing
    quantity takes the current source, caps at 🟡, and carries both dates.
  • A retracted paper is still a primary document (#31), which is the problem — the hierarchy
    rated it ✅ at tier 1. scripts/retractions.py checks every DOI against OpenAlex.
  • Disclose the conflicts you cannot remove (#37). Named Disclosure section, sources named not
    summarised, contrary case in the same cell for favourable verdicts — and explicitly no
    compensating. It was practice in one published example and nowhere in the rules, so it had
    decayed to zero across eight subsequent runs.

Bookkeeping that cannot be faked

  • wall_seconds must equal finishedstarted (#38). Every other run-line check is
    internal, and a fabricated duration satisfies all of them if used consistently.
  • unreachable reasons are a closed set (#39), plus a sixth: empty — the fetch returned 200
    and there was nothing behind it. Not blocked, nothing refused you; not dead, the URL resolves.
  • A manifest-less install can produce a compliant report again. RUBRIC told it to stamp
    version unknown and the gate rejected exactly that, so every npx skills add install was
    non-compliant and could not be fixed by following the rules. Thirteen releases missed it because
    dev-mode symlinks resolve back into the repo and hide it entirely.

Instructions

  • Name the measurement basis (#43). "At one point, up 500%" is only checkable against a stated
    series and window; trough-to-peak and trailing returns differ by 2–3×. Two runs of one video
    returned ❌ and ✅ on the same claim from different bases.
  • Worked examples must be invented, not quoted from content the tool is measured on (#41).
    SKILL.md taught the invented-term rule using a phrase spoken verbatim in a video with nine
    baselines, so every run of it was handed the answer. scripts/check-fixture-independence.py
    now asserts it — and caught two more on its first run.
  • The run-record schema moved to RUN-RECORD.md, out of the path every run reads (#45, partial).

Known

  • Verification is still sequential for every real input — the parallel path is gated at 10,000
    words and no run on record has exceeded 4,445 (#47).
  • Reports from non-dev installs carry no version, and the comparison architecture assumes one (#46).

v0.12.1 — verdicts that don't depend on an invisible assumption

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 16:24

Every fix here came from running the tool against a video with four prior readings and comparing, not from reading the code. One of them is provably correct, because the claim it fixes has computable ground truth.

A verdict that changed with an assumption nobody could see

The video claims a needle at 0.999c releases "over 100 times the energy of a nuclear bomb" and "over 1 megaton of TNT". It never says how heavy the needle is.

Three runs each silently picked a mass, and the verdict followed the pick:

run assumed verdict
0.8.0 2.2 g threshold 🟠 misleading
0.12.0 0.4 g 🟡 plausible
0.12.1-dev 1 g 🟡 plausible

That is not ambiguity and not unverifiability — the physics is exact. It is underspecification, and the honest check reports where the claim holds and where it fails.

New rule: carry the range, never pick a point inside it. The run after the fix produced:

A typical sewing needle weighs 0.2–1 g: 0.2 g → 0.09 Mt (6× Hiroshima); 1 g → 0.46 Mt (31×). Reaching "over 1 Mt" needs ≈2.2 g; "100× Hiroshima" needs ≈3.3 g. The claim holds only at the very top of, or above, the plausible needle-mass range. — 🟠 misleading

Every figure re-derived independently and matched exactly. The verdict is now reproducible instead of an artefact of an unstated guess, and a reader can check the arithmetic without trusting anyone.

A merged row can no longer come out softer than its parts

Two separately-misleading halves had been merged into one 🟡, because the pair read as directionally reasonable. Merging is often right — the same run correctly folded an earthquake's magnitude, building count and death toll into one row and took the harshest verdict. What it may never do is come out gentler than any part of it.

Extraction finishes before budgeting

Two readings of one video extracted 42 claims and 30, both verified everything they listed, and neither produced a single ⚪ not-checked row — so the gap was extraction, not budget. Among the losses was the pair that catches the video calling entry heating "friction" in one beat while correctly describing compression twelve minutes later.

Extraction is now exhaustive and finishes before any budget question; surplus becomes ⚪, which is a disclosed gap a reader can see. The run after the fix recovered that finding, and sharpened it — it now names the contradiction across both segments.

Coverage is also measured rather than only instructed: source_words in the run record, and runstats.py prints claims per thousand words.

Two browser tabs per run

Every run rendered twice — once, then again after finalising the run line. --open now skips when the page already exists and says so; --reopen covers picking a report back up later. The underlying circularity (finalising the footer costs tool calls, which change the tool count) is settled by fiat: the counts describe the work up to the last tally.py pass. Rendering and handing off are not in them.

Documentation bugs that cost real runs

  • Step 7 printed uv run scripts/tally.py <file> as a runnable command. It resolves from nowhere.
  • RUBRIC's tally-line template lists every bucket, but zero-count buckets must be omitted. Following the documented template literally produced a report tally.py rejects — arithmetically correct and non-compliant.

Also

Two pairs of rules that were each one idea are now one bullet each (30 → 27 per-claim rules), with all 14 substantive constraints verified present. This is not a restructure — a proposed "one home per rule" rewrite was dropped after testing showed exactly one sentence in SKILL.md restates RUBRIC above 30% overlap. The two files divide correctly: SKILL.md says when and points, RUBRIC.md says how.

Full changelog: v0.12.0...v0.12.1

v0.12.0 — the bookkeeping layer

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 14:21

Three changes, none of which moves a verdict — which is exactly why they ship together. Batching only hurts when it makes a moved score unattributable.

Blocked sources are recorded instead of lost

RUBRIC has always said unreachable ≠ unverifiable, and asked the row to state that the evidence exists but couldn't be reached. That fired per row and then the information died. Nobody could see that six claims dead-ended at the same paywalled outlet, and nothing measured whether it was getting worse.

The run record now carries them:

"unreachable": [{"claim": 7, "url": "https://example.com/study", "reason": "paywall"}]

reason is one of paywall, blocked, dead, timeout, login. When the list isn't empty the report says so under the tally:

Unreachable: 4 sources — 3 paywalled, 1 blocked.

tally.py cross-checks the two — same shape as the existing run-line/run-record check. A record listing blocked sources against a report that never mentions them is two accounts of one event disagreeing.

Beyond the honesty, this turns a one-afternoon experiment into a time series: every run now measures the crawler-blocklist problem instead of asserting it from a single sample.

Claim numbers can be split late

A blind run split a claim into 6a/6b — exactly what step 3 asks for — hit missing claim numbers: [6], and had to renumber fifteen rows. The constraint existed only inside a regex.

Suffixes are now valid. They must run a, b, c… with no gaps, and every row sharing an ordinal must carry one, so a lone 6a is still an error.

This matters more than the friction it removes. #16 wants more splitting, not less, and its trigger is discovering during verification that one row holds a true assertion and a false one. That fix is now local instead of a renumbering pass, and rests on claim 6a survives it.

The copies are tested

uv run scripts/check-consistency.py

The verdict scale is written in five places and the score bands in six. The duplication is deliberate — skills ship as independent directories, so a cross-skill import breaks for anyone who installs one without the other — which makes a test the only available defence, and there wasn't one.

That's how v0.8.1's carousel crash happened: render_carousel.py was missing not checked. Verified by deleting the entry again and watching the check fail:

✗ verdict scale: share carousel disagrees with tally.py — missing ['not checked']

Also

A report written under pre-0.11.0 rules failed the legacy check but was told to use the 0.11.0 verdict-cell format. A gated check now describes the rule it actually applied — quoting today's format at a report from two releases ago describes a requirement that didn't exist when it was written.

Full changelog: v0.11.0...v0.12.0

v0.11.0 — the gate stops teaching the report to lie

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 13:29

Two defects found by running the tool end to end against videos with five releases of baselines, rather than by reading the code. Both were in the validator, which makes them worse than ordinary bugs: tally.py exists so the bookkeeping cannot rot, and it was rotting it.

The gate demanded a word, and the word set the number

undeclared_unverifiable rejected a ❓ row unless the literal string searched or by construction appeared anywhere in it. searched_count then decided whether that row counted toward M using those same strings.

So the gate forced the author to type one of two words, and the word they typed silently set M — the figure RUBRIC calls "the number a reader should judge the report by."

A blind run hit it and reported it without being asked:

my prose said "the search was working" … so I had to insert the exact keywords

The report it produced reads 13 individually source-checked against a query log covering 10 claims, and passes, because the aggregate check is M <= total searches and 13 ≤ 14.

The declaration now lives in the verdict cell, as a parenthetical:

❓ unverifiable (searched)           counts toward M
❓ unverifiable (by construction)    does not

One function answers both questions, so the two checks can no longer disagree about the same row. Evidence prose that mentions searching can't move M. And an undeclared row is now counted the cautious way — it does not count toward M and it is reported — where previously adding the magic word did both at once.

Reports stamped below 0.11.0 keep the prose scan they were written under.

An anecdote belonged to two buckets

SKILL.md's verdict scale listed anecdote under ❓ unverifiable. RUBRIC.md's tally format listed it under not rateable. tally.py recognises "not rateable" only by a bare em-dash.

Two runs of the same release resolved it differently on the same day, produced different M arithmetic, and both passed. The second said outright: "I had to guess."

Settled: an anecdote is ❓ by construction. It is an assertion about the world with a truth value nobody outside the story can reach. Opinion and prediction have no truth value to check and keep the em-dash.

The general lesson, now written down

Make the artifact invalid on structure, never on a keyword. A check that rejects a row unless some word appears will get the word.

The existing rule — when a mechanical step keeps getting skipped, make the artifact invalid without it — is what invited this. Both halves now live in CLAUDE.md.

Full changelog: v0.10.0...v0.11.0

v0.10.0 — spend the search budget where it changes conclusions

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 12:10

Two rules about where a run spends its most expensive calls. No new checks, no format change — but both move what gets searched, so both can move a verdict.

Follow-up searches go to load-bearing claims

Step 4 already sorts claims into load-bearing (the thesis collapses without them) and incidental (wrong here is embarrassing, not fatal). It then spent the expensive treatment equally on both — up to two follow-up searches each, three for load-bearing. On a 20-claim report where 6 are load-bearing, that is up to 42 follow-ups where 18 carry the same conclusion.

Now:

  • Load-bearing: three follow-ups, unchanged.
  • Incidental: one search, unless what comes back would move the verdict. A first result that contradicts the claim still earns a second look before it is rated ❌ — the steelman rule asks for that anyway. "The first search was thin" is not a reason to spend two more on an aside.
  • Promotion allowed. Load-bearing is judged before verification, and checking a claim sometimes reveals the argument leans on it harder than it looked.

The rule that a thesis with an unchecked load-bearing premise has not been audited is untouched.

Origin counts come from the results you already have

The bigger change is one of emphasis. Every claim backed by several URLs needs an origin count — but you can nearly always produce one from the results in front of you: read them, see that all four cite the same filing, and write

[4 URLs → 1 origin: the vendor's release]

That costs nothing and names the origin, which tells a reader more than a number does.

Checked against the shipped examples, this is already what happens — every origin marker in the 0.7.0 and 0.8.0 reports sits beside coverage 0 in the run line. The measurement tool was never doing this work, and the instruction implied otherwise.

So coverage-check is now reserved for the case reading your own results cannot cover: breadth you have no way to inspect. Once per report, on the claim whose verdict most depends on the answer.

That cap exists because of what the call costs. GDELT takes 11–15s for a trivial one-day query; the documented limit is one request per five seconds, but once tripped the throttle persists for minutes — four retries backing off 6s, 12s and 24s were all still refused. Five calls is a minute at best and a stalled run at worst.

And the honesty rule that makes it safe

RUBRIC now says to state which kind of count it is when the distinction matters. A measured origin count and one judged from the tells are different evidence, and presenting the second as the first is precisely the move this tool exists to catch in other people's work.

Versioning

This release is the first under a written bump rule (CLAUDE.md): minor when the same content, run before and after, could produce a different report; patch when it cannot. Ask "could this move a verdict?", not "is this a feature or a fix?".

Both changes here alter what gets searched, so: minor.

Full changelog: v0.9.0...v0.10.0

v0.9.0 — tier the document, not the domain

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 12:01

One rule, applied one level down from where it already lived. It moves verdicts.

Advertorial carries the advertiser's tier

The source hierarchy tiers by domain. That is the wrong granularity, and it has been quietly inflating verdicts.

Restricting search to reputable domains and reading the top twenty results returned five advertorials and index pages — every one of which reads as tier 2 if you tier by domain:

reuters.com/media-campaign/brandfeatures/medc/...    branded content
theatlantic.com/sponsored/deloitte-shifts/...        sponsored
sponsored.bloomberg.com/immersive/globalx/...        sponsored subdomain
ft.com/partnercontent/comarch/...                    "paid for and produced by Comarch"

They are not tier 2. They are the advertiser talking about itself under a masthead it paid for — which RUBRIC already calls tier 4 in its other form: "a source about itself is tier 4, whatever its usual tier."

Restricting search to reputable domains raises the share of these rather than lowering it, because that is where the advertising budget goes.

Since a tier-4 ceiling caps a claim at 🟡 plausible, this changes outcomes: a claim corroborated only by advertorial can no longer reach ✅ confirmed.

Enforced, not just written down

Unlike most of what this rubric covers, the signal is decidable from the URL. tally.py now rejects a row that cites a sponsored URL without naming it:

row 7 cites sponsored content without naming it: https://www.ft.com/partnercontent/comarch/...
  — advertorial carries the advertiser's tier, not the publisher's

Only the undeclared case fails. Citing advertorial is legitimate — sometimes the advertorial is the story — and a row that says what it is has done the work.

tally.py --self-test

Checks that every version-gated rule can actually fire. Since v0.6.0 the repo docs have warned that a check gated at a version that never ships never fires and does so silently; the warning was doing no work.

On its first run it caught this release's own constant sitting at 0.9.0 against a 0.8.1 manifest. Run it after adding a gated check and after every version bump.

Also

#18 (batch the first-pass searches) closed as wontfix. It was built, measured at 44s→45s per claim, and reverted for teaching the agent to treat one search as covering several claims.

Full changelog: v0.8.1...v0.9.0

v0.8.1 — carousel crash on not-checked claims

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 01 Aug 11:43

Patch release. One crash, one hardening pass, no rule changes.

The bug

render_carousel.py omitted not checked from its verdict map, so any carousel built from a ⚪ not checked claim died:

KeyError: 'not checked'

The share skill's documented schema listed the same five verdicts, so the renderer and its own docs agreed with each other while both disagreed with the report format. Six verdicts in, five out.

How it was found

By reading jordan-gibbs/hyperresearch, which keeps a golden test specifically so drift between its profiles and its templates shows up as a diff rather than silently. We have no such test — and the drift was already here. The verdict scale turns out to be defined in five places, the score bands in six. Tracked in #29.

Unknown verdicts now stop the render

ERROR: claim slide 1/5 has unknown verdict 'confirmd'.
       Valid verdicts: confirmed, plausible, misleading, false, unverifiable, not checked

Deliberately not a neutral fallback chip. A carousel slide replaces the verdict with a colour and a label and shows nothing else, so a wrong mapping puts a wrong verdict in front of everyone the slide is shared with.

That is the opposite of how report-card treats the same input, on purpose. The HTML page degrades an unknown verdict to "not rateable" and renders anyway, because the raw cell text stays visible beside it and nothing is misrepresented.

Gate refuses, publisher refuses, viewer degrades.

Also

Missing fields in slides.json now name the slide and the field instead of raising inside an f-string. Slide type already behaved this way; nothing else did.

Full changelog: v0.8.0...v0.8.1

v0.8.0 — a report you can hand to someone

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 31 Jul 22:32

Until now the tool produced a markdown file in a temp directory. That is a fine artifact for a developer and a poor one for everyone else — and the temp directory was deleting it after a few days.

Read a real report →

New: the report-card skill

Renders a finished report as one self-contained HTML page. Score hero, verdict filter chips (Problems shows only the misleading and false rows), claims as cards on a phone, clean print-to-PDF, and link-preview tags so a pasted URL shows the score instead of a bare link.

Stdlib only. No dependencies, no install step, and no network requests in the page it writes — which is what lets it run in a code-execution sandbox under plain python3, where the HTML is the only thing you can take away.

It refuses to render a report that fails tally.py. A page this presentable, built from a report whose own arithmetic doesn't reconcile, launders a broken report into something authoritative. That is the exact failure this project exists to catch.

Reports live somewhere real now

$BULLSHIT_DETECTOR_REPORTS, or ~/.bullshit-detector/reports/<YYYY>/.

The skill had always claimed the file survives the session and can be diffed against a later run. On macOS that was true for about three days — com.apple.tmp_cleaner runs nightly. The storage location was quietly defeating a stated goal. Point the variable at a git repo if you want them versioned.

The run ends with a handoff, not a wall of text

Score, tally, run line, and both files as clickable file:// links. The full card prints only when you ask for it. The numbers come from the script rather than from the agent's memory of what it wrote twenty minutes earlier — the same discipline the tally and the search count already needed.

Three rules, found by auditing our own output

Not by review — by reading a 0.7.0 report that passed every check.

  • Ambiguity that can't change the verdict no longer costs a claim row (#23). If a claim has two readings and both reach the same verdict, check both, show both, keep the row. The reason to drop was that you'd otherwise guess a reading and hide it. Showing your work removes the reason.
  • A derived row inherits its input's spread (#24). A row resting on a claim checked under two readings must carry the range, not the end that argues best. Collapsing an inherited range is clean arithmetic on a selected input — which the rubric already calls misleading when the content does it.
  • Show the sum (#25). "Arithmetic checks out" without the arithmetic is an unsourced verdict about a number.

Examples for 0.6.0 through 0.8.0

The folder had skipped three releases. Highlights:

  • 1/10 — the IMF's own economic outlook. The tool finding nothing wrong. A detector that never returns a low score isn't detecting anything.
  • The same video read by three releases — 5/10 at 0.5.0, 0.6.1 and 0.7.0, while the sourcing underneath goes from one clickable link to forty-one.
  • 7/10 — a developer-wealth course funnel, where every number offered as proof is private by construction. Auditing this one produced the three rules above.

0.5.1 gets no example, and the folder says why.

Full changelog: v0.7.0...v0.8.0

v0.7.0 — what the run cost, and what that revealed

Choose a tag to compare

@SerhiiKorniienko SerhiiKorniienko released this 31 Jul 16:33

This release is about measuring the tool's own work, and about what happened when it did.

Reports now say what they cost

One italic line, last thing in the report:

*run: 18m0s, searches 26, tools 51, coverage 0, per claim 68s*

Deliberately dull, and deliberately last — a reader came to find out whether the content is true, not what it cost to find out. Fuller detail, including every search query verbatim, goes to a run record written beside the report and never into the report itself. scripts/runstats.py reads those across runs.

Two of the five figures are derivable, which is what makes the line worth printing rather than logging. Seconds-per-claim is the wall time over M, so it cannot drift from the tally. And searches can never exceed tool calls, because issuing a search is a tool call.

What the measuring found

A report claimed 31 claims individually source-checked against 15 tool calls. Those cannot both be true. It passed every check that existed at the time, and the discrepancy was visible only in a harness transcript no reader of a published report can reach.

A run reported 21 claims source-checked from 12 searches. RUBRIC has always required every claim carrying a verdict to have had its own search, so that report claimed more verification than it performed.

Three runs disagreed with their own run records about how many searches they had issued — 35 against 40 logged, 21 against 29, and one that matched. Both numbers were being recalled independently rather than one deriving from the other.

All three are now rejected by tally.py: searches cannot exceed tools, searched claims cannot exceed searches, and the report's footer must agree with the run record beside it. The search count is counted from the query log rather than recalled.

An optimisation that was tried and reverted

Batching the first-pass searches was the largest item on the performance backlog, on the reasoning that searches dominate a run. Measured against the video with three prior baselines, it moved cost per checked claim from 44s to 45s — nothing — and produced the 21-claims-from-12-searches report above. Batching taught the agent to treat one search as covering several claims.

The skill text had explicitly warned that batching must never become skimming. That is the answer to whether a warning is sufficient, and the reason the guard is now a check rather than a sentence. Reverted; #18 reopened.

Fetching once

The normalized source text is saved to /tmp/bs-source-<slug>-<date>.md and re-read instead of re-fetched. Fetching is the most expensive call in the workflow and the most likely to fail — for YouTube it only works from a residential connection at all — and it moves the evidence underneath a run: three runs of one video across a few hours reported 137,717, then 141,618, then 141,926 views. Harmless in a header, not harmless when a claim was already rated against the older figure.

Published reports are filed by release

examples/0.5.0/ and examples/0.4.x/. The rubric, the source hierarchy and the verdict rules change every release, and search does not return the same evidence twice, so two reports from different releases are two instruments rather than two readings. Links into the old flat paths will 404.

Read this before comparing to earlier numbers

The first fully reconciled run reports 68 seconds per checked claim, against 44s measured at 0.6.1 on the same video. That looks like a large regression and probably is not one: 0.6.1's figure was computed before anything counted searches, so nothing verified that its 23 checked claims were each actually searched. The batching run shows how that goes wrong in practice.

The honest statement is that this is the first trustworthy reading of the instrument, and earlier figures are not comparable to it. Whether M has been quietly overstated for several releases cannot be settled from the artifacts — 0.6.1's search count is unrecoverable. It is the question #3 exists to answer.

Full changelog: v0.6.1...v0.7.0