@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
|
@@ -4,11 +4,14 @@ credited with, which retriever answered, what was actually re-executed, and
|
|
|
4
4
|
when an attempt is unjudgeable."""
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import pathlib
|
|
8
9
|
import shutil
|
|
10
|
+
import subprocess
|
|
9
11
|
import sys
|
|
10
12
|
import tempfile
|
|
11
13
|
import unittest
|
|
14
|
+
from unittest import mock
|
|
12
15
|
|
|
13
16
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
14
17
|
import run_baseline as rb # noqa: E402
|
|
@@ -260,6 +263,264 @@ class JudgeGate(unittest.TestCase):
|
|
|
260
263
|
self.assertEqual(v["reason"], "no_saved_verdict")
|
|
261
264
|
|
|
262
265
|
|
|
266
|
+
class VerdictObject(unittest.TestCase):
|
|
267
|
+
"""Finding the judge's verdict in a reply that holds other braces.
|
|
268
|
+
|
|
269
|
+
The old read spanned from the FIRST brace in the document to the last, so a
|
|
270
|
+
judge that quoted a Malloy snippet on its way to deciding handed
|
|
271
|
+
`json.loads` a blob starting mid-query. The case then counted in no bucket
|
|
272
|
+
at all and left the denominator the pass rate is printed over.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
GOOD = '{"why": "the figures agree", "verdict": "match", "confidence": 9}'
|
|
276
|
+
|
|
277
|
+
def test_a_quoted_malloy_query_does_not_break_the_read(self):
|
|
278
|
+
# The reproduction: a fenced query, then the verdict.
|
|
279
|
+
text = ("```malloy\nrun: orders -> { aggregate: n is count() }\n```\n"
|
|
280
|
+
f"Looks right.\n{self.GOOD}")
|
|
281
|
+
self.assertEqual(rb.parse_verdict(text)["verdict"], "match")
|
|
282
|
+
|
|
283
|
+
def test_the_last_verdict_object_wins(self):
|
|
284
|
+
# The judge is told to end with the object, and prose reasoning toward
|
|
285
|
+
# it may quote a draft on the way.
|
|
286
|
+
text = ('{"why": "draft", "verdict": "near_match", "confidence": 6}\n'
|
|
287
|
+
f"On reflection:\n{self.GOOD}")
|
|
288
|
+
v = rb.parse_verdict(text)
|
|
289
|
+
self.assertEqual(v["verdict"], "match")
|
|
290
|
+
self.assertEqual(v["reason"], "the figures agree")
|
|
291
|
+
|
|
292
|
+
def test_a_nested_object_still_parses(self):
|
|
293
|
+
text = ('{"why": "a", "verdict": "match", "confidence": 9, '
|
|
294
|
+
'"column_pairing": {"gold": "pred"}}')
|
|
295
|
+
self.assertEqual(rb.parse_verdict(text)["column_pairing"],
|
|
296
|
+
{"gold": "pred"})
|
|
297
|
+
|
|
298
|
+
def test_prose_around_the_object_is_fine(self):
|
|
299
|
+
self.assertEqual(
|
|
300
|
+
rb.parse_verdict(f"Here you go:\n{self.GOOD}\nHope that helps"
|
|
301
|
+
)["verdict"], "match")
|
|
302
|
+
|
|
303
|
+
def test_a_reply_with_no_object_is_unparseable(self):
|
|
304
|
+
# The shape five cases across three runs actually produced: the judge
|
|
305
|
+
# reasons to the point of deciding and the process ends.
|
|
306
|
+
v = rb.parse_verdict("I have what I need to decide.")
|
|
307
|
+
self.assertIsNone(v["verdict"])
|
|
308
|
+
self.assertEqual(v["reason"], "judge_unparseable")
|
|
309
|
+
|
|
310
|
+
def test_braces_without_a_verdict_are_not_a_verdict(self):
|
|
311
|
+
v = rb.parse_verdict('{"note": "no verdict here"}')
|
|
312
|
+
self.assertEqual(v["reason"], "judge_unparseable")
|
|
313
|
+
|
|
314
|
+
def test_the_retry_predicate_agrees_with_the_parser(self):
|
|
315
|
+
# `judge_unusable` reuses `parse_verdict` rather than re-deciding what
|
|
316
|
+
# parseable means, so a reply this now reads must not be re-rolled.
|
|
317
|
+
text = f"```malloy\nrun: x -> {{ aggregate: n is count() }}\n```\n{self.GOOD}"
|
|
318
|
+
self.assertFalse(rb.judge_unusable([{"type": "assistant"}], text))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class JudgePrompt(unittest.TestCase):
|
|
322
|
+
"""What the judge is SHOWN, and whether a run can say which version of it.
|
|
323
|
+
|
|
324
|
+
The tests cover the code paths; the defect that ran for three arms was in
|
|
325
|
+
the text the model reads. These are about that text.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def test_the_prompt_is_pinned_separately_from_the_skill(self):
|
|
329
|
+
# `rubricSha` hashes eval-judge/SKILL.md. The prompt template lives
|
|
330
|
+
# here and carries the golden rendering and the tie-breaking rules, so
|
|
331
|
+
# a run that names only the skill cannot say which judge it ran.
|
|
332
|
+
import ledger
|
|
333
|
+
self.assertIn("judgePromptSha", ledger.RUN_OPTIONAL)
|
|
334
|
+
|
|
335
|
+
def test_a_stale_rubric_figure_loses_to_the_golden(self):
|
|
336
|
+
# An answer holding the golden's own figures was failed against a
|
|
337
|
+
# rubric still quoting the previous morning's. Stated as a rule the
|
|
338
|
+
# judge applies at any precision, rather than a regex over prose.
|
|
339
|
+
self.assertIn("Where the CASE RUBRIC quotes a figure that the GOLDEN "
|
|
340
|
+
"contradicts", rb.JUDGE_PROMPT)
|
|
341
|
+
self.assertIn("the rubric's figure is stale prose", rb.JUDGE_PROMPT)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class JudgeCostAcrossARebuild(unittest.TestCase):
|
|
345
|
+
"""A rebuild spends nothing on judging and must not erase what judging cost.
|
|
346
|
+
|
|
347
|
+
Found by writing a report off a rebuilt run: its cost line read $0.00 judge
|
|
348
|
+
for verdicts the original arm had paid $0.78 for.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def test_a_run_that_judged_records_what_it_spent(self):
|
|
352
|
+
self.assertEqual(rb.carry_judge_cost(0.78, None), (0.78, False))
|
|
353
|
+
|
|
354
|
+
def test_a_rebuild_keeps_the_earlier_figure_and_marks_it(self):
|
|
355
|
+
self.assertEqual(rb.carry_judge_cost(0.0, 0.78), (0.78, True))
|
|
356
|
+
|
|
357
|
+
def test_a_rejudge_wins_over_the_earlier_figure(self):
|
|
358
|
+
# It really did judge again, so the new spend is this run's own.
|
|
359
|
+
self.assertEqual(rb.carry_judge_cost(0.91, 0.78), (0.91, False))
|
|
360
|
+
|
|
361
|
+
def test_a_first_run_with_nothing_before_it_records_zero(self):
|
|
362
|
+
self.assertEqual(rb.carry_judge_cost(0.0, None), (0.0, False))
|
|
363
|
+
|
|
364
|
+
def test_a_prior_of_zero_is_not_carried(self):
|
|
365
|
+
# --no-judge then rebuild: there is no earlier spend to protect, and
|
|
366
|
+
# marking it carried would claim a judging that never happened.
|
|
367
|
+
self.assertEqual(rb.carry_judge_cost(0.0, 0.0), (0.0, False))
|
|
368
|
+
|
|
369
|
+
def test_the_prior_is_read_before_run_json_is_rewritten(self):
|
|
370
|
+
# The whole defect: read after `run_config` writes, it is always None
|
|
371
|
+
# and the fix silently does nothing.
|
|
372
|
+
src = pathlib.Path(rb.__file__).read_text()
|
|
373
|
+
self.assertLess(src.index("prior_judge = prior_judge_cost(a.out)"),
|
|
374
|
+
src.index('(a.out / "run.json").write_text'))
|
|
375
|
+
|
|
376
|
+
def test_a_missing_or_unreadable_run_json_is_none(self):
|
|
377
|
+
tmp = pathlib.Path(tempfile.mkdtemp())
|
|
378
|
+
try:
|
|
379
|
+
self.assertIsNone(rb.prior_judge_cost(tmp))
|
|
380
|
+
(tmp / "run.json").write_text("{not json")
|
|
381
|
+
self.assertIsNone(rb.prior_judge_cost(tmp))
|
|
382
|
+
finally:
|
|
383
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class TruncatedAttempt(unittest.TestCase):
|
|
387
|
+
"""An attempt the turn cap cut off is not judged.
|
|
388
|
+
|
|
389
|
+
Measured on a 29-case customer arm: four attempts ended at exactly 31 turns
|
|
390
|
+
with `error_max_turns`, and each went to the judge as a complete answer.
|
|
391
|
+
One of them was a planning fragment -- "Let me build the correct monthly
|
|
392
|
+
trend query" -- which the judge scored `no_match` at confidence 9, where it
|
|
393
|
+
counted in the pass rate as if the model had got the question wrong.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def setUp(self):
|
|
397
|
+
self.tmp = pathlib.Path(tempfile.mkdtemp())
|
|
398
|
+
|
|
399
|
+
def tearDown(self):
|
|
400
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
401
|
+
|
|
402
|
+
def judge(self, att, *, rebuild=True, rejudge=False, golden=None):
|
|
403
|
+
a = argparse.Namespace(rebuild=rebuild, rejudge=rejudge)
|
|
404
|
+
return rb.run_judge(
|
|
405
|
+
{"qid": "q", "question": "?",
|
|
406
|
+
"golden": golden if golden is not None else {"status": "verified",
|
|
407
|
+
"value": 1}},
|
|
408
|
+
att, a, self.tmp, "", "", False)
|
|
409
|
+
|
|
410
|
+
def test_a_truncated_attempt_is_refused_and_says_so(self):
|
|
411
|
+
v = self.judge({"answer_text": "Let me build the correct query.",
|
|
412
|
+
"submitted": True, "error": "error_max_turns"})
|
|
413
|
+
self.assertIsNone(v["verdict"])
|
|
414
|
+
self.assertEqual(v["reason"], "answerer_truncated")
|
|
415
|
+
|
|
416
|
+
def test_it_is_decided_before_a_saved_verdict_is_reused(self):
|
|
417
|
+
# The point of putting it first: `--rebuild` over the runs that
|
|
418
|
+
# motivated this re-derives the right ledger from their transcripts,
|
|
419
|
+
# rather than restoring the verdict the truncated attempt was given.
|
|
420
|
+
(self.tmp / "q").mkdir(parents=True)
|
|
421
|
+
(self.tmp / "q" / "judge.md").write_text(
|
|
422
|
+
'{"why": "trails off", "verdict": "no_match", "confidence": 9}')
|
|
423
|
+
v = self.judge({"answer_text": "frag", "submitted": True,
|
|
424
|
+
"error": "error_max_turns"})
|
|
425
|
+
self.assertEqual(v["reason"], "answerer_truncated")
|
|
426
|
+
self.assertIsNone(v["verdict"])
|
|
427
|
+
|
|
428
|
+
def test_it_is_decided_before_the_golden_gate(self):
|
|
429
|
+
# Order matters the other way too: a truncated attempt against a
|
|
430
|
+
# provisional key is truncated. Both refuse, and the one that names
|
|
431
|
+
# the harness's own setting is the one a conductor can act on.
|
|
432
|
+
v = self.judge({"answer_text": "frag", "submitted": True,
|
|
433
|
+
"error": "error_max_turns"},
|
|
434
|
+
golden={"status": "provisional", "value": 1})
|
|
435
|
+
self.assertEqual(v["reason"], "answerer_truncated")
|
|
436
|
+
|
|
437
|
+
def test_any_other_harness_failure_is_also_refused(self):
|
|
438
|
+
# The four-strikes abort does NOT cover this: it stops the arm and
|
|
439
|
+
# still judges what it collected. Measured on a live run, four attempts
|
|
440
|
+
# whose text was the CLI's own "model not found" message were scored
|
|
441
|
+
# `no_match` for $0.29, and the run printed `passed 0 of 4 decided
|
|
442
|
+
# (0%)` about a model no answerer had reached.
|
|
443
|
+
v = self.judge({"answer_text": "Error: model not found",
|
|
444
|
+
"submitted": False, "error": "error_during_execution"})
|
|
445
|
+
self.assertIsNone(v["verdict"])
|
|
446
|
+
self.assertTrue(v["reason"].startswith("environment_failure"))
|
|
447
|
+
# The cap keeps its own word, because the remedy differs: re-run those
|
|
448
|
+
# cases at a higher cap, versus fix the environment and re-run the arm.
|
|
449
|
+
self.assertNotIn("truncated", v["reason"])
|
|
450
|
+
|
|
451
|
+
def test_the_cli_reporting_subtype_success_on_an_error_still_refuses(self):
|
|
452
|
+
# Observed exactly: `is_error` true with subtype "success", so the
|
|
453
|
+
# attempt carried `run_error: "success"`. Testing the VALUE would have
|
|
454
|
+
# let every one of those through; the test is that an error was
|
|
455
|
+
# reported at all.
|
|
456
|
+
v = self.judge({"answer_text": "Error: model not found",
|
|
457
|
+
"submitted": False, "error": "success"})
|
|
458
|
+
self.assertIsNone(v["verdict"])
|
|
459
|
+
self.assertTrue(v["reason"].startswith("environment_failure"))
|
|
460
|
+
|
|
461
|
+
def test_a_clean_attempt_is_unaffected(self):
|
|
462
|
+
v = self.judge({"answer_text": "4.2M", "submitted": True,
|
|
463
|
+
"error": None})
|
|
464
|
+
self.assertEqual(v["reason"], "no_saved_verdict")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class ContaminatedVerdict(unittest.TestCase):
|
|
468
|
+
"""A voided verdict says why, and keeps what the judge said.
|
|
469
|
+
|
|
470
|
+
Nulling in silence left a score event carrying a correct `reason` and
|
|
471
|
+
`confidence` beside `verdict: null`, which is indistinguishable from the
|
|
472
|
+
field being dropped on the write path -- and was read as exactly that in a
|
|
473
|
+
real run report, where nine "lost" verdicts were recovered by re-parsing
|
|
474
|
+
the stored judge replies. They had been voided on purpose.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
def void(self, verdict, breaches, *, vetoed=False):
|
|
478
|
+
"""The real nulling, not a copy of it: `main` calls this same
|
|
479
|
+
function, so a change there cannot pass these tests by drifting."""
|
|
480
|
+
v = dict(verdict)
|
|
481
|
+
if vetoed:
|
|
482
|
+
# What the mustNotUse veto does just above the call site.
|
|
483
|
+
v["judge_verdict"] = v["verdict"]
|
|
484
|
+
v["verdict"] = "no_match"
|
|
485
|
+
return rb.void_contaminated(v, breaches)
|
|
486
|
+
|
|
487
|
+
def test_a_voided_verdict_names_contamination(self):
|
|
488
|
+
v = self.void({"verdict": "match", "reason": "the figures agree",
|
|
489
|
+
"confidence": 9},
|
|
490
|
+
["host tool available to the answerer: Bash"])
|
|
491
|
+
self.assertIsNone(v["verdict"])
|
|
492
|
+
self.assertEqual(v["reason"], "contaminated")
|
|
493
|
+
# The schema documents this reason; before, the judge's own prose sat
|
|
494
|
+
# here and the null looked like a bug.
|
|
495
|
+
self.assertEqual(v["judge_verdict"], "match")
|
|
496
|
+
|
|
497
|
+
def test_a_veto_that_is_also_contaminated_keeps_the_judges_read(self):
|
|
498
|
+
# Two overrides on one case. `judge_verdict` is for what the JUDGE
|
|
499
|
+
# said, so the veto's own `no_match` must not displace it.
|
|
500
|
+
v = self.void({"verdict": "match", "reason": "ok", "confidence": 8},
|
|
501
|
+
["mcp server 'malloy' failed"], vetoed=True)
|
|
502
|
+
self.assertIsNone(v["verdict"])
|
|
503
|
+
self.assertEqual(v["judge_verdict"], "match")
|
|
504
|
+
|
|
505
|
+
def test_a_clean_attempt_keeps_its_verdict_and_reason(self):
|
|
506
|
+
v = self.void({"verdict": "match", "reason": "the figures agree",
|
|
507
|
+
"confidence": 9}, [])
|
|
508
|
+
self.assertEqual(v["verdict"], "match")
|
|
509
|
+
self.assertEqual(v["reason"], "the figures agree")
|
|
510
|
+
|
|
511
|
+
def test_every_null_verdict_reason_is_in_the_documented_set(self):
|
|
512
|
+
# The generalisation of the report's own ask ("a score event with a
|
|
513
|
+
# reason also has a verdict, or an explicit unreadable marker"): a null
|
|
514
|
+
# verdict must always name which of the known causes it was, so no
|
|
515
|
+
# future nulling can be silent the way contamination was.
|
|
516
|
+
schema = (pathlib.Path(rb.__file__).parent.parent.parent
|
|
517
|
+
/ "eval-answer" / "reference" / "ledger-schema.md").read_text()
|
|
518
|
+
for reason in ("not_submitted", "contaminated", "answerer_truncated",
|
|
519
|
+
"judge_unparseable", "no_saved_verdict"):
|
|
520
|
+
with self.subTest(reason):
|
|
521
|
+
self.assertIn(reason, schema)
|
|
522
|
+
|
|
523
|
+
|
|
263
524
|
class GoldenStatusGate(unittest.TestCase):
|
|
264
525
|
"""A verdict is refused against a key nobody has established.
|
|
265
526
|
|
|
@@ -320,10 +581,14 @@ class GoldenStatusGate(unittest.TestCase):
|
|
|
320
581
|
# `criteria` holds no value by design; its clauses are the comparison.
|
|
321
582
|
# Refusing a value-less golden would drop every such case, and every
|
|
322
583
|
# deliberately-unanswerable case with it.
|
|
584
|
+
# The clauses live in `rubric`, which is the documented field
|
|
585
|
+
# (reference/case-format.md); this fixture said `criteria` and nothing
|
|
586
|
+
# read it, so the drift was invisible until a renderer had to find them.
|
|
323
587
|
v = self.judge({"status": "verified", "kind": "criteria",
|
|
324
|
-
"
|
|
588
|
+
"rubric": "breaks the total out by region"})
|
|
325
589
|
self.assertEqual(v["reason"], "no_saved_verdict")
|
|
326
590
|
|
|
591
|
+
|
|
327
592
|
def test_refusal_is_decided_before_a_saved_verdict_is_reused(self):
|
|
328
593
|
# `--rebuild` without `--rejudge` returns the saved judge.md. A saved
|
|
329
594
|
# verdict against an unestablished key is not evidence either.
|
|
@@ -341,6 +606,242 @@ class GoldenStatusGate(unittest.TestCase):
|
|
|
341
606
|
("provisional", "invalid", "ambiguous"))
|
|
342
607
|
|
|
343
608
|
|
|
609
|
+
class GoldenForJudge(unittest.TestCase):
|
|
610
|
+
"""What the GOLDEN line of the judge prompt says, per kind.
|
|
611
|
+
|
|
612
|
+
A `criteria` golden rendered as "unanswerable" is a contradiction the judge
|
|
613
|
+
can see, and one called it rather than scoring the case.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
def test_criteria_never_says_unanswerable(self):
|
|
617
|
+
line = rb.golden_for_judge(
|
|
618
|
+
{"kind": "criteria", "rubric": "names the channels"})
|
|
619
|
+
self.assertNotIn("unanswerable", line)
|
|
620
|
+
self.assertIn("CASE RUBRIC", line)
|
|
621
|
+
|
|
622
|
+
def test_unanswerable_says_so(self):
|
|
623
|
+
self.assertIn("unanswerable",
|
|
624
|
+
rb.golden_for_judge({"kind": "unanswerable"}))
|
|
625
|
+
|
|
626
|
+
def test_a_scalar_renders_its_value(self):
|
|
627
|
+
self.assertEqual(
|
|
628
|
+
rb.golden_for_judge({"kind": "scalar", "value": {"total": 12.5}}),
|
|
629
|
+
'{"total": 12.5}')
|
|
630
|
+
|
|
631
|
+
def test_rows_render_the_list(self):
|
|
632
|
+
self.assertEqual(
|
|
633
|
+
rb.golden_for_judge({"kind": "rows", "value": [{"a": 1}]}),
|
|
634
|
+
'[{"a": 1}]')
|
|
635
|
+
|
|
636
|
+
def test_an_explicit_null_value_is_a_refusal_case(self):
|
|
637
|
+
# How the ecommerce set's four refusal cases say there is no number.
|
|
638
|
+
self.assertIn("unanswerable",
|
|
639
|
+
rb.golden_for_judge({"kind": "scalar", "value": None}))
|
|
640
|
+
|
|
641
|
+
def test_a_golden_with_no_kind_is_unchanged(self):
|
|
642
|
+
# Sets predating the field must read exactly as they did.
|
|
643
|
+
self.assertEqual(rb.golden_for_judge({"value": 7}), "7")
|
|
644
|
+
self.assertIn("unanswerable", rb.golden_for_judge({}))
|
|
645
|
+
self.assertIn("unanswerable", rb.golden_for_judge(None))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class AnUnreadableVerdictIsNotLost(unittest.TestCase):
|
|
649
|
+
"""A judge reply that will not parse carries no verdict, so it counts in
|
|
650
|
+
none of match, no_match, near_match or needs_human. It therefore left the
|
|
651
|
+
denominator without appearing anywhere, and the pass rate was printed over
|
|
652
|
+
the remainder. Two cases went that way in one hosted run."""
|
|
653
|
+
|
|
654
|
+
GOOD = '{"why":"y","verdict":"match","confidence":9}'
|
|
655
|
+
|
|
656
|
+
def test_the_retry_predicate_reaches_run_cli(self):
|
|
657
|
+
# `claude` accepted `retry_when` and then passed `no_events` literally,
|
|
658
|
+
# so the judge's predicate never arrived and the judge never retried.
|
|
659
|
+
with mock.patch.object(rb, "run_cli",
|
|
660
|
+
return_value=([], "", "", 1, 0.0)) as run:
|
|
661
|
+
rb.claude("q", str(pathlib.Path(__file__).parent), "sonnet",
|
|
662
|
+
mcp=None, retry_when=rb.judge_unusable)
|
|
663
|
+
self.assertIs(run.call_args.kwargs["retry_when"], rb.judge_unusable)
|
|
664
|
+
|
|
665
|
+
def test_the_answerer_still_defaults_to_no_events(self):
|
|
666
|
+
# Re-rolling a bad ANSWER would put a second sample where the run
|
|
667
|
+
# records one. Only the default was ever meant to be the answerer's.
|
|
668
|
+
with mock.patch.object(rb, "run_cli",
|
|
669
|
+
return_value=([], "", "", 1, 0.0)) as run:
|
|
670
|
+
rb.claude("q", str(pathlib.Path(__file__).parent), "sonnet",
|
|
671
|
+
mcp=None)
|
|
672
|
+
self.assertIs(run.call_args.kwargs["retry_when"], rb.no_events)
|
|
673
|
+
|
|
674
|
+
def test_unparseable_prose_is_retried(self):
|
|
675
|
+
self.assertTrue(rb.judge_unusable([{"type": "assistant"}],
|
|
676
|
+
"The answer looks right to me."))
|
|
677
|
+
|
|
678
|
+
def test_no_text_at_all_is_retried(self):
|
|
679
|
+
self.assertTrue(rb.judge_unusable([], ""))
|
|
680
|
+
|
|
681
|
+
def test_a_good_verdict_is_not_retried(self):
|
|
682
|
+
self.assertFalse(rb.judge_unusable([{"type": "assistant"}], self.GOOD))
|
|
683
|
+
|
|
684
|
+
def test_a_verdict_wrapped_in_prose_is_not_retried(self):
|
|
685
|
+
# `parse_verdict` finds the object inside prose, so this is readable
|
|
686
|
+
# and re-rolling it would just cost a judge call.
|
|
687
|
+
self.assertFalse(rb.judge_unusable(
|
|
688
|
+
[{"type": "assistant"}], f"Here you go:\n{self.GOOD}\nHope that helps"))
|
|
689
|
+
|
|
690
|
+
def summary(self, **kw):
|
|
691
|
+
base = dict(out=pathlib.Path("r"), set_dir=pathlib.Path("s"),
|
|
692
|
+
events_n=0, attempted=10, decided=8, passed=4, near=0,
|
|
693
|
+
human=0, doubted=[], vetoed=[], alt_path=0, unscorable=0,
|
|
694
|
+
retrieval_mode="semantic",
|
|
695
|
+
tally={"semantic": 1, "lexical": 0, "unreported": 0},
|
|
696
|
+
rs={}, answerer_cost=0.0, judge_cost=0.0, publisher="",
|
|
697
|
+
environment="e")
|
|
698
|
+
return "\n".join(rb.summary_lines(**{**base, **kw}))
|
|
699
|
+
|
|
700
|
+
def test_the_summary_names_the_cases_it_could_not_read(self):
|
|
701
|
+
body = self.summary(unparseable=["cq-07", "cq-02"])
|
|
702
|
+
self.assertIn("unreadable 2", body)
|
|
703
|
+
self.assertIn("cq-02, cq-07", body) # sorted, so a diff is stable
|
|
704
|
+
|
|
705
|
+
def test_a_clean_run_still_says_zero(self):
|
|
706
|
+
# Printed even at zero, for the reason `unscorable` is: a reader must
|
|
707
|
+
# not have to know the line exists to notice it is missing.
|
|
708
|
+
body = self.summary()
|
|
709
|
+
self.assertIn("unreadable 0", body)
|
|
710
|
+
|
|
711
|
+
def test_an_aborted_arm_suppresses_the_pass_rate(self):
|
|
712
|
+
# The four-strikes abort fires, `run.json` says `aborted`, and the
|
|
713
|
+
# headline still printed a percentage. Live: `passed 0 of 4 decided
|
|
714
|
+
# (0%)` for an arm whose answerer never started.
|
|
715
|
+
body = self.summary(aborted=True)
|
|
716
|
+
self.assertIn("INCOMPLETE: the arm ABORTED", body)
|
|
717
|
+
self.assertNotIn("(50%)", body)
|
|
718
|
+
|
|
719
|
+
def test_a_truncated_case_suppresses_the_pass_rate(self):
|
|
720
|
+
# The stop rule, made mechanical. A directive did not hold against an
|
|
721
|
+
# arm that had already been paid for: on the run this comes from, the
|
|
722
|
+
# truncation was known before the cases were judged and the rate was
|
|
723
|
+
# quoted anyway.
|
|
724
|
+
body = self.summary(truncated=["cq-01", "cq-05"], max_turns=30)
|
|
725
|
+
self.assertIn("INCOMPLETE: 2 truncated", body)
|
|
726
|
+
self.assertIn("no pass rate until re-run", body)
|
|
727
|
+
self.assertNotIn("(50%)", body) # 4 of 8 would have printed this
|
|
728
|
+
|
|
729
|
+
def test_it_names_the_command_that_finishes_the_arm(self):
|
|
730
|
+
# Four cases, not a whole arm: the answers already paid for are kept
|
|
731
|
+
# and only the truncated ones are re-run, at twice the cap.
|
|
732
|
+
body = self.summary(truncated=["cq-05", "cq-01"], max_turns=30)
|
|
733
|
+
self.assertIn("--only cq-01,cq-05", body)
|
|
734
|
+
self.assertIn("--max-turns 60", body)
|
|
735
|
+
|
|
736
|
+
def test_a_contaminated_case_suppresses_it_too(self):
|
|
737
|
+
body = self.summary(contaminated=["cq-03"])
|
|
738
|
+
self.assertIn("INCOMPLETE: 1 contaminated", body)
|
|
739
|
+
self.assertNotIn("(50%)", body)
|
|
740
|
+
|
|
741
|
+
def test_both_are_named_when_both_happened(self):
|
|
742
|
+
body = self.summary(truncated=["cq-01"], contaminated=["cq-03"])
|
|
743
|
+
self.assertIn("INCOMPLETE: 1 truncated, 1 contaminated", body)
|
|
744
|
+
|
|
745
|
+
def test_contamination_reasons_are_counted(self):
|
|
746
|
+
# One breach across every case is a harness misconfiguration to fix
|
|
747
|
+
# once; one breach on one case is that answerer going somewhere it
|
|
748
|
+
# should not have. The count is what tells them apart.
|
|
749
|
+
body = self.summary(
|
|
750
|
+
contaminated=["cq-03", "cq-04"],
|
|
751
|
+
contamination_reasons={"host tool available to the answerer: Bash": 2})
|
|
752
|
+
self.assertIn("2x host tool available to the answerer: Bash", body)
|
|
753
|
+
|
|
754
|
+
def test_a_complete_run_still_prints_its_rate(self):
|
|
755
|
+
# The suppression is narrow: neither `unscorable` (a dataset state) nor
|
|
756
|
+
# `unreadable` (which has a retry) withholds the number.
|
|
757
|
+
body = self.summary(unscorable=3, unparseable=["cq-02"])
|
|
758
|
+
self.assertIn("(50%)", body)
|
|
759
|
+
self.assertNotIn("INCOMPLETE", body)
|
|
760
|
+
|
|
761
|
+
def test_it_is_not_folded_into_unscorable(self):
|
|
762
|
+
# `unscorable` is a DATASET state no answerer can change. This is the
|
|
763
|
+
# harness failing to read its own judge. Counting them together would
|
|
764
|
+
# send someone to fix answer keys that are fine.
|
|
765
|
+
body = self.summary(unscorable=3, unparseable=["cq-02"])
|
|
766
|
+
self.assertIn("unscorable 3", body)
|
|
767
|
+
self.assertIn("unreadable 1", body)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class HostedProbe(unittest.TestCase):
|
|
771
|
+
"""What the hosted reachability probe concludes from its transcript.
|
|
772
|
+
|
|
773
|
+
Three outcomes that are three different problems: no tool granted (a login
|
|
774
|
+
that was never done), a tool that answered with an error (reachable and
|
|
775
|
+
authenticated, wrong call), and a payload.
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
TOOLS = ("mcp__hosted__get_context",)
|
|
779
|
+
|
|
780
|
+
def events(self, *blocks):
|
|
781
|
+
out = []
|
|
782
|
+
for kind, content in blocks:
|
|
783
|
+
out.append({"type": kind, "message": {"content": content}})
|
|
784
|
+
return out
|
|
785
|
+
|
|
786
|
+
def use(self, tid="t1", name="mcp__hosted__get_context"):
|
|
787
|
+
return {"type": "tool_use", "id": tid, "name": name, "input": {}}
|
|
788
|
+
|
|
789
|
+
def res(self, text, tid="t1", err=False):
|
|
790
|
+
return {"type": "tool_result", "tool_use_id": tid,
|
|
791
|
+
"content": text, "is_error": err}
|
|
792
|
+
|
|
793
|
+
def test_no_tool_call_at_all_is_not_granted(self):
|
|
794
|
+
ev = self.events(("assistant", [{"type": "text",
|
|
795
|
+
"text": "I have no such tool."}]))
|
|
796
|
+
self.assertEqual(rb.probe_outcome(ev, self.TOOLS)[0], "not_granted")
|
|
797
|
+
|
|
798
|
+
def test_an_errored_result_is_rejected_not_reached(self):
|
|
799
|
+
# The defect this replaces: a bare tool_use returned True without ever
|
|
800
|
+
# looking at its result, so a refused call passed the gate.
|
|
801
|
+
ev = self.events(("assistant", [self.use()]),
|
|
802
|
+
("user", [self.res("search_targets is required",
|
|
803
|
+
err=True)]))
|
|
804
|
+
outcome, said, payload = rb.probe_outcome(ev, self.TOOLS)
|
|
805
|
+
self.assertEqual(outcome, "rejected")
|
|
806
|
+
self.assertIn("search_targets is required", said)
|
|
807
|
+
self.assertIsNone(payload)
|
|
808
|
+
|
|
809
|
+
def test_a_payload_is_reached_and_comes_back(self):
|
|
810
|
+
ev = self.events(("assistant", [self.use()]),
|
|
811
|
+
("user", [self.res(json.dumps(
|
|
812
|
+
{"retrieval": "semantic", "sources": []}))]))
|
|
813
|
+
outcome, _said, payload = rb.probe_outcome(ev, self.TOOLS)
|
|
814
|
+
self.assertEqual(outcome, "reached")
|
|
815
|
+
self.assertEqual(payload["retrieval"], "semantic")
|
|
816
|
+
|
|
817
|
+
def test_a_good_call_outweighs_an_earlier_refused_one(self):
|
|
818
|
+
ev = self.events(
|
|
819
|
+
("assistant", [self.use("t1")]),
|
|
820
|
+
("user", [self.res("nope", "t1", err=True)]),
|
|
821
|
+
("assistant", [self.use("t2")]),
|
|
822
|
+
("user", [self.res(json.dumps({"retrieval": "lexical"}), "t2")]))
|
|
823
|
+
self.assertEqual(rb.probe_outcome(ev, self.TOOLS)[0], "reached")
|
|
824
|
+
|
|
825
|
+
def test_a_call_with_no_result_is_not_a_missing_login(self):
|
|
826
|
+
ev = self.events(("assistant", [self.use()]))
|
|
827
|
+
self.assertEqual(rb.probe_outcome(ev, self.TOOLS)[0], "rejected")
|
|
828
|
+
|
|
829
|
+
def test_a_call_to_an_unallowed_tool_is_ignored(self):
|
|
830
|
+
ev = self.events(("assistant", [self.use(name="Bash")]),
|
|
831
|
+
("user", [self.res("ok")]))
|
|
832
|
+
self.assertEqual(rb.probe_outcome(ev, self.TOOLS)[0], "not_granted")
|
|
833
|
+
|
|
834
|
+
def test_the_probe_asks_for_the_arguments_the_tool_requires(self):
|
|
835
|
+
# A get_context with no arguments is a validation error on any server
|
|
836
|
+
# that enforces its required parameters, which is what the probe used
|
|
837
|
+
# to ask for.
|
|
838
|
+
a = argparse.Namespace(environment="examples", package="storefront")
|
|
839
|
+
args = rb.probe_arguments(a)
|
|
840
|
+
self.assertTrue(args["search_targets"])
|
|
841
|
+
self.assertEqual(args["scopes"],
|
|
842
|
+
[{"environment": "examples", "package": "storefront"}])
|
|
843
|
+
|
|
844
|
+
|
|
344
845
|
class UnscorablePreflight(unittest.TestCase):
|
|
345
846
|
"""A set of bare questions is a supported set, not a set to refuse.
|
|
346
847
|
|
|
@@ -532,6 +1033,123 @@ class PlatformMcpUrl(unittest.TestCase):
|
|
|
532
1033
|
|
|
533
1034
|
|
|
534
1035
|
|
|
1036
|
+
class RetrievalPrecisionIsReportedHonestly(unittest.TestCase):
|
|
1037
|
+
"""Precision was computed and thrown away; the run printed recall alone.
|
|
1038
|
+
|
|
1039
|
+
It is worth printing, but it reads everything outside `acceptable` as
|
|
1040
|
+
noise, so a set that never authored one scores every legitimate extra as a
|
|
1041
|
+
miss. Measured on a real run: 3% to 11%, on a set whose eight cases all had
|
|
1042
|
+
an empty `acceptable`. The caveat ships with the number.
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
def rs(self, **over):
|
|
1046
|
+
base = {"retrieval_scored": 8, "mean_recall": 0.938,
|
|
1047
|
+
"complete_retrievals": 7, "mean_precision": 0.073,
|
|
1048
|
+
"mean_returned": 25.6, "mean_required": 1.8,
|
|
1049
|
+
"cases_with_acceptable": 0, "failures_by_where_to_fix": {}}
|
|
1050
|
+
base.update(over)
|
|
1051
|
+
return base
|
|
1052
|
+
|
|
1053
|
+
def test_precision_prints_with_its_caveat_when_nothing_authored_acceptable(self):
|
|
1054
|
+
text = "\n".join(self.lines(rs=self.rs()))
|
|
1055
|
+
self.assertIn("entity precision mean 7.3%", text)
|
|
1056
|
+
self.assertIn("no case authored `acceptable`", text)
|
|
1057
|
+
self.assertIn("breadth, not as a verdict", text)
|
|
1058
|
+
|
|
1059
|
+
def test_breadth_does_not_depend_on_authoring(self):
|
|
1060
|
+
text = "\n".join(self.lines(rs=self.rs()))
|
|
1061
|
+
self.assertIn("26 returned per attempt for 2 the answer named", text)
|
|
1062
|
+
|
|
1063
|
+
def test_a_fully_authored_set_gets_the_number_without_the_caveat(self):
|
|
1064
|
+
text = "\n".join(self.lines(rs=self.rs(cases_with_acceptable=8)))
|
|
1065
|
+
self.assertIn("entity precision mean 7.3%", text)
|
|
1066
|
+
self.assertNotIn("no case authored", text)
|
|
1067
|
+
self.assertNotIn("only 8 of 8", text)
|
|
1068
|
+
|
|
1069
|
+
def test_a_partly_authored_set_says_it_is_uneven(self):
|
|
1070
|
+
text = "\n".join(self.lines(rs=self.rs(cases_with_acceptable=3)))
|
|
1071
|
+
self.assertIn("only 3 of 8", text)
|
|
1072
|
+
|
|
1073
|
+
def lines(self, **over):
|
|
1074
|
+
return RunSummary.lines(self, **over)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
class SkillsActuallyOpened(unittest.TestCase):
|
|
1078
|
+
"""A run names the skills it granted; only the ones opened shaped anything.
|
|
1079
|
+
|
|
1080
|
+
Measured on a real run: every attempt invoked zero of its 11 skills, so an
|
|
1081
|
+
edit to one could not have changed the answers and nothing in the report
|
|
1082
|
+
said so.
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
def test_zero_says_the_run_does_not_measure_the_skills(self):
|
|
1086
|
+
lines = rb.skill_lines({"attempts": 8, "with_skill": 0, "skills": []})
|
|
1087
|
+
text = "\n".join(lines)
|
|
1088
|
+
self.assertIn("0 of 8", text)
|
|
1089
|
+
self.assertIn("cannot be credited or blamed", text)
|
|
1090
|
+
|
|
1091
|
+
def test_some_usage_names_the_skills_and_does_not_warn(self):
|
|
1092
|
+
lines = rb.skill_lines({"attempts": 8, "with_skill": 3,
|
|
1093
|
+
"skills": ["malloy-phrase-detection"]})
|
|
1094
|
+
text = "\n".join(lines)
|
|
1095
|
+
self.assertIn("3 of 8", text)
|
|
1096
|
+
self.assertIn("malloy-phrase-detection", text)
|
|
1097
|
+
self.assertNotIn("cannot be credited", text)
|
|
1098
|
+
|
|
1099
|
+
def test_no_attempts_prints_nothing(self):
|
|
1100
|
+
self.assertEqual(rb.skill_lines({"attempts": 0, "with_skill": 0}), [])
|
|
1101
|
+
self.assertEqual(rb.skill_lines(None), [])
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
class NoLocalShadowsAnImportedModule(unittest.TestCase):
|
|
1105
|
+
"""A local named after an imported module breaks every call to that module
|
|
1106
|
+
in the same function, and only at runtime.
|
|
1107
|
+
|
|
1108
|
+
`ledger = verify_definitions.load_ledger(...)` in main() made `ledger` a
|
|
1109
|
+
local for the whole function, so `ledger.run_config(...)` 260 lines earlier
|
|
1110
|
+
raised UnboundLocalError on EVERY run. The unit tests all call helpers, so
|
|
1111
|
+
nothing noticed until a real arm was run end to end. This is the cheap
|
|
1112
|
+
structural guard that would have.
|
|
1113
|
+
"""
|
|
1114
|
+
|
|
1115
|
+
def test_no_function_rebinds_a_module_this_file_imports(self):
|
|
1116
|
+
import ast
|
|
1117
|
+
src = pathlib.Path(rb.__file__).read_text()
|
|
1118
|
+
tree = ast.parse(src)
|
|
1119
|
+
modules = {n.names[0].asname or n.names[0].name.split(".")[0]
|
|
1120
|
+
for n in ast.walk(tree) if isinstance(n, ast.Import)}
|
|
1121
|
+
bad = []
|
|
1122
|
+
for fn in ast.walk(tree):
|
|
1123
|
+
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1124
|
+
continue
|
|
1125
|
+
for node in ast.walk(fn):
|
|
1126
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) \
|
|
1127
|
+
and node.id in modules:
|
|
1128
|
+
bad.append(f"{fn.name}() rebinds the module name "
|
|
1129
|
+
f"{node.id!r} at line {node.lineno}")
|
|
1130
|
+
self.assertEqual(bad, [], "; ".join(bad))
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
class UsageFields(unittest.TestCase):
|
|
1134
|
+
"""The ledger must be able to reprice a run from its own token columns."""
|
|
1135
|
+
|
|
1136
|
+
def test_all_four_token_counts_are_captured(self):
|
|
1137
|
+
u = {"input_tokens": 224, "output_tokens": 31507,
|
|
1138
|
+
"cache_read_input_tokens": 2435273,
|
|
1139
|
+
"cache_creation_input_tokens": 900000}
|
|
1140
|
+
self.assertEqual(rb.usage_fields(u), {
|
|
1141
|
+
"input_tokens": 224, "output_tokens": 31507,
|
|
1142
|
+
"cache_read_tokens": 2435273, "cache_write_tokens": 900000})
|
|
1143
|
+
|
|
1144
|
+
def test_cache_writes_were_the_missing_column(self):
|
|
1145
|
+
# The one the ledger never held. On one analysed run it was 44% of the
|
|
1146
|
+
# agent's cost; cost_usd carried it and the breakdown could not.
|
|
1147
|
+
self.assertIn("cache_write_tokens", rb.usage_fields({}))
|
|
1148
|
+
|
|
1149
|
+
def test_absent_usage_is_all_null_not_a_crash(self):
|
|
1150
|
+
self.assertEqual(set(rb.usage_fields(None).values()), {None})
|
|
1151
|
+
|
|
1152
|
+
|
|
535
1153
|
class RunSummary(unittest.TestCase):
|
|
536
1154
|
"""The end-of-run report is three layers, and the order is the point.
|
|
537
1155
|
|
|
@@ -559,6 +1177,53 @@ class RunSummary(unittest.TestCase):
|
|
|
559
1177
|
def index_of(self, lines, needle):
|
|
560
1178
|
return next(i for i, l in enumerate(lines) if needle in l)
|
|
561
1179
|
|
|
1180
|
+
def test_a_consumed_coverage_report_is_named_not_pointed_at(self):
|
|
1181
|
+
# With a report given, "not measured here" is false and must not print.
|
|
1182
|
+
lines = self.lines(coverage_report={
|
|
1183
|
+
"path": "cov.json", "version": "0.0.58", "agentModel": "sonnet",
|
|
1184
|
+
"decided": 45, "cases": 49})
|
|
1185
|
+
text = "\n".join(lines)
|
|
1186
|
+
self.assertIn("cov.json", text)
|
|
1187
|
+
self.assertIn("45 of 49", text)
|
|
1188
|
+
self.assertNotIn("not measured here", text)
|
|
1189
|
+
# 4 undecided: the report did not settle every case, and the reader
|
|
1190
|
+
# has to know which rows fell back to what.
|
|
1191
|
+
self.assertIn("undecided cases fell back", text)
|
|
1192
|
+
|
|
1193
|
+
def test_a_report_that_decided_everything_carries_no_fallback_warning(self):
|
|
1194
|
+
lines = self.lines(coverage_report={
|
|
1195
|
+
"path": "cov.json", "version": "1", "agentModel": "m",
|
|
1196
|
+
"decided": 49, "cases": 49})
|
|
1197
|
+
self.assertNotIn("undecided cases fell back", "\n".join(lines))
|
|
1198
|
+
|
|
1199
|
+
def test_no_report_keeps_the_pointer(self):
|
|
1200
|
+
self.assertIn("not measured here", "\n".join(self.lines()))
|
|
1201
|
+
|
|
1202
|
+
def test_the_cascade_reads_as_a_funnel_with_owners(self):
|
|
1203
|
+
lines = self.lines(cascade={
|
|
1204
|
+
"total": 49, "not covered": 6, "unmeasured": 2,
|
|
1205
|
+
"no entities named": 0, "not retrieved": 5,
|
|
1206
|
+
"delivered, wrong": 6, "delivered, right": 30, "not scored": 0})
|
|
1207
|
+
text = "\n".join(lines)
|
|
1208
|
+
self.assertIn("cascade 49 cases", text)
|
|
1209
|
+
self.assertIn("covered? 41 yes, 6 no (model gap), 2 unmeasured", text)
|
|
1210
|
+
# The rung must not assert the docs: a miss is the docs OR the search
|
|
1211
|
+
# wording, and only diagnose separates them. This assertion is here
|
|
1212
|
+
# because the label was renamed in score_retrieval and the display line
|
|
1213
|
+
# in this file was missed, so the two disagreed in a shipped commit.
|
|
1214
|
+
self.assertIn("retrieved? 36 yes, 5 no (the entity exists and did "
|
|
1215
|
+
"not come back", text)
|
|
1216
|
+
self.assertNotIn("(documentation", text)
|
|
1217
|
+
# And a delivered-but-wrong answer names no owner until diagnose runs.
|
|
1218
|
+
self.assertIn("correct? 30 yes, 6 no (delivered, wrong", text)
|
|
1219
|
+
self.assertIn("diagnose decides", text)
|
|
1220
|
+
# It heads the COVERAGE & RETRIEVAL layer, above the retrieval-mode line.
|
|
1221
|
+
self.assertLess(self.index_of(lines, "cascade"),
|
|
1222
|
+
self.index_of(lines, " retrieval "))
|
|
1223
|
+
|
|
1224
|
+
def test_no_cascade_prints_nothing(self):
|
|
1225
|
+
self.assertNotIn("cascade", "\n".join(self.lines()))
|
|
1226
|
+
|
|
562
1227
|
def test_the_three_layers_appear_in_order(self):
|
|
563
1228
|
lines = self.lines()
|
|
564
1229
|
self.assertLess(self.index_of(lines, "RESULTS"),
|
|
@@ -729,5 +1394,138 @@ class RebuildCaseList(unittest.TestCase):
|
|
|
729
1394
|
finally:
|
|
730
1395
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
731
1396
|
|
|
1397
|
+
|
|
1398
|
+
class ContaminatedAttemptsLeaveTheAggregates(unittest.TestCase):
|
|
1399
|
+
"""A flagged attempt must not reach the printed score.
|
|
1400
|
+
|
|
1401
|
+
The ledger has nulled a contaminated verdict since 2026-09-01, but the
|
|
1402
|
+
nulling landed on the copy bound for events.jsonl while the summary read
|
|
1403
|
+
the judge's original. A 33-case run in which EVERY attempt was flagged
|
|
1404
|
+
printed `12 of 21 decided (57%)` over a ledger whose every score event said
|
|
1405
|
+
`verdict: null`. That is the contamination check working and the line a
|
|
1406
|
+
human reads disagreeing with it.
|
|
1407
|
+
"""
|
|
1408
|
+
|
|
1409
|
+
def test_a_flagged_attempt_is_not_counted_as_a_pass(self):
|
|
1410
|
+
verdicts = {"q1": {"verdict": "match"}, "q2": {"verdict": "match"}}
|
|
1411
|
+
attempts = {"q1": {"breaches": ["host tool available: TaskCreate"]},
|
|
1412
|
+
"q2": {"breaches": []}}
|
|
1413
|
+
for qid, v in verdicts.items():
|
|
1414
|
+
if attempts[qid].get("breaches"):
|
|
1415
|
+
v["verdict"] = None
|
|
1416
|
+
decided = [v for v in verdicts.values()
|
|
1417
|
+
if v.get("verdict") in ("match", "no_match")]
|
|
1418
|
+
self.assertEqual(len(decided), 1, "the flagged attempt still counted")
|
|
1419
|
+
self.assertIsNone(verdicts["q1"]["verdict"])
|
|
1420
|
+
self.assertEqual(verdicts["q2"]["verdict"], "match")
|
|
1421
|
+
|
|
1422
|
+
def test_a_fully_contaminated_run_decides_nothing(self):
|
|
1423
|
+
verdicts = {f"q{i}": {"verdict": "match"} for i in range(21)}
|
|
1424
|
+
attempts = {q: {"breaches": ["host tool available: TaskCreate"]}
|
|
1425
|
+
for q in verdicts}
|
|
1426
|
+
for qid, v in verdicts.items():
|
|
1427
|
+
if attempts[qid].get("breaches"):
|
|
1428
|
+
v["verdict"] = None
|
|
1429
|
+
decided = [v for v in verdicts.values()
|
|
1430
|
+
if v.get("verdict") in ("match", "no_match")]
|
|
1431
|
+
self.assertEqual(len(decided), 0,
|
|
1432
|
+
"a run with no clean attempt reported a score")
|
|
1433
|
+
|
|
1434
|
+
class GitSha(unittest.TestCase):
|
|
1435
|
+
"""The pin has to mark a dirty model repo, and it did not.
|
|
1436
|
+
|
|
1437
|
+
The path is used BOTH as git's working directory (`-C`) and as its
|
|
1438
|
+
pathspec. A relative one means different things in those two positions:
|
|
1439
|
+
git runs IN the directory and then resolves the pathspec against it, so
|
|
1440
|
+
`--model-repo sub` looks for `sub/sub`, matches nothing, and every tree
|
|
1441
|
+
reads clean. The sibling in `ledger.skills_git_sha` was fixed for exactly
|
|
1442
|
+
this; `git_sha` was not, and had no test at all.
|
|
1443
|
+
"""
|
|
1444
|
+
|
|
1445
|
+
def setUp(self):
|
|
1446
|
+
self.tmp = tempfile.mkdtemp()
|
|
1447
|
+
self.repo = pathlib.Path(self.tmp) / "repo"
|
|
1448
|
+
(self.repo / "sub").mkdir(parents=True)
|
|
1449
|
+
(self.repo / "sub" / "model.malloy").write_text("source: a is x\n")
|
|
1450
|
+
run = lambda *a: subprocess.run(a, cwd=self.repo, capture_output=True)
|
|
1451
|
+
run("git", "init", "-q")
|
|
1452
|
+
run("git", "config", "user.email", "t@t")
|
|
1453
|
+
run("git", "config", "user.name", "t")
|
|
1454
|
+
run("git", "add", "-A")
|
|
1455
|
+
run("git", "commit", "-qm", "init")
|
|
1456
|
+
self.cwd = os.getcwd()
|
|
1457
|
+
os.chdir(self.repo)
|
|
1458
|
+
|
|
1459
|
+
def tearDown(self):
|
|
1460
|
+
os.chdir(self.cwd)
|
|
1461
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
1462
|
+
|
|
1463
|
+
def dirty(self):
|
|
1464
|
+
(self.repo / "sub" / "model.malloy").write_text("source: a is CHANGED\n")
|
|
1465
|
+
|
|
1466
|
+
def test_a_clean_tree_has_no_marker(self):
|
|
1467
|
+
self.assertFalse(
|
|
1468
|
+
rb.git_sha(pathlib.Path("sub"), scope=pathlib.Path("sub"))
|
|
1469
|
+
.endswith("-dirty"))
|
|
1470
|
+
|
|
1471
|
+
def test_a_relative_path_still_marks_dirt(self):
|
|
1472
|
+
self.dirty()
|
|
1473
|
+
self.assertTrue(
|
|
1474
|
+
rb.git_sha(pathlib.Path("sub"), scope=pathlib.Path("sub"))
|
|
1475
|
+
.endswith("-dirty"),
|
|
1476
|
+
"a relative --model-repo silently reported a dirty model as clean")
|
|
1477
|
+
|
|
1478
|
+
def test_an_absolute_path_agrees_with_the_relative_one(self):
|
|
1479
|
+
self.dirty()
|
|
1480
|
+
rel = rb.git_sha(pathlib.Path("sub"), scope=pathlib.Path("sub"))
|
|
1481
|
+
abs_ = rb.git_sha(self.repo / "sub", scope=self.repo / "sub")
|
|
1482
|
+
self.assertEqual(rel, abs_, "the pin depends on how it was spelled")
|
|
1483
|
+
|
|
1484
|
+
def test_the_scope_still_narrows(self):
|
|
1485
|
+
# Dirt OUTSIDE the scope is not the model's, which is the whole reason
|
|
1486
|
+
# the scope exists. This must not regress into "any dirt anywhere".
|
|
1487
|
+
(self.repo / "scratch.txt").write_text("unrelated")
|
|
1488
|
+
self.assertFalse(
|
|
1489
|
+
rb.git_sha(pathlib.Path("sub"), scope=pathlib.Path("sub"))
|
|
1490
|
+
.endswith("-dirty"))
|
|
1491
|
+
|
|
1492
|
+
def test_not_a_repo_is_none(self):
|
|
1493
|
+
self.assertIsNone(rb.git_sha(pathlib.Path(self.tmp)))
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
class GoldenCheckScope(unittest.TestCase):
|
|
1497
|
+
"""The pre-arm audit covers the cases the arm RUNS, not the whole file.
|
|
1498
|
+
|
|
1499
|
+
A set is built incrementally, so one un-derivable golden elsewhere in
|
|
1500
|
+
cases.jsonl blocked an arm over a subset that did not include it. The only
|
|
1501
|
+
way past was --skip-golden-check, which switches the audit off for the
|
|
1502
|
+
cases that would have passed and stamps "skipped" into run.json.
|
|
1503
|
+
"""
|
|
1504
|
+
|
|
1505
|
+
SRC = (pathlib.Path(__file__).resolve().parent / "run_baseline.py").read_text()
|
|
1506
|
+
|
|
1507
|
+
def test_the_call_passes_the_selected_qids(self):
|
|
1508
|
+
# A source pin, like test_kinds_by_target_matches_the_server: the call
|
|
1509
|
+
# sits inside main() between a server probe and an arm, so the cheap
|
|
1510
|
+
# way to stop it silently losing the argument again is to read it.
|
|
1511
|
+
call = self.SRC.split("verify_goldens.verify(")[1][:400]
|
|
1512
|
+
self.assertIn("qids=checked_qids", call,
|
|
1513
|
+
"the golden check audits the whole set again")
|
|
1514
|
+
|
|
1515
|
+
def test_the_qids_come_from_the_narrowed_case_list(self):
|
|
1516
|
+
self.assertIn('checked_qids = {c["qid"] for c in cases}', self.SRC)
|
|
1517
|
+
|
|
1518
|
+
def test_verify_actually_filters_on_qids(self):
|
|
1519
|
+
# The other half: the argument has to mean something.
|
|
1520
|
+
import inspect
|
|
1521
|
+
import sys as _sys
|
|
1522
|
+
_sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
|
|
1523
|
+
/ "eval-answer" / "scripts"))
|
|
1524
|
+
import verify_goldens
|
|
1525
|
+
src = inspect.getsource(verify_goldens.verify)
|
|
1526
|
+
self.assertIn("qids", src)
|
|
1527
|
+
self.assertIn('c["qid"] in qids', src)
|
|
1528
|
+
|
|
1529
|
+
|
|
732
1530
|
if __name__ == "__main__":
|
|
733
1531
|
unittest.main()
|