@malloy-publisher/skills 0.1.20 → 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 +2 -1
- package/skills/malloy-analysis/SKILL.md +1 -1
- package/skills/malloy-analysis-pitfalls/SKILL.md +13 -1
- package/skills/malloy-analysis-report/SKILL.md +1 -1
- package/skills/malloy-analyze/SKILL.md +1 -1
- package/skills/malloy-charts/SKILL.md +1 -1
- 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 +1 -1
- package/skills/malloy-gotchas-queries/SKILL.md +1 -1
- package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
- package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
- package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
- 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 +1 -1
- package/skills/malloy-phrase-detection/SKILL.md +3 -2
- package/skills/malloy-publish/SKILL.md +1 -1
- package/skills/malloy-queries/SKILL.md +1 -1
- package/skills/malloy-review/SKILL.md +1 -1
- package/skills/malloy-scope/SKILL.md +1 -1
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for verify_definitions.py. Stdlib only: python3 verify_definitions_test.py
|
|
3
|
+
|
|
4
|
+
The thing these guard is not "does it compute a number" but "does it ever claim
|
|
5
|
+
to have validated something it did not". Every `unchecked` path below is a case
|
|
6
|
+
where saying `agrees` would be the tool committing the error it exists to find.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
|
+
|
|
22
|
+
import verify_definitions as vd # noqa: E402
|
|
23
|
+
|
|
24
|
+
MODEL = """\
|
|
25
|
+
source: order_items is duckdb.table('data/order_items.parquet') extend {
|
|
26
|
+
join_one: inventory_items is inventory_items on inventory_item_id = inventory_items.id
|
|
27
|
+
measure:
|
|
28
|
+
total_sales is sale_price.sum()
|
|
29
|
+
order_item_count is count()
|
|
30
|
+
total_sales_2022 is total_sales { where: year(created_at) = 2022 }
|
|
31
|
+
total_cost_through_join is inventory_items.cost.sum()
|
|
32
|
+
worst_margin is inventory_items.cost.min()
|
|
33
|
+
dimension:
|
|
34
|
+
order_year is year(created_at)
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write(text: str) -> pathlib.Path:
|
|
40
|
+
d = pathlib.Path(tempfile.mkdtemp())
|
|
41
|
+
p = d / "m.malloy"
|
|
42
|
+
p.write_text(text)
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def by_name(recs):
|
|
47
|
+
return {r["name"]: r for r in recs}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TheLedgerItBuilds(unittest.TestCase):
|
|
51
|
+
def setUp(self):
|
|
52
|
+
self.model = write(MODEL)
|
|
53
|
+
self.recs = by_name(vd.records(self.model, recursive=False))
|
|
54
|
+
|
|
55
|
+
def tearDown(self):
|
|
56
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
57
|
+
|
|
58
|
+
def test_a_measure_is_source_qualified(self):
|
|
59
|
+
# The flat `dict[name]` this replaced resolved a same-named field in
|
|
60
|
+
# two sources to whichever file was read first.
|
|
61
|
+
self.assertEqual(self.recs["total_sales"]["entityId"],
|
|
62
|
+
"measure:order_items:total_sales")
|
|
63
|
+
|
|
64
|
+
def test_a_definition_records_what_it_builds_on(self):
|
|
65
|
+
self.assertIn("measure:order_items:total_sales",
|
|
66
|
+
self.recs["total_sales_2022"]["depends"])
|
|
67
|
+
|
|
68
|
+
def test_a_leaf_definition_depends_on_nothing_defined(self):
|
|
69
|
+
self.assertEqual(self.recs["total_sales"]["depends"], [])
|
|
70
|
+
|
|
71
|
+
def test_views_and_joins_are_not_checkable_definitions(self):
|
|
72
|
+
# A view composes measures that are checked on their own; a join is
|
|
73
|
+
# structure rather than a value.
|
|
74
|
+
self.assertNotIn("inventory_items", self.recs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
TWO_SOURCES = """\
|
|
78
|
+
source: source_a is duckdb.table('data/a.parquet') extend {
|
|
79
|
+
measure:
|
|
80
|
+
total_sales is sale_price.sum()
|
|
81
|
+
}
|
|
82
|
+
source: source_b is duckdb.table('data/b.parquet') extend {
|
|
83
|
+
measure:
|
|
84
|
+
total_sales is price.sum()
|
|
85
|
+
doubled is total_sales * 2
|
|
86
|
+
borrowed is source_a.total_sales * 3
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class DependenciesResolveInTheSourceThatOwnsThem(unittest.TestCase):
|
|
92
|
+
"""A bare word names a field of the DECLARING source.
|
|
93
|
+
|
|
94
|
+
Resolving it against a flat `dict[name]` linked `source_b.doubled` to
|
|
95
|
+
`source_a.total_sales` on a model where the two sources both declare the
|
|
96
|
+
name -- and, worse, on one where only the OTHER source declares it, giving
|
|
97
|
+
a dependant a dependency its own source does not have. Either way the sha
|
|
98
|
+
chain hashes the wrong expression, so `stale_ids` marks the wrong rows.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def setUp(self):
|
|
102
|
+
self.model = write(TWO_SOURCES)
|
|
103
|
+
self.recs = {r["entityId"]: r
|
|
104
|
+
for r in vd.records(self.model, recursive=False)}
|
|
105
|
+
|
|
106
|
+
def tearDown(self):
|
|
107
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
108
|
+
|
|
109
|
+
def test_a_bare_word_resolves_to_its_own_sources_field(self):
|
|
110
|
+
self.assertEqual(self.recs["measure:source_b:doubled"]["depends"],
|
|
111
|
+
["measure:source_b:total_sales"])
|
|
112
|
+
|
|
113
|
+
def test_a_dotted_path_resolves_in_the_source_it_names(self):
|
|
114
|
+
self.assertEqual(self.recs["measure:source_b:borrowed"]["depends"],
|
|
115
|
+
["measure:source_a:total_sales"])
|
|
116
|
+
|
|
117
|
+
def test_a_name_only_the_other_source_declares_is_not_a_dependency(self):
|
|
118
|
+
p = write(
|
|
119
|
+
"source: source_a is duckdb.table('data/a.parquet') extend {\n"
|
|
120
|
+
" measure:\n total_sales is sale_price.sum()\n}\n"
|
|
121
|
+
"source: source_b is duckdb.table('data/b.parquet') extend {\n"
|
|
122
|
+
" dimension:\n doubled is total_sales * 2\n}\n")
|
|
123
|
+
try:
|
|
124
|
+
recs = {r["entityId"]: r for r in vd.records(p, recursive=False)}
|
|
125
|
+
self.assertEqual(recs["dimension:source_b:doubled"]["depends"], [])
|
|
126
|
+
finally:
|
|
127
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
128
|
+
|
|
129
|
+
def test_a_join_alias_resolves_to_the_source_it_targets(self):
|
|
130
|
+
"""`join_one: destination is airports` declares `destination`, so
|
|
131
|
+
`destination.airport_count` is a field of `airports`.
|
|
132
|
+
|
|
133
|
+
Scoping bare words to the declaring source is right; treating a join
|
|
134
|
+
ALIAS as if it were a source name is not, and it dropped the edge
|
|
135
|
+
outright. Measured on faa: `flights.destination_count` came back
|
|
136
|
+
depending on nothing, so an edit to `airports.airport_count` would not
|
|
137
|
+
have moved its sha. The join is a dependency too, because its `on`
|
|
138
|
+
clause decides which rows the field sees.
|
|
139
|
+
"""
|
|
140
|
+
p = write(
|
|
141
|
+
"source: airports is duckdb.table('data/a.parquet') extend {\n"
|
|
142
|
+
" measure:\n airport_count is count()\n}\n"
|
|
143
|
+
"source: flights is duckdb.table('data/f.parquet') extend {\n"
|
|
144
|
+
" join_one: destination is airports with destination_code\n"
|
|
145
|
+
" measure:\n destination_count is destination.airport_count\n}\n")
|
|
146
|
+
try:
|
|
147
|
+
recs = {r["entityId"]: r for r in vd.records(p, recursive=False)}
|
|
148
|
+
self.assertEqual(
|
|
149
|
+
recs["measure:flights:destination_count"]["depends"],
|
|
150
|
+
["measure:airports:airport_count", "join:flights:destination"])
|
|
151
|
+
finally:
|
|
152
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
153
|
+
|
|
154
|
+
def test_a_path_two_joins_deep_reaches_its_last_segment(self):
|
|
155
|
+
"""`re.findall` does not overlap, so a pairwise `(\\w+)\\.(\\w+)` reads
|
|
156
|
+
`a.b.c` as the single match `(a, b)` and never sees `c`.
|
|
157
|
+
|
|
158
|
+
The dependency at the END of the path is the one the sha chain exists
|
|
159
|
+
to track: with it dropped, editing the leaf measure left the measure
|
|
160
|
+
two joins above it reading as current, and `verify_goldens
|
|
161
|
+
--definitions` would promote against a definition that had moved.
|
|
162
|
+
"""
|
|
163
|
+
p = write(
|
|
164
|
+
"source: leaf is duckdb.table('data/l.parquet') extend {\n"
|
|
165
|
+
" measure:\n leaf_measure is count()\n}\n"
|
|
166
|
+
"source: mid is duckdb.table('data/m.parquet') extend {\n"
|
|
167
|
+
" join_one: leaf_alias is leaf with leaf_id\n"
|
|
168
|
+
" measure:\n mid_measure is leaf_alias.leaf_measure\n}\n"
|
|
169
|
+
"source: top is duckdb.table('data/t.parquet') extend {\n"
|
|
170
|
+
" join_one: mid_alias is mid with mid_id\n"
|
|
171
|
+
" measure:\n"
|
|
172
|
+
" top_measure is mid_alias.leaf_alias.leaf_measure\n}\n")
|
|
173
|
+
try:
|
|
174
|
+
recs = {r["entityId"]: r for r in vd.records(p, recursive=False)}
|
|
175
|
+
self.assertIn("measure:leaf:leaf_measure",
|
|
176
|
+
recs["measure:top:top_measure"]["depends"])
|
|
177
|
+
# Both joins on the way are dependencies too: either one's `on`
|
|
178
|
+
# clause changes which rows the leaf measure is read over.
|
|
179
|
+
self.assertIn("join:top:mid_alias",
|
|
180
|
+
recs["measure:top:top_measure"]["depends"])
|
|
181
|
+
self.assertIn("join:mid:leaf_alias",
|
|
182
|
+
recs["measure:top:top_measure"]["depends"])
|
|
183
|
+
finally:
|
|
184
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
185
|
+
|
|
186
|
+
def test_editing_a_leaf_moves_a_measure_two_joins_above_it(self):
|
|
187
|
+
base = ("source: leaf is duckdb.table('data/l.parquet') extend {\n"
|
|
188
|
+
" measure:\n leaf_measure is count()\n}\n"
|
|
189
|
+
"source: mid is duckdb.table('data/m.parquet') extend {\n"
|
|
190
|
+
" join_one: leaf_alias is leaf with leaf_id\n"
|
|
191
|
+
" measure:\n mid_measure is leaf_alias.leaf_measure\n}\n"
|
|
192
|
+
"source: top is duckdb.table('data/t.parquet') extend {\n"
|
|
193
|
+
" join_one: mid_alias is mid with mid_id\n"
|
|
194
|
+
" measure:\n"
|
|
195
|
+
" top_measure is mid_alias.leaf_alias.leaf_measure\n}\n")
|
|
196
|
+
|
|
197
|
+
def shas(text):
|
|
198
|
+
p = write(text)
|
|
199
|
+
try:
|
|
200
|
+
return {r["entityId"]: r["exprSha"]
|
|
201
|
+
for r in vd.records(p, recursive=False)}
|
|
202
|
+
finally:
|
|
203
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
204
|
+
|
|
205
|
+
before = shas(base)
|
|
206
|
+
after = shas(base.replace("leaf_measure is count()",
|
|
207
|
+
"leaf_measure is count() * 2"))
|
|
208
|
+
self.assertNotEqual(before["measure:top:top_measure"],
|
|
209
|
+
after["measure:top:top_measure"])
|
|
210
|
+
|
|
211
|
+
def test_a_method_call_on_a_column_is_not_a_traversal(self):
|
|
212
|
+
# `sale_price.sum()` is a path too; the walk must stop at a segment
|
|
213
|
+
# that names no join and no source, or every method call invents an
|
|
214
|
+
# edge.
|
|
215
|
+
p = write("source: s is duckdb.table('data/s.parquet') extend {\n"
|
|
216
|
+
" measure:\n total is sale_price.sum()\n}\n")
|
|
217
|
+
try:
|
|
218
|
+
recs = {r["entityId"]: r for r in vd.records(p, recursive=False)}
|
|
219
|
+
self.assertEqual(recs["measure:s:total"]["depends"], [])
|
|
220
|
+
finally:
|
|
221
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
222
|
+
|
|
223
|
+
def test_the_two_same_named_measures_hash_apart(self):
|
|
224
|
+
a = self.recs["measure:source_a:total_sales"]["exprSha"]
|
|
225
|
+
b = self.recs["measure:source_b:total_sales"]["exprSha"]
|
|
226
|
+
self.assertNotEqual(a, b)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AnEntityIdAddressesExactlyOneDefinition(unittest.TestCase):
|
|
230
|
+
"""`load_ledger` and `stale_ids` both build `{entityId: record}`, so two
|
|
231
|
+
definitions sharing an id means one is never staleness-checked and an
|
|
232
|
+
authored control written for one re-runs against the other. Measured on
|
|
233
|
+
real packages before this refused: auto_recalls lost 12 of 24 rows, faa 1
|
|
234
|
+
of 36."""
|
|
235
|
+
|
|
236
|
+
def test_two_files_declaring_one_source_name_are_refused(self):
|
|
237
|
+
d = pathlib.Path(tempfile.mkdtemp())
|
|
238
|
+
try:
|
|
239
|
+
(d / "a.malloy").write_text(
|
|
240
|
+
"source: airports is duckdb.table('data/a.parquet') extend {\n"
|
|
241
|
+
" measure:\n airport_count is count()\n}\n")
|
|
242
|
+
(d / "b.malloy").write_text(
|
|
243
|
+
"source: airports is duckdb.table('data/a.parquet') extend {\n"
|
|
244
|
+
" measure:\n airport_count is count()\n}\n")
|
|
245
|
+
with self.assertRaises(vd.Collision) as caught:
|
|
246
|
+
vd.records(d, recursive=False)
|
|
247
|
+
msg = str(caught.exception)
|
|
248
|
+
self.assertIn("measure:airports:airport_count", msg)
|
|
249
|
+
# Both sites named, so the reader can go fix one of them.
|
|
250
|
+
self.assertIn("a.malloy", msg)
|
|
251
|
+
self.assertIn("b.malloy", msg)
|
|
252
|
+
self.assertIn("Nothing was written", msg)
|
|
253
|
+
finally:
|
|
254
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
255
|
+
|
|
256
|
+
def test_one_file_declaring_two_sources_is_fine(self):
|
|
257
|
+
p = write(TWO_SOURCES)
|
|
258
|
+
try:
|
|
259
|
+
self.assertEqual(len(vd.records(p, recursive=False)), 4)
|
|
260
|
+
finally:
|
|
261
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
262
|
+
|
|
263
|
+
def test_a_field_outside_any_source_is_not_a_ledger_entity(self):
|
|
264
|
+
# An aggregate inside a `run:` block. It cannot be required by a case,
|
|
265
|
+
# because a required id is `kind:source:name` and there is no source to
|
|
266
|
+
# name, and `entity_id` would mint a two-part id that `check_findable`
|
|
267
|
+
# reports as malformed.
|
|
268
|
+
p = write("source: s is duckdb.table('data/s.parquet') extend {\n"
|
|
269
|
+
" measure:\n real_one is count()\n}\n"
|
|
270
|
+
"run: s -> {\n aggregate:\n query_local is count()\n}\n")
|
|
271
|
+
try:
|
|
272
|
+
recs = vd.records(p, recursive=False)
|
|
273
|
+
ids = [r["entityId"] for r in recs]
|
|
274
|
+
self.assertIn("measure:s:real_one", ids)
|
|
275
|
+
self.assertTrue(all(i.count(":") == 2 for i in ids), ids)
|
|
276
|
+
self.assertNotIn("measure:query_local", ids)
|
|
277
|
+
finally:
|
|
278
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class TheShaChainInvalidatesDownstream(unittest.TestCase):
|
|
282
|
+
"""A measure whose own text never moved is still stale when something it
|
|
283
|
+
builds on does. A flat hash of the one line reports it as current, which is
|
|
284
|
+
how a ledger goes quietly wrong."""
|
|
285
|
+
|
|
286
|
+
def shas(self, text):
|
|
287
|
+
p = write(text)
|
|
288
|
+
try:
|
|
289
|
+
return {r["name"]: r["exprSha"] for r in vd.records(p, False)}
|
|
290
|
+
finally:
|
|
291
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
292
|
+
|
|
293
|
+
def test_editing_a_dependency_moves_its_dependant(self):
|
|
294
|
+
before = self.shas(MODEL)
|
|
295
|
+
after = self.shas(MODEL.replace("total_sales is sale_price.sum()",
|
|
296
|
+
"total_sales is sale_price.sum() * 2"))
|
|
297
|
+
self.assertNotEqual(before["total_sales"], after["total_sales"])
|
|
298
|
+
# The point: this one's own text is byte-identical across the two.
|
|
299
|
+
self.assertNotEqual(before["total_sales_2022"], after["total_sales_2022"])
|
|
300
|
+
|
|
301
|
+
def test_an_unrelated_definition_is_not_disturbed(self):
|
|
302
|
+
before = self.shas(MODEL)
|
|
303
|
+
after = self.shas(MODEL.replace("total_sales is sale_price.sum()",
|
|
304
|
+
"total_sales is sale_price.sum() * 2"))
|
|
305
|
+
self.assertEqual(before["order_item_count"], after["order_item_count"])
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class WhatItRefusesToCheck(unittest.TestCase):
|
|
309
|
+
def setUp(self):
|
|
310
|
+
self.model = write(MODEL)
|
|
311
|
+
self.recs = by_name(vd.records(self.model, recursive=False))
|
|
312
|
+
|
|
313
|
+
def tearDown(self):
|
|
314
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
315
|
+
|
|
316
|
+
def test_a_join_crossing_measure_is_held_back_for_a_raw_check(self):
|
|
317
|
+
# Fanout inflates the measure and the control expression equally, so a
|
|
318
|
+
# within-model comparison stays green on a genuinely broken join.
|
|
319
|
+
r = self.recs["total_cost_through_join"]
|
|
320
|
+
self.assertEqual(r["check"]["kind"], "raw")
|
|
321
|
+
self.assertIn("inventory_items", r["needs"])
|
|
322
|
+
|
|
323
|
+
def test_a_method_call_on_a_column_is_not_a_join(self):
|
|
324
|
+
# The regression: matching any `word.word` read `sale_price.sum()` as a
|
|
325
|
+
# join hop and held back the measure twelve of the set's cases use.
|
|
326
|
+
self.assertEqual(self.recs["total_sales"]["check"]["kind"], "within_model")
|
|
327
|
+
|
|
328
|
+
def test_a_fanout_safe_aggregate_across_a_join_is_still_checkable(self):
|
|
329
|
+
# MIN survives uniform duplication, so fanout cannot move it.
|
|
330
|
+
self.assertEqual(self.recs["worst_margin"]["check"]["kind"], "within_model")
|
|
331
|
+
|
|
332
|
+
def test_a_multi_line_definition_is_unreadable_not_checked(self):
|
|
333
|
+
p = write("source: s is t extend {\n dimension:\n full_name is concat(\n")
|
|
334
|
+
try:
|
|
335
|
+
r = by_name(vd.records(p, False))["full_name"]
|
|
336
|
+
self.assertEqual(r["check"]["kind"], "unreadable")
|
|
337
|
+
self.assertIn("more than one line", r["needs"])
|
|
338
|
+
finally:
|
|
339
|
+
shutil.rmtree(p.parent, ignore_errors=True)
|
|
340
|
+
|
|
341
|
+
def test_incomplete_spots_every_unbalanced_delimiter(self):
|
|
342
|
+
for expr in ("concat(", "x { where: y", "arr[0"):
|
|
343
|
+
self.assertTrue(vd.incomplete(expr), expr)
|
|
344
|
+
for expr in ("sale_price.sum()", "count() { where: s != 'C' }"):
|
|
345
|
+
self.assertFalse(vd.incomplete(expr), expr)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class RunningOneCheck(unittest.TestCase):
|
|
349
|
+
def ns(self):
|
|
350
|
+
return argparse.Namespace(publisher="http://t", environment="e",
|
|
351
|
+
package="p", model_path="m.malloy")
|
|
352
|
+
|
|
353
|
+
def rec(self, **kw):
|
|
354
|
+
base = {"kind": "measure", "source": "order_items", "name": "total_sales",
|
|
355
|
+
"expr": "sale_price.sum()", "check": {"kind": "within_model"},
|
|
356
|
+
"needs": None}
|
|
357
|
+
base.update(kw)
|
|
358
|
+
return base
|
|
359
|
+
|
|
360
|
+
def check(self, rows, err=None, **kw):
|
|
361
|
+
with mock.patch.object(vd, "try_query", lambda *a, **k: (rows, err)):
|
|
362
|
+
return vd.run_check(self.rec(**kw), self.ns())
|
|
363
|
+
|
|
364
|
+
def test_matching_values_agree(self):
|
|
365
|
+
v, _ = self.check([{"stated": 42.0, "control": 42.0}])
|
|
366
|
+
self.assertEqual(v, "agrees")
|
|
367
|
+
|
|
368
|
+
def test_a_different_value_disagrees_and_names_both(self):
|
|
369
|
+
v, detail = self.check([{"stated": 84.0, "control": 42.0}])
|
|
370
|
+
self.assertEqual(v, "disagrees")
|
|
371
|
+
self.assertIn("84", detail)
|
|
372
|
+
self.assertIn("42", detail)
|
|
373
|
+
|
|
374
|
+
def test_float_association_is_not_a_disagreement(self):
|
|
375
|
+
# Two aggregation orders differ in the last places; verify_goldens
|
|
376
|
+
# tolerates the same.
|
|
377
|
+
v, _ = self.check([{"stated": 5788744.332576918,
|
|
378
|
+
"control": 5788744.3325769575}])
|
|
379
|
+
self.assertEqual(v, "agrees")
|
|
380
|
+
|
|
381
|
+
def test_a_failed_query_is_unchecked_not_a_disagreement(self):
|
|
382
|
+
# A check that could not run is not a check that found a defect.
|
|
383
|
+
v, detail = self.check([], err="HTTP 400: nope")
|
|
384
|
+
self.assertEqual(v, "unchecked")
|
|
385
|
+
self.assertIn("query failed", detail)
|
|
386
|
+
|
|
387
|
+
def test_no_rows_is_unchecked(self):
|
|
388
|
+
v, _ = self.check([])
|
|
389
|
+
self.assertEqual(v, "unchecked")
|
|
390
|
+
|
|
391
|
+
def test_a_raw_check_is_never_run_here(self):
|
|
392
|
+
v, _ = self.check([{"stated": 1, "control": 2}],
|
|
393
|
+
check={"kind": "raw"}, needs="crosses a join")
|
|
394
|
+
self.assertEqual(v, "unchecked")
|
|
395
|
+
|
|
396
|
+
def test_a_dimension_compares_every_sampled_row(self):
|
|
397
|
+
rows = [{"stated": i, "control": i} for i in range(5)]
|
|
398
|
+
rows[3]["control"] = 99
|
|
399
|
+
v, detail = self.check(rows, kind="dimension", name="order_year",
|
|
400
|
+
expr="year(created_at)")
|
|
401
|
+
self.assertEqual(v, "disagrees")
|
|
402
|
+
self.assertIn("row 4", detail)
|
|
403
|
+
|
|
404
|
+
def test_an_all_null_slice_is_unchecked_not_agreed(self):
|
|
405
|
+
# Every sampled row null on both sides proves nothing about the
|
|
406
|
+
# definition; calling it a pass is the over-claim this file guards.
|
|
407
|
+
v, detail = self.check([{"stated": None, "control": None}] * 3,
|
|
408
|
+
kind="dimension")
|
|
409
|
+
self.assertEqual(v, "unchecked")
|
|
410
|
+
self.assertIn("null", detail)
|
|
411
|
+
|
|
412
|
+
def test_a_passing_dimension_records_the_slice_it_used(self):
|
|
413
|
+
rec = self.rec(kind="dimension")
|
|
414
|
+
with mock.patch.object(vd, "try_query",
|
|
415
|
+
lambda *a, **k: ([{"stated": 1, "control": 1}] * 7, None)):
|
|
416
|
+
v, _ = vd.run_check(rec, self.ns())
|
|
417
|
+
self.assertEqual(v, "agrees")
|
|
418
|
+
self.assertIn("slice", rec["check"])
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TheQueryItBuilds(unittest.TestCase):
|
|
422
|
+
def test_a_measure_aggregates(self):
|
|
423
|
+
q = vd.within_model_query({"kind": "measure", "source": "s",
|
|
424
|
+
"name": "m", "expr": "x.sum()"})
|
|
425
|
+
self.assertIn("aggregate:", q)
|
|
426
|
+
self.assertNotIn("limit:", q)
|
|
427
|
+
|
|
428
|
+
def test_a_dimension_selects_a_slice(self):
|
|
429
|
+
# `aggregate:` rejects a scalar outright ("Cannot use a scalar field in
|
|
430
|
+
# an aggregate"), which is how this was found.
|
|
431
|
+
q = vd.within_model_query({"kind": "dimension", "source": "s",
|
|
432
|
+
"name": "d", "expr": "year(t)"})
|
|
433
|
+
self.assertIn("select:", q)
|
|
434
|
+
self.assertIn(f"limit: {vd.DIM_SLICE}", q)
|
|
435
|
+
|
|
436
|
+
def test_both_sides_come_from_one_query(self):
|
|
437
|
+
# Two queries could see different rows, and the difference would read
|
|
438
|
+
# as a bad definition rather than a moving population.
|
|
439
|
+
q = vd.within_model_query({"kind": "measure", "source": "s",
|
|
440
|
+
"name": "m", "expr": "x.sum()"})
|
|
441
|
+
self.assertEqual(q.count("run:"), 1)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class TheExitContract(unittest.TestCase):
|
|
445
|
+
"""Matches verify_goldens: 0 clean, 1 a finding, 3 could not run."""
|
|
446
|
+
|
|
447
|
+
def setUp(self):
|
|
448
|
+
self.model = write(MODEL)
|
|
449
|
+
|
|
450
|
+
def tearDown(self):
|
|
451
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
452
|
+
|
|
453
|
+
def run_main(self, rows, extra=()):
|
|
454
|
+
argv = ["--model", str(self.model), "--publisher", "http://t",
|
|
455
|
+
"--package", "p", "--model-path", "m.malloy", "--quiet", *extra]
|
|
456
|
+
with mock.patch.object(vd, "try_query", lambda *a, **k: (rows, None)):
|
|
457
|
+
return vd.main(argv)
|
|
458
|
+
|
|
459
|
+
def test_a_disagreement_exits_1(self):
|
|
460
|
+
self.assertEqual(self.run_main([{"stated": 1, "control": 2}]), 1)
|
|
461
|
+
|
|
462
|
+
def test_agreement_exits_0(self):
|
|
463
|
+
self.assertEqual(self.run_main([{"stated": 1, "control": 1}]), 0)
|
|
464
|
+
|
|
465
|
+
def test_building_without_a_server_is_could_not_run_not_a_pass(self):
|
|
466
|
+
# The whole point of 3: a ledger built and never checked must not read
|
|
467
|
+
# as validation.
|
|
468
|
+
self.assertEqual(vd.main(["--model", str(self.model), "--quiet"]),
|
|
469
|
+
vd.CANNOT_RUN)
|
|
470
|
+
|
|
471
|
+
def test_a_missing_model_is_could_not_run(self):
|
|
472
|
+
self.assertEqual(vd.main(["--model", "/nope/absent.malloy", "--quiet"]),
|
|
473
|
+
vd.CANNOT_RUN)
|
|
474
|
+
|
|
475
|
+
def test_a_publisher_without_an_address_is_could_not_run(self):
|
|
476
|
+
self.assertEqual(vd.main(["--model", str(self.model),
|
|
477
|
+
"--publisher", "http://t", "--quiet"]),
|
|
478
|
+
vd.CANNOT_RUN)
|
|
479
|
+
|
|
480
|
+
def test_ledger_mode_reads_merges_checks_and_writes_back(self):
|
|
481
|
+
led = self.model.parent / "led.jsonl"
|
|
482
|
+
vd.main(["--model", str(self.model), "--out", str(led), "--quiet"])
|
|
483
|
+
rows = [json.loads(l) for l in led.read_text().splitlines()]
|
|
484
|
+
for r in rows:
|
|
485
|
+
if r["name"] == "total_cost_through_join":
|
|
486
|
+
r["check"]["query"] = "run: x -> { aggregate: control is 1 }"
|
|
487
|
+
led.write_text("".join(json.dumps(r) + "\n" for r in rows))
|
|
488
|
+
with mock.patch.object(vd, "try_query",
|
|
489
|
+
lambda *a, **k: ([{"stated": 1, "control": 1}], None)):
|
|
490
|
+
code = vd.main(["--model", str(self.model), "--ledger", str(led),
|
|
491
|
+
"--publisher", "http://t", "--package", "p",
|
|
492
|
+
"--model-path", "m.malloy", "--quiet"])
|
|
493
|
+
self.assertEqual(code, 0)
|
|
494
|
+
back = {r["name"]: r for r in
|
|
495
|
+
(json.loads(l) for l in led.read_text().splitlines())}
|
|
496
|
+
self.assertEqual(back["total_cost_through_join"]["check"]["query"],
|
|
497
|
+
"run: x -> { aggregate: control is 1 }")
|
|
498
|
+
self.assertEqual(back["total_cost_through_join"]["verdict"], "agrees")
|
|
499
|
+
|
|
500
|
+
def test_a_missing_ledger_is_could_not_run(self):
|
|
501
|
+
self.assertEqual(vd.main(["--model", str(self.model),
|
|
502
|
+
"--ledger", "/nope/led.jsonl", "--quiet"]),
|
|
503
|
+
vd.CANNOT_RUN)
|
|
504
|
+
|
|
505
|
+
def test_the_ledger_is_written_as_jsonl(self):
|
|
506
|
+
out = self.model.parent / "led.jsonl"
|
|
507
|
+
self.run_main([{"stated": 1, "control": 1}], extra=["--out", str(out)])
|
|
508
|
+
rows = [json.loads(l) for l in out.read_text().splitlines() if l.strip()]
|
|
509
|
+
self.assertTrue(rows)
|
|
510
|
+
self.assertTrue(all("entityId" in r and "exprSha" in r for r in rows))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class WhatTheScoreRestsOn(unittest.TestCase):
|
|
514
|
+
"""The composition rule, per case: a golden is trustworthy if it was derived
|
|
515
|
+
independently, OR if every definition it tests has been validated."""
|
|
516
|
+
|
|
517
|
+
def ledger(self, **verdicts):
|
|
518
|
+
return {eid: {"entityId": eid, "verdict": v, "exprSha": "s"}
|
|
519
|
+
for eid, v in verdicts.items()}
|
|
520
|
+
|
|
521
|
+
def case(self, ids=("measure:s:m",), **golden):
|
|
522
|
+
g = {"status": "verified", "kind": "scalar", "value": 1}
|
|
523
|
+
g.update(golden)
|
|
524
|
+
return {"qid": "q", "golden": g,
|
|
525
|
+
"expectedEntities": {"required": list(ids)}}
|
|
526
|
+
|
|
527
|
+
def setUp(self):
|
|
528
|
+
self.set_dir = pathlib.Path(tempfile.mkdtemp())
|
|
529
|
+
|
|
530
|
+
def tearDown(self):
|
|
531
|
+
shutil.rmtree(self.set_dir, ignore_errors=True)
|
|
532
|
+
|
|
533
|
+
def basis(self, case, ledger=None, stale=()):
|
|
534
|
+
return vd.case_basis(case, ledger or {}, set(stale), self.set_dir)
|
|
535
|
+
|
|
536
|
+
def test_a_second_derivation_makes_a_key_independent(self):
|
|
537
|
+
# It was established twice, in differently shaped ways, so no single
|
|
538
|
+
# model bug could have certified it.
|
|
539
|
+
c = self.case()
|
|
540
|
+
c["golden"]["verification"] = {"primaryAxis": "a", "variesAxis": "b"}
|
|
541
|
+
self.assertEqual(self.basis(c), "independent")
|
|
542
|
+
|
|
543
|
+
def test_free_text_verifiedby_is_not_the_signal(self):
|
|
544
|
+
# The regression: matching `verifiedBy` by prefix classified 34 goldens
|
|
545
|
+
# of the ecommerce set as unchecked, every one of which reads "authored
|
|
546
|
+
# and re-derived against ecommerce-truth". A well-founded set reading as
|
|
547
|
+
# unvalidated is the same over-claim, pointed the other way.
|
|
548
|
+
c = self.case(verifiedBy="authored and re-derived against ecommerce-truth")
|
|
549
|
+
self.assertEqual(self.basis(c, self.ledger()), "unchecked")
|
|
550
|
+
c["golden"]["verification"] = {"primaryAxis": "a", "variesAxis": "b"}
|
|
551
|
+
self.assertEqual(self.basis(c, self.ledger()), "independent")
|
|
552
|
+
|
|
553
|
+
def test_a_value_free_golden_is_independent(self):
|
|
554
|
+
# Nothing to re-derive, so nothing a model bug could get wrong.
|
|
555
|
+
for kind in ("criteria", "unanswerable"):
|
|
556
|
+
self.assertEqual(self.basis(self.case(kind=kind)), "independent")
|
|
557
|
+
|
|
558
|
+
def test_validated_definitions_carry_a_model_derived_key(self):
|
|
559
|
+
self.assertEqual(
|
|
560
|
+
self.basis(self.case(verifiedBy="something else"),
|
|
561
|
+
self.ledger(**{"measure:s:m": "agrees"})),
|
|
562
|
+
"definitions")
|
|
563
|
+
|
|
564
|
+
def test_one_unchecked_definition_makes_the_whole_case_unchecked(self):
|
|
565
|
+
# The weakest link decides. A case is only as established as the least
|
|
566
|
+
# established thing it depends on.
|
|
567
|
+
led = self.ledger(**{"measure:s:m": "agrees", "measure:s:n": "unchecked"})
|
|
568
|
+
self.assertEqual(
|
|
569
|
+
self.basis(self.case(ids=("measure:s:m", "measure:s:n")), led),
|
|
570
|
+
"unchecked")
|
|
571
|
+
|
|
572
|
+
def test_a_definition_missing_from_the_ledger_is_unchecked(self):
|
|
573
|
+
self.assertEqual(self.basis(self.case(), self.ledger()), "unchecked")
|
|
574
|
+
|
|
575
|
+
def test_a_stale_row_does_not_count_as_validated(self):
|
|
576
|
+
# It agreed once, against a definition that has since moved.
|
|
577
|
+
led = self.ledger(**{"measure:s:m": "agrees"})
|
|
578
|
+
self.assertEqual(self.basis(self.case(), led, stale=("measure:s:m",)),
|
|
579
|
+
"unchecked")
|
|
580
|
+
|
|
581
|
+
def test_a_disagreeing_definition_outranks_everything(self):
|
|
582
|
+
led = self.ledger(**{"measure:s:m": "disagrees"})
|
|
583
|
+
self.assertEqual(self.basis(self.case(), led), "disagrees")
|
|
584
|
+
|
|
585
|
+
def test_a_case_naming_no_entities_is_unchecked_not_validated(self):
|
|
586
|
+
# Nothing was established, so it must not read as though something was.
|
|
587
|
+
c = {"qid": "q", "golden": {"kind": "scalar", "value": 1}}
|
|
588
|
+
self.assertEqual(self.basis(c, self.ledger()), "unchecked")
|
|
589
|
+
|
|
590
|
+
def test_an_anyof_group_is_satisfied_by_either_member(self):
|
|
591
|
+
# The regression: the groups were flattened and every member demanded,
|
|
592
|
+
# so a case answerable through `startYear` OR the model's alias for it
|
|
593
|
+
# was held unvalidated because only the alias is a checkable definition.
|
|
594
|
+
c = {"qid": "q", "golden": {"kind": "scalar", "value": 1},
|
|
595
|
+
"expectedEntities": {"requiredAnyOf": [["dimension:s:raw",
|
|
596
|
+
"dimension:s:alias"]]}}
|
|
597
|
+
led = {"dimension:s:alias": {"verdict": "agrees", "exprSha": "x"}}
|
|
598
|
+
self.assertEqual(vd.case_basis(c, led, set(), self.set_dir), "definitions")
|
|
599
|
+
|
|
600
|
+
def test_a_passthrough_column_counts_as_validated(self):
|
|
601
|
+
# A raw column exposed as-is has no expression that could be wrong.
|
|
602
|
+
# Absent from the ledger it blocked a case forever; recorded as
|
|
603
|
+
# `no_definition` it is what it is.
|
|
604
|
+
c = {"qid": "q", "golden": {"kind": "scalar", "value": 1},
|
|
605
|
+
"expectedEntities": {"required": ["dimension:s:runtimeMinutes"]}}
|
|
606
|
+
led = {"dimension:s:runtimeMinutes": {"verdict": "no_definition",
|
|
607
|
+
"exprSha": None}}
|
|
608
|
+
self.assertEqual(vd.case_basis(c, led, set(), self.set_dir), "definitions")
|
|
609
|
+
|
|
610
|
+
def test_an_anyof_group_with_no_validated_member_is_unchecked(self):
|
|
611
|
+
c = {"qid": "q", "golden": {"kind": "scalar", "value": 1},
|
|
612
|
+
"expectedEntities": {"requiredAnyOf": [["dimension:s:a",
|
|
613
|
+
"dimension:s:b"]]}}
|
|
614
|
+
self.assertEqual(vd.case_basis(c, {}, set(), self.set_dir), "unchecked")
|
|
615
|
+
|
|
616
|
+
def test_requiredanyof_members_count_as_tested(self):
|
|
617
|
+
c = {"qid": "q", "golden": {"kind": "scalar", "value": 1},
|
|
618
|
+
"expectedEntities": {"requiredAnyOf": [["measure:s:a", "measure:s:b"]]}}
|
|
619
|
+
self.assertEqual(vd.tested_ids(c), ["measure:s:a", "measure:s:b"])
|
|
620
|
+
|
|
621
|
+
def test_a_disagreeing_definition_is_named_even_under_an_independent_key(self):
|
|
622
|
+
# The golden was derived from raw tables and is trustworthy; the model's
|
|
623
|
+
# own total_sales still contradicts its docs. Both are true, and the
|
|
624
|
+
# second is the finding the run exists to surface.
|
|
625
|
+
c = self.case()
|
|
626
|
+
c["golden"]["verification"] = {"primaryAxis": "a", "variesAxis": "b"}
|
|
627
|
+
ev = vd.evidence_basis([c], self.ledger(**{"measure:s:m": "disagrees"}),
|
|
628
|
+
set(), self.set_dir)
|
|
629
|
+
self.assertEqual(ev["counts"], {"independent": 1})
|
|
630
|
+
self.assertEqual(ev["disagreeing"], ["measure:s:m"])
|
|
631
|
+
|
|
632
|
+
def test_the_basis_totals_cover_every_case(self):
|
|
633
|
+
cases = [self.case(kind="criteria"), self.case(), self.case()]
|
|
634
|
+
ev = vd.evidence_basis(cases, self.ledger(), set(), self.set_dir)
|
|
635
|
+
self.assertEqual(sum(ev["counts"].values()), len(cases))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class StalenessIsAHashComparison(unittest.TestCase):
|
|
639
|
+
def setUp(self):
|
|
640
|
+
self.model = write(MODEL)
|
|
641
|
+
|
|
642
|
+
def tearDown(self):
|
|
643
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
644
|
+
|
|
645
|
+
def test_an_unmoved_definition_is_not_stale(self):
|
|
646
|
+
led = {r["entityId"]: r for r in vd.records(self.model, False)}
|
|
647
|
+
self.assertEqual(vd.stale_ids(led, self.model), set())
|
|
648
|
+
|
|
649
|
+
def test_editing_a_dependency_makes_its_dependant_stale_too(self):
|
|
650
|
+
led = {r["entityId"]: r for r in vd.records(self.model, False)}
|
|
651
|
+
self.model.write_text(MODEL.replace("total_sales is sale_price.sum()",
|
|
652
|
+
"total_sales is sale_price.sum() * 2"))
|
|
653
|
+
stale = vd.stale_ids(led, self.model)
|
|
654
|
+
self.assertIn("measure:order_items:total_sales", stale)
|
|
655
|
+
# Its own text never changed; the sha chain is what catches it.
|
|
656
|
+
self.assertIn("measure:order_items:total_sales_2022", stale)
|
|
657
|
+
self.assertNotIn("measure:order_items:order_item_count", stale)
|
|
658
|
+
|
|
659
|
+
def test_a_removed_definition_is_stale(self):
|
|
660
|
+
led = {"measure:order_items:gone": {"entityId": "measure:order_items:gone",
|
|
661
|
+
"exprSha": "x"}}
|
|
662
|
+
self.assertEqual(vd.stale_ids(led, self.model),
|
|
663
|
+
{"measure:order_items:gone"})
|
|
664
|
+
|
|
665
|
+
def test_no_model_means_nothing_can_be_called_stale(self):
|
|
666
|
+
self.assertEqual(vd.stale_ids({"a": {"exprSha": "x"}}, None), set())
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class AuthoredControls(unittest.TestCase):
|
|
670
|
+
"""A raw check is a control query a person wrote into the ledger, re-run and
|
|
671
|
+
compared here. Re-deriving the definition's own expression over raw tables
|
|
672
|
+
computes the same quantity and agrees; what catches a wrong population is
|
|
673
|
+
the person who read the docs and wrote the population down."""
|
|
674
|
+
|
|
675
|
+
def ns(self, truth=None):
|
|
676
|
+
return argparse.Namespace(
|
|
677
|
+
publisher="http://m", environment="e", package="p",
|
|
678
|
+
model_path="m.malloy", truth_publisher=truth, truth_environment=None,
|
|
679
|
+
truth_package="truth", truth_model="truth.malloy")
|
|
680
|
+
|
|
681
|
+
def rec(self, **check):
|
|
682
|
+
c = {"kind": "raw"}
|
|
683
|
+
c.update(check)
|
|
684
|
+
return {"kind": "measure", "source": "order_items",
|
|
685
|
+
"name": "total_gross_margin",
|
|
686
|
+
"expr": "sale_price.sum() - inventory_items.cost.sum()",
|
|
687
|
+
"check": c, "needs": "reaches through inventory_items"}
|
|
688
|
+
|
|
689
|
+
def exercise(self, rec, stated, control, truth=None):
|
|
690
|
+
calls = []
|
|
691
|
+
|
|
692
|
+
def fake(base, env, pkg, model, q, **k):
|
|
693
|
+
calls.append(base)
|
|
694
|
+
if "stated is" in q:
|
|
695
|
+
return [{"stated": stated}], None
|
|
696
|
+
return [{"control": control}], None
|
|
697
|
+
with mock.patch.object(vd, "try_query", fake):
|
|
698
|
+
v, d = vd.run_check(rec, self.ns(truth))
|
|
699
|
+
return v, d, calls
|
|
700
|
+
|
|
701
|
+
def test_no_authored_query_stays_unchecked(self):
|
|
702
|
+
v, d, _ = self.exercise(self.rec(), 1, 1)
|
|
703
|
+
self.assertEqual(v, "unchecked")
|
|
704
|
+
self.assertIn("check.query", d)
|
|
705
|
+
|
|
706
|
+
def test_an_agreeing_control_agrees(self):
|
|
707
|
+
v, _, _ = self.exercise(self.rec(query="run: x -> { aggregate: control is y }"),
|
|
708
|
+
5.0, 5.0)
|
|
709
|
+
self.assertEqual(v, "agrees")
|
|
710
|
+
|
|
711
|
+
def test_a_disagreeing_control_disagrees_and_carries_the_note(self):
|
|
712
|
+
# The cogs shape: the model includes cancelled lines its own status doc
|
|
713
|
+
# says are not sales. Nothing mechanical sees that; the person who read
|
|
714
|
+
# the doc did, and wrote it down.
|
|
715
|
+
rec = self.rec(query="run: x -> { aggregate: control is z }",
|
|
716
|
+
note="excludes Cancelled, per the status doc")
|
|
717
|
+
v, d, _ = self.exercise(rec, 6002288.38, 5788744.33)
|
|
718
|
+
self.assertEqual(v, "disagrees")
|
|
719
|
+
self.assertIn("status doc", d)
|
|
720
|
+
|
|
721
|
+
def test_against_truth_routes_only_the_control_to_the_truth_server(self):
|
|
722
|
+
rec = self.rec(query="run: raw -> { aggregate: control is c }",
|
|
723
|
+
against="truth")
|
|
724
|
+
v, _, calls = self.exercise(rec, 1, 1, truth="http://t")
|
|
725
|
+
self.assertEqual(v, "agrees")
|
|
726
|
+
# stated came from the model server, the control from the truth server
|
|
727
|
+
self.assertEqual(sorted(set(calls)), ["http://m", "http://t"])
|
|
728
|
+
|
|
729
|
+
def test_against_truth_without_a_truth_server_is_unchecked(self):
|
|
730
|
+
rec = self.rec(query="run: raw -> {}", against="truth")
|
|
731
|
+
v, d, _ = self.exercise(rec, 1, 1)
|
|
732
|
+
self.assertEqual(v, "unchecked")
|
|
733
|
+
self.assertIn("--truth-publisher", d)
|
|
734
|
+
|
|
735
|
+
def test_an_unknown_target_is_unchecked(self):
|
|
736
|
+
v, d, _ = self.exercise(self.rec(query="run: x -> {}", against="warehouse"), 1, 1)
|
|
737
|
+
self.assertEqual(v, "unchecked")
|
|
738
|
+
self.assertIn("warehouse", d)
|
|
739
|
+
|
|
740
|
+
def test_a_control_without_a_control_column_is_unchecked_not_a_crash(self):
|
|
741
|
+
def fake(base, env, pkg, model, q, **k):
|
|
742
|
+
return ([{"stated": 1}] if "stated is" in q else [{"wrong": 1}]), None
|
|
743
|
+
with mock.patch.object(vd, "try_query", fake):
|
|
744
|
+
v, d = vd.run_check(self.rec(query="run: x -> {}"), self.ns())
|
|
745
|
+
self.assertEqual(v, "unchecked")
|
|
746
|
+
self.assertIn("`control`", d)
|
|
747
|
+
|
|
748
|
+
def test_a_dimension_is_not_a_target_for_an_authored_control(self):
|
|
749
|
+
rec = self.rec(query="run: x -> {}")
|
|
750
|
+
rec["kind"] = "dimension"
|
|
751
|
+
v, _, _ = self.exercise(rec, 1, 1)
|
|
752
|
+
self.assertEqual(v, "unchecked")
|
|
753
|
+
|
|
754
|
+
def test_an_authored_control_supersedes_the_within_model_check(self):
|
|
755
|
+
# total_sales is sale_price.sum() agrees with itself while including the
|
|
756
|
+
# cancelled lines its own docs exclude. The within-model check cannot see
|
|
757
|
+
# that; a person's population assertion can, and it wins.
|
|
758
|
+
rec = self.rec(kind="within_model",
|
|
759
|
+
query="run: order_items -> { aggregate: control is "
|
|
760
|
+
"sale_price.sum() { where: status != 'Cancelled' } }",
|
|
761
|
+
note="excludes Cancelled, per the status doc")
|
|
762
|
+
rec["name"], rec["expr"] = "total_sales", "sale_price.sum()"
|
|
763
|
+
v, d, _ = self.exercise(rec, 12566292.88, 12119909.43)
|
|
764
|
+
self.assertEqual(v, "disagrees")
|
|
765
|
+
self.assertIn("authored control", d)
|
|
766
|
+
|
|
767
|
+
def test_a_failing_control_query_is_unchecked_not_a_disagreement(self):
|
|
768
|
+
def fake(base, env, pkg, model, q, **k):
|
|
769
|
+
if "stated is" in q:
|
|
770
|
+
return [{"stated": 1}], None
|
|
771
|
+
return [], "HTTP 400: nope"
|
|
772
|
+
with mock.patch.object(vd, "try_query", fake):
|
|
773
|
+
v, d = vd.run_check(self.rec(query="run: x -> {}"), self.ns())
|
|
774
|
+
self.assertEqual(v, "unchecked")
|
|
775
|
+
self.assertIn("control query failed", d)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class TheLedgerKeepsWhatAPersonWrote(unittest.TestCase):
|
|
779
|
+
"""`--ledger` rebuilds from the model and must not lose an authored control,
|
|
780
|
+
and must not inherit a verdict from a definition that may have moved."""
|
|
781
|
+
|
|
782
|
+
def setUp(self):
|
|
783
|
+
self.model = write(MODEL)
|
|
784
|
+
|
|
785
|
+
def tearDown(self):
|
|
786
|
+
shutil.rmtree(self.model.parent, ignore_errors=True)
|
|
787
|
+
|
|
788
|
+
def test_a_rebuild_carries_authored_fields_forward_and_recomputes_the_rest(self):
|
|
789
|
+
eid = "measure:order_items:total_cost_through_join"
|
|
790
|
+
prior = {r["entityId"]: r for r in vd.records(self.model, False)}
|
|
791
|
+
prior[eid]["check"]["query"] = "run: x -> { aggregate: control is 1 }"
|
|
792
|
+
prior[eid]["check"]["note"] = "why"
|
|
793
|
+
prior[eid]["cause"] = "CONVENTION"
|
|
794
|
+
prior[eid]["verdict"] = "agrees" # must NOT survive
|
|
795
|
+
second = {r["entityId"]: r
|
|
796
|
+
for r in vd.records(self.model, False, existing=prior)}
|
|
797
|
+
self.assertEqual(second[eid]["check"]["query"],
|
|
798
|
+
"run: x -> { aggregate: control is 1 }")
|
|
799
|
+
self.assertEqual(second[eid]["check"]["note"], "why")
|
|
800
|
+
self.assertEqual(second[eid]["cause"], "CONVENTION")
|
|
801
|
+
self.assertEqual(second[eid]["verdict"], "unchecked")
|
|
802
|
+
|
|
803
|
+
def test_a_definition_the_model_no_longer_declares_is_dropped(self):
|
|
804
|
+
gone = "measure:order_items:gone"
|
|
805
|
+
prior = {gone: {"entityId": gone, "check": {"query": "q"}}}
|
|
806
|
+
ids = {r["entityId"] for r in vd.records(self.model, False, existing=prior)}
|
|
807
|
+
self.assertNotIn(gone, ids)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class TheSummary(unittest.TestCase):
|
|
811
|
+
def test_it_says_how_many_are_not_validated_by_this_run(self):
|
|
812
|
+
recs = [{"verdict": "agrees", "check": {"kind": "within_model"}},
|
|
813
|
+
{"verdict": "unchecked", "check": {"kind": "raw"}}]
|
|
814
|
+
text = " ".join(vd.summarise(recs))
|
|
815
|
+
self.assertIn("1 agrees", text)
|
|
816
|
+
self.assertIn("NOT validated", text)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
if __name__ == "__main__":
|
|
820
|
+
unittest.main(verbosity=1)
|