@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
@@ -9,10 +9,13 @@ import sys
9
9
  import tempfile
10
10
  import unittest
11
11
 
12
+ import score_retrieval
13
+
12
14
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
15
 
14
16
  from score_retrieval import ( # noqa: E402
15
- attribute, main, score_case, summarise,
17
+ MEASURED_GAPS, MEASURED_OK, attribute, cascade, coverage_report_summary,
18
+ load_coverage_report, main, score_case, summarise,
16
19
  )
17
20
 
18
21
  M_SALES = "measure:order_items:total_sales"
@@ -39,6 +42,12 @@ M_SALES_ALIAS = "measure:orders:total_sales" # same type + name, sibling sou
39
42
  M_REVENUE = "measure:order_items:revenue"
40
43
 
41
44
 
45
+ def calls_with_targets(entities, targets, qid="q"):
46
+ return [{"kind": "tool_call", "tool": "get_context", "qid": qid,
47
+ "sample": None, "phase": "baseline", "targets": list(targets),
48
+ "rankedSummary": {"entityIds": list(entities)}}]
49
+
50
+
42
51
  def calls_with_docs(entities, tokens, qid="q"):
43
52
  return [{"kind": "tool_call", "tool": "get_context", "qid": qid,
44
53
  "sample": None, "phase": "baseline",
@@ -94,11 +103,39 @@ class AnyOf(unittest.TestCase):
94
103
  self.assertEqual(r["missing"], [f"{M_REVENUE} | {M_SALES}"]
95
104
  if f"{M_REVENUE} | {M_SALES}" in r["missing"]
96
105
  else [f"{M_SALES} | {M_REVENUE}"])
97
- self.assertEqual(r["where_to_fix"], "retrieval ranking")
106
+ # No targets recorded at all, so nothing proves the agent asked for a
107
+ # measure; the honest read is the ownerless one.
108
+ self.assertEqual(r["where_to_fix"], "never asked")
98
109
 
99
- def test_full_delivery_with_a_wrong_answer_is_construction(self):
110
+ def test_full_delivery_with_a_wrong_answer_is_delivered_wrong(self):
100
111
  r = score_case(self.group_case(), calls([D_STATUS, M_REVENUE]), KEY, "no_match")
101
- self.assertEqual(r["where_to_fix"], "query construction")
112
+ self.assertEqual(r["where_to_fix"], "delivered, wrong")
113
+
114
+ def test_a_satisfied_group_does_not_make_its_unused_route_unasked(self):
115
+ """The route not taken is not a miss, so its KIND is not unasked-for.
116
+
117
+ Collecting every undelivered member regardless of whether its group
118
+ was satisfied let a satisfied group contribute a phantom kind, which
119
+ `attribute` read as NEVER-ASKED. A case whose only real miss was a
120
+ dimension the agent DID search for came back owned by agent-skill,
121
+ "no search asked for a join at all" -- charging the skills team for a
122
+ route the case never needed.
123
+ """
124
+ c = {"qid": "q", "coverage": "covered",
125
+ "expectedEntities": {
126
+ "required": [D_STATUS],
127
+ "requiredAnyOf": [[M_SALES, "join:order_items:users"]]}}
128
+ # The agent asked for exactly the kinds it needed, never for a join,
129
+ # and the group is satisfied through its measure route.
130
+ events = calls_with_targets([M_SALES],
131
+ ["dimension: order status",
132
+ "measure: total sales"])
133
+ r = score_case(c, events, KEY, "no_match")
134
+ self.assertEqual(r["missing"], [D_STATUS])
135
+ self.assertEqual(r["recall"], 0.5)
136
+ self.assertEqual(r["where_to_fix"], "not retrieved")
137
+ self.assertEqual(r["owner"], "undecided")
138
+ self.assertNotIn("join", r["why"])
102
139
 
103
140
 
104
141
  class Recall(unittest.TestCase):
@@ -160,25 +197,95 @@ class Precision(unittest.TestCase):
160
197
 
161
198
 
162
199
  class Attribution(unittest.TestCase):
163
- def test_full_recall_and_a_wrong_answer_blames_construction(self):
200
+ def test_full_recall_and_a_wrong_answer_names_no_owner(self):
201
+ # Everything arrived. eval-diagnose attributes construction only after
202
+ # sufficiency -- WRONG-PICK is the model's if the docs did not
203
+ # distinguish the candidates -- so charging the agent here filed
204
+ # documentation gaps as skills bugs.
164
205
  r = score_case(case(), calls([M_SALES]), KEY, "no_match")
165
206
  self.assertEqual((r["component"], r["owner"]),
166
- ("construction", "agent-skill"))
207
+ ("construction", "undecided"))
208
+ self.assertEqual(r["where_to_fix"], "delivered, wrong")
209
+
210
+ def test_a_miss_after_a_search_of_the_right_kind_names_no_owner(self):
211
+ # The entity exists and a measure search was issued, and it still did
212
+ # not come back. Docs or search wording; eval-diagnose separates
213
+ # NOT-RETURNED from QUESTION-VOCAB. The run must not pick.
214
+ r = score_case(case(coverage="covered"),
215
+ calls_with_targets([], ["measure: total sales"]),
216
+ KEY, "no_match")
217
+ self.assertEqual(r["where_to_fix"], "not retrieved")
218
+ self.assertEqual(r["owner"], "undecided")
219
+
220
+ def test_a_miss_with_no_search_of_that_kind_is_the_agents(self):
221
+ # The regression, and it is from a real run: the agent searched only
222
+ # `source:` and `dimension:` for "how many titles were released in
223
+ # 2019?", so the measure could not come back, and a documented measure
224
+ # was blamed on its docs. eval-diagnose calls this NEVER-ASKED.
225
+ r = score_case(case(coverage="covered"),
226
+ calls_with_targets([], ["source: titles",
227
+ "dimension: release year"]),
228
+ KEY, "no_match")
229
+ self.assertEqual(r["where_to_fix"], "never asked")
230
+ self.assertEqual(r["owner"], "agent-skill")
231
+ self.assertIn("measure", r["why"])
232
+
233
+ def test_the_kinds_the_agent_searched_are_recorded(self):
234
+ # So a reader can judge the search instead of taking the label's word,
235
+ # and so a set can be surveyed for the vocabulary its questions need.
236
+ r = score_case(case(), calls_with_targets([M_SALES],
237
+ ["measure: total sales"]),
238
+ KEY, "match")
239
+ self.assertEqual(r["asked_kinds"], ["measure"])
167
240
 
168
- def test_a_missed_entity_that_exists_blames_retrieval(self):
169
- r = score_case(case(coverage="covered"), calls([]), KEY, "no_match")
170
- self.assertEqual((r["component"], r["owner"]),
171
- ("get_context/retrieval", "retrieval"))
241
+ def test_typed_targets_are_read_as_well_as_prefixed_strings(self):
242
+ r = score_case(case(coverage="covered"),
243
+ calls_with_targets([], [{"target_type": "measure",
244
+ "search_text": "sales"}]),
245
+ KEY, "no_match")
246
+ self.assertEqual(r["where_to_fix"], "not retrieved")
172
247
 
173
248
  def test_a_missed_entity_that_does_not_exist_blames_the_model(self):
249
+ # `derivable` is a MEASURED label: someone looked and found nothing to
250
+ # surface. That stays the model's.
174
251
  r = score_case(case(coverage="derivable"), calls([]), KEY, "no_match")
175
252
  self.assertEqual((r["component"], r["owner"]), ("get_context/model", "model"))
176
253
 
177
- def test_a_passing_attempt_is_attributed_to_nobody(self):
254
+ def test_a_missed_entity_with_no_coverage_label_blames_nobody_yet(self):
255
+ # The bug: with no authored label, `case.get("coverage", "unknown")`
256
+ # fell through to MODEL with "coverage is unknown, so the entity does
257
+ # not exist" -- a model gap asserted on no evidence. A set that arrives
258
+ # as bare questions has no labels, so every retrieval failure in it was
259
+ # attributed to the model.
260
+ c = {"qid": "q", "expectedEntities": {"required": [M_SALES]}}
261
+ r = score_case(c, calls([]), KEY, "no_match")
262
+ self.assertEqual(r["where_to_fix"], "coverage not measured")
263
+ self.assertEqual(r["owner"], "unknown")
264
+ self.assertNotEqual(r["component"], "get_context/model")
265
+ self.assertTrue(r["failed"], "still a failure; only the owner is undecided")
266
+
267
+ def test_an_explicit_unknown_label_is_also_unmeasured(self):
268
+ r = score_case(case(coverage="unknown"), calls([]), KEY, "no_match")
269
+ self.assertEqual(r["where_to_fix"], "coverage not measured")
270
+
271
+ def test_a_miss_is_attributed_even_when_the_answer_was_right(self):
272
+ # An answer that came out right WITHOUT a required entity was right by
273
+ # another route -- usually the agent rebuilding the model's own measure
274
+ # inline, which holds only while the measure is trivial. Attributing
275
+ # the miss anyway is what makes it visible: measured on one run, 4 of 5
276
+ # undelivered entities sat on passing cases.
178
277
  for verdict in ("match", "near_match"):
179
278
  r = score_case(case(), calls([]), KEY, verdict)
180
- self.assertEqual(r["where_to_fix"], "",
181
- f"{verdict} should not be a failure")
279
+ self.assertTrue(r["where_to_fix"],
280
+ f"{verdict} with an undelivered entity must attribute")
281
+ # The pass rate is untouched: the answer is still not a failure.
282
+ self.assertFalse(r["failed"])
283
+ self.assertEqual(r["verdict"], verdict)
284
+
285
+ def test_a_pass_that_received_everything_has_nothing_to_attribute(self):
286
+ r = score_case(case(), calls([M_SALES]), KEY, "match")
287
+ self.assertEqual(r["recall"], 1.0)
288
+ self.assertEqual(r["where_to_fix"], "")
182
289
 
183
290
  def test_an_unscored_verdict_is_attributed_to_nobody(self):
184
291
  # needs_human and null are neither passes nor failures. Attributing them
@@ -204,8 +311,348 @@ class Attribution(unittest.TestCase):
204
311
  r = score_case(c, calls([]), KEY, "match")
205
312
  self.assertEqual(r["where_to_fix"], "")
206
313
 
314
+ def test_a_covered_case_naming_no_entities_is_not_a_refusal(self):
315
+ """`recall is None` used to mean one thing and now means two.
316
+
317
+ A case with no `expectedEntities` reaches the same branch as an
318
+ `absent`-coverage case, and took its label: a wrong answer to a
319
+ perfectly ANSWERABLE question was reported as "the model cannot answer
320
+ this and the answerer did not decline", blaming refusal behaviour for
321
+ a key the author was told to leave out.
322
+ """
323
+ c = {"qid": "q", "coverage": "covered",
324
+ "expectedEntities": {"required": [], "acceptable": []}}
325
+ r = score_case(c, calls([M_SALES]), KEY, "no_match")
326
+ self.assertIsNone(r["recall"])
327
+ self.assertEqual(r["where_to_fix"], "retrieval not measured")
328
+ self.assertNotEqual(r["where_to_fix"], "refusal behaviour")
329
+ self.assertIn("names no required entities", r["why"])
330
+
331
+ def test_it_names_no_owner_it_has_not_established(self):
332
+ # `unknown` yields no lever in LEVER_BY_OWNER, which is the point:
333
+ # nothing here knows whether this was retrieval, the docs or the agent.
334
+ c = {"qid": "q", "coverage": "covered", "expectedEntities": {}}
335
+ r = score_case(c, calls([M_SALES]), KEY, "no_match")
336
+ self.assertEqual(r["owner"], "unknown")
337
+
338
+ def test_an_absent_case_still_reads_as_a_refusal(self):
339
+ # The branch that was always right keeps its label.
340
+ c = {"qid": "q", "coverage": "absent", "expectedEntities": {}}
341
+ r = score_case(c, calls([]), KEY, "no_match")
342
+ self.assertEqual(r["where_to_fix"], "refusal behaviour")
343
+
344
+ def test_a_covered_case_naming_no_entities_passes_unattributed(self):
345
+ c = {"qid": "q", "coverage": "covered", "expectedEntities": {}}
346
+ r = score_case(c, calls([M_SALES]), KEY, "match")
347
+ self.assertEqual(r["where_to_fix"], "")
348
+
349
+
350
+ class MeasuredCoverage(unittest.TestCase):
351
+ """check_coverage.py's verdict reaches attribution, and beats the label.
352
+
353
+ The `--out` report it writes was read by nothing in the repo. The authored
354
+ `coverage` label is a standing hand judgement about the question; the
355
+ verdict is a measurement against this build, which is what an attribution
356
+ is about.
357
+ """
358
+
359
+ def test_a_measured_ok_with_a_miss_is_a_retrieval_rung_finding(self):
360
+ r = score_case(case(coverage="derivable"), calls([]), KEY, "no_match",
361
+ measured="ok")
362
+ self.assertIn(r["where_to_fix"], ("not retrieved", "never asked"))
363
+ self.assertEqual(r["coverage_source"], "measured")
364
+
365
+ def test_a_measured_gap_blames_the_model_and_names_the_code(self):
366
+ r = score_case(case(coverage="covered"), calls([]), KEY, "no_match",
367
+ measured="NO-DISAMBIG")
368
+ self.assertEqual(r["where_to_fix"], "model coverage")
369
+ self.assertIn("NO-DISAMBIG", r["why"])
370
+
371
+ def test_measurement_beats_the_authored_label(self):
372
+ # Label says covered (retrieval's fault); measurement says the model
373
+ # has no representing entity (model's fault). The measurement wins.
374
+ r = score_case(case(coverage="covered"), calls([]), KEY, "no_match",
375
+ measured="COVERAGE")
376
+ self.assertEqual(r["owner"], "model")
377
+
378
+ def test_an_undecided_measurement_falls_back_to_the_label(self):
379
+ r = score_case(case(coverage="covered"), calls([]), KEY, "no_match",
380
+ measured=None)
381
+ self.assertEqual(r["coverage_source"], "authored")
382
+ self.assertIn(r["where_to_fix"], ("not retrieved", "never asked"))
383
+
384
+ def test_no_label_and_no_measurement_charges_nobody(self):
385
+ c = {"qid": "q", "expectedEntities": {"required": [M_SALES]}}
386
+ r = score_case(c, calls([]), KEY, "no_match")
387
+ self.assertEqual(r["coverage_source"], "none")
388
+ self.assertEqual(r["where_to_fix"], "coverage not measured")
389
+
390
+ def test_gap_vocabulary_matches_check_coverage(self):
391
+ # score_retrieval stays stdlib-only and does not import check_coverage,
392
+ # so this is the one place the two files are held to the same codes.
393
+ import check_coverage as cc
394
+ for v in cc.FAIL_VERDICTS:
395
+ self.assertIn(v, MEASURED_GAPS, v)
396
+ self.assertEqual(cc.OK, MEASURED_OK)
397
+
398
+ def test_load_coverage_report_keys_verdicts_by_qid(self):
399
+ with tempfile.TemporaryDirectory() as d:
400
+ path = os.path.join(d, "cov.json")
401
+ with open(path, "w") as fh:
402
+ json.dump({"version": "0.0.58", "cases_detail": [
403
+ {"qid": "a", "verdict": "ok"},
404
+ {"qid": "b", "verdict": None}]}, fh)
405
+ self.assertEqual(load_coverage_report(path), {"a": "ok", "b": None})
406
+
407
+ def test_coverage_report_summary_records_what_run_json_needs(self):
408
+ # The file, the version, the judge, and decided-of-cases. Not the
409
+ # percentage: 4 of 49 decided is a sample size, not a coverage number.
410
+ with tempfile.TemporaryDirectory() as d:
411
+ path = os.path.join(d, "cov.json")
412
+ with open(path, "w") as fh:
413
+ json.dump({"version": "0.0.58", "agentModel": "sonnet",
414
+ "cases": 49, "decided": 45, "ok": 22,
415
+ "coverage": 0.489, "cases_detail": []}, fh)
416
+ got = coverage_report_summary(path)
417
+ self.assertEqual(got, {"path": path, "version": "0.0.58",
418
+ "agentModel": "sonnet", "decided": 45,
419
+ "cases": 49})
420
+
421
+
422
+ class Cascade(unittest.TestCase):
423
+ """Each metric conditions the next, so the report is a funnel, and the rungs
424
+ must sum to the rows or a case has fallen between them."""
425
+
426
+ def rows(self):
427
+ bare = {"qid": "q", "expectedEntities": {"required": [M_SALES]}}
428
+ return [
429
+ score_case(case(coverage="derivable"), calls([]), KEY, "no_match"),
430
+ score_case(bare, calls([]), KEY, "no_match"),
431
+ score_case(case(), calls([]), KEY, "no_match"),
432
+ score_case(case(), calls([M_SALES]), KEY, "no_match"),
433
+ score_case(case(), calls([M_SALES]), KEY, "match"),
434
+ score_case(case(), calls([M_SALES]), KEY, "needs_human"),
435
+ ]
436
+
437
+ # `passed_not_covered` and `passed_not_retrieved` OVERLAY the rungs: they
438
+ # re-count rows already counted by "not covered" and "not retrieved". They
439
+ # are not rungs and must never be added to them.
440
+ OVERLAY = ("total", "passed_not_covered", "passed_not_retrieved",
441
+ "passed_unmeasured", "passed_no_entities_named")
442
+
443
+ def test_every_row_lands_on_exactly_one_rung(self):
444
+ c = cascade(self.rows())
445
+ self.assertEqual(c["total"], 6)
446
+ self.assertEqual(sum(v for k, v in c.items()
447
+ if k not in self.OVERLAY), 6)
448
+
449
+ def test_the_rungs_never_revise_the_pass_rate(self):
450
+ """The rule a hand-written report broke, on a run that was 10 of 10.
451
+
452
+ Rendered as a shrinking funnel -- 5 of 10 covered, 3 of those 5
453
+ retrieved, 3 of those 3 correct -- the last rung reads as "3 of 10
454
+ succeeded" on a run where every answer was right. The three numbers
455
+ that reconcile it are `delivered, right` plus the two overlays, and a
456
+ report that drops them cannot be checked. So pin the identity.
457
+ """
458
+ rows = [
459
+ # passes that stop on the coverage rung
460
+ score_case(case(coverage="derivable"), calls([M_SALES]), KEY, "match"),
461
+ score_case(case(coverage="absent"), calls([M_SALES]), KEY, "match"),
462
+ # a pass that stops on the retrieval rung
463
+ score_case(case(required=(M_SALES, M_COUNT)), calls([M_SALES]),
464
+ KEY, "match"),
465
+ # a pass that goes all the way
466
+ score_case(case(), calls([M_SALES]), KEY, "match"),
467
+ # and a genuine failure, which the last rung DOES own
468
+ score_case(case(), calls([M_SALES]), KEY, "no_match"),
469
+ ]
470
+ c = cascade(rows)
471
+ passes = self.passes(c)
472
+ self.assertEqual(passes, 4, "four of the five rows matched")
473
+ self.assertEqual(c["delivered, right"], 1)
474
+ self.assertNotEqual(c["delivered, right"], passes,
475
+ "the last rung alone must not be read as the score")
476
+
477
+ @staticmethod
478
+ def passes(c):
479
+ """The identity, spelled once. EVERY rung that can hold a pass is a
480
+ term; a rung without one silently drops the passes that land there."""
481
+ return (c["delivered, right"] + c["passed_not_covered"]
482
+ + c["passed_not_retrieved"] + c["passed_unmeasured"]
483
+ + c["passed_no_entities_named"])
484
+
485
+ def test_the_identity_holds_for_the_ORDINARY_case_shape(self):
486
+ """The shape the first version of this test never built.
487
+
488
+ `expectedEntities` is "almost never present" (case-format.md), so a
489
+ case with no required entities and a case with no coverage label are
490
+ not edge shapes -- they are what most of a real set looks like. Both
491
+ landed on rungs that had no pass counter, so every pass on them left
492
+ the identity and the funnel under-reconciled against a correct
493
+ headline pass rate.
494
+ """
495
+ no_entities = {"qid": "q", "coverage": "covered"}
496
+ no_coverage = {"qid": "q", "expectedEntities": {"required": []}}
497
+ rows = [
498
+ score_case(no_entities, calls([M_SALES]), KEY, "match"),
499
+ score_case(no_coverage, calls([M_SALES]), KEY, "match"),
500
+ score_case(case(), calls([M_SALES]), KEY, "match"),
501
+ ]
502
+ c = cascade(rows)
503
+ self.assertEqual(c["no entities named"], 1)
504
+ self.assertEqual(c["unmeasured"], 1)
505
+ self.assertEqual(c["delivered, right"], 1)
506
+ self.assertEqual(self.passes(c), 3, "all three matched")
507
+
508
+ def test_every_rung_that_can_hold_a_pass_has_a_counter(self):
509
+ """Structural, so a rung added later cannot repeat this.
510
+
511
+ A passing row is built for each rung in turn and the identity must
512
+ account for it. Without the two new counters this fails on the two
513
+ rungs that had none.
514
+ """
515
+ for label, c in [
516
+ ("not covered", cascade([score_case(
517
+ case(coverage="derivable"), calls([M_SALES]), KEY, "match")])),
518
+ ("unmeasured", cascade([score_case(
519
+ {"qid": "q", "expectedEntities": {"required": []}},
520
+ calls([M_SALES]), KEY, "match")])),
521
+ ("no entities named", cascade([score_case(
522
+ {"qid": "q", "coverage": "covered"}, calls([M_SALES]), KEY,
523
+ "match")])),
524
+ ("not retrieved", cascade([score_case(
525
+ case(required=(M_SALES, M_COUNT)), calls([M_SALES]), KEY,
526
+ "match")])),
527
+ ("delivered, right", cascade([score_case(
528
+ case(), calls([M_SALES]), KEY, "match")])),
529
+ ]:
530
+ with self.subTest(rung=label):
531
+ self.assertEqual(c[label], 1)
532
+ self.assertEqual(self.passes(c), 1,
533
+ f"the pass on {label!r} left the identity")
534
+
535
+ def test_the_rungs(self):
536
+ c = cascade(self.rows())
537
+ self.assertEqual(c["not covered"], 1)
538
+ self.assertEqual(c["unmeasured"], 1)
539
+ self.assertEqual(c["not retrieved"], 1)
540
+ self.assertEqual(c["delivered, wrong"], 1)
541
+ self.assertEqual(c["delivered, right"], 1)
542
+ self.assertEqual(c["not scored"], 1)
543
+
544
+ def test_a_pass_on_an_earlier_rung_is_counted_there(self):
545
+ # A real run: every case matched, yet the last rung read 6 because two
546
+ # passed despite a coverage gap and incomplete retrieval. 6 is exactly
547
+ # the number a reader mistakes for the pass rate.
548
+ rows = [
549
+ score_case(case(coverage="derivable"), calls([M_SALES]), KEY, "match"),
550
+ score_case(case(required=(M_SALES, M_COUNT)), calls([M_SALES]),
551
+ KEY, "match"),
552
+ score_case(case(), calls([M_SALES]), KEY, "match"),
553
+ ]
554
+ c = cascade(rows)
555
+ self.assertEqual(c["passed_not_covered"], 1)
556
+ self.assertEqual(c["passed_not_retrieved"], 1)
557
+ self.assertEqual(c["delivered, right"], 1)
558
+ # and the three still sum
559
+ self.assertEqual(c["not covered"] + c["not retrieved"]
560
+ + c["delivered, right"], 3)
561
+
562
+ def test_a_measured_ok_counts_as_covered(self):
563
+ c = cascade([score_case(case(coverage="derivable"), calls([M_SALES]),
564
+ KEY, "match", measured="ok")])
565
+ self.assertEqual(c["delivered, right"], 1)
566
+
567
+ def test_no_rows_is_an_empty_funnel_not_a_crash(self):
568
+ self.assertEqual(cascade([])["total"], 0)
569
+
570
+
571
+ class LabelsMatchTheRunPackage(unittest.TestCase):
572
+ """The run package's Malloy model counts failures by filtering on these
573
+ labels as string literals. A label renamed here and not there makes that
574
+ measure read zero forever, silently: the exact unearned number this script
575
+ exists to prevent. So the two are held equal, and a new label must come with
576
+ a measure."""
577
+
578
+ def test_the_template_filters_on_exactly_the_labels_emitted(self):
579
+ import re
580
+ from score_retrieval import (DELIVERED, MODEL, NEVER_ASKED,
581
+ NOT_RETURNED, REFUSAL, UNMEASURED)
582
+ here = os.path.dirname(os.path.abspath(__file__))
583
+ tpl = os.path.join(here, "..", "..", "eval-loop", "templates",
584
+ "eval-run-package", "eval_run.malloy")
585
+ with open(tpl) as fh:
586
+ literals = set(re.findall(r"where_to_fix = '([^']+)'", fh.read()))
587
+ emitted = {t[2] for t in (DELIVERED, MODEL, NEVER_ASKED, NOT_RETURNED,
588
+ REFUSAL, UNMEASURED)}
589
+ self.assertEqual(literals, emitted)
590
+
591
+ def package_file(self, *parts):
592
+ here = os.path.dirname(os.path.abspath(__file__))
593
+ with open(os.path.join(here, "..", "..", "eval-loop", "templates",
594
+ "eval-run-package", *parts)) as fh:
595
+ return fh.read()
596
+
597
+ def emitted(self):
598
+ from score_retrieval import (DELIVERED, MODEL, NEVER_ASKED,
599
+ NOT_RETURNED, REFUSAL, UNMEASURED)
600
+ return {t[2] for t in (DELIVERED, MODEL, NEVER_ASKED, NOT_RETURNED,
601
+ REFUSAL, UNMEASURED)}
602
+
603
+ def test_the_notebook_legend_names_every_label(self):
604
+ """The legend is what a reader consults to interpret the column, so a
605
+ label missing from it is worse than no legend. It named `documentation`
606
+ -- a value that never appears -- and omitted `never asked` entirely."""
607
+ text = self.package_file("eval_run.malloynb")
608
+ for label in self.emitted():
609
+ self.assertIn(f"**{label}**", text, label)
610
+
611
+ def test_the_dashboard_tooltip_names_every_label(self):
612
+ text = self.package_file("public", "app.js")
613
+ start = text.index("Where a failure would have to be fixed")
614
+ tooltip = text[start:start + 700]
615
+ for label in self.emitted():
616
+ self.assertIn(label, tooltip, label)
617
+
618
+ def test_the_template_documents_the_real_component_and_owner_values(self):
619
+ """The `where_to_fix` sweep checked its own column and left the two
620
+ beside it: `component` was documented as `get_context/retrieval`, a
621
+ value nothing emits, while `get_context` and `get_context/agent-call`
622
+ went unmentioned; `owner` claimed `retrieval` and omitted `undecided`
623
+ and `unknown`."""
624
+ from score_retrieval import (DELIVERED, MODEL, NEVER_ASKED,
625
+ NOT_RETURNED, REFUSAL, UNMEASURED)
626
+ atts = (DELIVERED, MODEL, NEVER_ASKED, NOT_RETURNED, REFUSAL,
627
+ UNMEASURED)
628
+ text = self.package_file("eval_run.malloy")
629
+ # The doc block for each column, up to the `public:` line it describes.
630
+ for col, values in (("component", {a[0] for a in atts}),
631
+ ("owner", {a[1] for a in atts})):
632
+ block = text[:text.index(f"public: {col}")].rsplit("\n\n", 1)[-1]
633
+ for v in values:
634
+ if v:
635
+ self.assertIn(v, block, f"{col} doc omits {v!r}")
636
+
637
+ def test_no_retired_label_survives_in_the_package(self):
638
+ # Each of these was a real value once; each now matches nothing.
639
+ for f in (("eval_run.malloy",), ("eval_run.malloynb",),
640
+ ("public", "app.js"), ("README.md",)):
641
+ text = self.package_file(*f)
642
+ for retired in ("query construction", "retrieval ranking"):
643
+ self.assertNotIn(retired, text, f"{f}: {retired}")
644
+
207
645
 
208
646
  class Summary(unittest.TestCase):
647
+ def test_an_unmeasured_failure_is_counted_not_dropped(self):
648
+ # "Every failure is attributed somewhere" must hold for this bucket too,
649
+ # or a bare-question set's failures vanish from the where-to-fix totals.
650
+ c = {"qid": "q", "expectedEntities": {"required": [M_SALES]}}
651
+ s = summarise([score_case(c, calls([]), KEY, "no_match")])
652
+ self.assertEqual(s["failures"], 1)
653
+ self.assertEqual(s["failures_by_where_to_fix"],
654
+ {"coverage not measured": 1})
655
+
209
656
  def test_absent_cases_stay_out_of_the_means(self):
210
657
  rows = [
211
658
  score_case(case(qid="a"), calls([M_SALES], qid="a"),
@@ -244,7 +691,7 @@ class Summary(unittest.TestCase):
244
691
  self.assertEqual(s["attributed"], s["failures"],
245
692
  "a failure fell through attribute()")
246
693
  self.assertEqual(s["failures_by_where_to_fix"], {
247
- "query construction": 1, "retrieval ranking": 1,
694
+ "delivered, wrong": 1, "never asked": 1,
248
695
  "model coverage": 1, "refusal behaviour": 1})
249
696
 
250
697
 
@@ -318,11 +765,86 @@ class EndToEnd(unittest.TestCase):
318
765
  capture_output=True, text=True, check=True).stdout
319
766
  rows = [json.loads(l) for l in out.splitlines()]
320
767
  by = {r["qid"]: r for r in rows}
321
- self.assertEqual(by["a"]["owner"], "agent-skill",
768
+ self.assertEqual(by["a"]["owner"], "undecided",
322
769
  "had everything and still failed")
323
770
  self.assertEqual(by["b"]["owner"], "model",
324
771
  "nothing to retrieve, so not retrieval's fault")
325
772
 
326
773
 
774
+ class TargetKinds(unittest.TestCase):
775
+ """What a search target can return, and who is blamed when it cannot."""
776
+
777
+ def test_kinds_by_target_matches_the_server(self):
778
+ # This module is stdlib-only and cannot import the TypeScript, so the
779
+ # map is mirrored and pinned here. Wrong in either direction
780
+ # misattributes: too narrow blames the agent for not asking when it
781
+ # did, too wide reads a real never-asked as a retrieval failure.
782
+ here = os.path.dirname(os.path.abspath(__file__))
783
+ src = os.path.join(here, "..", "..", "..", "packages", "server", "src",
784
+ "mcp", "tools", "get_context_tool.ts")
785
+ with open(src, encoding="utf-8") as fh:
786
+ text = fh.read()
787
+ block = text.split("const KINDS_BY_TARGET")[1].split("};")[0]
788
+ for target, kinds in score_retrieval.KINDS_BY_TARGET.items():
789
+ self.assertIn(f"{target}:", block,
790
+ f"{target} is not in the server's map")
791
+ line = next(l for l in block.splitlines()
792
+ if l.strip().startswith(f"{target}:"))
793
+ for k in kinds:
794
+ self.assertIn(f'"{k}"', line,
795
+ f"server's {target} does not select {k}")
796
+ if not kinds:
797
+ self.assertIn("[]", line, f"{target} should select nothing")
798
+
799
+ def test_a_bare_target_still_counts_as_having_asked(self):
800
+ # A target carrying no `search_text` enumerates its type. `targets`
801
+ # drops it (there is no term to record), so reading only that scored an
802
+ # agent who enumerated every measure as never having asked for one.
803
+ ev = [{"kind": "tool_call", "tool": "get_context", "qid": "q",
804
+ "sample": None, "phase": "baseline",
805
+ "rankedSummary": {"entityIds": []},
806
+ "targets": ["dimension: carrier"],
807
+ "target_shapes": [{"type": "dimension", "has_text": True},
808
+ {"type": "measure", "has_text": False}]}]
809
+ _, _, _, asked = score_retrieval.retrieved(ev, KEY)
810
+ self.assertIn("measure", asked)
811
+
812
+ def test_a_view_target_covers_a_named_query(self):
813
+ ev = [{"kind": "tool_call", "tool": "get_context", "qid": "q",
814
+ "sample": None, "phase": "baseline",
815
+ "rankedSummary": {"entityIds": []},
816
+ "target_shapes": [{"type": "view", "has_text": True}]}]
817
+ _, _, _, asked = score_retrieval.retrieved(ev, KEY)
818
+ self.assertEqual(asked, {"view", "query"})
819
+
820
+ def test_a_legacy_run_without_shapes_still_reads_its_targets(self):
821
+ ev = [{"kind": "tool_call", "tool": "get_context", "qid": "q",
822
+ "sample": None, "phase": "baseline",
823
+ "rankedSummary": {"entityIds": []},
824
+ "targets": ["measure: flight count", "dimension: carrier"]}]
825
+ _, _, _, asked = score_retrieval.retrieved(ev, KEY)
826
+ self.assertEqual(asked, {"measure", "dimension"})
827
+
828
+ def test_a_measure_missed_with_no_measure_target_is_never_asked(self):
829
+ # The deterministic case, and the one this whole distinction exists
830
+ # for: `target_type` is a hard filter on the server, so no amount of
831
+ # documentation could have delivered it.
832
+ comp, owner, where, why = score_retrieval.attribute(
833
+ recall=0.0, coverage="covered", passed=False,
834
+ missing_kinds={"measure"}, asked_kinds={"source", "dimension"})
835
+ self.assertEqual(owner, "agent-skill")
836
+ self.assertIn("never asked", where)
837
+
838
+ def test_a_measure_missed_despite_a_measure_target_is_not_never_asked(self):
839
+ # A measure target existed but described a different concept. That is a
840
+ # judgement about phrasing, not a mechanical miss, so it goes to
841
+ # diagnose rather than being charged to the agent here.
842
+ comp, owner, where, why = score_retrieval.attribute(
843
+ recall=0.0, coverage="covered", passed=False,
844
+ missing_kinds={"measure"}, asked_kinds={"measure", "dimension"})
845
+ self.assertNotIn("never asked", where)
846
+ self.assertEqual(owner, "undecided")
847
+
848
+
327
849
  if __name__ == "__main__":
328
850
  unittest.main(verbosity=2)