@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
@@ -11,19 +11,27 @@ This is the customer's question. For a case with a known answer, did the agent R
11
11
  the entities the answer needs? It is mechanical, exact, and per-case, and it is
12
12
  what makes a wrong answer attributable:
13
13
 
14
- recall 1.0 and the answer is wrong -> retrieval delivered everything, so the
15
- failure is construction, and no embedding or ranking work will fix it.
14
+ recall 1.0 and the answer is wrong -> retrieval delivered everything, so no
15
+ embedding or ranking work will fix it. Whether the agent misused what it had
16
+ or the docs never said how to use it is eval-diagnose's call, sufficiency
17
+ first, so the row reads "delivered, wrong" and names no owner.
16
18
 
17
19
  recall below 1.0 -> the agent never had the entity. Coverage then says whose
18
- problem that is: `covered` means the entity was there and search missed it;
19
- `derivable` or `absent` means there was nothing to surface. (An earlier
20
- version of this text said "no query-writing skill would have saved it"; that
21
- was false whenever the set named one route and the model offered another,
22
- which is what `requiredAnyOf` below exists to express.)
23
-
24
- Those two look identical in an answer score and have opposite owners. Components
20
+ problem that is: `covered` means the entity was there and search missed it,
21
+ which from the customer's side is a documentation finding -- the retrieval
22
+ algorithm is fixed, semantic search over doc strings, so an entity that exists
23
+ and does not come back is one whose docs do not say what people ask
24
+ (eval-diagnose NOT-RETURNED, owner model). `derivable` or `absent` means there
25
+ was nothing to surface. (An earlier version of this text said "no
26
+ query-writing skill would have saved it"; that was false whenever the set
27
+ named one route and the model offered another, which is what `requiredAnyOf`
28
+ below exists to express.) No label at all means nobody has measured it, and
29
+ the row says so instead of asserting a model gap.
30
+
31
+ Those look identical in an answer score and have different owners. Components
25
32
  and owners match eval-diagnose's taxonomy so the output drops into `issue` events
26
- without translation.
33
+ without translation; a label that named the engine ("retrieval ranking") did
34
+ not, and was corrected.
27
35
 
28
36
  INPUTS
29
37
 
@@ -37,8 +45,8 @@ INPUTS
37
45
  `required` lists entities the answer cannot be produced without. `requiredAnyOf`
38
46
  lists GROUPS, each satisfied by any one member: the case can be answered through
39
47
  either route, and naming only one would score the other as a retrieval miss --
40
- which then steers diagnosis to "retrieval ranking" for a failure that was never
41
- retrieval's. Every `required` entity is a group of one.
48
+ which then steers the fix to the docs for an entity whose docs were never the
49
+ problem. Every `required` entity is a group of one.
42
50
 
43
51
  WHAT COUNTS AS DELIVERED
44
52
 
@@ -54,6 +62,11 @@ recoverable.
54
62
  `rankedSummary.entityIds`), `attempt` (for `submitted`), and `score` (for
55
63
  `verdict`). Everything else is ignored.
56
64
 
65
+ `--coverage` is optional: a `check_coverage.py --out` report. Its per-case
66
+ verdict is a measurement against this build and beats the case's authored
67
+ `coverage` label; `coverage_source` on each row says which one attribution
68
+ used, and `none` means neither existed and nobody gets charged.
69
+
57
70
  WHAT COUNTS
58
71
 
59
72
  Recall is over `required`. Precision counts anything outside `acceptable` as
@@ -80,14 +93,64 @@ from typing import Any
80
93
  # rather than silently mislabelling every issue this script emits. The third
81
94
  # element is the human label -- "owner" reads like a person, and the value people
82
95
  # actually want from this column is where to go and fix it.
