Coverage for src / quber / playground / benchmark / run_shapes.py: 30%

173 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Measure four ways of returning an answer over one set of questions. 

2 

3Every arm answers from the SAME retrieved context: retrieval runs once per 

4question and its chunks are handed to all four, so a difference in the results 

5is a difference in the return shape and not in what was retrieved. 

6 

7The arms: 

8 

9 baseline the playground's current agent — prose plus cited ids 

10 fixed one envelope, payload chosen from the closed union in `shapes` 

11 planned a planner declares fields per question; they are compiled and applied 

12 declared the caller states the shape; nothing is inferred 

13 

14Scoring is deterministic. Gold values were copied out of printed table cells, 

15so a returned figure is compared to the gold string directly and, separately, 

16as a number — no judge is asked whether two figures agree. The baseline has no 

17value field to compare, so it is scored on whether the gold figure appears 

18anywhere in its prose: that measures whether the information was found, which 

19is the point the comparison has to be fair about. 

20 

21Usage: 

22 python -m quber.playground.benchmark.run_shapes --gold <path> 

23 python -m quber.playground.benchmark.run_shapes --gold <path> --limit 4 

24""" 

25 

26from __future__ import annotations 

27 

28import argparse 

29import asyncio 

30import json 

31import re 

32import time 

33from decimal import Decimal 

34from pathlib import Path 

35from typing import Any, Dict, List, Optional 

36 

37from loguru import logger 

38from pydantic import BaseModel, Field 

39 

40from quber.playground import db 

41from quber.playground.agent import answer as baseline_answer 

42from quber.playground.answers import declared, fixed, planned 

43from quber.playground.answers.figures import as_number, norm_printed 

44from quber.playground.retrieval import RetrievedChunk, retrieve 

45 

46OUTPUT = Path("output/benchmark/answer_shapes.json") 

47CONCURRENCY = 4 

48K = 10 

49 

50ARMS = ("baseline", "fixed", "planned", "declared") 

51 

52 

53class ProseAnswer(BaseModel): 

54 """The shape a caller declares when it wants an explanation.""" 

55 

56 text: str = Field(description="The explanation, in prose.") 

57 cited_ids: List[str] = Field(default_factory=list) 

58 

59 

60# ---------------------------------------------------------------- normalizing 

61 

62# `norm_printed` and `as_number` live in `answers.figures`, which is the only 

63# place a printed figure is read as a number. Scoring a run and exporting a 

64# batch both compare figures, and each keeping its own reading is how the same 

65# cell comes out with two different signs. 

66 

67 

68def values_agree(got: Optional[str], gold: str) -> Dict[str, bool]: 

69 """Exact-as-printed and numeric agreement between a returned and gold figure.""" 

70 if got is None: 

71 return {"exact": False, "numeric": False} 

72 exact = norm_printed(got) == norm_printed(gold) 

73 a, b = as_number(got), as_number(gold) 

74 return {"exact": exact, "numeric": bool(a is not None and b is not None and a == b)} 

75 

76 

77def looks_like_prose(s: Optional[str]) -> bool: 

78 """True when a value field holds a sentence rather than a figure. 

79 

80 A printed figure is short and at most a few tokens ('$1.2 million', 

81 '4.6x'). Four or more tokens, or a long string, means the value came back 

82 wrapped in words — the failure this whole exercise is about. 

83 """ 

84 if not s: 

85 return False 

86 t = norm_printed(s) 

87 return len(t) > 40 or len(t.split()) >= 4 

88 

89 

90def gold_in_prose(prose: str, gold: str) -> bool: 

91 """Whether the gold figure appears in a prose answer, as printed or as a number. 

92 

93 Whitespace is ignored on the literal comparison: a cell prints "$ (10,550)" 

94 where prose writes "$(10,550)", and that is the same figure. 

95 

96 Scale words are deliberately not part of a token. Both sides drop the scale 

