@malloy-publisher/skills 0.1.19 → 0.1.21

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 (79) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +45 -5
  3. package/skills/eval-answer/reference/definition-ledger.md +305 -0
  4. package/skills/eval-answer/reference/ledger-schema.md +36 -7
  5. package/skills/eval-answer/scripts/check_coverage.py +12 -21
  6. package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
  7. package/skills/eval-answer/scripts/check_findable.py +386 -0
  8. package/skills/eval-answer/scripts/check_findable_test.py +326 -0
  9. package/skills/eval-answer/scripts/json_scan.py +43 -0
  10. package/skills/eval-answer/scripts/ledger.py +63 -7
  11. package/skills/eval-answer/scripts/ledger_test.py +7 -0
  12. package/skills/eval-answer/scripts/mcp_payload.py +111 -2
  13. package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
  14. package/skills/eval-answer/scripts/publisher_rest.py +10 -4
  15. package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
  16. package/skills/eval-answer/scripts/score_retrieval.py +326 -41
  17. package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
  18. package/skills/eval-answer/scripts/verify_definitions.py +752 -0
  19. package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
  20. package/skills/eval-answer/scripts/verify_goldens.py +336 -22
  21. package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
  22. package/skills/eval-diagnose/SKILL.md +47 -2
  23. package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
  24. package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
  25. package/skills/eval-diagnose/scripts/diagnose.py +471 -42
  26. package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
  27. package/skills/eval-import/SKILL.md +180 -1
  28. package/skills/eval-import/reference/case-format.md +1 -1
  29. package/skills/eval-improve/SKILL.md +1 -1
  30. package/skills/eval-improve/scripts/improve.py +29 -3
  31. package/skills/eval-improve/scripts/improve_test.py +51 -0
  32. package/skills/eval-judge/SKILL.md +1 -1
  33. package/skills/eval-loop/SKILL.md +112 -3
  34. package/skills/eval-loop/reference/measurement.md +44 -4
  35. package/skills/eval-loop/reference/running-a-run.md +5 -1
  36. package/skills/eval-loop/scripts/agent_harness.py +21 -20
  37. package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
  38. package/skills/eval-loop/scripts/build_run_package.py +9 -3
  39. package/skills/eval-loop/scripts/flip_table.py +107 -2
  40. package/skills/eval-loop/scripts/flip_table_test.py +165 -0
  41. package/skills/eval-loop/scripts/run_baseline.py +956 -97
  42. package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
  43. package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
  44. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
  45. package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
  46. package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
  47. package/skills/eval-report/SKILL.md +362 -0
  48. package/skills/malloy/SKILL.md +6 -7
  49. package/skills/malloy-analysis/SKILL.md +22 -45
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +21 -6
  51. package/skills/malloy-analysis-report/SKILL.md +1 -1
  52. package/skills/malloy-analyze/SKILL.md +1 -1
  53. package/skills/malloy-charts/SKILL.md +2 -2
  54. package/skills/malloy-dashboards/SKILL.md +1 -1
  55. package/skills/malloy-debug/SKILL.md +1 -1
  56. package/skills/malloy-define/SKILL.md +1 -1
  57. package/skills/malloy-discover/SKILL.md +1 -1
  58. package/skills/malloy-document/SKILL.md +1 -1
  59. package/skills/malloy-getting-started/SKILL.md +1 -1
  60. package/skills/malloy-gotchas-modeling/SKILL.md +2 -2
  61. package/skills/malloy-gotchas-queries/SKILL.md +2 -2
  62. package/skills/malloy-gotchas-rendering/SKILL.md +2 -2
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
  65. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  66. package/skills/malloy-lookml-review/SKILL.md +1 -1
  67. package/skills/malloy-materialization/SKILL.md +1 -1
  68. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  69. package/skills/malloy-model/SKILL.md +1 -1
  70. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  71. package/skills/malloy-modeling/SKILL.md +1 -1
  72. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  73. package/skills/malloy-notebooks/SKILL.md +1 -1
  74. package/skills/malloy-patterns/SKILL.md +2 -2
  75. package/skills/malloy-phrase-detection/SKILL.md +12 -7
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +2 -2
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. package/skills/malloy-scope/SKILL.md +7 -4
@@ -46,6 +46,17 @@ WHAT IT CHECKS, AND WHAT EACH CATCHES
46
46
  is compared with the model's own definition of X. Catches the
