@malloy-publisher/skills 0.1.19 → 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 +6 -7
  49. package/skills/malloy-analysis/SKILL.md +22 -45
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +21 -6
  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 +2 -2
  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 +2 -2
  61. package/skills/malloy-gotchas-queries/SKILL.md +2 -2
  62. package/skills/malloy-gotchas-rendering/SKILL.md +2 -2
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
  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 +2 -2
  75. package/skills/malloy-phrase-detection/SKILL.md +12 -7
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +2 -2
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. package/skills/malloy-scope/SKILL.md +7 -4
@@ -59,13 +59,43 @@ def outcome(verdict: str | None) -> str:
59
59
  return "neither"
60
60
 
61
61
 
62
+ # A golden a person has established is WRONG. Its verdict says nothing about
63
+ # the model -- the answer was scored against a key that does not hold -- so it
64
+ # leaves every aggregate.
65
+ EXCLUDED_GOLD = {"verified_wrong"}
66
+
67
+
68
+ def counts_toward_score(gold_status: str | None) -> bool:
69
+ """Does this score belong in the run's aggregates?
70
+
71
+ Exported for the same reason `outcome` is. `run_baseline.py` dropped
72
+ `verified_wrong` before counting, `eval_run.malloy` did not, and the
73
+ package's own doc comment on `gold_status` said it did -- so one
74
+ `verified_wrong` golden made the printed pass rate and the notebook's
75
+ `pass_rate` disagree (92.31% against 91.67% on a real 13-case run) with
76
+ nothing to say which was right. One rule, three readers: this function,
77
+ the `counts` column `build_run_package.py` writes from it, and the
78
+ measures in `eval_run.malloy` that filter on that column.
79
+ """
80
+ return gold_status not in EXCLUDED_GOLD
81
+
82
+
62
83
  def verdicts(run: Path) -> dict[str, dict[str, Any]]:
63
84
  """qid -> the scored outcome, for cases this run actually scored."""
64
85
  out: dict[str, dict[str, Any]] = {}
86
+ queries: dict[str, str | None] = {}
65
87
  for line in (run / "events.jsonl").read_text().splitlines():
66
88
  if not line.strip():
67
89
  continue
68
90
  e = json.loads(line)
91
+ if e.get("kind") == "attempt":
92
+ # The query the arm actually ran. A flip is a matched pair -- same
93
+ # question, same model, one right answer and one wrong one -- and
94
+ # the diff between the two queries is what isolates the cause. It
95
+ # was not read here, so the richest evidence in the run was the one
96
+ # thing the flip table did not print.
97
+ queries[e["qid"]] = e.get("final_query")
98
+ continue
69
99
  if e.get("kind") != "score":
70
100
  continue
71
101
  v = e.get("verdict")
@@ -73,6 +103,7 @@ def verdicts(run: Path) -> dict[str, dict[str, Any]]:
73
103
  "verdict": v,
74
104
  "confidence": e.get("confidence"),
75
105
  "reason": (e.get("reason") or "")[:200],
106
+ "final_query": queries.get(e["qid"]),
76
107
  # near_match and needs_human are neither: counting either as a fail
77
108
  # would manufacture a flip every time the judge hedged in one run
78
109
  # and not the other.
@@ -82,6 +113,30 @@ def verdicts(run: Path) -> dict[str, dict[str, Any]]:
82
113
  return out
83
114
 
84
115
 
