13 KiB
Review round 2: verifying the response to round 1
Reviewer: Claude, 2026-08-03. Every claim in
response-rag-retrieval-2026-08-03.md was re-run, not read.
Verdict: five findings are genuinely fixed. One is not fixed — it was moved. Three new problems appeared in the fix itself.
Reproduction:
python -m rag.run_eval \
--cases evals/manual_adversarial_hard10.jsonl \
--documents ../../ingestion/scratch/rag-table-pilot/out/all/retrieval_documents.jsonl \
--parents ../../ingestion/scratch/rag-table-pilot/out/all/logical_tables.jsonl \
--aliases evals/drug_aliases.json
-> recall_at_1/3/5 = 0.8889, negative_abstain_rate = 1.0
The reported numbers reproduce exactly.
Confirmed fixed
Checked in the code and by re-running, not taken on trust:
- F2 —
QUANTITATIVE_TERMSand_structured_boostare gone. Ranking is now real BM25 with IDF over the loaded corpus plus a character-n-gram term. No hand-written vocabulary remains in the ranker. - F7 —
run_evalnow constructsEvidencePolicy()with the shipped defaults. - F8 —
_char_ngramsoperates on_normalized(text)in original order. The sorted-unique-terms behaviour is gone. - F1 mechanism —
ambiguity_marginis removed fromEvidencePolicyand_is_ambiguousis deleted. Score ties no longer cause a refusal. - F5 partly —
run_evalno longer handsdrug_idto retrieval. ACatalogDrugResolverruns first, and thefamcicloviatypo genuinely resolves throughSequenceMatcher;renal-herpes-typonow passes asanswerablewith resolution actually exercised. This is a real improvement. - F4 partly — the prose layer now covers 14,915 documents across 684 drug IDs, not 6. Also a real improvement.
- Verification claims — all reproduced:
ingestion204 passed,apps/ai-service10 passed, and lint is clean under bothruff check rag testsand the project's stricter--select F,E9,B,ARG. The two ARG001 errors from round 1 are fixed.
1. NOT fixed — finding 2 was relocated, not resolved
Round 1's finding was: four of ten passes are bought by a hand-written term list drawn from the scored queries. The ranker is now clean. But the same pattern reappeared one layer up, in the thing that replaced it:
VETERINARY_TERMS = frozenset({"gia suc", "gia cam", "meo", "thu y"})
plus a special-cased regex for chó. The evaluation has exactly one
negative case, and it is about a mèo. negative_abstain_rate: 1.0 is
computed over n = 1, and that one word is in the list.
Measured, running the full QueryRoutingService against out/all:
| Query ending | Result |
|---|---|
... cho mèo |
ABSTAIN out_of_scope_veterinary |
... cho chó |
ABSTAIN out_of_scope_veterinary |
... cho thỏ |
ANSWERS grounded_evidence_available |
... cho ngựa |
ANSWERS grounded_evidence_available |
... cho lợn |
ANSWERS grounded_evidence_available |
... cho bò sữa |
ANSWERS grounded_evidence_available |
... cho chuột lang |
ANSWERS grounded_evidence_available |
... cho vẹt cảnh |
ANSWERS grounded_evidence_available |
... dùng trong thú cưng |
ANSWERS grounded_evidence_available |
Seven of nine veterinary phrasings are answered with a human famciclovir
dose and the decision grounded_evidence_available. Note the last row: thu y is in the list, but thú cưng normalises to thu cung and misses.
HumanClinicalScopeGuard is not a scope guard. It is a five-entry animal-word
list, and the evaluation that scores it contains exactly the words in it. The
round-1 objection was never about _structured_boost specifically — it was
about measuring a component against the cases it was written from. That
objection still stands, unchanged, against this code.
A scope guard that generalises cannot be a keyword list. It has to come from something the corpus actually says — the book is a human formulary, so the question is whether the query's subject is a human patient, not whether it contains one of five nouns.
2. recall_at_5 is not a measurement
EvidencePolicy.evidence_limit is 3, so result.evidence never exceeds three
items and retrieved_ids never exceeds length 3 — observed lengths across the
run are {0, 1, 2, 3}. _recall_at(rows, 5) then slices [:5] of a tuple
that is at most 3 long.
recall_at_5 is forced to equal recall_at_3 for every possible input.
It is not a third data point; it is recall_at_3 printed twice. Round 1 asked
for Recall@1 and Recall@3 reported separately, and that part is done and
useful — but reporting a third identical figure makes the result look more
thoroughly measured than it is.
Either raise evidence_limit above 5 for the diagnostic run, or drop
recall_at_5.
3. expected_drug_id was added and never scored
The field exists in EvaluationCase and is populated by read_cases for all
10 cases. It appears nowhere else — EvaluationOutcome.passed and
summarize() never read it.
So resolution now happens, but resolution correctness is still unmeasured. A case that resolves to the wrong drug and then abstains is indistinguishable in the report from a case that correctly abstained. That is precisely the distinction finding 5 existed to create.
Scoring it is a two-line change and would make ors-who-composition's
drug_resolution_ambiguous legible as "resolver declined" rather than an
unexplained miss.
4. The alias catalog is one drug out of 684, and it is one under test
evals/drug_aliases.json in full:
{"thuoc_uong_bu_nuoc_va_ien_giai": ["oresol", "ORS"]}
684 drugs in the catalog, hand-aliases for 1, and that 1 is the drug behind
two of the ten cases. docs/v1-delivery-plan.md §B1 records 344 real
X - xem Y aliases already extractable from the back index, plus 492
ten_thuong_mai entries (§B2). None are wired in.
This is the same shape as finding 2: the coverage that exists is exactly the coverage the test needs. Loading the 344 measured aliases would make the resolver's alias path testable against something other than itself.
5. "out/all" does not mean the whole book, and "out/100" no longer means anything
Measured from the manifests and the artifacts:
| artifact | manifest pages | docs | drugs | prose | table_whole | table_row | formula |
|---|---|---|---|---|---|---|---|
out/all |
116 | 15,727 | 684 | 14,915 | 133 | 669 | 10 |
out/100 |
100 | 15,593 | 684 | 14,915 | 116 | 552 | 10 |
out/hard10 |
10 | 164 | 6 | 130 | 4 | 28 | 2 |
Two things follow.
The prose layer is now genuinely whole-corpus (identical 14,915 documents in
both), which is the real fix and deserves the credit. But the table/formula
layer in out/all covers 116 pages, and its 133 parents + 10 formulas match
the 133 logical parents + 10 formulas recorded in the progress log — so "all"
is honest for tables and misleading as a general label. The response's
sentence "out/all now has 15,727 documents across all 684 drug IDs" is
literally true and reads as whole-book coverage of everything, which it is not.
Second: out/100 and out/all now differ by 17 tables and 117 rows and
nothing else. The 100-page scope has stopped being a distinct scope. Either
retire it or say what it is for.
6. assert on the request path
routing.py:131 — assert resolution.drug_id is not None. Assertions are
removed under python -O, at which point retrieve is called with None.
Minor, but it is in the live path; make it an explicit raise.
On the ORS miss
The response calls the one positive miss "intentionally safe". That is defensible — "Công thức oresol WHO UNICEF pha một lít có bao nhiêu natri clorid?" does name two catalog entities, and declining beats guessing.
Worth stating the cost plainly, though: this is now the second mechanism that refuses an answerable clinical question (the tie was the first, and it is gone). Any question naming a drug and one of its ingredients will hit it, and that pattern is common in a formulary. It is untested beyond this single case. Not a defect — an accepted trade-off that should be measured before it is called safe.
7. Added after the fact — the alias gap makes common drugs unreachable
This came out of testing §4's practical effect and is more serious than §1.
Monograph headings that carry a parenthesised synonym become a single
compound drug_id, and build_drug_catalog produces no alias for either
part:
paracetamol_acetaminophen -> {"paracetamol acetaminophen",
"PARACETAMOL (Acetaminophen)"}
acid_acetylsalicylic_aspirin -> {"acid acetylsalicylic aspirin",
"ACID ACETYLSALICYLIC (Aspirin)"}
Measured against out/all:
| Query | Resolution |
|---|---|
Liều paracetamol cho người lớn là bao nhiêu? |
not_found |
Chống chỉ định của aspirin là gì? |
not_found |
Liều paracetamol acetaminophen cho người lớn? |
resolved |
Liều metformin cho người lớn là bao nhiêu? |
resolved |
Two of the most-asked-about drugs in any formulary are unreachable unless the user types the book's exact compound heading. It fails safely — it abstains rather than answering wrongly — which is exactly why the 8/9 diagnostic cannot see it: none of the ten cases involves a parenthesised heading.
docs/v1-delivery-plan.md §B1/§B2 already record 344 X - xem Y aliases
and 492 ten_thuong_mai entries as extractable. Until they are loaded,
resolver coverage is whatever the headings happen to spell.
Correction to my own suspicion. I expected the response's claim — "queries
with multiple distinct drug entities abstain as ambiguous" — to be false,
because Nên dùng paracetamol hay ibuprofen cho trẻ sốt cao? answers about
ibuprofen alone. It is not false. Re-tested with two drugs that are both in
the catalog:
Tuong tac giua digoxin va amiodaron -> ambiguous
Nen dung omeprazol hay pantoprazol -> ambiguous
Tuong tac giua warfarin va amiodaron -> ambiguous
The multi-entity guard works. The paracetamol/ibuprofen query slips through because paracetamol is not reachable at all, so the query looks single-entity. The alias gap does not merely reduce coverage — it silently disables the ambiguity protection that Codex is relying on.
8. "Veterinary" is not a requirement this project ever had
Worth saying plainly, because §1 spent the entire fix budget on it. The veterinary category exists in this codebase for one reason: Codex wrote one negative eval case about a cat, round 1 showed it passed by coincidence, and the repair was a guard for cats.
The out-of-scope categories the project documents are different ones —
docs/v1-delivery-plan.md §8 (general chapters printed 37-98 and appendices
1497-1528 are not in the corpus) and docs/architecture.md (scoped refusal
for questions that are not formulary lookups). Measured against out/all:
| Category | Result | Reason |
|---|---|---|
| general chapter — "nguyên tắc kê đơn thuốc" | ABSTAIN | drug_not_resolved |
| general chapter — "ngộ độc và thuốc giải độc" | ABSTAIN | drug_not_resolved |
| appendix — "bảng tương hợp thuốc tiêm truyền" | ABSTAIN | drug_not_resolved |
| drug outside the formulary — semaglutid | ABSTAIN | drug_not_resolved |
| symptom diagnosis — "tôi đau đầu buồn nôn" | ABSTAIN | drug_not_resolved |
| recommendation — "nên dùng X hay Y cho trẻ sốt cao" | ANSWERS | grounded_evidence_available |
Five of six abstain, but none of them because scope was checked — they abstain
because no drug name matched, which is drug_not_resolved doing scope work by
accident. The one that gets through is the recommendation question, which
architecture.md explicitly says must be refused.
So the guard covers a category nobody asked for, covers it with five words, and the category that is specified is unhandled.
Summary
| Round-1 finding | Status |
|---|---|
| 1 — refusal was a score tie | mechanism removed; replacement is a 5-word list, see §1 |
| 2 — boost tuned on scored queries | fixed in the ranker; pattern reappears in the scope guard |
| 3 — Recall@3 sold as Recall@1 | fixed; but recall_at_5 is padding, see §2 |
| 4 — eval locked to 6 drugs | prose fixed (684 drugs); table layer still 116 pages |
| 5 — drug_id handed in | resolver added and works; correctness still unscored, see §3 |
| 6 — manual cases in expert gate | fixed; expert_release_gate now has 0 cases and null metrics |
| 7 — eval used non-default policy | fixed |
| 8 — char n-grams sorted | fixed |
Priority order:
- §7 — the alias gap.
Liều paracetamol cho người lớn?returnsnot_found. It is the most likely question a real user asks, it fails today, and it also disables the multi-entity ambiguity guard. Loading the 344 back-index aliases and splitting parenthesised headings fixes both. - §1 — the scope guard. Seven of nine veterinary phrasings are answered
with a human dose under the label
grounded_evidence_available. Lower than §7 only because a doctor is unlikely to ask it; the label is what makes it dangerous. - §8 — the specified out-of-scope category (recommendation questions) is unhandled while an unspecified one has a guard.
- §2, §3, §4, §6 — reporting and coverage bookkeeping.