47
47
  rubric that goes stale when the model is fixed.
48
48
 
49
+ 4b. rubric alternatives -- a rubric that accepts an alternative reading
50
+ ("either the full name or the nickname is fine") whose
51
+ `expectedEntities.required` names one id per concept and no
52
+ `requiredAnyOf` group. The two halves of a key are read by
53
+ different things -- prose by the judge, ids by retrieval
54
+ scoring -- so they can disagree silently, and an answer taking
55
+ the other reading then scores CORRECT and loses recall in the
56
+ same run. Review items: the phrasing is a heuristic over
57
+ prose. Measured over the sets available locally: 3 findings
58
+ over 64 rubrics, all three genuine.
59
+
49
60
  5. set names -- every `required` / `requiredAnyOf` entity id, and every
50
61
  `mustNotUse` name, exists in the model under test (`--model`).
51
62
  An id naming a field this package does not have can never be
@@ -99,6 +110,10 @@ EXIT CODES
99
110
  2 usage error (argparse)
100
111
  3 the value check did not happen -- says nothing about whether the goldens
101
112
  still hold. Either this crashed, or set.json names no truthPackage.
113
+ With --definitions, a set whose every value-bearing case rests on
114
+ validated definitions exits 0 instead: the values were not re-derived,
115
+ but the definitions they rest on were checked, which is the other half
116
+ of the same rule.
102
117
 
103
118
  3 is load-bearing and it is why the codes are enumerated here. `improve.py`'s
104
119
  acceptance gate has to tell "your edit may have invalidated a golden" from "the
@@ -113,6 +128,12 @@ golden was re-derived, and 0 told a caller it had been: `improve.py` recorded
113
128
  `clean: True` for an audit that never looked. When one of those checks DOES
114
129
  find something, 1 wins over 3: a finding is evidence, and 3 says there is none
115
130
  to read, so a caller obeying that would discard it.
