@malloy-publisher/skills 0.1.22 → 0.1.24

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 (60) 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 -0
  4. package/skills/eval-answer/scripts/check_findable.py +99 -0
  5. package/skills/eval-answer/scripts/check_findable_test.py +38 -0
  6. package/skills/eval-answer/scripts/init_truth_package.py +10 -0
  7. package/skills/eval-answer/scripts/ledger.py +1 -1
  8. package/skills/eval-answer/scripts/score_retrieval.py +14 -1
  9. package/skills/eval-answer/scripts/score_retrieval_test.py +8 -1
  10. package/skills/eval-answer/scripts/verify_goldens.py +69 -0
  11. package/skills/eval-answer/scripts/verify_goldens_test.py +64 -0
  12. package/skills/eval-diagnose/SKILL.md +16 -1
  13. package/skills/eval-import/SKILL.md +1 -1
  14. package/skills/eval-import/reference/case-format.md +1 -1
  15. package/skills/eval-improve/SKILL.md +1 -1
  16. package/skills/eval-judge/SKILL.md +46 -2
  17. package/skills/eval-judge/reference/writing-rubrics.md +8 -0
  18. package/skills/eval-loop/SKILL.md +21 -6
  19. package/skills/eval-loop/reference/running-a-run.md +4 -0
  20. package/skills/eval-loop/scripts/agent_harness.py +5 -0
  21. package/skills/eval-loop/scripts/agent_harness_test.py +3 -1
  22. package/skills/eval-loop/scripts/build_run_package.py +17 -4
  23. package/skills/eval-loop/scripts/run_baseline.py +192 -9
  24. package/skills/eval-loop/scripts/run_baseline_test.py +219 -12
  25. package/skills/eval-loop/templates/eval-run-package/public/app.js +29 -4
  26. package/skills/eval-loop/templates/eval-run-package/public/index.html +35 -7
  27. package/skills/eval-report/SKILL.md +101 -12
  28. package/skills/malloy/SKILL.md +2 -1
  29. package/skills/malloy-analysis/SKILL.md +2 -2
  30. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  31. package/skills/malloy-analysis-report/SKILL.md +1 -1
  32. package/skills/malloy-analyze/SKILL.md +1 -1
  33. package/skills/malloy-charts/SKILL.md +1 -1
  34. package/skills/malloy-dashboards/SKILL.md +31 -10
  35. package/skills/malloy-debug/SKILL.md +1 -1
  36. package/skills/malloy-define/SKILL.md +1 -1
  37. package/skills/malloy-discover/SKILL.md +1 -1
  38. package/skills/malloy-document/SKILL.md +1 -1
  39. package/skills/malloy-getting-started/SKILL.md +1 -1
  40. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  41. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  42. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  43. package/skills/malloy-html-data-app-embedding/SKILL.md +3 -1
  44. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  45. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  46. package/skills/malloy-lookml-review/SKILL.md +1 -1
  47. package/skills/malloy-materialization/SKILL.md +9 -7
  48. package/skills/malloy-materialization-tuning/SKILL.md +3 -2
  49. package/skills/malloy-model/SKILL.md +1 -1
  50. package/skills/malloy-model-as-you-go/SKILL.md +2 -2
  51. package/skills/malloy-modeling/SKILL.md +3 -3
  52. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  53. package/skills/malloy-notebooks/SKILL.md +1 -1
  54. package/skills/malloy-patterns/SKILL.md +1 -1
  55. package/skills/malloy-phrase-detection/SKILL.md +14 -9
  56. package/skills/malloy-publish/SKILL.md +31 -12
  57. package/skills/malloy-queries/SKILL.md +1 -1
  58. package/skills/malloy-review/SKILL.md +1 -1
  59. package/skills/malloy-scope/SKILL.md +1 -1
  60. package/skills/malloy-source-unreachable/SKILL.md +71 -0
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.22",
4
+ "version": "0.1.24",
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.22
4
+ version: 0.1.24
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -124,6 +124,7 @@ on that axis.
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
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. |
126
126
  | `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |
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. |
127
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. |
128
129
  | `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |
129
130
  | `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |
@@ -51,6 +51,7 @@ import argparse
51
51
  import json
52
52
  import pathlib
53
53
  import sys
54
+ import time
54
55
  import urllib.error
55
56
  import urllib.parse
56
57
  import urllib.request
@@ -176,6 +177,30 @@ def compiled_entities(rest_url: str, environment: str,
176
177
  return out or None
177
178
 
178
179
 
180
+ def embedding_index_status(rest_url: str, environment: str, package: str,
181
+ timeout: int = 15) -> str | None:
182
+ """`embeddingIndex.status` for one package, or None if it cannot be read.
183
+
184
+ Retrieval is semantic only once this reaches `ready`; before that the
185
+ server answers the same calls from the lexical matcher without saying so,
186
+ and fewer entities come back. A checker that does not wait for it reports
187
+ the difference as a broken answer key.
188
+ """
189
+ url = (f"{rest_url.rstrip('/')}/api/v0/environments/"
190
+ f"{urllib.parse.quote(environment)}/packages/"
191
+ f"{urllib.parse.quote(package)}")
192
+ try:
193
+ with urllib.request.urlopen(url, timeout=timeout) as r:
194
+ body = json.loads(r.read())
195
+ except (urllib.error.URLError, OSError, ValueError):
196
+ return None
197
+ index = body.get("embeddingIndex")
198
+ if not isinstance(index, dict):
199
+ return None
200
+ status = index.get("status")
201
+ return status if isinstance(status, str) else None
202
+
203
+
179
204
  def stale_packages(rest_url: str, timeout: int = 30) -> set[tuple[str, str]] | None:
180
205
  """`{(environment, package)}` serving a model older than their files.
181
206
 
@@ -214,6 +239,26 @@ def declared_findings(cases: list[dict[str, Any]],
214
239
  continue
215
240
  if f"{kind}:{name}" in declared[src]:
216
241
  continue
242
+ # A DOTTED name is a join path, not a field name. `declared[src]` holds
243
+ # the source's OWN fields, so `dimension:order_items:products.brand`
244
+ # never matched and every dotted id was reported as a missing field --
245
+ # five of them on one set, against a get_context that returns each as
246
+ # its top result. Resolve it where it lives: the last segment is the
247
+ # field, the segment before it names the joined source.
248
+ if "." in name:
249
+ *path, leaf = name.split(".")
250
+ hop = path[-1]
251
+ if hop in declared and f"{kind}:{leaf}" in declared[hop]:
252
+ continue
253
+ if hop not in declared:
254
+ out.append(f"{eid}: the join path names {hop!r}, which the "
255
+ f"compiled model has no source for "
256
+ f"(required by {', '.join(qids)})")
257
+ else:
258
+ out.append(f"{eid}: the compiled {hop} source declares no "
259
+ f"{kind} {leaf!r} to reach through that join "
260
+ f"(required by {', '.join(qids)})")
261
+ continue
217
262
  other = sorted(k.split(":", 1)[0] for k in declared[src]
218
263
  if k.split(":", 1)[1] == name)
219
264
  if other:
@@ -310,6 +355,11 @@ def main(argv: list[str] | None = None) -> int:
310
355
  "both directions.")
311
356
  ap.add_argument("--out", default=None,
312
357
  help="write the per-entity rows as JSON")
358
+ ap.add_argument("--index-wait", type=int, default=300,
359
+ help="seconds to wait for the embedding index to be ready "
360
+ "before searching (needs --publisher). 0 to skip the "
361
+ "wait, which risks reading the lexical matcher as a "
362
+ "missing entity")
313
363
  a = ap.parse_args(argv)
314
364
 
315
365
  f = a.set_dir / "cases.jsonl"
@@ -318,6 +368,55 @@ def main(argv: list[str] | None = None) -> int:
318
368
  return 3
319
369
  cases = [json.loads(line) for line in f.read_text().splitlines() if line.strip()]
320
370
 
371
+ # The embedding index is built lazily, on the first call that ranks. A
372
+ # search run seconds after a server start is answered by the LEXICAL
373
+ # matcher, which finds fewer things -- and this check reads "fewer things"
374
+ # as "the key names entities that cannot be retrieved" and tells you to fix
375
+ # the key. Three ids were reported missing that way on one set and all
376
+ # three passed on a re-run. So wait for it, and say so when it cannot.
377
+ if a.publisher and a.index_wait:
378
+ # The server's enum is indexing | ready | cooldown | too-many-entities.
379
+ # `cooldown` (the embedding provider failed recently) and
380
+ # `too-many-entities` (over the cap, permanently lexical) will never
381
+ # become `ready`, so waiting on them burns the whole timeout for a
382
+ # result that is knowable on the first read.
383
+ TERMINAL = ("ready", "cooldown", "too-many-entities")
384
+ # `indexing` does NOT clear on its own: the docs say it clears "once a
385
+ # get_context question triggers a sync, which is what builds the
386
+ # index". Polling the package resource never triggers one, so a wait
387
+ # that only polls is a guaranteed stall on a freshly loaded package.
388
+ # One ranking call first, as serve.py --warm-retrieval does.
389
+ try:
390
+ get_context(a.mcp_url, [{"target_type": "source"}],
391
+ a.environment, a.package)
392
+ except (urllib.error.URLError, RuntimeError, OSError):
393
+ pass # the search below reports it properly
394
+ deadline = time.monotonic() + a.index_wait
395
+ status = embedding_index_status(a.publisher, a.environment, a.package)
396
+ while status not in TERMINAL and status is not None \
397
+ and time.monotonic() < deadline:
398
+ time.sleep(2)
399
+ status = embedding_index_status(a.publisher, a.environment,
400
+ a.package)
401
+ if status is None:
402
+ print("! the embedding index status could not be read; a miss "
403
+ "below may be the lexical matcher rather than the key",
404
+ file=sys.stderr)
405
+ elif status == "too-many-entities":
406
+ print("! this package is over the embedding cap, so retrieval is "
407
+ "permanently lexical here. The misses below are real for "
408
+ "this server and say nothing about a semantic one",
409
+ file=sys.stderr)
410
+ elif status == "cooldown":
411
+ print("! the embedding provider is in cooldown, so retrieval is "
412
+ "lexical for now. Re-run rather than acting on a miss below",
413
+ file=sys.stderr)
414
+ elif status != "ready":
415
+ print(f"! the embedding index is {status!r} after "
416
+ f"{a.index_wait}s, so this search may be answered "
417
+ f"lexically; a miss below may be the index, not the key",
418
+ file=sys.stderr)
419
+
321
420
  declared_out: list[str] = []
322
421
  if a.publisher:
323
422
  # Staleness first. A stale package answers the models endpoint normally
@@ -322,5 +322,43 @@ class Staleness(unittest.TestCase):
322
322
  self.assertIsNone(check_findable.stale_packages("http://127.0.0.1:9"))
323
323
 
324
324
 
325
+ class DottedJoinPaths(unittest.TestCase):
326
+ """A dotted name is a join path, resolved on the joined source.
327
+
328
+ `declared[src]` holds a source's OWN fields, so every dotted id read as a
329
+ field the source does not declare. On the storefront tour set that was five
330
+ of eight reported misses, each one an id get_context returns as its top
331
+ result -- and the check ends by telling you to fix the key, so a false miss
332
+ here sends you to break an answer key that is correct.
333
+ """
334
+
335
+ DECLARED = {
336
+ "order_items": {"source:order_items", "measure:total_sales"},
337
+ "products": {"source:products", "dimension:brand", "measure:product_count"},
338
+ }
339
+
340
+ def findings(self, eid):
341
+ cases = [{"qid": "q1", "expectedEntities": {"required": [eid]}}]
342
+ return check_findable.declared_findings(cases, self.DECLARED)
343
+
344
+ def test_a_reachable_join_path_is_not_a_finding(self):
345
+ self.assertEqual(self.findings("dimension:order_items:products.brand"), [])
346
+
347
+ def test_the_kind_still_has_to_match_on_the_joined_source(self):
348
+ out = self.findings("measure:order_items:products.brand")
349
+ self.assertEqual(len(out), 1)
350
+ self.assertIn("declares no measure 'brand'", out[0])
351
+
352
+ def test_an_unknown_hop_is_named_as_the_hop(self):
353
+ out = self.findings("dimension:order_items:suppliers.name")
354
+ self.assertEqual(len(out), 1)
355
+ self.assertIn("'suppliers'", out[0])
356
+
357
+ def test_a_field_missing_on_the_joined_source_is_a_finding(self):
358
+ out = self.findings("dimension:order_items:products.colour")
359
+ self.assertEqual(len(out), 1)
360
+ self.assertIn("'colour'", out[0])
361
+
362
+
325
363
  if __name__ == "__main__":
326
364
  unittest.main()
@@ -23,6 +23,13 @@ WHAT IT PRODUCES
23
23
  the header comment says to; the script will not guess
24
24
  semantics, because guessed semantics are exactly what a
25
25
  truth package exists to exclude.
26
+ A key that composes two tables (the top N by one
27
+ measure, then another measure over those rows) is written
28
+ as a raw per-key rollup declared in truth.malloy --
29
+ `source: t_x_by_key is t_x -> { group_by: key; aggregate:
30
+ ... }` -- joined from an `extend` in the canonical query.
31
+ A raw aggregate by key is not semantics, and the
32
+ Publisher refuses a join declared inline in a query.
26
33
  publisher.json the truth package, named, with the standard description.
27
34
  publisher.config.json (--publisher-config) a Publisher server config
28
35
  serving ONLY the truth package, for the second server the
@@ -133,6 +140,9 @@ def main() -> int:
133
140
  "// own doc for where each comes from",
134
141
  "// - raw-column dimensions for anything nested (a VARIANT column, a JSON",
135
142
  "// path) that a golden query will need to group by",
143
+ "// - a raw per-key rollup (`source: t_x_by_key is t_x -> { group_by: key;",
144
+ "// aggregate: ... }`) wherever a key composes two tables, joined from an",
145
+ "// `extend` in the canonical query: the server refuses an inline join",
136
146
  "// Keep column names in the warehouse's own case so a golden query reads as",
137
147
  "// a warehouse query, not as a model query.",
138
148
  "",
@@ -169,7 +169,7 @@ RUN_REQUIRED = {"runId", "target", "answererModel", "phase", "started"}
169
169
  RUN_RECOMMENDED = {"judgeModel", "judgeVersion", "datasetVersion", "modelSha",
170
170
  "skillsVersion", "answererManifest"}
171
171
  RUN_OPTIONAL = {"label", "effort", "environment", "package", "modelPath",
172
- "modelGitSha", "mcpUrl", "publisher", "predictionsReExecuted",
172
+ "modelGitSha", "modelDir", "mcpUrl", "publisher", "predictionsReExecuted",
173
173
  "serverVersion", "diagnoserModel", "improverModel",
174
174
  "rubricSha",
175
175
  # The prompt the judge was SHOWN, hashed. `rubricSha` covers
@@ -528,7 +528,17 @@ def cascade(rows: list[dict[str, Any]]) -> dict[str, int]:
528
528
  # pins that identity never caught it because every row it builds
529
529
  # supplies a required entity.
530
530
  "passed_not_covered": 0, "passed_not_retrieved": 0,
531
- "passed_unmeasured": 0, "passed_no_entities_named": 0}
531
+ "passed_unmeasured": 0, "passed_no_entities_named": 0,
532
+ # Recall, tallied OUTSIDE the funnel. The funnel is an elif chain, so a
533
+ # row whose coverage was never measured stops at rung 1 and never
534
+ # reaches the recall check -- `not retrieved` is then structurally 0,
535
+ # and a display that subtracts it from a denominator reports every
536
+ # retrieval as a success. That is what happened: 12 cases, coverage
537
+ # unknown, three of them at recall 0.5, printed "retrieved? 12 yes, 0
538
+ # no". These two count every row whose recall was actually computed and
539
+ # whose coverage is not a KNOWN gap, so the rung says what was measured
540
+ # whether or not coverage ran.
541
+ "recall_scored": 0, "recall_short": 0}
532
542
  for r in rows:
