@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
|
@@ -60,6 +60,8 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
|
|
|
60
60
|
from agent_harness import (NO_EDITS, NO_SHELL, default_manifest, # noqa: E402
|
|
61
61
|
manifest_skills, skills_roots, spawn_agent)
|
|
62
62
|
import ledger # noqa: E402
|
|
63
|
+
import cluster_failures # noqa: E402
|
|
64
|
+
import score_retrieval # noqa: E402
|
|
63
65
|
from ledger import read_jsonl # noqa: E402
|
|
64
66
|
|
|
65
67
|
SKILLS_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
|
@@ -114,8 +116,6 @@ def worst(values: Iterable[str | None], vocab: tuple[str, ...],
|
|
|
114
116
|
return max(ranked, key=vocab.index) if ranked else fallback
|
|
115
117
|
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
119
|
def skill_codes() -> set[str]:
|
|
120
120
|
text = (SKILLS_ROOT / "eval-diagnose" / "SKILL.md").read_text()
|
|
121
121
|
codes = set(CODE_IN_TABLE.findall(text))
|
|
@@ -127,7 +127,8 @@ def skill_codes() -> set[str]:
|
|
|
127
127
|
|
|
128
128
|
|
|
129
129
|
def evidence_for(qid: str, case: dict[str, Any],
|
|
130
|
-
events: list[dict[str, Any]]
|
|
130
|
+
events: list[dict[str, Any]],
|
|
131
|
+
passed_elsewhere: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
131
132
|
"""Everything the ladder's Step 1 asks for: asked, returned, used.
|
|
132
133
|
|
|
133
134
|
Assembled here rather than letting the agent read the ledger, for two
|
|
@@ -148,6 +149,12 @@ def evidence_for(qid: str, case: dict[str, Any],
|
|
|
148
149
|
continue
|
|
149
150
|
summary = c.get("rankedSummary") or {}
|
|
150
151
|
asked.append({"targets": c.get("targets"),
|
|
152
|
+
# Without this a narrow scope is invisible, and a miss it
|
|
153
|
+
# fully explains reads as a retrieval failure. One
|
|
154
|
+
# diagnosis asserted "a single unscoped get_context call"
|
|
155
|
+
# about a call scoped to one source, and charged the miss
|
|
156
|
+
# to retrieval on that premise.
|
|
157
|
+
"scopes": c.get("scopes"),
|
|
151
158
|
"returnedInRankOrder": summary.get("entityIds") or [],
|
|
152
159
|
"resultCount": summary.get("resultCount"),
|
|
153
160
|
"error": c.get("error")})
|
|
@@ -168,6 +175,12 @@ def evidence_for(qid: str, case: dict[str, Any],
|
|
|
168
175
|
"answerText": (attempt.get("answer_text") or "")[:4000],
|
|
169
176
|
"queriesRun": attempt.get("queries") or
|
|
170
177
|
([attempt["final_query"]] if attempt.get("final_query") else []),
|
|
178
|
+
# The matched pair, when another arm of the SAME model answered this
|
|
179
|
+
# question correctly. Same question, same model, one right answer and
|
|
180
|
+
# one wrong one: the diff between the two queries isolates the cause,
|
|
181
|
+
# and it is the cheapest evidence in the whole run. Absent unless
|
|
182
|
+
# --compare-run named an arm.
|
|
183
|
+
"passedInAnotherArm": passed_elsewhere,
|
|
171
184
|
"getContextCalls": asked,
|
|
172
185
|
"nGetContext": attempt.get("n_get_context"),
|
|
173
186
|
"nExecute": attempt.get("n_execute"),
|
|
@@ -192,8 +205,34 @@ commit to a code -- use them. You may not edit anything.{scope_line}
|
|
|
192
205
|
EVIDENCE FROM THE RUN
|
|
193
206
|
{evidence}
|
|
194
207
|
|
|
195
|
-
In `getContextCalls`, `targets` is what the agent searched for
|
|
196
|
-
`returnedInRankOrder` is what came back
|
|
208
|
+
In `getContextCalls`, `targets` is what the agent searched for, `scopes` is
|
|
209
|
+
the scope it searched UNDER, and `returnedInRankOrder` is what came back.
|
|
210
|
+
|
|
211
|
+
**If `passedInAnotherArm` is present, start there.** Another run of the SAME
|
|
212
|
+
model answered this question correctly. That is a matched pair: same question,
|
|
213
|
+
same model, one right answer and one wrong one, and the diff between the two
|
|
214
|
+
queries usually names the cause outright. Read `finalQuery` there against
|
|
215
|
+
`queriesRun` here and say what the passing arm did differently.
|
|
216
|
+
|
|
217
|
+
A case that flips is not noise to be averaged away. It is a case where the
|
|
218
|
+
agent found two paths and the model did not make one of them obviously right,
|
|
219
|
+
which is a MODEL-quality finding: the fix is usually a doc or a name that
|
|
220
|
+
makes the wrong path unattractive, not a change to the agent. On one real set
|
|
221
|
+
five flips over 28 cases read as an 18% churn rate and turned out to be one
|
|
222
|
+
question asked two ways, where the wrong idiom returned 2 rows against the
|
|
223
|
+
right one's 443. If both arms ran the SAME query, the flip is downstream of it
|
|
224
|
+
-- the judge, the rubric, or non-determinism in the data -- and that is a
|
|
225
|
+
different finding, worth saying plainly.
|
|
226
|
+
|
|
227
|
+
**Read `scopes` before you blame retrieval for anything.** A call carrying a
|
|
228
|
+
`source` in its scope is pinned to that source and cannot return an entity from
|
|
229
|
+
another one, however well documented that entity is. A miss under a narrow
|
|
230
|
+
scope is the agent's scoping -- `agent-call` -- not `get_context/retrieval`.
|
|
231
|
+
Not hypothetical: a diagnosis asserted "a single unscoped get_context call"
|
|
232
|
+
about a call scoped to `source: flights`, and charged a missing `airports`
|
|
233
|
+
field to retrieval on that premise. The scope was in the request all along and
|
|
234
|
+
was missing from the evidence; it is there now, so calling a scoped call
|
|
235
|
+
unscoped is a checkable error.
|
|
197
236
|
|
|
198
237
|
Emit the object defined under `## Per case` in `reference/output-contract.md`
|
|
199
238
|
of the eval-diagnose skill as the LAST thing in your reply. Read that file; it
|
|
@@ -208,12 +247,26 @@ the analysis inside it is.
|
|
|
208
247
|
|
|
209
248
|
|
|
210
249
|
def diagnose_one(qid: str, case: dict[str, Any], events: list[dict[str, Any]],
|
|
211
|
-
a: argparse.Namespace, art: pathlib.Path
|
|
250
|
+
a: argparse.Namespace, art: pathlib.Path, *,
|
|
251
|
+
answered_correctly: bool = False,
|
|
252
|
+
passed_elsewhere: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
212
253
|
d = art / qid
|
|
213
254
|
out = d / "diagnosis.json"
|
|
214
255
|
if out.exists() and not a.force:
|
|
215
256
|
return {**json.loads(out.read_text()), "qid": qid, "_cached": True}
|
|
216
257
|
|
|
258
|
+
# Said up front, because the evidence would otherwise read as a wrong
|
|
259
|
+
# answer and the diagnosis would go looking for one. The finding here is
|
|
260
|
+
# narrower: a required entity never reached the answerer, and the answer
|
|
261
|
+
# was right anyway -- by another route, which is worth naming.
|
|
262
|
+
correct_line = ("\nTHIS ANSWER WAS CORRECT. Diagnose the RETRIEVAL miss "
|
|
263
|
+
"only: a required entity never reached the answerer and "
|
|
264
|
+
"the answer was right without it. Say by what route it "
|
|
265
|
+
"was right (a rebuilt measure, a sibling field, the "
|
|
266
|
+
"source docs), and do NOT look for a wrong number. An "
|
|
267
|
+
"answer that is right without the model's own entity is "
|
|
268
|
+
"right for now, not right by design.\n"
|
|
269
|
+
if answered_correctly else "")
|
|
217
270
|
platform = a.target == "platform"
|
|
218
271
|
scope_line = ""
|
|
219
272
|
if platform and a.scope:
|
|
@@ -236,8 +289,9 @@ def diagnose_one(qid: str, case: dict[str, Any], events: list[dict[str, Any]],
|
|
|
236
289
|
DIAGNOSE_PROMPT.format(
|
|
237
290
|
environment=a.environment, package=a.package,
|
|
238
291
|
tools_name="the hosted platform" if platform else "Publisher",
|
|
239
|
-
scope_line=scope_line,
|
|
240
|
-
evidence=json.dumps(evidence_for(qid, case, events
|
|
292
|
+
scope_line=scope_line + correct_line,
|
|
293
|
+
evidence=json.dumps(evidence_for(qid, case, events,
|
|
294
|
+
passed_elsewhere),
|
|
241
295
|
indent=2)[:14000]),
|
|
242
296
|
skills=["eval-diagnose", *a.role_skills], skills_root=a.roots,
|
|
243
297
|
model=a.model,
|
|
@@ -341,6 +395,127 @@ def validate(obj: dict[str, Any], codes: set[str]) -> list[str]:
|
|
|
341
395
|
return bad
|
|
342
396
|
|
|
343
397
|
|
|
398
|
+
def retrieval_finding(case: dict[str, Any], events: list[dict[str, Any]],
|
|
399
|
+
key: tuple, verdict: str | None) -> dict[str, Any] | None:
|
|
400
|
+
"""The retrieval attribution for one case, when there is one to report.
|
|
401
|
+
|
|
402
|
+
Calls the real scorer rather than restating its rule: `where_to_fix` is
|
|
403
|
+
non-empty exactly when a required entity did not reach the answerer, and
|
|
404
|
+
`score_retrieval` owns that decision for the run summary too, so the two
|
|
405
|
+
cannot drift into disagreeing about what counts as a miss.
|
|
406
|
+
"""
|
|
407
|
+
row = score_retrieval.score_case(case, events, key, verdict)
|
|
408
|
+
return row if row.get("where_to_fix") else None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def behaviour_stats(qid: str, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
412
|
+
"""How an attempt CONDUCTED itself, for one case, in comparable numbers.
|
|
413
|
+
|
|
414
|
+
The measures a behavioural root cause gets stated in: how much retrieval it
|
|
415
|
+
did, how much of that retrieval named what it wanted, how many queries it
|
|
416
|
+
ran, how many skills it opened, how long it took. Cheap -- it reads events
|
|
417
|
+
already on disk and calls nothing.
|
|
418
|
+
"""
|
|
419
|
+
attempt = next((e for e in events
|
|
420
|
+
if e.get("kind") == "attempt" and e.get("qid") == qid), {})
|
|
421
|
+
calls = [e for e in events if e.get("kind") == "tool_call"
|
|
422
|
+
and e.get("qid") == qid and e.get("tool") == "get_context"]
|
|
423
|
+
# `target_shapes` when the run recorded it, `targets` otherwise. They are
|
|
424
|
+
# not interchangeable and the difference is the whole point: `targets` is
|
|
425
|
+
# the terms SEARCHED FOR, and a target carrying no `search_text` is dropped
|
|
426
|
+
# before it is written, so counting bare targets from it always yields
|
|
427
|
+
# zero. Measured on a real 10-case run, every attempt read
|
|
428
|
+
# `targetsWithoutSearchText: 0` while the argument the field exists to test
|
|
429
|
+
# is precisely about how often that count is high.
|
|
430
|
+
shapes = [t for c in calls for t in (c.get("target_shapes") or [])]
|
|
431
|
+
if shapes:
|
|
432
|
+
by_type: dict[str, int] = {}
|
|
433
|
+
for t in shapes:
|
|
434
|
+
by_type[t.get("type") or "?"] = by_type.get(t.get("type") or "?", 0) + 1
|
|
435
|
+
n_targets = len(shapes)
|
|
436
|
+
bare = sum(1 for t in shapes if not t.get("has_text"))
|
|
437
|
+
measured = True
|
|
438
|
+
else:
|
|
439
|
+
# A run written before `target_shapes` existed. The types are readable
|
|
440
|
+
# from the `"<type>: <text>"` strings, but the bare count is NOT
|
|
441
|
+
# recoverable, so it is reported as unknown rather than as zero -- a
|
|
442
|
+
# falsifier that silently reads zero is worse than one that abstains.
|
|
443
|
+
targets = [t for c in calls for t in (c.get("targets") or [])]
|
|
444
|
+
by_type = {}
|
|
445
|
+
for t in targets:
|
|
446
|
+
kind = (t.split(":", 1)[0].strip()
|
|
447
|
+
if isinstance(t, str) and ":" in t else "?")
|
|
448
|
+
by_type[kind] = by_type.get(kind, 0) + 1
|
|
449
|
+
n_targets, bare, measured = len(targets), None, False
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
"qid": qid,
|
|
453
|
+
"verdict": next((e.get("verdict") for e in events
|
|
454
|
+
if e.get("kind") == "score" and e.get("qid") == qid),
|
|
455
|
+
None),
|
|
456
|
+
"targetTypes": by_type,
|
|
457
|
+
"targetsMeasured": measured,
|
|
458
|
+
"nGetContext": attempt.get("n_get_context"),
|
|
459
|
+
"nExecute": attempt.get("n_execute"),
|
|
460
|
+
"nExecuteErrors": attempt.get("n_execute_errors"),
|
|
461
|
+
"searchTargets": n_targets,
|
|
462
|
+
"targetsWithoutSearchText": bare,
|
|
463
|
+
"skillsInvoked": attempt.get("skills_invoked") or [],
|
|
464
|
+
"numTurns": attempt.get("num_turns"),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def controls_block(controls: list[dict[str, Any]]) -> str:
|
|
469
|
+
"""The CONTROLS section of the clustering prompt, sized to what exists.
|
|
470
|
+
|
|
471
|
+
Three cases, because the instruction that is right for twenty passing cases
|
|
472
|
+
is wrong for one and meaningless for none:
|
|
473
|
+
|
|
474
|
+
- **None.** Every case in the run failed, so there is nothing to compare
|
|
475
|
+
against. Asking the agent to compare rates against an empty list invites
|
|
476
|
+
it to read `[]` as evidence of absence. Say plainly that no behavioural
|
|
477
|
+
cluster can be falsified here.
|
|
478
|
+
- **One or two.** Not a rate. Two passing cases cannot establish that a
|
|
479
|
+
behaviour is rarer in passes, and treating them as if they could is how a
|
|
480
|
+
cluster gets confirmed by a coin flip.
|
|
481
|
+
- **Three or more.** The real comparison.
|
|
482
|
+
"""
|
|
483
|
+
n = len(controls)
|
|
484
|
+
if not n:
|
|
485
|
+
return ("CONTROLS: none. Every scored case in this run failed.\n\n"
|
|
486
|
+
"There is nothing to compare a behaviour against, so a cluster\n"
|
|
487
|
+
"whose root cause is a BEHAVIOUR -- how much the agent\n"
|
|
488
|
+
"retrieved, how it phrased its targets, how many queries it ran\n"
|
|
489
|
+
"-- cannot be falsified from this run. Mark every such cluster\n"
|
|
490
|
+
"`unfalsified` and say why. Do NOT read the absence of controls\n"
|
|
491
|
+
"as evidence that the behaviour is causal. A cluster resting on\n"
|
|
492
|
+
"a MODEL fact -- a missing entity, a wrong measure, an\n"
|
|
493
|
+
"undocumented convention -- is unaffected: those are checked\n"
|
|
494
|
+
"against the model, not against other cases.")
|
|
495
|
+
head = (f"CONTROLS: the same measurements on the {n} case(s) that PASSED "
|
|
496
|
+
f"this run\n\n" + json.dumps(controls, indent=2) + "\n\n")
|
|
497
|
+
if n < 3:
|
|
498
|
+
return head + (
|
|
499
|
+
f"{n} passing case(s) is NOT a rate. It is enough to notice that a\n"
|
|
500
|
+
"behaviour you called causal also appears in a passing case, which\n"
|
|
501
|
+
"is worth saying; it is not enough to establish that the behaviour\n"
|
|
502
|
+
"is rarer in passes. Do not compute a percentage from it. If the\n"
|
|
503
|
+
"behaviour appears here too, mark the cluster `contributing` rather\n"
|
|
504
|
+
"than `primary`; if it does not, the cluster stays weakly supported\n"
|
|
505
|
+
"and say so.")
|
|
506
|
+
return head + (
|
|
507
|
+
"These are the falsifier for any cluster whose root cause is a\n"
|
|
508
|
+
"BEHAVIOUR -- how much the agent retrieved, how it phrased its targets,\n"
|
|
509
|
+
"how many queries it ran, which skills it opened. Diagnosis only ever\n"
|
|
510
|
+
"looks at failures, so a behaviour common to both looks causal here and\n"
|
|
511
|
+
"is not.\n\n"
|
|
512
|
+
"Before you claim a behaviour explains a cluster, compare it against\n"
|
|
513
|
+
"these rows. If it occurs at a similar rate in the passes, say so and\n"
|
|
514
|
+
"mark that cluster `contributing` rather than `primary`; a cluster\n"
|
|
515
|
+
"whose behaviour does not separate the two groups must not be routed to\n"
|
|
516
|
+
"a skill or model edit as the root cause.")
|
|
517
|
+
|
|
518
|
+
|
|
344
519
|
CLUSTER_PROMPT = """Apply Step 5 of the eval-diagnose skill across a whole run.
|
|
345
520
|
|
|
346
521
|
These are the per-case diagnoses from one run. Cluster them.
|
|
@@ -348,6 +523,8 @@ These are the per-case diagnoses from one run. Cluster them.
|
|
|
348
523
|
DIAGNOSED ISSUES
|
|
349
524
|
{issues}
|
|
350
525
|
|
|
526
|
+
{controls}
|
|
527
|
+
|
|
351
528
|
Emit the object defined under `## Per run, clustering` in
|
|
352
529
|
`reference/output-contract.md` of the eval-diagnose skill as the LAST thing in
|
|
353
530
|
your reply. Read that file; it is the contract a script parses, and a shape
|
|
@@ -367,12 +544,14 @@ the improve step's job, not yours.
|
|
|
367
544
|
|
|
368
545
|
|
|
369
546
|
def cluster(issues: list[dict[str, Any]], a: argparse.Namespace,
|
|
370
|
-
out: pathlib.Path
|
|
547
|
+
out: pathlib.Path,
|
|
548
|
+
controls: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
371
549
|
keep = ("qid", "component", "primary_code", "contributing_codes", "owner",
|
|
372
550
|
"sufficiency", "severity", "diagnosis", "sharedWith")
|
|
373
551
|
compact = [{k: v for k, v in i.items() if k in keep} for i in issues]
|
|
374
552
|
r = spawn_agent(
|
|
375
|
-
CLUSTER_PROMPT.format(issues=json.dumps(compact, indent=2)
|
|
553
|
+
CLUSTER_PROMPT.format(issues=json.dumps(compact, indent=2),
|
|
554
|
+
controls=controls_block(controls or [])),
|
|
376
555
|
skills=["eval-diagnose", *a.role_skills], skills_root=a.roots,
|
|
377
556
|
model=a.cluster_model,
|
|
378
557
|
# 14, not 8. The output contract moved into
|
|
@@ -389,6 +568,137 @@ def cluster(issues: list[dict[str, Any]], a: argparse.Namespace,
|
|
|
389
568
|
return res
|
|
390
569
|
|
|
391
570
|
|
|
571
|
+
def select_cases(events: list[dict[str, Any]],
|
|
572
|
+
cases: dict[str, dict[str, Any]],
|
|
573
|
+
want_verdicts: set[str] | tuple[str, ...],
|
|
574
|
+
*, include_holdout: bool = False,
|
|
575
|
+
no_retrieval_misses: bool = False,
|
|
576
|
+
only: str | None = None,
|
|
577
|
+
limit: int | None = None) -> tuple[
|
|
578
|
+
list[str], list[str], list[str], dict[str, list[str]],
|
|
579
|
+
dict[str, list[str]]]:
|
|
580
|
+
"""Sort every scored case into diagnose / passed / excluded.
|
|
581
|
+
|
|
582
|
+
Pure, so the three accounting rules it enforces are pinned by tests rather
|
|
583
|
+
than only by reading a run: a case lands in exactly ONE bucket; a case the
|
|
584
|
+
run did not pass stays counted as non-passing even when `--only` or
|
|
585
|
+
`--limit` keeps it out of this diagnosis; and a case that PASSED never
|
|
586
|
+
reaches the non-passing denominator, however it was excluded.
|
|
587
|
+
|
|
588
|
+
Returns (failed, passed, retrieval_only, excluded, excluded_passes).
|
|
589
|
+
`excluded` holds only non-passing cases, so `not_passing` can sum it;
|
|
590
|
+
`excluded_passes` holds passes kept out of diagnosis, reported separately.
|
|
591
|
+
"""
|
|
592
|
+
# Account for EVERY scored case, not just the ones that get diagnosed.
|
|
593
|
+
# A run that diagnosed 8 of 18 failures reported six clusters as though
|
|
594
|
+
# they covered the failures; they covered 44% of them, and nothing said so.
|
|
595
|
+
# Each case lands in exactly one bucket, so the buckets sum to the scored
|
|
596
|
+
# cases and a reader can see what the clusters are silent about.
|
|
597
|
+
failed, passed, retrieval_only = [], [], []
|
|
598
|
+
excluded: dict[str, list[str]] = {}
|
|
599
|
+
# Passes kept out of diagnosis. Separate from `excluded` because
|
|
600
|
+
# `not_passing` sums that one and a pass is not a non-passing case.
|
|
601
|
+
excluded_passes: dict[str, list[str]] = {}
|
|
602
|
+
|
|
603
|
+
def exclude(why: str, *qids: str) -> None:
|
|
604
|
+
excluded.setdefault(why, []).extend(qids)
|
|
605
|
+
|
|
606
|
+
for e in events:
|
|
607
|
+
if e.get("kind") != "score":
|
|
608
|
+
continue
|
|
609
|
+
qid, verdict = e["qid"], e.get("verdict")
|
|
610
|
+
case = cases.get(qid)
|
|
611
|
+
if case is None:
|
|
612
|
+
exclude("not in the case file", qid)
|
|
613
|
+
elif ledger.is_contaminated(e):
|
|
614
|
+
# Ahead of the holdout and verdict checks: a contaminated attempt
|
|
615
|
+
# is not evidence either way, so it is excluded for that reason
|
|
616
|
+
# whatever split it is on.
|
|
617
|
+
exclude("contaminated", qid)
|
|
618
|
+
elif verdict is None:
|
|
619
|
+
# No verdict to explain: an unestablished key, a truncated
|
|
620
|
+
# attempt, or a judge reply that could not be read. The `reason`
|
|
621
|
+
# says which, and none of them is a model failure.
|
|
622
|
+
exclude(f"unscored ({e.get('reason') or 'no reason recorded'})",
|
|
623
|
+
qid)
|
|
624
|
+
elif case.get("split") == "holdout" and not include_holdout:
|
|
625
|
+
# Ahead of the verdict checks, beside contamination and for the
|
|
626
|
+
# same reason: what split a case is on does not depend on how it
|
|
627
|
+
# scored. Below the `match` branch, a holdout case that answered
|
|
628
|
+
# correctly WITH a retrieval miss went straight into
|
|
629
|
+
# `retrieval_only` and on to a diagnosis call, because the holdout
|
|
630
|
+
# test was on a branch it never reached -- so the split leaked on
|
|
631
|
+
# every run and no flag could stop it.
|
|
632
|
+
#
|
|
633
|
+
# Which bucket still depends on how it scored, because
|
|
634
|
+
# `not_passing` sums `excluded` and a pass is not a non-passing
|
|
635
|
+
# case. Withholding a holdout PASS through `exclude()` printed
|
|
636
|
+
# "1 of 5 non-passing case(s) diagnosed (20%)" on a run with one
|
|
637
|
+
# failure and four holdout passes -- the same arithmetic the
|
|
638
|
+
# retrieval-miss branch below was already fixed for.
|
|
639
|
+
if verdict == "match":
|
|
640
|
+
excluded_passes.setdefault(
|
|
641
|
+
"holdout, withheld from diagnosis", []).append(qid)
|
|
642
|
+
else:
|
|
643
|
+
exclude("holdout, withheld from diagnosis", qid)
|
|
644
|
+
elif verdict == "match":
|
|
645
|
+
passed.append(qid)
|
|
646
|
+
# A correct answer can still rest on a retrieval miss, and that is
|
|
647
|
+
# a finding: the answer was right by another route, which on the
|
|
648
|
+
# run this comes from meant the agent rebuilding the model's own
|
|
649
|
+
# measure inline. It held while the measure was `count()` and
|
|
650
|
+
# failed the moment one carried a grain rule. "It worked anyway" is
|
|
651
|
+
# not a reason to leave the gap, so the case is diagnosed -- with
|
|
652
|
+
# its correctness recorded, so nobody reads the issue as a wrong
|
|
653
|
+
# number.
|
|
654
|
+
key = (qid, e.get("sample"), e.get("phase"))
|
|
655
|
+
if retrieval_finding(case, events, key, verdict):
|
|
656
|
+
retrieval_only.append(qid)
|
|
657
|
+
elif verdict not in want_verdicts:
|
|
658
|
+
exclude(f"{verdict}, not in --verdicts", qid)
|
|
659
|
+
else:
|
|
660
|
+
failed.append(qid)
|
|
661
|
+
failed = list(dict.fromkeys(failed))
|
|
662
|
+
retrieval_only = [q for q in dict.fromkeys(retrieval_only)
|
|
663
|
+
if q not in failed]
|
|
664
|
+
if no_retrieval_misses:
|
|
665
|
+
if retrieval_only:
|
|
666
|
+
# NOT `exclude()`: these cases PASSED. `excluded` is the account of
|
|
667
|
+
# what the clusters are silent about among cases the run did not
|
|
668
|
+
# pass, and `not_passing` sums it -- so putting a pass in there
|
|
669
|
+
# printed "coverage: 0 of 2 non-passing case(s) diagnosed (0%)" on
|
|
670
|
+
# a run where every case passed. Reported on its own line instead,
|
|
671
|
+
# which is also what `retrieval_only` gets when the flag is off.
|
|
672
|
+
excluded_passes["passed with a retrieval miss "
|
|
673
|
+
"(--no-retrieval-misses)"] = list(retrieval_only)
|
|
674
|
+
retrieval_only = []
|
|
675
|
+
|
|
676
|
+
# `--only` and `--limit` narrow what gets DIAGNOSED; they do not change
|
|
677
|
+
# what the run failed. The cases they drop are recorded as exclusions so
|
|
678
|
+
# the coverage denominator below still counts every non-passing case:
|
|
679
|
+
# truncating `failed` in place moved numerator and denominator together
|
|
680
|
+
# and printed "coverage: 5 of 5 non-passing case(s) diagnosed (100%)" on a
|
|
681
|
+
# run with 23 failures and --limit 5, which is the exact silence the
|
|
682
|
+
# coverage line was added to break.
|
|
683
|
+
if only:
|
|
684
|
+
want = {q.strip() for q in only.split(",")}
|
|
685
|
+
dropped = [q for q in failed if q not in want]
|
|
686
|
+
if dropped:
|
|
687
|
+
exclude("not named in --only", *dropped)
|
|
688
|
+
failed = [q for q in failed if q in want]
|
|
689
|
+
retrieval_only = [q for q in retrieval_only if q in want]
|
|
690
|
+
# `is not None`, not truthiness: `--limit 0` used to read as "no limit" and
|
|
691
|
+
# diagnose EVERY failure, one billable agent each -- failing open, on the
|
|
692
|
+
# one axis where failing open costs money. 0 now means zero cases, which is
|
|
693
|
+
# what it says. Unlimited is the default, spelled by omitting the flag.
|
|
694
|
+
if limit is not None:
|
|
695
|
+
if failed[limit:]:
|
|
696
|
+
exclude(f"beyond --limit {limit}", *failed[limit:])
|
|
697
|
+
failed = failed[:limit]
|
|
698
|
+
retrieval_only = retrieval_only[:max(0, limit - len(failed))]
|
|
699
|
+
return failed, passed, retrieval_only, excluded, excluded_passes
|
|
700
|
+
|
|
701
|
+
|
|
392
702
|
def main(argv: list[str] | None = None) -> int:
|
|
393
703
|
ap = argparse.ArgumentParser()
|
|
394
704
|
ap.add_argument("--run", required=True, type=pathlib.Path)
|
|
@@ -420,7 +730,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
420
730
|
"will not close it -- take the stable list from "
|
|
421
731
|
"flip_table.py and pass --verdicts near_match. Never "
|
|
422
732
|
"diagnose a one-armed near_match; that is noise.")
|
|
423
|
-
ap.add_argument("--
|
|
733
|
+
ap.add_argument("--no-retrieval-misses", action="store_true",
|
|
734
|
+
help="do not diagnose a case that answered correctly but "
|
|
735
|
+
"never received a required entity. Those are real "
|
|
736
|
+
"findings -- the answer was right by another route -- "
|
|
737
|
+
"so this is an opt-out for a run that only wants "
|
|
738
|
+
"answer failures, not a default.")
|
|
739
|
+
ap.add_argument("--include-holdout", action="store_true",
|
|
740
|
+
help="diagnose holdout cases too. Holdout is normally "
|
|
741
|
+
"withheld so the acceptance check keeps something "
|
|
742
|
+
"the improve step never saw -- but a MEASURE-ONLY "
|
|
743
|
+
"run never reaches improve, so it is holding them "
|
|
744
|
+
"back from nothing. Refused when the run already "
|
|
745
|
+
"carries a candidate edit.")
|
|
746
|
+
ap.add_argument("--compare-run", type=pathlib.Path, default=None,
|
|
747
|
+
help="another arm of the SAME model. A case this run "
|
|
748
|
+
"failed and that arm PASSED is a matched pair -- same "
|
|
749
|
+
"question, same model, one right answer and one wrong "
|
|
750
|
+
"one -- and the diff between the two queries usually "
|
|
751
|
+
"names the cause outright. Without it a flipped case "
|
|
752
|
+
"is diagnosed from one side only, which is the "
|
|
753
|
+
"richest evidence in the run going unread")
|
|
754
|
+
ap.add_argument("--limit", type=int, default=None,
|
|
755
|
+
help="how many to process; each one spawns a real agent. Omit for no limit. 0 means zero, not unlimited.")
|
|
424
756
|
ap.add_argument("--no-cluster", action="store_true")
|
|
425
757
|
ap.add_argument("--target", choices=("local", "platform"), default="local",
|
|
426
758
|
help="platform: probe through a hosted MCP server "
|
|
@@ -449,7 +781,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
449
781
|
"model, and without it diagnosis is biased away from "
|
|
450
782
|
"model-owned causes. Deliberately NOT the answerer's "
|
|
451
783
|
"manifest -- a diagnoser fluent in the answerer's own "
|
|
452
|
-
"playbook over-attributes to query
|
|
784
|
+
"playbook over-attributes to how the query was built")
|
|
453
785
|
ap.add_argument("--skills-root", default=None,
|
|
454
786
|
help="checkout holding skills/ and manifests/ for the role "
|
|
455
787
|
"skills (a Publisher checkout); this checkout still "
|
|
@@ -501,36 +833,72 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
501
833
|
raise SystemExit(f"--verdicts: {', '.join(sorted(unknown))} is not a "
|
|
502
834
|
f"diagnosable verdict (no_match, near_match, "
|
|
503
835
|
f"needs_human)")
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
836
|
+
# Holdout exists so the acceptance check has something the improve step
|
|
837
|
+
# never saw. A run that will never reach improve is holding it back from
|
|
838
|
+
# nothing -- but the harness cannot take that on trust, so it checks: a run
|
|
839
|
+
# already carrying a `candidate` has an edit in flight, and diagnosing its
|
|
840
|
+
# holdout would burn the only split that can still falsify that edit.
|
|
841
|
+
if a.include_holdout:
|
|
842
|
+
spent = [e for e in events if e.get("kind") == "candidate"]
|
|
843
|
+
if spent:
|
|
844
|
+
raise SystemExit(
|
|
845
|
+
f"--include-holdout: this run already carries "
|
|
846
|
+
f"{len(spent)} candidate edit(s), so its holdout is the only "
|
|
847
|
+
f"thing left that can falsify them. Diagnose holdout only on a "
|
|
848
|
+
f"run that will not reach improve.")
|
|
849
|
+
print(" --include-holdout: holdout cases WILL be diagnosed. This run "
|
|
850
|
+
"must not go on to an improve step.")
|
|
851
|
+
|
|
852
|
+
failed, passed, retrieval_only, excluded, excluded_passes = select_cases(
|
|
853
|
+
events, cases, want_verdicts,
|
|
854
|
+
include_holdout=a.include_holdout,
|
|
855
|
+
no_retrieval_misses=a.no_retrieval_misses,
|
|
856
|
+
only=a.only, limit=a.limit)
|
|
857
|
+
# Diagnosed together, because the question asked of both is the same one:
|
|
858
|
+
# why did the model not deliver what the answer needed. They are told
|
|
859
|
+
# apart on the way in, so the prompt can say the answer was right, and on
|
|
860
|
+
# the way out, so a reader never mistakes one for a wrong number.
|
|
861
|
+
to_diagnose = failed + retrieval_only
|
|
862
|
+
if not to_diagnose:
|
|
522
863
|
print("no diagnosable failures in this run")
|
|
523
864
|
return 0
|
|
524
865
|
|
|
525
866
|
art = a.run / "artifacts"
|
|
526
867
|
art.mkdir(parents=True, exist_ok=True)
|
|
527
|
-
|
|
868
|
+
extra = (f" + {len(retrieval_only)} that answered correctly without a "
|
|
869
|
+
f"required entity" if retrieval_only else "")
|
|
870
|
+
print(f"tier 1: {len(failed)} failed dev cases{extra}, {a.model}, "
|
|
528
871
|
f"{a.parallel} at a time ({len(codes)} codes in the skill)")
|
|
529
872
|
|
|
873
|
+
# The matched pairs, if another arm was named. A case this run failed and
|
|
874
|
+
# that arm passed hands the diagnosing agent the one comparison it can
|
|
875
|
+
# never make from a single run: same question, same model, two outcomes.
|
|
876
|
+
other_arm: dict[str, dict[str, Any]] = {}
|
|
877
|
+
if a.compare_run:
|
|
878
|
+
try:
|
|
879
|
+
import flip_table
|
|
880
|
+
theirs = flip_table.verdicts(a.compare_run)
|
|
881
|
+
except Exception as exc: # noqa: BLE001
|
|
882
|
+
raise SystemExit(f"--compare-run: could not read "
|
|
883
|
+
f"{a.compare_run}: {exc}")
|
|
884
|
+
label = (json.loads((a.compare_run / "run.json").read_text())
|
|
885
|
+
.get("label") if (a.compare_run / "run.json").exists()
|
|
886
|
+
else a.compare_run.name)
|
|
887
|
+
for q in to_diagnose:
|
|
888
|
+
v = theirs.get(q)
|
|
889
|
+
if v and v.get("passed"):
|
|
890
|
+
other_arm[q] = {"arm": label, "verdict": v["verdict"],
|
|
891
|
+
"finalQuery": v.get("final_query")}
|
|
892
|
+
print(f" --compare-run {label}: {len(other_arm)} of "
|
|
893
|
+
f"{len(to_diagnose)} case(s) passed there, so they are "
|
|
894
|
+
f"diagnosed as matched pairs")
|
|
895
|
+
|
|
530
896
|
issues: list[dict[str, Any]] = []
|
|
531
897
|
with futures.ThreadPoolExecutor(max_workers=a.parallel) as ex:
|
|
532
|
-
fut = {ex.submit(diagnose_one, q, cases[q], events, a, art
|
|
533
|
-
|
|
898
|
+
fut = {ex.submit(diagnose_one, q, cases[q], events, a, art,
|
|
899
|
+
answered_correctly=q in retrieval_only,
|
|
900
|
+
passed_elsewhere=other_arm.get(q)): q
|
|
901
|
+
for q in to_diagnose}
|
|
534
902
|
for i, f in enumerate(futures.as_completed(fut), 1):
|
|
535
903
|
q = fut[f]
|
|
536
904
|
try:
|
|
@@ -539,11 +907,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
539
907
|
obj = {"qid": q, "error": f"{type(exc).__name__}: {exc}"[:200]}
|
|
540
908
|
issues.append(obj)
|
|
541
909
|
if obj.get("error"):
|
|
542
|
-
print(f" [{i}/{len(
|
|
910
|
+
print(f" [{i}/{len(to_diagnose)}] ! {q} {obj['error']}",
|
|
911
|
+
flush=True)
|
|
543
912
|
else:
|
|
544
913
|
obj["_invalid"] = validate(obj, codes)
|
|
545
914
|
tag = "=" if obj.get("_cached") else ("!" if obj["_invalid"] else ".")
|
|
546
|
-
|
|
915
|
+
mark = " (answered correctly)" if q in retrieval_only else ""
|
|
916
|
+
print(f" [{i}/{len(to_diagnose)}] {tag} {q}{mark} "
|
|
547
917
|
f"{obj.get('component')}/{obj.get('primary_code')} "
|
|
548
918
|
f"-> {obj.get('owner')}"
|
|
549
919
|
+ (f" [{'; '.join(obj['_invalid'])}]"
|
|
@@ -552,8 +922,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
552
922
|
good = [i for i in issues if not i.get("error")]
|
|
553
923
|
clusters: dict[str, Any] = {"clusters": []}
|
|
554
924
|
if good and not a.no_cluster:
|
|
555
|
-
|
|
556
|
-
|
|
925
|
+
# The control group. Diagnosis only ever reads failures, so any
|
|
926
|
+
# behaviour common to the whole run looks causal from inside it. On one
|
|
927
|
+
# measured run the largest cluster was built on "substitutes broad
|
|
928
|
+
# enumeration for targeted retrieval", and the enumeration rate was 25%
|
|
929
|
+
# of targets in the failures against 26% in the passes -- identical.
|
|
930
|
+
# What actually separated them was volume, which question difficulty
|
|
931
|
+
# explains at least as well as call style. So the passes go to the
|
|
932
|
+
# clustering agent as a falsifier, measured the same way.
|
|
933
|
+
controls = [behaviour_stats(q, events) for q in passed]
|
|
934
|
+
how = (f"{len(controls)} passing case(s) as controls"
|
|
935
|
+
if len(controls) >= 3 else
|
|
936
|
+
f"only {len(controls)} passing case(s): too few to establish a "
|
|
937
|
+
f"rate, so behavioural clusters stay weakly supported"
|
|
938
|
+
if controls else
|
|
939
|
+
"NO passing cases in this run, so no behavioural cluster can be "
|
|
940
|
+
"falsified here")
|
|
941
|
+
print(f"\ntier 2: clustering {len(good)} diagnoses with "
|
|
942
|
+
f"{a.cluster_model} ({how})")
|
|
943
|
+
clusters = cluster(good, a, a.run, controls=controls)
|
|
557
944
|
for c in clusters.get("clusters", []):
|
|
558
945
|
print(f" {len(c.get('qids', [])):>2} cases {c.get('cluster_id')} "
|
|
559
946
|
f"({c.get('owner')}) {(c.get('rootCause') or '')[:66]}")
|
|
@@ -613,10 +1000,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
613
1000
|
# so the browsable package always carried the mechanical grouping -- which
|
|
614
1001
|
# on both the ecommerce and VideoAmp runs charged everything to retrieval
|
|
615
1002
|
# while the diagnosis beside it said otherwise.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1003
|
+
# Imported, not spelled: this file and `cluster_failures.py` both write
|
|
1004
|
+
# `clusters.jsonl`, and when `score_retrieval` renamed the labels these two
|
|
1005
|
+
# maps kept the old ones -- so the package's `where_to_fix` column carried
|
|
1006
|
+
# "query construction" from here and "delivered, wrong" from the scorer,
|
|
1007
|
+
# under one name, with the column's own doc matching neither.
|
|
1008
|
+
where = cluster_failures.WHERE_BY_OWNER
|
|
1009
|
+
lever = cluster_failures.LEVER_BY_OWNER
|
|
620
1010
|
with (a.run / "clusters.jsonl").open("w") as fh:
|
|
621
1011
|
for n, c in enumerate(clusters.get("clusters", []), 1):
|
|
622
1012
|
qids = [q for q in c.get("qids", []) if q in by_qid]
|
|
@@ -639,9 +1029,48 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
639
1029
|
invalid = [i for i in good if i.get("_invalid")]
|
|
640
1030
|
spend = sum(i.get("cost_usd") or 0 for i in issues) + \
|
|
641
1031
|
(clusters.get("_cost_usd") or 0)
|
|
642
|
-
print(f"\n{len(good)}/{len(
|
|
1032
|
+
print(f"\n{len(good)}/{len(to_diagnose)} diagnosed, "
|
|
643
1033
|
f"{len(clusters.get('clusters', []))} clusters, "
|
|
644
1034
|
f"{len(new) // 2} issues in {ev_path}")
|
|
1035
|
+
if retrieval_only:
|
|
1036
|
+
# Counted apart from the failures on purpose. These answers were
|
|
1037
|
+
# RIGHT; what is wrong is that the model did not deliver what they
|
|
1038
|
+
# needed and they were right by another route. Folding them into a
|
|
1039
|
+
# failure count would misreport the run.
|
|
1040
|
+
print(f" of those, {len(retrieval_only)} answered correctly and are "
|
|
1041
|
+
f"diagnosed for a retrieval miss only: "
|
|
1042
|
+
f"{', '.join(sorted(retrieval_only))}")
|
|
1043
|
+
# What the clusters are silent about. A run that diagnosed 8 of 18
|
|
1044
|
+
# non-passing cases reported its six clusters as though they covered the
|
|
1045
|
+
# failures, and the exclusions -- five holdout, two near_match, one
|
|
1046
|
+
# unparseable diagnoser reply, one lost verdict -- were each individually
|
|
1047
|
+
# correct and never added up anywhere. The denominator is every case the
|
|
1048
|
+
# run did NOT pass, because that is the number a reader has in mind.
|
|
1049
|
+
# `retrieval_only` cases are PASSES and stay out of this denominator: the
|
|
1050
|
+
# account answers "what did the clusters not cover, of what did not pass".
|
|
1051
|
+
not_passing = len(failed) + sum(len(v) for v in excluded.values())
|
|
1052
|
+
# Numerator and denominator must describe the same population. `good` now
|
|
1053
|
+
# includes cases that PASSED and were diagnosed for a retrieval miss, and
|
|
1054
|
+
# counting those against a non-passing denominator printed "2 of 1 (200%)".
|
|
1055
|
+
# They are reported on their own line above instead.
|
|
1056
|
+
good_failures = [i for i in good if i.get("qid") in set(failed)]
|
|
1057
|
+
if not_passing:
|
|
1058
|
+
print(f"\ncoverage: {len(good_failures)} of {not_passing} "
|
|
1059
|
+
f"non-passing case(s) diagnosed "
|
|
1060
|
+
f"({100 * len(good_failures) / not_passing:.0f}%)")
|
|
1061
|
+
for why, qids in sorted(excluded.items(),
|
|
1062
|
+
key=lambda kv: (-len(kv[1]), kv[0])):
|
|
1063
|
+
print(f" {len(qids):>3} {why}: {', '.join(sorted(qids))}")
|
|
1064
|
+
undiagnosed = len(failed) - len(good_failures)
|
|
1065
|
+
if undiagnosed:
|
|
1066
|
+
print(f" {undiagnosed:>3} selected but not diagnosed (the "
|
|
1067
|
+
f"diagnoser errored or its reply did not parse)")
|
|
1068
|
+
# Below the coverage block and never inside its denominator: these PASSED.
|
|
1069
|
+
# Reported all the same, because "2 passing cases carried a retrieval miss
|
|
1070
|
+
# and you told me not to look at them" is a thing a reader should see.
|
|
1071
|
+
for why, qids in sorted(excluded_passes.items()):
|
|
1072
|
+
print(f"\n{len(qids)} passing case(s) kept out of diagnosis -- {why}: "
|
|
1073
|
+
f"{', '.join(sorted(qids))}")
|
|
645
1074
|
if invalid:
|
|
646
1075
|
print(f"{len(invalid)} broke the skill's vocabulary "
|
|
647
1076
|
f"(see `_invalid` in diagnoses.jsonl)")
|