83
- CONSTRUCTION = ("construction", "agent-skill", "query construction")
84
- RETRIEVAL = ("get_context/retrieval", "retrieval", "retrieval ranking")
96
+ # Delivered everything and still wrong. eval-diagnose attributes construction
97
+ # "only after sufficiency": first establish that the docs said enough to use the
98
+ # entity correctly. WRONG-PICK is the model's if the docs did not distinguish the
99
+ # candidates, SCOPE is the model's if the rule was undocumented, CONVENTION is
100
+ # the model's ("expose a named measure"). So this row names no owner. An earlier
101
+ # version charged every such case to the agent, which is how a documentation gap
102
+ # gets filed as a skills bug and never fixed.
103
+ DELIVERED = ("construction", "undecided", "delivered, wrong")
104
+ # Covered, asked for by the right KIND of search, and it still did not come
105
+ # back. Two live causes, and this run cannot separate them: the docs do not
106
+ # describe the entity the way the question phrases it, or the search vocabulary
107
+ # was off. They have different owners, so the label names the OUTCOME and
108
+ # leaves the owner undecided; eval-diagnose separates them (NOT-RETURNED and
109
+ # LOW-RANK are the model's, QUESTION-VOCAB and VAGUE the agent's, and its
110
+ # RETRIEVAL code needs a rare-token proof that is never assigned mechanically
111
+ # here). Two earlier labels are both wrong and are worth naming because each
112
+ # looks reasonable: "retrieval ranking" names the engine, which from the
113
+ # customer's side is fixed and not the thing anyone can edit; `documentation`
114
+ # asserts one of the two causes outright, and was contradicted on the first
115
+ # real run.
116
+ NOT_RETURNED = ("get_context", "undecided", "not retrieved")
117
+ # Never issued a search for that kind of entity at all. eval-diagnose's
118
+ # NEVER-ASKED / WRONG-TYPE-OR-SCOPE, owner agent-skill. Mechanical and certain:
119
+ # a measure cannot be returned by a search that asked only for dimensions.
120
+ NEVER_ASKED = ("get_context/agent-call", "agent-skill", "never asked")
85
121
  MODEL = ("get_context/model", "model", "model coverage")
86
- # A case whose coverage is `absent` should have been declined. Answering it is an
87
- # answerer failure, so it shares construction's component and owner, but it is
88
- # worth its own label: the fix is refusal behaviour, not query-writing.
122
+ # A case whose coverage is `absent` should have been declined. Answering it is
123
+ # the answerer's, and documentation cannot help when there is nothing to
124
+ # document; its own label because the fix is refusal behaviour, not query-writing.
89
125
  REFUSAL = ("construction", "agent-skill", "refusal behaviour")
90
126
  UNATTRIBUTED = ("", "", "")
127
+ # Coverage values that were MEASURED and found nothing to surface. Only these
128
+ # may send a retrieval miss to the model: the authored labels `derivable` and
129
+ # `absent`, and check_coverage.py's four gap verdicts, which are eval-diagnose's
130
+ # codes. The four must match `check_coverage.FAIL_VERDICTS`; the test pins that,
131
+ # because this file stays stdlib-only and does not import it.
132
+ MEASURED_GAPS = ("derivable", "absent",
133
+ "COVERAGE", "AMBIGUOUS", "NO-DISAMBIG", "CONVENTION")
134
+ # check_coverage.py's "a correct answer is expressible": the measured `covered`.
135
+ MEASURED_OK = "ok"
136
+ # A failure that is retrieval's or the model's, and nothing measured which. Its
137
+ # own bucket, because the alternative was worse: with no authored `coverage`
138
+ # label the case fell through to MODEL with the words "coverage is unknown, so
139
+ # the entity does not exist" -- a model gap asserted on no evidence, on exactly
140
+ # the sets that arrive as bare questions with no labels. `unknown` as the
141
+ # conservative reading is eval-diagnose's own convention for `sufficiency`; it
142
+ # is not a new owner, it is the absence of one.
143
+ UNMEASURED = ("get_context", "unknown", "coverage not measured")
144
+ # A case whose coverage was measured as fine and which names no required
145
+ # entities, so retrieval was never measured for it. Its own label because both
146
+ # neighbours assert something this row has no evidence for: REFUSAL says the
147
+ # question was unanswerable, DELIVERED says every required entity arrived. It
148
+ # used to take REFUSAL, which blamed the answerer for "not declining" a
149
+ # perfectly answerable question purely because `expectedEntities` was left out
150
+ # -- and case-format.md says to leave it out, so that was the ordinary case.
151
+ # Owner `unknown` on purpose: LEVER_BY_OWNER yields no lever for it, which is
152
+ # the point. Nothing here knows whose fix this is.
153
+ NO_ENTITIES = ("undecided", "unknown", "retrieval not measured")
91
154
 
