@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.
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +45 -5
- package/skills/eval-answer/reference/definition-ledger.md +305 -0
- package/skills/eval-answer/reference/ledger-schema.md +36 -7
- package/skills/eval-answer/scripts/check_coverage.py +12 -21
- package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
- package/skills/eval-answer/scripts/check_findable.py +386 -0
- package/skills/eval-answer/scripts/check_findable_test.py +326 -0
- package/skills/eval-answer/scripts/json_scan.py +43 -0
- package/skills/eval-answer/scripts/ledger.py +63 -7
- package/skills/eval-answer/scripts/ledger_test.py +7 -0
- package/skills/eval-answer/scripts/mcp_payload.py +111 -2
- package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
- package/skills/eval-answer/scripts/publisher_rest.py +10 -4
- package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
- package/skills/eval-answer/scripts/score_retrieval.py +326 -41
- package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
- package/skills/eval-answer/scripts/verify_definitions.py +752 -0
- package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
- package/skills/eval-answer/scripts/verify_goldens.py +336 -22
- package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
- package/skills/eval-diagnose/SKILL.md +47 -2
- package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
- package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
- package/skills/eval-diagnose/scripts/diagnose.py +471 -42
- package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
- package/skills/eval-import/SKILL.md +180 -1
- package/skills/eval-import/reference/case-format.md +1 -1
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-improve/scripts/improve.py +29 -3
- package/skills/eval-improve/scripts/improve_test.py +51 -0
- package/skills/eval-judge/SKILL.md +1 -1
- package/skills/eval-loop/SKILL.md +112 -3
- package/skills/eval-loop/reference/measurement.md +44 -4
- package/skills/eval-loop/reference/running-a-run.md +5 -1
- package/skills/eval-loop/scripts/agent_harness.py +21 -20
- package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
- package/skills/eval-loop/scripts/build_run_package.py +9 -3
- package/skills/eval-loop/scripts/flip_table.py +107 -2
- package/skills/eval-loop/scripts/flip_table_test.py +165 -0
- package/skills/eval-loop/scripts/run_baseline.py +956 -97
- package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
- package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
- package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
- package/skills/eval-report/SKILL.md +362 -0
- package/skills/malloy/SKILL.md +6 -7
- package/skills/malloy-analysis/SKILL.md +22 -45
- package/skills/malloy-analysis-pitfalls/SKILL.md +21 -6
- package/skills/malloy-analysis-report/SKILL.md +1 -1
- package/skills/malloy-analyze/SKILL.md +1 -1
- package/skills/malloy-charts/SKILL.md +2 -2
- package/skills/malloy-dashboards/SKILL.md +1 -1
- package/skills/malloy-debug/SKILL.md +1 -1
- package/skills/malloy-define/SKILL.md +1 -1
- package/skills/malloy-discover/SKILL.md +1 -1
- package/skills/malloy-document/SKILL.md +1 -1
- package/skills/malloy-getting-started/SKILL.md +1 -1
- package/skills/malloy-gotchas-modeling/SKILL.md +2 -2
- package/skills/malloy-gotchas-queries/SKILL.md +2 -2
- package/skills/malloy-gotchas-rendering/SKILL.md +2 -2
- package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
- package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
- package/skills/malloy-html-data-apps/SKILL.md +1 -1
- package/skills/malloy-lookml-review/SKILL.md +1 -1
- package/skills/malloy-materialization/SKILL.md +1 -1
- package/skills/malloy-materialization-tuning/SKILL.md +1 -1
- package/skills/malloy-model/SKILL.md +1 -1
- package/skills/malloy-model-as-you-go/SKILL.md +1 -1
- package/skills/malloy-modeling/SKILL.md +1 -1
- package/skills/malloy-notebook-chat/SKILL.md +1 -1
- package/skills/malloy-notebooks/SKILL.md +1 -1
- package/skills/malloy-patterns/SKILL.md +2 -2
- package/skills/malloy-phrase-detection/SKILL.md +12 -7
- package/skills/malloy-publish/SKILL.md +1 -1
- package/skills/malloy-queries/SKILL.md +2 -2
- package/skills/malloy-review/SKILL.md +1 -1
- 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
|
-
|
|
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(
|
|
144
|
-
return abs(
|
|
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
|
-
|
|
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{
|
|
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 (
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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}
|
|
639
|
-
f"model
|
|
640
|
-
f"
|
|
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
|
|
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, "
|
|
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)
|
|
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
|
|