@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
|
@@ -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
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
87
|
-
# answerer
|
|
88
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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:
|