97 when they parse — "$241.4M" and "$241.4 million" each read as 241.4 — so 

98 absorbing a trailing "thousand" into the token achieves nothing and breaks 

99 any figure whose closing parenthesis then sits mid-token. 

100 """ 

101 if not prose: 

102 return False 

103 if re.sub(r"\s+", "", gold) in re.sub(r"\s+", "", prose): 

104 return True 

105 g = as_number(gold) 

106 if g is None: 

107 return False 

108 # A decimal part must be a real decimal: `\.?\d*` would also match the full 

109 # stop ending a sentence, leaving a token that cannot parse. The optional 

110 # parenthesis is allowed on either side of the currency sign because prose 

111 # writes "$(10,550)" and cells print "(10,550)". 

112 for tok in re.findall(r"\(?-?\$?\(?[\d][\d,]*(?:\.\d+)?\)?%?[xX]?", prose): 

113 if as_number(tok) == g: 

114 return True 

115 return False 

116 

117 

118# ------------------------------------------------------------------ grounding 

119 

120 

121def cell_text(doc_key: str, cell_id: str) -> Optional[str]: 

122 """The text printed in one tagged table cell of one document, if it exists.""" 

123 with db.connect() as conn: 

124 row = conn.execute( 

125 """SELECT c.content FROM ade_playground.chunks c 

126 JOIN ade_playground.documents d ON d.id = c.document_id 

127 WHERE d.doc_key = %s AND c.content LIKE %s 

128 LIMIT 1""", 

129 (doc_key, f'%id="{cell_id}"%'), 

130 ).fetchone() 

131 if not row: 

132 return None 

133 m = re.search(rf'<td id="{re.escape(cell_id)}"[^>]*>(.*?)</td>', row[0], re.S) 

134 return norm_printed(re.sub(r"<[^>]+>", "", m.group(1))) if m else None 

135 

136 

137def retrieval_hit(chunks: List[RetrievedChunk], cell_id: str) -> bool: 

138 return any(f'id="{cell_id}"' in c.content for c in chunks) 

139 

140 

141# ----------------------------------------------------------------------- arms 

142 

143 

144def scalar_field(obj: Any) -> Optional[str]: 

145 """The single figure a compiled or declared model returned, if it has one. 

146 

147 A planner-compiled model names its field whatever suited the question, so 

148 the figure is found by taking the model's first string field that is not 

149 bookkeeping. 

150 """ 

151 if obj is None: 

152 return None 

153 data = obj.model_dump() if isinstance(obj, BaseModel) else dict(obj) 

154 for key, val in data.items(): 

155 if key in ("cited_ids", "not_found"): 

156 continue 

157 if isinstance(val, str) and val.strip(): 

158 return val 

159 if isinstance(val, (int, float, Decimal)): 

160 return str(val) 

161 return None 

162 

163 

164async def run_arm(arm: str, q: Dict[str, Any], chunks: List[RetrievedChunk]) -> Dict[str, Any]: 

165 """One arm's attempt at one question, with what it returned and how long it took.""" 

166 started = time.perf_counter() 

167 out: Dict[str, Any] = {"arm": arm} 

168 is_value = q["kind"] == "value" 

169 try: 

170 if arm == "baseline": 

171 res = await baseline_answer(q["question"], chunks) 

172 out.update( 

173 shape="prose", 

174 value=None, 

175 prose=res.answer, 

176 cited_ids=res.cited_ids, 

177 ) 

178 

179 elif arm == "fixed": 

180 res = await fixed.answer(q["question"], chunks) 

181 payload = res.payload 

182 out.update(shape=payload.kind, cited_ids=res.cited_ids) 

183 if payload.kind == "scalar": 

184 # The whole scalar, not just the printed form. Anything reading 

185 # this file downstream needs `number` to have a numeric column 

186 # without parsing `value` back into one, and a second parser of 

