feat(retrieval): numeral-fold (ordinal-word <-> Roman) + mined ground-truth eval instrument

The first MEASURED, above-noise retrieval win this thread. The 75-q
n=3 audit_mode bench couldn't resolve any single lever (every failure
class <=3-5 q, sub the 5pp floor — four hypotheses died there). Fix
the instrument, not just the lever:

- bench/mine_questions.py + bench/recall_at_k.py: mine questions from
  corpus titles (ground-truth target known by construction), grade by
  deterministic retrieval recall@k via `query --dry-run` — no LLM, no
  verifier, no n=3 noise, scalable to the 22K-deep numeral pool. The
  curated qa_questions.txt stays the separate verifier-honesty/trap
  gate; mined fixtures measure the answerable long tail per class.

- _numeral_fold_variants in query.py: ordinal-word ("Alexander the
  second") <-> multi-char Roman ("Alexander II"), additive+symmetric,
  unioned into _title_query_tokens exactly like _hyphen_fold_variants
  (#000007). Strict 2..40 Roman set → no English-word collision;
  single-char Romans (I/V/X) intentionally out of scope (universal
  len>1 token filter — stated before building, ~4 of 10 residual
  misses).

Measured on the mined numeral fixture: recall@8 22/40 (55%) -> 30/40
(75%), +20pp; 20 hits now rank-1. Discipline applied end to end:
measured-first, mirrored precedent, full-suite regression run (2482
passed, 0 regressions — numeral-fold is hot-path in
_title_query_tokens), real-path test (FakeSource->ingest->query()->
real _Hit, not a hand-built object), measured-after on a noise-free
instrument. The ~6 multi-char residual misses are a different
downstream cause the instrument now exposes for future iteration.
This commit is contained in:
russell@unturf.com 2026-05-18 15:16:10 -04:00
parent 5d43fdc037
commit a3ac6539c1
No known key found for this signature in database
6 changed files with 635 additions and 0 deletions

View file

@ -156,7 +156,64 @@ def _hyphen_fold_variants(s: str) -> set[str]:
return out
# Numeral-fold (measured 2026-05-18): ordinal-word query ("Alexander
# the second") never reaches a Roman-numeral title ("Alexander II")
# because `second` != `ii`. Mined-fixture recall@8 = 22/40 = 55 %,
# 18 clean misses all this shape. Same additive+symmetric mechanism
# as `_hyphen_fold_variants` (#000007). Scope is honest: only
# multi-char Romans survive the universal `len > 1` token filter, so
# single-char Romans (I/V/X — "Charles V") are out of reach by token-
# fold alone (a deeper change; ~4 of the 18, separately measurable).
# Strict 2..40 set → no English-word collision ("did"/"mix"/"civil"
# are not Romans here); single-char forms intentionally absent.
_NUM_ORD_TO_ROMAN = {
"second": "ii", "third": "iii", "fourth": "iv", "sixth": "vi",
"seventh": "vii", "eighth": "viii", "ninth": "ix", "eleventh": "xi",
"twelfth": "xii", "thirteenth": "xiii", "fourteenth": "xiv",
"fifteenth": "xv", "sixteenth": "xvi", "seventeenth": "xvii",
"eighteenth": "xviii", "nineteenth": "xix", "twentieth": "xx",
}
_NUM_ROMAN_TO_ORD = {v: k for k, v in _NUM_ORD_TO_ROMAN.items()}
def _numeral_fold_variants(s: str) -> set[str]:
"""Symmetric ordinal-word <-> multi-char-Roman fold.
"who was Alexander the second?" -> {"ii"}
"Alexander II" -> {"second"}
"plain query" -> set()
Additive (mirrors `_hyphen_fold_variants`): callers union this
into the token set, so existing matches are preserved and a
folded query token can now also overlap a Roman-numeral title.
Stopword/length filtered like `_title_query_tokens`.
"""
out: set[str] = set()
for tok in _TITLE_TOKEN_RE.findall(s):
tl = tok.lower()
v = _NUM_ORD_TO_ROMAN.get(tl) or _NUM_ROMAN_TO_ORD.get(tl)
if v and len(v) > 1 and v not in _TITLE_STOPWORDS:
out.add(v)
return out
def _title_query_tokens(s: str) -> set[str]:
base = {
t.lower()
for t in _TITLE_TOKEN_RE.findall(s)
if t.lower() not in _TITLE_STOPWORDS and len(t) > 1
}
# Hyphen-fold: additively include joined-no-hyphen variants for
# hyphenated runs in `s`. Symmetric — the function is called on
# both queries and titles, and additive fold preserves existing
# match patterns (e.g. `Coca-Cola history` query keeps {coca,
# cola, cocacola, history} so a `Coca-Cola` title still passes
# title-breadth via {coca, cola, cocacola}). See Ticket #000007.
base |= _hyphen_fold_variants(s)
# Numeral-fold: ordinal-word <-> Roman-numeral, same additive+
# symmetric discipline (measured 2026-05-18; see above).
base |= _numeral_fold_variants(s)
return base
base = {
t.lower()
for t in _TITLE_TOKEN_RE.findall(s)

133
bench/mine_questions.py Normal file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Mine ground-truth-carrying eval questions from the corpus itself.
The 75-q curated bench can't resolve any single retrieval lever (each
failure class is <=3-5 q, sub the 5pp n=3 noise floor four
hypotheses died on that). Fix the *instrument*: mine questions whose
target article is KNOWN by construction (we mined the question from
that title), so a lever is graded by deterministic retrieval
recall@k no LLM, no verifier, no n=3 noise, scalable to thousands.
This is NOT a replacement for bench/qa_questions.txt that curated,
deliberately-adversarial set stays the verifier-honesty/trap gate.
Mined questions are answerable-by-construction; they measure the
*answerable long tail* of retrieval per failure class.
v1 = the numeral class (Arabic/ordinal query vs Roman-numeral title
the diagnosed `world war 2`!=`World War II`, `henry the eighth`!=
`Henry VIII` miss). ~22K-deep pool in the live shards.
Pure python+sqlite3, deterministic, no egress, no LLM-for-generation.
"""
from __future__ import annotations
import argparse
import glob
import json
import re
import sqlite3
from pathlib import Path
# Strict Roman set 1..40 (covers monarchs/popes/wars); membership test
# avoids English-word collisions ("DID"/"MIX"/"CI" are not in here).
_ROMAN = {
1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII",
8: "VIII", 9: "IX", 10: "X", 11: "XI", 12: "XII", 13: "XIII",
14: "XIV", 15: "XV", 16: "XVI", 17: "XVII", 18: "XVIII", 19: "XIX",
20: "XX", 21: "XXI", 22: "XXII", 23: "XXIII", 24: "XXIV", 25: "XXV",
26: "XXVI", 27: "XXVII", 28: "XXVIII", 29: "XXIX", 30: "XXX",
31: "XXXI", 32: "XXXII", 33: "XXXIII", 34: "XXXIV", 35: "XXXV",
36: "XXXVI", 37: "XXXVII", 38: "XXXVIII", 39: "XXXIX", 40: "XL",
}
_ROMAN_TO_INT = {v: k for k, v in _ROMAN.items()}
_ORD = {
1: "first", 2: "second", 3: "third", 4: "fourth", 5: "fifth",
6: "sixth", 7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth",
11: "eleventh", 12: "twelfth", 13: "thirteenth", 14: "fourteenth",
15: "fifteenth", 16: "sixteenth", 17: "seventeenth",
18: "eighteenth", 19: "nineteenth", 20: "twentieth",
}
# High-precision monarch/pope shape: "<Name> <Roman>" or
# "<Name> <Roman> of <Place>". Clean, unambiguous, answerable.
_MONARCH = re.compile(
r"^([A-Z][a-z]+(?:os|us|er)?) (" + "|".join(_ROMAN.values()) + r")"
r"( of [A-Z][a-zA-Z ]+)?$"
)
_BAD = ("list of", "(disambiguation)", "(album)", "(song)", "(film)",
"(band)", "(novel)", "(video game)")
def mine(shards_dir: str, limit: int) -> list[dict]:
out: list[dict] = []
seen: set[str] = set()
for db in sorted(glob.glob(f"{shards_dir}/00*.db")):
c = sqlite3.connect(db)
c.row_factory = sqlite3.Row
try:
rows = c.execute(
"SELECT document_root, title FROM documents "
"WHERE title IS NOT NULL"
).fetchall()
except sqlite3.OperationalError:
c.close()
continue
for r in rows:
title = (r["title"] or "").replace("_", " ").strip()
tl = title.lower()
if any(b in tl for b in _BAD) or title in seen:
continue
m = _MONARCH.match(title)
if not m:
continue
name, roman, place = m.group(1), m.group(2), (m.group(3) or "")
n = _ROMAN_TO_INT[roman]
if n not in _ORD: # keep natural ordinal phrasing only
continue
seen.add(title)
# Surface variant: the Arabic/ordinal form a user types,
# vs the Roman-numeral title the corpus stores.
q = f"who was {name} the {_ORD[n]}{place.lower()}?"
out.append({
"question": q,
"target_title": title,
"target_root": r["document_root"],
"shard": Path(db).name,
})
if len(out) >= limit:
c.close()
return out
c.close()
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--shards-dir",
default=str(Path.home() / ".arborist" / "shards"))
ap.add_argument("--limit", type=int, default=40)
ap.add_argument("--cls", default="numeral")
a = ap.parse_args()
rows = mine(a.shards_dir, a.limit)
root = Path(__file__).resolve().parents[1] / "bench"
txt = root / f"qa_questions_{a.cls}.txt"
mp = root / f"qa_questions_{a.cls}_map.json"
hdr = [
f"# AUTO-MINED ({a.cls} class) from corpus titles via "
"bench/mine_questions.py — ground-truth-carrying.",
"# Graded by deterministic retrieval recall@k "
"(bench/recall_at_k.py), NOT audit_mode. Not adversarial; "
"complements (never replaces) qa_questions.txt.",
"",
]
txt.write_text("\n".join(hdr + [r["question"] for r in rows]) + "\n")
mp.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n")
print(f"mined {len(rows)} {a.cls} questions")
print(f" -> {txt}\n -> {mp}")
for r in rows[:8]:
print(f" Q: {r['question']!r} -> target: {r['target_title']!r}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,43 @@
# AUTO-MINED (numeral class) from corpus titles via bench/mine_questions.py — ground-truth-carrying.
# Graded by deterministic retrieval recall@k (bench/recall_at_k.py), NOT audit_mode. Not adversarial; complements (never replaces) qa_questions.txt.
who was Albert the third?
who was Ahmed the third?
who was Alaric the first?
who was Alexander the first of epirus?
who was Alexander the second of scotland?
who was Alexander the second?
who was Alexander the fourth?
who was Alyattes the second?
who was Afonso the fourth of portugal?
who was Alfonso the second of asturias?
who was Alfonso the fourth of aragon?
who was Alfonso the third?
who was Alfonso the fifth?
who was Anastasius the second?
who was Abbas the second of egypt?
who was Charles the fifth?
who was Constantius the second?
who was Constantine the second of scotland?
who was Charles the first of england?
who was Frederick the fifth?
who was Henry the seventh?
who was Mehmed the first?
who was Mustafa the first?
who was Mieszko the first of poland?
who was Malcolm the first of scotland?
who was Osman the second?
who was Quake the second?
who was Stephen the third?
who was Oscar the first of sweden?
who was Charles the fifteenth of sweden?
who was Sviatoslav the first of kiev?
who was Catherine the second of russia?
who was Childeric the first?
who was Rudolph the first of germany?
who was Xerxes the second of persia?
who was Richard the second of england?
who was Gustav the first of sweden?
who was Photios the first of constantinople?
who was James the fifth of scotland?
who was Basarab the first of wallachia?

View file

@ -0,0 +1,242 @@
[
{
"question": "who was Albert the third?",
"target_title": "Albert III",
"target_root": "43ff05c3c851e358df34c36d7398fefc53998e5a9cd8654b5c2f8dd9524b9efd",
"shard": "000.db"
},
{
"question": "who was Ahmed the third?",
"target_title": "Ahmed III",
"target_root": "2fc91045e60b915d146f1f4a0f0c72e2df2f644efbc299ea27ca40170e6092aa",
"shard": "000.db"
},
{
"question": "who was Alaric the first?",
"target_title": "Alaric I",
"target_root": "57e7aa58a4fd36b42a920956929e7c852108f2e3d36c87487d411ec4aa7f6686",
"shard": "000.db"
},
{
"question": "who was Alexander the first of epirus?",
"target_title": "Alexander I of Epirus",
"target_root": "db479a5835381d3d94705976dd6adede8fef8e51ea88cc6c3fe4c789e1e1ae35",
"shard": "000.db"
},
{
"question": "who was Alexander the second of scotland?",
"target_title": "Alexander II of Scotland",
"target_root": "4d5885f25ab6d7ebd0054dfdb4783f00db96f210ea33cdff2c0dbb0b92b45a13",
"shard": "000.db"
},
{
"question": "who was Alexander the second?",
"target_title": "Alexander II",
"target_root": "190bf209efb2f096f9b93c7dd2d0b0be9a64d6aa469d27cf2272664e4f624de2",
"shard": "000.db"
},
{
"question": "who was Alexander the fourth?",
"target_title": "Alexander IV",
"target_root": "cc3134f31a0d69f03aa37a5821fc179082f34316e782d464b0685f3ed0e90f0a",
"shard": "000.db"
},
{
"question": "who was Alyattes the second?",
"target_title": "Alyattes II",
"target_root": "464c0878c6fcabaf67f37389aa343b180b2762325a9aa916aa0d95dd7529e853",
"shard": "000.db"
},
{
"question": "who was Afonso the fourth of portugal?",
"target_title": "Afonso IV of Portugal",
"target_root": "88c2a881546a4cfa59c8eba1e037d2092ab73c9f09feb9524801d64763dfbd3e",
"shard": "000.db"
},
{
"question": "who was Alfonso the second of asturias?",
"target_title": "Alfonso II of Asturias",
"target_root": "1b2073183930900d65f1433efb70e8080628b0aa440817a3eb235d70ddf18f61",
"shard": "000.db"
},
{
"question": "who was Alfonso the fourth of aragon?",
"target_title": "Alfonso IV of Aragon",
"target_root": "fb96fb9d074fefd80da864c412a9f07cab26b1a217b54a378442143c147316d7",
"shard": "000.db"
},
{
"question": "who was Alfonso the third?",
"target_title": "Alfonso III",
"target_root": "555a58a9f7c947f32e18dcd6dfb03fb13ba7cc29f144c5b98f8af087892d9ac0",
"shard": "000.db"
},
{
"question": "who was Alfonso the fifth?",
"target_title": "Alfonso V",
"target_root": "c7aaf0276378cefbbb1a44dc1f90b4cb5928411880c7869ea68628f09f1ebde2",
"shard": "000.db"
},
{
"question": "who was Anastasius the second?",
"target_title": "Anastasius II",
"target_root": "2cb452df76d8d15494550c8070b51bca9f369647c95574e6a4f9cb441f865c0c",
"shard": "000.db"
},
{
"question": "who was Abbas the second of egypt?",
"target_title": "Abbas II of Egypt",
"target_root": "8c3378f3375831933b0c0865074747c264599ceb2650f866c5d61d3635126035",
"shard": "000.db"
},
{
"question": "who was Charles the fifth?",
"target_title": "Charles V",
"target_root": "22d8e5eb9bebef543cecda7b33a1f0d300cd24e3a13427c6a297524c9813718f",
"shard": "000.db"
},
{
"question": "who was Constantius the second?",
"target_title": "Constantius II",
"target_root": "5f8bd920302cc83ee5aef59abfda0aafd31b6e1cb48cfc4f5c7d4c3e69bedd4a",
"shard": "000.db"
},
{
"question": "who was Constantine the second of scotland?",
"target_title": "Constantine II of Scotland",
"target_root": "24e398f6b012f01c0e1b1cb7fad7b4ee2fda8898a15975e5d1c75a4ee77bc3df",
"shard": "000.db"
},
{
"question": "who was Charles the first of england?",
"target_title": "Charles I of England",
"target_root": "ad6bc7ccd05e08e3aa9fd3db04dc85e52fc64e54c22532fe1af00ec956bef907",
"shard": "000.db"
},
{
"question": "who was Frederick the fifth?",
"target_title": "Frederick V",
"target_root": "ac3d08404d5e30a1642647512b5e5170253fb46577a244390c909dd8679922c0",
"shard": "000.db"
},
{
"question": "who was Henry the seventh?",
"target_title": "Henry VII",
"target_root": "91e7879a395c0a43c218964ccc28bcff93ec3e3e141483af3b50e2d596b185df",
"shard": "000.db"
},
{
"question": "who was Mehmed the first?",
"target_title": "Mehmed I",
"target_root": "9a526b048fe00f930da0b4128eb30dde55ebfee10490cea2322768c8d1f18023",
"shard": "000.db"
},
{
"question": "who was Mustafa the first?",
"target_title": "Mustafa I",
"target_root": "6d4d3e3d401f3b1651fc3d779d71b8084a5ff69ba2f95516dcefec63f8e6fd20",
"shard": "000.db"
},
{
"question": "who was Mieszko the first of poland?",
"target_title": "Mieszko I of Poland",
"target_root": "fee8e212dc5d6439d1b791eea1bc997d825a4b4abf3bfeeef2cfae57e948d077",
"shard": "000.db"
},
{
"question": "who was Malcolm the first of scotland?",
"target_title": "Malcolm I of Scotland",
"target_root": "1f1983a45ed9b9cfa49425103fed17a1ca0e36284189525a31a9bc73a14d2181",
"shard": "000.db"
},
{
"question": "who was Osman the second?",
"target_title": "Osman II",
"target_root": "71da21f6a76233de3ac45683f8d8b419d9962581cba0f2fb0cf81093b7e41b0a",
"shard": "000.db"
},
{
"question": "who was Quake the second?",
"target_title": "Quake II",
"target_root": "86b1189f25d0fe612423ef88be44885da48f115c27b64024078c2fd257acb66a",
"shard": "000.db"
},
{
"question": "who was Stephen the third?",
"target_title": "Stephen III",
"target_root": "07d31c9abc8c1e8626997b248c82592b09dff67e96069c31c90b95230407e4c2",
"shard": "000.db"
},
{
"question": "who was Oscar the first of sweden?",
"target_title": "Oscar I of Sweden",
"target_root": "09ab96881d295f59dd8131a2d9d15bc2d633bd9e9606c1c27d7e1c7c889cfec0",
"shard": "000.db"
},
{
"question": "who was Charles the fifteenth of sweden?",
"target_title": "Charles XV of Sweden",
"target_root": "381a3cc80ba62a7454ea87bfd56ddefb27229491e3926a88d84968fd791485bb",
"shard": "000.db"
},
{
"question": "who was Sviatoslav the first of kiev?",
"target_title": "Sviatoslav I of Kiev",
"target_root": "99066a1e171e3a3924c115f6010a306743520730d9a0e0ed4f359c0e7239a7dd",
"shard": "000.db"
},
{
"question": "who was Catherine the second of russia?",
"target_title": "Catherine II of Russia",
"target_root": "d7a6a6d1f63b0ddd4977b2c9203fdd6659b0fdcadbb43096910c028ea492a42b",
"shard": "000.db"
},
{
"question": "who was Childeric the first?",
"target_title": "Childeric I",
"target_root": "a21db476b6e4ece9aa1cc7da6b68b199474f82c297eaa3304fd3713d0b7ec682",
"shard": "000.db"
},
{
"question": "who was Rudolph the first of germany?",
"target_title": "Rudolph I of Germany",
"target_root": "7f882ac84cf32819f3af2dda446d18ad7cd818e94b201c56a3a89a0d78bf9161",
"shard": "000.db"
},
{
"question": "who was Xerxes the second of persia?",
"target_title": "Xerxes II of Persia",
"target_root": "edafe8586c5666f8fb38a499ac73f0d954fba6c2d3b869610de16ba33bcc8a3f",
"shard": "000.db"
},
{
"question": "who was Richard the second of england?",
"target_title": "Richard II of England",
"target_root": "d08b7f5eb56fb3352a77facfbc204b53b1a6cef4215d15ac7a2a97f3a30dda8d",
"shard": "000.db"
},
{
"question": "who was Gustav the first of sweden?",
"target_title": "Gustav I of Sweden",
"target_root": "4ff24ede9c8fc1bd401972bef9a3a8e4a4614b8ad289027e9e51b00f16631a04",
"shard": "000.db"
},
{
"question": "who was Photios the first of constantinople?",
"target_title": "Photios I of Constantinople",
"target_root": "1309569c1cb8f37ff5f7d2cfc00080efb5f6846e54b1a45c07357e1b0a2445f2",
"shard": "000.db"
},
{
"question": "who was James the fifth of scotland?",
"target_title": "James V of Scotland",
"target_root": "6b0e3af072a252daaad838123f5b2c2db3b0ac91bd7833faf17ff4f0a488fbe0",
"shard": "000.db"
},
{
"question": "who was Basarab the first of wallachia?",
"target_title": "Basarab I of Wallachia",
"target_root": "6f543c2b310d31ef08d43e4454a69eb2294ffc412df4cb26bafd92ea9841ca2e",
"shard": "000.db"
}
]

80
bench/recall_at_k.py Normal file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Deterministic retrieval recall@k on a mined ground-truth fixture.
For each mined question (surface-variant form), run retrieval ONLY
(`query --dry-run` no LLM) and check whether the KNOWN target
article is in the top-k sources. Recall@k is a hard deterministic
number on N ground-truth questions no verifier, no n=3 LLM noise,
no 5pp floor. This is the instrument a retrieval lever (e.g.
numeral-fold) is measured against: lever recall@k minus baseline
recall@k, on the same mined fixture.
usage: recall_at_k.py qa_questions_numeral_map.json [--k 8] [--conc 4]
"""
from __future__ import annotations
import argparse
import concurrent.futures as cf
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ARB = ROOT / ".venv" / "bin" / "arborist"
SHARDS = Path.home() / ".arborist" / "shards"
def _norm(t: str) -> str:
return (t or "").replace("_", " ").strip().casefold()
def probe(item: dict, k: int) -> tuple[bool, int]:
"""Return (target_in_topk, rank_or_-1). Retrieval only."""
try:
out = subprocess.run(
[str(ARB), "--shards-dir", str(SHARDS), "query", "--dry-run",
"--json", "--top-k", str(k), "--answer-mode", "claim_lattice",
item["question"]],
capture_output=True, text=True, timeout=120,
).stdout
d = json.loads(out)
except Exception:
return (False, -1)
tgt = _norm(item["target_title"])
titles = [_norm(s.get("title") or "") for s in (d.get("sources") or [])]
return (tgt in titles, titles.index(tgt) if tgt in titles else -1)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("map_json")
ap.add_argument("--k", type=int, default=8)
ap.add_argument("--conc", type=int, default=4)
a = ap.parse_args()
items = json.loads(Path(a.map_json).read_text())
hits = 0
ranks: list[int] = []
misses: list[str] = []
with cf.ThreadPoolExecutor(max_workers=a.conc) as ex:
for it, (ok, rank) in zip(
items, ex.map(lambda i: probe(i, a.k), items)
):
if ok:
hits += 1
ranks.append(rank)
else:
misses.append(f"{it['question']!r} -> {it['target_title']!r}")
n = len(items)
print(f"recall@{a.k}: {hits}/{n} = {hits/n:.0%} "
f"(deterministic, no LLM — the instrument)")
if ranks:
print(f" of the hits, mean rank: {sum(ranks)/len(ranks):.1f} "
f"(0=top); rank-1 count: {sum(1 for r in ranks if r == 0)}")
print(f" MISSES ({len(misses)}) — target article never surfaced:")
for m in misses[:25]:
print(f" {m}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,80 @@
"""Numeral-fold: ordinal-word <-> multi-char Roman, retrieval-side.
Measured 2026-05-18: mined ground-truth recall@8 22/40 -> 30/40
(+20pp) when an ordinal-word query ("Alexander the second") can
reach a Roman-numeral title ("Alexander II"). Same additive+
symmetric discipline as `_hyphen_fold_variants` (#000007).
Tested through the REAL path (FakeSource -> ingest -> query() ->
real `_Hit`), never a hand-built object the discipline the
reverted disambiguation v1 violated (false-green on the wrong type).
"""
from __future__ import annotations
from collections.abc import Iterator
from arborist.qa.query import _numeral_fold_variants, _title_query_tokens
def test_variant_is_symmetric_additive_and_collision_free():
assert _numeral_fold_variants("who was Alexander the second?") == {"ii"}
assert _numeral_fold_variants("Alexander II") == {"second"}
# non-numeral queries: provably no effect (additive safety)
assert _numeral_fold_variants("what is the capital of france?") == set()
# strict set → English words that look Roman-ish never fold
assert _numeral_fold_variants("the civil war did mix things") == set()
# single-char Romans intentionally absent (universal len>1 filter)
assert _numeral_fold_variants("Charles V") == set()
def test_title_query_tokens_now_overlaps_ordinal_and_roman():
q = _title_query_tokens("who was Alexander the second?")
t = _title_query_tokens("Alexander II")
# was {alexander} (1) before the fold → below title-breadth.
assert {"alexander", "ii", "second"} <= (q & t)
def test_query_retrieves_roman_title_for_ordinal_word(tmp_path):
"""End-to-end through query(): an ordinal-word question must now
surface its Roman-numeral-titled article."""
from arborist.qa.client import StubClient
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.qa.query import DEFAULT_QUERY_POLICY, query
from arborist.source import Source
from arborist.store import connect
class FakeSource(Source):
source_type = "test"
def __init__(self, ds):
self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
shard = tmp_path / "shard.db"
c = connect(shard)
try:
ingest_source(c, FakeSource([
Document(uri="t://1",
content="Alexander II of Russia was Emperor of Russia. "
"Alexander II enacted major reforms. " * 12,
source_type="test", title="Alexander II"),
Document(uri="t://2",
content="Unrelated article about gardening tools. " * 12,
source_type="test", title="Gardening"),
]))
finally:
c.close()
r = query(
question="who was Alexander the second?",
qa_db=tmp_path / "qa.db",
chat_client=StubClient(answer="Alexander II was Emperor of Russia. [E1]\n"),
model_id="stub",
single_db=shard,
policy=dict(DEFAULT_QUERY_POLICY, answer_mode="claim_lattice_pointer"),
)
titles = {(s.get("title") or "") for s in (r.get("sources") or [])}
assert r["status"] != "error"
assert "Alexander II" in titles # the Roman-titled article surfaced