@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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"], "
|
|
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
|
|
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", "
|
|
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
|
|
169
|
-
r = score_case(case(coverage="covered"),
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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.
|
|
181
|
-
|
|
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
|
-
"
|
|
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"], "
|
|
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)
|