@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.
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +1 -1
- package/skills/eval-answer/scripts/check_findable.py +99 -0
- package/skills/eval-answer/scripts/check_findable_test.py +38 -0
- package/skills/eval-answer/scripts/score_retrieval.py +14 -1
- package/skills/eval-answer/scripts/score_retrieval_test.py +8 -1
- package/skills/eval-answer/scripts/verify_goldens.py +44 -0
- package/skills/eval-diagnose/SKILL.md +16 -1
- package/skills/eval-import/SKILL.md +1 -1
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-judge/SKILL.md +22 -1
- package/skills/eval-loop/SKILL.md +21 -6
- package/skills/eval-loop/scripts/build_run_package.py +17 -4
- package/skills/eval-loop/scripts/run_baseline.py +117 -6
- package/skills/eval-loop/scripts/run_baseline_test.py +127 -12
- package/skills/eval-loop/templates/eval-run-package/public/app.js +29 -4
- package/skills/eval-loop/templates/eval-run-package/public/index.html +35 -7
- package/skills/eval-report/SKILL.md +101 -12
- package/skills/malloy/SKILL.md +2 -1
- package/skills/malloy-analysis/SKILL.md +2 -2
- package/skills/malloy-analysis-pitfalls/SKILL.md +2 -2
- 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 +26 -8
- 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 +43 -24
- 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 +2 -2
- package/skills/malloy-html-data-apps/SKILL.md +1 -1
- package/skills/malloy-lookml-review/SKILL.md +1 -1
- package/skills/malloy-lookml-review/reference/curate-visibility.md +1 -1
- package/skills/malloy-materialization/SKILL.md +3 -3
- package/skills/malloy-materialization-tuning/SKILL.md +2 -2
- package/skills/malloy-model/SKILL.md +51 -17
- package/skills/malloy-model-as-you-go/SKILL.md +2 -2
- package/skills/malloy-modeling/SKILL.md +2 -2
- package/skills/malloy-notebook-chat/SKILL.md +1 -1
- package/skills/malloy-notebooks/SKILL.md +2 -2
- package/skills/malloy-patterns/SKILL.md +1 -1
- package/skills/malloy-phrase-detection/SKILL.md +14 -9
- package/skills/malloy-publish/SKILL.md +31 -12
- package/skills/malloy-queries/SKILL.md +11 -12
- package/skills/malloy-review/SKILL.md +1 -1
- package/skills/malloy-scope/SKILL.md +1 -1
- 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
|
-
|
|
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, {
|
|
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
|
|
3029
|
-
#
|
|
3030
|
-
#
|
|
3031
|
-
# a
|
|
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
|
-
"
|
|
1205
|
-
"
|
|
1206
|
-
"
|
|
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
|
-
#
|
|
1211
|
-
#
|
|
1212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
+
html += `<details class="sub" style="margin-top:8px"><summary><span
|
|
305
|
+
class="caret">▶</span>Rubric — 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
|
-
<
|
|
343
|
+
<details class="trace"><summary><span class="caret">▶</span>The attempt,
|
|
344
|
+
step by step<span class="mute"> · ${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
|
-
|
|
170
|
-
|
|
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>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<span
|
|
197
|
-
<span><i class="dot
|
|
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 — <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.
|
|
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
|
-
##
|
|
138
|
+
## Coverage, retrieval, accuracy, cost
|
|
92
139
|
|
|
93
|
-
|
|
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
|
|
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
|
|
169
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|