116
+ def query_diff(a: dict[str, Any], b: dict[str, Any], la: str, lb: str) -> str:
117
+ """The two queries a flipped case ran, side by side.
118
+
119
+ The count says a case moved; this says what moved it. On a real set five
120
+ flips over 28 cases read as an 18% churn rate, and the five turned out to
121
+ be one question asked two ways -- two discrete query idioms, one returning
122
+ 443 rows and the other 2. Reading the diff would have handed over the
123
+ finding; averaging the five hid it. The queries were already in the ledger
124
+ and this table had never printed them.
125
+
126
+ Identical queries are worth saying too: then the flip is downstream of the
127
+ query, in the judge or in the data, and that is a different search.
128
+ """
129
+ qa, qb = (a.get("final_query") or "").strip(), (b.get("final_query") or "").strip()
130
+ if not qa and not qb:
131
+ return " (neither arm recorded a final query)"
132
+ if qa == qb:
133
+ return (" both arms ran the SAME query, so the flip is downstream "
134
+ "of it:\n the judge, the rubric, or non-determinism in the "
135
+ "data.")
136
+ return (f" {la} ran:\n " + qa.replace("\n", "\n ") +
137
+ f"\n {lb} ran:\n " + qb.replace("\n", "\n "))
138
+
139
+
85
140
  def cost(run: Path) -> dict[str, float]:
86
141
  tot: dict[str, float] = {"usd": 0.0, "turns": 0.0, "seconds": 0.0}
87
142
  for line in (run / "events.jsonl").read_text().splitlines():
@@ -114,6 +169,47 @@ COMPARABLE = ("datasetVersion", "datasetSha", "judgeVersion", "rubricSha",
114
169
  "retrievalMode", "modelGitSha", "targetVersion")
115
170
 
116
171
 
172
+ def completeness_note(ca: dict, cb: dict, la: str, lb: str,
173
+ A: dict, B: dict) -> int:
174
+ """Say which cases one arm excluded and the other scored. Never refuses.
175
+
176
+ This REPLACED a refusal, and the refusal was wrong. It rested on the claim
177
+ that an excluded case "reads as a flip", and it does not: a flip requires a
178
+ real pass or fail on BOTH sides (`a_only` / `b_only` below), so a case
179
+ carrying `verdict: null` on either side lands in `unscored` and contributes
180
+ to no flip count. The pairing already handles this, and the header already
181
+ prints "N cases, M scored in both, K not". Refusing a 99-versus-100 pair
182
+ threw away 99 good comparisons to avoid a distortion that was not there.
183
+
184
+ What IS worth saying is the asymmetry, which nothing named before. The
185
+ excluded cases are not a random sample: an attempt truncates BECAUSE it ran
186
+ long, so the cases one arm drops are its hard ones, and the surviving
187
+ comparison is over an easier subset than the case list suggests. That is a
188
+ caveat to carry into the number, not a reason to withhold it.
189
+
190
+ An `aborted` arm needs nothing here either: it stopped early, so its cases
191
+ are absent rather than unscored, and the existing "the runs do not cover
192
+ the same cases" check already exits 1 on it.
193
+ """
194
+ for label, cfg, mine, theirs in ((la, ca, A, B), (lb, cb, B, A)):
195
+ excluded = [q for q in sorted(set(mine) & set(theirs))
196
+ if mine[q]["passed"] is None
197
+ and theirs[q]["passed"] is not None]
198
+ if not excluded:
199
+ continue
200
+ why = ", ".join(
201
+ [f"{len(cfg.get('truncated') or [])} truncated"]
202
+ * bool(cfg.get("truncated"))
203
+ + [f"{len(cfg.get('contaminated') or [])} contaminated"]
204
+ * bool(cfg.get("contaminated"))) or "unscored"
205
+ print(f"\n ! {label} left {len(excluded)} case(s) unscored that "
206
+ f"the other arm scored ({why}): {', '.join(excluded)}")
207
+ print(" They are out of the flips above, so the comparison holds --"
208
+ " but an attempt truncates BECAUSE it ran long, so these are not"
209
+ " a random sample of the set.")
210
+ return 0
211
+
212
+
117
213
  def retrieval_gate(ca: dict, cb: dict, la: str, lb: str,
118
214
  allow: bool) -> int:
