@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.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +45 -5
  3. package/skills/eval-answer/reference/definition-ledger.md +305 -0
  4. package/skills/eval-answer/reference/ledger-schema.md +36 -7
  5. package/skills/eval-answer/scripts/check_coverage.py +12 -21
  6. package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
  7. package/skills/eval-answer/scripts/check_findable.py +386 -0
  8. package/skills/eval-answer/scripts/check_findable_test.py +326 -0
  9. package/skills/eval-answer/scripts/json_scan.py +43 -0
  10. package/skills/eval-answer/scripts/ledger.py +63 -7
  11. package/skills/eval-answer/scripts/ledger_test.py +7 -0
  12. package/skills/eval-answer/scripts/mcp_payload.py +111 -2
  13. package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
  14. package/skills/eval-answer/scripts/publisher_rest.py +10 -4
  15. package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
  16. package/skills/eval-answer/scripts/score_retrieval.py +326 -41
  17. package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
  18. package/skills/eval-answer/scripts/verify_definitions.py +752 -0
  19. package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
  20. package/skills/eval-answer/scripts/verify_goldens.py +336 -22
  21. package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
  22. package/skills/eval-diagnose/SKILL.md +47 -2
  23. package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
  24. package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
  25. package/skills/eval-diagnose/scripts/diagnose.py +471 -42
  26. package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
  27. package/skills/eval-import/SKILL.md +180 -1
  28. package/skills/eval-import/reference/case-format.md +1 -1
  29. package/skills/eval-improve/SKILL.md +1 -1
  30. package/skills/eval-improve/scripts/improve.py +29 -3
  31. package/skills/eval-improve/scripts/improve_test.py +51 -0
  32. package/skills/eval-judge/SKILL.md +1 -1
  33. package/skills/eval-loop/SKILL.md +112 -3
  34. package/skills/eval-loop/reference/measurement.md +44 -4
  35. package/skills/eval-loop/reference/running-a-run.md +5 -1
  36. package/skills/eval-loop/scripts/agent_harness.py +21 -20
  37. package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
  38. package/skills/eval-loop/scripts/build_run_package.py +9 -3
  39. package/skills/eval-loop/scripts/flip_table.py +107 -2
  40. package/skills/eval-loop/scripts/flip_table_test.py +165 -0
  41. package/skills/eval-loop/scripts/run_baseline.py +956 -97
  42. package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
  43. package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
  44. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
  45. package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
  46. package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
  47. package/skills/eval-report/SKILL.md +362 -0
  48. package/skills/malloy/SKILL.md +2 -1
  49. package/skills/malloy-analysis/SKILL.md +1 -1
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +13 -1
  51. package/skills/malloy-analysis-report/SKILL.md +1 -1
  52. package/skills/malloy-analyze/SKILL.md +1 -1
  53. package/skills/malloy-charts/SKILL.md +1 -1
  54. package/skills/malloy-dashboards/SKILL.md +1 -1
  55. package/skills/malloy-debug/SKILL.md +1 -1
  56. package/skills/malloy-define/SKILL.md +1 -1
  57. package/skills/malloy-discover/SKILL.md +1 -1
  58. package/skills/malloy-document/SKILL.md +1 -1
  59. package/skills/malloy-getting-started/SKILL.md +1 -1
  60. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  61. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  62. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  65. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  66. package/skills/malloy-lookml-review/SKILL.md +1 -1
  67. package/skills/malloy-materialization/SKILL.md +1 -1
  68. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  69. package/skills/malloy-model/SKILL.md +1 -1
  70. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  71. package/skills/malloy-modeling/SKILL.md +1 -1
  72. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  73. package/skills/malloy-notebooks/SKILL.md +1 -1
  74. package/skills/malloy-patterns/SKILL.md +1 -1
  75. package/skills/malloy-phrase-detection/SKILL.md +3 -2
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +1 -1
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. 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", a.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
- if a.limit:
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.20
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.20
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, or set the noise band | `reference/measurement.md` |
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 noise band, and targeted fixes. Read this before quoting any number. -->
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`. Every flip it reports is noise by construction, since
37
- nothing changed. Record the result with the set, in `CALIBRATION.md`, and cite
38
- that file when you quote a band.
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 balanced {...} in the text; agents narrate before the payload."""
374
- depth, start, best = 0, None, None
375
- for i, ch in enumerate(text):
376
- if ch == "{":
377
- if depth == 0:
378
- start = i
379
- depth += 1
380
- elif ch == "}":
381
- if depth == 0:
382
- continue
383
- depth -= 1
384
- if depth == 0 and start is not None:
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", "cost_usd",
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, [