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 +12 -0
- catchbench/_reuse.py +109 -0
- catchbench/agent_detectors.py +180 -0
- catchbench/core.py +95 -0
- catchbench/corpora.py +158 -0
- catchbench/detection.py +197 -0
- catchbench/gold.py +676 -0
- catchbench/graph_ad.py +157 -0
- catchbench/live.py +426 -0
- catchbench/llm_judge.py +518 -0
- catchbench/namedvalue.py +436 -0
- catchbench/post.py +197 -0
- catchbench/pre.py +272 -0
- catchbench/pre_baselines.py +108 -0
- catchbench/pre_static_scanner.py +489 -0
- catchbench/pygod_extra.py +182 -0
- catchbench/pyod_extra.py +113 -0
- catchbench-0.1.0.dist-info/METADATA +657 -0
- catchbench-0.1.0.dist-info/RECORD +23 -0
- catchbench-0.1.0.dist-info/WHEEL +5 -0
- catchbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- catchbench-0.1.0.dist-info/licenses/NOTICE +86 -0
- catchbench-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|