@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
@@ -211,5 +211,426 @@ class OffVocabularyValuesDoNotLoseTheRun(unittest.TestCase):
211
211
  improve_py)
212
212
 
213
213
 
214
+ class BehaviourStats(unittest.TestCase):
215
+ """The measurements a behavioural root cause gets stated in.
216
+
217
+ Diagnosis reads failures only, so a behaviour common to the whole run looks
218
+ causal from inside it. On one measured run the largest cluster was built on
219
+ "substitutes broad enumeration for targeted retrieval", and the enumeration
220
+ rate was 25% of targets in the failures against 26% in the passes.
221
+
222
+ THE FIXTURES HERE ARE THE SHAPES THE HARNESS ACTUALLY WRITES. An earlier
223
+ version of this class invented a `{"target_type", "search_text"}` dict that
224
+ no writer emits, and the code passed those tests while reading
225
+ `targetsWithoutSearchText: 0` on every real attempt -- because `targets`
226
+ holds `"<type>: <text>"` STRINGS and a target with no text is dropped
227
+ before the ledger sees it.
228
+ """
229
+
230
+ def events(self, qid, tool_call, *, verdict="match", turns=9):
231
+ return [
232
+ {"kind": "attempt", "qid": qid, "n_get_context": 2, "n_execute": 5,
233
+ "n_execute_errors": 1, "num_turns": turns,
234
+ "skills_invoked": ["malloy-analysis"]},
235
+ {"kind": "score", "qid": qid, "verdict": verdict},
236
+ {"kind": "tool_call", "qid": qid, "tool": "get_context",
237
+ **tool_call},
238
+ {"kind": "tool_call", "qid": qid, "tool": "execute_query"},
239
+ ]
240
+
241
+ def shapes(self, *pairs):
242
+ return {"target_shapes": [{"type": t, "has_text": h} for t, h in pairs]}
243
+
244
+ def test_a_bare_target_is_counted_from_target_shapes(self):
245
+ s = diagnose.behaviour_stats("q1", self.events("q1", self.shapes(
246
+ ("dimension", False), ("measure", True), ("view", False))))
247
+ self.assertEqual(s["searchTargets"], 3)
248
+ self.assertEqual(s["targetsWithoutSearchText"], 2)
249
+ self.assertTrue(s["targetsMeasured"])
250
+
251
+ def test_it_records_which_target_types_were_asked_for(self):
252
+ # A type-classification error is the most common agent-call defect, and
253
+ # a falsifier that cannot reach it is not much of a falsifier -- which
254
+ # a clustering agent said, in those terms, about the first version.
255
+ s = diagnose.behaviour_stats("q1", self.events("q1", self.shapes(
256
+ ("measure", True), ("dimension", True), ("dimension", False))))
257
+ self.assertEqual(s["targetTypes"], {"measure": 1, "dimension": 2})
258
+
259
+ def test_a_legacy_run_reads_types_but_abstains_on_the_bare_count(self):
260
+ # Runs written before `target_shapes` existed. The types survive in the
261
+ # `"<type>: <text>"` strings; the bare count does not, and reporting it
262
+ # as 0 would be a falsifier quietly asserting the opposite of the truth.
263
+ s = diagnose.behaviour_stats("q1", self.events(
264
+ "q1", {"targets": ["measure: total revenue", "source: flights"]}))
265
+ self.assertEqual(s["targetTypes"], {"measure": 1, "source": 1})
266
+ self.assertIsNone(s["targetsWithoutSearchText"])
267
+ self.assertFalse(s["targetsMeasured"])
268
+
269
+ def test_the_two_shapes_do_not_get_mixed(self):
270
+ # If both are present the measured one wins; the legacy strings are a
271
+ # lossy view of the same call.
272
+ s = diagnose.behaviour_stats("q1", self.events("q1", {
273
+ "targets": ["measure: total revenue"],
274
+ **self.shapes(("measure", True), ("dimension", False))}))
275
+ self.assertEqual(s["searchTargets"], 2)
276
+ self.assertEqual(s["targetsWithoutSearchText"], 1)
277
+
278
+ def test_it_carries_the_verdict_so_the_groups_are_comparable(self):
279
+ s = diagnose.behaviour_stats("q1", self.events("q1", self.shapes(),
280
+ verdict="match"))
281
+ self.assertEqual(s["verdict"], "match")
282
+ self.assertEqual(s["skillsInvoked"], ["malloy-analysis"])
283
+ self.assertEqual(s["numTurns"], 9)
284
+
285
+ def test_only_this_case_is_measured(self):
286
+ ev = self.events("q1", self.shapes(("dimension", True))) + \
287
+ self.events("q2", self.shapes(("a", True), ("b", True)))
288
+ self.assertEqual(
289
+ diagnose.behaviour_stats("q1", ev)["searchTargets"], 1)
290
+
291
+ def test_an_attempt_with_no_calls_is_still_a_row(self):
292
+ s = diagnose.behaviour_stats("q9", [])
293
+ self.assertEqual(s["qid"], "q9")
294
+ self.assertEqual(s["searchTargets"], 0)
295
+
296
+ def test_the_clustering_prompt_asks_for_the_falsification(self):
297
+ self.assertIn("{controls}", diagnose.CLUSTER_PROMPT)
298
+
299
+
300
+ class ControlsBlock(unittest.TestCase):
301
+ """The CONTROLS section sizes itself, because the instruction that is right
302
+ for twenty passing cases is wrong for one and meaningless for none."""
303
+
304
+ def block(self, n):
305
+ return diagnose.controls_block(
306
+ [{"qid": f"q{i}", "verdict": "match"} for i in range(n)])
307
+
308
+ def test_a_run_where_everything_failed_says_there_is_nothing_to_compare(self):
309
+ # 10 of 10 failing is a real shape, and asking the agent to compare
310
+ # rates against an empty list invites it to read [] as evidence.
311
+ b = self.block(0)
312
+ self.assertIn("CONTROLS: none", b)
313
+ self.assertIn("unfalsified", b)
314
+ self.assertIn("Do NOT read the absence of controls", b)
315
+ # A model-fact cluster is not affected by having no controls, and the
316
+ # block has to say so or every cluster gets downgraded.
317
+ self.assertIn("unaffected", b)
318
+
319
+ def test_one_or_two_controls_is_not_a_rate(self):
320
+ for n in (1, 2):
321
+ with self.subTest(n=n):
322
+ b = self.block(n)
323
+ self.assertIn("is NOT a rate", b)
324
+ self.assertIn("Do not compute a percentage", b)
325
+
326
+ def test_three_or_more_gets_the_real_comparison(self):
327
+ b = self.block(3)
328
+ self.assertIn("similar rate in the passes", b)
329
+ self.assertNotIn("is NOT a rate", b)
330
+
331
+ def test_the_prompt_renders_at_every_size(self):
332
+ # The failure this guards: an unrendered {controls} shipping to the
333
+ # agent, or a KeyError at the one moment the run cannot be redone.
334
+ for n in (0, 1, 2, 3, 20):
335
+ with self.subTest(n=n):
336
+ out = diagnose.CLUSTER_PROMPT.format(
337
+ issues="[]", controls=self.block(n))
338
+ self.assertNotIn("{controls}", out)
339
+ self.assertNotIn("{issues}", out)
340
+
341
+ def test_every_size_carries_the_rows_it_has(self):
342
+ self.assertIn('"qid": "q0"', self.block(1))
343
+ self.assertNotIn('"qid"', self.block(0))
344
+
345
+
346
+ class RetrievalMissOnAPassingCase(unittest.TestCase):
347
+ """A correct answer that never received a required entity is a finding.
348
+
349
+ It was silently dropped in three places: `attribute()` short-circuited on
350
+ `passed`, `summarise()` dropped the empty label, and `diagnose.py` routed
351
+ `match` to a control group. Measured on one run, 3 of 4 findings sat on
352
+ passing cases and produced nothing.
353
+ """
354
+
355
+ def case(self, qid="q", required=("measure:m:total",)):
356
+ return {"qid": qid, "coverage": "covered",
357
+ "expectedEntities": {"required": list(required)}}
358
+
359
+ def events(self, qid, returned, targets):
360
+ return [
361
+ {"kind": "attempt", "qid": qid, "sample": None, "phase": "baseline"},
362
+ {"kind": "score", "qid": qid, "sample": None, "phase": "baseline",
363
+ "verdict": "match", "reason": "ok"},
364
+ {"kind": "tool_call", "qid": qid, "sample": None, "phase": "baseline",
365
+ "tool": "get_context",
366
+ "rankedSummary": {"entityIds": list(returned)},
367
+ "target_shapes": [{"type": t, "has_text": True} for t in targets]},
368
+ ]
369
+
370
+ def test_a_pass_that_missed_an_entity_yields_a_finding(self):
371
+ ev = self.events("q", [], ["source", "dimension"])
372
+ row = diagnose.retrieval_finding(self.case(), ev,
373
+ ("q", None, "baseline"), "match")
374
+ self.assertIsNotNone(row)
375
+ # Deterministic: it needed a measure and sent no measure target.
376
+ self.assertIn("never asked", row["where_to_fix"])
377
+ # And the answer is still not a failure.
378
+ self.assertFalse(row["failed"])
379
+
380
+ def test_a_pass_that_received_everything_yields_nothing(self):
381
+ ev = self.events("q", ["measure:m:total"], ["measure"])
382
+ self.assertIsNone(diagnose.retrieval_finding(
383
+ self.case(), ev, ("q", None, "baseline"), "match"))
384
+
385
+ def test_it_calls_the_real_scorer_rather_than_restating_the_rule(self):
386
+ # Two definitions of "a miss" would drift, and the run summary and the
387
+ # diagnosis would then disagree about what happened.
388
+ src = pathlib.Path(diagnose.__file__).read_text()
389
+ self.assertIn("score_retrieval.score_case", src)
390
+
391
+ def test_the_prompt_says_the_answer_was_correct(self):
392
+ # Without it the diagnoser reads the evidence as a wrong answer and
393
+ # goes looking for a number that is not wrong.
394
+ src = pathlib.Path(diagnose.__file__).read_text()
395
+ self.assertIn("THIS ANSWER WAS CORRECT", src)
396
+ self.assertIn("answered_correctly", src)
397
+
398
+ def test_there_is_an_opt_out(self):
399
+ src = pathlib.Path(diagnose.__file__).read_text()
400
+ self.assertIn("--no-retrieval-misses", src)
401
+
402
+ def test_the_coverage_denominator_excludes_them(self):
403
+ # They are passes. Counting a diagnosed pass against a non-passing
404
+ # denominator printed "2 of 1 non-passing case(s) diagnosed (200%)".
405
+ src = pathlib.Path(diagnose.__file__).read_text()
406
+ self.assertIn("good_failures", src)
407
+
408
+
409
+ class SelectingWhatToDiagnose(unittest.TestCase):
410
+ """Every scored case lands in exactly one bucket, and narrowing what gets
411
+ diagnosed does not change what the run failed."""
412
+
413
+ def cases(self, n, split=None):
414
+ return {f"q{i}": {"qid": f"q{i}", "coverage": "covered",
415
+ **({"split": split} if split else {})}
416
+ for i in range(n)}
417
+
418
+ def scores(self, n, verdict="no_match"):
419
+ return [{"kind": "score", "qid": f"q{i}", "sample": None,
420
+ "phase": "baseline", "verdict": verdict, "reason": "r"}
421
+ for i in range(n)]
422
+
423
+ def select(self, events, cases, **kw):
424
+ """(failed, passed, retrieval_only, excluded) -- the four buckets most
425
+ of these assert on. `excluded_passes` has its own tests below."""
426
+ return diagnose.select_cases(events, cases, ("no_match",), **kw)[:4]
427
+
428
+ def test_limit_records_what_it_dropped_as_an_exclusion(self):
429
+ # Truncating `failed` in place moved numerator and denominator
430
+ # together: 23 failures with --limit 5 printed "5 of 5 (100%)".
431
+ failed, _, _, excluded = self.select(
432
+ self.scores(23), self.cases(23), limit=5)
433
+ self.assertEqual(len(failed), 5)
434
+ not_passing = len(failed) + sum(len(v) for v in excluded.values())
435
+ self.assertEqual(not_passing, 23)
436
+ self.assertEqual(len(excluded["beyond --limit 5"]), 18)
437
+
438
+ def test_limit_zero_selects_nothing_rather_than_everything(self):
439
+ """`if limit:` read 0 as "no limit" and diagnosed all 23, one billable
440
+ agent each -- failing OPEN on the one axis where that costs money.
441
+ 0 means zero; unlimited is spelled by omitting the flag."""
442
+ failed, _, _, excluded = self.select(
443
+ self.scores(23), self.cases(23), limit=0)
444
+ self.assertEqual(failed, [])
445
+ self.assertEqual(len(excluded["beyond --limit 0"]), 23)
446
+
447
+ def test_omitting_the_limit_still_means_unlimited(self):
448
+ failed, _, _, excluded = self.select(self.scores(23), self.cases(23))
449
+ self.assertEqual(len(failed), 23)
450
+ self.assertEqual(excluded, {})
451
+
452
+ def test_a_holdout_PASS_is_not_a_non_passing_case(self):
453
+ """The invariant, for the branch that still broke it.
454
+
455
+ The holdout test sits above the verdict checks on purpose, so the
456
+ split cannot depend on the score. That sent a holdout case that
457
+ PASSED into `excluded`, which `not_passing` sums: a real run with one
458
+ failure and four holdout passes printed "1 of 5 non-passing case(s)
459
+ diagnosed (20%)" when it had diagnosed the only failure there was.
460
+ """
461
+ events = self.scores(1, verdict="no_match") + [
462
+ {"kind": "score", "qid": f"h{i}", "sample": None,
463
+ "phase": "baseline", "verdict": "match", "reason": "r"}
464
+ for i in range(4)]
465
+ cases = dict(self.cases(1))
466
+ for i in range(4):
467
+ cases[f"h{i}"] = {"qid": f"h{i}", "split": "holdout"}
468
+ failed, _, _, excluded, excluded_passes = diagnose.select_cases(
469
+ events, cases, ("no_match",))
470
+ self.assertEqual(failed, ["q0"])
471
+ not_passing = len(failed) + sum(len(v) for v in excluded.values())
472
+ self.assertEqual(not_passing, 1, "four holdout PASSES inflated it to 5")
473
+ self.assertEqual(
474
+ len(excluded_passes["holdout, withheld from diagnosis"]), 4)
475
+
476
+ def test_a_holdout_FAILURE_is_still_a_non_passing_case(self):
477
+ events = [{"kind": "score", "qid": "h0", "sample": None,
478
+ "phase": "baseline", "verdict": "no_match", "reason": "r"}]
479
+ cases = {"h0": {"qid": "h0", "split": "holdout"}}
480
+ failed, _, _, excluded, excluded_passes = diagnose.select_cases(
481
+ events, cases, ("no_match",))
482
+ self.assertEqual(failed, [])
483
+ self.assertEqual(excluded_passes, {})
484
+ self.assertEqual(
485
+ len(excluded["holdout, withheld from diagnosis"]), 1,
486
+ "a holdout failure is still withheld, and still non-passing")
487
+
488
+ def test_only_records_what_it_dropped_as_an_exclusion(self):
489
+ failed, _, _, excluded = self.select(
490
+ self.scores(4), self.cases(4), only="q0,q1")
491
+ self.assertEqual(sorted(failed), ["q0", "q1"])
492
+ self.assertEqual(
493
+ len(failed) + sum(len(v) for v in excluded.values()), 4)
494
+
495
+ def test_an_unnarrowed_run_excludes_nothing_for_narrowing(self):
496
+ failed, _, _, excluded = self.select(self.scores(3), self.cases(3))
497
+ self.assertEqual(len(failed), 3)
498
+ self.assertEqual(excluded, {})
499
+
500
+ def test_a_passing_holdout_with_a_retrieval_miss_stays_withheld(self):
501
+ """The leak: `verdict == "match"` was tested BEFORE the split, so a
502
+ holdout case that answered correctly with an incomplete retrieval went
503
+ into `retrieval_only` and on to a diagnosis call on every run, with no
504
+ flag able to prevent it. Holdout exists so improve has something
505
+ diagnosis never saw."""
506
+ cases = {"q0": {"qid": "q0", "coverage": "covered", "split": "holdout",
507
+ "expectedEntities": {"required": ["measure:m:total"]}}}
508
+ events = [
509
+ {"kind": "score", "qid": "q0", "sample": None, "phase": "baseline",
510
+ "verdict": "match", "reason": "ok"},
511
+ {"kind": "tool_call", "qid": "q0", "sample": None,
512
+ "phase": "baseline", "tool": "get_context",
513
+ "rankedSummary": {"entityIds": []},
514
+ "target_shapes": [{"type": "dimension", "has_text": True}]},
515
+ ]
516
+ (failed, passed, retrieval_only, excluded,
517
+ excluded_passes) = diagnose.select_cases(events, cases, ("no_match",))
518
+ self.assertEqual(retrieval_only, [])
519
+ self.assertEqual(passed, [])
520
+ self.assertEqual(failed, [])
521
+ # Withheld, which is what this test is about -- and in the PASSES
522
+ # bucket, because it matched and `not_passing` sums the other one.
523
+ self.assertIn("q0",
524
+ excluded_passes["holdout, withheld from diagnosis"])
525
+ self.assertEqual(excluded, {})
526
+
527
+ def test_include_holdout_lets_that_same_case_through(self):
528
+ cases = {"q0": {"qid": "q0", "coverage": "covered", "split": "holdout",
529
+ "expectedEntities": {"required": ["measure:m:total"]}}}
530
+ events = [
531
+ {"kind": "score", "qid": "q0", "sample": None, "phase": "baseline",
532
+ "verdict": "match", "reason": "ok"},
533
+ {"kind": "tool_call", "qid": "q0", "sample": None,
534
+ "phase": "baseline", "tool": "get_context",
535
+ "rankedSummary": {"entityIds": []},
536
+ "target_shapes": [{"type": "dimension", "has_text": True}]},
537
+ ]
538
+ _, passed, retrieval_only, _ = self.select(
539
+ events, cases, include_holdout=True)
540
+ self.assertEqual(passed, ["q0"])
541
+ self.assertEqual(retrieval_only, ["q0"])
542
+
543
+ def passing_with_a_miss(self):
544
+ """Three cases that all PASSED, two of them on an incomplete
545
+ retrieval."""
546
+ cases, ev = {}, []
547
+ for q in ("q1", "q2", "q3"):
548
+ cases[q] = {"qid": q, "coverage": "covered",
549
+ "expectedEntities": {"required": ["measure:m:total"]}}
550
+ got = [] if q in ("q1", "q2") else ["measure:m:total"]
551
+ ev += [{"kind": "score", "qid": q, "sample": None,
552
+ "phase": "baseline", "verdict": "match", "reason": "ok"},
553
+ {"kind": "tool_call", "qid": q, "sample": None,
554
+ "phase": "baseline", "tool": "get_context",
555
+ "rankedSummary": {"entityIds": got},
556
+ "target_shapes": [{"type": "measure", "has_text": True}]}]
557
+ return cases, ev
558
+
559
+ def test_a_pass_kept_out_of_diagnosis_is_not_a_non_passing_case(self):
560
+ """`--no-retrieval-misses` used to push these into `excluded`, which
561
+ `not_passing` sums, so a run where every case passed printed
562
+ "coverage: 0 of 2 non-passing case(s) diagnosed (0%)"."""
563
+ cases, ev = self.passing_with_a_miss()
564
+ f, p, r, excluded, excluded_passes = diagnose.select_cases(
565
+ ev, cases, ("no_match",), no_retrieval_misses=True)
566
+ self.assertEqual(f, [])
567
+ self.assertEqual(sorted(p), ["q1", "q2", "q3"])
568
+ self.assertEqual(excluded, {})
569
+ not_passing = len(f) + sum(len(v) for v in excluded.values())
570
+ self.assertEqual(not_passing, 0)
571
+ # Still reported, just not as a failure.
572
+ self.assertEqual(
573
+ excluded_passes["passed with a retrieval miss "
574
+ "(--no-retrieval-misses)"], ["q1", "q2"])
575
+
576
+ def test_without_the_flag_those_passes_are_diagnosed(self):
577
+ cases, ev = self.passing_with_a_miss()
578
+ f, p, r, excluded, excluded_passes = diagnose.select_cases(
579
+ ev, cases, ("no_match",))
580
+ self.assertEqual(sorted(r), ["q1", "q2"])
581
+ self.assertEqual(excluded_passes, {})
582
+ self.assertEqual(len(f) + sum(len(v) for v in excluded.values()), 0)
583
+
584
+ def test_contamination_still_outranks_the_split(self):
585
+ cases = self.cases(1, split="holdout")
586
+ events = [{"kind": "score", "qid": "q0", "sample": None,
587
+ "phase": "baseline", "verdict": "no_match", "reason": "r",
588
+ "contaminated": True}]
589
+ _, _, _, excluded = self.select(events, cases)
590
+ self.assertIn("contaminated", excluded)
591
+ self.assertNotIn("holdout, withheld from diagnosis", excluded)
592
+
593
+ class MatchedPair(unittest.TestCase):
594
+ """A case that passed in another arm is the richest evidence in the run,
595
+ and it was the one thing the diagnoser never saw.
596
+
597
+ diagnose.py reads one run. A flip lives across two, so a case that passed
598
+ once and failed once was diagnosed from the failing side alone -- with the
599
+ matched pair, same question and same model, sitting unread in the other
600
+ run directory.
601
+ """
602
+
603
+ def evidence(self, passed_elsewhere=None):
604
+ events = [
605
+ {"kind": "attempt", "qid": "q1",
606
+ "final_query": "run: wrong -> { x }", "answer_text": "2 rows"},
607
+ {"kind": "score", "qid": "q1", "verdict": "no_match",
608
+ "reason": "expected 443"},
609
+ ]
610
+ return diagnose.evidence_for("q1", {"question": "how many?"}, events,
611
+ passed_elsewhere)
612
+
613
+ def test_absent_by_default(self):
614
+ self.assertIsNone(self.evidence()["passedInAnotherArm"])
615
+
616
+ def test_the_other_arms_query_reaches_the_agent(self):
617
+ e = self.evidence({"arm": "aa-2", "verdict": "match",
618
+ "finalQuery": "run: right -> { y }"})
619
+ self.assertEqual(e["passedInAnotherArm"]["finalQuery"],
620
+ "run: right -> { y }")
621
+ self.assertEqual(e["passedInAnotherArm"]["arm"], "aa-2")
622
+
623
+ def test_this_arms_query_is_still_there_to_diff_against(self):
624
+ e = self.evidence({"arm": "aa-2", "finalQuery": "run: right -> { y }"})
625
+ self.assertIn("run: wrong -> { x }", e["queriesRun"])
626
+
627
+ def test_the_prompt_tells_the_agent_what_to_do_with_it(self):
628
+ self.assertIn("passedInAnotherArm", diagnose.DIAGNOSE_PROMPT)
629
+ self.assertIn("matched pair", diagnose.DIAGNOSE_PROMPT)
630
+
631
+ def test_the_prompt_says_a_flip_is_not_noise(self):
632
+ # The doctrine change, pinned: a flip is a model-quality finding.
633
+ self.assertIn("not noise", diagnose.DIAGNOSE_PROMPT)
634
+
214
635
  if __name__ == "__main__":
215
636
  unittest.main()
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.20
4
+ version: 0.1.21
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -95,6 +95,134 @@ back until somebody derives keys. A set of bare questions already measures
95
95
  whether the model can express an answer at all, and the answers it produces
96
96
  are what the keys get derived from.
97
97
 
98
+ ### Deriving a key yourself, when nothing arrived with the question
99
+
100
+ The table above is about what ARRIVED. Sometimes nothing did, nobody knows the
101
+ right number, and the set still needs keys. You may derive one, and the rules
102
+ are the ones already stated rather than new ones:
103
+
104
+ - Use your full model access and query until you can defend the number. The
105
+ agent that gets tested later holds only `get_context` and `execute_query`;
106
+ you are not that agent, and the gap is the point.
107
+ - Record the query as `canonicalQuery`. A number with no query is somebody's
108
+ guess, which is the row above it in the table.
109
+ - Record the entities you used to get there as `expectedEntities.required`, in
110
+ the `kind:source:name` form: the measures, dimensions and views the answer
111
+ cannot be produced without. `reference/case-format.md` says to leave this
112
+ field empty at import because guessing it invents a retrieval expectation
113
+ nobody stated; you are not guessing, you just used them. That is the only
114
+ trustworthy producer this field has, and it is what makes retrieval
115
+ measurable on a set nobody sent keys for. Where the model offers two
116
+ legitimate routes, write a `requiredAnyOf` group rather than pick one.
117
+ `verify_goldens.py` check 5 audits every id against the model, so a wrong one
118
+ is a hard finding rather than a silent retrieval miss.
119
+ - **Derive the list with the model in front of you, not from memory, and not
120
+ with grep.** The model knows what each field IS; an author does not reliably.
121
+ Ask the question of the model: which entities does an answer to this depend
122
+ on, and is each a measure or a dimension. Confirm every id with `get_context`
123
+ before writing it. A list written from recollection is the most common way a
124
+ set acquires a retrieval expectation nobody can satisfy.
125
+
126
+ Measured: an agent given the model and one question found alternatives a
127
+ hand-written key had missed on all three cases tried -- carrier name against
128
+ nickname against code, `destination_count` against its underlying
129
+ `destination.airport_count`. At roughly $0.13 a question. It is better than a
130
+ person working from notes at finding what the model actually offers.
131
+
132
+ It is worse at deciding what to REQUIRE, and the two rules below are why. On
133
+ the same three cases it proposed demoting two named measures on the grounds
134
+ that a raw expression answered identically. One of those equivalences was
135
+ false; the other was true today and is not a reason to demote anything.
136
+
137
+ - **But VERIFY every "this is not strictly required" claim by running both
138
+ sides.** This is the rule that makes the step above safe, and it is not
139
+ optional.
140
+
141
+ The same probe got one badly wrong. Told that a named measure is not required
142
+ when a raw column plus a plain aggregate answers identically, it wrote that
143
+ `average_plane_size` was "equally answerable by
144
+ `avg(aircraft.aircraft_models.seats)`". Executed, those are 229 and 196.93.
145
+ The measure is `aircraft.avg(...)`, an average over distinct aircraft, and the
146
+ inline version averages over flights. The claim was false for exactly the
147
+ measure whose entire purpose is to carry that distinction -- and it is the
148
+ same mistake, on the same field, that produced the only wrong answer in the
149
+ run this set came from.
150
+
151
+ Had that proposal been written into the key, the case would have stopped
152
+ testing the thing it exists to test.
153
+
154
+ So: an equivalence is a claim about DATA, and reading a definition is not
155
+ evidence. Run both expressions and compare the rows.
156
+
157
+ **But agreeing today is not a reason to demote the model's entity, and this
158
+ is the part the probe got backwards.** `airport_count is count()` returns the
159
+ same 984 as a bare `count()` right now, and stops doing so the moment anyone
160
+ adds a null filter or a deduplication to it. The key would not notice: it
161
+ would go on passing while testing nothing. That applies to every measure that
162
+ is currently a thin wrapper, which is most of them until the day one is not.
163
+
164
+ **`requiredAnyOf` is for two routes the MODEL defines** -- a carrier's `name`
165
+ and its `nickname`, `destination_count` and the `destination.airport_count`
166
+ it wraps. Both are entities, both are retrievable, and an answer using either
167
+ has gone through the semantic layer. That is a real choice and the group
168
+ records it.
169
+
170
+ It is NOT for "the model's entity, or a raw expression that happens to
171
+ agree". Those are not two routes: one is the route, the other is a bypass
172
+ that currently works. Keeping the named entity in `required` is what makes
173
+ the eval measure what the semantic layer is for, and finding it IS the
174
+ retrieval behaviour under test -- an agent that searches for the pre-defined
175
+ entity inherits its definition and its rules, while one that rebuilds from
176
+ raw columns inherits whatever it thought of.
177
+
178
+ What the equivalence check is FOR is reading a run afterwards. When an answer
179
+ came out right without the entity, comparing the two says whether it got
180
+ lucky or got it wrong, and a set whose passes are merely lucky is a set of
181
+ near-misses.
182
+
183
+ - **Then prove each one is retrievable, and record what finds it.**
184
+ `check_findable.py --set <set> --mcp-url <mcp> --publisher <rest>
185
+ --environment <env> --package <pkg>` runs two checks and needs no model call.
186
+ With `--publisher` it reads the COMPILED model and settles whether each field
187
+ exists and what KIND it is; then it searches for each entity to confirm the
188
+ index can actually deliver it.
189
+
190
+ This matters more than it looks, because `required` is what BOTH retrieval
191
+ numbers are computed against: whether the agent asked for the entity, and
192
+ whether it came back. An id retrieval cannot deliver makes both fiction, and
193
+ the case reports a retrieval miss on every run, which reads as a defect in
194
+ the model or the agent rather than in the key.
195
+
196
+ **Prefer the compiled model over a grep, and prefer it in both directions.**
197
+ Measured on one package: `dimension:flights:flight_count` names a field the
198
+ model really has, so grepping the text passes it, and the compiled model says
199
+ `flight_count` is a MEASURE, which no `dimension` request can return.
200
+ Conversely `dimension:airports:own_type` appears zero times in the `.malloy`
201
+ and the compiled model declares it, because the source exposes it implicitly
202
+ from the data; the grep calls it missing and acting on that deletes a good
203
+ entity. `verify_goldens.py` check 5 is the grep, it is free and needs no
204
+ server, and it is not the authority.
205
+
206
+ A pass is a floor, not a verdict on the docs: each entity is searched by its
207
+ OWN NAME, the easiest query that could find it. An entity that answers to its
208
+ identifier and not to the words a question uses still fails at run time, as
209
+ `not retrieved`.
210
+
211
+ Writing the list forces the question an author has to answer anyway: what
212
+ would a reasonable agent search for, and of what type, to find this? If you
213
+ cannot state that, the agent cannot be expected to guess it.
214
+ - `status: provisional`, never `verified`, for the reason stated above: you
215
+ derived it THROUGH the model under test, so a model bug would certify its
216
+ own key. `verify_goldens.py --promote` is still the only way out.
217
+ - **Where two readings are both defensible from the model, do not pick one.**
218
+ A model that documents two conventions for the same population produces two
219
+ honest numbers, and choosing quietly is how a confident wrong key gets
220
+ written. Go to Hold an ambiguous golden in `skill:eval-loop`'s
221
+ `reference/golden-side-door.md`: record the competing candidates rather than
222
+ a new number.
223
+ - What no query can settle is not a failure to derive. It is step 4's second
224
+ kind, and those clauses score on day one.
225
+
98
226
  ## Step 3: run their query, if they gave one
