@malloy-publisher/skills 0.1.21 → 0.1.23

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 (52) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-answer/scripts/check_findable.py +99 -0
  4. package/skills/eval-answer/scripts/check_findable_test.py +38 -0
  5. package/skills/eval-answer/scripts/score_retrieval.py +14 -1
  6. package/skills/eval-answer/scripts/score_retrieval_test.py +8 -1
  7. package/skills/eval-answer/scripts/verify_goldens.py +44 -0
  8. package/skills/eval-diagnose/SKILL.md +16 -1
  9. package/skills/eval-import/SKILL.md +1 -1
  10. package/skills/eval-improve/SKILL.md +1 -1
  11. package/skills/eval-judge/SKILL.md +22 -1
  12. package/skills/eval-loop/SKILL.md +21 -6
  13. package/skills/eval-loop/scripts/build_run_package.py +17 -4
  14. package/skills/eval-loop/scripts/run_baseline.py +117 -6
  15. package/skills/eval-loop/scripts/run_baseline_test.py +127 -12
  16. package/skills/eval-loop/templates/eval-run-package/public/app.js +29 -4
  17. package/skills/eval-loop/templates/eval-run-package/public/index.html +35 -7
  18. package/skills/eval-report/SKILL.md +101 -12
  19. package/skills/malloy/SKILL.md +2 -1
  20. package/skills/malloy-analysis/SKILL.md +2 -2
  21. package/skills/malloy-analysis-pitfalls/SKILL.md +2 -2
  22. package/skills/malloy-analysis-report/SKILL.md +1 -1
  23. package/skills/malloy-analyze/SKILL.md +1 -1
  24. package/skills/malloy-charts/SKILL.md +1 -1
  25. package/skills/malloy-dashboards/SKILL.md +26 -8
  26. package/skills/malloy-debug/SKILL.md +1 -1
  27. package/skills/malloy-define/SKILL.md +1 -1
  28. package/skills/malloy-discover/SKILL.md +1 -1
  29. package/skills/malloy-document/SKILL.md +1 -1
  30. package/skills/malloy-getting-started/SKILL.md +1 -1
  31. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  32. package/skills/malloy-gotchas-queries/SKILL.md +43 -24
  33. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  34. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  35. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
  36. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  37. package/skills/malloy-lookml-review/SKILL.md +1 -1
  38. package/skills/malloy-lookml-review/reference/curate-visibility.md +1 -1
  39. package/skills/malloy-materialization/SKILL.md +3 -3
  40. package/skills/malloy-materialization-tuning/SKILL.md +2 -2
  41. package/skills/malloy-model/SKILL.md +51 -17
  42. package/skills/malloy-model-as-you-go/SKILL.md +2 -2
  43. package/skills/malloy-modeling/SKILL.md +2 -2
  44. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  45. package/skills/malloy-notebooks/SKILL.md +2 -2
  46. package/skills/malloy-patterns/SKILL.md +1 -1
  47. package/skills/malloy-phrase-detection/SKILL.md +14 -9
  48. package/skills/malloy-publish/SKILL.md +31 -12
  49. package/skills/malloy-queries/SKILL.md +11 -12
  50. package/skills/malloy-review/SKILL.md +1 -1
  51. package/skills/malloy-scope/SKILL.md +1 -1
  52. package/skills/malloy-source-unreachable/SKILL.md +71 -0
@@ -465,6 +465,33 @@ def format_rows(rows: list[dict], limit: int = 60) -> str:
465
465
  return "\n".join(out)
466
466
 
467
467
 
468
+ # A tool result too large for the model's context is not returned inline: the
469
+ # host writes it to a file and hands back a notice naming the path. The
470
+ # ANSWERER is unaffected -- it reads the file and carries on -- but the notice
471
+ # is not JSON, so this module parsed nothing and recorded an empty entity list.
472
+ # An empty list is not "nothing came back"; it scores as a total retrieval miss
473
+ # on a call that returned a full response, and a run then reports a recall it
474
+ # did not measure. Seen twice in one arm: 86.4% printed against 95% actual, and
475
+ # diagnose went on to explain the phantom miss with an index-readiness story
476
+ # that was not true.
477
+ OFFLOADED = re.compile(r"(?:saved to|written to)\s+(/[^\s'\"]+)")
478
+
479
+
480
+ def offloaded_json(text: str) -> dict[str, Any] | None:
481
+ """The response body a host spilled to a file, read back, or None.
482
+
483
+ Only ever reads a path the host itself named in the result it returned.
484
+ """
485
+ m = OFFLOADED.search(text or "")
486
+ if not m:
487
+ return None
488
+ try:
489
+ body = pathlib.Path(m.group(1)).read_text()
490
+ except OSError:
491
+ return None
492
+ return resource_json(body)
493
+
494
+
468
495
  def resource_json(text: str) -> dict[str, Any] | None:
469
496
  """The machine-readable part of a tool result. Publisher wraps it in a
470
497
  '[Resource from publisher at ...]' preamble; a hosted MCP may return
@@ -914,6 +941,52 @@ def resolve_target_version(scope: str | None, target_version: str | None
914
941
  return _strip_v(target_version, "--target-version")
915
942
 
916
943
 
944
+ def measure_coverage(a: argparse.Namespace) -> str | None:
945
+ """Run check_coverage.py for this model version, into the run directory.
946
+
947
+ Coverage is the first of the four things a run reports -- can the model
948
+ express an answer at all, did retrieval deliver it, did the agent get it
949
+ right, what did it cost -- and the only one that says whether a failure
950
+ was ever winnable. A run that skips it cannot tell a model gap from a
951
+ retrieval miss from a bad answer, so it is measured rather than left to a
952
+ follow-up command nobody runs.
953
+
954
+ It reads the MODEL through the REST API, not the answers and not a local
955
+ checkout, so it needs no `--model-repo` and is valid for every arm against
956
+ this model version: keep the file and pass `--coverage` next time.
957
+
958
+ A failure here does not stop the arm. Coverage is one rung of the report,
959
+ and losing it is worth saying loudly and continuing; killing a run over a
960
+ side measurement would cost the answerers instead.
961
+ """
962
+ out = a.out / "coverage.json"
963
+ script = (pathlib.Path(__file__).resolve().parent.parent.parent
964
+ / "eval-answer" / "scripts" / "check_coverage.py")
965
+ cmd = [sys.executable, str(script), "--set", str(a.set_dir),
966
+ "--publisher", a.publisher, "--environment", a.environment,
967
+ "--package", a.package, "--out", str(out),
968
+ "--parallel", str(a.parallel)]
969
+ # `--only` narrows the arm, so it must narrow this too: without it a
970
+ # one-case re-run paid a claude -p call for every case in the set.
971
+ if a.only:
972
+ cmd += ["--only", a.only]
973
+ print("measuring coverage: can the model express each answer at all "
974
+ "(reads the model; no answerer, no judge, no warehouse)", flush=True)
975
+ try:
976
+ r = subprocess.run(cmd, timeout=a.timeout * 2)
977
+ except Exception as e: # noqa: BLE001 -- any failure is the same call here
978
+ print(f" ! coverage not measured ({e}); the covered? rung will be "
979
+ f"blank and no failure can be attributed to a model gap",
980
+ flush=True)
981
+ return None
982
+ if r.returncode != 0 or not out.exists():
983
+ print(" ! coverage not measured (check_coverage.py exited "
984
+ f"{r.returncode}); the covered? rung will be blank and no "
985
+ "failure can be attributed to a model gap", flush=True)
986
+ return None
987
+ return str(out)
988
+
989
+
917
990
  def run_retrieval_gate(a: argparse.Namespace) -> str:
918
991
  """Hold the arm until retrieval is steady, and say what happened.
919
992
 
@@ -1225,7 +1298,15 @@ def cascade_lines(c: dict | None) -> list[str]:
1225
1298
  return []
1226
1299
  covered = (c["total"] - c["not covered"] - c["unmeasured"]
1227
1300
  - c["no entities named"])
1228
- retrieved = covered - c["not retrieved"]
1301
+ # Retrieval does NOT inherit coverage's denominator, and it must not be
1302
+ # derived from the funnel either. `cascade()` is an elif chain: a row whose
1303
+ # coverage was never measured stops at the first rung, so `not retrieved` is
1304
+ # structurally 0 whenever coverage did not run, and computing the rung from
1305
+ # it reports every retrieval as a success. `recall_scored` / `recall_short`
1306
+ # are tallied outside the chain, over the rows whose recall was actually
1307
+ # computed, which is what this rung is supposed to say.
1308
+ retrieved = c.get("recall_scored", 0) - c.get("recall_short", 0)
1309
+ not_retrieved = c.get("recall_short", 0)
1229
1310
  # A pass that stops on an earlier rung is reported there. Otherwise the
1230
1311
  # last rung reads as the pass count and disagrees with the headline.
1231
1312
  anyway = lambda n: f"; {n} answered correctly anyway" if n else ""
@@ -1245,7 +1326,7 @@ def cascade_lines(c: dict | None) -> list[str]:
1245
1326
  lines = [f" cascade {c['total']} cases",
1246
1327
  f" covered? {covered} yes, {c['not covered']} no (model "
1247
1328
  f"gap{anyway(c.get('passed_not_covered', 0))})" + covered_tail,
1248
- f" retrieved? {retrieved} yes, {c['not retrieved']} no "
1329
+ f" retrieved? {retrieved} yes, {not_retrieved} no "
1249
1330
  f"(the entity exists and did not come back: the docs, or the "
1250
1331
  f"search wording; diagnose decides"
1251
1332
  f"{anyway(c.get('passed_not_retrieved', 0))})",
@@ -1875,6 +1956,18 @@ def run_answerer(case: dict[str, Any], a: argparse.Namespace,
1875
1956
  calls.append({**info, "error": text[:300] if failed else None,
1876
1957
  "rankedSummary": None})
1877
1958
  else:
1959
+ # The host may have spilled the body to a file. Read it
1960
+ # back rather than scoring the notice as an empty response.
1961
+ if payload is None:
1962
+ payload = offloaded_json(text)
1963
+ if payload is None and OFFLOADED.search(text or ""):
1964
+ # Named a file we could not read. Record NO summary
1965
+ # rather than an empty one: unmeasured is the truth,
1966
+ # and a zero here is a miss the run did not observe.
1967
+ calls.append({**info,
1968
+ "error": text[:300] if failed else None,
1969
+ "rankedSummary": None})
1970
+ continue
1878
1971
  ids = entity_ids(payload or {})
1879
1972
  # Which retriever answered, from the response that answered
1880
1973
  # it: "semantic", "lexical" when the embedding path is down,
@@ -2645,6 +2738,11 @@ def main(argv: list[str] | None = None) -> int:
2645
2738
  "in retrieval attribution, and run.json records which "
2646
2739
  "report was read. Without it an unlabelled case is "
2647
2740
  "attributed to nobody, not to the model")
2741
+ ap.add_argument("--no-coverage", action="store_true",
2742
+ help="skip measuring coverage. The covered? rung goes "
2743
+ "blank and no failure can be attributed to a model "
2744
+ "gap, so a low score cannot be told from an "
2745
+ "unanswerable set")
2648
2746
  ap.add_argument("--parallel", type=int, default=4)
2649
2747
  ap.add_argument("--max-turns", type=int, default=30)
2650
2748
  ap.add_argument("--timeout", type=int, default=900)
@@ -3025,15 +3123,28 @@ def main(argv: list[str] | None = None) -> int:
3025
3123
 
3026
3124
  retrieval_gate = run_retrieval_gate(a)
3027
3125
 
3028
- # Coverage is a read of the MODEL and costs a judge call per case, so the
3029
- # run does not measure it; it consumes a report made separately and says
3030
- # which one. A path that does not exist is refused here, not discovered as
3031
- # a traceback after the answerers have been paid for.
3126
+ # Coverage is one of the four things a run measures -- whether the model
3127
+ # can express an answer AT ALL, before any question of whether retrieval
3128
+ # found it or the agent got it right. A model that cannot express an answer
3129
+ # cannot succeed at it, so a run without coverage cannot say whether a
3130
+ # failure was ever winnable. It is measured by default for that reason.
3131
+ #
3132
+ # It reads the MODEL, not the answers, so it is the same for every arm
3133
+ # against one model version: pass `--coverage` to reuse a report you
3134
+ # already have, and it is used as given. Otherwise the run measures it
3135
+ # once, here, before the answerers are paid for -- a `claude -p` per case
3136
+ # with no warehouse, no judge and no answerer. `--no-coverage` skips it.
3032
3137
  if a.coverage and not pathlib.Path(a.coverage).exists():
3033
3138
  raise SystemExit(
3034
3139
  f"--coverage {a.coverage} does not exist. It should be a "
3035
3140
  f"check_coverage.py --out report for the model version this run "
3036
3141
  f"answers from.")
3142
+ # `--rejudge` re-scores saved answers and calls no answerer, so it must
3143
+ # not re-measure coverage either; it does not set `rebuild` (only
3144
+ # `--from` does), so it needs naming here.
3145
+ if not a.coverage and not a.no_coverage and not a.rebuild \
3146
+ and not getattr(a, 'rejudge', False):
3147
+ a.coverage = measure_coverage(a)
3037
3148
  coverage_report = coverage_report_summary(a.coverage) if a.coverage else None
3038
3149
 
3039
3150
  # BEFORE the fresh run.json overwrites it: a rebuild reuses saved verdicts
@@ -1199,28 +1199,63 @@ class RunSummary(unittest.TestCase):
1199
1199
  def test_no_report_keeps_the_pointer(self):
1200
1200
  self.assertIn("not measured here", "\n".join(self.lines()))
1201
1201
 
1202
+ def rows(self, spec):
1203
+ """Cascade input built the way the harness builds it.
1204
+
1205
+ The fixtures here were hand-written dicts, and a hand-written cascade
1206
+ can describe a state `cascade()` cannot produce -- which is how a
1207
+ display bug survived its own test. Build the rows, let `cascade()`
1208
+ count them.
1209
+ """
1210
+ out = []
1211
+ for coverage, recall, failed, n in spec:
1212
+ out += [{"coverage": coverage, "recall": recall, "failed": failed,
1213
+ "verdict": "no_match" if failed else "match"}] * n
1214
+ return rb.cascade(out)
1215
+
1202
1216
  def test_the_cascade_reads_as_a_funnel_with_owners(self):
1203
- lines = self.lines(cascade={
1204
- "total": 49, "not covered": 6, "unmeasured": 2,
1205
- "no entities named": 0, "not retrieved": 5,
1206
- "delivered, wrong": 6, "delivered, right": 30, "not scored": 0})
1217
+ lines = self.lines(cascade=self.rows([
1218
+ ("absent", None, True, 6), # a known coverage gap
1219
+ ("unknown", 1.0, True, 2), # coverage never measured
1220
+ ("covered", 0.5, True, 5), # retrieved short
1221
+ ("covered", 1.0, True, 6), # delivered, wrong
1222
+ ("covered", 1.0, False, 30), # delivered, right
1223
+ ]))
1207
1224
  text = "\n".join(lines)
1208
1225
  self.assertIn("cascade 49 cases", text)
1209
1226
  self.assertIn("covered? 41 yes, 6 no (model gap), 2 unmeasured", text)
1210
- # The rung must not assert the docs: a miss is the docs OR the search
1211
- # wording, and only diagnose separates them. This assertion is here
1212
- # because the label was renamed in score_retrieval and the display line
1213
- # in this file was missed, so the two disagreed in a shipped commit.
1214
- self.assertIn("retrieved? 36 yes, 5 no (the entity exists and did "
1227
+ # 43 rows carried a recall (49 less the 6 coverage gaps), 5 fell short.
1228
+ # The rung counts what recall MEASURED, not what the funnel let through.
1229
+ self.assertIn("retrieved? 38 yes, 5 no (the entity exists and did "
1215
1230
  "not come back", text)
1216
1231
  self.assertNotIn("(documentation", text)
1217
- # And a delivered-but-wrong answer names no owner until diagnose runs.
1218
1232
  self.assertIn("correct? 30 yes, 6 no (delivered, wrong", text)
1219
1233
  self.assertIn("diagnose decides", text)
1220
- # It heads the COVERAGE & RETRIEVAL layer, above the retrieval-mode line.
1221
1234
  self.assertLess(self.index_of(lines, "cascade"),
1222
1235
  self.index_of(lines, " retrieval "))
1223
1236
 
1237
+
1238
+ def test_unmeasured_coverage_does_not_zero_the_retrieval_rung(self):
1239
+ """check_coverage is a separate spend, so most runs have none.
1240
+
1241
+ `cascade()` is an elif chain: a row whose coverage was never measured
1242
+ stops at the first rung and never reaches the recall check, so
1243
+ `not retrieved` is structurally 0. A display that subtracted it from a
1244
+ denominator therefore reported EVERY retrieval as a success -- 12
1245
+ cases, three at recall 0.5, printing "retrieved? 12 yes, 0 no". The
1246
+ rung reads a tally kept outside the chain.
1247
+ """
1248
+ text = "\n".join(self.lines(cascade=self.rows([
1249
+ ("unknown", 1.0, False, 10),
1250
+ ("unknown", 0.5, False, 1),
1251
+ ("unknown", None, False, 1),
1252
+ ])))
1253
+ self.assertIn("covered? 0 yes, 0 no (model gap), 12 unmeasured", text)
1254
+ self.assertIn("retrieved? 10 yes, 1 no", text)
1255
+ self.assertNotIn("retrieved? 12 yes", text)
1256
+ self.assertNotIn("retrieved? 0 yes", text)
1257
+
1258
+
1224
1259
  def test_no_cascade_prints_nothing(self):
1225
1260
  self.assertNotIn("cascade", "\n".join(self.lines()))
1226
1261
 
@@ -1526,6 +1561,86 @@ class GoldenCheckScope(unittest.TestCase):
1526
1561
  self.assertIn("qids", src)
1527
1562
  self.assertIn('c["qid"] in qids', src)
1528
1563
 
1564
+ class CoverageByDefault(unittest.TestCase):
1565
+ """Coverage is measured unless asked not to.
1566
+
1567
+ It answers the first of the four questions a run reports -- can the model
1568
+ express an answer at all -- and it is the one that says whether a failure
1569
+ was ever winnable. Left to a follow-up command it went unrun, and the
1570
+ covered? rung came back blank on every real run.
1571
+ """
1572
+
1573
+ def ns(self, **kw):
1574
+ a = argparse.Namespace(
1575
+ coverage=None, no_coverage=False, rebuild=False,
1576
+ out=pathlib.Path("/tmp/does-not-matter"),
1577
+ set_dir=pathlib.Path("evals/e"), publisher="http://p",
1578
+ environment="env", package="pkg", timeout=60,
1579
+ parallel=4, only=None, rejudge=False)
1580
+ for k, v in kw.items():
1581
+ setattr(a, k, v)
1582
+ return a
1583
+
1584
+ def test_it_runs_when_no_report_was_given(self):
1585
+ a = self.ns()
1586
+ with mock.patch.object(rb, "subprocess") as sp, \
1587
+ mock.patch.object(pathlib.Path, "exists", return_value=True):
1588
+ sp.run.return_value = argparse.Namespace(returncode=0)
1589
+ out = rb.measure_coverage(a)
1590
+ self.assertIsNotNone(out)
1591
+ cmd = sp.run.call_args[0][0]
1592
+ self.assertIn("--publisher", cmd)
1593
+ self.assertIn("http://p", cmd)
1594
+ # Through REST, so it needs no local checkout and stays valid for every
1595
+ # arm against this model version.
1596
+ self.assertNotIn("--model", cmd)
1597
+ self.assertIn("--parallel", cmd)
1598
+
1599
+ def test_only_narrows_coverage_as_it_narrows_the_arm(self):
1600
+ """A one-case re-run paid a claude -p call for every case in the set."""
1601
+ a = self.ns(only="q1,q2")
1602
+ with mock.patch.object(rb, "subprocess") as sp, \
1603
+ mock.patch.object(pathlib.Path, "exists", return_value=True):
1604
+ sp.run.return_value = argparse.Namespace(returncode=0)
1605
+ rb.measure_coverage(a)
1606
+ cmd = sp.run.call_args[0][0]
1607
+ self.assertIn("--only", cmd)
1608
+ self.assertIn("q1,q2", cmd)
1609
+
1610
+ def test_a_failure_does_not_kill_the_arm(self):
1611
+ """Losing one rung must not cost the answerers."""
1612
+ a = self.ns()
1613
+ with mock.patch.object(rb, "subprocess") as sp:
1614
+ sp.run.return_value = argparse.Namespace(returncode=1)
1615
+ self.assertIsNone(rb.measure_coverage(a))
1616
+
1617
+
1618
+ class OffloadedToolResult(unittest.TestCase):
1619
+ """A response spilled to a file is not an empty response.
1620
+
1621
+ A get_context result too large for the model's context is written to a
1622
+ file and replaced by a notice naming the path. The answerer reads the file
1623
+ and is unaffected; the ledger used to parse the notice, find no JSON, and
1624
+ record an empty entity list -- a total retrieval miss on a call that
1625
+ returned in full. One arm printed 86.4% recall against an actual 95%, and
1626
+ diagnose then explained the phantom miss with an index-readiness story
1627
+ that was false.
1628
+ """
1629
+
1630
+ def test_the_body_is_read_back_from_the_named_file(self):
1631
+ with tempfile.TemporaryDirectory() as d:
1632
+ p = pathlib.Path(d) / "big.txt"
1633
+ p.write_text(json.dumps({"sources": [], "retrieval": "semantic"}))
1634
+ out = rb.offloaded_json(
1635
+ f"Error: result exceeds maximum allowed tokens. "
1636
+ f"Output has been saved to {p}")
1637
+ self.assertEqual(out, {"sources": [], "retrieval": "semantic"})
1638
+
1639
+ def test_an_unreadable_path_is_none_not_empty(self):
1640
+ self.assertIsNone(rb.offloaded_json("saved to /nope/does-not-exist.txt"))
1641
+
1642
+ def test_an_ordinary_result_is_not_mistaken_for_an_offload(self):
1643
+ self.assertIsNone(rb.offloaded_json('{"sources": []}'))
1529
1644
 
1530
1645
  if __name__ == "__main__":
1531
- unittest.main()
1646
+ unittest.main()
@@ -92,9 +92,19 @@ function pill(v) {
92
92
  return `<span class="pill ${esc(v)}">${esc(verdictLabel[v] || v.replace('_', ' '))}</span>`;
93
93
  }
94
94
 
95
+ /* The entities this answer needed, one dot each, and the count in front.
96
+ The dots alone made a reader count them to learn the one thing that
97
+ matters -- how many of the needed entities came back -- and the four
98
+ shades read as four verdicts rather than as one binary with three ways
99
+ of arriving at it. The fraction says it outright; the shades stay for
100
+ HOW each one was delivered, which the legend explains. */
95
101
  function dots(required) {
96
102
  if (!required || !required.length) return '';
97
- return '<span class="dots">' + required.map(e =>
103
+ const got = required.filter(e => e.status !== 'missing').length;
104
+ const all = got === required.length;
105
+ return `<span class="dots"><span class="recall${all ? '' : ' short'}"`
106
+ + ` title="${got} of ${required.length} needed entities came back">`
107
+ + `${got}/${required.length}</span>` + required.map(e =>
98
108
  `<i class="dot ${esc(e.status)}" title="${esc(e.entity_id)} — ${esc(e.status)}"></i>`)
99
109
  .join('') + '</span>';
100
110
  }
@@ -145,7 +155,18 @@ function predictionBlock(p) {
145
155
  cells(l).map((c, i) => [cols[i] || `c${i}`, isFinite(c) && c !== '' ? Number(c) : c])));
146
156
  return rowsTable(rows);
147
157
  }
148
- return `<div class="box mute">${esc(t)}</div>`;
158
+ // Malloy renders a result with no grouping as `name ------ value`, one line
159
+ // per field and not a pipe in sight, so it used to miss both branches above
160
+ // and land in the raw fallback: a lone number shown as
161
+ // `check_sum --------- 213939.33999999976`, full width, dashes and all.
162
+ // Through rowsTable it is a one-row table with the column named and the
163
+ // number formatted, which is what every other result here looks like.
164
+ const scalar = t.split('\n').map(l => l.match(/^\s*([^\s|]+)\s+-{3,}\s+(.*\S)\s*$/))
165
+ .filter(Boolean);
166
+ if (scalar.length && scalar.length === t.split('\n').filter(l => l.trim()).length)
167
+ return rowsTable([Object.fromEntries(scalar.map(([, k, v]) =>
168
+ [k, isFinite(v) && v !== '' ? Number(v) : v]))]);
169
+ return `<div class="box mute rawtext">${esc(t)}</div>`;
149
170
  }
150
171
 
151
172
  /* Enough markdown for an agent's answer: fences, tables, bold, code, headings, bullets. */
@@ -280,7 +301,8 @@ async function loadCase(qid) {
280
301
  const d0 = detail[0];
281
302
  let html = `<div><h3>Reference answer</h3>${goldenBlock(d0)}`;
282
303
  if (d0.rubric || d0.must_state)
283
- html += `<details class="sub" style="margin-top:8px"><summary>Rubric</summary>
304
+ html += `<details class="sub" style="margin-top:8px"><summary><span
305
+ class="caret">&#9654;</span>Rubric &mdash; how this answer was judged</summary>
284
306
  <div class="box" style="margin-top:6px">${d0.rubric ? esc(d0.rubric) : ''}${
285
307
  d0.must_state ? `<br><br><b>Must state.</b> ${esc(d0.must_state)}` : ''}</div></details>`;
286
308
  html += '</div>';
@@ -318,10 +340,13 @@ async function loadCase(qid) {
318
340
  than on what the answer claims.</p>${predictionBlock(d.prediction)}`
319
341
  : `<h3>Re-executed rows</h3><p class="cap">None. The judge scored this
320
342
  attempt from its answer text and the reference answer alone.</p>`}
321
- <h3>The attempt, step by step</h3>
343
+ <details class="trace"><summary><span class="caret">&#9654;</span>The attempt,
344
+ step by step<span class="mute"> &middot; ${mine.length || 1} step${
345
+ (mine.length || 1) === 1 ? '' : 's'}</span></summary>
322
346
  <div class="timeline">${mine.length ? mine.map(stepHtml).join('')
323
347
  : `<div class="step text"><div class="prose">${md(d.answer)}</div></div>${
324
348
  d.final_query ? `<div class="step execute_query"><span class="k">final query</span><pre>${esc(d.final_query)}</pre></div>` : ''}`}</div>
349
+ </details>
325
350
  ${d.transcript ? `<div class="qid" style="margin-top:10px">transcript: ${esc(d.transcript)}</div>` : ''}
326
351
  </div>`;
327
352
  }
@@ -96,6 +96,9 @@
96
96
  .kind { font:400 12px "IBM Plex Mono",monospace; color:var(--mute); }
97
97
  .armcell { display:flex; align-items:center; justify-content:flex-end; gap:10px; }
98
98
  .dots { display:flex; gap:4px; align-items:center; }
99
+ .recall { font-family:"IBM Plex Mono",monospace; font-size:11.5px;
100
+ color:var(--mute); margin-right:3px; white-space:nowrap; }
101
+ .recall.short { color:var(--fail); font-weight:600; }
99
102
  .dot { width:10px; height:10px; border-radius:50%; display:inline-block; background:var(--na); }
100
103
  .dot.exact,.dot.alias { background:var(--pass); }
101
104
  .dot.alias { background:color-mix(in srgb,var(--pass) 60%,var(--surface)); }
@@ -166,8 +169,30 @@
166
169
  td.num { text-align:right; }
167
170
  tr:last-child td { border-bottom:0; }
168
171
  .more { font-size:12px; color:var(--mute); padding:6px 10px; }
169
- details.sub summary { cursor:pointer; font-size:13px; color:var(--accent); list-style:none; }
170
- details.sub summary::-webkit-details-marker { display:none; }
172
+ /* A bare coloured word did not read as clickable: it looks like a label, and
173
+ the rubric went unopened. Give every disclosure the same affordance the
174
+ case rows have -- a caret that turns, a border, and a hover state. */
175
+ details.sub summary, details.trace summary {
176
+ cursor:pointer; font-size:13px; color:var(--accent); list-style:none;
177
+ display:inline-flex; align-items:center; gap:7px;
178
+ border:1px solid var(--rule); border-radius:6px; padding:5px 10px;
179
+ background:var(--surface); user-select:none; }
180
+ details.sub summary:hover, details.trace summary:hover { border-color:var(--accent); }
181
+ details.sub summary::-webkit-details-marker,
182
+ details.trace summary::-webkit-details-marker { display:none; }
183
+ details.sub summary .caret, details.trace summary .caret { font-size:9px; }
184
+ details.sub[open] summary .caret, details.trace[open] summary .caret {
185
+ transform:rotate(90deg); }
186
+ /* The trace is the longest thing in the drawer and the least often wanted:
187
+ a reader opens a case for the verdict and the rows, and only then asks how
188
+ the agent got there. Collapsed, the judge's reasoning and the re-executed
189
+ rows sit above the fold. */
190
+ details.trace { margin-top:18px; }
191
+ details.trace > .timeline { margin-top:10px; }
192
+ /* Raw text the parsers could not turn into a table: keep it small and
193
+ wrapped rather than letting a long line set the drawer width. */
194
+ .rawtext { font-family:"IBM Plex Mono",monospace; font-size:12.5px;
195
+ white-space:pre-wrap; word-break:break-word; }
171
196
  .empty { color:var(--mute); font-style:italic; padding:20px; text-align:center; }
172
197
  .err { background:var(--fail-soft); border:1px solid var(--fail); color:var(--fail);
173
198
  padding:14px 16px; border-radius:6px; font-size:13.5px; }
@@ -190,11 +215,14 @@
190
215
  <span class="count" id="count"></span>
191
216
  </div>
192
217
  <div class="legend">
193
- <span>Dots: the entities the golden answer depends on —</span>
194
- <span><i class="dot exact"></i> returned as a ranked entity</span>
195
- <span><i class="dot alias"></i> returned under a sibling source</span>
196
- <span><i class="dot in_docs"></i> in a returned source's docs</span>
197
- <span><i class="dot missing"></i> not returned</span>
218
+ <span><b>Retrieval</b>, per case: how many of the entities the right answer
219
+ needs came back from <code>get_context</code>. One dot each, and the
220
+ count in front &mdash; <span class="recall">3/3</span> means all of them.</span>
221
+ <span class="mute">The shade says how each one arrived:</span>
222
+ <span><i class="dot exact"></i> ranked directly</span>
223
+ <span><i class="dot alias"></i> under a sibling source</span>
224
+ <span><i class="dot in_docs"></i> named in a returned source's docs</span>
225
+ <span><i class="dot missing"></i> <b>did not come back</b></span>
198
226
  </div>
199
227
  <!-- Every case row carries two words from the SET, describing the question
200
228
  rather than how it went. Both were read as verdicts until they were
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-report
3
3
  description: 'Write the summary a person reads after an evaluation run: what ran, what it scored, which failures were the MODEL and which were the EVAL itself (out of turns, contaminated, unestablished golden, unreadable judge), what was learned, and what to do next. Every claim links to the artifact behind it. Use at the end of any run, before quoting a number to anyone. Never scores an answer (eval-answer) or decides who owns a failure (eval-diagnose).'
4
- version: 0.1.21
4
+ version: 0.1.23
5
5
  ---
6
6
 
7
7
  # Report a Run
@@ -26,6 +26,30 @@ one exception is anything that qualifies THIS run's number -- a truncated
26
26
  attempt, a contaminated one, an unestablished answer key -- which the "Eval
27
27
  failures" section exists for.
28
28
 
29
+ **Do not audit the harness. You were asked to measure a model.** This is the
30
+ most common way this job goes wrong, and it does not look like going wrong: a
31
+ run turns up something odd in a script, the odd thing is genuinely a bug, and
32
+ the reply comes back as a critique of the tooling with the model's score
33
+ somewhere underneath. The reader asked what their model scored. Answer that.
34
+
35
+ So, unless the user asked you to work on the harness:
36
+
37
+ - Do not read harness source to satisfy your own curiosity about a number.
38
+ Read it when a number you must report cannot be explained any other way, and
39
+ stop when it can.
40
+ - Do not propose harness fixes, refactors, flags or "while I was in there"
41
+ improvements. Not in the report, not in the chat reply.
42
+ - When a harness defect DID change this run's number, the report gets one
43
+ sentence: what the number should be and why. Not the mechanism, not the
44
+ file, not the fix.
45
+ - Keep a defect that changed nothing out of the report entirely. Mention it
46
+ once in chat, in a line, and let the user decide whether they want it
47
+ chased.
48
+
49
+ A harness bug you found and did not chase is not a loose end. It is the job
50
+ being done. If the user wants it fixed they will say so, and then it is a
51
+ different task with its own turn.
52
+
29
53
  ## Step 1: build the artifacts, before writing a word
30
54
 
31
55
  ```bash
@@ -64,6 +88,29 @@ not in `~/Downloads`, both of which have lost a findings document before.
64
88
  from the console.** A figure retyped from scrollback is a figure nobody can
65
89
  check, and the console rounds.
66
90
 
91
+ ### Keep it under a page and a half
92
+
93
+ **Budget: about 80 lines, and 120 is the ceiling for a run with several
94
+ distinct failures.** Reports have shipped at 200 and the length is not
95
+ thoroughness -- it is the artifact links restated as prose, the cascade
96
+ explained twice, and a paragraph apologising for a figure nobody disputed.
97
+ A reader who wants the detail opens the case matrix, which is why step 1 builds
98
+ it. What cannot be recovered from the artifacts is your judgement: what broke,
99
+ why, and what to do. Spend the lines there.
100
+
101
+ What earns its place: the headline, one row per case, one short entry per
102
+ failure, the retrieval numbers, and the next steps. What does not: restating a
103
+ number you already gave, explaining what a cascade is before showing it,
104
+ defending a decision nobody questioned, or any section whose content is that
105
+ nothing happened -- except "Eval failures", where "None." is the point.
106
+
107
+ **Being brief is not being terse with the vocabulary.** The reader does not
108
+ know what `near_match`, a cluster, recall or a holdout is, so the first time
109
+ one appears, say what it means in the same sentence -- "`near_match`, which is
110
+ excluded from the pass rate" -- and then use it. Cutting the explanations is
111
+ how a short report becomes an unreadable one; cutting the restatements is how
112
+ it becomes a good one.
113
+
67
114
  ### The template
68
115
 
69
116
  ```markdown
@@ -77,7 +124,7 @@ check, and the console rounds.
77
124
  - Target `<env>/<package>`, model `<modelPath>`, pinned `<modelSha or targetVersion>`
78
125
  - Answerer `<model>`, judge `<model>`, cap `<maxTurns>` turns
79
126
  - Steps run: scrape/run, eval<, diagnose><, improve>
80
- - Cost $X answerer + $Y judge
127
+ - Cost $X answerer + $Y judge, N turns median / N p90, N entities delivered per answer
81
128
 
82
129
  ## The result
83
130
 
@@ -88,9 +135,15 @@ check, and the console rounds.
88
135
 
89
136
  [case matrix](<url>) - [aggregate tables](<url>)
90
137
 
91
- ## Retrieval and coverage
138
+ ## Coverage, retrieval, accuracy, cost
92
139
 
93
- Covered? -> Retrieved? -> Correct?, with the per-arm numbers under each.
140
+ **Coverage N of M** -- how many questions the model can express an answer to at
141
+ all. **Entity recall N%** -- one number, first, before the cascade. It is the share
142
+ of the entities an answer needed that retrieval actually handed the agent, and
143
+ it is the headline of this section for the same reason the pass rate is the
144
+ headline of the last one. Then the cascade: Covered? -> Retrieved? -> Correct?,
145
+ with the per-arm numbers under each. Say in one clause what recall counts, then
146
+ give the number.
94
147
 
95
148
  ## Model failures
96
149
 
@@ -119,10 +172,10 @@ these in order and put every one that fires into the list, with its command:
119
172
 
120
173
  | If the run shows | Then the next step is |
121
174
  |---|---|
122
- | `coverage: unmeasured` | run `check_coverage.py --set <set> --model <pkg> --out coverage.json`, then re-run with `--coverage` so it charges the failures |
175
+ | `coverage: unmeasured` | the run was given `--no-coverage`, or the measurement failed and said so. It is measured by default, so say in "Eval failures" that this score cannot tell a model gap from a bad answer, and re-run without the flag |
123
176
  | `goldenCheck: skipped` or the set names no `truthPackage` | build one with `init_truth_package.py`; until then the goldens were derived through the model under test and certify themselves |
124
177
  | any golden still `provisional` | re-derive and `verify_goldens.py --promote` |
125
- | a stale entity name warning | fix `expectedEntities`; it scores as a retrieval miss on every run until you do |
178
+ | a stale entity name warning | fix `expectedEntities`; it scores as a retrieval miss on every run until you do. A next step, not a section: it goes in this list and nowhere else in the report |
126
179
  | a passing case with recall below 1.0 | check whether `required` over-specifies one path |
127
180
  | `truncated` non-empty | re-run those cases at a higher cap with `--from` |
128
181
  | diagnose did not run | run it, or say the failures have no owner yet |
@@ -165,8 +218,23 @@ wrong answer. One entry each: what the answer said, what was right, and the
165
218
  mechanism in one sentence. Do not write a cluster id here; write what it got
166
219
  wrong.
167
220
 
168
- **Eval failures** are everything that stopped the run measuring the model.
169
- Report every one that occurred, with its count and its qids, and say plainly
221
+ **Eval failures** are the things that stopped the run measuring the model, and
222
+ **only** those. The test is one question: *did this cost a verdict, or make one
223
+ untrustworthy?* If no, it does not appear in the report at any length.
224
+
225
+ That rules out most of what is tempting to put here, and all of it has been put
226
+ here on a real run: a `-dirty` model pin, a stale-entity-name warning that cost
227
+ no case, a defect you hit in the harness and worked around, a setup step that
228
+ took two tries, anything you would open with "worth knowing, though it changes
229
+ no verdict". A reader wants to know what their model scored. Harness defects are
230
+ real and belong in a harness issue, filed against the harness -- that is the
231
+ skill's opening rule, and this section is where it gets broken.
232
+
233
+ The section is usually two words. "**None.**" is a complete and good answer, and
234
+ a reader who sees it learns exactly what they need to. Do not pad it into a
235
+ paragraph explaining the absence.
236
+
237
+ Report every one that DID occur, with its count and its qids, and say plainly
170
238
  that these are NOT evidence about the model:
171
239
 
172
240
  | What happened | How it reads in the ledger | Who fixes it |
@@ -185,11 +253,32 @@ harness prints `INCOMPLETE` and withholds the percentage; the report does the
185
253
  same. Give the counts and the re-run command instead of a number with a caveat,
186
254
  because the number is what gets repeated and the caveat is what gets dropped.
187
255
 
188
- ## Retrieval and coverage belong in every report
256
+ ## The four measurements, and why a report carries all of them
257
+
258
+ A pass rate says an answer was wrong. It does not say where, and on its own it
259
+ makes every failure look like the model's. A run measures four things, in this
260
+ order, and each one tells you whether the next is even a fair question:
189
261
 
190
- A pass rate says an answer was wrong. It does not say WHERE, and the three
191
- metrics that do are already in the run summary and the notebook. Report them,
192
- because a report that omits them makes every failure look like the model's:
262
+ | | Measures | A failure here means |
263
+ |---|---|---|
264
+ | **a. Coverage** | can the data and the model express an answer at all | nothing downstream was winnable. Fix the model |
265
+ | **b. Retrieval** | did `get_context` hand the agent the entities it needed | the model has it and the agent never saw it: the docs, or the search wording |
266
+ | **c. Accuracy** | did the agent get the answer right | it had what it needed and still missed: the agent, or a doc that misleads |
267
+ | **d. Cost** | dollars, turns, wall-clock, entities per answer | it works and cannot be afforded, which is its own kind of not working |
268
+
269
+ **Coverage first, and a report without it is incomplete.** A model that cannot
270
+ express an answer can never succeed at that question, so a pass rate quoted
271
+ without coverage cannot distinguish a bad model from an unanswerable set.
272
+ `run_baseline.py` measures it by default; if a run skipped it, say so in
273
+ "Eval failures" and treat the score as provisional.
274
+
275
+ **Cost is a result, not an aside.** Report dollars, the turn and call counts,
276
+ and the entity payload per answer. An agent that answers correctly in 40 turns
277
+ and 75 delivered entities per question is a different product from one that
278
+ does it in 8 and 5, and only the report says which you have.
279
+
280
+ Report them all, because a report that omits them makes every failure look
281
+ like the model's:
193
282
 
194
283
  **Copy the cascade the run printed. Do not re-derive it.** `cascade_lines()`
195
284
  in `run_baseline.py` already renders every rung, and it carries one field a