92
155
  PASSING = {"match", "near_match"}
93
156
  # Verdicts the acceptance check counts as neither a pass nor a failure.
@@ -113,12 +176,44 @@ def attempt_key(e: dict[str, Any]) -> tuple:
113
176
  return (e.get("qid"), e.get("sample"), e.get("phase"))
114
177
 
115
178
 
179
+ # Which entity KINDS each search target type can return. Mirrors
180
+ # `KINDS_BY_TARGET` in the server's get_context tool, and `test_kinds_by_target_
181
+ # matches_the_server` pins it against that file, because this module stays
182
+ # stdlib-only and cannot import TypeScript. Getting this wrong in either
183
+ # direction misattributes a miss: too narrow and the agent is blamed for not
184
+ # asking when it did, too wide and a real never-asked reads as a retrieval
185
+ # failure.
186
+ #
187
+ # `target_type` is a HARD FILTER on the server, which is what makes a miss of
188
+ # this shape mechanical rather than a judgement: no amount of documentation can
189
+ # deliver a measure to a search that asked only for dimensions.
190
+ KINDS_BY_TARGET = {
191
+ "source": {"source"},
192
+ "dimension": {"dimension"},
193
+ "measure": {"measure"},
194
+ # A model-level named query is a pre-built analysis, which is what the
195
+ # published shape says a `view` target is for.
196
+ "view": {"view", "query"},
197
+ "join": {"join"},
198
+ # Publisher indexes no dimensional values, so this target selects nothing.
199
+ "dimensional_value": set(),
200
+ }
201
+
202
+
116
203
  def retrieved(events: list[dict[str, Any]],
117
- key: tuple) -> tuple[list[str], int, set[str]]:
204
+ key: tuple) -> tuple[list[str], int, set[str], set[str]]:
118
205
  """Entities pooled over the attempt's get_context calls, the call count,
119
- and every identifier named in the returned sources' documentation."""
206
+ every identifier named in the returned sources' documentation, and the
207
+ entity KINDS the agent actually searched for.
208
+
209
+ The kinds matter: a measure cannot come back from a search that asked only
210
+ for a source and a dimension, and that is the agent's miss rather than the
211
+ model's. `targets` on the tool_call records what was asked, phrased
212
+ `"measure: count of titles"`, so the distinction is mechanical.
213
+ """
120
214
  seen: dict[str, None] = {}
121
215
  tokens: set[str] = set()
216
+ asked: set[str] = set()
122
217
  calls = 0
123
218
  for e in events:
124
219
  if e.get("kind") != "tool_call" or e.get("tool") != "get_context":
@@ -130,7 +225,26 @@ def retrieved(events: list[dict[str, Any]],
130
225
  for eid in rs.get("entityIds") or []:
131
226
  seen.setdefault(eid, None)
132
227
  tokens.update(rs.get("docTokens") or [])
133
- return list(seen), calls, tokens
228
+ # `target_shapes` when the run recorded it: it keeps EVERY target,
229
+ # including one carrying no `search_text`, which `targets` drops
230
+ # because there is no term to record. Reading only `targets` scored an
231
+ # agent that enumerated all measures as never having asked for one.
232
+ shapes = e.get("target_shapes")
233
+ if shapes:
234
+ for t in shapes:
235
+ if isinstance(t, dict) and t.get("type"):
236
+ asked.add(str(t["type"]).strip().lower())
237
+ else:
238
+ for t in e.get("targets") or []:
239
+ if isinstance(t, str) and ":" in t:
240
+ asked.add(t.split(":", 1)[0].strip().lower())
241
+ elif isinstance(t, dict) and t.get("target_type"):
242
+ asked.add(str(t["target_type"]).strip().lower())
243
+ # Expand target types to the kinds they can actually return, so a `view`
244
+ # target counts as having asked for a model-level named query.
245
+ reachable = set().union(*(KINDS_BY_TARGET.get(a, {a}) for a in asked)) \
246
+ if asked else set()
247
+ return list(seen), calls, tokens, reachable
134
248
 
135
249
 
136
250
  def split_entity(eid: str) -> tuple[str, str, str]:
@@ -165,7 +279,9 @@ def groups(exp: dict[str, Any]) -> list[list[str]]:
165
279
  return out
166
280
 
167
281
 
168
- def attribute(recall: float | None, coverage: str, passed: bool | None) -> tuple:
282
+ def attribute(recall: float | None, coverage: str, passed: bool | None,
283
+ missing_kinds: set[str] | None = None,
284
+ asked_kinds: set[str] | None = None) -> tuple:
169
285
  """Where to fix this outcome. Returns (component, owner, label, why).