187 # printed figures is how a negative silently becomes positive. 

188 out.update( 

189 value=payload.value, 

190 number=payload.number, 

191 unit=payload.unit, 

192 period=payload.period, 

193 label=payload.label, 

194 source_id=payload.source_id, 

195 ) 

196 elif payload.kind == "prose": 

197 out.update(value=None, prose=payload.text) 

198 else: 

199 out.update(value=None, payload=payload.model_dump(mode="json")) 

200 

201 elif arm == "planned": 

202 plan = await planned.plan_for(q["question"]) 

203 out["plan"] = plan.model_dump() 

204 if plan.wants_value and plan.fields: 

205 model = planned.compile_model(plan) 

206 res = await declared.answer(q["question"], chunks, model) 

207 data = res.model_dump() 

208 out.update( 

209 shape="value", 

210 value=scalar_field(res), 

211 cited_ids=data.get("cited_ids", []), 

212 fields=data, 

213 ) 

214 else: 

215 res = await declared.answer(q["question"], chunks, ProseAnswer) 

216 out.update(shape="prose", value=None, prose=res.text, cited_ids=res.cited_ids) 

217 

218 elif arm == "declared": 

219 # The caller knows which it wants, so the shape is supplied, not inferred. 

220 if is_value: 

221 res = await declared.answer(q["question"], chunks, declared.DeclaredScalar) 

222 out.update( 

223 shape="value", 

224 value=res.value, 

225 unit=res.unit, 

226 period=res.period, 

227 cited_ids=res.cited_ids, 

228 not_found=res.not_found, 

229 ) 

230 else: 

231 res = await declared.answer(q["question"], chunks, ProseAnswer) 

232 out.update(shape="prose", value=None, prose=res.text, cited_ids=res.cited_ids) 

233 

234 except Exception as exc: 

235 out.update(error=f"{type(exc).__name__}: {exc}", shape=None, value=None, cited_ids=[]) 

236 

237 out["seconds"] = round(time.perf_counter() - started, 2) 

238 return score(out, q) 

239 

240 

241def score(out: Dict[str, Any], q: Dict[str, Any]) -> Dict[str, Any]: 

242 """Add the measurements this comparison turns on.""" 

243 is_value = q["kind"] == "value" 

244 shape = out.get("shape") 

245 out["shape_correct"] = shape in ("scalar", "value") if is_value else shape in ("prose", "unanswerable") 

246 out["cites_any"] = bool(out.get("cited_ids")) 

247 

248 if is_value: 

249 gold = q["gold_value"] 

250 if out.get("value") is not None: 

251 out.update(values_agree(out["value"], gold)) 

252 out["prose_leak"] = looks_like_prose(out["value"]) 

253 out["found_gold"] = out["exact"] or out["numeric"] 

254 else: 

255 # No value field: the arm either answered in prose or failed. 

256 prose = out.get("prose") or "" 

257 out.update(exact=False, numeric=False, prose_leak=bool(prose)) 

258 out["found_gold"] = gold_in_prose(prose, gold) 

259 return out 

260 

261 

262# ------------------------------------------------------------------ the sweep 

263 

264 

265async def run_question(q: Dict[str, Any], sem: asyncio.Semaphore) -> Dict[str, Any]: 

266 async with sem: 

267 chunks = await retrieve(q["slug"], q["question"], k=K) 

268 row: Dict[str, Any] = { 

269 **q, 

270 "retrieved_pages": sorted({c.page + 1 for c in chunks}), 

271 "retrieval_hit": retrieval_hit(chunks, q["gold_cell_id"]) if q["kind"] == "value" else None, 

272 } 

273 results = [] 

274 for arm in ARMS: 

275 async with sem: 

276 results.append(await run_arm(arm, q, chunks)) 

277 row["arms"] = results 

278 return row 

279 

280 

281def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]: 

282 """Per-arm totals over the value questions and the prose controls.""" 