533
543
  cov = r["coverage"]
534
544
  passed = not r["failed"] and r["verdict"] not in UNSCORED
@@ -550,6 +560,9 @@ def cascade(rows: list[dict[str, Any]]) -> dict[str, int]:
550
560
  c["delivered, wrong"] += 1
551
561
  else:
552
562
  c["delivered, right"] += 1
563
+ if cov not in MEASURED_GAPS and r["recall"] is not None:
564
+ c["recall_scored"] += 1
565
+ c["recall_short"] += r["recall"] < 1.0
553
566
  return c
554
567
 
555
568
 
@@ -437,8 +437,15 @@ class Cascade(unittest.TestCase):
437
437
  # `passed_not_covered` and `passed_not_retrieved` OVERLAY the rungs: they
438
438
  # re-count rows already counted by "not covered" and "not retrieved". They
439
439
  # are not rungs and must never be added to them.
440
+ #
441
+ # `recall_scored` / `recall_short` are a parallel TALLY, not a rung either.
442
+ # The rungs are an elif chain, so a row whose coverage was never measured
443
+ # stops at the first one and its recall is never classified; these two
444
+ # count recall over every row that carried one, which is what the retrieval
445
+ # line reports. Same rows, counted on a second axis -- so they overlay.
440
446
  OVERLAY = ("total", "passed_not_covered", "passed_not_retrieved",
441
- "passed_unmeasured", "passed_no_entities_named")
447
+ "passed_unmeasured", "passed_no_entities_named",
448
+ "recall_scored", "recall_short")
442
449
 
443
450
  def test_every_row_lands_on_exactly_one_rung(self):
444
451
  c = cascade(self.rows())
@@ -218,6 +218,16 @@ def check_value(case: dict[str, Any], a: argparse.Namespace
218
218
  # canonicalQuery" and count as an error, so a set with one criteria
219
219
  # golden failed its own audit with exit 1.
220
220
  return "skipped", "criteria: the clauses are the key, nothing to re-derive", None
221
+ if g.get("status") in ("ambiguous", "invalid"):
222
+ # A held golden is settled by a person through the golden side door
223
+ # and never re-derived (`promotion_blocker` says the same). Running the
224
+ # value check on it printed HTTP 404 on every audit for a key whose
225
+ # canonicalQuery read the model under test, `verify()` counted that
226
+ # error as drift, and `run_baseline` refused to start the arm -- with
227
+ # `--refresh` as the suggested remedy, which cannot fix an error.
228
+ return ("skipped",
229
+ f"{g['status']}: settled by a person through the golden side "
230
+ f"door, not re-derived", None)
221
231
  if not q:
222
232
  # A PROVISIONAL golden with no query is young, not wrong, and it is
223
233
  # exactly what `eval-import` produces from a set that arrived as
@@ -243,6 +253,21 @@ def check_value(case: dict[str, Any], a: argparse.Namespace
243
253
  rows, err = try_query(a.publisher, a.environment, a.truth_package,
244
254
  a.truth_model, q)
245
255
  if err:
256
+ if "not queryable" in err:
257
+ # The single most likely state of a set authored before its truth
258
+ # package existed: every key replays a packaged view (`run:
259
+ # orders -> ...`), and the truth server answers 404 "Query
260
+ # target is not queryable" 23 times with no hint that the query's
261
+ # root source is not a raw table. The message sent a reader to the
262
+ # server; the defect is in the golden.
263
+ m = re.match(r"\s*run:\s*([A-Za-z_]\w*)", q)
264
+ root = f"`{m.group(1)}`" if m else "its root source"
265
+ return ("error",
266
+ f"canonicalQuery reads {root}, which the truth package does "
267
+ f"not serve as queryable: either the truth model does not "
268
+ f"declare it (a key derived through the model under test, "
269
+ f"not the raw tables) or the query did not compile. "
270
+ f"Re-derive the key through the truth package", None)
246
271
  return "error", err[:160], None
247
272
 
248
273
  want = g.get("value")
@@ -518,6 +543,49 @@ def promotion_blocker(case: dict[str, Any], set_dir: pathlib.Path) -> str | None
518
543
  return None
519
544
 
520
545
 
546
+ # A figure is a number a reader would check: 8,817.56, 943, 25%. Years and
547
+ # small ordinals are not -- "top 3", "2025" -- so they do not raise a finding.
548
+ FIGURE = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d+|\d{3,})")
549
+
550
+
551
+ def criteria_figure_findings(case: dict[str, Any]) -> list[str]:
552
+ """Numbers quoted inside a `criteria` golden, which nothing can verify.
553
+
554
+ A criteria golden is exempt from both numeric checks and legitimately so:
555
+ it holds no value to re-derive, and the rubric-figure check compares
556
+ figures against the golden's ROWS, which it does not have. The clauses are
557
+ prose and prose cannot drift.
558
+
559
+ Its prose can still assert numbers, and then the exemption is a hole rather
560
+ than a simplification. This set shipped one: a criteria golden's note said
561
+ "by order count it is Amelia Cohen (80 orders)". Both halves were wrong --
562
+ at the grain the same rubric requires, the top by order count is the same
563
+ customer who tops spend, and 80 is three customers sharing a name, summed
564
+ by exactly the grouping the neighbouring case exists to forbid. Every check
565
+ passed it, because every check skips this kind of golden.
566
+
567
+ Reported as review, not failure: quoting a figure is often right, and the
568
+ point is that a human has to be the one who checked it.
569
+ """
570
+ g = case.get("golden") or {}
571
+ if g.get("kind") != "criteria":
572
+ return []
573
+ out = []
574
+ for field, text in (("rubric", g.get("rubric")),
575
+ ("verification.note", (g.get("verification") or {}).get("note"))):
576
+ if not isinstance(text, str):
577
+ continue
578
+ figs = sorted(set(FIGURE.findall(text)))
579
+ if figs:
580
+ out.append(f"review {case['qid']}: the criteria golden's {field} quotes "
581
+ f"{', '.join(figs[:6])}"
582
+ f"{' and more' if len(figs) > 6 else ''}, and NOTHING in this "
583
+ f"toolchain can check a figure in a criteria golden -- it has no "
584
+ f"rows for the rubric-figure check and no value to re-derive. "
585
+ f"Query each one at the grain this rubric requires, or drop it")
586
+ return out
587
+
588
+
521
589
  def axis_findings(case: dict[str, Any], set_dir: pathlib.Path) -> list[str]:
522
590
  g = case.get("golden") or {}
523
591
  if g.get("kind") == "unanswerable":
@@ -1021,6 +1089,7 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
1021
1089
  for c in chosen:
1022
1090
  findings += rubric_number_findings(c)
1023
1091
  findings += axis_findings(c, set_dir)
1092
+ findings += criteria_figure_findings(c)
1024
1093
  findings += rubric_alternative_findings(chosen)
1025
1094
  findings += stale_rubric_claims(chosen, model_definitions(model))
1026
1095
  findings += unknown_name_findings(chosen, model_text(model))
@@ -1021,3 +1021,67 @@ class NumericRendering(unittest.TestCase):
1021
1021
 
1022
1022
  if __name__ == "__main__":
1023
1023
  unittest.main()
1024
+
1025
+
1026
+ class HeldGoldensAreNotReDerived(unittest.TestCase):
1027
+ """An `ambiguous` or `invalid` golden is a person's judgement. Running the
1028
+ value check on one reported an error on every audit, `verify()` counted the
1029
+ error as drift, and the arm refused to start."""
1030
+
1031
+ def setUp(self):
1032
+ self.a = argparse.Namespace(rewrite=False, publisher="http://x",
1033
+ environment="e", truth_package="t",
1034
+ truth_model="truth.malloy")
1035
+
1036
+ def case(self, status):
1037
+ return {"qid": "s-1", "golden": {"kind": "scalar", "status": status,
1038
+ "canonicalQuery": "run: model -> { aggregate: n }",
1039
+ "value": {"n": 1}}}
1040
+
1041
+ def test_a_held_golden_is_skipped_with_the_reason(self):
1042
+ for status in ("ambiguous", "invalid"):
1043
+ with self.subTest(status=status):
1044
+ with unittest.mock.patch.object(verify_goldens, "try_query") as tq:
1045
+ got, detail, rows = check_value(self.case(status), self.a)
1046
+ tq.assert_not_called()
1047
+ self.assertEqual(got, "skipped")
1048
+ self.assertIn(status, detail)
1049
+ self.assertIn("side door", detail)
1050
+
1051
+ def test_a_provisional_golden_with_a_query_still_runs(self):
1052
+ with unittest.mock.patch.object(verify_goldens, "try_query",
1053
+ return_value=([{"n": 1}], None)) as tq:
1054
+ got, _, _ = check_value(self.case("provisional"), self.a)
1055
+ tq.assert_called_once()
1056
+ self.assertEqual(got, "ok")
1057
+
1058
+
1059
+ class ANotQueryableKeyNamesItsRootSource(unittest.TestCase):
1060
+ """A set authored before its truth package existed replays packaged views;
1061
+ the truth server answers 404 for each, and the bare message sent a reader
1062
+ to the server instead of the golden."""
1063
+
1064
+ def test_the_root_source_and_the_remedy_are_in_the_detail(self):
1065
+ a = argparse.Namespace(rewrite=False, publisher="http://x", environment="e",
1066
+ truth_package="t", truth_model="truth.malloy")
1067
+ case = {"qid": "s-2", "golden": {"kind": "scalar", "status": "provisional",
1068
+ "canonicalQuery": "run: orders -> { aggregate: n }",
1069
+ "value": {"n": 1}}}
1070
+ err = 'HTTP 404: {"code":404,"message":"Query target is not queryable."}'
1071
+ with unittest.mock.patch.object(verify_goldens, "try_query",
1072
+ return_value=(None, err)):
1073
+ got, detail, _ = check_value(case, a)
1074
+ self.assertEqual(got, "error")
1075
+ self.assertIn("`orders`", detail)
1076
+ self.assertIn("truth package", detail)
1077
+
1078
+ def test_any_other_error_is_reported_as_it_came(self):
1079
+ a = argparse.Namespace(rewrite=False, publisher="http://x", environment="e",
1080
+ truth_package="t", truth_model="truth.malloy")
1081
+ case = {"qid": "s-3", "golden": {"kind": "scalar", "status": "provisional",
1082
+ "canonicalQuery": "run: t_a -> { aggregate: n }",
1083
+ "value": {"n": 1}}}
1084
+ with unittest.mock.patch.object(verify_goldens, "try_query",
1085
+ return_value=(None, "HTTP 500: boom")):
1086
+ got, detail, _ = check_value(case, a)
1087
+ self.assertEqual((got, detail), ("error", "HTTP 500: boom"))
@@ -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.22
4
+ version: 0.1.24
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -221,6 +221,21 @@ If the agent could not reasonably have known to ask, that is a model gap.
221
221
  | `GUIDANCE-NOT-RETRIEVED` | entities came back, governing guidance did not | model: put guidance on the entities agents search for |
222
222
  | `GUIDANCE-DECLINED` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat |
223
223
 
224
+ **Before diagnosing a `NOT-RETURNED`, check the call actually returned
225
+ nothing.** The `tool_call` event carries `retrieval_mode` and `rankedSummary`.
226
+ A `rankedSummary` of `null` means the response could not be read, not that it
227
+ was empty -- a body too large for the model's context is written to a file, and
228
+ the run records no summary for it. There is nothing to diagnose there: say the
229
+ call was not measured.
230
+
231
+ And **never attribute a miss to the embedding index unless `retrieval_mode`
232
+ says `lexical`.** The mode is recorded per call precisely so this is checkable.
233
+ A diagnosis once explained a phantom empty result with "the index was not ready
234
+ yet" on a run whose index was ready before the first question and whose
235
+ response did hold results; the empty list was a parsing bug in the harness. An
236
+ unverifiable cause that sounds right is worse than `needs_human`, because it
237
+ closes the finding.
238
+
224
239
  A missing join is coverage, not an agent-call miss. The model has to volunteer
225
240
  relationships. A declared join is not a retrieval entity; do not look for it in
226
241
  `get_context` results.
@@ -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.22
4
+ version: 0.1.24
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -13,7 +13,7 @@ On the case:
13
13
  | field | from the source | when it is missing |
14
14
  |---|---|---|
15
15
  | `qid` | their id, if they gave one, prefixed with the set name | derive from the question: a slug of its first words plus a counter. Stable, because scores are keyed on it |
16
- | `question` | the question text, byte for byte | it is not a case. Do not reconstruct a question from a criterion |
16
+ | `question` | the question text, byte for byte. One exception: a source hard-wrapped at a column width cannot be one line byte for byte, so a line break inside a question becomes one space, the seal is on the unwrapped text, and `sourceNote` says so | it is not a case. Do not reconstruct a question from a criterion |
17
17
  | `questionSha` | never from the source | stamped at conversion by `import_cases.py --stamp` |
18
18
  | `split` | rarely present | you choose, and freeze it at import |
19
19
  | `source` | how it arrived: the filename, or the log query | say `unknown` rather than guessing a provenance |
@@ -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.22
4
+ version: 0.1.24
5
5
  ---
6
6
 
7
7
  # Improve the Model
@@ -1,12 +1,12 @@
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.22
4
+ version: 0.1.24
5
5
  ---
6
6
 
7
7
  # The judge
8
8
 
9
- JUDGE_VERSION: 5
9
+ JUDGE_VERSION: 6
10
10
 
11
11
  This skill IS the judge. One fresh judge subagent is spawned per attempt, with
12
12
  this skill installed in its workspace and the case materials in its prompt. It
@@ -119,6 +119,27 @@ Output, exactly this shape:
119
119
  reading that produced it, that is `no_match`. Use `near_match` only when you
120
120
  can name the rubric clause that makes the difference defensible.
121
121
 
122
+ **A rubric clause cannot make a wrong VALUE defensible, and a clause that
123
+ tries is a defect in the rubric rather than a licence to you.** `near_match`
124
+ turns on the answer being right under a reading the QUESTION allows -- a tie
125
+ broken the other way, a grain the question left open, a basis the question
126
+ never fixed. It does not turn on the answer being transparent about how it
127
+ got a figure the question did not ask for. Those two look alike in a rubric
128
+ and are opposites in a report: one is a number a reader can act on, the
129
+ other is a number a reader would act on wrongly. Naming the method makes a
130
+ wrong figure DIAGNOSABLE, which is worth having, and it is not partial
131
+ credit.
132
+
133
+ The test, before you write `near_match` on a case with a value: would a
134
+ reader who acted on this figure be wrong? If yes, it is `no_match` however
135
+ plainly the answer explained itself, and however the rubric is worded. Say
136
+ in `why` that you are overriding a rubric clause, so the clause gets fixed.
137
+ This rule exists because a set shipped one: a question asked for sales over
138
+ the company's season, the answer gave the meteorological window 25% lower
139
+ and said which window it used, and a clause granting `near_match` for a
140
+ stated window kept a materially wrong answer out of the pass rate
141
+ entirely -- the arm reported 100%.
142
+
122
143
  It is a third outcome because as a pass it was a large share of the measured
123
144
  noise: the same unchanged answer reads `match` in one run and `near_match`
124
145
  in the next, and the pass rate moves although nothing did. A verdict whose
@@ -165,6 +186,29 @@ Output, exactly this shape:
165
186
  total; the question had never asked for one. A rubric that means "campaign
166
187
  total only" must say so as `REQUIRED`, and the question should say so too.
167
188
 
189
+ ### Rubric markers
190
+
191
+ A case rubric marks its alternate readings and disclosures with the words
192
+ below, and each word fixes the verdict. Apply them as written; do not re-weigh
193
+ a reading the rubric has already classified. (`reference/writing-rubrics.md`
194
+ is where authors are told to use them; this table is the judge's half.)
195
+
196
+ | Marker | Verdict | Meaning |
197
+ |---|---|---|
198
+ | `PREFERRED` | `match` | The reading the golden encodes. |
199
+ | `ACCEPT` | `match` | Equally right: a different but faithful route to the same claim. Check the figure against the golden the way the clause says to. |
200
+ | `DIVERGENT` | `near_match` | Defensible and not what was asked for. Never `no_match`, however clearly the answer committed to it. |
201
+ | `WRONG` | `no_match` | Plausible and incorrect; the clause usually names the trap. |
202
+ | `REQUIRED` | omitted: `no_match` | A disclosure without which the number misleads. |
203
+ | `CREDITED` | omitted: `match` | Context a good analyst adds; its absence costs nothing. |
204
+
205
+ Measured on an unchanged answer, rubric and golden: an answer whose
206
+ recommended figure the rubric marked `DIVERGENT` scored `near_match` under
207
+ one judge and `no_match` under the next, because the judge had never been
208
+ told what the word meant and weighed the commitment instead. An unmarked
209
+ clause is `CREDITED` (the author's bug, not yours to repair by inventing a
210
+ requirement).
211
+
168
212
  ### Anchors
169
213
 
170
214
  - **match**: question "total sales by category"; golden 8 rows
@@ -40,6 +40,14 @@ Rules that follow from this:
40
40
  must say "for the campaign as a whole". If it does not, the rubric must accept
41
41
  a correct figure at any stated grain (judge rule 11). A rubric that quietly
42
42
  assumes the golden's grain fails correct answers.
43
+ - **Fix the cardinality in the question, or accept any prefix in the rubric.**
44
+ "The most", "the top ones", "most often" name no N; the truth query needs
45
+ one, so the golden fixes 10 or 20 or 25 and an answer that shows five
46
+ correct rows reads as missing fifteen. Measured over one 24-case set, that
47
+ class alone produced a `no_match`, a flip and two `near_match` verdicts on
48
+ answers whose rows were a correct prefix of the golden's. Either write the N
49
+ into the question, or mark the rubric `ACCEPT the first N of the golden's
50
+ order for any N of at least five`, and say which order the golden is in.
43
51
  - **A right value plus a missing `CREDITED` disclosure is a `match`.** Not a
44
52
  near match. Do not deduct for it.
45
53
  - **`DIVERGENT` is about definitions, not arithmetic.** A clause permitting a