170
286
 
171
287
  EVERY failure gets attributed. An earlier version returned nothing when
@@ -173,36 +289,122 @@ def attribute(recall: float | None, coverage: str, passed: bool | None) -> tuple
173
289
  counted as failures in the score table and appeared under no heading here,
174
290
  so the two never summed to the same number.
175
291
  """
176
- if passed:
177
- return (*UNATTRIBUTED, "passed")
292
+ # NO short-circuit on `passed`: recall is read before the verdict is. A
293
+ # short-circuit attributes a case that answered correctly while never
294
+ # receiving a required entity to nobody, and `summarise()` then drops it
295
+ # because the empty label is falsy, so no issue can ever be raised for it.
296
+ # Measured on one run: 4 of 5 undelivered entities sat on passing cases.
297
+ #
298
+ # "It worked anyway" is not a reason to leave the gap. The answer being
299
+ # right is recorded on the row (`failed`, `verdict`) and reported
300
+ # separately, so attributing the miss does not move the pass rate.
178
301
  if passed is None:
179
302
  # needs_human or unscorable. Neither a pass nor a failure, so it must not
180
303
  # be attributed -- doing so would inflate whichever bucket it landed in.
181
304
  return (*UNATTRIBUTED, "not scored")
182
305
  if recall is None:
183
- # No required entities, which in this set means coverage is `absent`.
184
- # The case failed, so the answerer produced something for a question the
185
- # model cannot answer.
186
- return (*REFUSAL,
187
- "the model cannot answer this and the answerer did not decline")
306
+ # Two different shapes reach here, and they were conflated. `absent`
307
+ # coverage means the model genuinely cannot answer, so declining is the
308
+ # pass and answering is the answerer's fault. A case that simply names
309
+ # no required entities is NOT that: the question may be perfectly
310
+ # answerable, and all that is true is that retrieval went unmeasured.
311
+ if coverage == "absent":
312
+ if passed:
313
+ # It declined a question the model cannot answer, which is the
314
+ # right behaviour and the pass. Nothing to attribute.
315
+ return (*UNATTRIBUTED, "declined an unanswerable question")
316
+ return (*REFUSAL,
317
+ "the model cannot answer this and the answerer did not decline")
318
+ if passed:
319
+ return (*UNATTRIBUTED,
320
+ "correct; the case names no required entities, so retrieval "
321
+ "was not measured for it")
322
+ return (*NO_ENTITIES,
323
+ "the answer was wrong and the case names no required entities, "
324
+ "so nothing here can say whether retrieval delivered. Add "
325
+ "`expectedEntities.required` to measure it, or diagnose the "
326
+ "failure directly")
188
327
  if recall >= 1.0:
189
- return (*CONSTRUCTION,
190
- "retrieval delivered every required entity; the failure is in the query")
191
- if coverage == "covered":
192
- return (*RETRIEVAL,
193
- "the entity exists in the model and was not returned")
194
- return (*MODEL,
195
- f"nothing to return: coverage is {coverage}, so the entity does not exist")
328
+ if passed:
329
+ # Everything arrived and the answer was right. The only shape with
330
+ # genuinely nothing to report.
331
+ return (*UNATTRIBUTED, "delivered and correct")
332
+ return (*DELIVERED,
333
+ "retrieval delivered every required entity; the failure is in how "
334
+ "it was used, or in docs that never said how to use it. Diagnose "
335
+ "decides which, sufficiency first")
336
+ # Below here recall is < 1.0: a required entity did not reach the answerer.
337
+ # That is attributed whether or not the answer came out right, because an
338
+ # answer that was right WITHOUT the entity was right by another route --
339
+ # most often the agent rebuilding the model's own measure inline, which is
340
+ # correct only while the measure is trivial.
341
+ if coverage in ("covered", MEASURED_OK):
342
+ unasked = sorted((missing_kinds or set()) - (asked_kinds or set()))
343
+ if unasked:
344
+ return (*NEVER_ASKED,
345
+ f"the entity exists and no search asked for a "
346
+ f"{'/'.join(unasked)} at all, so nothing of that kind could "
347
+ f"come back (eval-diagnose NEVER-ASKED)")
348
+ return (*NOT_RETURNED,
349
+ "the entity exists, a search of the right kind was issued, and it "
350
+ "did not come back. The docs may not say what this question asks, "
351
+ "or the search wording may be off; eval-diagnose separates "
352
+ "NOT-RETURNED from QUESTION-VOCAB. Assumes a semantic run")
353
+ if coverage in MEASURED_GAPS:
354
+ return (*MODEL,
355
+ f"nothing to return: coverage is {coverage}, so the entity does not exist")
356
+ return (*UNMEASURED,
357
+ "recall below 1.0 and coverage was never measured for this case; run "
358
+ "check_coverage.py before calling this a model gap or a retrieval miss")
359
+
360
+
361
+ def load_coverage_report(path: str) -> dict[str, str | None]:
362
+ """`qid -> verdict` from a `check_coverage.py --out` report.
363
+
364
+ That report used to be written and read by nothing. This is the read. A
365
+ case whose measurement was undecided carries None and falls back to whatever
366
+ else the case has, and `coverage_source` on the row says which won.
367
+ """
368
+ with open(path) as fh:
369
+ data = json.load(fh)
370
+ return {r["qid"]: r.get("verdict") for r in data.get("cases_detail", [])}
371
+
372
+
373
+ def coverage_report_summary(path: str) -> dict[str, Any]:
374
+ """What run.json records about a coverage report it consumed.
375
+
376
+ Enough to say which measurement charged the failures: the file, the model
377
+ version it was stamped with, the judge that decided it, and how much of the
378
+ set it actually decided. `decided` of `cases` matters more than the
379
+ percentage: a report that decided 4 of 49 is not a coverage number, it is a
380
+ sample size.
381
+ """
382
+ with open(path) as fh:
383
+ d = json.load(fh)
384
+ return {"path": path, "version": d.get("version"),
385
+ "agentModel": d.get("agentModel"),
386
+ "decided": d.get("decided"), "cases": d.get("cases")}
196
387
 
197
388
 
198
389
  def score_case(case: dict[str, Any], events: list[dict[str, Any]],
199
- key: tuple, verdict: str | None) -> dict[str, Any]:
200
- coverage = case.get("coverage", "unknown")
390
+ key: tuple, verdict: str | None,
391
+ measured: str | None = None) -> dict[str, Any]:
392
+ # A measured verdict beats the authored label. The label is a standing hand
393
+ # judgement about the question; the verdict is a measurement against THIS
394
+ # build, which is the thing an attribution is about. Neither present reads
395
+ # "unknown", which attribute() refuses to charge to anyone.
396
+ if measured is not None:
397
+ coverage, coverage_source = measured, "measured"
398
+ elif case.get("coverage") is not None:
399
+ coverage, coverage_source = case["coverage"], "authored"
400
+ else:
401
+ coverage, coverage_source = "unknown", "none"
201
402
  exp = case.get("expectedEntities") or {}
202
403
  req_groups = groups(exp)
203
404
  required = {e for g in req_groups for e in g}
204
- acceptable = set(exp.get("acceptable") or []) | required
205
- got, calls, tokens = retrieved(events, key)
405
+ authored_acceptable = set(exp.get("acceptable") or [])
406
+ acceptable = authored_acceptable | required
407
+ got, calls, tokens, asked = retrieved(events, key)
206
408
  got_set = set(got)
207
409
  route = {e: delivery(e, got_set, tokens) for e in sorted(required)}
208
410
  delivered = {e for e, r in route.items() if r != "missing"}
@@ -226,21 +428,41 @@ def score_case(case: dict[str, Any], events: list[dict[str, Any]],
226
428
  if not any(e in delivered for e in g))
227
429
  noise = sorted(got_set - acceptable)
228
430
 
229
- component, owner, where_to_fix, why = attribute(recall, coverage, passed)
431
+ # Only a group NO member satisfied, the same guard `missing` uses above. A
432
+ # `requiredAnyOf` group names alternate routes to one need, so the routes
433
+ # not taken are not missing and their kinds are not unasked-for. Collecting
434
+ # every undelivered member instead let a satisfied group contribute a
435
+ # phantom kind, and `attribute` read that as NEVER-ASKED: a case whose real
436
+ # miss was a dimension the agent DID search for came back owned by
437
+ # agent-skill, "no search asked for a join at all". That is the
438
+ # charge-the-agent default this file exists to remove.
439
+ missing_kinds = {split_entity(e)[0] for g in req_groups
440
+ if not any(e in delivered for e in g) for e in g}
441
+ component, owner, where_to_fix, why = attribute(
442
+ recall, coverage, passed, missing_kinds, asked)
230
443
  return {
231
444
  "qid": case["qid"],
232
445
  "sample": key[1],
233
446
  "phase": key[2],
234
447
  "coverage": coverage,
448
+ "coverage_source": coverage_source,
235
449
  "verdict": verdict,
236
450
  "failed": passed is False,
237
451
  "recall": recall,
238
452
  "precision": precision,
239
453
  "n_required": len(req_groups),
240
454
  "n_returned": len(got_set),
455
+ # Precision reads everything outside `acceptable` as noise, so a set
456
+ # that never authored one scores every legitimate extra as a miss. The
457
+ # flag travels with the row so a reader knows which they are looking at.
458
+ "has_acceptable": bool(authored_acceptable),
241
459
  "delivery": route,
242
460
  "n_ranked": sum(1 for r in route.values() if r in ("exact", "alias")),
243
461
  "n_get_context": calls,
462
+ # What the agent asked for, so a reader can judge the search rather than
463
+ # take the label's word for it -- and so a set can be surveyed for the
464
+ # vocabulary its questions actually need.
465
+ "asked_kinds": sorted(asked),
244
466
  "missing": missing,
245
467
  "noise": noise,
246
468
  "component": component,
@@ -269,10 +491,68 @@ def summarise(rows: list[dict[str, Any]]) -> dict[str, Any]:
269
491
  "mean_precision": mean([r["precision"] for r in scored
270
492
  if r["precision"] is not None]),
271
493
  "complete_retrievals": sum(1 for r in scored if r["recall"] >= 1.0),
494
+ # How much get_context handed back, and how much of it the answer
495
+ # needed. Unlike precision these do not depend on `acceptable` being
496
+ # authored, so they say something about retrieval on any set.
497
+ "mean_returned": mean([r["n_returned"] for r in scored]),
498
+ "mean_required": mean([r["n_required"] for r in scored]),
499
+ "cases_with_acceptable": sum(1 for r in rows if r.get("has_acceptable")),
272
500
  "failures_by_where_to_fix": placed,
273
501
  }
274
502
 
275
503
 
504
+ def cascade(rows: list[dict[str, Any]]) -> dict[str, int]:
505
+ """The three metrics as a funnel, because each one conditions the next.
506
+
507
+ A case the model cannot express has no meaningful retrieval or answer result;
508
+ a case whose entity never arrived has no meaningful answer result. Reported
509
+ as three independent percentages those read as three unrelated problems.
510
+ Reported as a cascade, each failure sits at the one rung that owns it, and
511
+ the rungs sum to the row count so a reader can check the arithmetic.
512
+ """
513
+ c = {"total": len(rows), "not covered": 0, "unmeasured": 0,
514
+ "no entities named": 0, "not retrieved": 0, "delivered, wrong": 0,
515
+ "delivered, right": 0, "not scored": 0,
516
+ # Passes that stop on an earlier rung. Without these the funnel looks
517
+ # like it contradicts the pass rate: a run where every case matched
518
+ # read "correct? 6 yes" because two passed despite a coverage gap and
519
+ # incomplete retrieval, and 6 is exactly the number a reader would
520
+ # mistake for the score.
521
+ #
522
+ # EVERY rung that can hold a pass carries one. The two below were
523
+ # missing, and they are the rungs the ordinary case lands on:
524
+ # `expectedEntities` is "almost never present" per case-format.md, so
525
+ # a passing case with no entities named went into `no entities named`,
526
+ # which had no counter, and vanished from the identity
527
+ # `passes = delivered_right + the passed_* overlays`. The test that
528
+ # pins that identity never caught it because every row it builds
529
+ # supplies a required entity.
530
+ "passed_not_covered": 0, "passed_not_retrieved": 0,
531
+ "passed_unmeasured": 0, "passed_no_entities_named": 0}
532
+ for r in rows:
533
+ cov = r["coverage"]
534
+ passed = not r["failed"] and r["verdict"] not in UNSCORED
535
+ if cov in MEASURED_GAPS:
536
+ c["not covered"] += 1
537
+ c["passed_not_covered"] += passed
538
+ elif cov not in ("covered", MEASURED_OK):
539
+ c["unmeasured"] += 1
540
+ c["passed_unmeasured"] += passed
541
+ elif r["recall"] is None:
542
+ c["no entities named"] += 1
543
+ c["passed_no_entities_named"] += passed
544
+ elif r["recall"] < 1.0:
545
+ c["not retrieved"] += 1
546
+ c["passed_not_retrieved"] += passed
547
+ elif r["verdict"] in UNSCORED:
548
+ c["not scored"] += 1
549
+ elif r["failed"]:
550
+ c["delivered, wrong"] += 1
551
+ else:
552
+ c["delivered, right"] += 1
553
+ return c
554
+
555
+
276
556
  def load(events_path, cases_path) -> tuple[dict, dict]:
277
557
  """(cases by qid, attempts by key) for anything scoring a run.