283 value_rows = [r for r in rows if r["kind"] == "value"] 

284 hit_rows = [r for r in value_rows if r["retrieval_hit"]] 

285 prose_rows = [r for r in rows if r["kind"] == "prose"] 

286 out: Dict[str, Any] = { 

287 "value_questions": len(value_rows), 

288 "value_questions_with_retrieval_hit": len(hit_rows), 

289 "prose_controls": len(prose_rows), 

290 "arms": {}, 

291 } 

292 

293 def arm_of(row: Dict[str, Any], arm: str) -> Dict[str, Any]: 

294 return next(a for a in row["arms"] if a["arm"] == arm) 

295 

296 for arm in ARMS: 

297 vs = [arm_of(r, arm) for r in hit_rows] 

298 ps = [arm_of(r, arm) for r in prose_rows] 

299 n = len(vs) or 1 

300 out["arms"][arm] = { 

301 "value_exact_as_printed": sum(a.get("exact", False) for a in vs), 

302 "value_numeric_match": sum(a.get("numeric", False) for a in vs), 

303 "found_gold_anywhere": sum(a.get("found_gold", False) for a in vs), 

304 "returned_a_value_field": sum(a.get("value") is not None for a in vs), 

305 "prose_leak": sum(a.get("prose_leak", False) for a in vs), 

306 "shape_correct_on_values": sum(a["shape_correct"] for a in vs), 

307 "shape_correct_on_prose": sum(a["shape_correct"] for a in ps), 

308 "cites_any": sum(a["cites_any"] for a in vs), 

309 "errors": sum("error" in a for a in vs + ps), 

310 "mean_seconds": round(sum(a["seconds"] for a in vs) / n, 2), 

311 } 

312 return out 

313 

314 

315async def run(gold_path: Path, limit: Optional[int]) -> None: 

316 gold = json.loads(gold_path.read_text()) 

317 questions = gold[:limit] if limit else gold 

318 logger.info("{n} questions x {a} arms", n=len(questions), a=len(ARMS)) 

319 sem = asyncio.Semaphore(CONCURRENCY) 

320 rows = await asyncio.gather(*(run_question(q, sem) for q in questions)) 

321 summary = summarize(list(rows)) 

322 

323 OUTPUT.parent.mkdir(parents=True, exist_ok=True) 

324 OUTPUT.write_text(json.dumps({"summary": summary, "rows": rows}, indent=2, default=str)) 

325 logger.success("written to {p}", p=OUTPUT) 

326 print(json.dumps(summary, indent=2)) 

327 

328 

329def rescore(path: Path) -> None: 

330 """Recompute the measurements over a finished run's stored answers. 

331 

332 Every arm's raw output is kept in the results file, so a correction to the 

333 scoring is applied by re-reading it — no model is called again, and the 

334 answers being scored are byte-identical to the ones originally returned. 

335 """ 

336 data = json.loads(path.read_text()) 

337 for row in data["rows"]: 

338 row["arms"] = [score(arm, row) for arm in row["arms"]] 

339 data["summary"] = summarize(data["rows"]) 

340 path.write_text(json.dumps(data, indent=2, default=str)) 

341 logger.success("rescored {p}", p=path) 

342 print(json.dumps(data["summary"], indent=2)) 

343 

344 

345def main() -> None: 

346 p = argparse.ArgumentParser(description=__doc__) 

347 p.add_argument("--gold", type=Path, help="gold question set JSON") 

348 p.add_argument("--limit", type=int, default=None) 

349 p.add_argument("--rescore", type=Path, help="recompute scores over a finished run") 

350 args = p.parse_args() 

351 if args.rescore: 

352 rescore(args.rescore) 

353 return 

354 if not args.gold: 

355 p.error("--gold is required unless --rescore is given") 

356 asyncio.run(run(args.gold, args.limit)) 

357 

358 

359if __name__ == "__main__": 

360 main()