bench2agent 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,562 @@
1
+ """Portable full-text benchmark statistics and evidence; standard library only.
2
+
3
+ Build a snapshot AFTER the mention pass and reviewed identities have settled.
4
+ Every parsed paper is retained, including papers with no stated role. Runtime
5
+ queries never substitute the abstract inventory for missing full-text data.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import collections
10
+ import datetime as dt
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import unicodedata
17
+ from pathlib import Path
18
+
19
+ from .paths import ROOT
20
+ SNAPSHOT = "benchmark_snapshot.json"
21
+ ROLES = ("evaluates_on", "trains_on")
22
+ # Where a registered benchmark lives is OUR mapping (config/benchmarks.json):
23
+ # a Hub dataset id or a GitHub repo, shown only when it answered the API at
24
+ # the registry's last check, or an author-registered homepage, never checked.
25
+ HOSTS = (("hf", "huggingface", "https://huggingface.co/datasets/"),
26
+ ("gh", "github", "https://github.com/"))
27
+ LINK_NOTE = ("locations: our mapping from this benchmark id to where the artifact lives. "
28
+ "check=api: the Hub/GitHub id answered the API when the registry was last checked "
29
+ "(checked_at null means the date is not recorded in this snapshot); check=none: an "
30
+ "author-registered homepage. An empty list is no confirmed location, not none. "
31
+ "introducing_paper: the reviewed introducing paper in this corpus, null when there is "
32
+ "none; it is a paper, not a download location. Neither ranks or filters anything.")
33
+
34
+
35
+ def locations(entry: dict, checked_at: str | None = None) -> list[dict]:
36
+ ok = entry.get("ok") or {}
37
+ out = [{"url": prefix + entry[field], "host": host, "check": "api", "checked_at": checked_at}
38
+ for field, host, prefix in HOSTS if entry.get(field) and ok.get(field)]
39
+ if entry.get("url"):
40
+ out.append({"url": entry["url"], "host": "web", "check": "none", "checked_at": None})
41
+ return out
42
+
43
+
44
+ def introducing_paper(entry: dict) -> dict | None:
45
+ claims = entry.get("claims") or []
46
+ if not claims:
47
+ return None
48
+ c = next((c for c in claims if c["edition"] == entry.get("first_claim")), claims[0])
49
+ return {"paper_id": c["arxiv_base"], "url": "https://arxiv.org/abs/" + c["arxiv_base"],
50
+ "edition": c["edition"], "title": c["title"]}
51
+
52
+
53
+ def fold(s: str) -> str:
54
+ s = s.lower().translate(str.maketrans({"τ": "tau", "∞": "inf", "²": "2", "³": "3"}))
55
+ s = re.sub(r"^(the|a)\s+", "", s)
56
+ s = re.sub(r"\s+(benchmark|dataset|corpus|suite)s?$", "", s)
57
+ return re.sub(r"[^a-z0-9+]", "", s)
58
+
59
+
60
+ def person(s: str) -> str:
61
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower()
62
+ return re.sub(r"[^a-z ]", "", s).strip()
63
+
64
+
65
+ def read_json(path: Path, default=None):
66
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
67
+
68
+
69
+ def rows(path: Path):
70
+ with path.open(encoding="utf-8") as fh:
71
+ for line in fh:
72
+ if line.strip():
73
+ yield json.loads(line)
74
+
75
+
76
+ def catalogue(root: Path) -> list[dict]:
77
+ reg = read_json(root / "config/benchmarks.json", {"benchmarks": []})
78
+ out = {e["id"]: dict(e) for e in reg["benchmarks"]}
79
+ intro = read_json(root / "config/benchmarks_introduced.json", {})
80
+ # Only the reviewed schema is eligible: old title-gated outputs are not a
81
+ # substitute for claim-by-claim review.
82
+ for e in intro.get("benchmarks", []):
83
+ if "developers" in e and e.get("claims"):
84
+ out[e["id"]] = {**out.get(e["id"], {}), **e}
85
+ # Two extraction claims in one introducing paper can name the same
86
+ # product. They are duplicates, not homonyms (e.g. LIBERO's expanded name).
87
+ # Preserve all claim sentences and remap IDs only in the serving snapshot.
88
+ parent = {bid: bid for bid in out}
89
+
90
+ def find(bid):
91
+ while parent[bid] != bid:
92
+ parent[bid] = parent[parent[bid]]
93
+ bid = parent[bid]
94
+ return bid
95
+
96
+ known = {}
97
+ for e in out.values():
98
+ for c in e.get("claims", []):
99
+ key = (fold(e["name"]), e.get("kind"), c["arxiv_base"])
100
+ if key in known:
101
+ parent[find(e["id"])] = find(known[key])
102
+ known[key] = e["id"]
103
+ groups = collections.defaultdict(list)
104
+ for e in out.values():
105
+ groups[find(e["id"])].append(e)
106
+ merged, remap = [], {}
107
+ for group in groups.values():
108
+ group.sort(key=lambda e: (not bool(e.get("registered")), len(e["id"]), e["id"]))
109
+ entry = dict(group[0])
110
+ ids = {e["id"] for e in group}
111
+ for bid in ids:
112
+ remap[bid] = entry["id"]
113
+ if len(group) > 1:
114
+ entry["merged_ids"] = sorted(ids - {entry["id"]})
115
+ claims = {c.get("claim_id", c["arxiv_base"] + c["evidence"]): c
116
+ for e in group for c in e.get("claims", [])}
117
+ entry["claims"] = sorted(claims.values(), key=lambda c: (c["edition"].rsplit("-", 1)[1], c["edition"], c["arxiv_base"]))
118
+ entry["first_claim"] = entry["claims"][0]["edition"]
119
+ entry["developers"] = sorted({a for e in group for a in e.get("developers", [])})
120
+ entry["aliases"] = sorted({a for e in group for a in e.get("aliases", [])})
121
+ entry["homonym_of"] = sorted({bid for e in group for bid in e.get("homonym_of") or []} - ids) or None
122
+ merged.append(entry)
123
+ for e in merged:
124
+ if e.get("homonym_of"):
125
+ e["homonym_of"] = sorted({remap.get(bid, bid) for bid in e["homonym_of"]} - {e["id"]}) or None
126
+ out = {e["id"]: e for e in merged}
127
+ return list(out.values())
128
+
129
+
130
+ def _files(root: Path, edition: str) -> tuple[Path, Path]:
131
+ venue, year = edition.rsplit("-", 1)
132
+ stem = "papers" if edition == "icml-2026" else (
133
+ f"papers_{year}" if venue == "icml" else f"papers_{venue}_{year}")
134
+ resolved = "resolved" if edition == "icml-2026" else f"resolved_{edition}"
135
+ return root / "data/processed" / (stem + ".jsonl"), root / "data/raw/arxiv" / (resolved + ".jsonl")
136
+
137
+
138
+ def _cited(title: str, refs: list[str]) -> bool:
139
+ # Matching titles, not names, distinguishes homonyms. No global reference
140
+ # list is used: refs must come from the sentence asserting THIS role.
141
+ key = fold(title)
142
+ return len(key) >= 12 and any(key in fold(r) for r in refs)
143
+
144
+
145
+ def build_snapshot(root: Path = ROOT, out: Path | None = None) -> dict:
146
+ files = sorted((root / "data/interim/mentions").glob("mentions_*.jsonl"))
147
+ if not files:
148
+ raise FileNotFoundError("No full-text mention outputs. Finish icml.bench_mentions --all first.")
149
+ dependencies = files + [root / "config/benchmarks.json", root / "config/benchmarks_introduced.json"]
150
+ stamps = {}
151
+
152
+ def remember(paths):
153
+ for p in paths:
154
+ if p.exists():
155
+ stamps[str(p.relative_to(root))] = (p.stat().st_mtime_ns, p.stat().st_size)
156
+
157
+ remember(dependencies)
158
+ metadata = {}
159
+ topics = {}
160
+ editions = {}
161
+ for file in files:
162
+ ed = file.stem.removeprefix("mentions_")
163
+ if not re.fullmatch(r"[a-z]+-\d{4}", ed):
164
+ raise ValueError(f"Invalid edition: {ed}")
165
+ papers_file, resolved = _files(root, ed)
166
+ if not papers_file.exists() or not resolved.exists():
167
+ raise FileNotFoundError(f"Paper metadata and arXiv mapping required for {ed}")
168
+ dependencies += [papers_file, resolved]
169
+ remember([papers_file, resolved])
170
+ papers = {p["event_id"]: p for p in rows(papers_file)}
171
+ tp = root / f"data/processed/topics_{ed}.json"
172
+ if tp.exists():
173
+ dependencies.append(tp)
174
+ remember([tp])
175
+ for t in read_json(tp, {}).get("topics", []):
176
+ for eid in set(t.get("explicit", []) + t.get("via_child", [])):
177
+ topics.setdefault((ed, eid), []).append(t["label"])
178
+ matched = set()
179
+ for r in rows(resolved):
180
+ base = r.get("arxiv_base")
181
+ p = papers.get(r.get("event_id"))
182
+ if base and p:
183
+ matched.add(base)
184
+ metadata[(ed, base)] = {"title": p["title"], "authors": p.get("authors") or [],
185
+ "fields": sorted(set(topics.get((ed, p["event_id"]), []) +
186
+ [s for s in (p.get("area"), p.get("subarea")) if s]))}
187
+ editions[ed] = {"total_papers": len(papers), "arxiv_matched": len(matched)}
188
+ # Read source stamps before the expensive scan; reject a changing source
189
+ # rather than publishing a mixture of two extraction revisions.
190
+ entries = catalogue(root)
191
+ by_key = collections.defaultdict(dict)
192
+ by_fold = collections.defaultdict(dict)
193
+ for e in entries:
194
+ for name in [e["name"], *e.get("aliases", [])]:
195
+ by_fold[fold(name)][e["id"]] = e
196
+ key = e.get("registered") or e.get("homonym_of_registered") or (
197
+ "hom:" + e.get("fold", fold(e["name"])) if e.get("homonym_of") else e["id"])
198
+ by_key[key][e["id"]] = e
199
+ for old_id in e.get("merged_ids", []):
200
+ by_key[old_id][e["id"]] = e
201
+ # Registered products and their homonyms must both be candidates even if
202
+ # only one has an introducing paper inside the observation window.
203
+ for key, candidates in by_key.items():
204
+ for e in list(candidates.values()):
205
+ for other in by_fold[fold(e["name"])].values():
206
+ candidates[other["id"]] = other
207
+ result = []
208
+ for file in files:
209
+ ed = file.stem.removeprefix("mentions_")
210
+ seen = set()
211
+ for r in rows(file):
212
+ base = r["arxiv_base"]
213
+ if base in seen:
214
+ raise ValueError(f"Duplicate paper {ed}/{base}")
215
+ seen.add(base)
216
+ meta = metadata.get((ed, base))
217
+ if meta is None:
218
+ raise ValueError(f"Missing paper metadata: {ed}/{base}")
219
+ uses, unresolved = {}, []
220
+ for key, m in r.get("mentions", {}).items():
221
+ candidates = list(by_key.get(key, {}).values())
222
+ if not candidates:
223
+ # Unreviewed provisional vocabulary is evidence of a
224
+ # name, not an established entity to rank.
225
+ if m.get("roles"):
226
+ unresolved.append({"key": key, "name": m.get("as_written"), "reason": "unreviewed"})
227
+ continue
228
+ for role, evidence in m.get("roles", {}).items():
229
+ if role not in ROLES:
230
+ continue
231
+ selected = candidates
232
+ if len(candidates) > 1:
233
+ refs = m.get("role_cites", [])
234
+ if isinstance(refs, dict):
235
+ refs = refs.get(role, [])
236
+ selected = [e for e in candidates if any(_cited(c["title"], refs)
237
+ for c in e.get("claims", []))]
238
+ if len(selected) != 1:
239
+ unresolved.append({"key": key, "role": role, "reason": "homonym",
240
+ "candidates": [e["id"] for e in candidates]})
241
+ continue
242
+ e = selected[0]
243
+ use = uses.setdefault(e["id"], {"surfaces": {}, "roles": {}})
244
+ use["surfaces"].update(m.get("surfaces") or {m.get("as_written", e["name"]): 1})
245
+ use["roles"].setdefault(role, []).extend(evidence)
246
+ result.append({"paper_id": base, "edition": ed, **meta, "uses": uses,
247
+ "unattributed": unresolved})
248
+ editions[ed]["parsed_papers"] = len(seen)
249
+ editions[ed]["field_labelled_papers"] = sum(bool(p["fields"]) for p in result if p["edition"] == ed)
250
+ for name, stamp in stamps.items():
251
+ p = root / name
252
+ if (p.stat().st_mtime_ns, p.stat().st_size) != stamp:
253
+ raise ValueError(f"Source changed during export: {name}; retry after extraction finishes")
254
+ source_id = hashlib.sha256(json.dumps(stamps, sort_keys=True).encode()).hexdigest()[:16]
255
+ data = {"schema_version": 1, "snapshot_id": source_id,
256
+ "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
257
+ "source": "arxiv_html_stated_roles", "editions": editions,
258
+ # the registry's own API-check date, never the export date
259
+ "registry_checked": read_json(root / "config/benchmarks.json", {}).get("checked"),
260
+ "benchmarks": entries, "papers": result}
261
+ target = out or root / "data/processed" / SNAPSHOT
262
+ target.parent.mkdir(parents=True, exist_ok=True)
263
+ temp = target.with_suffix(".tmp")
264
+ temp.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
265
+ os.replace(temp, target)
266
+ return {"path": str(target), "snapshot_id": source_id, "editions": len(editions),
267
+ "parsed_papers": len(result), "benchmarks": len(entries)}
268
+
269
+
270
+ class BenchmarkStore:
271
+ def __init__(self, root: Path = ROOT):
272
+ self.root = root
273
+ self._stamp = None
274
+ self._data = None
275
+
276
+ def data(self) -> dict:
277
+ path = self.root / "data/processed" / SNAPSHOT
278
+ if not path.exists():
279
+ return {"source": "arxiv_html_stated_roles", "available": False,
280
+ "benchmarks": catalogue(self.root), "papers": [], "editions": {}}
281
+ stamp = (path.stat().st_mtime_ns, path.stat().st_size)
282
+ if stamp != self._stamp:
283
+ data = read_json(path)
284
+ if data.get("schema_version") != 1 or data.get("source") != "arxiv_html_stated_roles":
285
+ raise ValueError("Unsupported benchmark snapshot; rebuild with bench2agent benchmarks")
286
+ self._data, self._stamp = {**data, "available": True}, stamp
287
+ return self._data
288
+
289
+ def _base(self, data: dict) -> dict:
290
+ return {k: data.get(k) for k in ("available", "source", "snapshot_id", "generated_at")}
291
+
292
+ def _links(self, data: dict, entry: dict) -> dict:
293
+ """Keyed by the entry (benchmark id), never by name: a homonym gets its own."""
294
+ return {"locations": locations(entry, data.get("registry_checked")),
295
+ "introducing_paper": introducing_paper(entry)}
296
+
297
+ def _entry(self, data: dict, name: str) -> dict:
298
+ exact = [e for e in data["benchmarks"] if e["id"] == name or name in e.get("merged_ids", [])]
299
+ found = exact or [e for e in data["benchmarks"]
300
+ if fold(name) in {fold(s) for s in [e["name"], *e.get("aliases", [])]}]
301
+ if len(found) != 1:
302
+ raise ValueError("Pass a benchmark ID from benchmark_usage or new_benchmarks. "
303
+ + json.dumps([{"id": e["id"], "name": e["name"]} for e in found]))
304
+ return found[0]
305
+
306
+ def scope(self, a: dict) -> dict:
307
+ data = self.data()
308
+ fields = collections.Counter(f for p in data["papers"] for f in {s.casefold() for s in p.get("fields", [])})
309
+ query = (a.get("query") or "").casefold()
310
+ return {**self._base(data), "editions": data["editions"],
311
+ "fields": [{"name": f, "parsed_papers": n} for f, n in fields.most_common()
312
+ if query in f.casefold()][:100],
313
+ "catalogue_entries": len(data["benchmarks"]),
314
+ "note": "Fields are exact declared topic/area/subarea labels; their coverage is incomplete. "
315
+ "Usage needs benchmark_snapshot.json; missing data is not zero usage."}
316
+
317
+ def _select(self, data: dict, a: dict, *, latest: bool = False) -> tuple[list, dict]:
318
+ eds = list(data["editions"])
319
+ venue = (a.get("venue") or "").lower()
320
+ if venue:
321
+ eds = [ed for ed in eds if ed.rsplit("-", 1)[0] == venue]
322
+ if a.get("edition"):
323
+ eds = [ed for ed in eds if ed == a["edition"].lower()]
324
+ if a.get("years"):
325
+ eds = [ed for ed in eds if int(ed.rsplit("-", 1)[1]) in a["years"]]
326
+ if latest and not a.get("edition") and not a.get("years"):
327
+ newest = {}
328
+ for ed in eds:
329
+ v = ed.rsplit("-", 1)[0]
330
+ newest[v] = max(newest.get(v, ed), ed)
331
+ eds = list(newest.values())
332
+ if data["available"] and not eds:
333
+ raise ValueError("No analysed editions match the requested scope")
334
+ papers = [p for p in data["papers"] if p["edition"] in eds]
335
+ topic = a.get("topic")
336
+ if topic:
337
+ known = {f.casefold() for p in data["papers"] for f in p.get("fields", [])}
338
+ if topic.casefold() not in known:
339
+ raise ValueError("Unknown field label; call benchmark_scope(query=...) or supply paper_ids")
340
+ papers = [p for p in papers if topic.casefold() in {f.casefold() for f in p.get("fields", [])}]
341
+ if a.get("paper_ids") is not None:
342
+ selected = set(a["paper_ids"])
343
+ papers = [p for p in papers if p["paper_id"] in selected]
344
+ denominators = collections.Counter(p["edition"] for p in papers)
345
+ cov = {ed: {**data["editions"][ed], "in_scope_parsed": denominators[ed]} for ed in sorted(eds)}
346
+ return papers, {"topic": topic, "editions": cov,
347
+ "parsed_papers": len(papers),
348
+ "field_labelled_papers": sum(bool(p.get("fields")) for p in papers),
349
+ "unattributed_papers": sum(bool(p.get("unattributed")) for p in papers),
350
+ "note": "Denominators include every successfully parsed paper in this scope, "
351
+ "including those with no stated benchmark. Latest editions differ by venue. "
352
+ "Pooled counts are paper-edition observations. Field filters cover labelled papers only; "
353
+ "zero in-scope papers is not proof a venue has no papers in that field."}
354
+
355
+ def _require(self, data: dict) -> dict | None:
356
+ if not data["available"]:
357
+ return {**self._base(data), "error": "Full-text benchmark usage snapshot is not installed. "
358
+ "After extraction and identity review, run bench2agent benchmarks on the build machine "
359
+ "and distribute the data bundle. Abstract mentions are not a fallback."}
360
+ return None
361
+
362
+ @staticmethod
363
+ def _role(a: dict) -> str:
364
+ role = a.get("role", "evaluates_on")
365
+ if role not in ROLES:
366
+ raise ValueError("role must be evaluates_on or trains_on")
367
+ return role
368
+
369
+ @staticmethod
370
+ def _paper(p: dict, bid: str, role: str) -> dict:
371
+ return {"paper_id": p["paper_id"], "title": p["title"], "edition": p["edition"],
372
+ "url": "https://arxiv.org/abs/" + p["paper_id"],
373
+ "surfaces": p["uses"][bid].get("surfaces", {}),
374
+ "evidence": p["uses"][bid]["roles"][role]}
375
+
376
+ def usage(self, a: dict) -> dict:
377
+ data = self.data()
378
+ if error := self._require(data):
379
+ return error
380
+ papers, coverage = self._select(data, a, latest=a.get("latest", True))
381
+ role = self._role(a)
382
+ counts = collections.defaultdict(list)
383
+ for p in papers:
384
+ for bid, use in p["uses"].items():
385
+ if use["roles"].get(role):
386
+ counts[bid].append(p)
387
+ cat = {e["id"]: e for e in data["benchmarks"]}
388
+ limit = max(1, min(int(a.get("limit", 10)), 100))
389
+ results = [{"id": bid, "name": cat[bid]["name"], "kind": cat[bid].get("kind"),
390
+ "papers": len(ps), "per_1000": round(len(ps) / len(papers) * 1000, 3),
391
+ **self._links(data, cat[bid]),
392
+ "evidence": [self._paper(p, bid, role) for p in ps[:2]]}
393
+ for bid, ps in sorted(counts.items(), key=lambda x: (-len(x[1]), x[0]))[:limit]]
394
+ return {**self._base(data), "role": role, "coverage": coverage, "results": results,
395
+ "total_benchmarks": len(counts), "truncated": len(counts) > limit,
396
+ "note": "Counts measure explicitly stated use, not quality. Missing stated roles do not prove non-use. "
397
+ + LINK_NOTE}
398
+
399
+ def evidence(self, a: dict) -> dict:
400
+ data = self.data()
401
+ if error := self._require(data):
402
+ return error
403
+ entry = self._entry(data, a["benchmark"])
404
+ papers, coverage = self._select(data, a)
405
+ role = self._role(a)
406
+ matches = [self._paper(p, entry["id"], role) for p in papers
407
+ if p["uses"].get(entry["id"], {}).get("roles", {}).get(role)]
408
+ offset, limit = max(0, int(a.get("offset", 0))), max(1, min(int(a.get("limit", 10)), 100))
409
+ return {**self._base(data), "id": entry["id"], "name": entry["name"], "role": role,
410
+ **self._links(data, entry),
411
+ "coverage": coverage, "total": len(matches), "results": matches[offset:offset + limit],
412
+ "truncated": offset + limit < len(matches), "note": LINK_NOTE}
413
+
414
+ def trend(self, a: dict) -> dict:
415
+ data = self.data()
416
+ if error := self._require(data):
417
+ return error
418
+ entry = self._entry(data, a["benchmark"])
419
+ papers, coverage = self._select(data, a)
420
+ role = self._role(a)
421
+ counts = collections.Counter(p["edition"] for p in papers
422
+ if p["uses"].get(entry["id"], {}).get("roles", {}).get(role))
423
+ results, comparisons = [], []
424
+ for ed, cov in coverage["editions"].items():
425
+ n, k = cov["in_scope_parsed"], counts[ed]
426
+ results.append({"edition": ed, "papers": k, "parsed_papers": n,
427
+ "per_1000": round(k / n * 1000, 3) if n else None})
428
+ venues = collections.defaultdict(list)
429
+ for r in results:
430
+ venues[r["edition"].rsplit("-", 1)[0]].append(r)
431
+ for series in venues.values():
432
+ for old, new in zip(series, series[1:]):
433
+ n1, n2, k1, k2 = old["parsed_papers"], new["parsed_papers"], old["papers"], new["papers"]
434
+ z, state = None, "insufficient_scope"
435
+ if n1 and n2:
436
+ pooled = (k1 + k2) / (n1 + n2)
437
+ se = math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
438
+ z = (k2 / n2 - k1 / n1) / se if se else 0.0
439
+ state = "no_detected_change"
440
+ if k1 == 0 and k2 >= 8:
441
+ state = "appearing"
442
+ elif abs(z) >= 2.576:
443
+ state = "rising" if z > 0 else "falling"
444
+ comparisons.append({"from": old["edition"], "to": new["edition"],
445
+ "z": round(z, 3) if z is not None else None, "state": state})
446
+ return {**self._base(data), "id": entry["id"], "name": entry["name"], "role": role,
447
+ **self._links(data, entry),
448
+ "coverage": coverage, "results": results, "comparisons": comparisons,
449
+ "rule": "Within venue: two-proportion z, abs(z) >= 2.576; 0 to >=8 papers is appearing."}
450
+
451
+ def adoption(self, a: dict) -> dict:
452
+ data = self.data()
453
+ if error := self._require(data):
454
+ return error
455
+ entry = self._entry(data, a["benchmark"])
456
+ if not entry.get("claims"):
457
+ return {"error": "No reviewed introducing paper in this corpus; developer attribution is unavailable."}
458
+ papers, coverage = self._select(data, a)
459
+ return {**self._base(data), **self._adoption(entry, papers), **self._links(data, entry),
460
+ "coverage": coverage}
461
+
462
+ def _adoption(self, entry: dict, papers: list) -> dict:
463
+ claimers = {c["arxiv_base"] for c in entry["claims"]}
464
+ devs = {person(s) for s in entry.get("developers", []) if person(s)}
465
+ first_year = int(entry["first_claim"].rsplit("-", 1)[1])
466
+ totals = {role: {who: 0 for who in ("others", "self", "undetermined")} for role in ROLES}
467
+ before, per_edition = [], {}
468
+ grouped = collections.defaultdict(list)
469
+ for p in sorted(papers, key=lambda p: (p["edition"].rsplit("-", 1)[1], p["edition"])):
470
+ if p["uses"].get(entry["id"]) and p["paper_id"] not in claimers:
471
+ grouped[p["paper_id"]].append(p)
472
+ ambiguous = {p["paper_id"] for p in papers if p["paper_id"] not in claimers and any(
473
+ entry["id"] in u.get("candidates", []) for u in p.get("unattributed", []))}
474
+ for observed in grouped.values():
475
+ p = observed[0]
476
+ if int(p["edition"].rsplit("-", 1)[1]) < first_year:
477
+ before.append(p["paper_id"])
478
+ continue
479
+ users = {person(s) for obs in observed for s in obs.get("authors", []) if person(s)}
480
+ who = "undetermined" if not users or not devs else "self" if users & devs else "others"
481
+ ec = per_edition.setdefault(p["edition"], {r: {w: 0 for w in totals[r]} for r in ROLES})
482
+ for role in ROLES:
483
+ if any(obs["uses"][entry["id"]]["roles"].get(role) for obs in observed):
484
+ totals[role][who] += 1
485
+ ec[role][who] += 1
486
+ return {"id": entry["id"], "name": entry["name"],
487
+ "first_claim": entry["first_claim"], **totals,
488
+ "by_edition": per_edition, "before_claim": len(before),
489
+ "unattributed_using_papers": len(ambiguous),
490
+ "note": "Unique using papers, introducing papers excluded. Same-name attribution requires local "
491
+ "citation evidence; unattributed uses are additional unresolved observations, not non-use. "
492
+ "Duplicate paper observations are merged and assigned to their earliest observed edition. "
493
+ "Author-name matching is conservative; same-year ordering is unknown. "
494
+ "first_claim means first reviewed claim in this corpus, not first public release."}
495
+
496
+ def new(self, a: dict) -> dict:
497
+ data = self.data()
498
+ kind = a.get("kind", "benchmark")
499
+ if kind not in ("benchmark", "dataset"):
500
+ raise ValueError("kind must be benchmark or dataset")
501
+ sort = a.get("sort", "introduced")
502
+ if sort not in ("introduced", "adoption"):
503
+ raise ValueError("sort must be introduced or adoption")
504
+ entries = [e for e in data["benchmarks"] if e.get("claims") and e.get("kind") == kind]
505
+ if a.get("edition"):
506
+ entries = [e for e in entries if e["first_claim"] == a["edition"].lower()]
507
+ if a.get("venue"):
508
+ entries = [e for e in entries if e["first_claim"].rsplit("-", 1)[0] == a["venue"].lower()]
509
+ if a.get("years"):
510
+ entries = [e for e in entries if int(e["first_claim"].rsplit("-", 1)[1]) in a["years"]]
511
+ if a.get("topic") or a.get("paper_ids") is not None:
512
+ if error := self._require(data):
513
+ return error
514
+ selected, _ = self._select(data, {k: v for k, v in a.items() if k in ("topic", "paper_ids")})
515
+ ids = {p["paper_id"] for p in selected}
516
+ entries = [e for e in entries if any(c["arxiv_base"] in ids for c in e["claims"])]
517
+ entries.sort(key=lambda e: (-int(e["first_claim"].rsplit("-", 1)[1]), e["id"]))
518
+ stats = {}
519
+ if a.get("include_adoption") or sort == "adoption":
520
+ if error := self._require(data):
521
+ return error
522
+ wanted = {e["id"] for e in entries}
523
+ using = collections.defaultdict(list)
524
+ for p in data["papers"]:
525
+ relevant = set(p["uses"]) & wanted
526
+ relevant |= {bid for u in p.get("unattributed", []) for bid in u.get("candidates", []) if bid in wanted}
527
+ for bid in relevant:
528
+ using[bid].append(p)
529
+ stats = {e["id"]: self._adoption(e, using[e["id"]]) for e in entries}
530
+ role = self._role({"role": a.get("adoption_role", "trains_on" if kind == "dataset" else "evaluates_on")})
531
+ if sort == "adoption":
532
+ entries.sort(key=lambda e: (-stats[e["id"]][role]["others"], e["id"]))
533
+ offset, limit = max(0, int(a.get("offset", 0))), max(1, min(int(a.get("limit", 10)), 100))
534
+ return {**self._base(data), "total": len(entries), "kind": kind,
535
+ "results": [{**{k: e.get(k) for k in ("id", "name", "kind", "first_claim", "claims", "homonym_of")},
536
+ **self._links(data, e),
537
+ **({"adoption": stats[e["id"]]} if e["id"] in stats else {})}
538
+ for e in entries[offset:offset + limit]],
539
+ "truncated": offset + limit < len(entries),
540
+ "sort": sort,
541
+ "adoption_scope": "All installed editions, no field filter; introduction filters do not filter using papers." if stats else None,
542
+ "note": "Reviewed introductions are listed regardless of adoption. first_claim is inside this "
543
+ "corpus, not global priority. Call benchmark_adoption for use counts; unavailable is not zero. "
544
+ + LINK_NOTE}
545
+
546
+ def verify(self, paper_id: str, quote: str) -> dict | None:
547
+ if len(quote.strip()) < 20:
548
+ return None
549
+ q = " ".join(quote.split())
550
+ data = self.data()
551
+ for p in data["papers"]:
552
+ if p["paper_id"] == paper_id:
553
+ for use in p["uses"].values():
554
+ for evidence in use["roles"].values():
555
+ if any(not ev.get("where", "").endswith(":cell") and
556
+ q in " ".join(ev["text"].split()) for ev in evidence):
557
+ return {"title": p["title"], "edition": p["edition"]}
558
+ for e in data["benchmarks"]:
559
+ for c in e.get("claims", []):
560
+ if c["arxiv_base"] == paper_id and q in " ".join(c["evidence"].split()):
561
+ return {"title": c["title"], "edition": c["edition"]}
562
+ return None