@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
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a model's DEFINITIONS once, so answer keys need not be re-derived per case.
3
+
4
+ WHY
5
+
6
+ The loop's standing rule is that a golden must never be derived through the
7
+ model under test, because a model bug would certify its own golden. That rule
8
+ is sound and it does not scale: on a model with real transformation, deriving
9
+ every key independently means reimplementing the model, and you end up with two
10
+ implementations and no reason to trust either.
11
+
12
+ This is the other half. A definition is checked ONCE, against the layer directly
13
+ beneath it; every case that depends on it inherits the result. Work then scales
14
+ with the number of definitions rather than the number of cases, and a deep model
15
+ is CHEAPER per case than a shallow one, because deep chains share their lower
16
+ layers. Measured on a 548-definition customer model: the twenty deepest measures
17
+ close over 812 definition-checks without reuse and 116 with it.
18
+
19
+ WHAT IT CHECKS, AND WHAT IT CANNOT
20
+
21
+ `within_model`, the only kind this script runs today: ask the model for the
22
+ measure and for the expression it CLAIMS to be, in one query, and compare.
23
+
24
+ run: order_items -> {
25
+ aggregate: stated is total_sales, control is sale_price.sum()
26
+ }
27
+
28
+ That catches a measure that is not what it says -- a filter nobody mentioned, a
29
+ renamed column, the wrong aggregate. It does NOT catch a bad source: a join that
30
+ fans out inflates both sides equally, and the comparison stays green. Measures
31
+ that cross a join therefore record `unchecked` with `needs: raw`, never `agrees`.
32
+ Reporting them as validated would be this tool committing the exact error it
33
+ exists to find.
34
+
35
+ `raw` is an AUTHORED control. The plan for this file once said raw checks would
36
+ "catch a definition wrong about the business, like the cogs case". They cannot:
37
+ re-deriving the definition's own expression over the base tables computes the
38
+ same quantity, cancelled lines and all, and agrees. What caught the cogs case
39
+ was a person who read the `status` doc and wrote down what the population
40
+ should be. So a raw check is a control query the conductor authors into the
41
+ ledger record -- `check.query`, a `run:` returning one row with a `control`
42
+ column -- and this file re-runs and compares it every time, against the model
43
+ by default (`check.against: model`) or against a truth package of raw tables
44
+ (`against: truth`, which needs --truth-publisher). A control may be authored on
45
+ any measure, not only a raw one, and it supersedes the within-model check: that
46
+ check can say a measure is what its expression says, a person can say what its
47
+ population should be. A raw record with no authored query stays `unchecked`,
48
+ and `needs` says so. That is "validate what
49
+ someone asserted", not "find business-wrong definitions unaided", and the
50
+ difference is the whole point.
51
+
52
+ Build once, then maintain. `--out` writes a fresh ledger. `--ledger` reads an
53
+ existing one, keeps every field a person wrote (`check.query`, `check.against`,
54
+ `check.note`, `cause`), re-parses the model, re-runs every check, and writes it
55
+ back, so an authored control survives every rebuild and a definition that moved
56
+ gets a fresh verdict rather than a stale `agrees`.
57
+
58
+ EXIT CODES, matching `verify_goldens.py`
59
+
60
+ 0 every check that could run ran, and agreed
61
+ 1 a definition disagrees with its own stated expression
62
+ 3 could not run: no model, no server, or nothing checkable
63
+
64
+ 3 is load-bearing. A caller must treat anything outside {0, 1} as "did not run"
65
+ and must never read it as a pass.
66
+
67
+ USAGE
68
+
69
+ python3 verify_definitions.py --model <file-or-dir> --out <ledger.jsonl>
70
+ python3 verify_definitions.py --model m.malloy --publisher http://localhost:4811 \\
71
+ --environment samples --package ecommerce --out evals/definitions/ecommerce.jsonl
72
+ # after authoring check.query on the raw records:
73
+ python3 verify_definitions.py --model m.malloy --ledger evals/definitions/ecommerce.jsonl \\
74
+ --publisher http://localhost:4811 --package ecommerce --model-path ecommerce.malloy
75
+ """
76
+ from __future__ import annotations
77
+
78
+ import argparse
79
+ import hashlib
80
+ import json
81
+ import pathlib
82
+ import re
83
+ import sys
84
+ import traceback
85
+ from typing import Any
86
+
87
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
88
+
89
+ from mcp_payload import entity_id # noqa: E402
90
+ from publisher_rest import try_query # noqa: E402
91
+ from verify_goldens import has_second_derivation, parse_definitions # noqa: E402
92
+
93
+ CANNOT_RUN = 3
94
+
95
+
96
+ class Collision(Exception):
97
+ """Two definitions that the ledger's own id cannot tell apart.
98
+
99
+ Its own exception rather than a `SystemExit` so `records()` stays callable
100
+ from a test and from `stale_ids()` without either having to catch an exit.
101
+ `main()` turns it into CANNOT_RUN, because a ledger that silently dropped
102
+ one of the two is the thing this refuses to produce.
103
+ """
104
+
105
+
106
+ # Kinds whose correctness is a question at all. A `view` composes measures that
107
+ # are checked on their own, and a `join` is structure rather than a value.
108
+ CHECKABLE_KINDS = ("measure", "dimension")
109
+
110
+ WORD = re.compile(r"[A-Za-z_]\w*")
111
+ # A WHOLE dotted path, however many hops: the one place a word in an expression
112
+ # refers to a field of a source OTHER than the one declaring it. Matched entire
113
+ # rather than pair by pair, because `re.findall` does not overlap: a pairwise
114
+ # `(\w+)\.(\w+)` reads `a.b.c` as one match `(a, b)` and never sees `c`, so the
115
+ # dependency at the END of a two-join path -- the one the chain exists to
116
+ # track -- was silently dropped.
117
+ PATH = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+")
118
+ # Aggregates that survive uniform duplication, so fanout does not move them.
119
+ # `verify_goldens`' own fanout note lists the same four.
120
+ FANOUT_SAFE = ("avg(", "stddev(", "min(", "max(")
121
+ # What a person writes into a record and a rebuild must never lose. Everything
122
+ # else on the record is recomputed from the model.
123
+ AUTHORED_CHECK_FIELDS = ("query", "against", "note")
124
+ AUTHORED_FIELDS = ("cause",)
125
+ # Rows sampled for a dimension comparison. A wrong dimension shows on a slice,
126
+ # and the slice is recorded so nobody reads it as a whole-table proof.
127
+ DIM_SLICE = 200
128
+
129
+
130
+ def sha256_text(text: str) -> str:
131
+ return hashlib.sha256(text.encode()).hexdigest()
132
+
133
+
134
+ def expr_sha(expr: str, dep_shas: list[str]) -> str:
135
+ """Content hash of a definition AND everything beneath it.
136
+
137
+ A Merkle chain, not a flat hash of the one line. A measure whose own text
138
+ never changed is still stale when a definition it builds on moves, and a
139
+ flat hash reports it as current -- which is how a ledger goes quietly wrong.
140
+ `packageSha` exists but is whole-package, so it invalidates everything on
141
+ any edit and can distinguish nothing.
142
+ """
143
+ return sha256_text(expr + "|" + "|".join(sorted(dep_shas)))
144
+
145
+
146
+ def incomplete(expr: str) -> bool:
147
+ """Whether the captured expression is only the first line of a longer one.
148
+
149
+ `parse_definitions` reads line by line, so `full_name is concat(` comes back
150
+ truncated at the paren. Interpolating that into a check query builds
151
+ something that cannot compile, and the failure would read as "the server
152
+ rejected it" rather than "this tool never had the whole definition".
153
+ Unbalanced delimiters are the cheap, reliable tell.
154
+ """
155
+ return (expr.count("(") != expr.count(")")
156
+ or expr.count("{") != expr.count("}")
157
+ or expr.count("[") != expr.count("]"))
158
+
159
+
160
+ def needs_raw(expr: str, joins: set[str]) -> str | None:
161
+ """Why a within-model check would be blind here, or None if it would not.
162
+
163
+ The within-model check compares a measure against its own stated expression
164
+ THROUGH the model, so anything that corrupts both sides equally is invisible
165
+ to it. A join is the case that matters: fanout multiplies the stated measure
166
+ and the control expression identically.
167
+
168
+ Measures only. A dimension is compared row by row and a duplicating join
169
+ duplicates both sides alike, so fanout cannot hide a difference there; the
170
+ caller applies this to measures and passes dimensions through.
171
+
172
+ Detection needs the join NAMES, not a dot. `sale_price.sum()` is a method
173
+ call on a column in this very source and is perfectly checkable;
174
+ `inventory_items.cost.sum()` traverses a join and is not. A first pass
175
+ matched any `word.word` and held back `total_sales` -- the measure twelve
176
+ of the set's cases depend on -- for a join it does not cross.
177
+ """
178
+ hops = {m.split(".", 1)[0] for m in re.findall(r"\b\w+\.\w+", expr)}
179
+ crossed = sorted(hops & joins)
180
+ if crossed and not any(f in expr.lower() for f in FANOUT_SAFE):
181
+ return (f"reaches through {', '.join(crossed)}, so fanout would inflate "
182
+ f"both sides equally")
183
+ return None
184
+
185
+
186
+ def records(model: pathlib.Path, recursive: bool,
187
+ existing: dict[str, dict[str, Any]] | None = None
188
+ ) -> list[dict[str, Any]]:
189
+ """The ledger's rows, built from the model text and linked by `depends`.
190
+
191
+ `existing` is a prior ledger keyed by entityId. Fields a person wrote on it
192
+ are carried onto the rebuilt row; nothing else is, so a verdict is never
193
+ inherited from a definition that has since moved. A row whose definition
194
+ the model no longer declares is dropped, authored fields and all -- a
195
+ control for a measure that no longer exists is not a validated measure.
196
+ """
197
+ parsed = parse_definitions(model, recursive=recursive)
198
+ # Keyed on (source, name) for the reason `parse_definitions` gives: a flat
199
+ # `dict[name]` resolves a same-named field in two sources to whichever was
200
+ # read first, so a model declaring `total_sales` on two sources links one
201
+ # of them to the other's definition and hashes it into the wrong chain.
202
+ by_key: dict[tuple[str, str], dict[str, Any]] = {}
203
+ for r in parsed:
204
+ if r.get("expr") is not None:
205
+ by_key.setdefault((r["source"], r["name"]), r)
206
+ # Anything an expression can traverse INTO: a declared join, or a source
207
+ # name used as one. Both make the within-model comparison fanout-blind.
208
+ joins = ({r["name"] for r in parsed if r["kind"] == "join"}
209
+ | {r["source"] for r in parsed if r["source"]})
210
+ sources = {r["source"] for r in parsed if r["source"]}
211
+ # A join's ALIAS is not its target source: `join_one: destination is
212
+ # airports with destination_code` declares `destination`, and
213
+ # `destination.airport_count` is a field of `airports`. Resolving the
214
+ # alias as if it were a source name loses that edge entirely -- measured
215
+ # on the faa model, `flights.destination_count` came back depending on
216
+ # nothing. `parse_definitions` records the join's expr as
217
+ # "airports with destination_code", so the target is its first word. A
218
+ # join written without `is` (`join_one: carriers with carrier`) has alias
219
+ # and target already equal and needs no entry.
220
+ join_target = {(r["source"], r["name"]): r["expr"].split()[0]
221
+ for r in parsed
222
+ if r["kind"] == "join" and (r.get("expr") or "").split()}
223
+
224
+ def deps_of(rec: dict[str, Any]) -> list[tuple[str, str]]:
225
+ """What this expression builds on, resolved in the source that owns it.
226
+
227
+ A bare word in a Malloy expression names a field of the DECLARING
228
+ source, so it resolves there and nowhere else. Resolving bare words
229
+ globally is what produced a phantom edge from `source_b.doubled` to
230
+ `source_a.total_sales` on a model where only `source_a` declares the
231
+ name, and on faa pointed `airports.name` at a `code` belonging to
232
+ `carriers`.
233
+
234
+ A dotted path is the one place a word refers to another source's
235
+ field. It is walked segment by segment, each segment resolved in the
236
+ source the walk has reached so far: a join alias moves the walk to the
237
+ source that join targets, and every segment that names a definition on
238
+ the way is itself a dependency, because a join's `on` clause decides
239
+ which rows the segments after it see. Walking rather than reading the
240
+ first pair is what makes `a.b.c` reach `c`.
241
+ """
242
+ src, expr = rec["source"], rec["expr"]
243
+ found: set[tuple[str, str]] = set()
244
+ for path in PATH.findall(expr):
245
+ here: str | None = src
246
+ for seg in path.split("."):
247
+ if here is None:
248
+ break
249
+ if (here, seg) in by_key:
250
+ found.add((here, seg))
251
+ # Where the next segment resolves: the source this join
252
+ # targets, or the segment itself when it names a source
253
+ # directly. Anything else ends the walk -- a raw column or a
254
+ # method call (`sale_price.sum()`) has nothing to traverse.
255
+ here = join_target.get((here, seg),
256
+ seg if seg in sources else None)
257
+ # Whole paths are blanked before the bare scan, so a segment of
258
+ # `source_a.total_sales` is not ALSO read as this source's own
259
+ # `total_sales` when both declare that name.
260
+ bare = PATH.sub(" ", expr)
261
+ found |= {(src, w) for w in WORD.findall(bare) if (src, w) in by_key}
262
+ found.discard((src, rec["name"]))
263
+ return sorted(found)
264
+
265
+ # Depth-first so a dependency's sha exists before its dependants'.
266
+ shas: dict[tuple[str, str], str] = {}
267
+
268
+ def sha_of(key: tuple[str, str], stack: frozenset = frozenset()) -> str:
269
+ if key in shas:
270
+ return shas[key]
271
+ if key in stack: # cycle: hash the text alone
272
+ return sha256_text(by_key[key]["expr"])
273
+ rec = by_key[key]
274
+ v = expr_sha(rec["expr"],
275
+ [sha_of(d, stack | {key}) for d in deps_of(rec)])
276
+ shas[key] = v
277
+ return v
278
+
279
+ out = []
280
+ for r in parsed:
281
+ if r["kind"] not in CHECKABLE_KINDS:
282
+ continue
283
+ if not r["source"]:
284
+ # A field the scanner found outside any `source:` block -- an
285
+ # aggregate inside a `run:` query, which `sample_queries.malloy`
286
+ # in bigquery-ga4 has seventeen of. It is query-local, not a model
287
+ # entity: no case can require it, because a required id is
288
+ # `kind:source:name` and there is no source to name. Kept out of
289
+ # the ledger rather than given the two-part id `entity_id` mints
290
+ # for a null source, which `check_findable` reports as malformed.
291
+ continue
292
+ if r.get("passthrough"):
293
+ # A raw column exposed as-is. There is no expression that could be
294
+ # wrong, so `no_definition` rather than `agrees`: nothing was
295
+ # checked, and nothing needed to be. It is in the ledger so that a
296
+ # case naming it is distinguishable from a case naming a definition
297
+ # nobody got to.
298
+ out.append({
299
+ "entityId": entity_id("dimension", r["source"], r["name"]),
300
+ "kind": "dimension", "source": r["source"], "name": r["name"],
301
+ "expr": None, "exprSha": None, "depends": [],
302
+ "check": {"kind": "passthrough"}, "verdict": "no_definition",
303
+ "needs": None, "file": r["file"], "line": r["line"]})
304
+ continue
305
+ deps = deps_of(r)
306
+ # Fanout inflates a SUM; it does not corrupt a dimension. A dimension is
307
+ # compared row by row, and a join that duplicates rows duplicates both
308
+ # sides identically, so the comparison still holds. Holding dimensions
309
+ # back for fanout was wrong reasoning: it left `name` and `job` -- the
310
+ # two a "top director" question turns on -- permanently unvalidated for
311
+ # a risk that does not apply to them.
312
+ why = ("spans more than one line, so only its first line was read"
313
+ if incomplete(r["expr"])
314
+ else needs_raw(r["expr"], joins - {r["source"]})
315
+ if r["kind"] == "measure" else None)
316
+ out.append({
317
+ "entityId": entity_id(r["kind"], r["source"], r["name"]),
318
+ "kind": r["kind"],
319
+ "source": r["source"],
320
+ "name": r["name"],
321
+ "expr": r["expr"],
322
+ "exprSha": sha_of((r["source"], r["name"])),
323
+ "depends": [entity_id(by_key[d]["kind"], d[0], d[1])
324
+ for d in deps],
325
+ "check": {"kind": "unreadable" if incomplete(r["expr"])
326
+ else "raw" if why else "within_model"},
327
+ "verdict": "unchecked",
328
+ "needs": why,
329
+ "file": r["file"],
330
+ "line": r["line"],
331
+ })
332
+ # An entityId must address exactly one definition, because everything
333
+ # downstream keys on it: `load_ledger` and `stale_ids` both build
334
+ # `{entityId: record}`, so two definitions sharing an id means one is never
335
+ # staleness-checked and an authored control written for one silently
336
+ # re-runs against the other. Two FILES declaring the same source name is
337
+ # all it takes, and real packages do it -- `auto_recalls` lost 12 of its 24
338
+ # rows this way, `faa` 1 of 36, because `airports.malloy` and
339
+ # `flights.malloy` both declare `source: airports`. The id format is a
340
+ # contract shared with a case's `required` ids, so it cannot grow a file
341
+ # segment; the honest outcome is a refusal naming the files, not a ledger
342
+ # quietly missing half its rows.
343
+ seen: dict[str, dict[str, Any]] = {}
344
+ clashes: list[str] = []
345
+ for rec in out:
346
+ prior = seen.get(rec["entityId"])
347
+ if prior is None:
348
+ seen[rec["entityId"]] = rec
349
+ continue
350
+ clashes.append(
351
+ f" {rec['entityId']}\n"
352
+ f" {prior['file']}:{prior['line']}\n"
353
+ f" {rec['file']}:{rec['line']}")
354
+ if clashes:
355
+ raise Collision(
356
+ f"{len(clashes)} definition(s) share an entityId with another, so "
357
+ f"the ledger cannot address them apart:\n" + "\n".join(clashes) +
358
+ "\n\nAn entityId is `kind:source:name`, so two files declaring the "
359
+ "same source name collide. Point --model at one file, or rename "
360
+ "one of the sources. Nothing was written.")
361
+ if existing:
362
+ for rec in out:
363
+ prior = existing.get(rec["entityId"]) or {}
364
+ for k in AUTHORED_CHECK_FIELDS:
365
+ if k in (prior.get("check") or {}):
366
+ rec["check"][k] = prior["check"][k]
367
+ for k in AUTHORED_FIELDS:
368
+ if k in prior:
369
+ rec[k] = prior[k]
370
+ return out
371
+
372
+
373
+ def stated_query(rec: dict[str, Any]) -> str:
374
+ """The measure's own value through the model, on its own. A control query
375
+ is the other side, written by a person, and may run elsewhere."""
376
+ return (f"run: {rec['source']} -> {{\n"
377
+ f" aggregate: stated is {rec['name']}\n"
378
+ f"}}")
379
+
380
+
381
+ def within_model_query(rec: dict[str, Any]) -> str:
382
+ """One query asking for the entity and its own stated expression together.
383
+
384
+ Both in one query against one source, so they see identical rows: a
385
+ difference is the definition, not the population.
386
+
387
+ A measure aggregates; a dimension is scalar and `aggregate:` rejects it
388
+ outright ("Cannot use a scalar field in an aggregate"), so a dimension is
389
+ compared row by row over a slice instead. The slice is the point rather
390
+ than a shortcut: a wrong dimension differs on the first rows that reach it.
391
+ """
392
+ if rec["kind"] == "measure":
393
+ return (f"run: {rec['source']} -> {{\n"
394
+ f" aggregate:\n"
395
+ f" stated is {rec['name']}\n"
396
+ f" control is {rec['expr']}\n"
397
+ f"}}")
398
+ return (f"run: {rec['source']} -> {{\n"
399
+ f" select:\n"
400
+ f" stated is {rec['name']}\n"
401
+ f" control is {rec['expr']}\n"
402
+ f" limit: {DIM_SLICE}\n"
403
+ f"}}")
404
+
405
+
406
+ def close_enough(a: Any, b: Any) -> bool:
407
+ """Floats from two aggregation orders differ in the last places; that is
408
+ association, not disagreement. `verify_goldens` tolerates the same."""
409
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
410
+ if a == b:
411
+ return True
412
+ scale = max(abs(a), abs(b), 1.0)
413
+ return abs(a - b) / scale < 1e-9
414
+ return a == b
415
+
416
+
417
+ def run_authored(rec: dict[str, Any], a: argparse.Namespace) -> tuple[str, str]:
418
+ """(verdict, detail) for a raw record: the measure through the model against
419
+ the control a person wrote. Two queries, deliberately -- the control is
420
+ supposed to differ in population, and may run on another server."""
421
+ q = (rec["check"].get("query") or "").strip()
422
+ if not q:
423
+ return "unchecked", ((rec.get("needs") or "reaches through a join")
424
+ + "; no authored control yet (check.query)")
425
+ if rec["kind"] != "measure":
426
+ return "unchecked", ("an authored control compares one aggregate; this "
427
+ "is a dimension")
428
+ against = rec["check"].get("against") or "model"
429
+ if against == "model":
430
+ target = (a.publisher, a.environment, a.package, a.model_path)
431
+ elif against == "truth":
432
+ if not getattr(a, "truth_publisher", None):
433
+ return "unchecked", ("check.against is truth but no --truth-publisher "
434
+ "was given, so the control could not run")
435
+ target = (a.truth_publisher, a.truth_environment or a.environment,
436
+ a.truth_package, a.truth_model)
437
+ else:
438
+ return "unchecked", f"check.against is {against!r}; expected model or truth"
439
+ stated_rows, err = try_query(a.publisher, a.environment, a.package,
440
+ a.model_path, stated_query(rec))
441
+ if err:
442
+ return "unchecked", f"stated query failed: {err[:120]}"
443
+ control_rows, err = try_query(*target, q)
444
+ if err:
445
+ return "unchecked", f"control query failed: {err[:120]}"
446
+ if not stated_rows or not control_rows:
447
+ return "unchecked", "a side returned no rows"
448
+ if "control" not in control_rows[0]:
449
+ return "unchecked", ("the control query must return a column named "
450
+ "`control`; got " + ", ".join(control_rows[0]))
451
+ stated, control = stated_rows[0].get("stated"), control_rows[0]["control"]
452
+ rec["check"]["against"] = against
453
+ if close_enough(stated, control):
454
+ return "agrees", (f"stated={stated} control={control} (authored control, "
455
+ f"against {against})")
456
+ return "disagrees", (f"stated={stated} but the authored control gives "
457
+ f"{control} (against {against}) -- "
458
+ f"{rec['check'].get('note') or 'see check.query'}")
459
+
460
+
461
+ def run_check(rec: dict[str, Any], a: argparse.Namespace) -> tuple[str, str]:
462
+ """(verdict, detail) for one definition.
463
+
464
+ An authored control wins on ANY measure record, not only a raw one. The
465
+ within-model check can only say a measure is what its expression says; a
466
+ person can say what its population should be, and `total_sales is
467
+ sale_price.sum()` -- which agrees with itself while including the cancelled
468
+ lines its own docs exclude -- is exactly the case where the second question
469
+ matters more than the first.
470
+ """
471
+ if (rec["check"].get("query") or "").strip() and rec["kind"] == "measure":
472
+ return run_authored(rec, a)
473
+ if rec["check"]["kind"] == "passthrough":
474
+ return "no_definition", "a raw column passed through; no expression to check"
475
+ if rec["check"]["kind"] == "raw":
476
+ return run_authored(rec, a)
477
+ if rec["check"]["kind"] != "within_model":
478
+ return "unchecked", rec["needs"] or "not a within-model check"
479
+ if not rec["source"]:
480
+ return "unchecked", "no enclosing source, so there is nothing to query"
481
+ q = within_model_query(rec)
482
+ rows, err = try_query(a.publisher, a.environment, a.package, a.model_path, q)
483
+ if err:
484
+ return "unchecked", f"query failed: {err[:120]}"
485
+ if not rows:
486
+ return "unchecked", "query returned no rows"
487
+ for n, row in enumerate(rows, 1):
488
+ stated, control = row.get("stated"), row.get("control")
489
+ if stated is None and control is None:
490
+ continue
491
+ if not close_enough(stated, control):
492
+ where = f" (row {n} of {len(rows)})" if len(rows) > 1 else ""
493
+ return "disagrees", (f"stated={stated} but its own expression gives "
494
+ f"{control}{where} -- {rec['name']} is not "
495
+ f"{rec['expr']}")
496
+ if all(r.get("stated") is None and r.get("control") is None for r in rows):
497
+ return "unchecked", "every sampled row was null on both sides"
498
+ if len(rows) > 1:
499
+ rec["check"]["slice"] = f"first {len(rows)} rows"
500
+ return "agrees", f"{len(rows)} rows match"
501
+ return "agrees", f"stated={rows[0].get('stated')} control={rows[0].get('control')}"
502
+
503
+
504
+ def verify(model: pathlib.Path, a: argparse.Namespace,
505
+ existing: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
506
+ recs = records(model, a.recursive, existing)
507
+ if not recs:
508
+ return {"records": [], "findings": [], "cannotRun":
509
+ "no measure or dimension definitions found in the model"}
510
+ findings: list[str] = []
511
+ if a.publisher:
512
+ for r in recs:
513
+ verdict, detail = run_check(r, a)
514
+ r["verdict"], r["detail"] = verdict, detail
515
+ if verdict == "disagrees":
516
+ findings.append(f"{r['entityId']}: {detail}")
517
+ if not a.quiet:
518
+ print(f" {verdict.upper():10s} {r['entityId']:52s} {detail[:70]}")
519
+ return {"records": recs, "findings": findings, "cannotRun": None}
520
+
521
+
522
+ def summarise(recs: list[dict[str, Any]]) -> list[str]:
523
+ tally: dict[str, int] = {}
524
+ for r in recs:
525
+ tally[r["verdict"]] = tally.get(r["verdict"], 0) + 1
526
+ raw = [r for r in recs if r["check"]["kind"] == "raw"]
527
+ authored = sum(1 for r in raw if (r["check"].get("query") or "").strip())
528
+ lines = [f"{len(recs)} definition(s): "
529
+ + ", ".join(f"{n} {k}" for k, n in sorted(tally.items()))]
530
+ if raw:
531
+ lines.append(f"{len(raw)} reach through a join: {authored} carry an "
532
+ f"authored control, {len(raw) - authored} do not and are "
533
+ f"NOT validated by this run")
534
+ return lines
535
+
536
+
537
+ # ------------------------------------------------- reading a ledger back
538
+
539
+ # Kinds that hold no value, so there is nothing a model bug could get wrong.
540
+ VALUE_FREE_KINDS = ("criteria", "unanswerable")
541
+
542
+
543
+ def load_ledger(path: pathlib.Path | None) -> dict[str, dict[str, Any]]:
544
+ """`entityId -> record`, or empty when there is no ledger."""
545
+ if not path or not path.exists():
546
+ return {}
547
+ out = {}
548
+ for line in path.read_text().splitlines():
549
+ if line.strip():
550
+ r = json.loads(line)
551
+ out[r["entityId"]] = r
552
+ return out
553
+
554
+
555
+ def stale_ids(ledger: dict[str, dict[str, Any]], model: pathlib.Path | None,
556
+ recursive: bool = False) -> set[str]:
557
+ """Ledger rows whose definition has moved since they were checked.
558
+
559
+ Pure hash comparison, no queries, so this is cheap enough to run before
560
+ every arm. An id the model no longer declares counts as stale too: a
561
+ definition that was renamed away is not a validated definition.
562
+ """
563
+ if not model:
564
+ return set()
565
+ current = {r["entityId"]: r["exprSha"] for r in records(model, recursive)}
566
+ return {eid for eid, rec in ledger.items()
567
+ if current.get(eid) != rec.get("exprSha")}
568
+
569
+
570
+ def tested_groups(case: dict[str, Any]) -> list[list[str]]:
571
+ """The definitions a case depends on, as GROUPS satisfied by any member.
572
+
573
+ Read from `expectedEntities`, which already names entities in the same
574
+ `kind:source:name` form the ledger keys on, rather than from a new
575
+ hand-maintained field. A wrong id here is the failure mode that cost a real
576
+ set two days, so this reuses a link the set already maintains and that
577
+ `verify_goldens` check 5 already audits against the model.
578
+
579
+ Grouped, the same way `score_retrieval.groups()` does it. A first version
580
+ flattened `requiredAnyOf` and demanded every member, which defeats the
581
+ point of the group: a case answerable through `startYear` OR the model's
582
+ `production_year` alias was held unvalidated because only one of the two is
583
+ a checkable definition.
584
+ """
585
+ exp = case.get("expectedEntities") or {}
586
+ out = [[r] for r in (exp.get("required") or [])]
587
+ for group in exp.get("requiredAnyOf") or []:
588
+ if group:
589
+ out.append(list(group))
590
+ return out
591
+
592
+
593
+ def tested_ids(case: dict[str, Any]) -> list[str]:
594
+ """Every id a case names, flattened. For reporting, never for deciding."""
595
+ return [e for g in tested_groups(case) for e in g]
596
+
597
+
598
+ def case_basis(case: dict[str, Any], ledger: dict[str, dict[str, Any]],
599
+ stale: set[str], set_dir: pathlib.Path | None = None) -> str:
600
+ """What this case's verdict rests on: independent, definitions, unchecked
601
+ or disagrees.
602
+
603
+ The composition rule, applied per case: a golden is trustworthy if it was
604
+ derived independently, OR if every definition it tests has been validated.
605
+ Anything else is `unchecked` -- a statement about the EVIDENCE, not about
606
+ the answer, and never to be read as a failing case.
607
+
608
+ Independence is read from `golden.verification` (or `gold/<qid>.json`) via
609
+ `verify_goldens.has_second_derivation`, which is the structured record of
610
+ "two differently shaped derivations agree". NOT from `verifiedBy`: that is
611
+ free text, and a first pass matched it by prefix and classified 34 goldens
612
+ of the ecommerce set as unchecked when every one of them says "authored and
613
+ re-derived against ecommerce-truth". A well-founded set reading as
614
+ unvalidated is the same over-claim as an unfounded one reading as validated,
615
+ pointed the other way.
616
+ """
617
+ g = case.get("golden") or {}
618
+ if g.get("kind") in VALUE_FREE_KINDS:
619
+ return "independent"
620
+ if set_dir is not None and has_second_derivation(case, set_dir):
621
+ return "independent"
622
+ groups = tested_groups(case)
623
+ if not groups:
624
+ return "unchecked"
625
+
626
+ def ok(eid: str) -> bool:
627
+ rec = ledger.get(eid)
628
+ # `no_definition` is a passthrough column: raw data with no expression
629
+ # that could be wrong. Nothing validated it because nothing had to.
630
+ return (rec is not None and eid not in stale
631
+ and rec.get("verdict") in ("agrees", "no_definition"))
632
+
633
+ if any((ledger.get(e) or {}).get("verdict") == "disagrees"
634
+ for g in groups for e in g):
635
+ return "disagrees"
636
+ return "definitions" if all(any(ok(e) for e in g) for g in groups) else "unchecked"
637
+
638
+
639
+ def evidence_basis(cases: list[dict[str, Any]],
640
+ ledger: dict[str, dict[str, Any]],
641
+ stale: set[str],
642
+ set_dir: pathlib.Path | None = None) -> dict[str, Any]:
643
+ """Counts per basis, plus the ids worth naming. Pure, so tests can pin it."""
644
+ counts: dict[str, int] = {}
645
+ disagreeing: set[str] = set()
646
+ for c in cases:
647
+ b = case_basis(c, ledger, stale, set_dir)
648
+ counts[b] = counts.get(b, 0) + 1
649
+ # Collected for EVERY case, not only those whose basis is "disagrees".
650
+ # A golden derived from raw tables is trustworthy however wrong the
651
+ # model's own definition is -- and that the definition is wrong is
652
+ # exactly the finding the run exists to surface. On the ecommerce set
653
+ # every golden is independent, `total_sales` disagrees with its own
654
+ # docs once a control is authored, and without this the run would have
655
+ # said nothing about it.
656
+ disagreeing |= {e for e in tested_ids(c)
657
+ if (ledger.get(e) or {}).get("verdict") == "disagrees"}
658
+ return {"counts": counts, "disagreeing": sorted(disagreeing),
659
+ "stale": sorted(stale), "ledgerSize": len(ledger)}
660
+
661
+
662
+ def main(argv: list[str] | None = None) -> int:
663
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
664
+ ap.add_argument("--model", required=True, help="a .malloy file or a package directory")
665
+ ap.add_argument("--out", default=None, help="write a fresh ledger here (JSONL)")
666
+ ap.add_argument("--ledger", default=None,
667
+ help="an existing ledger to read, re-check and write back, "
668
+ "keeping every field a person authored on it")
669
+ ap.add_argument("--truth-publisher", dest="truth_publisher", default=None,
670
+ help="server for controls with check.against: truth")
671
+ ap.add_argument("--truth-environment", dest="truth_environment", default=None)
672
+ ap.add_argument("--truth-package", dest="truth_package", default=None)
673
+ ap.add_argument("--truth-model", dest="truth_model", default="truth.malloy")
674
+ ap.add_argument("--publisher", default=None,
675
+ help="server to run the checks against; without it the "
676
+ "ledger is built and nothing is checked")
677
+ ap.add_argument("--environment", default="samples")
678
+ ap.add_argument("--package", default=None)
679
+ ap.add_argument("--model-path", dest="model_path", default=None,
680
+ help="model path within the package, for the query endpoint")
681
+ ap.add_argument("--recursive", action="store_true",
682
+ help="read .malloy files in subdirectories too")
683
+ ap.add_argument("--quiet", action="store_true")
684
+ a = ap.parse_args(argv)
685
+
686
+ model = pathlib.Path(a.model)
687
+ if not model.exists():
688
+ print(f"--model {a.model} does not exist", file=sys.stderr)
689
+ return CANNOT_RUN
690
+ if a.publisher and not (a.package and a.model_path):
691
+ print("--publisher needs --package and --model-path to address a query",
692
+ file=sys.stderr)
693
+ return CANNOT_RUN
694
+ if a.truth_publisher and not a.truth_package:
695
+ print("--truth-publisher needs --truth-package", file=sys.stderr)
696
+ return CANNOT_RUN
697
+ existing = None
698
+ if a.ledger:
699
+ led = pathlib.Path(a.ledger)
700
+ if not led.exists():
701
+ print(f"--ledger {a.ledger} does not exist; build one with --out first",
702
+ file=sys.stderr)
703
+ return CANNOT_RUN
704
+ existing = load_ledger(led)
705
+
706
+ r = verify(model, a, existing)
707
+ if r["cannotRun"]:
708
+ print(r["cannotRun"], file=sys.stderr)
709
+ return CANNOT_RUN
710
+
711
+ # --ledger writes back where it read from unless --out says otherwise.
712
+ if a.out or a.ledger:
713
+ out = pathlib.Path(a.out or a.ledger)
714
+ out.parent.mkdir(parents=True, exist_ok=True)
715
+ out.write_text("".join(json.dumps(x) + "\n" for x in r["records"]))
716
+ if not a.quiet:
717
+ print(f"\nwrote {len(r['records'])} record(s) to {out}")
718
+
719
+ if not a.quiet:
720
+ for line in summarise(r["records"]):
721
+ print(f" {line}")
722
+
723
+ if r["findings"]:
724
+ print(f"\n{len(r['findings'])} definition(s) disagree with their own "
725
+ f"expression:", file=sys.stderr)
726
+ for f in r["findings"]:
727
+ print(f" {f}", file=sys.stderr)
728
+ return 1
729
+
730
+ if not a.publisher:
731
+ print("\nNo --publisher, so nothing was checked: this built the ledger "
732
+ "and validated nothing. Do not read it as a pass.", file=sys.stderr)
733
+ return CANNOT_RUN
734
+ return 0
735
+
736
+
737
+ if __name__ == "__main__":
738
+ try:
739
+ sys.exit(main())
740
+ except SystemExit:
741
+ raise
742
+ except Collision as e:
743
+ # Its own arm so the message reads as the actionable refusal it is,
744
+ # rather than a traceback a reader has to interpret.
745
+ print(f"\n{e}", file=sys.stderr)
746
+ sys.exit(CANNOT_RUN)
747
+ except Exception:
748
+ traceback.print_exc()
749
+ print("\nverify_definitions could not run, so this says NOTHING about the "
750
+ "definitions. Fix the error above; do not read it as a pass.",
751
+ file=sys.stderr)
752
+ sys.exit(CANNOT_RUN)