Skip to content

Repository files navigation

Attest

ci license python benchmark

A research assistant that checks its own citations, and a measurement of what that checking is actually worth.

This is a postmortem. What's this? It is a working system, a benchmark harness, sixteen result files, and an honest account of which of its claims survived being checked.

The retrieval is ordinary on purpose. The measurement is the point.

The problem

A fabricated citation looks exactly like a real one. Same format, plausible title, sitting next to a claim it doesn't support. You either trust it or open every source, and opening every source is the work the assistant was supposed to save.

So the thing that decides whether a research tool is worth using isn't accuracy, it's the cost of checking. A tool that's right 95% of the time, where you can't tell which 5% is wrong, has to be checked 100% of the time.

Retrieval doesn't fix that. On a corpus where the right paper came back nearly every time, 9.87% of citations didn't support the claim they were attached to, and another 13.42% pointed at the wrong paragraph of the right paper.

What checking bought

                      off               on
citations that fail   ████████ 9.87%    ███ 4.15%      better
misattributed         ██████████ 13.4%  ████ 5.88%     better
confidently wrong     █ 1 of 40         · 0 of 40      better
correctness           ████████████ .450 ██████████ .375 worse

41 questions. Same corpus, prompts, grader, retrieval mode; one switch changed. Source: verifier-off-on.json, runs bench-50440d1e and bench-bacb0341.

off on
Citations that don't hold up 9.87% 4.15% −5.72 pts
Misattributed citations 13.42% 5.88% −7.54 pts
Citations per claim 2.38 1.76 −0.62
Unsupported claims, all answers 1.81% (3/166) 3.05% (5/164) +1.24 pts
Unsupported claims, answers shown 1.81% (3/166) 1.39% (2/144) −0.42 pts
Confidently wrong answers 1 of 40 0 of 40
Correctness vs gold answer 0.450 0.375 −0.075

Verification makes the agent cite more precisely. Citations that fail drop 58% relative. But most of that is simply citing less per claim, 2.38 down to 1.76, so the off arm emits more references that fail. Real improvement. Not the improvement the headline suggests.

It does not reduce invented claims. Per distinct claim the off arm leaves 3 of 166 unsupported and the on arm 5 of 164, which is the wrong direction. Three of those five sit in one answer the Gate escalated. Count only what a user sees as confident and it's 3 against 2.

At n=3 and n=2 that establishes nothing. This benchmark can't measure fabrication, because there isn't enough of it here to measure. It can measure citation precision, and that improves.

The cost is 7.5 points of completeness. The agent says less, so more answers land partial.

Ruling out the boring explanation — an agent that says nothing fabricates nothing
claims made      395 -> 289    (-27%)
fabrications      39 ->  12    (-69%)

volume alone predicts 28.5 fabrications. There are 12.
16.5 prevented beyond what saying-less accounts for.

Fabrications fell 2.5x faster than claim volume, so that particular deflationary story doesn't account for the effect. It isn't the only one. Counted per distinct claim the effect vanishes, because the off arm attaches more citations per claim rather than making more claims. Volume of claims is ruled out. Volume of citations per claim is not, and that's the one that mattered.

Error compounding

Correctness by question depth

Without verification, correctness falls as a question needs more papers: 0.58 at one paper, 0.25 across a literature. Depths 3 and 4 hold six and four questions, so the gap at any single depth is noise.

Someone else's benchmark

Everything above is self-graded. We wrote the questions, the reference answers, the rubric, and the judge's prompt. Qasper has evidence spans annotated by humans.

Evidence F1 Answer F1 Questions
0.473 0.307 260, over 94 papers

Evidence F1 is the one that matters. It compares paragraphs the agent cited against paragraphs humans marked as proof, and it's the only faithfulness number here that doesn't depend on a judge we wrote. The agent matched the annotators exactly on 45 of 260. It held as the sample grew: 0.471 at 156 questions, 0.473 at 260.

Then the result that tests the whole premise. Of 8 questions Qasper marks unanswerable, the agent declined on 2. It answered the other 6 from a paper that doesn't contain the answer. By Qasper's own metric that's 0 of 8, since the metric wants an empty answer and this agent declines in prose. Either way, 2 of 8, and it isn't good.

Source: qasper-260-unverified.json. Answer F1 isn't a leaderboard position: the Longformer baseline of 0.336 was fine-tuned on Qasper's training split, this is zero-shot, and the 2021 papers are plausibly in the model's training data. The run was meant to cover 1,005 questions and stopped at 260 when the Cohere trial key hit its 1,000-call monthly ceiling.

How it works

flowchart LR
    Q(["question<br/><i>+ selected passage</i>"]) --> P["Planner<br/><i>bounded sub-queries</i>"]
    P -->|"paper or task run"| T["Tool loop<br/><i>≤4 calls (8 for tasks),<br/>model chooses</i>"]
    P -->|"fixed run"| R["Retriever<br/><i>pgvector + FTS<br/>→ RRF → rerank</i>"]
    T --> S["Synthesizer<br/><i>grounded generation,<br/>provider-native citation spans</i>"]
    R --> S
    S --> V["Verifier<br/><i>every claim vs its cited paragraph</i>"]
    V --> G{"Gate<br/><i>supported? and<br/>on-topic?</i>"}
    G -- pass --> A(["answer + verdicts"])
    G -- "fail, first time" --> S
    G -- "fail again" --> E(["escalation:<br/>verified findings and<br/>flagged claims, separately"])

    T -.-> I{{"ingest_paper<br/><i>interrupt: human approves</i>"}}
    I -. "resume from checkpoint" .-> T
    V -.-> J["Judge<br/><i>different model family</i>"]
    V -.-> L["Lexical check<br/><i>deterministic, free</i>"]
Loading

Five nodes on LangGraph, checkpointed in Postgres. Three things matter more than the boxes.

Retrieval is a tool, not a stage (ADR-012). On a paper or task run the model picks, each iteration, between reading a section verbatim, searching the paper, searching the library, searching arXiv, and proposing an ingest. Four calls, eight for tasks, ending early on "done" or a repeat.

The ingest tool pauses the whole run. interrupt() parks the graph in its checkpoint, the API emits an approval event and frees its slot, and POST /runs/{id}/resume re-enters with your verdict. Replay repeats the earlier tool choices only because chat_json responses are memoised in Redis. Cold cache, and a resumed run re-samples. No test covers that.

(The bug worth remembering: interrupt() pauses by raising, and the loop's catch-all for failed tools swallowed it into a "tool failed" note.)

The Verifier is the unusual part. Every claim is checked against the exact paragraph it cites, by a judge from a different model family than the one that wrote it (ADR-003), plus a deterministic lexical check. A claim its paragraph doesn't support, but which another retrieved paragraph of the same paper does, is recorded as misattributed rather than fabricated. A misfiled reference isn't an invention. A claim supported by nothing in context is a fabrication however true it happens to be.

The Gate allows exactly one revision with the failing claims named, then escalates.

The glass box

Paste an arXiv link and the paper renders in-app: sections, figures, MathML, sanitized server-side and served in a sandboxed iframe with an opaque origin. Highlight a passage and a toolbar offers Ask, Explain, Summarize, so the selection becomes the agent's context instead of a guess. Tool calls stream as they happen. Ask about a paper the library doesn't have and the run pauses until you approve fetching it. Claims are underlined with their verdict, and clicking one shows the paragraph it was checked against.

Verification is off by default on the reader path. A run naming a paper or a task resolves to unverified unless it asks otherwise (app/runs.py), a late product call: readers didn't want a verdict on every sentence. So everything measured above describes /lab/compare and the benchmark, not what a reader sees.

Limitations

The five that matter, then the rest.

The headline counts citations, not claims. A claim attached to four chunks counts four times, so an arm that cites shotgun-style scores worse without inventing anything. Found after the off/on delta had been written up as a fabrication result. Per claim it doesn't survive.

Escalation is barely better than a coin flip. Precision 0.50: 3 of 6 escalations withheld an answer that was correct. Users meet those as pointless refusals.

Verified doesn't mean complete. 25 of 40 answers are partial. The Gate checks whether what was said is supported, not whether enough was said, so it passes them. Silent failure rate 0.657.

n=41, one domain, one model, on a corpus where the right paper is nearly always there. A benchmark this size can't detect a change in a 3-in-166 base rate.

Two-stage retrieval was rejected on one path and adopted on the other without being re-measured. It lifted gold-source coverage from 12/19 to 19/20 while making answers worse, 5/9 against 7/9, so RETRIEVAL_MODE still defaults to hybrid_rerank. But search_corpus calls retrieve_two_stage unconditionally, so every reader run uses it. The rejecting measurement ran on an abstracts-only corpus that no longer exists.

The rest — grader sensitivity, upper bounds, the cross-family check, and six other caveats
  • The grader moves the numbers. Re-grading stored answers with a revised rubric changed 18 of 40 grades with the agent unchanged. Comparisons across runs only hold when both sides pass through the same grader.
  • Committed citation numbers are an upper bound. Three fixes on 2026-07-29 all moved measured fabrication down and the benchmark wasn't re-run: the misattribution sweep was asking the lexical checker for a verdict it wasn't good enough to give, citation spans reached the judge with no subject so the judge invented one and then correctly refuted its own invention, and a tokenizer bug dropped the final content word of any claim ending in a period.
  • Misattribution is measured too generously offline. The offline check tests a failed claim against every chunk of a paper; the runtime check sees only retrieved ones. A claim from the model's own training can match a paragraph it was never shown.
  • The cross-family judge check compares config labels, not models. Startup refuses to boot when JUDGE_PROVIDER equals ACTOR_PROVIDER. It can't tell that a judge served over a local endpoint is the same family as the actor.
  • Two checkers aren't better than one. On 32 hand-labelled pairs the judge alone scores 30, the lexical check 31, both together 30. The lexical check earns its place by making the misattribution sweep affordable, ranking up to 83 candidates so the judge reads three.
  • Small samples pointed the wrong way. A 10-question slice predicted full text would drop correctness 0.500 to 0.400. The 41-question run showed 0.390 to 0.415.
  • Retrieval numbers are inflated. The corpus is the gold set, so hit@k reads 1.0. Recall@k and precision@k aren't computed at all.
  • The daily budget doesn't count everything. Chat and judge tokens only. Embedding and rerank endpoints aren't priced per token; ingestion is bounded by a paper quota instead.
  • One retrieval path skips the corpus filter. read_section reads by paper id with no source predicate. Safe only because ids are unique across corpora.
  • A figure that exists only as pixels in a plot is out of reach. Prose and results tables are both indexed. Charts aren't.
  • One gold paper has no full text. 1412.6980 (Adam) is a PDF-only submission, tagged answerable: false and scored on whether the agent declined. It did.

Run it

Needs Docker, uv, a Cohere key and an OpenAI key. Copy .env.example to .env, fill in COHERE_API_KEY and JUDGE_API_KEY, and the rest has working defaults.

make dev              # postgres + redis + api
make ui               # build the UI the API serves
make ingest-gold      # ingest the benchmark's papers
make ingest-fulltext  # add body text (dry run; RUN=1 to execute)
make ask Q="..."      # one question, streamed
make bench            # full benchmark -> bench/results/<date>-<sha>.json
make check            # ruff + mypy --strict + pytest

Open http://localhost:8000 and paste an arXiv link. A paper already held opens instantly and free. A new one goes through the access-key gate and a daily quota.

Reproducing the experiments, running without hosted keys, and what it costs
# verifier off/on
VERIFIER_ENABLED=false uv run python -m bench.harness --label verifier-off
uv run python -m bench.harness --label verifier-on
uv run python -m bench.off_on --off <off.json> --on <on.json>

# Qasper
uv run python -m bench.ingest_qasper --papers 281
VERIFIER_ENABLED=false uv run python -m bench.run_qasper --papers 281

Without hosted keys. Set JUDGE_PROVIDER=ollama with JUDGE_BASE_URL=http://localhost:11434/v1, EMBEDDING_PROVIDER=openai_compat against the same endpoint, and RETRIEVAL_MODE=hybrid. Verification and embedding then run locally. That last setting is required for the sentence to be true, because there's no local rerank endpoint and RERANK_PROVIDER=openai_compat is rejected at startup. The actor still needs a hosted key, and ADR-013 says why: grounded generation returns provider-native citation spans that the receipts layer consumes, and prompted-JSON spans are the pattern this project exists to distrust.

Cost. A first benchmark run is a few dollars. Re-runs are close to free, since provider calls are memoised in Redis keyed on the prompt (ADR-006). On a Cohere trial key the binding constraint isn't money, it's 1,000 calls a month.

No deployed instance. Deployment was planned day 1, dropped day 13 over an unfenced public endpoint, and the fence that objection named got built day 14: daily Redis budget, paper quota, bounded run slots, per-run timeout, keyless demo replay. The deploy itself never happened (ADR-005).

Where to look

If you read this the way you'd read a submission, these are the files that decide whether the claims hold.

bench/questions.json The 41 questions, gold answers, gold sources, depth labels.
bench/harness.py Runs the benchmark, writes one record per question as it goes.
bench/metrics.py Every rate in the table. Read accuracy and escalation first, the docstrings argue with the metrics they compute.
bench/off_on.py Re-checks two runs offline with identical code. Produced the headline table.
verify/grounding.py The lexical check, and the tokenizer whose ./- handling corrupted numbers in one line.
verify/judge.py Cross-family judge, its prompt, its host-qualified cache key.
agent/nodes/verifier.py Per-claim verdicts, the misattribution sweep, and a pinned decision that was reconsidered and deliberately not changed.
agent/nodes/gate.py One retry, then escalation.
tests/test_verification_regressions.py Six measurement bugs pinned where they happened.

Decisions

Thirteen ADRs in docs/adr/, each with what got rejected. Several were reversed by measurement and are left as reversals rather than edited to look right from the start. Three worth reading:

010 — cosine similarity can't detect a swapped figure. Then a bug turned out to have caused the conclusion that ADR drew about the lexical checker.

008 — a retrieval metric improved while the answers got worse.

005 — the decision that reversed.

Left undone

Not a roadmap. The project is stopped. These were open at the end, so nothing here reads as finished work.

No public deployment. No retrieval ablation, though RETRIEVAL_MODE supports every mode and bench/metrics.py computes hit@k, so it's runnable. No bench-slice CI job; CI runs ruff, mypy, pytest against real Postgres and Redis plus a UI build, and the slice job is a TODO. No threshold sweep, so escalation precision 0.50 is a single point on a curve nobody plotted. The benchmark wasn't re-run after the 2026-07-29 fixes. And the selection toolbar broke at the end: the reader's core gesture stopped working behind a CSP change, verified correct server-side, still refused by the browser when work stopped.

The commit history

Several commits exist to correct a number this project published about itself. The subject lines say so. bench: count claims as well as citations - and the headline does not survive it is one.

Six bugs were found in the measurement layer, and every one of them made the system look worse than it was. A tokenizer dropping the last word of any claim ending in a period. A judge inventing subjects for subjectless citation spans. A misattribution sweep asking the free checker for a verdict it wasn't good enough to give. None was caught by the 32 hand-labelled pairs, because those exercise one function while the defects lived in the pipeline around it.

A project arguing that systems should verify their own outputs should be able to show it doing that to itself.

License

MIT, see LICENSE. Bundled fonts are OFL, see ui/public/fonts/README.md.

About

Agentic research assistant that verifies its own citations — with measured trade-offs

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages