@malloy-publisher/skills 0.1.20 → 0.1.21
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.
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +45 -5
- package/skills/eval-answer/reference/definition-ledger.md +305 -0
- package/skills/eval-answer/reference/ledger-schema.md +36 -7
- package/skills/eval-answer/scripts/check_coverage.py +12 -21
- package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
- package/skills/eval-answer/scripts/check_findable.py +386 -0
- package/skills/eval-answer/scripts/check_findable_test.py +326 -0
- package/skills/eval-answer/scripts/json_scan.py +43 -0
- package/skills/eval-answer/scripts/ledger.py +63 -7
- package/skills/eval-answer/scripts/ledger_test.py +7 -0
- package/skills/eval-answer/scripts/mcp_payload.py +111 -2
- package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
- package/skills/eval-answer/scripts/publisher_rest.py +10 -4
- package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
- package/skills/eval-answer/scripts/score_retrieval.py +326 -41
- package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
- package/skills/eval-answer/scripts/verify_definitions.py +752 -0
- package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
- package/skills/eval-answer/scripts/verify_goldens.py +336 -22
- package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
- package/skills/eval-diagnose/SKILL.md +47 -2
- package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
- package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
- package/skills/eval-diagnose/scripts/diagnose.py +471 -42
- package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
- package/skills/eval-import/SKILL.md +180 -1
- package/skills/eval-import/reference/case-format.md +1 -1
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-improve/scripts/improve.py +29 -3
- package/skills/eval-improve/scripts/improve_test.py +51 -0
- package/skills/eval-judge/SKILL.md +1 -1
- package/skills/eval-loop/SKILL.md +112 -3
- package/skills/eval-loop/reference/measurement.md +44 -4
- package/skills/eval-loop/reference/running-a-run.md +5 -1
- package/skills/eval-loop/scripts/agent_harness.py +21 -20
- package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
- package/skills/eval-loop/scripts/build_run_package.py +9 -3
- package/skills/eval-loop/scripts/flip_table.py +107 -2
- package/skills/eval-loop/scripts/flip_table_test.py +165 -0
- package/skills/eval-loop/scripts/run_baseline.py +956 -97
- package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
- package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
- package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
- package/skills/eval-report/SKILL.md +362 -0
- package/skills/malloy/SKILL.md +2 -1
- package/skills/malloy-analysis/SKILL.md +1 -1
- package/skills/malloy-analysis-pitfalls/SKILL.md +13 -1
- package/skills/malloy-analysis-report/SKILL.md +1 -1
- package/skills/malloy-analyze/SKILL.md +1 -1
- package/skills/malloy-charts/SKILL.md +1 -1
- package/skills/malloy-dashboards/SKILL.md +1 -1
- package/skills/malloy-debug/SKILL.md +1 -1
- package/skills/malloy-define/SKILL.md +1 -1
- package/skills/malloy-discover/SKILL.md +1 -1
- package/skills/malloy-document/SKILL.md +1 -1
- package/skills/malloy-getting-started/SKILL.md +1 -1
- package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
- package/skills/malloy-gotchas-queries/SKILL.md +1 -1
- package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
- package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
- package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
- package/skills/malloy-html-data-apps/SKILL.md +1 -1
- package/skills/malloy-lookml-review/SKILL.md +1 -1
- package/skills/malloy-materialization/SKILL.md +1 -1
- package/skills/malloy-materialization-tuning/SKILL.md +1 -1
- package/skills/malloy-model/SKILL.md +1 -1
- package/skills/malloy-model-as-you-go/SKILL.md +1 -1
- package/skills/malloy-modeling/SKILL.md +1 -1
- package/skills/malloy-notebook-chat/SKILL.md +1 -1
- package/skills/malloy-notebooks/SKILL.md +1 -1
- package/skills/malloy-patterns/SKILL.md +1 -1
- package/skills/malloy-phrase-detection/SKILL.md +3 -2
- package/skills/malloy-publish/SKILL.md +1 -1
- package/skills/malloy-queries/SKILL.md +1 -1
- package/skills/malloy-review/SKILL.md +1 -1
- package/skills/malloy-scope/SKILL.md +1 -1
|
@@ -175,9 +175,17 @@ def verify_goldens(a: argparse.Namespace, d: pathlib.Path,
|
|
|
175
175
|
# and its failure was indistinguishable from a golden the edit really did
|
|
176
176
|
# invalidate. `--environment` too: the verifier's own default is `samples`,
|
|
177
177
|
# which silently verified against the wrong environment on any other set.
|
|
178
|
+
# The TRUTH server's environment, which is not the model server's. It is a
|
|
179
|
+
# separate server and names its environments however it likes, so passing
|
|
180
|
+
# `--environment` here 404'd every case ("Environment 'local' could not be
|
|
181
|
+
# resolved to a path"), the audit exited non-zero, and the acceptance
|
|
182
|
+
# check read that as "this edit may have invalidated a golden" and BLOCKED
|
|
183
|
+
# every cluster. No edit could ever be accepted. `run_baseline.py` grew
|
|
184
|
+
# `--truth-environment` for exactly this; this caller had not.
|
|
178
185
|
cmd = [sys.executable, str(script.resolve()),
|
|
179
186
|
"--set", str(a.set_dir.resolve()),
|
|
180
|
-
"--environment",
|
|
187
|
+
"--environment",
|
|
188
|
+
getattr(a, "truth_environment", None) or a.environment]
|
|
181
189
|
# Appended only when set: passing None here is a TypeError inside
|
|
182
190
|
# subprocess.run, and the two excepts below catch OSError and a timeout,
|
|
183
191
|
# not that -- so it would have crashed improve.py AFTER the model edit,
|
|
@@ -185,6 +193,9 @@ def verify_goldens(a: argparse.Namespace, d: pathlib.Path,
|
|
|
185
193
|
# exits 3, which `couldNotRun` already reads correctly.
|
|
186
194
|
if a.truth_publisher:
|
|
187
195
|
cmd += ["--publisher", a.truth_publisher]
|
|
196
|
+
# getattr: older callers and the tests build this Namespace by hand.
|
|
197
|
+
if getattr(a, "definitions", None):
|
|
198
|
+
cmd += ["--definitions", str(a.definitions)]
|
|
188
199
|
if model:
|
|
189
200
|
cmd += ["--model", str(model)]
|
|
190
201
|
# The isolation guard needs to know what is under test; without it the
|
|
@@ -331,11 +342,24 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
331
342
|
"whole point of the second server. Without it the "
|
|
332
343
|
"value check does not run and the acceptance check "
|
|
333
344
|
"blocks")
|
|
345
|
+
ap.add_argument("--truth-environment", default=None,
|
|
346
|
+
help="the environment name on the TRUTH server, when it "
|
|
347
|
+
"differs from --environment. That server is separate "
|
|
348
|
+
"and names its environments independently; without "
|
|
349
|
+
"this the audit 404s on every case and the "
|
|
350
|
+
"acceptance check blocks every cluster. Same flag, "
|
|
351
|
+
"same reason, as run_baseline.py")
|
|
352
|
+
ap.add_argument("--definitions", type=pathlib.Path, default=None,
|
|
353
|
+
help="a definition ledger (verify_definitions.py). Passed to "
|
|
354
|
+
"the golden audit so a set with no truth package can "
|
|
355
|
+
"pass the acceptance check when every tested definition "
|
|
356
|
+
"is validated")
|
|
334
357
|
ap.add_argument("--max-turns", type=int, default=60)
|
|
335
358
|
ap.add_argument("--timeout", type=int, default=1800)
|
|
336
359
|
ap.add_argument("--retries", type=int, default=1)
|
|
337
360
|
ap.add_argument("--only", default=None, help="comma-separated issue_ids")
|
|
338
|
-
ap.add_argument("--limit", type=int, default=None
|
|
361
|
+
ap.add_argument("--limit", type=int, default=None,
|
|
362
|
+
help="how many to process; each one spawns a real agent. Omit for no limit. 0 means zero, not unlimited.")
|
|
339
363
|
ap.add_argument("--force", action="store_true",
|
|
340
364
|
help="redo clusters that already have a result")
|
|
341
365
|
ap.add_argument("--no-isolate", dest="isolate", action="store_false",
|
|
@@ -392,7 +416,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
392
416
|
want = {x.strip() for x in a.only.split(",")}
|
|
393
417
|
issues = [i for i in issues if i["issue_id"] in want]
|
|
394
418
|
issues.sort(key=lambda i: -len(i.get("qids", [])))
|
|
395
|
-
|
|
419
|
+
# `is not None`: see diagnose.py's select_cases. 0 means zero issues,
|
|
420
|
+
# not every issue -- each one spawns a real agent that edits the model.
|
|
421
|
+
if a.limit is not None:
|
|
396
422
|
issues = issues[:a.limit]
|
|
397
423
|
if not issues:
|
|
398
424
|
print("no open model-owned issues; run diagnose.py first, or every "
|
|
@@ -13,6 +13,7 @@ import subprocess
|
|
|
13
13
|
import sys
|
|
14
14
|
import tempfile
|
|
15
15
|
import unittest
|
|
16
|
+
from unittest import mock
|
|
16
17
|
|
|
17
18
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
18
19
|
sys.path.insert(0, str(HERE))
|
|
@@ -42,6 +43,28 @@ class Invocation(unittest.TestCase):
|
|
|
42
43
|
def tearDown(self):
|
|
43
44
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
44
45
|
|
|
46
|
+
def capture(self):
|
|
47
|
+
seen = {}
|
|
48
|
+
|
|
49
|
+
def fake_run(cmd, **kw):
|
|
50
|
+
seen["cmd"] = cmd
|
|
51
|
+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
|
52
|
+
with mock.patch.object(improve.subprocess, "run", fake_run):
|
|
53
|
+
improve.verify_goldens(self.a, self.art, "a diff")
|
|
54
|
+
return seen["cmd"]
|
|
55
|
+
|
|
56
|
+
def test_definitions_reaches_the_verifier_command(self):
|
|
57
|
+
# The acceptance check can pass on a set with no truth package only if
|
|
58
|
+
# the ledger gets as far as the verifier.
|
|
59
|
+
self.a.definitions = pathlib.Path("/tmp/led.jsonl")
|
|
60
|
+
cmd = self.capture()
|
|
61
|
+
self.assertIn("--definitions", cmd)
|
|
62
|
+
self.assertIn("/tmp/led.jsonl", cmd)
|
|
63
|
+
|
|
64
|
+
def test_a_namespace_without_definitions_is_still_accepted(self):
|
|
65
|
+
# Older callers and these fixtures build the Namespace by hand.
|
|
66
|
+
self.assertNotIn("--definitions", self.capture())
|
|
67
|
+
|
|
45
68
|
def test_the_verifier_accepts_the_arguments_improve_sends(self):
|
|
46
69
|
r = improve.verify_goldens(self.a, self.art, "a diff")
|
|
47
70
|
# Exit 2 is argparse refusing the command. Whatever else happens, the
|
|
@@ -177,5 +200,33 @@ class VerifierContract(unittest.TestCase):
|
|
|
177
200
|
self.assertEqual(p.returncode, 0)
|
|
178
201
|
|
|
179
202
|
|
|
203
|
+
class TruthEnvironment(unittest.TestCase):
|
|
204
|
+
"""The truth server is separate and names its environments independently.
|
|
205
|
+
|
|
206
|
+
Passing the MODEL server's environment to the audit 404'd every case, the
|
|
207
|
+
audit exited non-zero, and the acceptance check read that as "this edit
|
|
208
|
+
may have invalidated a golden" -- so it BLOCKED every cluster and no edit
|
|
209
|
+
could ever be accepted. run_baseline.py grew --truth-environment for
|
|
210
|
+
exactly this; this caller had not.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
SRC = (pathlib.Path(__file__).resolve().parent / "improve.py").read_text()
|
|
214
|
+
|
|
215
|
+
def test_the_flag_exists(self):
|
|
216
|
+
self.assertIn('ap.add_argument("--truth-environment"', self.SRC)
|
|
217
|
+
|
|
218
|
+
def test_the_audit_is_given_the_truth_environment(self):
|
|
219
|
+
self.assertIn('getattr(a, "truth_environment", None) or a.environment',
|
|
220
|
+
self.SRC)
|
|
221
|
+
|
|
222
|
+
def test_it_falls_back_to_the_model_environment(self):
|
|
223
|
+
# One server serving both is the ordinary local case, and it must keep
|
|
224
|
+
# working without the new flag.
|
|
225
|
+
import argparse as _a
|
|
226
|
+
ns = _a.Namespace(environment="samples", truth_environment=None)
|
|
227
|
+
self.assertEqual(
|
|
228
|
+
getattr(ns, "truth_environment", None) or ns.environment, "samples")
|
|
229
|
+
|
|
230
|
+
|
|
180
231
|
if __name__ == "__main__":
|
|
181
232
|
unittest.main()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: eval-judge
|
|
3
3
|
description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.21
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# The judge
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: eval-loop
|
|
3
3
|
description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files under the model package''s evals/ directory; checkpoints are git commits of the model repo. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.21
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# The Evaluation Loop
|
|
@@ -41,7 +41,8 @@ all of them for every run is how a skill stops being read.
|
|
|
41
41
|
| a golden is wrong, doubted, or out of step with the model | `reference/golden-side-door.md` |
|
|
42
42
|
| auditing a key you doubt, or a set you did not author | `reference/auditing-an-answer-key.md` |
|
|
43
43
|
| deciding whether an edit stays | `reference/acceptance-check.md` |
|
|
44
|
-
| about to quote a number,
|
|
44
|
+
| about to quote a number, set the band, or read a set's flips | `reference/measurement.md` |
|
|
45
|
+
| the run finished and someone has to read it | `skill:eval-report` |
|
|
45
46
|
| you changed judge doctrine or its inputs | `reference/checking-the-judge.md` |
|
|
46
47
|
|
|
47
48
|
Read the file, do not work from the summary here. The acceptance-check rules and
|
|
@@ -58,10 +59,22 @@ produces a confident wrong answer rather than an error.
|
|
|
58
59
|
| **d. improve** | One smallest model edit, then the acceptance check | improve writes `candidate`; you write `acceptance_check`. Revert on reject. |
|
|
59
60
|
| **e. checkpoint** | Git commit after an accepted acceptance check | `checkpoint` event, then the commit |
|
|
60
61
|
|
|
62
|
+
Every run then **reports** (below). A run whose result exists only as JSONL and
|
|
63
|
+
a scrolled-away console summary has not been delivered.
|
|
64
|
+
|
|
61
65
|
**scrape** and **run** share a letter but are not the same job. Scrape writes
|
|
62
66
|
cases. Run writes attempts. Do not invent questions and score
|
|
63
67
|
them in one breath.
|
|
64
68
|
|
|
69
|
+
**Step d is `skill:eval-improve`, not a hand edit plus another arm.** The
|
|
70
|
+
failure mode is specific and it has happened: the edits were made by hand,
|
|
71
|
+
published, and validated by re-running the whole arm -- with the answer key
|
|
72
|
+
repaired in the same window. No `candidate`, `acceptance_check` or `checkpoint`
|
|
73
|
+
event exists in that run's ledger, and the resulting move cannot be attributed
|
|
74
|
+
to the model, the key, or the answerer. Re-running a whole arm after changing
|
|
75
|
+
two things is the one thing the acceptance check exists to replace: it scores
|
|
76
|
+
the edit against dev AND holdout, and it is cheaper than the arm.
|
|
77
|
+
|
|
65
78
|
### Scrape, minimally
|
|
66
79
|
|
|
67
80
|
Importing an existing corpus IS the scrape step: copy the set from its home
|
|
@@ -292,6 +305,29 @@ the run measure something other than what it names:
|
|
|
292
305
|
under a published version, which is what makes it a pin. Check which you have
|
|
293
306
|
before deciding how much of this you need.
|
|
294
307
|
|
|
308
|
+
**Look for a set and for prior runs before you author either.** A minute of
|
|
309
|
+
`find . -name cases.jsonl`, a glance at `evals/*/runs/` and at your host's
|
|
310
|
+
own transcripts for this repo. Two sessions fourteen minutes apart built the
|
|
311
|
+
same 29-case answer key from scratch, because the first had committed
|
|
312
|
+
nothing before it was deleted and the second had no way to know it existed.
|
|
313
|
+
Roughly a working day was spent twice, and three specific things were
|
|
314
|
+
rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a
|
|
315
|
+
judge-rendering bug that had already cost $17 of arm once. The two keys,
|
|
316
|
+
independently authored from the same questions, differ by about ten points
|
|
317
|
+
on comparable answers -- which is the available measure of how much a key
|
|
318
|
+
depends on its author, and a reason to reuse one rather than rebuild it.
|
|
319
|
+
|
|
320
|
+
**Audit the set's entity ids before the first arm.** `verify_goldens.py`
|
|
321
|
+
checks that each id names something in the model; `check_findable.py` checks
|
|
322
|
+
that a search of its own kind actually returns it. Both are free of model
|
|
323
|
+
calls. An id that fails either scores a retrieval miss on every run, and the
|
|
324
|
+
miss reads as the model's fault.
|
|
325
|
+
|
|
326
|
+
**Commit the set before you spend money on an arm**, and keep durable
|
|
327
|
+
outputs in the repository. A findings document in `~/Downloads` is gone the
|
|
328
|
+
first time somebody tidies up; the set, the run directory and the write-up
|
|
329
|
+
belong in git beside the model.
|
|
330
|
+
|
|
295
331
|
2. The server must be up with retrieval tracing on, so a call's ranked results
|
|
296
332
|
can be recovered afterwards (open-source Publisher: `PUBLISHER_MCP_TRACE=retrieval`).
|
|
297
333
|
Confirm a trace lookup is available (absent means tracing is off).
|
|
@@ -332,6 +368,44 @@ the run measure something other than what it names:
|
|
|
332
368
|
whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
|
|
333
369
|
model.
|
|
334
370
|
|
|
371
|
+
The call budget is `--max-turns`, written to `run.json` as `maxTurns`.
|
|
372
|
+
**Size it from a pilot rather than taking the default of 30.** Run the three
|
|
373
|
+
cheapest cases uncapped (`--max-turns 100`) and set the cap at twice their
|
|
374
|
+
maximum. A set whose questions need two or three sources joined does not fit
|
|
375
|
+
a cap sized for single-source lookups: on one such set the completed
|
|
376
|
+
attempts had a median of 17 turns and a 90th percentile of 26 against a cap
|
|
377
|
+
of 30, and four cases died at it. A cap 15% above the 90th percentile of
|
|
378
|
+
completed work is not a safety margin. Note also that `malloy-analysis` tells
|
|
379
|
+
the answerer to persist -- retry a phrasing, let a small query settle whether
|
|
380
|
+
a field exists -- so a tight cap and that instruction are in direct conflict.
|
|
381
|
+
|
|
382
|
+
**Do not change the model and the measuring instrument in the same step.**
|
|
383
|
+
Those are two different axes and only one of them is cheap to separate.
|
|
384
|
+
|
|
385
|
+
Batching MODEL edits is fine and expected. Clustering exists so that one
|
|
386
|
+
edit closes several cases, and an arm per fix does not survive contact with
|
|
387
|
+
arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured
|
|
388
|
+
$0.33 a case on a proxied warehouse that is $660 of answering to attribute
|
|
389
|
+
what the acceptance check attributes for the price of the affected cases
|
|
390
|
+
plus holdout. Budget five arms for a defensible claim (a baseline, two for
|
|
391
|
+
the A/A, two post-edit), not one per edit, and let `skill:eval-improve`
|
|
392
|
+
carry each cluster.
|
|
393
|
+
|
|
394
|
+
The instrument is the other axis: the answer key, the judge and its prompt,
|
|
395
|
+
the answerer model, the skills. Move one of those together with the model
|
|
396
|
+
and there is nothing left holding still, so the result measures neither. On
|
|
397
|
+
the run this comes from, nine model commits and a re-derived answer key
|
|
398
|
+
landed between two arms, and the move from 20% to 39% belongs to no one --
|
|
399
|
+
not because two model edits were batched, but because the ruler changed at
|
|
400
|
+
the same time as the thing being measured. A key repair mid-improve is not
|
|
401
|
+
forbidden; it ends that comparison, so re-baseline rather than quoting a
|
|
402
|
+
delta across it.
|
|
403
|
+
|
|
404
|
+
The answerer model is instrument too, and it is the one most often left
|
|
405
|
+
unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one
|
|
406
|
+
model and 8 / 4 / 15 on a smaller one. No statement about "the agent's"
|
|
407
|
+
capability means anything until two arms name the same answerer.
|
|
408
|
+
|
|
335
409
|
7. Generate every answerer prompt from the stored case in `cases.jsonl`.
|
|
336
410
|
Never retype the question. A truncated retype is indistinguishable from a
|
|
337
411
|
real question downstream.
|
|
@@ -352,6 +426,31 @@ the run measure something other than what it names:
|
|
|
352
426
|
6. `skill:eval-improve` only when this run includes improve, and only for
|
|
353
427
|
`owner: model`. Then run the acceptance check. On accept, checkpoint.
|
|
354
428
|
|
|
429
|
+
## Report the run, or nobody can read it
|
|
430
|
+
|
|
431
|
+
A run directory is JSONL. It is a record, not a result, and the console summary
|
|
432
|
+
scrolls away. **Every run ends by producing something a person can open**, and
|
|
433
|
+
that job is `skill:eval-report`: it builds the servable run package (the case
|
|
434
|
+
matrix app and the aggregate notebook) and gives the template for the write-up.
|
|
435
|
+
|
|
436
|
+
Read it at the end of every run, including a run that failed. The standing
|
|
437
|
+
complaint about this loop is that "a bunch of stuff happens and it is hard to
|
|
438
|
+
know the actual results", and a ledger nobody renders is why.
|
|
439
|
+
|
|
440
|
+
Two rules from it are worth repeating here, because they are the ones a
|
|
441
|
+
conductor skips:
|
|
442
|
+
|
|
443
|
+
- **Separate a MODEL failure from an EVAL failure.** A wrong answer and a
|
|
444
|
+
broken measurement look identical in a pass rate and have nothing else in
|
|
445
|
+
common. An arm holding a truncated, contaminated or environment-failed
|
|
446
|
+
attempt has no rate to quote at all.
|
|
447
|
+
- **Say when a step did not run, and why.** `improve` not running because every
|
|
448
|
+
cluster came back `owner: agent-skill` is a RESULT, and it reads identically
|
|
449
|
+
to having forgotten unless it is written down.
|
|
450
|
+
|
|
451
|
+
Keep the write-up in the repository beside the set, not in a chat log and not
|
|
452
|
+
in `~/Downloads`.
|
|
453
|
+
|
|
355
454
|
## Checkpoint
|
|
356
455
|
|
|
357
456
|
A checkpoint is a git commit of the model repository, taken after an acceptance check
|
|
@@ -401,7 +500,16 @@ conductor. Do not:
|
|
|
401
500
|
|
|
402
501
|
- The model is the only thing improve edits. No question text, qids, or
|
|
403
502
|
expected values in any name, doc, or comment.
|
|
404
|
-
- When the environment misbehaves, stop. Never diagnose a sick system.
|
|
503
|
+
- When the environment misbehaves, stop. Never diagnose a sick system. The
|
|
504
|
+
harness is part of the environment: a known-broken measurement does not
|
|
505
|
+
become quotable by being finished. Measured against this directive, an arm
|
|
506
|
+
already paid for gets quoted anyway -- it happened three times in one run,
|
|
507
|
+
once with the confound stated in the same message that started the arm -- so
|
|
508
|
+
the harness now enforces the parts it can. A run with a truncated or
|
|
509
|
+
contaminated attempt prints no pass rate and records `status: incomplete`,
|
|
510
|
+
and `flip_table.py` names what one arm left unscored that the other did not.
|
|
511
|
+
Read `run_error` on the attempts
|
|
512
|
+
and the INCOMPLETE line before quoting any number.
|
|
405
513
|
- When a subagent disagrees with you, probe. Do not win by authority.
|
|
406
514
|
- When a rule here is wrong, change this file and note it on the run.
|
|
407
515
|
|
|
@@ -412,5 +520,6 @@ conductor. Do not:
|
|
|
412
520
|
the judge.
|
|
413
521
|
- `skill:eval-diagnose`: component, owner, issue events. No edit.
|
|
414
522
|
- `skill:eval-improve`: smallest model edit, probe receipts, no self-accept.
|
|
523
|
+
- `skill:eval-report`: the run package and the write-up a person reads.
|
|
415
524
|
- The `malloy-analysis` skill: what the blind answerer follows. It is installed
|
|
416
525
|
from the `analysis` manifest group, not the `eval` group.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- Sampling, the flip-count bar, the A/A
|
|
1
|
+
<!-- Sampling, the flip-count bar, the A/A band, reading the disagreement set, and targeted fixes. Read this before quoting any number. -->
|
|
2
2
|
|
|
3
3
|
# Measurement
|
|
4
4
|
|
|
@@ -33,9 +33,49 @@ measure a baseline and diagnose failures but cannot defend an edit.
|
|
|
33
33
|
This table was asserted, not measured, and the number it needs is a property of
|
|
34
34
|
your harness and your set -- not of this skill. Measure it with an **A/A run**:
|
|
35
35
|
the same model, same config, same set, twice, compared with
|
|
36
|
-
`scripts/flip_table.py`.
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
`scripts/flip_table.py`. Record the result with the set, in `CALIBRATION.md`,
|
|
37
|
+
and cite that file when you quote a band.
|
|
38
|
+
|
|
39
|
+
An A/A flip is unattributable to a CHANGE, because there was no change. That
|
|
40
|
+
is not the same as saying it carries no information, and reading it as "noise
|
|
41
|
+
by construction" throws away the most useful evidence in the run. See below.
|
|
42
|
+
|
|
43
|
+
## Read the disagreement set before you average it
|
|
44
|
+
|
|
45
|
+
**First move on a flip set: read the flipping cases. Second move: establish
|
|
46
|
+
the band.** Both, in that order. The band alone is half the information.
|
|
47
|
+
|
|
48
|
+
A case that flips is a case where the agent found two paths and the model did
|
|
49
|
+
not make one of them obviously right. That is a MODEL-quality signal, and
|
|
50
|
+
often a cheaper one than a stable failure, because two runs hand you a matched
|
|
51
|
+
pair: same question, same model, one right answer and one wrong one, with the
|
|
52
|
+
diff between them isolating the cause.
|
|
53
|
+
|
|
54
|
+
Measured, on a real set. Two arms of one model over 28 cases disagreed on
|
|
55
|
+
five. Read as a floor, that is "this set has 18% churn, plan around it". Read
|
|
56
|
+
as evidence, it was a single named model defect:
|
|
57
|
+
|
|
58
|
+
- all five were the same question -- does a blank field on a fee rule match
|
|
59
|
+
every value?
|
|
60
|
+
- the two arms produced two discrete query idioms, not scattered answers
|
|
61
|
+
- the wrong idiom returned 2 fee IDs where the answer has 443, and 1 where it
|
|
62
|
+
has 390
|
|
63
|
+
- the two runs swapped: run 1 was right on one case and wrong on the other,
|
|
64
|
+
run 2 the reverse
|
|
65
|
+
|
|
66
|
+
A model that documents a rule in prose while leaving an inviting wrong path
|
|
67
|
+
reachable produces exactly this: a bimodal coin flip that averages to "noise".
|
|
68
|
+
The variance WAS the finding. Averaging the five would have hidden it, and
|
|
69
|
+
reading them took a day of manual transcript work that the table now does --
|
|
70
|
+
`flip_table.py` prints the two final queries side by side for every flipped
|
|
71
|
+
case, and `diagnose.py --compare-run <other arm>` hands the pair to the
|
|
72
|
+
diagnosing agent.
|
|
73
|
+
|
|
74
|
+
Some flips really are noise, and a set with no band cannot tell an improvement
|
|
75
|
+
from a wobble, so keep the band. What changes is which you do first, and what
|
|
76
|
+
you call the thing before you have looked at it. Prefer **disagreement set**
|
|
77
|
+
or **unstable cases** to "churn": the first two name a question, the last
|
|
78
|
+
names an answer you have not earned yet.
|
|
39
79
|
|
|
40
80
|
Re-measure whenever the model, judge, or set changes. This is not a formality:
|
|
41
81
|
observed bands have moved by a factor of three across a fortnight of ordinary
|
|
@@ -30,7 +30,11 @@ python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/s
|
|
|
30
30
|
# every run. Pass --model on a platform target too, from a checkout of the
|
|
31
31
|
# served version, since the harness cannot see the model text there.
|
|
32
32
|
# Exits 3 when set.json names no truthPackage: the audits ran, no
|
|
33
|
-
# golden was re-derived, and 0 would have claimed otherwise.
|
|
33
|
+
# golden was re-derived, and 0 would have claimed otherwise. With
|
|
34
|
+
# --definitions <ledger> (verify_definitions.py), a set whose every
|
|
35
|
+
# value-bearing case rests on validated definitions exits 0 instead:
|
|
36
|
+
# the composition rule, values not re-derived but their definitions
|
|
37
|
+
# checked.
|
|
34
38
|
python3 skills/eval-answer/scripts/verify_goldens.py \
|
|
35
39
|
--set <repo>/evals/ecommerce --publisher http://localhost:4881 \
|
|
36
40
|
--model <repo>/ecommerce --target-package ecommerce
|
|
@@ -50,10 +50,15 @@ import re
|
|
|
50
50
|
import shutil
|
|
51
51
|
import subprocess
|
|
52
52
|
import tempfile
|
|
53
|
+
import sys
|
|
53
54
|
import time
|
|
54
55
|
from dataclasses import dataclass, field
|
|
55
56
|
from typing import Any, Iterable
|
|
56
57
|
|
|
58
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
|
|
59
|
+
/ "eval-answer" / "scripts"))
|
|
60
|
+
from json_scan import json_objects # noqa: E402
|
|
61
|
+
|
|
57
62
|
SKILL_REF = re.compile(r"skill:([a-z0-9][a-z0-9-]*)")
|
|
58
63
|
|
|
59
64
|
# Never available to a spawned agent, whatever its role. The network tools
|
|
@@ -81,6 +86,10 @@ ALWAYS_BLOCKED = (
|
|
|
81
86
|
# does); seen 2026-09-01 on the first platform smoke, where they alone
|
|
82
87
|
# flagged the attempt contaminated.
|
|
83
88
|
"ListMcpResourcesTool", "ReadMcpResourceDirTool", "ReadMcpResourceTool",
|
|
89
|
+
# The same shape, one host surface later: granted when the runner is
|
|
90
|
+
# launched detached, and they alone flagged every attempt in a 37-case run.
|
|
91
|
+
# `Task` was blocked; the newer task-management tools were not.
|
|
92
|
+
"TaskCreate", "TaskGet", "TaskList", "TaskUpdate",
|
|
84
93
|
)
|
|
85
94
|
|
|
86
95
|
# The role-dependent half, and the reason it cannot join ALWAYS_BLOCKED:
|
|
@@ -370,26 +379,18 @@ def build_workspace(skills: Iterable[str],
|
|
|
370
379
|
|
|
371
380
|
|
|
372
381
|
def last_json_object(text: str) -> dict[str, Any] | None:
|
|
373
|
-
"""The last
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
best = text[start:i + 1]
|
|
386
|
-
if best is None:
|
|
387
|
-
return None
|
|
388
|
-
try:
|
|
389
|
-
obj = json.loads(best)
|
|
390
|
-
except json.JSONDecodeError:
|
|
391
|
-
return None
|
|
392
|
-
return obj if isinstance(obj, dict) else None
|
|
382
|
+
"""The last JSON object in the text; agents narrate before the payload.
|
|
383
|
+
|
|
384
|
+
Delegates to `json_scan`, the one scanner in this harness, rather than
|
|
385
|
+
counting braces here. Depth counting cannot see that a brace is inside a
|
|
386
|
+
string or a comment, so one stray `{` in a quoted Malloy snippet -- which
|
|
387
|
+
is what `eval-diagnose` asks an agent to read and reason over -- left the
|
|
388
|
+
depth permanently above zero and made a perfectly good trailing verdict
|
|
389
|
+
read as no object at all. The diagnosis then came back `unparseable` and
|
|
390
|
+
the case left the denominator.
|
|
391
|
+
"""
|
|
392
|
+
objs = json_objects(text)
|
|
393
|
+
return objs[-1] if objs else None
|
|
393
394
|
|
|
394
395
|
|
|
395
396
|
def spawn_agent(prompt: str, *, skills: Iterable[str],
|
|
@@ -13,6 +13,7 @@ import inspect
|
|
|
13
13
|
import pathlib
|
|
14
14
|
import sys
|
|
15
15
|
import unittest
|
|
16
|
+
import urllib.error
|
|
16
17
|
from unittest import mock
|
|
17
18
|
|
|
18
19
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
@@ -362,6 +363,84 @@ class TheRetrievalGateIsWired(unittest.TestCase):
|
|
|
362
363
|
self.assertEqual(rb.run_retrieval_gate(self.ns(rejudge=True)),
|
|
363
364
|
"not run (no answering phase)")
|
|
364
365
|
|
|
366
|
+
def test_a_401_is_named_not_retried(self):
|
|
367
|
+
# The probe carries no credentials and no CLI login reaches it, so
|
|
368
|
+
# waiting buys another 401. Retrying one spends twelve tries at ten
|
|
369
|
+
# seconds each to arrive at "retrieval is not ready", which blames the
|
|
370
|
+
# retriever for an auth failure.
|
|
371
|
+
def denied(_a):
|
|
372
|
+
raise rb.AuthRequired(401, "https://hosted/mcp")
|
|
373
|
+
with mock.patch.object(rb, "wait_retrieval_ready", denied):
|
|
374
|
+
note = rb.run_retrieval_gate(self.ns())
|
|
375
|
+
self.assertIn("not run", note)
|
|
376
|
+
self.assertIn("401", note)
|
|
377
|
+
self.assertIn("NOT confirmed", note)
|
|
378
|
+
|
|
379
|
+
def test_a_401_does_not_abort_the_run(self):
|
|
380
|
+
# It is the gate that cannot run, not the arm that must not.
|
|
381
|
+
def denied(_a):
|
|
382
|
+
raise rb.AuthRequired(403, "https://hosted/mcp")
|
|
383
|
+
with mock.patch.object(rb, "wait_retrieval_ready", denied):
|
|
384
|
+
rb.run_retrieval_gate(self.ns()) # no SystemExit
|
|
385
|
+
|
|
386
|
+
def test_the_probes_reply_is_a_confirmation_and_says_it_is_only_one(self):
|
|
387
|
+
# The reachability probe's call went through the CLI, so it WAS
|
|
388
|
+
# authenticated. A local run gets two confirmations; this gets one, and
|
|
389
|
+
# a weaker check must not read back as the same check.
|
|
390
|
+
def boom(_a):
|
|
391
|
+
raise AssertionError("made a second, unauthenticated probe")
|
|
392
|
+
with mock.patch.object(rb, "wait_retrieval_ready", boom):
|
|
393
|
+
note = rb.run_retrieval_gate(
|
|
394
|
+
self.ns(probe_payload={"retrieval": "semantic"}))
|
|
395
|
+
self.assertIn("ready:", note)
|
|
396
|
+
self.assertIn("1 confirmation", note)
|
|
397
|
+
|
|
398
|
+
def test_a_lexical_probe_reply_still_waits(self):
|
|
399
|
+
# One lexical read is exactly the cold-start case the gate exists for.
|
|
400
|
+
with mock.patch.object(rb, "wait_retrieval_ready",
|
|
401
|
+
lambda _a: (True, "semantic retrieval ready")):
|
|
402
|
+
note = rb.run_retrieval_gate(
|
|
403
|
+
self.ns(probe_payload={"retrieval": "lexical"}))
|
|
404
|
+
self.assertNotIn("1 confirmation", note)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class AuthIsNotAColdIndex(unittest.TestCase):
|
|
408
|
+
"""`retrieval_probe` separates "refused me" from "not warm yet"."""
|
|
409
|
+
|
|
410
|
+
def probe(self, code):
|
|
411
|
+
a = argparse.Namespace(mcp_url="http://x/mcp", environment="e",
|
|
412
|
+
package="p")
|
|
413
|
+
err = urllib.error.HTTPError("http://x/mcp", code, "no", {}, None)
|
|
414
|
+
|
|
415
|
+
def boom(*_a, **_kw):
|
|
416
|
+
raise err
|
|
417
|
+
with mock.patch.object(rb, "mcp_call", boom):
|
|
418
|
+
return rb.retrieval_probe(a)
|
|
419
|
+
|
|
420
|
+
def test_401_and_403_are_auth(self):
|
|
421
|
+
for code in (401, 403):
|
|
422
|
+
with self.subTest(code=code):
|
|
423
|
+
with self.assertRaises(rb.AuthRequired) as e:
|
|
424
|
+
self.probe(code)
|
|
425
|
+
self.assertEqual(e.exception.code, code)
|
|
426
|
+
|
|
427
|
+
def test_another_http_error_is_left_alone(self):
|
|
428
|
+
# A 503 IS worth retrying, so it must not take the auth path.
|
|
429
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
430
|
+
self.probe(503)
|
|
431
|
+
|
|
432
|
+
def test_waiting_does_not_swallow_it(self):
|
|
433
|
+
# A bare `except Exception` in `wait_retrieval_ready` would catch it
|
|
434
|
+
# and record "probe failed", which reads as a warming index.
|
|
435
|
+
def denied(_a):
|
|
436
|
+
raise rb.AuthRequired(401, "http://x/mcp")
|
|
437
|
+
a = argparse.Namespace(mcp_url="http://x/mcp", environment="e",
|
|
438
|
+
package="p")
|
|
439
|
+
with mock.patch.object(rb, "retrieval_probe", denied), \
|
|
440
|
+
mock.patch.object(rb.time, "sleep", lambda _s: None):
|
|
441
|
+
with self.assertRaises(rb.AuthRequired):
|
|
442
|
+
rb.wait_retrieval_ready(a, tries=12, pause=0)
|
|
443
|
+
|
|
365
444
|
|
|
366
445
|
class ReadOnlyRoles(unittest.TestCase):
|
|
367
446
|
def test_diagnose_and_cluster_block_the_shell_too(self):
|
|
@@ -374,5 +453,62 @@ class ReadOnlyRoles(unittest.TestCase):
|
|
|
374
453
|
self.assertIn(tool, diagnose.READ_ONLY)
|
|
375
454
|
|
|
376
455
|
|
|
456
|
+
|
|
457
|
+
class TaskToolsAreBlocked(unittest.TestCase):
|
|
458
|
+
"""The newer task-management surface joins the blocklist.
|
|
459
|
+
|
|
460
|
+
`--allowedTools` grants permission without restricting availability, so the
|
|
461
|
+
denylist is the only lever. `Task` was on it; TaskCreate/Get/List/Update
|
|
462
|
+
were not, and alone flagged every attempt in a 37-case run.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def test_the_task_management_tools_are_always_blocked(self):
|
|
466
|
+
for name in ("Task", "TaskCreate", "TaskGet", "TaskList", "TaskUpdate",
|
|
467
|
+
"TaskOutput", "TaskStop"):
|
|
468
|
+
self.assertIn(name, ah.ALWAYS_BLOCKED, name)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class VerdictScanner(unittest.TestCase):
|
|
472
|
+
"""`last_json_object` is how a diagnosis reaches `diagnose.py`.
|
|
473
|
+
|
|
474
|
+
It counted braces by hand, which cannot see that a brace sits inside a
|
|
475
|
+
string or a comment. `eval-diagnose` asks its agent to READ MALLOY SOURCE
|
|
476
|
+
and reason over it before emitting the verdict last, so one stray `{` in a
|
|
477
|
+
quoted snippet left the depth counter above zero for the rest of the reply
|
|
478
|
+
and a perfectly good trailing verdict read as no object at all. The case
|
|
479
|
+
then came back `unparseable` and left the denominator. It routes through
|
|
480
|
+
`json_scan` now, the one scanner in this harness.
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
REPLY = (
|
|
484
|
+
'Looking at the model:\n\n```malloy\n'
|
|
485
|
+
'source: orders is duckdb.table(\'o\') extend {\n'
|
|
486
|
+
' # a brace in a comment: "{"\n'
|
|
487
|
+
' measure: total is sum(amount)\n'
|
|
488
|
+
'}\n```\n\n'
|
|
489
|
+
'Verdict:\n{"code": "NOT-RETURNED", "owner": "model"}\n')
|
|
490
|
+
|
|
491
|
+
def test_a_brace_in_a_quoted_snippet_does_not_eat_the_verdict(self):
|
|
492
|
+
self.assertEqual(ah.last_json_object(self.REPLY),
|
|
493
|
+
{"code": "NOT-RETURNED", "owner": "model"})
|
|
494
|
+
|
|
495
|
+
def test_it_takes_the_LAST_object_because_the_verdict_comes_last(self):
|
|
496
|
+
text = '{"draft": 1} thinking... {"final": 2}'
|
|
497
|
+
self.assertEqual(ah.last_json_object(text), {"final": 2})
|
|
498
|
+
|
|
499
|
+
def test_prose_with_no_object_is_none(self):
|
|
500
|
+
self.assertIsNone(ah.last_json_object("no verdict here {not json"))
|
|
501
|
+
|
|
502
|
+
def test_a_json_array_is_not_a_verdict(self):
|
|
503
|
+
self.assertIsNone(ah.last_json_object("[1, 2, 3]"))
|
|
504
|
+
|
|
505
|
+
def test_it_is_the_shared_scanner_not_a_second_copy(self):
|
|
506
|
+
# The drift this harness spent several commits removing. If a local
|
|
507
|
+
# brace-counter comes back, this is what says so.
|
|
508
|
+
import json_scan
|
|
509
|
+
self.assertEqual(ah.last_json_object(self.REPLY),
|
|
510
|
+
json_scan.json_objects(self.REPLY)[-1])
|
|
511
|
+
|
|
512
|
+
|
|
377
513
|
if __name__ == "__main__":
|
|
378
514
|
unittest.main(verbosity=1)
|
|
@@ -37,7 +37,7 @@ TEMPLATE = pathlib.Path(__file__).resolve().parent.parent / "templates" / "eval-
|
|
|
37
37
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
|
|
38
38
|
/ "eval-answer" / "scripts"))
|
|
39
39
|
from score_retrieval import delivery, groups, score_case # noqa: E402
|
|
40
|
-
from flip_table import outcome # noqa: E402 (same directory)
|
|
40
|
+
from flip_table import counts_toward_score, outcome # noqa: E402 (same directory)
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]:
|
|
@@ -364,6 +364,11 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
|
|
|
364
364
|
# uses, so the package cannot disagree with the
|
|
365
365
|
# flip table about what a flip is.
|
|
366
366
|
"outcome": outcome(s.get("verdict")),
|
|
367
|
+
# Whether this score belongs in the aggregates, by
|
|
368
|
+
# the same function run_baseline.py uses before it
|
|
369
|
+
# prints one. Written as a column so the measures
|
|
370
|
+
# read the rule rather than restating it.
|
|
371
|
+
"counts": counts_toward_score(s.get("gold_status")),
|
|
367
372
|
**s})
|
|
368
373
|
|
|
369
374
|
mine = tool_calls.get(kk, [])
|
|
@@ -474,7 +479,8 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
|
|
|
474
479
|
"answer_text", "n_get_context", "n_execute", "n_execute_errors",
|
|
475
480
|
"host_tool_uses", "mcp_tool_uses", "reported_calls", "contaminated",
|
|
476
481
|
"final_query_source", "servedRevision",
|
|
477
|
-
"input_tokens", "output_tokens", "cache_read_tokens", "
|
|
482
|
+
"input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens",
|
|
483
|
+
"cost_usd",
|
|
478
484
|
"num_turns", "wall_seconds", "run_error", "transcriptPath",
|
|
479
485
|
"n_steps", "prediction"])
|
|
480
486
|
write_csv(data / "steps.csv", steps_rows, [
|
|
@@ -484,7 +490,7 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
|
|
|
484
490
|
"attempt_key", "run_id", "qid", "entity_id", "status"])
|
|
485
491
|
write_csv(data / "scores.csv", scores, [
|
|
486
492
|
"attempt_key", "run_id", "qid", "sample", "phase", "verdict", "outcome",
|
|
487
|
-
"reason", "confidence",
|
|
493
|
+
"counts", "reason", "confidence",
|
|
488
494
|
"judge_version", "rubric_sha", "golden_revision", "gold_status",
|
|
489
495
|
"contaminated", "judge_verdict", "must_not_use_hits", "artifactPath"])
|
|
490
496
|
write_csv(data / "retrieval.csv", retr, [
|