@malloy-publisher/skills 0.1.12 → 0.1.13
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 +162 -0
- package/skills/eval-answer/reference/ledger-schema.md +324 -0
- package/skills/eval-answer/scripts/check_contamination.py +213 -0
- package/skills/eval-answer/scripts/check_contamination_test.py +128 -0
- package/skills/eval-answer/scripts/init_truth_package.py +216 -0
- package/skills/eval-answer/scripts/init_truth_package_test.py +48 -0
- package/skills/eval-answer/scripts/ledger.py +386 -0
- package/skills/eval-answer/scripts/ledger_test.py +134 -0
- package/skills/eval-answer/scripts/mcp_payload.py +212 -0
- package/skills/eval-answer/scripts/mcp_payload_test.py +219 -0
- package/skills/eval-answer/scripts/publisher_rest.py +172 -0
- package/skills/eval-answer/scripts/publisher_rest_test.py +78 -0
- package/skills/eval-answer/scripts/score_retrieval.py +363 -0
- package/skills/eval-answer/scripts/score_retrieval_test.py +328 -0
- package/skills/eval-answer/scripts/verify_goldens.py +389 -0
- package/skills/eval-diagnose/SKILL.md +248 -0
- package/skills/eval-diagnose/reference/output-contract.md +47 -0
- package/skills/eval-diagnose/scripts/cluster_failures.py +151 -0
- package/skills/eval-diagnose/scripts/diagnose.py +481 -0
- package/skills/eval-improve/SKILL.md +181 -0
- package/skills/eval-improve/reference/output-contract.md +27 -0
- package/skills/eval-improve/scripts/improve.py +425 -0
- package/skills/eval-judge/SKILL.md +180 -0
- package/skills/eval-judge/reference/refusal.md +101 -0
- package/skills/eval-judge/reference/retrieval-judge.md +41 -0
- package/skills/eval-judge/reference/suspect-goldens.md +39 -0
- package/skills/eval-judge/reference/writing-rubrics.md +55 -0
- package/skills/eval-loop/SKILL.md +350 -0
- package/skills/eval-loop/reference/acceptance-check.md +58 -0
- package/skills/eval-loop/reference/checking-the-judge.md +31 -0
- package/skills/eval-loop/reference/golden-side-door.md +137 -0
- package/skills/eval-loop/reference/measurement.md +120 -0
- package/skills/eval-loop/reference/running-a-run.md +82 -0
- package/skills/eval-loop/scripts/agent_harness.py +434 -0
- package/skills/eval-loop/scripts/build_run_package.py +509 -0
- package/skills/eval-loop/scripts/check_judge.py +183 -0
- package/skills/eval-loop/scripts/flip_table.py +315 -0
- package/skills/eval-loop/scripts/platform_target_test.py +101 -0
- package/skills/eval-loop/scripts/run_baseline.py +1491 -0
- package/skills/eval-loop/scripts/serve.py +130 -0
- package/skills/eval-loop/templates/eval-run-package/README.md +183 -0
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +757 -0
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +160 -0
- package/skills/eval-loop/templates/eval-run-package/public/app.js +406 -0
- package/skills/eval-loop/templates/eval-run-package/public/index.html +215 -0
- package/skills/eval-loop/templates/eval-run-package/publisher.json +5 -0
- package/skills/malloy/SKILL.md +13 -2
- package/skills/malloy-analysis/SKILL.md +1 -1
- package/skills/malloy-analysis-pitfalls/SKILL.md +1 -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 +1 -1
- 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,213 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Score-time contamination check. Stdlib only.
|
|
3
|
+
|
|
4
|
+
An attempt is contaminated if the host-side tool-use log touched an eval path,
|
|
5
|
+
gold artifact, or the model file under test, or if the answerer reported more
|
|
6
|
+
MCP calls than the host observed as tool uses.
|
|
7
|
+
|
|
8
|
+
python check_contamination.py --log tool_uses.json [options]
|
|
9
|
+
|
|
10
|
+
All path matching is PLAIN SUBSTRING containment against the flattened tool
|
|
11
|
+
input, never glob expansion. --gold-paths and --eval-paths EXTEND the built-in
|
|
12
|
+
defaults rather than replacing them, so passing extra paths can only widen the
|
|
13
|
+
check.
|
|
14
|
+
|
|
15
|
+
Exit 0 always when the check itself ran. Exit 1 if the log cannot be read.
|
|
16
|
+
A contaminated attempt is data for the grader, not a script failure.
|
|
17
|
+
|
|
18
|
+
The log is a JSON object:
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
"toolUses": [
|
|
22
|
+
{"name": "Read", "input": {"path": "..."}},
|
|
23
|
+
{"name": "Shell", "input": {"command": "..."}},
|
|
24
|
+
{"name": "malloy_getContext", "input": {}}
|
|
25
|
+
],
|
|
26
|
+
"reportedCalls": 4
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
`reportedCalls` may also be passed as --reported-calls.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DEFAULT_GOLD_PATHS = (
|
|
41
|
+
"evals/",
|
|
42
|
+
"results/gold/",
|
|
43
|
+
"gold/",
|
|
44
|
+
"cases.jsonl",
|
|
45
|
+
"intents.jsonl",
|
|
46
|
+
"set.json",
|
|
47
|
+
"events.jsonl",
|
|
48
|
+
"judge-regressions",
|
|
49
|
+
)
|
|
50
|
+
DEFAULT_EVAL_PATHS = (
|
|
51
|
+
"publisher.db",
|
|
52
|
+
"mcp_traces",
|
|
53
|
+
)
|
|
54
|
+
HOST_FILE_TOOLS = (
|
|
55
|
+
"read",
|
|
56
|
+
"write",
|
|
57
|
+
"edit",
|
|
58
|
+
"grep",
|
|
59
|
+
"glob",
|
|
60
|
+
"shell",
|
|
61
|
+
"bash",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_name(entry: dict[str, Any]) -> str:
|
|
66
|
+
return str(entry.get("name") or entry.get("tool") or "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_host_file_tool(name: str) -> bool:
|
|
70
|
+
n = name.lower()
|
|
71
|
+
return any(n == t or n.endswith(f"_{t}") or n.endswith(f"-{t}") for t in HOST_FILE_TOOLS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_mcp_transport(haystack: str) -> bool:
|
|
75
|
+
"""A shell command that is itself an MCP call (curl JSON-RPC to /mcp).
|
|
76
|
+
|
|
77
|
+
The modelPath argument on an MCP execute_query is required, not a file
|
|
78
|
+
read, and that stays true when the MCP call rides over Shell instead of
|
|
79
|
+
a native tool. Gold and eval-path needles still apply to these entries;
|
|
80
|
+
only the model-path needle is exempted.
|
|
81
|
+
"""
|
|
82
|
+
return "tools/call" in haystack and "/mcp" in haystack
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _flatten_input(value: Any) -> str:
|
|
86
|
+
if value is None:
|
|
87
|
+
return ""
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return value
|
|
90
|
+
if isinstance(value, (int, float, bool)):
|
|
91
|
+
return str(value)
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return " ".join(_flatten_input(v) for v in value.values())
|
|
94
|
+
if isinstance(value, list):
|
|
95
|
+
return " ".join(_flatten_input(v) for v in value)
|
|
96
|
+
return str(value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _haystack(entry: dict[str, Any]) -> str:
|
|
100
|
+
name = str(entry.get("name") or entry.get("tool") or "")
|
|
101
|
+
raw = entry.get("input") or entry.get("arguments") or entry.get("args") or ""
|
|
102
|
+
return f"{name} {_flatten_input(raw)}".lower()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _matches(haystack: str, needles: list[str]) -> list[str]:
|
|
106
|
+
hits = []
|
|
107
|
+
for needle in needles:
|
|
108
|
+
n = needle.lower().strip()
|
|
109
|
+
if not n:
|
|
110
|
+
continue
|
|
111
|
+
if n in haystack:
|
|
112
|
+
hits.append(needle)
|
|
113
|
+
return hits
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check(
|
|
117
|
+
log: dict[str, Any],
|
|
118
|
+
*,
|
|
119
|
+
gold_paths: list[str] | None = None,
|
|
120
|
+
eval_paths: list[str] | None = None,
|
|
121
|
+
model_path: str | None = None,
|
|
122
|
+
reported_calls: int | None = None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
uses = log.get("toolUses") or log.get("tool_uses") or []
|
|
125
|
+
if not isinstance(uses, list):
|
|
126
|
+
raise ValueError("toolUses must be a list")
|
|
127
|
+
|
|
128
|
+
# Custom paths extend the defaults; they never replace them. Replacing
|
|
129
|
+
# once turned the gold check into a no-op when a caller passed glob
|
|
130
|
+
# patterns that substring-match nothing.
|
|
131
|
+
needles = list(DEFAULT_GOLD_PATHS) + list(gold_paths or ())
|
|
132
|
+
needles.extend(list(DEFAULT_EVAL_PATHS) + list(eval_paths or ()))
|
|
133
|
+
# A needle like "." or "/" matches everything and voids every verdict in a
|
|
134
|
+
# run -- which is what happened the first time a caller passed `--set .`.
|
|
135
|
+
# Substring matching needs a needle that could only mean the eval set.
|
|
136
|
+
short = [n for n in needles if len(n.strip("./")) < 3]
|
|
137
|
+
if short:
|
|
138
|
+
raise ValueError(f"contamination path(s) too short to be meaningful: "
|
|
139
|
+
f"{short!r}; pass an absolute path")
|
|
140
|
+
model_needles = []
|
|
141
|
+
if model_path:
|
|
142
|
+
model_needles = [os.path.normpath(model_path), os.path.basename(model_path)]
|
|
143
|
+
|
|
144
|
+
reasons: list[str] = []
|
|
145
|
+
for i, entry in enumerate(uses):
|
|
146
|
+
if not isinstance(entry, dict):
|
|
147
|
+
continue
|
|
148
|
+
name = _tool_name(entry) or f"use[{i}]"
|
|
149
|
+
hay = _haystack(entry)
|
|
150
|
+
hits = _matches(hay, needles)
|
|
151
|
+
# modelPath on malloy_executeQuery is required, not a file read --
|
|
152
|
+
# including when the MCP call rides over Shell as a curl JSON-RPC.
|
|
153
|
+
if model_needles and _is_host_file_tool(name) and not _is_mcp_transport(hay):
|
|
154
|
+
hits.extend(_matches(hay, model_needles))
|
|
155
|
+
if hits:
|
|
156
|
+
reasons.append(f"{name} touched {hits[0]}")
|
|
157
|
+
|
|
158
|
+
host_uses = len(uses)
|
|
159
|
+
reported = reported_calls
|
|
160
|
+
if reported is None:
|
|
161
|
+
raw = log.get("reportedCalls", log.get("reported_calls"))
|
|
162
|
+
if raw is not None:
|
|
163
|
+
reported = int(raw)
|
|
164
|
+
if reported is not None and reported > host_uses:
|
|
165
|
+
reasons.append(
|
|
166
|
+
f"reportedCalls {reported} > host tool uses {host_uses}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"contaminated": bool(reasons),
|
|
171
|
+
"reasons": reasons,
|
|
172
|
+
"host_tool_uses": host_uses,
|
|
173
|
+
"reported_calls": reported,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main() -> None:
|
|
178
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
179
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
180
|
+
ap.add_argument("--log", required=True, help="host tool-use JSON")
|
|
181
|
+
ap.add_argument(
|
|
182
|
+
"--gold-paths",
|
|
183
|
+
nargs="*",
|
|
184
|
+
default=None,
|
|
185
|
+
help="extra gold substrings; extends the defaults, substring match only",
|
|
186
|
+
)
|
|
187
|
+
ap.add_argument(
|
|
188
|
+
"--eval-paths",
|
|
189
|
+
nargs="*",
|
|
190
|
+
default=None,
|
|
191
|
+
help="extra eval-ledger substrings; extends the defaults",
|
|
192
|
+
)
|
|
193
|
+
ap.add_argument("--model-path", default=None)
|
|
194
|
+
ap.add_argument("--reported-calls", type=int, default=None)
|
|
195
|
+
args = ap.parse_args()
|
|
196
|
+
try:
|
|
197
|
+
with open(args.log) as f:
|
|
198
|
+
log = json.load(f)
|
|
199
|
+
out = check(
|
|
200
|
+
log,
|
|
201
|
+
gold_paths=args.gold_paths,
|
|
202
|
+
eval_paths=args.eval_paths,
|
|
203
|
+
model_path=args.model_path,
|
|
204
|
+
reported_calls=args.reported_calls,
|
|
205
|
+
)
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
print(f"harness error: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
print(json.dumps(out, indent=2))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from check_contamination import check
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContaminationTests(unittest.TestCase):
|
|
9
|
+
def test_clean_mcp_only(self):
|
|
10
|
+
log = {
|
|
11
|
+
"toolUses": [
|
|
12
|
+
{"name": "malloy_getContext", "input": {"query": "orders"}},
|
|
13
|
+
{"name": "malloy_executeQuery", "input": {"query": "run: orders -> { aggregate: n is count() }"}},
|
|
14
|
+
],
|
|
15
|
+
"reportedCalls": 2,
|
|
16
|
+
}
|
|
17
|
+
out = check(log)
|
|
18
|
+
self.assertFalse(out["contaminated"])
|
|
19
|
+
self.assertEqual(out["reasons"], [])
|
|
20
|
+
|
|
21
|
+
def test_read_gold_csv(self):
|
|
22
|
+
log = {
|
|
23
|
+
"toolUses": [
|
|
24
|
+
{"name": "Read", "input": {"path": "results/gold/beaver_full/dw_5261.csv"}},
|
|
25
|
+
],
|
|
26
|
+
"reportedCalls": 0,
|
|
27
|
+
}
|
|
28
|
+
out = check(log)
|
|
29
|
+
self.assertTrue(out["contaminated"])
|
|
30
|
+
self.assertTrue(any("gold" in r for r in out["reasons"]))
|
|
31
|
+
|
|
32
|
+
def test_eval_rest_path(self):
|
|
33
|
+
log = {
|
|
34
|
+
"toolUses": [
|
|
35
|
+
{"name": "Shell", "input": {"command": "curl http://127.0.0.1:4000/api/v0/evals/cases/abc"}},
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
out = check(log)
|
|
39
|
+
self.assertTrue(out["contaminated"])
|
|
40
|
+
|
|
41
|
+
def test_model_file_read(self):
|
|
42
|
+
log = {
|
|
43
|
+
"toolUses": [
|
|
44
|
+
{"name": "Read", "input": {"path": "/tmp/pkg/orders.malloy"}},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
out = check(log, model_path="/tmp/pkg/orders.malloy")
|
|
48
|
+
self.assertTrue(out["contaminated"])
|
|
49
|
+
|
|
50
|
+
def test_mcp_model_path_is_not_contamination(self):
|
|
51
|
+
log = {
|
|
52
|
+
"toolUses": [
|
|
53
|
+
{
|
|
54
|
+
"name": "malloy_executeQuery",
|
|
55
|
+
"input": {
|
|
56
|
+
"modelPath": "storefront.malloy",
|
|
57
|
+
"query": "run: order_items -> { aggregate: order_count }",
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"reportedCalls": 1,
|
|
62
|
+
}
|
|
63
|
+
out = check(log, model_path="/tmp/pkg/storefront.malloy")
|
|
64
|
+
self.assertFalse(out["contaminated"])
|
|
65
|
+
|
|
66
|
+
def test_mcp_over_shell_model_path_is_not_contamination(self):
|
|
67
|
+
# An answerer that reaches MCP with curl JSON-RPC carries modelPath
|
|
68
|
+
# inside a Bash command; that is transport, not a file read. Found
|
|
69
|
+
# live: every clean attempt in a run was flagged before this rule.
|
|
70
|
+
log = {
|
|
71
|
+
"toolUses": [
|
|
72
|
+
{
|
|
73
|
+
"name": "Bash",
|
|
74
|
+
"input": {
|
|
75
|
+
"command": "curl -s -X POST http://localhost:4040/mcp -d '{\"method\":\"tools/call\",\"params\":{\"name\":\"malloy_executeQuery\",\"arguments\":{\"modelPath\":\"storefront.malloy\"}}}'"
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
out = check(log, model_path="/tmp/pkg/storefront.malloy")
|
|
81
|
+
self.assertFalse(out["contaminated"])
|
|
82
|
+
|
|
83
|
+
def test_shell_cat_of_model_file_is_contamination(self):
|
|
84
|
+
log = {
|
|
85
|
+
"toolUses": [
|
|
86
|
+
{"name": "Bash", "input": {"command": "cat /tmp/pkg/storefront.malloy"}},
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
out = check(log, model_path="/tmp/pkg/storefront.malloy")
|
|
90
|
+
self.assertTrue(out["contaminated"])
|
|
91
|
+
|
|
92
|
+
def test_custom_gold_paths_extend_defaults(self):
|
|
93
|
+
# The historical bug: passing glob patterns REPLACED the default
|
|
94
|
+
# needles, and since matching is substring-only, the gold check
|
|
95
|
+
# became a no-op. Custom paths must extend, never replace.
|
|
96
|
+
log = {
|
|
97
|
+
"toolUses": [
|
|
98
|
+
{"name": "Read", "input": {"path": "results/gold/x/dw_1.csv"}},
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
out = check(log, gold_paths=["evals/**", "results/gold/**"])
|
|
102
|
+
self.assertTrue(out["contaminated"])
|
|
103
|
+
self.assertTrue(any("gold" in r for r in out["reasons"]))
|
|
104
|
+
|
|
105
|
+
def test_ledger_read_is_contamination(self):
|
|
106
|
+
log = {
|
|
107
|
+
"toolUses": [
|
|
108
|
+
{"name": "Read", "input": {"path": "evals/beaver/runs/r1/events.jsonl"}},
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
out = check(log)
|
|
112
|
+
self.assertTrue(out["contaminated"])
|
|
113
|
+
|
|
114
|
+
def test_underreported_calls(self):
|
|
115
|
+
log = {
|
|
116
|
+
"toolUses": [
|
|
117
|
+
{"name": "malloy_getContext", "input": {}},
|
|
118
|
+
],
|
|
119
|
+
"reportedCalls": 9,
|
|
120
|
+
}
|
|
121
|
+
out = check(log)
|
|
122
|
+
self.assertTrue(out["contaminated"])
|
|
123
|
+
self.assertTrue(any("reportedCalls" in r for r in out["reasons"]))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
os.chdir(os.path.dirname(__file__))
|
|
128
|
+
unittest.main()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scaffold a truth package from the model under test. Stdlib only.
|
|
3
|
+
|
|
4
|
+
python3 init_truth_package.py --package <path/to/model/package> \\
|
|
5
|
+
--out <set>/../truth --name <set>-truth [--publisher-config <path>]
|
|
6
|
+
|
|
7
|
+
WHY
|
|
8
|
+
|
|
9
|
+
The doctrine mandates raw-table goldens: every golden is computed in a
|
|
10
|
+
semantics-free package over the same raw tables, never through the model under
|
|
11
|
+
test, so a model bug cannot certify its own golden. `set.json` has had a
|
|
12
|
+
`truthPackage` field for that since the schema was written -- and nothing built
|
|
13
|
+
one. The VideoAmp author wrote `truth.malloy`, a `publisher.json`, a second
|
|
14
|
+
server config and an MCP client by hand before authoring case one: an hour of
|
|
15
|
+
plumbing for a structure that is entirely derivable from the package.
|
|
16
|
+
|
|
17
|
+
WHAT IT PRODUCES
|
|
18
|
+
|
|
19
|
+
truth.malloy one `source: t_<stem> is <connection>.table('<ref>')` per
|
|
20
|
+
distinct table the model reads, and NOTHING else -- no
|
|
21
|
+
measures, no joins, no filters, no renames. The author
|
|
22
|
+
adds scope filters and raw-column dimensions by hand, as
|
|
23
|
+
the header comment says to; the script will not guess
|
|
24
|
+
semantics, because guessed semantics are exactly what a
|
|
25
|
+
truth package exists to exclude.
|
|
26
|
+
publisher.json the truth package, named, with the standard description.
|
|
27
|
+
publisher.config.json (--publisher-config) a Publisher server config
|
|
28
|
+
serving ONLY the truth package, for the second server the
|
|
29
|
+
answerer has no route to. Proxy connections are copied
|
|
30
|
+
from the model package's own config when it has one, so
|
|
31
|
+
the truth server reaches the same warehouse.
|
|
32
|
+
README.md what is here, what the author still has to do, and the
|
|
33
|
+
two rules: never serve this on the answerer's Publisher;
|
|
34
|
+
never import the model under test.
|
|
35
|
+
|
|
36
|
+
It refuses to write into a directory that already has a truth.malloy, because
|
|
37
|
+
the hand-added scope filters are the valuable part and a rerun must not erase
|
|
38
|
+
them.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import pathlib
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
TABLE_REF = re.compile(r"""(?P<conn>[A-Za-z_][\w]*)\.table\(\s*['"](?P<ref>[^'"]+)['"]\s*\)""")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def table_refs(package: pathlib.Path) -> list[tuple[str, str]]:
|
|
52
|
+
"""(connection, table ref) for every distinct table the package reads."""
|
|
53
|
+
seen: dict[tuple[str, str], None] = {}
|
|
54
|
+
for f in sorted(package.rglob("*.malloy")):
|
|
55
|
+
text = f.read_text()
|
|
56
|
+
# Strip comments so a commented-out table does not become a source.
|
|
57
|
+
text = re.sub(r"//[^\n]*", "", text)
|
|
58
|
+
for m in TABLE_REF.finditer(text):
|
|
59
|
+
seen.setdefault((m.group("conn"), m.group("ref")), None)
|
|
60
|
+
return list(seen)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Data-file extensions a table ref may carry. Stripped before anything else
|
|
64
|
+
# looks at the ref, because the order is the whole bug: a file path's last
|
|
65
|
+
# DOT-segment IS its extension, so splitting first left `parquet` as the table
|
|
66
|
+
# name and a package of parquet files scaffolded as t_parquet, t_parquet_2,
|
|
67
|
+
# t_parquet_3 -- names no golden author can write a query against.
|
|
68
|
+
DATA_EXT = re.compile(r"\.(parquet|csv|tsv|json|jsonl|ndjson|orc|avro)$", re.I)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def stem(ref: str) -> str:
|
|
72
|
+
"""t_<table>, lowercased, with v_/fact_/dim_/tbl_ noise trimmed.
|
|
73
|
+
|
|
74
|
+
Two ref shapes reach this. A PATH (`data/users.parquet`) names a file, so
|
|
75
|
+
the table is the basename without its extension. A WAREHOUSE ref
|
|
76
|
+
(`analytics.public.orders`) has no slash, and there the last dot-segment is
|
|
77
|
+
the table. Deciding which on the presence of a slash keeps both correct.
|
|
78
|
+
"""
|
|
79
|
+
raw = ref.strip("/")
|
|
80
|
+
cut = DATA_EXT.sub("", raw)
|
|
81
|
+
had_ext = cut != raw
|
|
82
|
+
if "/" in cut:
|
|
83
|
+
last = cut.rsplit("/", 1)[-1]
|
|
84
|
+
# Only guess at a further extension when the known list did not match,
|
|
85
|
+
# so `data/a.b.parquet` keeps the `.b` that is part of the name.
|
|
86
|
+
if not had_ext:
|
|
87
|
+
last = re.sub(r"\.[A-Za-z0-9_]{1,10}$", "", last)
|
|
88
|
+
else:
|
|
89
|
+
last = cut if had_ext else cut.split(".")[-1]
|
|
90
|
+
last = re.sub(r"^(v_|vw_|fact_|dim_|tbl_)+", "", last, flags=re.I)
|
|
91
|
+
return "t_" + re.sub(r"\W+", "_", last).strip("_").lower()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> int:
|
|
95
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
96
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
97
|
+
ap.add_argument("--package", required=True, type=pathlib.Path,
|
|
98
|
+
help="the model package under test (holds publisher.json)")
|
|
99
|
+
ap.add_argument("--out", required=True, type=pathlib.Path,
|
|
100
|
+
help="where to write the truth package (a SIBLING of the set, "
|
|
101
|
+
"never inside the served tree)")
|
|
102
|
+
ap.add_argument("--name", required=True, help="truth package name, e.g. ir-truth")
|
|
103
|
+
ap.add_argument("--publisher-config", type=pathlib.Path, default=None,
|
|
104
|
+
help="also write a Publisher server config serving only the "
|
|
105
|
+
"truth package, to this path")
|
|
106
|
+
ap.add_argument("--environment", default="truth",
|
|
107
|
+
help="environment name in the generated server config")
|
|
108
|
+
a = ap.parse_args()
|
|
109
|
+
|
|
110
|
+
if not (a.package / "publisher.json").exists():
|
|
111
|
+
raise SystemExit(f"{a.package} has no publisher.json; point --package at the "
|
|
112
|
+
f"model package root")
|
|
113
|
+
if (a.out / "truth.malloy").exists():
|
|
114
|
+
raise SystemExit(f"{a.out / 'truth.malloy'} exists. The hand-added scope filters "
|
|
115
|
+
f"are the valuable part; delete it yourself if you mean to "
|
|
116
|
+
f"start over")
|
|
117
|
+
|
|
118
|
+
refs = table_refs(a.package)
|
|
119
|
+
if not refs:
|
|
120
|
+
raise SystemExit(f"no <connection>.table('...') references found under {a.package}")
|
|
121
|
+
names: dict[str, int] = {}
|
|
122
|
+
lines = [
|
|
123
|
+
f"// Truth sources for the {a.name} eval set. GENERATED by init_truth_package.py",
|
|
124
|
+
f"// from {a.package.name}/ -- then edited by hand, which is the point.",
|
|
125
|
+
"//",
|
|
126
|
+
"// These mirror the RAW tables the model reads and nothing else: no",
|
|
127
|
+
"// measures, no joins, no lenses, no renames, no guards. A golden must not",
|
|
128
|
+
"// be able to inherit the bug it is meant to catch.",
|
|
129
|
+
"//",
|
|
130
|
+
"// TO DO BY HAND, per source:",
|
|
131
|
+
"// - the package's documented scope filters (a report type, an audience",
|
|
132
|
+
"// level, a task name) -- as raw column predicates, quoting the model's",
|
|
133
|
+
"// own doc for where each comes from",
|
|
134
|
+
"// - raw-column dimensions for anything nested (a VARIANT column, a JSON",
|
|
135
|
+
"// path) that a golden query will need to group by",
|
|
136
|
+
"// Keep column names in the warehouse's own case so a golden query reads as",
|
|
137
|
+
"// a warehouse query, not as a model query.",
|
|
138
|
+
"",
|
|
139
|
+
]
|
|
140
|
+
for conn, ref in refs:
|
|
141
|
+
n = stem(ref)
|
|
142
|
+
if n in names:
|
|
143
|
+
names[n] += 1
|
|
144
|
+
n = f"{n}_{names[n]}"
|
|
145
|
+
else:
|
|
146
|
+
names[n] = 1
|
|
147
|
+
lines.append(f"source: {n} is {conn}.table('{ref}')")
|
|
148
|
+
a.out.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
(a.out / "truth.malloy").write_text("\n".join(lines) + "\n")
|
|
150
|
+
|
|
151
|
+
(a.out / "publisher.json").write_text(json.dumps({
|
|
152
|
+
"name": a.name, "version": "0.0.1",
|
|
153
|
+
"description": (f"Truth package for the {a.name} eval set: the raw tables the "
|
|
154
|
+
f"model reads, with the package's scope filters applied by hand "
|
|
155
|
+
f"and NO semantic modelling. Goldens are computed here so a "
|
|
156
|
+
f"model bug cannot certify its own golden. Never served on the "
|
|
157
|
+
f"answerer's Publisher."),
|
|
158
|
+
"explores": ["truth.malloy"],
|
|
159
|
+
}, indent=2) + "\n")
|
|
160
|
+
|
|
161
|
+
(a.out / "README.md").write_text(f"""# {a.name}
|
|
162
|
+
|
|
163
|
+
Truth package for the eval set beside it. `truth.malloy` was scaffolded from
|
|
164
|
+
`{a.package.name}/` -- one source per raw table the model reads -- and must now be
|
|
165
|
+
finished by hand: add the package's scope filters as raw predicates and any
|
|
166
|
+
raw-column dimensions the goldens will group by. Add nothing else.
|
|
167
|
+
|
|
168
|
+
Two rules:
|
|
169
|
+
|
|
170
|
+
1. **Never serve this on the answerer's Publisher.** It is served by a second
|
|
171
|
+
server the answerer has no route to. Serving both from one config puts gold
|
|
172
|
+
within the answerer's reach.
|
|
173
|
+
2. **Never import the model under test.** A truth query that reuses a model
|
|
174
|
+
measure lets a bug in the model certify its own golden.
|
|
175
|
+
|
|
176
|
+
Every golden's `canonicalQuery` runs here. `verify_goldens.py --set <set>`
|
|
177
|
+
re-derives them before each run.
|
|
178
|
+
""")
|
|
179
|
+
|
|
180
|
+
if a.publisher_config:
|
|
181
|
+
cfg: dict = {"frozenConfig": False,
|
|
182
|
+
"environments": [{"name": a.environment,
|
|
183
|
+
"packages": [{"name": a.name,
|
|
184
|
+
"location": str(a.out.resolve())}]}]}
|
|
185
|
+
model_cfg = a.package / "publisher.config.json"
|
|
186
|
+
if not model_cfg.exists():
|
|
187
|
+
model_cfg = a.package.parent / "publisher.config.json"
|
|
188
|
+
if model_cfg.exists():
|
|
189
|
+
try:
|
|
190
|
+
src = json.loads(model_cfg.read_text())
|
|
191
|
+
if src.get("connections"):
|
|
192
|
+
cfg["connections"] = src["connections"]
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
pass
|
|
195
|
+
a.publisher_config.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
a.publisher_config.write_text(json.dumps(cfg, indent=2) + "\n")
|
|
197
|
+
|
|
198
|
+
print(f"{a.out}/truth.malloy ({len(refs)} raw table sources)")
|
|
199
|
+
for conn, ref in refs:
|
|
200
|
+
print(f" {conn}.table('{ref}')")
|
|
201
|
+
print(f"{a.out}/publisher.json {a.out}/README.md")
|
|
202
|
+
if a.publisher_config:
|
|
203
|
+
print(f"{a.publisher_config} (serve with: eval-loop/scripts/serve.py "
|
|
204
|
+
f"--server-root <root> --port 4881 --mcp-port 4882 --allow-proxy)")
|
|
205
|
+
rel = [r for _, r in refs if "/" in r and not r.startswith("/") and "://" not in r]
|
|
206
|
+
if rel:
|
|
207
|
+
print(f"\n ! {len(rel)} table reference(s) are relative paths (e.g. {rel[0]!r}). "
|
|
208
|
+
f"They resolve against the TRUTH package directory, so point them at "
|
|
209
|
+
f"the model's data (../{a.package.name}/data/...) or symlink data/ here.")
|
|
210
|
+
print("\nNext: add scope filters and raw dimensions by hand (see the header), then "
|
|
211
|
+
"set `truthPackage` in set.json.")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for init_truth_package. Stdlib only: python3 init_truth_package_test.py"""
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from init_truth_package import stem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StemTests(unittest.TestCase):
|
|
10
|
+
def test_file_path_names_the_file_not_the_extension(self):
|
|
11
|
+
# The regression. Every table in a parquet package scaffolded as
|
|
12
|
+
# t_parquet, t_parquet_2, ... because the split ran before the
|
|
13
|
+
# extension strip, and a golden author cannot write against those.
|
|
14
|
+
self.assertEqual(stem("data/users.parquet"), "t_users")
|
|
15
|
+
self.assertEqual(stem("data/order_items.parquet"), "t_order_items")
|
|
16
|
+
|
|
17
|
+
def test_a_whole_package_of_one_file_type_gets_distinct_names(self):
|
|
18
|
+
refs = ["data/users.parquet", "data/products.parquet",
|
|
19
|
+
"data/inventory_items.parquet", "data/order_items.parquet"]
|
|
20
|
+
self.assertEqual(len({stem(r) for r in refs}), 4)
|
|
21
|
+
|
|
22
|
+
def test_warehouse_ref_still_takes_the_last_dot_segment(self):
|
|
23
|
+
self.assertEqual(stem("analytics.public.orders"), "t_orders")
|
|
24
|
+
self.assertEqual(stem("orders"), "t_orders")
|
|
25
|
+
|
|
26
|
+
def test_bare_filename_without_a_directory(self):
|
|
27
|
+
self.assertEqual(stem("users.parquet"), "t_users")
|
|
28
|
+
|
|
29
|
+
def test_prefix_noise_is_trimmed(self):
|
|
30
|
+
self.assertEqual(stem("data/fact_sales.csv"), "t_sales")
|
|
31
|
+
self.assertEqual(stem("/abs/path/dim_date.parquet"), "t_date")
|
|
32
|
+
self.assertEqual(stem("warehouse.dim_user"), "t_user")
|
|
33
|
+
|
|
34
|
+
def test_dots_inside_a_filename_survive(self):
|
|
35
|
+
# `.parquet` is the extension; `.b` is part of the name.
|
|
36
|
+
self.assertEqual(stem("data/a.b.parquet"), "t_a_b")
|
|
37
|
+
|
|
38
|
+
def test_path_with_no_extension(self):
|
|
39
|
+
self.assertEqual(stem("data/users"), "t_users")
|
|
40
|
+
|
|
41
|
+
def test_every_other_extension_we_know(self):
|
|
42
|
+
for ext in ("csv", "tsv", "json", "jsonl", "ndjson", "orc", "avro"):
|
|
43
|
+
self.assertEqual(stem(f"data/events.{ext}"), "t_events", ext)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
os.chdir(os.path.dirname(__file__))
|
|
48
|
+
unittest.main()
|