119
215
  """Refuse a pair whose runs used different retrievers.
@@ -402,13 +498,21 @@ def main() -> int:
402
498
  f" ({', '.join(f'{v} {k}' for k, v in sorted(tally.items()))})")
403
499
 
404
500
  if a_only or b_only:
405
- print(f"\nthe flips\n---------")
501
+ print(f"\nthe disagreement set\n--------------------")
502
+ print(" Read these before averaging them. A flip is a case where the "
503
+ "agent found\n two paths and the model did not make one of them "
504
+ "obviously right, which is\n a model-quality signal and a "
505
+ "matched pair: same question, same model, one\n right answer "
506
+ "and one wrong one. The band says how much movement there is;\n"
507
+ " these say what it IS.\n")
406
508
  for q in a_only:
407
509
  print(f" {q}\n {la}: {A[q]['verdict']} -> "
408
510
  f"{lb}: {B[q]['verdict']}\n {B[q]['reason'][:150]}")
511
+ print(query_diff(A[q], B[q], la, lb))
409
512
  for q in b_only:
410
513
  print(f" {q}\n {la}: {A[q]['verdict']} -> "
411
- f"{lb}: {B[q]['verdict']}\n {B[q]['reason'][:150]}")
514
+ f"{lb}: {B[q]['verdict']}\n {A[q]['reason'][:150]}")
515
+ print(query_diff(A[q], B[q], la, lb))
412
516
 
413
517
  if a_args.targets:
414
518
  targeted_report(a_args, A, B, la, lb,
@@ -443,6 +547,7 @@ def main() -> int:
443
547
 
444
548
  gate = retrieval_gate(cfg_a, cfg_b, la, lb,
445
549
  a_args.allow_retrieval_mismatch)
550
+ completeness_note(cfg_a, cfg_b, la, lb, A, B)
446
551
 
447
552
  ca, cb = cost(a_args.a), cost(a_args.b)
448
553
  print(f"\ncost\n----")
@@ -146,6 +146,70 @@ class Calibration(unittest.TestCase):
146
146
  self.assertIn("sonnet / opus", block)
147
147
 
148
148
 
149
+ class CompletenessNote(unittest.TestCase):
150
+ """The asymmetry between two arms' unscored cases, reported not refused.
151
+
152
+ This replaced a refusal that was based on a false claim: that a case one
153
+ arm excluded "reads as a flip". It cannot. A flip needs a real pass or fail
154
+ on BOTH sides, so a null verdict on either lands in `unscored` and counts
155
+ toward no flip. Refusing a 99-versus-100 pair discarded 99 good
156
+ comparisons to avoid a distortion that was not there.
157
+ """
158
+
159
+ def verdicts(self, **qids):
160
+ return {q: {"verdict": v, "passed": p, "outcome": "x", "reason": "",
161
+ "confidence": None}
162
+ for q, (v, p) in qids.items()}
163
+
164
+ def test_an_excluded_case_cannot_become_a_flip(self):
165
+ # The claim the refusal rested on, tested directly against the pairing
166
+ # rule the report uses.
167
+ A = self.verdicts(q1=("match", True), q2=(None, None))
168
+ B = self.verdicts(q1=("match", True), q2=("no_match", False))
169
+ shared = sorted(A)
170
+ a_only = [q for q in shared if A[q]["passed"] and B[q]["passed"] is False]
171
+ b_only = [q for q in shared if A[q]["passed"] is False and B[q]["passed"]]
172
+ unscored = [q for q in shared
173
+ if A[q]["passed"] is None or B[q]["passed"] is None]
174
+ self.assertEqual(a_only + b_only, [])
175
+ self.assertEqual(unscored, ["q2"])
176
+
177
+ def test_it_never_refuses(self):
178
+ A = self.verdicts(q1=("match", True), q2=(None, None))
179
+ B = self.verdicts(q1=("match", True), q2=("no_match", False))
180
+ self.assertEqual(
181
+ ft.completeness_note({"status": "incomplete", "truncated": ["q2"]},
182
+ {"status": "complete"}, "a", "b", A, B), 0)
183
+
184
+ def test_two_complete_arms_say_nothing(self):
185
+ A = B = self.verdicts(q1=("match", True))
186
+ out = io.StringIO()
187
+ with contextlib.redirect_stdout(out):
188
+ ft.completeness_note({}, {}, "a", "b", A, B)
189
+ self.assertEqual(out.getvalue(), "")
190
+
191
+ def test_it_names_the_cases_and_why(self):
192
+ A = self.verdicts(q1=("match", True), q2=(None, None))
193
+ B = self.verdicts(q1=("match", True), q2=("no_match", False))
194
+ out = io.StringIO()
195
+ with contextlib.redirect_stdout(out):
196
+ ft.completeness_note({"truncated": ["q2"]}, {}, "arm-a", "arm-b",
197
+ A, B)
198
+ body = out.getvalue()
199
+ self.assertIn("arm-a left 1 case(s) unscored", body)
200
+ self.assertIn("1 truncated", body)
201
+ self.assertIn("q2", body)
202
+ # The caveat that is actually true: the dropped cases are the long ones.
203
+ self.assertIn("BECAUSE it ran long", body)
204
+
205
+ def test_the_answerer_model_is_already_a_pin(self):
206
+ # Not this note's job, and worth pinning so nobody adds a second
207
+ # mechanism: an arm on one model against another is already reported
208
+ # as a differing pin. That was the largest uncontrolled variable in the
209
+ # run this came from.
210
+ self.assertIn("answererModel", ft.COMPARABLE)
211
+
212
+
149
213
  class Outcome(unittest.TestCase):
150
214
  def test_near_match_is_neither(self):
151
215
  self.assertEqual(ft.outcome("near_match"), "neither")
@@ -153,6 +217,107 @@ class Outcome(unittest.TestCase):
153
217
  def test_an_unknown_verdict_is_never_a_fail(self):
154
218
  self.assertEqual(ft.outcome("invented"), "neither")
155
219
 
220
+ class CountsTowardScore(unittest.TestCase):
221
+ """One rule, three readers, because two of them had drifted.
222
+
223
+ `run_baseline.py` dropped a `verified_wrong` golden before counting and
224
+ `eval_run.malloy` did not, while the package's own doc comment on
225
+ `gold_status` said it did. On a real 13-case run with one such golden the
226
+ printed rate read 91.67% and the notebook's `pass_rate` read 92.31%, off
227
+ the same rows, with nothing to say which was right.
228
+ """
229
+
230
+ def test_a_verified_wrong_golden_leaves_the_aggregates(self):
231
+ self.assertFalse(ft.counts_toward_score("verified_wrong"))
232
+
233
+ def test_every_other_status_counts(self):
234
+ for s in ("verified", "verified_benign", "suspect", "provisional",
235
+ "ambiguous", "invalid"):
236
+ with self.subTest(status=s):
237
+ self.assertTrue(ft.counts_toward_score(s))
238
+
239
+ def test_an_absent_status_counts(self):
240
+ # A run predating gold_status must not silently lose every case.
241
+ self.assertTrue(ft.counts_toward_score(None))
242
+
243
+ def test_run_baseline_reads_this_rule_rather_than_restating_it(self):
244
+ src = (pathlib.Path(__file__).resolve().parent
245
+ / "run_baseline.py").read_text()
246
+ # The AGGREGATE site reads the helper. The other `verified_wrong`
247
+ # test in that file is a different question -- whether a case is
248
+ # scorable at all, from the golden's own status, before an attempt --
249
+ # and is correctly separate.
250
+ self.assertIn('counts_toward_score(v.get("gold_status"))', src)
251
+ self.assertNotIn('v.get("gold_status") == "verified_wrong"', src)
252
+
253
+ def test_the_flattener_writes_it_as_a_column(self):
254
+ src = (pathlib.Path(__file__).resolve().parent
255
+ / "build_run_package.py").read_text()
256
+ self.assertIn('"counts": counts_toward_score(', src)
257
+ self.assertIn('"counts", "reason"', src)
258
+
259
+ def test_the_malloy_measures_filter_on_that_column(self):
260
+ malloy = (pathlib.Path(__file__).resolve().parent.parent
261
+ / "templates" / "eval-run-package" / "eval_run.malloy").read_text()
262
+ for m in ("confident_count", "passed", "failed", "near_matches",
263
+ "needs_human"):
264
+ line = next(l for l in malloy.splitlines()
265
+ if l.strip().startswith(f"{m} is count()"))
266
+ self.assertIn("counts = 'true'", line, f"{m} does not exclude it")
267
+ # Written as a string because flatten() serialises a bool that way.
268
+ self.assertIn("public: counts", malloy)
269
+
270
+ class DisagreementSet(unittest.TestCase):
271
+ """A flip is a matched pair, and the table has to print the pair.
272
+
273
+ The count says a case moved; the two queries say what moved it. On a real
274
+ set five flips over 28 cases read as an 18% churn rate and were one
275
+ question asked two ways, the wrong idiom returning 2 rows against 443.
276
+ That took a day of transcript reading because this table printed only the
277
+ verdicts.
278
+ """
279
+
280
+ def test_it_prints_both_queries(self):
281
+ out = ft.query_diff({"final_query": "run: a -> { x }"},
282
+ {"final_query": "run: b -> { y }"}, "arm1", "arm2")
283
+ self.assertIn("run: a -> { x }", out)
284
+ self.assertIn("run: b -> { y }", out)
285
+ self.assertIn("arm1", out)
286
+ self.assertIn("arm2", out)
287
+
288
+ def test_an_identical_query_is_called_out_as_a_different_finding(self):
289
+ q = "run: same -> { x }"
290
+ out = ft.query_diff({"final_query": q}, {"final_query": q}, "a", "b")
291
+ self.assertIn("SAME query", out)
292
+ self.assertIn("downstream", out)
293
+
294
+ def test_a_missing_query_says_so_rather_than_printing_nothing(self):
295
+ out = ft.query_diff({}, {}, "a", "b")
296
+ self.assertIn("neither arm recorded", out)
297
+
298
+ def test_one_side_missing_still_shows_the_other(self):
299
+ out = ft.query_diff({"final_query": "run: a"}, {}, "a", "b")
300
+ self.assertIn("run: a", out)
301
+
302
+ def test_verdicts_carries_the_final_query(self):
303
+ run = pathlib.Path(tempfile.mkdtemp()) / "r"
304
+ run.mkdir()
305
+ (run / "events.jsonl").write_text(
306
+ json.dumps({"kind": "attempt", "qid": "q1",
307
+ "final_query": "run: flights -> { x }"}) + "\n" +
308
+ json.dumps({"kind": "score", "qid": "q1", "verdict": "match"}) + "\n")
309
+ v = ft.verdicts(run)
310
+ self.assertEqual(v["q1"]["final_query"], "run: flights -> { x }")
311
+
312
+ def test_a_run_with_no_attempt_event_still_scores(self):
313
+ # Older runs, and a --rebuild that wrote scores only.
314
+ run = pathlib.Path(tempfile.mkdtemp()) / "r"
315
+ run.mkdir()
316
+ (run / "events.jsonl").write_text(
317
+ json.dumps({"kind": "score", "qid": "q1", "verdict": "match"}) + "\n")
318
+ v = ft.verdicts(run)
319
+ self.assertTrue(v["q1"]["passed"])
320
+ self.assertIsNone(v["q1"]["final_query"])
156
321
 
157
322
  if __name__ == "__main__":
158
323
  unittest.main()