catchbench 0.1.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.
catchbench/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """CatchBench: the benchmark for agent auditing across the PRE / LIVE / POST lifecycle.
2
+
3
+ The public arena where methods compete at finding and attributing agent failures over real
4
+ agent traces. Organized along the agent lifecycle (PRE deploy gate, LIVE real-time, POST
5
+ forensics); each pillar holds scenarios, each scenario is one ``Task`` with its own labels,
6
+ metric, and baseline set. The shared ``Task`` / ``Method`` contract keeps those inputs fixed
7
+ while each compatible method supplies its own evaluation logic.
8
+ """
9
+ from catchbench.core import Method, ResultRow, RunPipeline, Task
10
+
11
+ __all__ = ["Method", "ResultRow", "RunPipeline", "Task"]
12
+ __version__ = "0.0.1"
catchbench/_reuse.py ADDED
@@ -0,0 +1,109 @@
1
+ """Resolve GRADE's experiment modules and the ``auditable`` dependency.
2
+
3
+ Installed modules take precedence. GRADE's experiment modules are not included in its wheel, so
4
+ an editable sibling checkout or ``GRADE_DIR`` is used as a fallback. ``AUDITABLE_DIR`` remains a
5
+ development fallback for ``auditable`` when the declared package dependency is unavailable.
6
+ Importing this module may add fallback checkout directories to ``sys.path``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import logging
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from catchbench.corpora import install_hub_revision_pins
17
+
18
+
19
+ install_hub_revision_pins()
20
+
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+ _REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
24
+ _GRADE_MODULES = (
25
+ "grade",
26
+ "agent_failure_detection",
27
+ "agent_failure_localization",
28
+ "agent_graph_characterization",
29
+ "agent_graph_swegym",
30
+ "agent_graph_tau_bench",
31
+ )
32
+
33
+
34
+ def _module_location(name: str) -> str | None:
35
+ try:
36
+ spec = importlib.util.find_spec(name)
37
+ except (AttributeError, ImportError, ValueError):
38
+ return None
39
+ if spec is None:
40
+ return None
41
+ return spec.origin or next(iter(spec.submodule_search_locations or ()), None)
42
+
43
+
44
+ def _prepend(paths: tuple[Path, ...]) -> None:
45
+ for path in reversed(paths):
46
+ value = str(path)
47
+ if value not in sys.path:
48
+ sys.path.insert(0, value)
49
+
50
+
51
+ def _resolve_grade() -> str:
52
+ locations = [_module_location(name) for name in _GRADE_MODULES]
53
+ if all(locations):
54
+ source = f"installed modules ({locations[0]})"
55
+ _LOGGER.debug("Using GRADE from %s", source)
56
+ return source
57
+
58
+ configured = os.environ.get("GRADE_DIR")
59
+ checkout = Path(configured).expanduser() if configured else _REPOSITORY_ROOT.parent / "grade"
60
+ paths = (checkout / "experiment", checkout / "src")
61
+ if all(path.is_dir() for path in paths):
62
+ _prepend(paths)
63
+ if all(_module_location(name) for name in _GRADE_MODULES):
64
+ source = f"checkout bridge ({checkout})"
65
+ _LOGGER.debug("Using GRADE from %s", source)
66
+ return source
67
+
68
+ raise ImportError(
69
+ "CatchBench needs GRADE's experiment modules, which are not distributed in GRADE's "
70
+ "wheel. Clone GRADE next to this repository and install its experiment dependencies:\n"
71
+ " git clone https://github.com/yzhao062/grade.git ../grade\n"
72
+ " python -m pip install -e \"../grade[experiments]\"\n"
73
+ "Alternatively, set GRADE_DIR to the GRADE checkout. The checkout must contain "
74
+ "experiment/ and src/."
75
+ )
76
+
77
+
78
+ def _resolve_auditable() -> str:
79
+ location = _module_location("auditable")
80
+ if location:
81
+ source = f"installed package ({location})"
82
+ _LOGGER.debug("Using auditable from %s", source)
83
+ return source
84
+
85
+ configured = os.environ.get("AUDITABLE_DIR")
86
+ if configured:
87
+ path = Path(configured).expanduser() / "src"
88
+ if path.is_dir():
89
+ _prepend((path,))
90
+ location = _module_location("auditable")
91
+ if location:
92
+ source = f"checkout bridge ({path.parent})"
93
+ _LOGGER.debug("Using auditable from %s", source)
94
+ return source
95
+
96
+ raise ImportError(
97
+ "CatchBench requires auditable>=0.2.0. Install it with "
98
+ "`python -m pip install \"auditable>=0.2.0\"`, or set AUDITABLE_DIR to a source "
99
+ "checkout containing src/auditable."
100
+ )
101
+
102
+
103
+ SOURCES = {
104
+ "auditable": _resolve_auditable(),
105
+ "grade": _resolve_grade(),
106
+ }
107
+
108
+ # GRADE's localization eval uses a conda BLAS stack that wants this set (matches the seed).
109
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
@@ -0,0 +1,180 @@
1
+ """Agent-specific published detectors as benchmark baselines: GUARDIAN and G-Safeguard.
2
+
3
+ Both are run on the dependency-graph substrate so they compete on the same data as every other method,
4
+ and both are implemented as their core mechanism with the simplifications noted, in the ADBench / BOND
5
+ tradition of porting a published method onto the benchmark's representation rather than gesturing at it.
6
+
7
+ - GUARDIAN (Zhou et al., 2025, arXiv:2505.19234) safeguards multi-agent collaboration with an
8
+ UNSUPERVISED reconstruction autoencoder over a temporal attributed graph: it reconstructs node
9
+ attributes and structure and scores a node by reconstruction error. Here it is a directed-GCN
10
+ attribute-reconstruction autoencoder over the per-run dependency graph (the temporal direction is
11
+ the step -> dependency edge); a node it reconstructs poorly is anomalous, and a run's failure score
12
+ is its mean node error. The explicit adjacency-reconstruction term and the Information-Bottleneck
13
+ compression are simplified to an attribute-reconstruction objective that stays structure-aware
14
+ through message passing. Unsupervised: it never sees the failure label.
15
+
16
+ - G-Safeguard (Wang et al., 2025, arXiv:2502.11127) detects injected / anomalous agents with a
17
+ SUPERVISED GNN over the multi-agent utterance graph and then remediates topologically. Here it is a
18
+ supervised graph-classification GNN (GCN layers, mean pooling, a linear head) trained with
19
+ seed-averaged stratified cross-validation to predict run failure from the dependency graph. The
20
+ topological remediation step is out of scope because the board scores detection, not intervention.
21
+
22
+ Both train on CPU with fixed seeds for reproducibility, the same posture as the PyGOD baseline.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+
28
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
29
+
30
+ from typing import List, Sequence, Tuple
31
+
32
+ import numpy as np
33
+
34
+ Graph = Tuple[np.ndarray, np.ndarray] # (node features [n, d], edge_index [2, m])
35
+
36
+
37
+ def _batched(graphs: Sequence[Graph]):
38
+ """Standardize node features across all graphs and pack them into one disconnected PyG batch,
39
+ self-looping isolated nodes (sparse dependency graphs have many) so message passing is defined."""
40
+ import torch
41
+ from torch_geometric.data import Batch, Data
42
+ from sklearn.preprocessing import StandardScaler
43
+
44
+ sizes = [len(np.asarray(x)) for x, _ in graphs]
45
+ stacked = StandardScaler().fit_transform(
46
+ np.vstack([np.asarray(x, dtype=float) for x, _ in graphs]))
47
+ datas, offset = [], 0
48
+ for (x, edges), n in zip(graphs, sizes):
49
+ node_x = torch.tensor(stacked[offset:offset + n], dtype=torch.float)
50
+ offset += n
51
+ e = np.asarray(edges, dtype=np.int64).reshape(2, -1)
52
+ touched = set(e.flatten().tolist()) if e.shape[1] else set()
53
+ isolated = [v for v in range(n) if v not in touched]
54
+ if isolated:
55
+ loops = np.array(isolated, dtype=np.int64)
56
+ e = np.concatenate([e, np.stack([loops, loops])], axis=1)
57
+ datas.append(Data(x=node_x, edge_index=torch.tensor(e, dtype=torch.long)))
58
+ return Batch.from_data_list(datas), len(datas)
59
+
60
+
61
+ def guardian_run_scores(graphs: Sequence[Graph], *, seed: int = 0, hid: int = 16,
62
+ epochs: int = 50) -> np.ndarray:
63
+ """GUARDIAN's unsupervised reconstruction-AE core: fit a directed-GCN attribute autoencoder over
64
+ the run graphs and return each run's mean per-node reconstruction error (higher = more anomalous).
65
+ Seed-averaged over a few inits, because a single GNN init is noisy at this graph scale."""
66
+ import torch
67
+ from torch_geometric.nn import GCNConv
68
+
69
+ batch, n_runs = _batched(graphs)
70
+ in_dim = batch.x.shape[1]
71
+
72
+ class _AE(torch.nn.Module):
73
+ def __init__(self) -> None:
74
+ super().__init__()
75
+ self.enc1 = GCNConv(in_dim, hid * 2)
76
+ self.enc2 = GCNConv(hid * 2, hid) # latent bottleneck (the IB nod)
77
+ self.dec1 = GCNConv(hid, hid * 2)
78
+ self.dec2 = GCNConv(hid * 2, in_dim) # reconstruct attributes
79
+
80
+ def forward(self, x, edge_index):
81
+ z = self.enc2(torch.relu(self.enc1(x, edge_index)), edge_index)
82
+ x_hat = self.dec2(torch.relu(self.dec1(z, edge_index)), edge_index)
83
+ return x_hat
84
+
85
+ per_seed = []
86
+ for s in range(3):
87
+ torch.manual_seed(seed + s)
88
+ model = _AE()
89
+ opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
90
+ model.train()
91
+ for _ in range(epochs):
92
+ opt.zero_grad()
93
+ x_hat = model(batch.x, batch.edge_index)
94
+ loss = ((batch.x - x_hat) ** 2).mean()
95
+ loss.backward()
96
+ opt.step()
97
+ model.eval()
98
+ with torch.no_grad():
99
+ x_hat = model(batch.x, batch.edge_index)
100
+ node_err = ((batch.x - x_hat) ** 2).mean(dim=1).numpy()
101
+ per_seed.append(np.array([node_err[batch.batch.numpy() == k].mean() for k in range(n_runs)]))
102
+ return np.mean(per_seed, axis=0)
103
+
104
+
105
+ def _to_data(graph: Graph, scaler):
106
+ """One PyG Data from a raw graph, node features transformed by an already-fit scaler, isolated
107
+ nodes self-looped (matching ``_batched``) so message passing is defined."""
108
+ import torch
109
+ from torch_geometric.data import Data
110
+
111
+ x, edges = graph
112
+ node_x = torch.tensor(scaler.transform(np.asarray(x, dtype=float)), dtype=torch.float)
113
+ n = node_x.shape[0]
114
+ e = np.asarray(edges, dtype=np.int64).reshape(2, -1)
115
+ touched = set(e.flatten().tolist()) if e.shape[1] else set()
116
+ isolated = [v for v in range(n) if v not in touched]
117
+ if isolated:
118
+ loops = np.array(isolated, dtype=np.int64)
119
+ e = np.concatenate([e, np.stack([loops, loops])], axis=1)
120
+ return Data(x=node_x, edge_index=torch.tensor(e, dtype=torch.long))
121
+
122
+
123
+ def gsafeguard_cv_auc(graphs: Sequence[Graph], y: np.ndarray, *, seed: int = 0, hid: int = 32,
124
+ epochs: int = 60, n_splits: int = 5) -> float:
125
+ """G-Safeguard's supervised-GNN detector: a graph-classification GCN trained with seed-averaged
126
+ stratified K-fold cross-validation to predict run failure, returning the mean held-out ROC-AUC.
127
+ Grouped at the run, since each run is one graph and one label. The feature scaler is fit on each
128
+ fold's training graphs only, so the held-out fold never informs preprocessing (no train/test leak)."""
129
+ import torch
130
+ from torch_geometric.data import Batch
131
+ from torch_geometric.nn import GCNConv, global_mean_pool
132
+ from sklearn.metrics import roc_auc_score
133
+ from sklearn.model_selection import StratifiedKFold
134
+ from sklearn.preprocessing import StandardScaler
135
+
136
+ y = np.asarray(y)
137
+ n_runs = len(graphs)
138
+ if n_runs < n_splits or len(set(y.tolist())) < 2:
139
+ return 0.5
140
+ in_dim = np.asarray(graphs[0][0]).shape[1]
141
+
142
+ class _GNN(torch.nn.Module):
143
+ def __init__(self) -> None:
144
+ super().__init__()
145
+ self.c1 = GCNConv(in_dim, hid)
146
+ self.c2 = GCNConv(hid, hid)
147
+ self.lin = torch.nn.Linear(hid, 1)
148
+
149
+ def forward(self, b):
150
+ h = torch.relu(self.c1(b.x, b.edge_index))
151
+ h = torch.relu(self.c2(h, b.edge_index))
152
+ return self.lin(global_mean_pool(h, b.batch)).squeeze(-1)
153
+
154
+ aucs = []
155
+ for s in range(3): # seed-average for a stable held-out estimate
156
+ skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed + s)
157
+ for tr, te in skf.split(np.zeros(n_runs), y):
158
+ if len(set(y[tr].tolist())) < 2 or len(set(y[te].tolist())) < 2:
159
+ continue # skip degenerate single-class folds
160
+ scaler = StandardScaler().fit( # train folds only: no held-out stats leak in
161
+ np.vstack([np.asarray(graphs[int(i)][0], dtype=float) for i in tr]))
162
+ train_batch = Batch.from_data_list([_to_data(graphs[int(i)], scaler) for i in tr])
163
+ test_batch = Batch.from_data_list([_to_data(graphs[int(i)], scaler) for i in te])
164
+ train_y = torch.tensor(y[tr], dtype=torch.float)
165
+
166
+ torch.manual_seed(seed + s)
167
+ model = _GNN()
168
+ opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
169
+ loss_fn = torch.nn.BCEWithLogitsLoss()
170
+ model.train()
171
+ for _ in range(epochs):
172
+ opt.zero_grad()
173
+ loss = loss_fn(model(train_batch), train_y)
174
+ loss.backward()
175
+ opt.step()
176
+ model.eval()
177
+ with torch.no_grad():
178
+ prob = torch.sigmoid(model(test_batch)).numpy()
179
+ aucs.append(roc_auc_score(y[te], prob))
180
+ return float(np.mean(aucs)) if aucs else 0.5
catchbench/core.py ADDED
@@ -0,0 +1,95 @@
1
+ """The five-layer plug-in contract (core interfaces).
2
+
3
+ One scenario is one ``Task``; a ``Method`` evaluates a ``Task``; ``RunPipeline`` runs every
4
+ valid ``(Task, Method)`` pair and emits leaderboard rows. The POST localization seed runs
5
+ through these interfaces, and LIVE and PRE Tasks plug into the same contract.
6
+
7
+ Under this contract, a ``Method`` sees the whole ``Task`` and returns its metric dict.
8
+ Supervised baselines prevent in-sample scoring by running out-of-sample cross-validation inside
9
+ ``evaluate``: grouped by run for step-level localization and stratified over runs for run-level
10
+ detection, where each run is one row.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass
16
+ from typing import Mapping, Protocol, Sequence, runtime_checkable
17
+
18
+
19
+ @runtime_checkable
20
+ class Task(Protocol):
21
+ """One benchmark scenario: its data, its labels, and its metric.
22
+
23
+ ``pillar`` is "PRE", "LIVE", or "POST"; ``granularity`` is "run", "step", "edge", or
24
+ "plan". ``setup`` loads the corpus and is idempotent.
25
+ """
26
+
27
+ task_id: str
28
+ pillar: str
29
+ granularity: str
30
+ dataset: str
31
+
32
+ def setup(self) -> None: ...
33
+
34
+
35
+ @runtime_checkable
36
+ class Method(Protocol):
37
+ """A leaderboard entry. ``supports`` lists the ``task_id``s it can run."""
38
+
39
+ method_id: str
40
+ supports: set
41
+
42
+ def evaluate(self, task: Task) -> Mapping[str, float]: ...
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ResultRow:
47
+ """One method's score on one task."""
48
+
49
+ pillar: str
50
+ task: str
51
+ dataset: str
52
+ method: str
53
+ metrics: Mapping[str, float]
54
+
55
+
56
+ class RunPipeline:
57
+ """Run every valid ``(Task, Method)`` pair and collect leaderboard rows."""
58
+
59
+ def __init__(self, tasks: Sequence[Task], methods: Sequence[Method]) -> None:
60
+ self.tasks = list(tasks)
61
+ self.methods = list(methods)
62
+
63
+ def run(self) -> list[ResultRow]:
64
+ rows: list[ResultRow] = []
65
+ for task in self.tasks:
66
+ for method in self.methods:
67
+ if task.task_id not in method.supports:
68
+ continue # only valid (Task, Method) pairs run
69
+ rows.append(
70
+ ResultRow(
71
+ task.pillar, task.task_id, getattr(task, "dataset", ""),
72
+ method.method_id, method.evaluate(task),
73
+ )
74
+ )
75
+ return rows
76
+
77
+ @staticmethod
78
+ def leaderboard(rows: Sequence[ResultRow]) -> str:
79
+ """A text leaderboard, one block per task, methods in input order."""
80
+ by_task: dict[tuple[str, str, str], list[ResultRow]] = defaultdict(list)
81
+ for row in rows:
82
+ by_task[(row.pillar, row.task, row.dataset)].append(row)
83
+ out: list[str] = []
84
+ for (pillar, task, dataset), task_rows in by_task.items():
85
+ metric_keys = list(task_rows[0].metrics.keys())
86
+ label = f"{task} :: {dataset}" if dataset else task
87
+ mw = max([len("method")] + [len(row.method) for row in task_rows]) # fit the widest id
88
+ out.append(f"\n[{pillar}] {label}")
89
+ out.append(f" {'method':{mw}s}" + "".join(f"{k:>10s}" for k in metric_keys))
90
+ for row in task_rows:
91
+ out.append(
92
+ f" {row.method:{mw}s}"
93
+ + "".join(f"{row.metrics[k]:>10.3f}" for k in metric_keys)
94
+ )
95
+ return "\n".join(out)
catchbench/corpora.py ADDED
@@ -0,0 +1,158 @@
1
+ """Immutable Hugging Face revisions used by the POST and LIVE boards."""
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CorpusRevision:
12
+ name: str
13
+ repo_id: str
14
+ revision: str
15
+
16
+
17
+ CORPUS_REVISIONS = (
18
+ CorpusRevision(
19
+ "Who&When",
20
+ "Kevin355/Who_and_When",
21
+ "59b9fcba1aaed7bbf206b5f4d3c68b8face2f49c",
22
+ ),
23
+ CorpusRevision(
24
+ "SWE-Gym",
25
+ "SWE-Gym/OpenHands-Sampled-Trajectories",
26
+ "baf3a4e4bff514d48ddc08a93a2ade5c126212c7",
27
+ ),
28
+ CorpusRevision(
29
+ "tau-bench",
30
+ "AgentSuite/tau-bench-trajectories",
31
+ "382e57d1784b55c5155f4ef394ef48f1c747a287",
32
+ ),
33
+ )
34
+
35
+ _BY_REPO = {corpus.repo_id: corpus for corpus in CORPUS_REVISIONS}
36
+ _PINNED_FETCHES: set[tuple[str, str]] = set()
37
+ _PATCHED = False
38
+
39
+
40
+ class CorpusRevisionError(RuntimeError):
41
+ """The runner cannot prove that it will score the recorded corpus revisions."""
42
+
43
+
44
+ def verify_corpus_heads(api: Any = None) -> dict[str, str]:
45
+ """Resolve every dataset head and refuse to score if it differs from the board record."""
46
+ if api is None:
47
+ try:
48
+ from huggingface_hub import HfApi
49
+ except ImportError as exc: # pragma: no cover - exercised by minimal installations
50
+ raise CorpusRevisionError(
51
+ "Corpus revision preflight needs huggingface_hub; install the dev-seed or full extra."
52
+ ) from exc
53
+ api = HfApi()
54
+
55
+ resolved: dict[str, str] = {}
56
+ errors = []
57
+ for corpus in CORPUS_REVISIONS:
58
+ try:
59
+ actual = str(api.dataset_info(corpus.repo_id, revision="main").sha)
60
+ except Exception as exc:
61
+ errors.append(
62
+ f"{corpus.name} ({corpus.repo_id}): could not resolve main ({exc}); "
63
+ f"recorded={corpus.revision}"
64
+ )
65
+ continue
66
+ resolved[corpus.name] = actual
67
+ if actual != corpus.revision:
68
+ errors.append(
69
+ f"{corpus.name} ({corpus.repo_id}): main={actual}; recorded={corpus.revision}"
70
+ )
71
+ if errors:
72
+ raise CorpusRevisionError(
73
+ "Corpus revision preflight failed; refusing to score an unrecorded population:\n "
74
+ + "\n ".join(errors)
75
+ )
76
+ return resolved
77
+
78
+
79
+ def revision_header(revisions: dict[str, str] | None = None) -> str:
80
+ """One board-header line recording the exact population revisions being scored."""
81
+ revisions = revisions or {corpus.name: corpus.revision for corpus in CORPUS_REVISIONS}
82
+ cells = [f"{corpus.name}={revisions[corpus.name]}" for corpus in CORPUS_REVISIONS]
83
+ return "Corpus revisions :: " + " | ".join(cells)
84
+
85
+
86
+ def _pin_hub_call(name: str, function):
87
+ @functools.wraps(function)
88
+ def pinned(*args, **kwargs):
89
+ import inspect
90
+
91
+ bound = inspect.signature(function).bind_partial(*args, **kwargs)
92
+ repo_id = bound.arguments.get("repo_id")
93
+ corpus = _BY_REPO.get(repo_id)
94
+ if corpus is None:
95
+ return function(*args, **kwargs)
96
+ requested = bound.arguments.get("revision")
97
+ if requested not in (None, "main", corpus.revision):
98
+ raise CorpusRevisionError(
99
+ f"{name} requested {repo_id}@{requested}, but the board records {corpus.revision}"
100
+ )
101
+ bound.arguments["revision"] = corpus.revision
102
+ result = function(*bound.args, **bound.kwargs)
103
+ _PINNED_FETCHES.add((repo_id, name))
104
+ return result
105
+
106
+ return pinned
107
+
108
+
109
+ def install_hub_revision_pins() -> None:
110
+ """Force GRADE's Hub calls through the recorded revisions before its modules are imported."""
111
+ global _PATCHED
112
+ if _PATCHED:
113
+ return
114
+ try:
115
+ import huggingface_hub
116
+ except ImportError: # the actionable error is emitted by the preflight or a GRADE loader
117
+ return
118
+ for name in ("snapshot_download", "hf_hub_download", "list_repo_files"):
119
+ function = getattr(huggingface_hub, name)
120
+ setattr(huggingface_hub, name, _pin_hub_call(name, function))
121
+ _PATCHED = True
122
+
123
+
124
+ def _whoandwhen_cached_revisions() -> set[str]:
125
+ """Read the commit recorded by snapshot_download's local-dir metadata."""
126
+ try:
127
+ import agent_graph_characterization as whoandwhen
128
+ except ImportError:
129
+ return set()
130
+ metadata_root = Path(whoandwhen.CACHE).parent / ".cache" / "huggingface" / "download"
131
+ revisions = set()
132
+ for path in metadata_root.rglob("*.metadata"):
133
+ lines = path.read_text(encoding="utf-8").splitlines()
134
+ if lines:
135
+ revisions.add(lines[0])
136
+ return revisions
137
+
138
+
139
+ def verify_pinned_fetches() -> None:
140
+ """Check that each loader used a pinned Hub call, or a verified Who&When local snapshot."""
141
+ errors = []
142
+ for corpus in CORPUS_REVISIONS:
143
+ calls = {name for repo_id, name in _PINNED_FETCHES if repo_id == corpus.repo_id}
144
+ if corpus.name == "Who&When" and not calls:
145
+ cached = _whoandwhen_cached_revisions()
146
+ if cached == {corpus.revision}:
147
+ continue
148
+ detail = ", ".join(sorted(cached)) if cached else "no snapshot metadata"
149
+ errors.append(f"Who&When local cache is not verified at {corpus.revision} ({detail})")
150
+ elif "hf_hub_download" not in calls and "snapshot_download" not in calls:
151
+ errors.append(
152
+ f"{corpus.name} loader made no observed pinned download for {corpus.repo_id}; "
153
+ "GRADE may have changed its fetch path"
154
+ )
155
+ if errors:
156
+ raise CorpusRevisionError(
157
+ "Corpus fetch verification failed; refusing to print scores:\n " + "\n ".join(errors)
158
+ )