@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.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +162 -0
  3. package/skills/eval-answer/reference/ledger-schema.md +324 -0
  4. package/skills/eval-answer/scripts/check_contamination.py +213 -0
  5. package/skills/eval-answer/scripts/check_contamination_test.py +128 -0
  6. package/skills/eval-answer/scripts/init_truth_package.py +216 -0
  7. package/skills/eval-answer/scripts/init_truth_package_test.py +48 -0
  8. package/skills/eval-answer/scripts/ledger.py +386 -0
  9. package/skills/eval-answer/scripts/ledger_test.py +134 -0
  10. package/skills/eval-answer/scripts/mcp_payload.py +212 -0
  11. package/skills/eval-answer/scripts/mcp_payload_test.py +219 -0
  12. package/skills/eval-answer/scripts/publisher_rest.py +172 -0
  13. package/skills/eval-answer/scripts/publisher_rest_test.py +78 -0
  14. package/skills/eval-answer/scripts/score_retrieval.py +363 -0
  15. package/skills/eval-answer/scripts/score_retrieval_test.py +328 -0
  16. package/skills/eval-answer/scripts/verify_goldens.py +389 -0
  17. package/skills/eval-diagnose/SKILL.md +248 -0
  18. package/skills/eval-diagnose/reference/output-contract.md +47 -0
  19. package/skills/eval-diagnose/scripts/cluster_failures.py +151 -0
  20. package/skills/eval-diagnose/scripts/diagnose.py +481 -0
  21. package/skills/eval-improve/SKILL.md +181 -0
  22. package/skills/eval-improve/reference/output-contract.md +27 -0
  23. package/skills/eval-improve/scripts/improve.py +425 -0
  24. package/skills/eval-judge/SKILL.md +180 -0
  25. package/skills/eval-judge/reference/refusal.md +101 -0
  26. package/skills/eval-judge/reference/retrieval-judge.md +41 -0
  27. package/skills/eval-judge/reference/suspect-goldens.md +39 -0
  28. package/skills/eval-judge/reference/writing-rubrics.md +55 -0
  29. package/skills/eval-loop/SKILL.md +350 -0
  30. package/skills/eval-loop/reference/acceptance-check.md +58 -0
  31. package/skills/eval-loop/reference/checking-the-judge.md +31 -0
  32. package/skills/eval-loop/reference/golden-side-door.md +137 -0
  33. package/skills/eval-loop/reference/measurement.md +120 -0
  34. package/skills/eval-loop/reference/running-a-run.md +82 -0
  35. package/skills/eval-loop/scripts/agent_harness.py +434 -0
  36. package/skills/eval-loop/scripts/build_run_package.py +509 -0
  37. package/skills/eval-loop/scripts/check_judge.py +183 -0
  38. package/skills/eval-loop/scripts/flip_table.py +315 -0
  39. package/skills/eval-loop/scripts/platform_target_test.py +101 -0
  40. package/skills/eval-loop/scripts/run_baseline.py +1491 -0
  41. package/skills/eval-loop/scripts/serve.py +130 -0
  42. package/skills/eval-loop/templates/eval-run-package/README.md +183 -0
  43. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +757 -0
  44. package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +160 -0
  45. package/skills/eval-loop/templates/eval-run-package/public/app.js +406 -0
  46. package/skills/eval-loop/templates/eval-run-package/public/index.html +215 -0
  47. package/skills/eval-loop/templates/eval-run-package/publisher.json +5 -0
  48. package/skills/malloy/SKILL.md +13 -2
  49. package/skills/malloy-analysis/SKILL.md +1 -1
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  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 +1 -1
  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 +1 -1
  61. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  62. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  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 +1 -1
  75. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +1 -1
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. 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()