278
558
 
@@ -300,10 +580,14 @@ def main(argv: list[str] | None = None) -> int:
300
580
  ap.add_argument("--events", required=True)
301
581
  ap.add_argument("--cases", required=True)
302
582
  ap.add_argument("--json", action="store_true", help="emit rows as JSONL")
583
+ ap.add_argument("--coverage", default=None,
584
+ help="a check_coverage.py --out report; its per-case verdict "
585
+ "beats the authored `coverage` label")
303
586
  a = ap.parse_args(argv)
304
587
 
305
588
  events = read_jsonl(a.events)
306
589
  cases = {c["qid"]: c for c in read_jsonl(a.cases)}
590
+ measured = load_coverage_report(a.coverage) if a.coverage else {}
307
591
 
308
592
  verdicts = {attempt_key(e): e.get("verdict")
309
593
  for e in events if e.get("kind") == "score"}
@@ -317,7 +601,8 @@ def main(argv: list[str] | None = None) -> int:
317
601
  print(f"warning: no case for qid {e.get('qid')!r}", file=sys.stderr)
318
602
  continue
319
603
  key = attempt_key(e)
320
- rows.append(score_case(case, events, key, verdicts.get(key)))
604
+ rows.append(score_case(case, events, key, verdicts.get(key),
605
+ measured.get(e.get("qid"))))
321
606
 
322
607
  rows.sort(key=lambda r: (r["qid"], str(r["sample"])))
323
608
  if a.json: