See my other projects(opens in a new tab)

Evaluation

I did not write a single number below. A program produced all of them: it put 70 questions to the real models and checked every answer that came back. When the result was unflattering, I published it unchanged.

Correct refusals
100%

Questions the document cannot answer, refused instead of invented.

False refusals
4%

The answer was in the document and it refused anyway. Lower is better.

Found the right clause
98%

The clause the answer needed was among the 8 passages retrieved.

Quotes were real
100%

Every quote shown appeared in the document, word for word.

70 questions · $0.0072 each · commit 8dff8d9. Read the first two together: a system that refuses everything scores 100% on the first. The second is what catches it.

One thing up front: this exam was sat on three synthetic documents written for this project. It does not tell you what happens with real policies. The full report lists what else it cannot measure.

The numbers over time

The shipped configuration, over the 70-question set. One point per run.

0%50%100%b0da99fb0da99fe950a6b0a6f11c8dff8d9
  • Balanced accuracy
  • Refusal accuracy
  • False-refusal rate

The full report

How the numbers were produced, and what they miss. That is the opening section.

Read this first — what these numbers do not show

Placed before the results rather than after them, because a caveat at the bottom of a report is a caveat nobody reads.

  • 1 question(s) failed with a provider error and are counted as refusals. A question the provider never answered looks identical, in every metric here, to one the system declined — so a bad afternoon at the API arrives as a false-refusal rate. The arms carrying the entailment check make three serial provider calls per question instead of two, and they are the arms with errors: naive_entailed (1). Read every false-refusal figure below with that subtracted.

  • The entailment check did not do what it was built to do. It exists because an earlier run of this report diagnosed the previous mechanisms as blind to unwarranted inference, and it is the only pass that is shown the question. On this corpus it moved refusal accuracy by +0%, moved the false-refusal rate by +0%, and added 28% to the cost of every question. Subtract the provider errors above and it changed no decisions — the same finding as the two mechanisms before it, reached the same way. It does catch something on the adversarial set (report_hard.md), and nothing here; shipping it always would be paying on every question for a check that fires on documents this corpus does not contain.

  • The verifier scores multi-clause answers lowest — the category the product exists to handle. Mean groundedness by category runs from 1.00 (table) down to 0.87 (multi_clause), while decision accuracy on multi_clause is 89%: every one of those answers was correct. The cause is in the verification prompt, which flags "two separate excerpts merged into a single claim that neither supports alone" — and a correct multi-clause answer is exactly that. The rule that catches a fabricated synthesis also catches a legitimate one. Two answers landed on 0.50, at the suppression boundary; raising the threshold to 0.6 would withhold correct answers about coverage exclusions, which is the kind of answer a user most needs.

  • Citation validity of 100% is partly structural. The answering model is constrained by a provider-enforced JSON schema and the context is small, so malformed or invented chunk ids are close to impossible by construction. The interesting half of binding — catching a quote that does not appear in a chunk it names — was never exercised here.

Run
Generated2026-08-11 00:26 UTC
Commit8dff8d9
Answering modelclaude-haiku-4-5-20251001
Embedding modelvoyage-4-lite (1024 dimensions)
Promptsanswer_v2, verify_v1
Questions70
Adversarial negatives21 (30%)
The ablation

Two independent variables, four arms: the prompt (a strict grounding prompt versus a naive one) crossed with the mechanisms (citation binding and self-verification, on or off).

The naive prompt is not a strawman. It asks for accuracy, requests citations and returns the same JSON — it is what a competent developer writes on a first pass. What it does not do is forbid outside knowledge, demand verbatim quotes, or say that “not in the document” is an acceptable answer.

ArmRefusal accuracyFalse-refusalBalancedCitation validitySuppressed$/question
naive prompt, no mechanisms76%0%88%100%0$0.0035
naive prompt + mechanisms71%4%84%97%2$0.0063
strict prompt, no mechanisms100%4%98%100%0$0.0049
strict prompt + mechanisms (shipped)100%4%98%100%0$0.0072

Baseline to shipped: balanced accuracy 88% → 98%, refusal accuracy 76% → 100%.

Read refusal accuracy and false-refusal rate together. The first is trivially gamed by refusing everything, the second by never refusing. Balanced accuracy is the mean of the two and lands at 50% for either degenerate strategy — it is the column to compare arms on.

Comparing rows tells you which lever did the work. naive_only → strict_only isolates the prompt. naive_only → naive_guarded isolates the mechanisms. If the two paths to strict_guarded are not equal, the levers are not independent.

Retrieval

Measured over the answerable questions only — a negative has no correct chunk to find. A hit requires every expected span to be present in the chunks that actually reached the prompt, not merely retrieved: a peril without its limit has retrieved the question restated, not the answer.

Recall@898%
MRR0.833
Answerable questions49

By category

CategoryQuestionsRecall@8Decision accuracy
cross_lingual7100%100%
factual19100%95%
multi_clause989%89%
negative0100%
table14100%100%

negative has no recall figure by construction — there is nothing to retrieve. Its decision accuracy is the refusal accuracy for that subset.

Refusal
Correct refusals21 / 21
False refusals2 / 49
Refusal accuracy100%
False-refusal rate4%
Balanced accuracy98%

The retrieval floor

Before any model is called, the nearest retrieved passage is checked against a cosine-distance threshold. A question nothing is close to is refused for free. Reproduce with uv run python -m eval.measure_floor.

The threshold is 0.72, measured in the space of voyage-4-lite. Both are stated because neither means anything without the other: cosine distance is not comparable across embedding models, so a threshold quoted on its own cannot be checked, and a threshold left behind when the model changes cannot be noticed.

PopulationnminmedianmaxRefused by the floor
answerable490.36030.48900.69670 / 49
on-topic, unanswerable210.52420.58910.72212 / 21
other insurance topic180.40950.71840.83039 / 18
unrelated entirely180.73390.85590.958618 / 18
identifier queries80.58320.70120.80103 / 8

What the floor does not do is the point. The answerable and on-topic-unanswerable populations overlap almost completely — the nearest unanswerable question is closer than the median answerable one — so no threshold separates them and the floor does not try. It separates on-topic from off-topic, where the gap is real, and leaves the harder judgement to the prompt.

Citations and groundedness
Citations offered61
Survived binding61
Citation validity100%
Answers suppressed (caught hallucinations)0
Mean groundedness (served answers)0.96

Mean groundedness by category, over served answers:

CategoryMean groundednessDecision accuracy
multi_clause0.8789%
factual0.9795%
cross_lingual0.98100%
table1.00100%

Groundedness distribution over served answers:

BandAnswers
high (>=0.8)43
medium (0.5-0.8)4
low (<0.5)0

The mean covers served answers only. Including suppressed ones would mix “we checked and it held up” with “we checked, it didn't, and we withheld it” — the second is a success of the system, counted separately as a caught hallucination.

Cost and latency
Cost per question$0.0072
p50 latency5.8s
p95 latency8.1s
Total for this run$2.77

p50 and p95 rather than a mean: one cold start moves a mean and says nothing about the typical experience.

Hard documents

A policy that contradicts itself. And one typeset in two columns. A separate exam, separate numbers.

100%

Correct refusals

10%

False refusals

12

questions

The whole run: report_hard.md

To regenerate it: python -m eval.run_eval