@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
@@ -16,8 +16,9 @@ import unittest.mock
16
16
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
17
17
  import verify_goldens # noqa: E402
18
18
  from verify_goldens import ( # noqa: E402
19
- model_text, promotion_blocker, question_drift_findings,
20
- truth_isolation_findings, unknown_name_findings, verify)
19
+ check_value, close_enough, model_text, promotion_blocker,
20
+ question_drift_findings, truth_isolation_findings,
21
+ unknown_name_findings, verify)
21
22
 
22
23
  MODEL = """
23
24
  source: order_items is duckdb.table('data/order_items.parquet') extend {
@@ -531,6 +532,90 @@ class AValueFreeSetNeedsNoTruthServer(unittest.TestCase):
531
532
  self.assertTrue(verify(self.tmp, None, "samples", quiet=True)["skipped"])
532
533
 
533
534
 
535
+ class TheCompositionRuleAtTheGate(unittest.TestCase):
536
+ """A truth server re-derives values; a ledger validates the definitions a
537
+ value rests on. Either is evidence. Neither present is a check that did not
538
+ happen, and the gate says which."""
539
+
540
+ def setUp(self):
541
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
542
+ (self.tmp / "set.json").write_text('{"name": "s"}') # no truthPackage
543
+ self.led = self.tmp / "led.jsonl"
544
+
545
+ def tearDown(self):
546
+ shutil.rmtree(self.tmp, ignore_errors=True)
547
+
548
+ def write_case(self, status="verified", ids=("measure:s:m",),
549
+ verification=None):
550
+ g = {"status": status, "kind": "scalar", "value": {"v": 1}}
551
+ if verification:
552
+ g["verification"] = verification
553
+ (self.tmp / "cases.jsonl").write_text(json.dumps(
554
+ {"qid": "q1", "question": "x", "split": "dev", "golden": g,
555
+ "expectedEntities": {"required": list(ids)}}) + "\n")
556
+
557
+ def write_ledger(self, **verdicts):
558
+ self.led.write_text("".join(json.dumps(
559
+ {"entityId": e, "verdict": v, "exprSha": "s"}) + "\n"
560
+ for e, v in verdicts.items()))
561
+
562
+ def test_validated_definitions_lift_the_skip(self):
563
+ self.write_case()
564
+ self.write_ledger(**{"measure:s:m": "agrees"})
565
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led)
566
+ self.assertTrue(r["skipped"], "still no truth server")
567
+ self.assertTrue(r["ledgerValidated"], "but the definitions are checked")
568
+ self.assertEqual(r["unvalidated"], [])
569
+
570
+ def test_an_unchecked_definition_names_the_case(self):
571
+ self.write_case()
572
+ self.write_ledger(**{"measure:s:m": "unchecked"})
573
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led)
574
+ self.assertFalse(r["ledgerValidated"])
575
+ self.assertEqual(r["unvalidated"], ["q1 (unchecked)"])
576
+
577
+ def test_a_definition_missing_from_the_ledger_does_not_validate(self):
578
+ self.write_case()
579
+ self.write_ledger(**{"measure:s:other": "agrees"})
580
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led)
581
+ self.assertFalse(r["ledgerValidated"])
582
+
583
+ def test_an_empty_ledger_validates_nothing(self):
584
+ # Zero rows is not "every definition agrees"; it is no evidence at all.
585
+ self.write_case()
586
+ self.led.write_text("")
587
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led)
588
+ self.assertFalse(r["ledgerValidated"])
589
+
590
+ def test_promotion_through_the_ledger_still_needs_the_second_derivation(self):
591
+ # Validated definitions say the pieces are right; two derivations
592
+ # agreeing say the VALUE is. `verified` needs both.
593
+ self.write_case(status="provisional")
594
+ self.write_ledger(**{"measure:s:m": "agrees"})
595
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led,
596
+ promote=True)
597
+ self.assertEqual(r["promoted"], [])
598
+ self.assertTrue(any("q1" in n for n in r["promotionNotes"]),
599
+ r["promotionNotes"])
600
+
601
+ def test_promotion_through_the_ledger_names_the_ledger(self):
602
+ self.write_case(status="provisional",
603
+ verification={"primaryAxis": "a", "variesAxis": "b"})
604
+ self.write_ledger(**{"measure:s:m": "agrees"})
605
+ r = verify(self.tmp, None, "samples", quiet=True, definitions=self.led,
606
+ promote=True)
607
+ self.assertEqual(r["promoted"], ["q1"])
608
+ g = json.loads((self.tmp / "cases.jsonl").read_text())["golden"]
609
+ self.assertEqual(g["status"], "verified")
610
+ self.assertIn("definition ledger", g["verifiedBy"])
611
+
612
+ def test_no_ledger_keeps_the_old_behaviour(self):
613
+ self.write_case()
614
+ r = verify(self.tmp, None, "samples", quiet=True)
615
+ self.assertTrue(r["skipped"])
616
+ self.assertFalse(r["ledgerValidated"])
617
+
618
+
534
619
  class Promotion(unittest.TestCase):
535
620
  """`--promote` is the ONLY thing that writes `golden.status`.