99
227
 
100
228
  Do it at import, before any run. It is the cheapest finding in the whole loop.
@@ -151,6 +279,57 @@ Where you cannot decide, mark the case and report it rather than guessing. A
151
279
  criterion nobody could classify is a question for its author, and
152
280
  `skill:eval-loop`'s golden side door is where it waits.
153
281
 
282
+ ### Four rules for writing the rubric itself
283
+
284
+ Each of these cost a scored case on a real set, and none of them is obvious
285
+ while you are writing one.
286
+
287
+ **The golden rows are the figures. The rubric's prose is a gloss on them, and
288
+ where the two disagree the prose is what is stale.** On one set 26 of 29
289
+ rubrics quoted a figure that appears nowhere in their own golden rows: the
290
+ goldens were re-derived the next morning, the prose was not, and an agent that
291
+ computed 747 and 370 -- the exact numbers in the golden JSON -- was failed
292
+ against a rubric still saying 615 and 502. Say how to derive the figure, not
293
+ what it equalled. `verify_goldens.py` check 2 reports figures in the accepting
294
+ clause that are absent from the rows, but it reads only figures specific enough
295
+ to be a quoted result -- a bare three-digit number is invisible to it, and 615
296
+ is exactly that. So the check is a help, not a guarantee, and the rule above is
297
+ yours to keep. The judge is told the same thing from the other side: where a
298
+ rubric and a golden disagree about a figure, it scores against the golden.
299
+
300
+ **Do not assert the model's current behaviour.** "`contract_terms` cannot be
301
+ used here at all -- it returns ZERO rows" was true when written and false four
302
+ hours later, when a commit unblocked that view. Nothing linked the two, and the
303
+ judge then reasoned from the stale claim against an answer that was right.
304
+ Co-locating the set with the model makes such drift visible in a diff; it does
305
+ not detect it. A rubric that describes a bug is a rubric with an expiry date:
306
+ write the requirement, not the defect.
307
+
308
+ **When a question admits two honest populations, accept either and require the
309
+ answer to name which.** "Products in a category" can mean listed-in or
310
+ primary-category, and on one set the two readings differed by 4,070 against
311
+ 2,707. Six or more cases turned on it and one rubric had been written to accept
312
+ both. That one was right. The rest were repaired afterwards, having failed
313
+ correct answers in the meantime.
314
+
315
+ **If the rubric accepts an alternative, the entity list must too.** The two
316
+ halves of a key are read by different things: the rubric is prose for the judge,
317
+ `expectedEntities.required` is ids for retrieval scoring. They can disagree
318
+ without anything noticing. A rubric saying "either the full carrier name or the
319
+ nickname is fine" beside a required list naming only `dimension:carriers:name`
320
+ scores an answer that used the nickname CORRECT and docks it recall in the same
321
+ run, and that lost recall then reads as a retrieval failure. Use a
322
+ `requiredAnyOf` group, which is satisfied when any member is delivered.
323
+ `verify_goldens.py` reports the mismatch as a review item, but the rule is
324
+ yours: it is a heuristic over prose and cannot catch every phrasing.
325
+
326
+ **A trap note is not a requirement.** Notes that arrive beside the questions
327
+ describe what their author thought was hard, and they mention things the
328
+ question never asked for. Turning one into a rubric clause invents a
329
+ requirement the answerer was never given, and it happened: a term appearing
330
+ nowhere in any question became a clause an answer was marked down for missing.
331
+ Convert a note only where it constrains the answer to the question as asked.
332
+
154
333
  ## Step 5: never change a question, and seal it
155
334
 
156
335
  The question is the stimulus. It is what a human asked, and it is not yours to
@@ -18,7 +18,7 @@ On the case:
18
18
  | `split` | rarely present | you choose, and freeze it at import |
19
19
  | `source` | how it arrived: the filename, or the log query | say `unknown` rather than guessing a provenance |
20
20
  | `golden` | see the classification table in `SKILL.md` | absent. A case with no golden is legitimate |
21
- | `expectedEntities` | almost never present | leave it out. Guessing which fields an answer needs invents a retrieval expectation nobody stated, and it scores as a retrieval miss forever |
21
+ | `expectedEntities` | almost never present | leave it out. Guessing which fields an answer needs invents a retrieval expectation nobody stated, and it scores as a retrieval miss forever. The one exception is a key you derived yourself (`SKILL.md`, Deriving a key yourself): then you are not guessing, you are recording what you used |
22
22
 
23
23
  `expectedEntities` is the one most worth leaving empty. A wrong `required` id
24
24
  cannot be delivered by any run, so it reads as a model failure on every case
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-improve
3
3
  description: 'Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.20
4
+ version: 0.1.21
5
5
  ---
6
6
 
7
7
  # Improve the Model