131
+
132
+ The composition rule: a golden is trustworthy if it was derived independently,
133
+ OR if every definition it tests has itself been validated. A truth server is the
134
+ first half. `--definitions <ledger>` (from verify_definitions.py) is the second,
135
+ and the only thing it settles is the exit code and the promotion basis -- never a
136
+ golden's value, which it does not touch.
116
137
  """
117
138
  from __future__ import annotations
118
139
 
@@ -137,11 +158,37 @@ def rewrite_table_refs(malloy: str) -> str:
137
158
  return _TABLE_REF.sub(lambda m: m.group(1), malloy)
138
159
 
139
160
 
161
+ def as_number(v: Any) -> float | None:
162
+ """`v` as a float when it IS one, including as a plain numeric string.
163
+
164
+ Not a general parser: a bool is not a number here (`True == 1` in Python
165
+ and a boolean golden compared numerically would match `1`), and a string
166
+ with anything else in it stays a string.
167
+ """
168
+ if isinstance(v, bool):
169
+ return None
170
+ if isinstance(v, (int, float)):
171
+ return float(v)
172
+ if isinstance(v, str):
173
+ try:
174
+ return float(v.strip().replace(",", ""))
175
+ except ValueError:
176
+ return None
177
+ return None
178
+
179
+
140
180
  def close_enough(want: Any, got: Any, places: int | None) -> bool:
141
- if isinstance(want, (int, float)) and isinstance(got, (int, float)):
181
+ # Numerically whenever both sides ARE numbers, including when one or both
182
+ # arrived as strings. Malloy renders a number in its shortest form, so a
183
+ # golden stated to two decimals comes back as `75.7` and a string compare
184
+ # called it drift -- which blocks the arm, and whose only workaround was
185
+ # padding expressions in the truth queries whose sole purpose was to make
186
+ # one string look like the other.
187
+ w, g = as_number(want), as_number(got)
188
+ if w is not None and g is not None:
142
189
  # Compare at the precision the golden is stated to, not float exactness.
143
- tol = 10 ** -places / 2 if places is not None else max(abs(want) * 1e-9, 1e-9)
144
- return abs(float(want) - float(got)) <= max(tol, 0.011)
190
+ tol = 10 ** -places / 2 if places is not None else max(abs(w) * 1e-9, 1e-9)
191
+ return abs(w - g) <= max(tol, 0.011)
145
192
  return want == got
146
193
 
147
194
 
@@ -172,6 +219,23 @@ def check_value(case: dict[str, Any], a: argparse.Namespace
172
219
  # golden failed its own audit with exit 1.
173
220
  return "skipped", "criteria: the clauses are the key, nothing to re-derive", None
174
221
  if not q:
222
+ # A PROVISIONAL golden with no query is young, not wrong, and it is
223
+ # exactly what `eval-import` produces from a set that arrived as
224
+ # values: "100 provisional (0 with their query, 100 a number alone)".
225
+ # Calling that a hard finding made the import skill's own output
226
+ # refuse the audit that runs before every arm, and the only way past
227
+ # was --skip-golden-check, which throws away the receipt for whatever
228
+ # DID verify. It cannot be scored either way: a provisional golden
229
+ # takes `verdict: null` and stays out of the pass rate, so the arm it
230
+ # was blocking would have measured coverage and nothing else.
231
+ #
232
+ # A VERIFIED golden with no query is still an error. `verified` is a
233
+ # claim that it re-derived, and that claim needs the query.
234
+ if (g.get("status") or "provisional") == "provisional":
235
+ return ("unrederivable",
236
+ "provisional, holds a value, no canonicalQuery yet: not "
237
+ "re-derivable, so not verifiable. `--promote` after you "
238
+ "add one", None)
175
239
  return "error", "no canonicalQuery: this golden cannot be re-derived", None
176
240
  if a.rewrite:
177
241
  q = rewrite_table_refs(q)
@@ -200,7 +264,18 @@ def check_value(case: dict[str, Any], a: argparse.Namespace
200
264
  if not rows:
201
265
  return "diff", "query returned no rows", rows
202
266
  got = rows[0]
203
- scalars = {k: v for k, v in (want or {}).items() if k not in ("currency", "round")}
267
+ # Type-check before dereferencing, the way the `kind: rows` branch above
268
+ # already does. A bare string here -- `"value": "Ecommerce"` instead of
269
+ # `{"answer": "Ecommerce"}` -- is the obvious authoring mistake for a
270
+ # benchmark whose answers ARE strings, and it used to raise
271
+ # AttributeError out of `verify()`, killing the whole sweep. One bad case
272
+ # is one finding; the other thirty-nine still get audited.
273
+ if not isinstance(want, dict):
274
+ return ("error",
275
+ f"kind=scalar but value is {type(want).__name__}, not an "
276
+ f"object mapping the query's column name to its expected "
277
+ f'value (e.g. {{"answer": {json.dumps(want)}}})', rows)
278
+ scalars = {k: v for k, v in want.items() if k not in ("currency", "round")}
204
279
  for k, v in scalars.items():
205
280
  if k not in got:
206
281
  return "diff", f"golden names {k!r}, query returned {sorted(got)}", rows
@@ -211,7 +286,7 @@ def check_value(case: dict[str, Any], a: argparse.Namespace
211
286
 
212
287
  # ---------------------------------------------------------------- 2. rubric numbers
213
288
 
214
- _NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d{2,}|\d{4,})(?![\w])")
289
+ _NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d{1,}|\d{4,})(?![\w])")
215
290
 
216
291
 
217
292
  def golden_numbers(value: Any) -> list[float]:
@@ -287,9 +362,32 @@ def rubric_number_findings(case: dict[str, Any]) -> list[str]:
287
362
  Only the rubric's accepting clause is read -- the text before its first
288
363
  "wrong" / "close but wrong" / "trap" marker -- because the rejecting half
289
364
  quotes numbers that are supposed to be absent. Only figures specific
290
- enough to be a quoted result are checked (five or more significant
291
- digits, or a decimal with two-plus places on a value over 100); years and
292
- small counts are excluded by construction. A figure matches if it is a
365
+ enough to be a quoted result are checked (a comma-grouped
366
+ number, an integer of four digits or more, or any decimal, each then held
367
+ to three significant digits at 100 or more); years and small counts are
368
+ excluded by construction. The TOKENISER gates before the floor does, so
369
+ what the floor admits is only what the pattern found -- a bare three-digit
370
+ integer reaches neither, which is the limit described below.
371
+
372
+ The floor was five significant digits and is now three at or above 100.
373
+ Measured over the local sets: 14 findings to 17 over 54 rubrics. The three
374
+ added are one numerator ("2672/271019", the legitimate-intermediate case
375
+ below) and, twice, the last id in a long comma-separated list. No lexical
376
+ guard was added to suppress either -- see the paragraph below, where one
377
+ was tried and dropped for hiding a real finding.
378
+
379
+ WHAT THIS CHECK CANNOT CATCH, AND WHERE THAT IS HANDLED. A BARE
380
+ three-digit integer is not tokenised at all (`_NUM` starts at four digits
381
+ ungrouped), so the defect that motivated the change -- a rubric saying
382
+ "615 and 502" over goldens holding 747 and 370, one re-derivation out of
383
+ date, against which an agent that computed the golden's own figures was
384
+ marked wrong -- is NOT reported here. Tokenising three-digit integers was
385
+ measured on the same sets and takes the findings from 17 to 552, because a
386
+ rubric that quotes a list of ids contributes one finding per id; a report
387
+ of 552 items is not read. The general rule lives in the judge prompt
388
+ instead: where the rubric and the golden disagree about a figure, the
389
+ golden is the key. That holds at any precision and needs no regex. Lower
390
+ the floor again only with a fresh measurement beside it. A figure matches if it is a
293
391
  golden value, or the sum of a golden column -- rubrics legitimately quote
294
392
  "177,340,447.81 across the three groups".
295
393
 
@@ -325,7 +423,15 @@ def rubric_number_findings(case: dict[str, Any]) -> list[str]:
325
423
  digits = tok.replace(",", "").replace(".", "").lstrip("0")
326
424
  decimals = len(tok.split(".")[1]) if "." in tok else 0
327
425
  n = float(tok.replace(",", ""))
328
- if decimals == 0 and (len(digits) < 5 or 1900 <= n <= 2100):
426
+ # Three significant digits and at least 100, not five. The old floor
427
+ # let through the exact defect this check exists to catch: a rubric
428
+ # saying "615 and 502" over goldens holding 747 and 370, after the
429
+ # goldens were re-derived and the prose was not. The agent computed the
430
+ # golden's own figures and was failed for it. Three-digit quantities
431
+ # are ordinary results, so a floor above them is a floor above the
432
+ # answers.
433
+ if decimals == 0 and (len(digits) < 3 or n < 100
434
+ or 1900 <= n <= 2100):
329
435
  continue
330
436
  if decimals and n < 100 and len(digits) < 5:
331
437
  continue
@@ -436,14 +542,96 @@ DEFINES = re.compile(r"^\s*(\w+)\s+is\s+(.+?)\s*$", re.M)
436
542
  ASSERTS = re.compile(r"(\w+)\s+is\s+(?:defined\s+as\s+)?(count\([^)]*\)|sum\([^)]*\))")
437
543
 
438
544
 
439
- def model_definitions(model_path: pathlib.Path | None) -> dict[str, str]:
545
+ # `source: NAME is`, and the block labels that say what KIND of thing follows.
546
+ # Line-oriented like DEFINES above, for the same reason: this reads text, not a
547
+ # compiled model, because no Publisher API returns a definition's expression.
548
+ SOURCE_DECL = re.compile(r"^\s*source:\s*(\w+)\s+is\b")
549
+ KIND_LABEL = re.compile(r"^\s*(measure|dimension|view|join_one|join_many|join_cross):\s*(.*)$")
550
+ # `public: runtimeMinutes` inside an `include { }` block: a raw column passed
551
+ # through, with no expression. It has no definition that could be wrong, but it
552
+ # DOES get an entity id from get_context and so can be named in a case's
553
+ # `expectedEntities`. Parsed so the ledger knows it exists: without a row, a
554
+ # case naming one is indistinguishable from a case naming a definition nobody
555
+ # checked, and stays unvalidated forever.
556
+ PASSTHROUGH = re.compile(r"^\s*public:\s*(\w+)\s*$")
557
+ KIND_OF_LABEL = {"measure": "measure", "dimension": "dimension", "view": "view",
558
+ "join_one": "join", "join_many": "join", "join_cross": "join"}
559
+
560
+
561
+ def parse_definitions(model_path: pathlib.Path | None,
562
+ recursive: bool = False) -> list[dict[str, Any]]:
563
+ """Every `name is expr` in the model, with the source and kind it belongs to.
564
+
565
+ `model_definitions()` below is the flat view of this and keeps its old
566
+ contract. This one exists because a ledger keyed on `kind:source:name`
567
+ cannot use a flat `dict[name]`: the flat form resolves a same-named field
568
+ in two sources to whichever was read first, silently, and a model with
569
+ `total_sales` on two sources would have one of them validated under the
570
+ other's definition.
571
+
572
+ A line scanner, not a parser. It tracks the enclosing `source:` and the most
573
+ recent `measure:`/`dimension:`/`view:` block label, which is how Malloy
574
+ declares them. It does not track braces, so a definition inside a nested
575
+ block is attributed to the enclosing source rather than to the nesting --
576
+ good enough to key a ledger, and wrong in the same direction as the flat
577
+ dict it replaces rather than in a new one.
578
+ """
440
579
  if not model_path or not model_path.exists():
441
- return {}
442
- out: dict[str, str] = {}
443
- files = [model_path] if model_path.is_file() else sorted(model_path.glob("*.malloy"))
580
+ return []
581
+ # `glob`, not `rglob`, by default: `model_definitions()` has always read
582
+ # only the top level and `stale_rubric_claims()` is calibrated to that.
583
+ # `model_text()` next door DOES recurse, which reads like an oversight
584
+ # rather than a decision -- but changing it here would quietly move an
585
+ # existing check, so the ledger opts in and the inconsistency stays visible.
586
+ files = ([model_path] if model_path.is_file()
587
+ else sorted(model_path.rglob("*.malloy") if recursive
588
+ else model_path.glob("*.malloy")))
589
+ out: list[dict[str, Any]] = []
444
590
  for f in files:
445
- for name, expr in DEFINES.findall(f.read_text()):
446
- out.setdefault(name, expr.split("#")[0].strip())
591
+ source, kind = None, None
592
+ for n, raw in enumerate(f.read_text(errors="replace").splitlines(), 1):
593
+ line = raw.split("//")[0]
594
+ if not line.strip() or line.lstrip().startswith("#"):
595
+ continue
596
+ m = SOURCE_DECL.match(line)
597
+ if m:
598
+ source, kind = m.group(1), None
599
+ # `source: x is y extend { ... }` declares x itself; fall
600
+ # through so the `is` on this line is not read as a field.
601
+ continue
602
+ m = KIND_LABEL.match(line)
603
+ if m:
604
+ kind = KIND_OF_LABEL[m.group(1)]
605
+ line = m.group(2) # `measure: x is ...` on one line
606
+ if not line.strip():
607
+ continue
608
+ pt = PASSTHROUGH.match(line)
609
+ if pt:
610
+ out.append({"source": source, "kind": "dimension",
611
+ "name": pt.group(1), "expr": None,
612
+ "passthrough": True, "file": str(f), "line": n})
613
+ continue
614
+ d = DEFINES.match(line if line.startswith(" ") else " " + line)
615
+ if not d:
616
+ continue
617
+ out.append({"source": source, "kind": kind or "dimension",
618
+ "name": d.group(1), "expr": d.group(2).split("#")[0].strip(),
619
+ "passthrough": False,
620
+ "file": str(f), "line": n})
621
+ return out
622
+
623
+
624
+ def model_definitions(model_path: pathlib.Path | None) -> dict[str, str]:
625
+ """Flat `name -> expr`, first declaration wins. The old contract, unchanged.
626
+
627
+ `stale_rubric_claims()` compares a rubric's claim against a name it has no
628
+ source for, so the flat view is what it needs; `parse_definitions()` is for
629
+ anything that must tell two same-named fields apart.
630
+ """
631
+ out: dict[str, str] = {}
632
+ for r in parse_definitions(model_path):
633
+ if r["expr"] is not None:
634
+ out.setdefault(r["name"], r["expr"])
447
635
  return out
448
636
 
449
637
 
@@ -602,6 +790,57 @@ def question_drift_findings(cases: list[dict[str, Any]]) -> list[str]:
602
790
  return out
603
791
 
604
792
 
793
+ # A rubric that sanctions an alternative reading, in the words sets actually
794
+ # use. Deliberately narrow: these are phrases that grant the ANSWER a choice,
795
+ # not any use of the word "or".
796
+ _ALLOWS_ALT = re.compile(
797
+ r"\b(either\b[^.]{0,80}\bor\b"
798
+ r"|or the \w+ is (?:fine|correct|acceptable|also correct)"
799
+ r"|is (?:also )?(?:fine|correct|acceptable)\b[^.]{0,40}\beither"
800
+ r"|any of (?:these|the following)"
801
+ r"|(?:is|are) (?:also )?acceptable)", re.I)
802
+
803
+
804
+ def rubric_alternative_findings(cases: list[dict[str, Any]]) -> list[str]:
805
+ """Rubrics that accept an alternative while `required` demands one id.
806
+
807
+ The two halves of an answer key are read by different things and can
808
+ disagree without anything noticing: the rubric is prose for the judge, and
809
+ `expectedEntities.required` is ids for retrieval scoring. When the rubric
810
+ says "either the full carrier name or the nickname is fine" and the
811
+ required list names only `dimension:carriers:name`, an answer that uses the
812
+ nickname is scored CORRECT by the judge and loses recall in the same run.
813
+
814
+ That is not a retrieval finding, it is the key contradicting itself, and it
815
+ was mistaken for a retrieval failure on a real run. `requiredAnyOf` is the
816
+ repair: a group is satisfied when any member is delivered.
817
+
818
+ REVIEW, not a hard finding. The phrasing is a heuristic over prose, a set
819
+ may legitimately allow an alternative that is not an entity choice ("either
820
+ rounding is fine"), and a false alarm here costs a glance while a missed
821
+ one costs a wrong attribution.
822
+ """
823
+ out = []
824
+ for case in cases:
825
+ g = case.get("golden") or {}
826
+ rubric = g.get("rubric") or ""
827
+ if not rubric or not _ALLOWS_ALT.search(rubric):
828
+ continue
829
+ exp = case.get("expectedEntities") or {}
830
+ if exp.get("requiredAnyOf"):
831
+ continue
832
+ if not exp.get("required"):
833
+ continue
834
+ m = _ALLOWS_ALT.search(rubric)
835
+ out.append(
836
+ f"review {case['qid']}: the rubric accepts an alternative "
837
+ f"({m.group(0)[:60].strip()!r}) but expectedEntities.required "
838
+ f"names one id per concept and no requiredAnyOf group. An answer "
839
+ f"taking the other reading scores correct and loses recall in the "
840
+ f"same run")
841
+ return out
842
+
843
+
605
844
  def unknown_name_findings(cases: list[dict[str, Any]], text: str) -> list[str]:
606
845
  """Set names that do not exist in the model under test.
607
846
 
@@ -635,9 +874,14 @@ def unknown_name_findings(cases: list[dict[str, Any]], text: str) -> list[str]:
635
874
  f"whatever the model holds. Fix: "
636
875
  f"measure:<source>:<name>, or dimension:/view:")
637
876
  elif not _id_named(e, text):
638
- out.append(f"{qid}: required entity {e} names nothing in the "
639
- f"model under test, so it can only ever score as a "
640
- f"retrieval miss. Fix the id, or make it a "
877
+ out.append(f"{qid}: required entity {e} does not appear in "
878
+ f"the model TEXT. Confirm with check_findable.py "
879
+ f"before deleting it: this is a grep and cannot see "
880
+ f"a column a source exposes implicitly. Measured, "
881
+ f"dimension:airports:own_type appears zero times in "
882
+ f"the .malloy and retrieves at relevance 1.0. If it "
883
+ f"is not retrievable either, it can only ever score "
884
+ f"as a retrieval miss; fix the id, or make it a "
641
885
  f"requiredAnyOf group naming both packages' ids")
642
886
  for g in exp.get("requiredAnyOf") or []:
643
887
  if isinstance(g, list) and g and not any(_id_named(e, text) for e in g):
@@ -686,7 +930,8 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
686
930
  target_package: str | None = None,
687
931
  cases_file: str = "cases.jsonl",
688
932
  quiet: bool = False, attest: str | None = None,
689
- verbose: bool = False) -> dict[str, Any]:
933
+ verbose: bool = False,
934
+ definitions: pathlib.Path | None = None) -> dict[str, Any]:
690
935
  """Run every check. Returns a summary; `drifted` is the count that should
691
936
  stop a run.
692
937
 
@@ -737,6 +982,34 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
737
982
  if skipped and chosen and not any(needs_value_check(c) for c in chosen):
738
983
  skipped = None
739
984
 
985
+ # The composition rule's second half. A truth server re-derives VALUES; a
986
+ # definition ledger validates the DEFINITIONS a value rests on. When every
987
+ # value-bearing case's tested definitions read `agrees` in the ledger and
988
+ # are not stale against --model, the absent server is not a check that
989
+ # failed to happen, and main() may exit 0 instead of 3. The value loop
990
+ # below still does not run -- there is nothing to run it against -- so this
991
+ # settles the exit code and the promotion basis, never a golden's value.
992
+ # Imported here rather than at the top: verify_definitions imports from
993
+ # this file, and the cycle is harmless once this module is fully loaded.
994
+ ledger_validated, unvalidated = False, []
995
+ if skipped and definitions:
996
+ from verify_definitions import (case_basis, load_ledger, # noqa: E402
997
+ stale_ids)
998
+ led = load_ledger(definitions)
999
+ stale = stale_ids(led, model)
1000
+ for c in chosen:
1001
+ if not needs_value_check(c):
1002
+ continue
1003
+ basis = case_basis(c, led, stale, set_dir)
1004
+ if basis not in ("independent", "definitions"):
1005
+ unvalidated.append(f"{c['qid']} ({basis})")
1006
+ ledger_validated = bool(led) and not unvalidated
1007
+ if not quiet:
1008
+ print(f" definition ledger {definitions}: "
1009
+ + ("every value-bearing case rests on validated definitions"
1010
+ if ledger_validated else
1011
+ f"{len(unvalidated)} case(s) rest on unvalidated definitions"))
1012
+
740
1013
  tally: dict[str, int] = {}
741
1014
  findings: list[str] = []
742
1015
  refreshed: list[str] = []
@@ -748,6 +1021,7 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
748
1021
  for c in chosen:
749
1022
  findings += rubric_number_findings(c)
750
1023
  findings += axis_findings(c, set_dir)
1024
+ findings += rubric_alternative_findings(chosen)
751
1025
  findings += stale_rubric_claims(chosen, model_definitions(model))
752
1026
  findings += unknown_name_findings(chosen, model_text(model))
753
1027
  findings += question_drift_findings(chosen)
@@ -778,6 +1052,26 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
778
1052
  g["verifiedBy"] = "authored_criteria"
779
1053
  promoted.append(c["qid"])
780
1054
 
1055
+ # Promotion through the ledger. `verified` here means two derivations of
1056
+ # the value agree (the second derivation, as always) AND every definition
1057
+ # those derivations used is validated. Nothing re-derived the value on a
1058
+ # truth server, and verifiedBy says so by naming the ledger. No --attest
1059
+ # path: an authored control plus a human vouching, with no second
1060
+ # derivation, is too thin to call verified.
1061
+ if promote and ledger_validated:
1062
+ for c in chosen:
1063
+ g = c["golden"]
1064
+ if not needs_value_check(c) or g.get("status") != "provisional":
1065
+ continue
1066
+ why = promotion_blocker(c, set_dir)
1067
+ if why:
1068
+ promotion_notes.append(f"{c['qid']}: not promoted, {why}")
1069
+ continue
1070
+ g["status"] = "verified"
1071
+ g["verifiedBy"] = (f"verify_goldens.py --promote via definition "
1072
+ f"ledger {definitions.name}")
1073
+ promoted.append(c["qid"])
1074
+
781
1075
  # `[] if skipped else chosen` rather than an `if` block: the guard belongs
782
1076
  # next to the one call that needs it, and wrapping would reindent the
783
1077
  # --refresh write for nothing.
@@ -913,7 +1207,8 @@ def verify(set_dir: pathlib.Path, publisher: str, environment: str,
913
1207
  # string so both existing predicates still read; `tally` is {} rather than
914
1208
  # {"skipped": N}, because check_value already returns a per-case status
915
1209
  # spelled "skipped" and one word may not mean two things in one dict.
916
- return {"skipped": skipped, "tally": tally, "drifted": drifted,
1210
+ return {"skipped": skipped, "ledgerValidated": ledger_validated,
1211
+ "unvalidated": unvalidated, "tally": tally, "drifted": drifted,
917
1212
  "findings": findings, "refreshed": refreshed,
918
1213
  "promoted": promoted, "attested": attested,
919
1214
  "promotionNotes": promotion_notes}
@@ -969,6 +1264,11 @@ def main() -> int:
969
1264
  ap.add_argument("--target-package",
970
1265
  help="the package under test, for the isolation guard. "
971
1266
  "Falls back to set.json's `targetPackage`")
1267
+ ap.add_argument("--definitions", type=pathlib.Path, default=None,
1268
+ help="a definition ledger (verify_definitions.py). With no "
1269
+ "truth server, a set whose every value-bearing case "
1270
+ "rests on validated definitions exits 0 instead of 3, "
1271
+ "and --promote may promote through it")
972
1272
  args = ap.parse_args()
973
1273
  if args.attest is not None and (not args.promote or not args.attest.strip()):
974
1274
  ap.error("--attest needs --promote and non-empty text naming who "
@@ -978,7 +1278,8 @@ def main() -> int:
978
1278
  qids=set(args.qid) if args.qid else None, model=args.model,
979
1279
  refresh=args.refresh, promote=args.promote,
980
1280
  target_package=args.target_package, cases_file=args.cases,
981
- attest=args.attest, verbose=args.verbose)
1281
+ attest=args.attest, verbose=args.verbose,
1282
+ definitions=args.definitions)
982
1283
  if r.get("skipped"):
983
1284
  print(f"\n{r['skipped']}")
984
1285
  # Drift and findings both fail: a golden that cannot be re-derived is a
@@ -1000,10 +1301,23 @@ def main() -> int:
1000
1301
  f"or gold/<qid>.json with verifyRows).", file=sys.stderr)
1001
1302
  return CANNOT_RUN
1002
1303
  if r.get("skipped"):
1304
+ if r.get("ledgerValidated"):
1305
+ print("No truth server, and none needed here: every value-bearing "
1306
+ "golden rests on definitions the ledger validates. Values were "
1307
+ "not re-derived; the definitions they rest on were.",
1308
+ file=sys.stderr)
1309
+ return 0
1310
+ if args.definitions:
1311
+ print("The definition ledger does not validate every case: "
1312
+ + "; ".join(r.get("unvalidated") or ["the ledger has no rows"])
1313
+ + ". Author or re-run controls for those definitions, or name "
1314
+ "a truthPackage in set.json.", file=sys.stderr)
1315
+ return CANNOT_RUN
1003
1316
  print("The audits above ran without a truth server. No golden was "
1004
1317
  "re-derived, so this says NOTHING about whether the goldens still "
1005
1318
  "hold; do not read it as a pass. Name a truthPackage in set.json "
1006
- "(init_truth_package.py scaffolds one).", file=sys.stderr)
1319
+ "(init_truth_package.py scaffolds one), or validate the "
1320
+ "definitions and pass --definitions.", file=sys.stderr)
1007
1321
  return CANNOT_RUN
1008
1322
  return 0
1009
1323