@malloy-publisher/skills 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-answer/scripts/check_findable.py +99 -0
  4. package/skills/eval-answer/scripts/check_findable_test.py +38 -0
  5. package/skills/eval-answer/scripts/score_retrieval.py +14 -1
  6. package/skills/eval-answer/scripts/score_retrieval_test.py +8 -1
  7. package/skills/eval-answer/scripts/verify_goldens.py +44 -0
  8. package/skills/eval-diagnose/SKILL.md +16 -1
  9. package/skills/eval-import/SKILL.md +1 -1
  10. package/skills/eval-improve/SKILL.md +1 -1
  11. package/skills/eval-judge/SKILL.md +22 -1
  12. package/skills/eval-loop/SKILL.md +21 -6
  13. package/skills/eval-loop/scripts/build_run_package.py +17 -4
  14. package/skills/eval-loop/scripts/run_baseline.py +117 -6
  15. package/skills/eval-loop/scripts/run_baseline_test.py +127 -12
  16. package/skills/eval-loop/templates/eval-run-package/public/app.js +29 -4
  17. package/skills/eval-loop/templates/eval-run-package/public/index.html +35 -7
  18. package/skills/eval-report/SKILL.md +101 -12
  19. package/skills/malloy/SKILL.md +2 -1
  20. package/skills/malloy-analysis/SKILL.md +2 -2
  21. package/skills/malloy-analysis-pitfalls/SKILL.md +2 -2
  22. package/skills/malloy-analysis-report/SKILL.md +1 -1
  23. package/skills/malloy-analyze/SKILL.md +1 -1
  24. package/skills/malloy-charts/SKILL.md +1 -1
  25. package/skills/malloy-dashboards/SKILL.md +26 -8
  26. package/skills/malloy-debug/SKILL.md +1 -1
  27. package/skills/malloy-define/SKILL.md +1 -1
  28. package/skills/malloy-discover/SKILL.md +1 -1
  29. package/skills/malloy-document/SKILL.md +1 -1
  30. package/skills/malloy-getting-started/SKILL.md +1 -1
  31. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  32. package/skills/malloy-gotchas-queries/SKILL.md +43 -24
  33. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  34. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  35. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
  36. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  37. package/skills/malloy-lookml-review/SKILL.md +1 -1
  38. package/skills/malloy-lookml-review/reference/curate-visibility.md +1 -1
  39. package/skills/malloy-materialization/SKILL.md +3 -3
  40. package/skills/malloy-materialization-tuning/SKILL.md +2 -2
  41. package/skills/malloy-model/SKILL.md +51 -17
  42. package/skills/malloy-model-as-you-go/SKILL.md +2 -2
  43. package/skills/malloy-modeling/SKILL.md +2 -2
  44. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  45. package/skills/malloy-notebooks/SKILL.md +2 -2
  46. package/skills/malloy-patterns/SKILL.md +1 -1
  47. package/skills/malloy-phrase-detection/SKILL.md +14 -9
  48. package/skills/malloy-publish/SKILL.md +31 -12
  49. package/skills/malloy-queries/SKILL.md +11 -12
  50. package/skills/malloy-review/SKILL.md +1 -1
  51. package/skills/malloy-scope/SKILL.md +1 -1
  52. package/skills/malloy-source-unreachable/SKILL.md +71 -0
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.21",
4
+ "version": "0.1.23",
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.21
4
+ version: 0.1.23
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -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()
@@ -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())
@@ -518,6 +518,49 @@ def promotion_blocker(case: dict[str, Any], set_dir: pathlib.Path) -> str | None
518
518
  return None
519
519
 
520
520
 
521
+ # A figure is a number a reader would check: 8,817.56, 943, 25%. Years and
522
+ # small ordinals are not -- "top 3", "2025" -- so they do not raise a finding.
523
+ FIGURE = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d+|\d{3,})")
524
+
525
+
526
+ def criteria_figure_findings(case: dict[str, Any]) -> list[str]:
527
+ """Numbers quoted inside a `criteria` golden, which nothing can verify.
528
+
529
+ A criteria golden is exempt from both numeric checks and legitimately so:
530
+ it holds no value to re-derive, and the rubric-figure check compares
531
+ figures against the golden's ROWS, which it does not have. The clauses are
532
+ prose and prose cannot drift.
533
+
534
+ Its prose can still assert numbers, and then the exemption is a hole rather
535
+ than a simplification. This set shipped one: a criteria golden's note said
536
+ "by order count it is Amelia Cohen (80 orders)". Both halves were wrong --
537
+ at the grain the same rubric requires, the top by order count is the same
538
+ customer who tops spend, and 80 is three customers sharing a name, summed
539
+ by exactly the grouping the neighbouring case exists to forbid. Every check
540
+ passed it, because every check skips this kind of golden.
541
+
542
+ Reported as review, not failure: quoting a figure is often right, and the
543
+ point is that a human has to be the one who checked it.
544
+ """
545
+ g = case.get("golden") or {}
546
+ if g.get("kind") != "criteria":
547
+ return []
548
+ out = []
549
+ for field, text in (("rubric", g.get("rubric")),
550
+ ("verification.note", (g.get("verification") or {}).get("note"))):
551
+ if not isinstance(text, str):
552
+ continue
553
+ figs = sorted(set(FIGURE.findall(text)))
554
+ if figs:
555
+ out.append(f"review {case['qid']}: the criteria golden's {field} quotes "
556
+ f"{', '.join(figs[:6])}"
557
+ f"{' and more' if len(figs) > 6 else ''}, and NOTHING in this "
558
+ f"toolchain can check a figure in a criteria golden -- it has no "
559
+ f"rows for the rubric-figure check and no value to re-derive. "
560
+ f"Query each one at the grain this rubric requires, or drop it")
561
+ return out
562
+
563
+
521
564
  def axis_findings(case: dict[str, Any], set_dir: pathlib.Path) -> list[str]:
522
565
  g = case.get("golden") or {}
523
566
  if g.get("kind") == "unanswerable":
@@ -1021,6 +1064,7 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
1021
1064
  for c in chosen:
1022
1065
  findings += rubric_number_findings(c)
1023
1066
  findings += axis_findings(c, set_dir)
1067
+ findings += criteria_figure_findings(c)
1024
1068
  findings += rubric_alternative_findings(chosen)
1025
1069
  findings += stale_rubric_claims(chosen, model_definitions(model))
1026
1070
  findings += unknown_name_findings(chosen, model_text(model))
@@ -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.21
4
+ version: 0.1.23
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.21
4
+ version: 0.1.23
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.21
4
+ version: 0.1.23
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.21
4
+ version: 0.1.23
5
5
  ---
6
6
 
7
7
  # The judge
@@ -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
@@ -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.21
4
+ version: 0.1.23
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -328,11 +328,19 @@ the run measure something other than what it names:
328
328
  first time somebody tidies up; the set, the run directory and the write-up
329
329
  belong in git beside the model.
330
330
 
331
- 2. The server must be up with retrieval tracing on, so a call's ranked results
332
- can be recovered afterwards (open-source Publisher: `PUBLISHER_MCP_TRACE=retrieval`).
333
- Confirm a trace lookup is available (absent means tracing is off).
334
- Refuse to start a scored run without it: failures without traces cannot be
335
- attributed.
331
+ 2. The server must be up. Where your host offers retrieval tracing, turn it on
332
+ and confirm a trace lookup answers, so a call's ranked results can be
333
+ recovered afterwards.
334
+
335
+ **Open-source Publisher has none.** `PUBLISHER_MCP_TRACE=retrieval` is set by
336
+ `serve.py` and read by nothing: the trace store was written and never merged,
337
+ so there is no trace tool and `traceId` is null on every local attempt. This
338
+ does not block a scored run, because attribution never depended on it:
339
+ `rankedSummary` is copied onto the `tool_call` event at capture, precisely so
340
+ the evidence survives without a store. So do not refuse a local run for want
341
+ of tracing -- an earlier version of this rule did, and it refused every local
342
+ run there has ever been. Refuse one whose `tool_call` events carry no
343
+ `rankedSummary`, which is what attribution actually reads.
336
344
 
337
345
  3. Health-check: your host's status check until it reports serving, and inspect
338
346
  `loadErrors`. A dead database that still answers HTTP is an environment
@@ -500,6 +508,13 @@ conductor. Do not:
500
508
 
501
509
  - The model is the only thing improve edits. No question text, qids, or
502
510
  expected values in any name, doc, or comment.
511
+ - **You are measuring a model, not reviewing this harness.** Read a script when
512
+ a number you have to report cannot be explained otherwise, and stop there. Do
513
+ not audit the scripts, propose fixes to them, or hand back tooling critique
514
+ in place of a result -- a run that ends in harness feedback has not answered
515
+ the question it was asked. A defect that changed a number gets one sentence
516
+ in the report; a defect that changed nothing gets one line in chat, once.
517
+ Fixing it is a different task, and the user starts it.
503
518
  - When the environment misbehaves, stop. Never diagnose a sick system. The
504
519
  harness is part of the environment: a known-broken measurement does not
505
520
  become quotable by being finished. Measured against this directive, an arm
@@ -396,7 +396,8 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
396
396
  retr.append({"attempt_key": ak, "run_id": run_id, **r})
397
397
 
398
398
  exp = case.get("expectedEntities") or {}
399
- required = {e for g in groups(exp) for e in g}
399
+ required_groups = groups(exp)
400
+ required = {e for g in required_groups for e in g}
400
401
  acceptable = set(exp.get("acceptable") or []) | required
401
402
  got: dict[str, None] = {}
402
403
  mine_tokens: list[str] = []
@@ -412,11 +413,23 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
412
413
  # named in returned source docs (from the ledger when the run
413
414
  # recorded them, else from the transcript walked above).
414
415
  tokens = {t for t in mine_tokens} | set(_IDENT.findall(docs))
415
- for eid in sorted(required):
416
+ # ONE ROW PER GROUP, not per entity. `groups()` is the unit recall
417
+ # is scored in -- a `requiredAnyOf` group is satisfied by any one
418
+ # member -- and flattening it here made the app's badge disagree
419
+ # with the recall metric printed beside it: a case with one
420
+ # `required` plus a three-way alternative rendered four dots and
421
+ # read `2/4` in red on a case whose recall was 2 of 2. Nine of the
422
+ # storefront set's twelve cases carry such a group.
423
+ RANK = {"exact": 0, "alias": 1, "in_docs": 2, "missing": 3}
424
+ for g in sorted(required_groups, key=lambda g: sorted(g)[0]):
425
+ best = min(((delivery(e, set(got), tokens), e) for e in g),
426
+ key=lambda p: RANK.get(p[0], 3))
427
+ status, eid = best
416
428
  required_rows.append({
417
429
  "attempt_key": ak, "run_id": run_id, "qid": qid,
418
- "entity_id": eid,
419
- "status": delivery(eid, set(got), tokens)})
430
+ "entity_id": eid if len(g) == 1 else
431
+ f"{eid} (any of {len(g)})",
432
+ "status": status})
420
433
 
421
434
  roles: list[tuple[str, str]] = []
422
435
  roles += [(eid, "required") for eid in sorted(required)]