536
621
 
@@ -760,5 +845,179 @@ class RubricFigures(unittest.TestCase):
760
845
  self.assertEqual(len(f), 1)
761
846
  self.assertIn("99999.99", f[0])
762
847
 
848
+ def test_a_four_digit_stale_figure_is_now_caught(self):
849
+ # The floor was five significant digits, so a four-digit quantity the
850
+ # golden does not hold passed silently. Measured over the local sets,
851
+ # moving it to three digits at or above 100 took the findings from 14
852
+ # to 17 over 54 rubrics.
853
+ f = verify_goldens.rubric_number_findings(self.case(
854
+ "Right: 2672 returned line items.", returned=1830))
855
+ self.assertEqual(len(f), 1)
856
+ self.assertIn("2672", f[0])
857
+
858
+ def test_a_bare_three_digit_figure_is_NOT_caught(self):
859
+ # Deliberate, and the limit of this check. Catching it means
860
+ # tokenising every three-digit integer in rubric prose, which was
861
+ # measured over the same sets: 17 findings become 552, because a
862
+ # rubric quoting a list of ids contributes one per id. A check that
863
+ # reports 552 things is not read at all.
864
+ #
865
+ # So the "615 and 502 over goldens holding 747 and 370" case -- a
866
+ # correct answer failed against prose one re-derivation out of date --
867
+ # is NOT closed here. It is closed in the judge prompt, which is told
868
+ # that where the rubric and the golden disagree about a figure, the
869
+ # golden is the key. That rule holds at any precision and needs no
870
+ # regex. This test exists so nobody re-opens the floor without
871
+ # re-measuring.
872
+ f = verify_goldens.rubric_number_findings(self.case(
873
+ "Right: monthly page views of 615 and 502.",
874
+ first=747, second=370))
875
+ self.assertEqual(f, [])
876
+
877
+ def test_a_year_is_not_a_figure(self):
878
+ f = verify_goldens.rubric_number_findings(self.case(
879
+ "Right: the 2024 total, 747.", total=747))
880
+ self.assertEqual(f, [])
881
+
882
+ def test_a_small_count_is_not_a_figure(self):
883
+ # Below 100 stays out: "the top 12 categories", "5 rows", an ordinal.
884
+ f = verify_goldens.rubric_number_findings(self.case(
885
+ "Right: the top 12 categories, over 5 regions.", total=747))
886
+ self.assertEqual(f, [])
887
+
888
+ def test_a_one_decimal_average_is_checked(self):
889
+ # "about 740.5" is how a rubric quotes an average, and the tokeniser
890
+ # required TWO decimal places, so that whole shape was invisible.
891
+ # Admitting one-decimal figures added zero findings across every set
892
+ # available locally, so it is free.
893
+ f = verify_goldens.rubric_number_findings(self.case(
894
+ "Right: an average flight distance of about 688.2.",
895
+ average=740.48))
896
+ self.assertEqual(len(f), 1)
897
+ self.assertIn("688.2", f[0])
898
+
899
+ def test_a_small_decimal_is_still_excluded(self):
900
+ # The floor still applies after the tokeniser: a value under 100 with
901
+ # few digits is a rate or a ratio, not a quoted result.
902
+ f = verify_goldens.rubric_number_findings(self.case(
903
+ "Right: a ratio of 3.5.", total=747))
904
+ self.assertEqual(f, [])
905
+
906
+ def test_a_figure_the_golden_holds_is_not_reported(self):
907
+ f = verify_goldens.rubric_number_findings(self.case(
908
+ "Right: 747 page views.", views=747))
909
+ self.assertEqual(f, [])
910
+
911
+
912
+
913
+ class ScalarValueShape(unittest.TestCase):
914
+ """A bare string where an object belongs is one finding, not a blackout.
915
+
916
+ `"value": "Ecommerce"` instead of `{"answer": "Ecommerce"}` is the obvious
917
+ authoring mistake for a benchmark whose answers ARE strings. It raised
918
+ AttributeError out of `verify()` and killed the sweep, so the other
919
+ thirty-nine cases went unaudited and the script's own closing line --
920
+ "this says NOTHING about the goldens" -- was right.
921
+ """
922
+
923
+ def args(self):
924
+ return argparse.Namespace(
925
+ publisher="http://x", environment="truth", truth_package="t",
926
+ truth_model="truth.malloy", rewrite=False)
927
+
928
+ def check(self, value, rows):
929
+ c = {"qid": "q", "golden": {"kind": "scalar", "value": value,
930
+ "canonicalQuery": "run: t -> { ... }"}}
931
+ with unittest.mock.patch("verify_goldens.try_query",
932
+ return_value=(rows, None)):
933
+ return check_value(c, self.args())
934
+
935
+ def test_a_bare_string_is_an_error_not_a_crash(self):
936
+ status, detail, _ = self.check("Ecommerce", [{"answer": "Ecommerce"}])
937
+ self.assertEqual(status, "error")
938
+ self.assertIn("value is str", detail)
939
+
940
+ def test_the_error_shows_the_shape_it_wanted(self):
941
+ _, detail, _ = self.check("Ecommerce", [{"answer": "Ecommerce"}])
942
+ self.assertIn('{"answer": "Ecommerce"}', detail)
943
+
944
+ def test_a_list_under_scalar_is_also_caught(self):
945
+ status, detail, _ = self.check([1, 2], [{"answer": 1}])
946
+ self.assertEqual(status, "error")
947
+ self.assertIn("value is list", detail)
948
+
949
+ def test_the_proper_shape_still_works(self):
950
+ status, _, _ = self.check({"answer": "Ecommerce"},
951
+ [{"answer": "Ecommerce"}])
952
+ self.assertEqual(status, "ok")
953
+
954
+
955
+ class YoungGoldens(unittest.TestCase):
956
+ """`eval-import` produces "a number alone" as a legitimate outcome, and
957
+ the audit called it a hard finding -- so the import skill's own output
958
+ refused the check that runs before every arm."""
959
+
960
+ def args(self):
961
+ return argparse.Namespace(
962
+ publisher="http://x", environment="truth", truth_package="t",
963
+ truth_model="truth.malloy", rewrite=False)
964
+
965
+ def check(self, status_in):
966
+ c = {"qid": "q", "golden": {"kind": "scalar", "value": {"n": 1},
967
+ "status": status_in}}
968
+ return check_value(c, self.args())
969
+
970
+ def test_provisional_with_no_query_is_not_a_finding(self):
971
+ status, detail, _ = self.check("provisional")
972
+ self.assertEqual(status, "unrederivable")
973
+ self.assertIn("no canonicalQuery yet", detail)
974
+
975
+ def test_an_absent_status_reads_as_provisional(self):
976
+ c = {"qid": "q", "golden": {"kind": "scalar", "value": {"n": 1}}}
977
+ self.assertEqual(check_value(c, self.args())[0], "unrederivable")
978
+
979
+ def test_VERIFIED_with_no_query_is_still_an_error(self):
980
+ # `verified` is a claim that it re-derived. That claim needs the query.
981
+ status, detail, _ = self.check("verified")
982
+ self.assertEqual(status, "error")
983
+ self.assertIn("cannot be re-derived", detail)
984
+
985
+ def test_unrederivable_does_not_count_as_drift(self):
986
+ # `drifted` sums diff + error, and the arm gate reads `drifted`.
987
+ self.assertNotIn("unrederivable", ("diff", "error"))
988
+
989
+
990
+ class NumericRendering(unittest.TestCase):
991
+ """Malloy renders a number in its shortest form, so a golden stated to two
992
+ decimals comes back as `75.7`. Comparing the rendered strings called that
993
+ drift, which blocks an arm -- and the workaround was padding expressions
994
+ in the truth queries whose only job was to make one string look like the
995
+ other."""
996
+
997
+ def test_numeric_strings_compare_as_numbers(self):
998
+ self.assertTrue(close_enough("75.70", "75.7", None))
999
+
1000
+ def test_a_number_against_its_string_matches(self):
1001
+ self.assertTrue(close_enough(75.70, "75.7", None))
1002
+
1003
+ def test_a_thousands_separator_is_read(self):
1004
+ self.assertTrue(close_enough("1,234", "1234", None))
1005
+
1006
+ def test_genuine_text_still_compares_exactly(self):
1007
+ self.assertTrue(close_enough("Ecommerce", "Ecommerce", None))
1008
+ self.assertFalse(close_enough("Ecommerce", "Retail", None))
1009
+
1010
+ def test_a_bool_is_not_the_number_one(self):
1011
+ # `True == 1` in Python, and a boolean golden must not match "1".
1012
+ self.assertFalse(close_enough(True, "1", None))
1013
+
1014
+ def test_a_real_difference_is_still_drift(self):
1015
+ self.assertFalse(close_enough("75.70", "76.1", None))
1016
+
1017
+ def test_a_compound_string_is_left_alone(self):
1018
+ # Out of scope on purpose: it parses as no single number, so it stays
1019
+ # an exact compare rather than being guessed at.
1020
+ self.assertFalse(close_enough("C: 75.70", "C: 75.7", None))
1021
+
763
1022
  if __name__ == "__main__":
764
1023
  unittest.main()
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.20
4
+ version: 0.1.21
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -24,6 +24,35 @@ repair closes that. Take the stable list `flip_table.py` prints and pass
24
24
  `--only <qids> --verdicts near_match`. Never diagnose a one-armed `near_match`;
25
25
  that is noise, and it sends an agent to fix a model that is already right.
26
26
 
27
+ **A correct answer can still carry a finding.** A case that answered right
28
+ while a required entity never reached it is diagnosed too, for the retrieval
29
+ miss alone, and `diagnose.py` selects it automatically. The answer was right by
30
+ another route, and naming that route is the job: on the run this rule comes
31
+ from it was always the same one, the agent rebuilding the model's own measure
32
+ inline. That held while the measure was `count()` and failed the moment one
33
+ carried a grain rule, producing the run's only wrong answer. Three of the four
34
+ findings in that run sat on passing cases and, before this, produced nothing.
35
+
36
+ **"It worked anyway" is not a reason to leave the model or the skills
37
+ unfixed.** An answer that is right without the model's own entity is right for
38
+ now, not right by design. Write the issue against the miss, record that the
39
+ answer was correct so nobody reads it as a wrong number, and do not soften the
40
+ finding because the number came out right. `--no-retrieval-misses` opts out for
41
+ a run that only wants answer failures.
42
+
43
+ Holdout is withheld so the acceptance check keeps something the improve step
44
+ never saw. A **measure-only** run never reaches improve, so it is holding those
45
+ cases back from nothing: pass `--include-holdout` there. The script refuses it
46
+ on a run that already carries a `candidate`, because that run's holdout is the
47
+ only thing left that can falsify the edit.
48
+
49
+ **Say what the clusters do not cover.** `diagnose.py` prints a coverage account
50
+ of every non-passing case and which bucket it fell in -- holdout, contaminated,
51
+ a verdict outside `--verdicts`, unscored, or selected and not diagnosed. Quote
52
+ it whenever you report clusters. One run's six clusters were read as covering
53
+ its failures; they covered 8 of 18, and each individual exclusion had been
54
+ correct and added up nowhere.
55
+
27
56
  ## Components, in order
28
57
 
29
58
  Walk **in this order** and stop at the first with positive evidence. A later
@@ -35,7 +64,7 @@ never "C1" / "C2" / "C3":
35
64
  | `dataset` | Bad question, bad or missing golden, or environment drift? |
36
65
  | `agent-call` | Did the agent ask for the needed concepts, with the right type and scope? |
37
66
  | `get_context/model` | Is the needed entity absent, undocumented, weakly labeled, duplicated, or missing guidance? |
38
- | `get_context/retrieval` | Was an on-target request against a well-described entity ranked or grouped wrong? |
67
+ | `get_context/retrieval` | Was an on-target, in-scope request against a well-described entity ranked or grouped wrong? **Check the call's `scopes` first**: a call pinned to one source cannot return another source's entity, and that miss is `agent-call`. |
39
68
  | `construction` | Did sufficient context arrive, and the agent still built the wrong query? |
40
69
  | `model-definition` | Is a measure, join, filter convention, or source semantically wrong? |
41
70
 
@@ -316,6 +345,22 @@ Say what you considered merging and chose not to. A cluster is a claim that one
316
345
  change fixes N cases, and the near-misses are what a reviewer needs to falsify
317
346
  it.
318
347
 
348
+ **Falsify a behavioural cluster against the passes.** This skill reads failures
349
+ only, so any behaviour common to the whole run looks causal from inside it.
350
+ `diagnose.py` hands the clustering step a `CONTROLS` block: the same
351
+ measurements -- retrieval calls, targets carrying no `search_text`, queries,
352
+ skills opened, turns -- taken on the cases that PASSED. Before claiming a
353
+ behaviour explains a cluster, compare it there. If it occurs at a similar rate
354
+ in the passes it does not separate the groups: mark the cluster `contributing`
355
+ rather than `primary` and do not route it to an edit as the root cause.
356
+ Measured, on the run this comes from: the largest cluster said the agent
357
+ "substitutes broad enumeration for targeted retrieval", and bare targets were
358
+ 25% of all targets in the failures against 26% in the passes. What actually
359
+ separated them was volume -- failures made about 50% more retrieval calls --
360
+ and question difficulty explains that at least as well as call style does. With
361
+ no controls at all, a behavioural cluster is unfalsified, which is a different
362
+ claim from confirmed.
363
+
319
364
  Still no patch. Naming the shared root cause precisely enough that someone else
320
365
  can design the edit is the whole job here; the edit itself is
321
366
  `skill:eval-improve`, working from this. A cluster carrying an honest open
@@ -41,15 +41,52 @@ from typing import Any
41
41
 
42
42
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
43
43
  / "eval-answer" / "scripts"))
44
- from score_retrieval import load, score_case # noqa: E402
45
-
46
- # Which artifact a cluster's fix would land in. eval-improve needs this to know
47
- # what kind of edit it is even proposing.
48
- LEVER_BY_WHERE = {
49
- "query construction": "skill",
50
- "retrieval ranking": "ranking",
51
- "model coverage": "model",
52
- "refusal behaviour": "skill",
44
+ from score_retrieval import (DELIVERED, MODEL, NEVER_ASKED, # noqa: E402
45
+ NOT_RETURNED, REFUSAL, UNMEASURED,
46
+ load, score_case)
47
+
48
+ # The labels are imported, never spelled again here. They were spelled out
49
+ # once, and when `score_retrieval` renamed them -- "query construction" to
50
+ # `delivered, wrong`, "retrieval ranking" to `not retrieved` -- every branch
51
+ # below silently stopped matching: the two commonest failure kinds fell to the
52
+ # catch-all `other:` group and lost their lever, while this file's tests, of
53
+ # which there were none, stayed green.
54
+ WHERE_DELIVERED = DELIVERED[2]
55
+ WHERE_NOT_RETRIEVED = NOT_RETURNED[2]
56
+ WHERE_NEVER_ASKED = NEVER_ASKED[2]
57
+ WHERE_MODEL = MODEL[2]
58
+ WHERE_REFUSAL = REFUSAL[2]
59
+ WHERE_UNMEASURED = UNMEASURED[2]
60
+
61
+ # A DIAGNOSED cluster arrives labelled by eval-diagnose's OWNER rather than by
62
+ # a retrieval outcome, and both land in the same `where_to_fix` column of the
63
+ # run package. These two maps are the translation, and they live here, in
64
+ # eval-diagnose, because the owners they key on are eval-diagnose's vocabulary
65
+ # (`diagnose.py`'s OWNERS): `retrieval` and `dataset` are values the retrieval
66
+ # scorer never assigns, so holding them over there left eval-answer carrying
67
+ # keys only this package can trigger. `diagnose.py` imports them from here.
68
+ #
69
+ # One map rather than two, because `diagnose.py` and this file both write
70
+ # `clusters.jsonl` and each used to spell its own -- so the package served two
71
+ # vocabularies under one column name and the doc on it matched neither. A
72
+ # cluster's label is the nearest shared bucket, not a new one; its `component`
73
+ # and cause code carry the precise finding.
74
+ WHERE_BY_OWNER = {
75
+ "model": MODEL[2],
76
+ "retrieval": NOT_RETURNED[2],
77
+ "agent-skill": DELIVERED[2],
78
+ "dataset": "dataset",
79
+ }
80
+ # Which artifact the edit lands in. An owner nobody has named yet -- eval-
81
+ # diagnose has not run, or it ran and declined (`undecided` for a
82
+ # delivered-wrong or an unreturned entity, `unknown` for unmeasured coverage)
83
+ # -- yields no lever rather than a guessed one, because a guess here is the
84
+ # default blame this taxonomy exists to remove.
85
+ LEVER_BY_OWNER = {
86
+ "model": "model",
87
+ "retrieval": "retrieval",
88
+ "agent-skill": "skill",
89
+ "dataset": "dataset",
53
90
  }
54
91
 
55
92
 
@@ -63,7 +100,7 @@ def cluster_key(row: dict[str, Any], case: dict[str, Any]) -> tuple[str, str]:
63
100
  where = row["where_to_fix"]
64
101
  missing = row.get("missing") or []
65
102
 
66
- if where == "model coverage":
103
+ if where == WHERE_MODEL:
67
104
  note = (case.get("coverageNote") or "").strip()
68
105
  if note:
69
106
  # Notes are already phrased as an absence ("no measure; ..."), so
@@ -73,14 +110,20 @@ def cluster_key(row: dict[str, Any], case: dict[str, Any]) -> tuple[str, str]:
73
110
  return (f"model:{note[:60]}", f"the model has {lead}{note[:80]}")
74
111
  return ("model:unspecified", "the model does not cover what was asked")
75
112
 
76
- if where == "retrieval ranking" and missing:
113
+ if where == WHERE_NOT_RETRIEVED and missing:
77
114
  # One entity that several questions needed and none received.
78
115
  return (f"retrieval:{missing[0]}", f"{missing[0]} is never returned")
79
116
 
80
- if where == "refusal behaviour":
117
+ if where == WHERE_NEVER_ASKED and missing:
118
+ # A different edit from the one above, so a different group: the
119
+ # entity was reachable and no search of its kind went out.
120
+ return (f"unasked:{missing[0]}",
121
+ f"{missing[0]} is never searched for")
122
+
123
+ if where == WHERE_REFUSAL:
81
124
  return ("refusal", "answered a question the model cannot answer")
82
125
 
83
- if where == "query construction":
126
+ if where == WHERE_DELIVERED:
84
127
  tags = [t for t in (case.get("tags") or [])
85
128
  if t in ("window", "time-series", "ratio", "join", "distinct",
86
129
  "cohort", "cumulative", "rank")]
@@ -114,7 +157,7 @@ def main(argv: list[str] | None = None) -> int:
114
157
  g = groups.setdefault(key, {
115
158
  "clusterId": key, "label": label,
116
159
  "whereToFix": r["where_to_fix"], "component": r["component"],
117
- "owner": r["owner"], "lever": LEVER_BY_WHERE.get(r["where_to_fix"]),
160
+ "owner": r["owner"], "lever": LEVER_BY_OWNER.get(r["owner"]),
118
161
  "qids": [], "evidence": [], "proposedEdit": "", "confidence": None})
119
162
  g["qids"].append(r["qid"])
120
163
  why = (c.get("golden") or {}).get("rubric") or ""
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for cluster_failures.py. Stdlib only: python3 cluster_failures_test.py
3
+
4
+ What these guard is the seam this file has no other protection against: it
5
+ CONSUMES `score_retrieval`'s `where_to_fix` vocabulary and used to spell every
6
+ value again in its own branches. When the vocabulary was renamed -- "query
7
+ construction" to `delivered, wrong`, "retrieval ranking" to `not retrieved` --
8
+ every branch silently stopped matching, the two commonest failure kinds fell to
9
+ the catch-all `other:` group, and their lever came back null. Nothing failed,
10
+ because this file had no tests at all.
11
+
12
+ So the rule these pin is: every label `score_retrieval` can emit reaches a
13
+ branch here, and the levers come from the owner it already assigned.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import pathlib
19
+ import sys
20
+ import unittest
21
+
22
+ HERE = pathlib.Path(__file__).resolve().parent
23
+ sys.path.insert(0, str(HERE))
24
+ sys.path.insert(0, str(HERE.parent.parent / "eval-answer" / "scripts"))
25
+
26
+ import cluster_failures as cf # noqa: E402
27
+ import score_retrieval as sr # noqa: E402
28
+
29
+ # Every attribution `score_retrieval.attribute()` can return. A new one added
30
+ # there without a branch here fails `test_every_label_reaches_a_branch`.
31
+ ATTRIBUTIONS = (sr.DELIVERED, sr.NOT_RETURNED, sr.NEVER_ASKED,
32
+ sr.MODEL, sr.REFUSAL, sr.UNMEASURED)
33
+
34
+
35
+ def row(attribution, missing=("measure:m:total",), **kw):
36
+ component, owner, where = attribution
37
+ return {"qid": "q", "where_to_fix": where, "owner": owner,
38
+ "component": component, "missing": list(missing), **kw}
39
+
40
+
41
+ class EveryLabelIsHandled(unittest.TestCase):
42
+ def test_every_label_reaches_a_branch(self):
43
+ """No emitted label may fall through to `other:`, except the one that
44
+ genuinely has no specific edit behind it."""
45
+ fell_through = []
46
+ for att in ATTRIBUTIONS:
47
+ key, _ = cf.cluster_key(row(att), {"tags": ["ratio"]})
48
+ if key.startswith("other:"):
49
+ fell_through.append(att[2])
50
+ # `coverage not measured` is not an edit, it is the absence of a
51
+ # measurement, so it has no group of its own by design.
52
+ self.assertEqual(fell_through, [sr.UNMEASURED[2]])
53
+
54
+ def test_no_label_is_spelled_as_a_literal_in_the_code(self):
55
+ """The comments may quote the old names to explain the history; the
56
+ CODE may not spell any label, or the next rename breaks it again."""
57
+ tree = ast.parse(pathlib.Path(cf.__file__).read_text())
58
+ literals = {n.value for n in ast.walk(tree)
59
+ if isinstance(n, ast.Constant)
60
+ and isinstance(n.value, str)}
61
+ spelled = literals & {a[2] for a in ATTRIBUTIONS}
62
+ self.assertEqual(spelled, set())
63
+ # And they arrive from the one place that decides them.
64
+ imported = {alias.name for n in ast.walk(tree)
65
+ if isinstance(n, ast.ImportFrom)
66
+ and n.module == "score_retrieval" for alias in n.names}
67
+ self.assertLessEqual(
68
+ {"DELIVERED", "NOT_RETURNED", "NEVER_ASKED", "MODEL", "REFUSAL"},
69
+ imported)
70
+
71
+
72
+ class TheGroupAFailureLandsIn(unittest.TestCase):
73
+ def key(self, attribution, case=None, **kw):
74
+ return cf.cluster_key(row(attribution, **kw), case or {})[0]
75
+
76
+ def test_a_delivered_wrong_failure_groups_by_its_construction_tag(self):
77
+ self.assertEqual(
78
+ self.key(sr.DELIVERED, {"tags": ["ratio"]}), "construction:ratio")
79
+
80
+ def test_a_delivered_wrong_failure_without_a_tag_still_groups(self):
81
+ self.assertEqual(self.key(sr.DELIVERED, {}), "construction:other")
82
+
83
+ def test_an_unreturned_entity_groups_by_the_entity(self):
84
+ self.assertEqual(self.key(sr.NOT_RETURNED),
85
+ "retrieval:measure:m:total")
86
+
87
+ def test_never_asked_and_unreturned_are_different_groups(self):
88
+ # Same entity, different edit: one is the docs, the other is the search
89
+ # the agent never issued. Grouping them together proposes one fix for
90
+ # two problems.
91
+ self.assertNotEqual(self.key(sr.NEVER_ASKED),
92
+ self.key(sr.NOT_RETURNED))
93
+
94
+ def test_a_model_gap_groups_on_its_coverage_note(self):
95
+ self.assertEqual(
96
+ self.key(sr.MODEL, {"coverageNote": "no measure; shipped_at"}),
97
+ "model:no measure; shipped_at")
98
+
99
+ def test_a_refusal_is_its_own_group(self):
100
+ self.assertEqual(self.key(sr.REFUSAL), "refusal")
101
+
102
+
103
+ class TheLeverComesFromTheOwner(unittest.TestCase):
104
+ """One decision about who owns a failure, not two that drift."""
105
+
106
+ def lever(self, attribution):
107
+ return cf.LEVER_BY_OWNER.get(attribution[1])
108
+
109
+ def test_a_model_gap_is_fixed_in_the_model(self):
110
+ self.assertEqual(self.lever(sr.MODEL), "model")
111
+
112
+ def test_a_mechanical_agent_miss_is_fixed_in_the_skill(self):
113
+ self.assertEqual(self.lever(sr.NEVER_ASKED), "skill")
114
+ self.assertEqual(self.lever(sr.REFUSAL), "skill")
115
+
116
+ def test_an_undecided_owner_names_no_lever(self):
117
+ # Naming one would put back the default blame the taxonomy removed:
118
+ # eval-diagnose decides these, sufficiency first.
119
+ self.assertIsNone(self.lever(sr.DELIVERED))
120
+ self.assertIsNone(self.lever(sr.NOT_RETURNED))
121
+
122
+ def test_an_unknown_owner_names_no_lever(self):
123
+ self.assertIsNone(self.lever(sr.UNMEASURED))
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()