@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
@@ -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]]) -> 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 and
196
- `returnedInRankOrder` is what came back, in rank order.
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) -> dict[str, Any]:
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) -> dict[str, Any]:
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("--limit", type=int, default=None)
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 construction")
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
- failed = []
505
- for e in events:
506
- if e.get("kind") != "score" or e.get("verdict") not in want_verdicts:
507
- continue
508
- case = cases.get(e["qid"])
509
- if case is None or case.get("split") == "holdout":
510
- continue
511
- if ledger.is_contaminated(e):
512
- continue
513
- failed.append(e["qid"])
514
- failed = list(dict.fromkeys(failed))
515
-
516
- if a.only:
517
- want = {q.strip() for q in a.only.split(",")}
518
- failed = [q for q in failed if q in want]
519
- if a.limit:
520
- failed = failed[:a.limit]
521
- if not failed:
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
- print(f"tier 1: {len(failed)} failed dev cases, {a.model}, "
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): q
533
- for q in failed}
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(failed)}] ! {q} {obj['error']}", flush=True)
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
- print(f" [{i}/{len(failed)}] {tag} {q} "
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
- print(f"\ntier 2: clustering {len(good)} diagnoses with {a.cluster_model}")
556
- clusters = cluster(good, a, a.run)
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
- where = {"model": "model coverage", "retrieval": "retrieval ranking",
617
- "agent-skill": "query construction", "dataset": "dataset"}
618
- lever = {"model": "model", "retrieval": "retrieval", "agent-skill": "skill",
619
- "dataset": "dataset"}
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(failed)} diagnosed, "
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)")