@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
|
@@ -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,
|
|
20
|
-
|
|
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.
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 ==
|
|
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 ==
|
|
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 ==
|
|
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 ==
|
|
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":
|
|
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()
|