@malloy-publisher/skills 0.1.27 → 0.1.28

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 (55) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-answer/reference/ledger-schema.md +1 -1
  4. package/skills/eval-answer/scripts/verify_goldens_test.py +20 -2
  5. package/skills/eval-diagnose/SKILL.md +1 -1
  6. package/skills/eval-diagnose/scripts/diagnose.py +4 -1
  7. package/skills/eval-import/SKILL.md +1 -1
  8. package/skills/eval-improve/SKILL.md +1 -1
  9. package/skills/eval-judge/SKILL.md +1 -1
  10. package/skills/eval-loop/SKILL.md +1 -1
  11. package/skills/eval-loop/reference/running-a-run.md +8 -4
  12. package/skills/eval-loop/scripts/build_run_package.py +17 -11
  13. package/skills/eval-loop/scripts/run_baseline.py +160 -53
  14. package/skills/eval-loop/scripts/run_baseline_test.py +198 -2
  15. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +7 -1
  16. package/skills/eval-report/SKILL.md +1 -1
  17. package/skills/malloy/SKILL.md +2 -1
  18. package/skills/malloy-analysis/SKILL.md +1 -1
  19. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  20. package/skills/malloy-analysis-report/SKILL.md +1 -1
  21. package/skills/malloy-analyze/SKILL.md +1 -1
  22. package/skills/malloy-charts/SKILL.md +3 -1
  23. package/skills/malloy-dashboards/SKILL.md +1 -1
  24. package/skills/malloy-data-app-design/SKILL.md +167 -0
  25. package/skills/malloy-data-app-design/reference/archetypes.md +99 -0
  26. package/skills/malloy-data-app-design/reference/depth-patterns.md +162 -0
  27. package/skills/malloy-data-app-design/reference/token-contract.md +121 -0
  28. package/skills/malloy-data-app-design/reference/visual-craft.md +145 -0
  29. package/skills/malloy-debug/SKILL.md +1 -1
  30. package/skills/malloy-define/SKILL.md +1 -1
  31. package/skills/malloy-discover/SKILL.md +1 -1
  32. package/skills/malloy-document/SKILL.md +1 -1
  33. package/skills/malloy-getting-started/SKILL.md +1 -1
  34. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  35. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  36. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  37. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  38. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -1
  39. package/skills/malloy-html-data-apps/SKILL.md +20 -14
  40. package/skills/malloy-html-data-apps/reference/verification-harness.md +145 -20
  41. package/skills/malloy-lookml-review/SKILL.md +1 -1
  42. package/skills/malloy-materialization/SKILL.md +1 -1
  43. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  44. package/skills/malloy-model/SKILL.md +1 -1
  45. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  46. package/skills/malloy-modeling/SKILL.md +1 -1
  47. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  48. package/skills/malloy-notebooks/SKILL.md +2 -2
  49. package/skills/malloy-patterns/SKILL.md +1 -1
  50. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  51. package/skills/malloy-publish/SKILL.md +1 -1
  52. package/skills/malloy-queries/SKILL.md +6 -2
  53. package/skills/malloy-review/SKILL.md +1 -1
  54. package/skills/malloy-scope/SKILL.md +1 -1
  55. package/skills/malloy-source-unreachable/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.27",
4
+ "version": "0.1.28",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -122,7 +122,7 @@ on that axis.
122
122
  | `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |
123
123
  | `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |
124
124
  | `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |
125
- | `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |
125
+ | `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |
126
126
  | `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |
127
127
  | `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |
128
128
  | `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |
@@ -1192,8 +1192,6 @@ class VerifyQuotedFigures(unittest.TestCase):
1192
1192
  self.assertEqual(called, ["opus"])
1193
1193
 
1194
1194
 
1195
- if __name__ == "__main__":
1196
- unittest.main()
1197
1195
 
1198
1196
 
1199
1197
  class HeldGoldensAreNotReDerived(unittest.TestCase):
@@ -1258,3 +1256,23 @@ class ANotQueryableKeyNamesItsRootSource(unittest.TestCase):
1258
1256
  return_value=(None, "HTTP 500: boom")):
1259
1257
  got, detail, _ = check_value(case, a)
1260
1258
  self.assertEqual((got, detail), ("error", "HTTP 500: boom"))
1259
+
1260
+
1261
+ class NothingIsDefinedBelowTheMainGuard(unittest.TestCase):
1262
+ """CI runs this file as a script, `python3 <file>`, and `unittest.main()`
1263
+ runs what is defined so far and exits. A test class written below the
1264
+ guard is collected by `-m unittest` and never by CI: four classes sat
1265
+ there after one PR, and one review earlier had moved two more up for the
1266
+ same reason. Reading the file is the check that does not depend on how the
1267
+ tests were invoked."""
1268
+
1269
+ def test_the_guard_is_the_last_statement(self):
1270
+ src = pathlib.Path(__file__).read_text().splitlines()
1271
+ guard = [i for i, l in enumerate(src) if l.startswith('if __name__ == "__main__":')]
1272
+ self.assertEqual(len(guard), 1)
1273
+ below = [l for l in src[guard[0]:] if l.startswith(("class ", "def "))]
1274
+ self.assertEqual(below, [], f"defined below the main guard: {below}")
1275
+
1276
+
1277
+ if __name__ == "__main__":
1278
+ unittest.main()
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -706,7 +706,10 @@ def main(argv: list[str] | None = None) -> int:
706
706
  ap.add_argument("--run", required=True, type=pathlib.Path)
707
707
  ap.add_argument("--set", dest="set_dir", required=True, type=pathlib.Path)
708
708
  ap.add_argument("--model-dir", type=pathlib.Path, default=None,
709
- help="the Malloy package under test; the agent's cwd")
709
+ help="the Malloy package under test; the agent's cwd. A "
710
+ "relative path is taken from the working directory "
711
+ "(run_baseline's --model-dir is relative to its "
712
+ "--model-repo instead)")
710
713
  # The split is cheap-on-per-case, expensive-on-clustering, and one measured
711
714
  # run says it is the wrong way round: every failure in it came from per-case
712
715
  # work (wrong output shape, a probe at the wrong grain) while the clustering
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-improve
3
3
  description: 'Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # Improve the Model
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-judge
3
3
  description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # The judge
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-loop
3
3
  description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files under the model package''s evals/ directory; checkpoints are git commits of the model repo. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
4
- version: 0.1.27
4
+ version: 0.1.28
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -62,10 +62,14 @@ python3 skills/eval-loop/scripts/run_baseline.py \
62
62
  python3 skills/eval-loop/scripts/run_baseline.py \
63
63
  --set <repo>/evals/ecommerce --out results/<arm> \
64
64
  --parallel 4 --truth-publisher http://localhost:4881 \
65
- --model-repo <repo> # the checkout the MODEL is versioned in, recorded as
66
- # modelGitSha. It cannot be inferred: Publisher serves
67
- # a copy under publisher_data/, whose surrounding tree
68
- # is the server's storage, not the model's history.
65
+ --model-repo <repo> --model-dir <package> # the checkout the MODEL is
66
+ # versioned in, recorded as modelGitSha, and the package
67
+ # directory inside it the -dirty marker is decided over
68
+ # (relative to the repo). Without --model-dir any
69
+ # untracked file anywhere in the repo marks the model
70
+ # dirty. The repo cannot be inferred: Publisher serves a
71
+ # copy under publisher_data/, whose surrounding tree is
72
+ # the server's storage, not the model's history.
69
73
  # the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
70
74
  # for the second arm of the A/A). Pass --label only for a run that needs a
71
75
  # human name; hand-typed arm names stop being readable within an afternoon.
@@ -122,13 +122,12 @@ def golden_display(g: dict[str, Any]) -> str:
122
122
  _IDENT = re.compile(r"(?<![A-Za-z0-9_])[a-z][a-z0-9]*(?:_[a-z0-9]+)+(?![A-Za-z0-9_])")
123
123
 
124
124
 
125
- def _result_text(block: dict[str, Any]) -> str:
126
- c = block.get("content")
127
- if isinstance(c, str):
128
- return c
129
- if isinstance(c, list):
130
- return "\n".join(x.get("text", "") for x in c if isinstance(x, dict))
131
- return ""
125
+ # The run viewer used to keep its own copy of the raw reader, so a get_context
126
+ # result the CLI had spilled to a file showed the "<persisted-output>" note as
127
+ # its detail and no result count. run_baseline.result_text reads the file back
128
+ # when it is still there; when it is gone the note stays, and the summary the
129
+ # ledger recorded (none, for an unmeasured call) is what the viewer shows.
130
+ from run_baseline import result_text as _result_text # noqa: E402 (same directory)
132
131
 
133
132
 
134
133
  def _payload(text: str) -> Any:
@@ -373,14 +372,21 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
373
372
 
374
373
  mine = tool_calls.get(kk, [])
375
374
  for i, t in enumerate(mine, 1):
376
- rs = t.get("rankedSummary") or {}
377
- eids = rs.get("entityIds") or []
375
+ # A get_context call with no rankedSummary was never measured:
376
+ # the CLI's saved result file was gone by the time of a rebuild,
377
+ # or the call errored. It used to show as n_returned 0, the same
378
+ # row a search that found nothing writes, and empty_calls
379
+ # counted it. n_returned stays empty and `unmeasured` says why.
380
+ rs = t.get("rankedSummary")
381
+ unmeasured = t.get("tool") == "get_context" and rs is None
382
+ eids = (rs or {}).get("entityIds") or []
378
383
  calls.append({
379
384
  "attempt_key": ak,
380
385
  "run_id": run_id, "qid": qid, "sample": e.get("sample"),
381
386
  "call_index": i, "tool": t.get("tool"),
382
387
  "targets": t.get("targets"),
383
- "n_returned": len(eids),
388
+ "n_returned": None if unmeasured else len(eids),
389
+ "unmeasured": unmeasured,
384
390
  "entity_ids": eids[:40],
385
391
  "error": t.get("error"),
386
392
  # Which retriever answered this call. Absent on a server
@@ -512,7 +518,7 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
512
518
  "missing", "noise", "component", "owner", "where_to_fix", "why"])
513
519
  write_csv(data / "calls.csv", calls, [
514
520
  "attempt_key", "run_id", "qid", "sample", "call_index", "tool", "targets",
515
- "n_returned", "entity_ids", "error", "retrieval_mode"])
521
+ "n_returned", "unmeasured", "entity_ids", "error", "retrieval_mode"])
516
522
  write_csv(data / "entities.csv", ents, [
517
523
  "run_id", "qid", "sample", "entity_id", "entity_kind", "entity_source",
518
524
  "entity_name", "role"])
@@ -288,6 +288,11 @@ def git_sha(path: pathlib.Path, scope: pathlib.Path | None = None) -> str | None
288
288
  # so a dirty model repo read clean and the pin silently stopped pinning.
289
289
  path = path.resolve()
290
290
  scope = scope.resolve() if scope is not None else None
291
+ if scope is not None and not scope.exists():
292
+ # `git status -- <missing path>` exits 0 with empty output, so a typo
293
+ # (`pgk`) or a doubled path (`--model-repo repo/pkg --model-dir pkg`)
294
+ # read as a clean model. No pin is the honest answer here too.
295
+ return None
291
296
  try:
292
297
  d = path if path.is_dir() else path.parent
293
298
  head = subprocess.run(["git", "-C", str(d), "rev-parse", "HEAD"],
@@ -298,11 +303,29 @@ def git_sha(path: pathlib.Path, scope: pathlib.Path | None = None) -> str | None
298
303
  if scope is not None:
299
304
  cmd += ["--", str(scope)]
300
305
  dirty = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
306
+ if dirty.returncode != 0:
307
+ # A scope outside the repo makes git exit 128 with empty stdout,
308
+ # which read as "clean" and dropped the marker on a dirty model.
309
+ # No pin beats a wrong one: the schema says absent is honest.
310
+ return None
301
311
  return head.stdout.strip() + ("-dirty" if dirty.stdout.strip() else "")
302
312
  except Exception: # noqa: BLE001
303
313
  return None
304
314
 
305
315
 
316
+ def model_scope(repo: pathlib.Path | None, model_dir: pathlib.Path | None
317
+ ) -> pathlib.Path | None:
318
+ """The path the -dirty marker is decided over. A relative --model-dir is
319
+ resolved against --model-repo, not the working directory: given from
320
+ anywhere but the repo root it pointed outside the repo, git status exited
321
+ 128 with empty stdout, and a dirty model pinned as clean."""
322
+ if repo is None:
323
+ return None
324
+ if model_dir is None:
325
+ return repo
326
+ return model_dir if model_dir.is_absolute() else repo / model_dir
327
+
328
+
306
329
  # --- Run naming --------------------------------------------------------------
307
330
  #
308
331
  # A run's label is what every comparison, flip table and report prints, so a set
@@ -354,6 +377,18 @@ def next_run_label(out: pathlib.Path, set_name: str, phase: str) -> str:
354
377
  return f"{stem}-{n:02d}"
355
378
 
356
379
 
380
+ def imply_flags(a: argparse.Namespace) -> argparse.Namespace:
381
+ """`--rejudge` is `--rebuild`'s answer half plus a fresh judge, so it sets
382
+ `rebuild`. Without this, `--rejudge` alone reached every `if a.rebuild`
383
+ gate as a fresh run: it spawned a new answerer per case ($0.33 each) against
384
+ the flag's own help text, and with `--only` it took the ledger's full-write
385
+ path and left a 12-case run holding one case. `--from` already set both;
386
+ this makes the bare flag mean what it says."""
387
+ if getattr(a, "rejudge", False):
388
+ a.rebuild = True
389
+ return a
390
+
391
+
357
392
  def existing_run_refusal(out: pathlib.Path) -> str | None:
358
393
  """Why a fresh arm may not write into `out`, or None when it may.
359
394
 
@@ -474,22 +509,43 @@ def format_rows(rows: list[dict], limit: int = 60) -> str:
474
509
  # did not measure. Seen twice in one arm: 86.4% printed against 95% actual, and
475
510
  # diagnose went on to explain the phantom miss with an index-readiness story
476
511
  # 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.
512
+ # One note, one regex, one reader. The CLI names the file it spilled a result
513
+ # to in two spellings ("<persisted-output> ... Full output saved to: <path>" and
514
+ # "Output has been saved to <path>.txt."), sometimes with a colon, sometimes
515
+ # with a trailing period. Two regexes and two readers for this one concept
516
+ # handled those differently, and a rebuild after the CLI's temporary file was
517
+ # gone matched one, missed the other, and scored the call as zero entities.
518
+ # Only the CLI's own note counts, in either of its spellings, and only a
519
+ # .json or .txt path: a looser "saved to|written to" also matched a source doc
520
+ # that said "nightly extract is written to /etc/hosts" inside an ordinary JSON
521
+ # result, replaced the result with that file, and scored the call as zero.
522
+ SAVED_TO = re.compile(
523
+ r"(?:<persisted-output>.*?Full output saved to|Output has been saved to):?"
524
+ r"\s+(/[^\s'\"]+?\.(?:json|txt))\.?(?=\s|$)", re.S)
525
+
526
+
527
+ def saved_result(text: str) -> tuple[pathlib.Path | None, str | None]:
528
+ """(path, body) for a result the host spilled to a file.
529
+
530
+ (None, None) when the text names no file; (path, None) when it names one
531
+ that cannot be read, which is the ordinary state of a rebuild since the
532
+ CLI's tool-results/ files are temporary; (path, body) when it can. Only
533
+ ever reads a path the host itself named in the result it returned.
484
534
  """
485
- m = OFFLOADED.search(text or "")
535
+ m = SAVED_TO.search(text or "")
486
536
  if not m:
487
- return None
537
+ return None, None
538
+ path = pathlib.Path(m.group(1))
488
539
  try:
489
- body = pathlib.Path(m.group(1)).read_text()
540
+ return path, path.read_text()
490
541
  except OSError:
491
- return None
492
- return resource_json(body)
542
+ return path, None
543
+
544
+
545
+ def offloaded_json(text: str) -> dict[str, Any] | None:
546
+ """The response body a host spilled to a file, read back, or None."""
547
+ _, body = saved_result(text)
548
+ return resource_json(body) if body is not None else None
493
549
 
494
550
 
495
551
  def resource_json(text: str) -> dict[str, Any] | None:
@@ -594,40 +650,39 @@ def path_breaches(events: list[dict[str, Any]],
594
650
  return list(r["reasons"])
595
651
 
596
652
 
597
- # Two spellings, from two CLI paths: the persisted-output stub, and the MCP
598
- # token-cap error ("result ... exceeds maximum allowed tokens. Output has been
599
- # saved to <path>"). Both leave the whole payload on disk.
600
- PERSISTED_STUB = re.compile(
601
- r"(?:<persisted-output>.*?Full output saved to|Output has been saved to):? "
602
- r"(/\S+?\.(?:json|txt))\.?(?=\s|$)", re.S)
603
-
604
-
605
653
  def result_text(block: dict[str, Any]) -> str:
606
- """The text of one tool_result block, with a persisted stub resolved.
607
-
608
- Above a size the CLI decides, a tool result reaches the answerer as a
609
- `<persisted-output>` stub: a file path and a 2 KB preview. Measured on the
610
- first arm against a real model, 14 of 74 get_context results (53 to 70 KB each) arrived that
611
- way, and the answerer followed the path with Read every time. Reading the
612
- stub as the payload scored those calls as zero entities delivered, which
613
- is the opposite of what happened: the whole ranking was on disk. So when
614
- the stub names a file that still exists, its content is the result.
654
+ """The text of one tool_result block, with a spilled result read back.
655
+
656
+ Above a size the CLI decides, a tool result reaches the answerer as a note
657
+ naming a file plus a 2 KB preview. Measured on the first arm against a real
658
+ model, 14 of 74 get_context results (53 to 70 KB each) arrived that way and
659
+ the answerer followed the path with Read every time. Reading the note as
660
+ the payload scored those calls as zero entities delivered. When the file is
661
+ still there its content is the result; when it is gone (the CLI's
662
+ tool-results/ files are temporary, so a later rebuild always lands here)
663
+ the note comes back unchanged and the caller records the call as
664
+ unmeasured through `saved_result`, never as zero. Text that already parses
665
+ as a result is returned as it is, whatever paths it mentions.
615
666
  """
616
667
  text = _raw_result_text(block)
617
- m = PERSISTED_STUB.search(text)
618
- if m:
619
- path = pathlib.Path(m.group(1))
620
- if path.exists():
621
- try:
622
- blocks = json.loads(path.read_text())
623
- except (OSError, json.JSONDecodeError):
624
- return path.read_text()
625
- if isinstance(blocks, list):
626
- return "\n".join(b.get("text", "") for b in blocks
627
- if isinstance(b, dict) and b.get("type") == "text")
628
- if isinstance(blocks, dict) and blocks.get("type") == "text":
629
- return blocks.get("text", "")
630
- return text
668
+ if resource_json(text) is not None:
669
+ # Already a result. A note naming a file is looked for only when the
670
+ # text is not one, so a doc string that mentions a real path inside a
671
+ # JSON result is never read in its place.
672
+ return text
673
+ _, body = saved_result(text)
674
+ if body is None:
675
+ return text
676
+ try:
677
+ blocks = json.loads(body)
678
+ except json.JSONDecodeError:
679
+ return body
680
+ if isinstance(blocks, list):
681
+ return "\n".join(b.get("text", "") for b in blocks
682
+ if isinstance(b, dict) and b.get("type") == "text")
683
+ if isinstance(blocks, dict) and blocks.get("type") == "text":
684
+ return blocks.get("text", "")
685
+ return body
631
686
 
632
687
 
633
688
  def _raw_result_text(block: dict[str, Any]) -> str:
@@ -1225,8 +1280,44 @@ def _norm(q: str) -> str:
1225
1280
  never compared equal and `declared` never fired -- the harness fell through
1226
1281
  to `last_ok` and re-executed a probe. On one acceptance arm that read a
1227
1282
  model edit as a regression: the answer led with the filtered figure and
1228
- the harness graded the unfiltered probe it ran afterwards."""
1229
- return " ".join((q or "").replace(";", " ").split())
1283
+ the harness graded the unfiltered probe it ran afterwards.
1284
+
1285
+ Only a `;` outside a string literal is a clause separator, and the lexer's
1286
+ other two rules are honoured too: inside a string a backslash escapes the
1287
+ next character (`'O\\'Brien'` does not end at the escaped quote), and
1288
+ outside one `--` or `//` starts a comment that runs to the end of the line
1289
+ (an apostrophe in it opens no string). Without those, an escaped quote or a
1290
+ comment apostrophe made the printed form and the executed form normalise
1291
+ differently, and the harness graded the probe query instead."""
1292
+ out: list[str] = []
1293
+ quote: str | None = None
1294
+ q = q or ""
1295
+ i, n = 0, len(q)
1296
+ while i < n:
1297
+ ch = q[i]
1298
+ if quote:
1299
+ out.append(ch)
1300
+ if ch == "\\" and i + 1 < n:
1301
+ out.append(q[i + 1])
1302
+ i += 2
1303
+ continue
1304
+ if ch == quote:
1305
+ quote = None
1306
+ elif q.startswith("--", i) or q.startswith("//", i):
1307
+ j = q.find("\n", i)
1308
+ j = n if j < 0 else j
1309
+ out.append(q[i:j])
1310
+ i = j
1311
+ continue
1312
+ elif ch in ("'", '"', "`"):
1313
+ quote = ch
1314
+ out.append(ch)
1315
+ elif ch == ";":
1316
+ out.append(" ")
1317
+ else:
1318
+ out.append(ch)
1319
+ i += 1
1320
+ return " ".join("".join(out).split())
1230
1321
 
1231
1322
 
1232
1323
  def _model_path_of(query: str, runs: list[dict[str, Any]]) -> str | None:
@@ -2019,12 +2110,23 @@ def run_answerer(case: dict[str, Any], a: argparse.Namespace,
2019
2110
  calls.append({**info, "error": text[:300] if failed else None,
2020
2111
  "rankedSummary": None})
2021
2112
  else:
2113
+ if failed:
2114
+ # The call errored (an answerer that left out `scopes`
2115
+ # gets an MCP validation error). That is no ranking at
2116
+ # all, not a ranking of zero: recorded like a spilled
2117
+ # file that cannot be read, with no summary, so the
2118
+ # retrieval score never counts it as a search that
2119
+ # found nothing.
2120
+ calls.append({**info, "error": text[:300],
2121
+ "rankedSummary": None})
2122
+ continue
2022
2123
  # The host may have spilled the body to a file. Read it
2023
2124
  # back rather than scoring the notice as an empty response.
2024
2125
  if payload is None:
2025
2126
  payload = offloaded_json(text)
2026
- if payload is None and OFFLOADED.search(text or ""):
2027
- # Named a file we could not read. Record NO summary
2127
+ if payload is None and saved_result(text)[0] is not None:
2128
+ # Named a file we could not read (a rebuild after the
2129
+ # CLI's temporary file is gone). Record NO summary
2028
2130
  # rather than an empty one: unmeasured is the truth,
2029
2131
  # and a zero here is a miss the run did not observe.
2030
2132
  calls.append({**info,
@@ -2745,7 +2847,8 @@ def main(argv: list[str] | None = None) -> int:
2745
2847
  help="model within the package; defaults to set.json targetModelPath")
2746
2848
  ap.add_argument("--model-repo", default=None, type=pathlib.Path,
2747
2849
  help="the git checkout the MODEL is versioned in, recorded "
2748
- "as modelGitSha (dirty-marked over this path). The "
2850
+ "as modelGitSha (dirty-marked over --model-dir, or over "
2851
+ "this whole path without it). The "
2749
2852
  "Publisher serves a copy under publisher_data/, so "
2750
2853
  "there is no way to infer it; without this the run "
2751
2854
  "carries modelSha, the content pin, and no git pin")
@@ -2754,8 +2857,11 @@ def main(argv: list[str] | None = None) -> int:
2754
2857
  "was served from. The -dirty marker on modelGitSha is "
2755
2858
  "decided over this path, so an unrelated untracked file "
2756
2859
  "elsewhere in the repo (a scratch notebook, a run "
2757
- "directory) does not stamp a clean model dirty. "
2758
- "Recorded as modelDir; defaults to the whole repo")
2860
+ "directory) does not stamp a clean model dirty. A "
2861
+ "relative path is taken from --model-repo (not from the "
2862
+ "working directory, unlike diagnose.py's --model-dir); "
2863
+ "a path that does not exist records no pin. Recorded as "
2864
+ "modelDir; defaults to the whole repo")
2759
2865
  ap.add_argument("--skills-root", default=None,
2760
2866
  help="a checkout holding skills/ and manifests/ to load the "
2761
2867
  "answerer's and judge's doctrine from -- a Publisher "
@@ -2831,9 +2937,10 @@ def main(argv: list[str] | None = None) -> int:
2831
2937
  "delete events.jsonl, find -name judge.md -delete) was "
2832
2938
  "done three times on one set")
2833
2939
  ap.add_argument("--rejudge", action="store_true",
2834
- help="with --rebuild: reuse the answers but score them "
2835
- "again, for a judge or rubric change")
2940
+ help="reuse the saved answers but score them again, for a "
2941
+ "judge or rubric change. Implies --rebuild")
2836
2942
  a = ap.parse_args(argv)
2943
+ imply_flags(a)
2837
2944
  # Resolved once here rather than per attempt: the answerer's granted tool
2838
2945
  # list is part of what a run measured, so it must not vary within a run.
2839
2946
  a.hosted_tools = hosted_tools(
@@ -3269,7 +3376,7 @@ def main(argv: list[str] | None = None) -> int:
3269
3376
  # nobody said where that is.
3270
3377
  modelRepo=str(a.model_repo) if a.model_repo else None,
3271
3378
  modelDir=str(a.model_dir) if a.model_dir else None,
3272
- modelGitSha=(git_sha(a.model_repo, scope=a.model_dir or a.model_repo)
3379
+ modelGitSha=(git_sha(a.model_repo, scope=model_scope(a.model_repo, a.model_dir))
3273
3380
  if a.model_repo else None),
3274
3381
  skillsVersion=ledger.skills_git_sha(a.roots[0]),
3275
3382
  skillsRoot=str(a.roots[0]),