@malloy-publisher/skills 0.1.35 → 0.1.37

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 (90) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/skills/eval-answer/SKILL.md +2 -3
  4. package/skills/eval-answer/reference/ledger-schema.md +5 -2
  5. package/skills/eval-answer/scripts/golden_rows.py +164 -0
  6. package/skills/eval-answer/scripts/golden_rows_test.py +178 -0
  7. package/skills/eval-answer/scripts/init_truth_package.py +7 -0
  8. package/skills/eval-answer/scripts/init_truth_package_test.py +20 -0
  9. package/skills/eval-answer/scripts/ledger.py +8 -4
  10. package/skills/eval-answer/scripts/ledger_test.py +11 -0
  11. package/skills/eval-answer/scripts/publisher_rest.py +15 -5
  12. package/skills/eval-answer/scripts/publisher_rest_test.py +20 -0
  13. package/skills/eval-answer/scripts/verify_goldens.py +39 -5
  14. package/skills/eval-answer/scripts/verify_goldens_test.py +102 -0
  15. package/skills/eval-diagnose/SKILL.md +1 -1
  16. package/skills/eval-diagnose/scripts/diagnose.py +16 -5
  17. package/skills/eval-diagnose/scripts/diagnose_test.py +19 -0
  18. package/skills/eval-import/SKILL.md +1 -1
  19. package/skills/eval-improve/SKILL.md +1 -1
  20. package/skills/eval-improve/scripts/improve.py +18 -4
  21. package/skills/eval-improve/scripts/improve_test.py +19 -0
  22. package/skills/eval-judge/SKILL.md +1 -1
  23. package/skills/eval-loop/SKILL.md +1 -1
  24. package/skills/eval-loop/scripts/agent_harness.py +1 -1
  25. package/skills/eval-loop/scripts/build_run_package.py +21 -6
  26. package/skills/eval-loop/scripts/build_run_package_test.py +38 -1
  27. package/skills/eval-loop/scripts/check_judge.py +16 -3
  28. package/skills/eval-loop/scripts/check_judge_test.py +15 -0
  29. package/skills/eval-loop/scripts/flip_table.py +14 -3
  30. package/skills/eval-loop/scripts/flip_table_test.py +12 -1
  31. package/skills/eval-loop/scripts/run_baseline.py +220 -43
  32. package/skills/eval-loop/scripts/run_baseline_test.py +370 -0
  33. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +3 -0
  34. package/skills/eval-loop/templates/eval-run-package/public/app.js +2 -2
  35. package/skills/eval-report/SKILL.md +1 -1
  36. package/skills/malloy-analysis/SKILL.md +39 -34
  37. package/skills/malloy-analysis-report/SKILL.md +1 -1
  38. package/skills/malloy-charts/SKILL.md +25 -11
  39. package/skills/malloy-dashboards/SKILL.md +17 -9
  40. package/skills/malloy-define/SKILL.md +24 -6
  41. package/skills/malloy-discover/SKILL.md +23 -11
  42. package/skills/malloy-document/SKILL.md +1 -1
  43. package/skills/malloy-getting-started/SKILL.md +13 -2
  44. package/skills/malloy-gotchas-modeling/SKILL.md +35 -9
  45. package/skills/malloy-html-data-apps/SKILL.md +12 -12
  46. package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/archetypes.md +1 -1
  47. package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/depth-patterns.md +1 -1
  48. package/skills/{malloy-data-app-design/SKILL.md → malloy-html-data-apps/reference/design.md} +0 -5
  49. package/skills/{malloy-html-data-app-embedding/SKILL.md → malloy-html-data-apps/reference/embedding.md} +0 -5
  50. package/skills/{malloy-html-data-app-runtime/SKILL.md → malloy-html-data-apps/reference/runtime.md} +0 -5
  51. package/skills/malloy-html-data-apps/reference/verification-harness.md +2 -2
  52. package/skills/malloy-lookml-review/SKILL.md +1 -1
  53. package/skills/malloy-lookml-review/reference/propose-fields.md +1 -1
  54. package/skills/malloy-materialization/SKILL.md +57 -117
  55. package/skills/malloy-materialization/reference/access-filter.md +25 -0
  56. package/skills/malloy-materialization/reference/annotation.md +56 -0
  57. package/skills/malloy-materialization/reference/chaining.md +48 -0
  58. package/skills/malloy-materialization/reference/policy.md +52 -0
  59. package/skills/malloy-materialization/reference/refresh.md +53 -0
  60. package/skills/malloy-materialization/reference/storage.md +67 -0
  61. package/skills/malloy-materialization/reference/tuning.md +105 -0
  62. package/skills/malloy-model/SKILL.md +3 -4
  63. package/skills/malloy-model/reference/analysis-to-model.md +1 -1
  64. package/skills/malloy-model/reference/bridge-tables.md +13 -4
  65. package/skills/malloy-model-as-you-go/SKILL.md +5 -5
  66. package/skills/malloy-modeling/SKILL.md +14 -14
  67. package/skills/malloy-notebooks/SKILL.md +2 -2
  68. package/skills/malloy-phrase-detection/SKILL.md +2 -2
  69. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  70. package/skills/malloy-powerbi-review/reference/_concepts.md +1 -1
  71. package/skills/malloy-publish/SKILL.md +5 -5
  72. package/skills/malloy-queries/SKILL.md +50 -4
  73. package/skills/malloy-review/SKILL.md +1 -1
  74. package/skills/malloy-review/reference/rubric-correctness.md +6 -6
  75. package/skills/malloy-review/reference/rubric-documentation.md +1 -1
  76. package/skills/malloy-review/reference/rubric-queries.md +3 -3
  77. package/skills/malloy-review/reference/rubric-rendering.md +6 -6
  78. package/skills/malloy-review/reference/severity-taxonomy.md +1 -1
  79. package/skills/malloy-source-unreachable/SKILL.md +2 -2
  80. package/skills/malloy/SKILL.md +0 -103
  81. package/skills/malloy-analysis-pitfalls/SKILL.md +0 -89
  82. package/skills/malloy-analyze/SKILL.md +0 -271
  83. package/skills/malloy-debug/SKILL.md +0 -129
  84. package/skills/malloy-gotchas-queries/SKILL.md +0 -211
  85. package/skills/malloy-gotchas-rendering/SKILL.md +0 -129
  86. package/skills/malloy-materialization-tuning/SKILL.md +0 -104
  87. package/skills/malloy-patterns/SKILL.md +0 -122
  88. package/skills/malloy-scope/SKILL.md +0 -112
  89. /package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/token-contract.md +0 -0
  90. /package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/visual-craft.md +0 -0
package/README.md CHANGED
@@ -77,8 +77,7 @@ skill points to for detail it does not inline. Start with `malloy-getting-starte
77
77
  `malloy-modeling` to build or change a model, `malloy-analysis` to explore and answer questions, and
78
78
  `malloy-review` to check Malloy for correctness.
79
79
 
80
- A few skills (`malloy-modeling`, `malloy-publish`, `malloy-document`, `malloy-getting-started`, and
81
- the `malloy` index) are written for a Publisher host and name Publisher's MCP tools
80
+ A few skills (`malloy-modeling`, `malloy-publish`, `malloy-document`, and `malloy-getting-started`) are written for a Publisher host and name Publisher's MCP tools
82
81
  directly. The rest describe Malloy itself and refer to tools by bare name (`get_context`,
83
82
  `execute_query`, `search_malloy_docs`), because the prefix depends on the
84
83
  host. `search_database_schema` maps the same way if a shared skill starts using it. On Publisher those are `get_context`, `execute_query`, `search_malloy_docs`, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.35",
4
+ "version": "0.1.37",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.35
4
+ version: 0.1.37
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -290,5 +290,4 @@ original answerer to "try again" with the new key in context.
290
290
 
291
291
  - `skill:eval-diagnose`: why it failed, after this record exists.
292
292
  - `skill:eval-improve`: smallest model edit, model-owned issues only.
293
- - The `malloy-analysis-pitfalls` skill: checks before you trust a result you ran.
294
- The judge loads it by name; it is not part of the `eval` group.
293
+ - Step 5 of the `malloy-analysis` skill: checks before you trust a result you ran.
@@ -90,7 +90,7 @@ One JSON object per line:
90
90
  | `tags` | list |
91
91
  | `state` | `candidate` / `selected` / `excluded`. |
92
92
  | `source` | Where the case came from. |
93
- | `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory; a `scalar` value is an object naming the canonical query's column, `{"net_sales": 834216}`, never a bare number), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |
93
+ | `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (a `scalar` value is an object naming the canonical query's column, `{"net_sales": 834216}`, never a bare number; a `rows` golden's `path` is relative to the set directory and names either a CSV with a header row or a JSON array of row objects, and `value` wins when both are present -- `gold/<qid>.json`, the derivation record below, is neither and is refused; a file with no header or no data rows is an error, so a key that really is no rows is written `value: []`), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |
94
94
  | `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |
95
95
  | `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |
96
96
  | `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |
@@ -180,6 +180,7 @@ exist in the flat shape, so the flat shape is the contract.) `kind` is one of:
180
180
  | `submitted` | bool | False when there was no final query. Not a wrong answer. |
181
181
  | `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |
182
182
  | `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |
183
+ | `final_givens` | object or null | The `givens` (Publisher runtime parameters, `{name: value}`) the call behind `final_query` was sent with. A query written against a given returns unfiltered rows when re-run without it. Null when the call passed none. |
183
184
  | `servedRevision` | string or null | From the package actually queried. |
184
185
  | `n_get_context` / `n_execute` / `n_execute_errors` | int | |
185
186
  | `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |
@@ -213,9 +214,11 @@ One event per MCP `get_context` or `execute_query` the attempt made.
213
214
  | `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |
214
215
  | `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |
215
216
  | `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |
217
+ | `givens` | object or null | `execute_query` only: the `{name: value}` runtime parameters the call was sent with. Part of the same fact as `query` and `modelPath`: the text alone, re-run without them, returns a different population. |
218
+ | `filterParams` | object or null | `execute_query` only: values for the model's legacy `#(filter)` source filters, a separate mechanism from `givens`. The harness captures `givens`; this stays allowed for records that carry it. |
216
219
  | `error` | string or null | |
217
220
 
218
- Never persist `execute_query` result rows, givens, or credentials.
221
+ Never persist `execute_query` result rows or credentials. `givens` are recorded because they decide what the query means; they are filter values, not secrets, so do not put a credential in one.
219
222
 
220
223
  `targets` records the request; `rankedSummary` records the response. Without
221
224
  both, a low per-attempt recall has two readings that cannot be told apart: the
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Read a `rows` golden, wherever it keeps its rows.
3
+
4
+ A rows golden holds its key in `golden.value` (a list of row objects) or, for a
5
+ long one, in a CSV named by `golden.path`, relative to the set folder. Code
6
+ that read `value` alone treated a path-held golden as having no key: the judge
7
+ was told the question was unanswerable, and the golden audit reported an error.
8
+
9
+ `path` names a CSV with a header row, or a JSON array of row objects. `value`
10
+ wins when both exist. A `path` that leaves the set folder, names a file that is
11
+ not there, or holds no header or no data rows raises `GoldenRowsError` with the
12
+ case and the path.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import json
18
+ import pathlib
19
+ import re
20
+ from typing import Any
21
+
22
+ # The most rows a judge prompt carries. A golden past this is cut, and the
23
+ # prompt says so, rather than letting one case fill the context.
24
+ ROWS_CAP = 500
25
+
26
+ _INT = re.compile(r"-?(0|[1-9]\d*)")
27
+ _FLOAT = re.compile(r"-?(0|[1-9]\d*)?\.\d+([eE][-+]?\d+)?")
28
+
29
+
30
+ class GoldenRowsError(ValueError):
31
+ """A rows golden's `path` cannot be read; the message names case and path."""
32
+
33
+
34
+ def _cell(text: str | None) -> Any:
35
+ """A CSV cell as a value: numbers as numbers, empty as null.
36
+
37
+ Only canonical numerals convert, so an identifier such as `007` stays text.
38
+ `true` and `false` (lower case, as JSON and Publisher write them) become
39
+ booleans, so they compare equal to what a query returns.
40
+ """
41
+ if text is None or text == "":
42
+ return None
43
+ if text == "true":
44
+ return True
45
+ if text == "false":
46
+ return False
47
+ if _INT.fullmatch(text):
48
+ return int(text)
49
+ if _FLOAT.fullmatch(text):
50
+ return float(text)
51
+ return text
52
+
53
+
54
+ def load_rows(golden: dict[str, Any] | None, set_dir: pathlib.Path | None,
55
+ qid: str) -> list[dict[str, Any]] | None:
56
+ """The golden's rows, all of them, or None when it holds none.
57
+
58
+ `value` is used as it stands when it is a list. Otherwise `path` is read
59
+ (CSV, or a JSON list of row objects).
60
+ """
61
+ g = golden or {}
62
+ if isinstance(g.get("value"), list):
63
+ return g["value"]
64
+ rel = g.get("path")
65
+ if g.get("value") is not None or not rel:
66
+ return None
67
+ if set_dir is None:
68
+ raise GoldenRowsError(
69
+ f"{qid}: golden.path {rel!r} cannot be read without the set folder")
70
+ root = pathlib.Path(set_dir).resolve()
71
+ target = (root / rel).resolve()
72
+ if not target.is_relative_to(root):
73
+ raise GoldenRowsError(
74
+ f"{qid}: golden.path {rel!r} is outside the set folder {root}; "
75
+ f"keep the file under the set and give its path relative to it")
76
+ if not target.is_file():
77
+ raise GoldenRowsError(
78
+ f"{qid}: golden.path {rel!r} does not exist under {root}")
79
+ try:
80
+ if target.suffix.lower() == ".json":
81
+ rows = json.loads(target.read_text())
82
+ if not (isinstance(rows, list)
83
+ and all(isinstance(r, dict) for r in rows)):
84
+ raise GoldenRowsError(
85
+ f"{qid}: golden.path {rel!r} is JSON but not a list of "
86
+ f"row objects")
87
+ else:
88
+ rows = _read_csv(target, rel, qid)
89
+ except (OSError, ValueError, csv.Error) as exc:
90
+ if isinstance(exc, GoldenRowsError):
91
+ raise
92
+ raise GoldenRowsError(
93
+ f"{qid}: golden.path {rel!r} could not be read: {exc}") from exc
94
+ # A truncated export reads as zero rows, and a judge shown an empty key
95
+ # fails every correct answer. A key that really is "no rows" belongs in
96
+ # `value: []`, where nobody can mistake it for a lost file.
97
+ if not rows:
98
+ raise GoldenRowsError(
99
+ f"{qid}: golden.path {rel!r} has no data rows; re-export it, or "
100
+ f"write `value: []` on the golden if the answer is no rows")
101
+ return rows
102
+
103
+
104
+ def _read_csv(target: pathlib.Path, rel: str, qid: str
105
+ ) -> list[dict[str, Any]]:
106
+ """The CSV's rows, keyed by its header.
107
+
108
+ `csv.reader`, not `DictReader`: DictReader skips a blank line, and DuckDB
109
+ writes a NULL in a one-column file as exactly that, so the row dropped. In
110
+ a one-column file a blank line is one empty value. In a wider file it
111
+ carries no value at all and is skipped, as before.
112
+ """
113
+ with target.open(newline="", encoding="utf-8-sig") as fh:
114
+ reader = csv.reader(fh)
115
+ header = next(reader, None)
116
+ if not header:
117
+ raise GoldenRowsError(
118
+ f"{qid}: golden.path {rel!r} has no header row; the first "
119
+ f"line must name the columns")
120
+ out = []
121
+ for row in reader:
122
+ if not row:
123
+ if len(header) > 1:
124
+ continue
125
+ row = [""]
126
+ if len(row) != len(header):
127
+ raise GoldenRowsError(
128
+ f"{qid}: golden.path {rel!r} line {reader.line_num} "
129
+ f"has a different number of fields than the header "
130
+ f"({len(header)}); fix the row")
131
+ out.append({k: _cell(v) for k, v in zip(header, row)})
132
+ return out
133
+
134
+
135
+ def key_value(golden: dict[str, Any] | None, set_dir: pathlib.Path | None,
136
+ qid: str) -> Any:
137
+ """The golden's key as a reader should see it: its rows when they live in
138
+ `path`, otherwise `value` as it stands. Raises `GoldenRowsError`."""
139
+ rows = load_rows(golden, set_dir, qid)
140
+ return rows if rows is not None else (golden or {}).get("value")
141
+
142
+
143
+ def key_value_or_note(golden: dict[str, Any] | None,
144
+ set_dir: pathlib.Path | None, qid: str) -> Any:
145
+ """`key_value`, with an unreadable `path` given as a note in its place.
146
+
147
+ For a prompt that shows the key to an agent: a note that says the file is
148
+ broken is evidence, while a missing key reads as a keyless case.
149
+ """
150
+ try:
151
+ return key_value(golden, set_dir, qid)
152
+ except GoldenRowsError as exc:
153
+ return f"(the golden's rows could not be read: {exc})"
154
+
155
+
156
+ def render(rows: list[dict[str, Any]], total: int | None = None) -> str:
157
+ """The rows as the judge sees them, cut at ROWS_CAP with the cut stated."""
158
+ total = len(rows) if total is None else total
159
+ shown = rows[:ROWS_CAP]
160
+ text = json.dumps(shown)
161
+ if total > len(shown):
162
+ text += (f" (first {len(shown)} of {total} rows; the rest are not "
163
+ f"shown)")
164
+ return text
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for golden_rows.py: reading a `rows` golden that lives in a file."""
3
+ import pathlib
4
+ import shutil
5
+ import tempfile
6
+ import unittest
7
+
8
+ import sys
9
+
10
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
11
+ import golden_rows # noqa: E402
12
+
13
+
14
+ class LoadRows(unittest.TestCase):
15
+ def setUp(self):
16
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
17
+ self.set_dir = self.tmp / "myset"
18
+ (self.set_dir / "gold").mkdir(parents=True)
19
+
20
+ def tearDown(self):
21
+ shutil.rmtree(self.tmp, ignore_errors=True)
22
+
23
+ def csv(self, name, text):
24
+ (self.set_dir / "gold" / name).write_text(text)
25
+ return {"kind": "rows", "path": f"gold/{name}"}
26
+
27
+ def test_rows_come_from_the_csv_with_numbers_as_numbers(self):
28
+ g = self.csv("q1.csv", "region,total,share\nWest,12,0.5\nEast,7,0.25\n")
29
+ self.assertEqual(
30
+ golden_rows.load_rows(g, self.set_dir, "q1"),
31
+ [{"region": "West", "total": 12, "share": 0.5},
32
+ {"region": "East", "total": 7, "share": 0.25}])
33
+
34
+ def test_an_identifier_with_leading_zeros_stays_text(self):
35
+ g = self.csv("q1.csv", "code,n\n007,1\n")
36
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
37
+ [{"code": "007", "n": 1}])
38
+
39
+ def test_an_empty_cell_is_null(self):
40
+ g = self.csv("q1.csv", "a,b\n1,\n")
41
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
42
+ [{"a": 1, "b": None}])
43
+
44
+ def test_true_and_false_become_booleans(self):
45
+ g = self.csv("q1.csv", "id,active,note\n1,true,False\n2,false,truex\n")
46
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
47
+ [{"id": 1, "active": True, "note": "False"},
48
+ {"id": 2, "active": False, "note": "truex"}])
49
+
50
+ def test_a_row_longer_than_the_header_is_a_golden_rows_error(self):
51
+ g = self.csv("q1.csv", "region,total\nWest,12,EXTRA\n")
52
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
53
+ golden_rows.load_rows(g, self.set_dir, "q1")
54
+ self.assertEqual(
55
+ str(cm.exception),
56
+ "q1: golden.path 'gold/q1.csv' line 2 has a different number of "
57
+ "fields than the header (2); fix the row")
58
+
59
+ def test_a_row_shorter_than_the_header_is_a_golden_rows_error(self):
60
+ g = self.csv("q1.csv", "region,total\nWest,12\nEast\n")
61
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
62
+ golden_rows.load_rows(g, self.set_dir, "q1")
63
+ self.assertIn("line 3", str(cm.exception))
64
+
65
+ def test_an_empty_file_is_a_golden_rows_error(self):
66
+ g = self.csv("q1.csv", "")
67
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
68
+ golden_rows.load_rows(g, self.set_dir, "q1")
69
+ self.assertEqual(
70
+ str(cm.exception),
71
+ "q1: golden.path 'gold/q1.csv' has no header row; the first line "
72
+ "must name the columns")
73
+
74
+ def test_a_header_with_no_rows_is_a_golden_rows_error(self):
75
+ g = self.csv("q1.csv", "region,total\n")
76
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
77
+ golden_rows.load_rows(g, self.set_dir, "q1")
78
+ self.assertEqual(
79
+ str(cm.exception),
80
+ "q1: golden.path 'gold/q1.csv' has no data rows; re-export it, or "
81
+ "write `value: []` on the golden if the answer is no rows")
82
+
83
+ def test_an_empty_json_array_is_a_golden_rows_error(self):
84
+ g = self.csv("q1.json", "[]")
85
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
86
+ golden_rows.load_rows(g, self.set_dir, "q1")
87
+ self.assertIn("has no data rows", str(cm.exception))
88
+
89
+ def test_a_blank_line_in_a_one_column_file_is_a_null(self):
90
+ # DuckDB writes a one-column NULL as a blank line.
91
+ g = self.csv("q1.csv", "region\nWest\n\nEast\n")
92
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
93
+ [{"region": "West"}, {"region": None},
94
+ {"region": "East"}])
95
+
96
+ def test_a_blank_line_in_a_wider_file_is_skipped(self):
97
+ g = self.csv("q1.csv", "a,b\n1,2\n\n3,4\n")
98
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
99
+ [{"a": 1, "b": 2}, {"a": 3, "b": 4}])
100
+
101
+ def test_value_wins_when_both_exist(self):
102
+ g = self.csv("q1.csv", "a\n1\n")
103
+ g["value"] = [{"a": 99}]
104
+ self.assertEqual(golden_rows.load_rows(g, self.set_dir, "q1"),
105
+ [{"a": 99}])
106
+
107
+ def test_neither_value_nor_path_is_none(self):
108
+ self.assertIsNone(golden_rows.load_rows({"kind": "rows"},
109
+ self.set_dir, "q1"))
110
+
111
+ def test_a_missing_file_names_the_case_and_the_path(self):
112
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
113
+ golden_rows.load_rows({"kind": "rows", "path": "gold/nope.csv"},
114
+ self.set_dir, "q7")
115
+ self.assertIn("q7", str(cm.exception))
116
+ self.assertIn("gold/nope.csv", str(cm.exception))
117
+
118
+ def test_a_path_outside_the_set_is_refused(self):
119
+ (self.tmp / "secret.csv").write_text("a\n1\n")
120
+ for bad in ("../secret.csv", str(self.tmp / "secret.csv")):
121
+ with self.subTest(bad=bad):
122
+ with self.assertRaises(golden_rows.GoldenRowsError) as cm:
123
+ golden_rows.load_rows({"kind": "rows", "path": bad},
124
+ self.set_dir, "q1")
125
+ self.assertIn("outside", str(cm.exception))
126
+ self.assertIn("q1", str(cm.exception))
127
+
128
+ def test_a_symlink_out_of_the_set_is_refused(self):
129
+ (self.tmp / "secret.csv").write_text("a\n1\n")
130
+ (self.set_dir / "gold" / "link.csv").symlink_to(self.tmp / "secret.csv")
131
+ with self.assertRaises(golden_rows.GoldenRowsError):
132
+ golden_rows.load_rows({"kind": "rows", "path": "gold/link.csv"},
133
+ self.set_dir, "q1")
134
+
135
+
136
+ class KeyValue(unittest.TestCase):
137
+ """What diagnose, improve and the rubric audit show as the key."""
138
+
139
+ def setUp(self):
140
+ self.set_dir = pathlib.Path(tempfile.mkdtemp())
141
+ (self.set_dir / "gold").mkdir()
142
+ (self.set_dir / "gold" / "q1.csv").write_text("a\n1\n")
143
+
144
+ def tearDown(self):
145
+ shutil.rmtree(self.set_dir, ignore_errors=True)
146
+
147
+ def test_a_path_held_golden_gives_its_rows(self):
148
+ self.assertEqual(golden_rows.key_value(
149
+ {"kind": "rows", "path": "gold/q1.csv"}, self.set_dir, "q1"),
150
+ [{"a": 1}])
151
+
152
+ def test_a_value_golden_gives_its_value(self):
153
+ self.assertEqual(golden_rows.key_value(
154
+ {"kind": "scalar", "value": {"n": 3}}, self.set_dir, "q1"),
155
+ {"n": 3})
156
+
157
+ def test_an_unreadable_path_becomes_a_note(self):
158
+ self.assertEqual(
159
+ golden_rows.key_value_or_note(
160
+ {"kind": "rows", "path": "gold/nope.csv"}, self.set_dir, "q1"),
161
+ "(the golden's rows could not be read: q1: golden.path "
162
+ f"'gold/nope.csv' does not exist under {self.set_dir.resolve()})")
163
+
164
+
165
+ class Render(unittest.TestCase):
166
+ def test_a_short_list_renders_whole(self):
167
+ text = golden_rows.render([{"a": 1}], total=1)
168
+ self.assertEqual(text, '[{"a": 1}]')
169
+
170
+ def test_a_long_list_is_cut_and_says_so(self):
171
+ rows = [{"a": i} for i in range(golden_rows.ROWS_CAP + 5)]
172
+ text = golden_rows.render(rows, total=len(rows))
173
+ self.assertIn(f"first {golden_rows.ROWS_CAP} of {len(rows)} rows", text)
174
+ self.assertNotIn(f'"a": {golden_rows.ROWS_CAP + 1}}}', text)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ unittest.main()
@@ -96,7 +96,14 @@ def place_ref(package: pathlib.Path, src: pathlib.Path,
96
96
  return ref, None
97
97
  if "/" not in ref and not DATA_EXT.search(ref):
98
98
  return ref, None # a warehouse table name, not a file
99
+ # Beside the model file first (what this has always done). If nothing is
100
+ # there, the ref was written from the package root, as a model in a
101
+ # subfolder such as `_shared/` does: resolving it against that folder again
102
+ # doubles the folder name and finds nothing.
99
103
  target = (src.parent / ref).resolve()
104
+ from_root = (package / ref).resolve()
105
+ if not target.exists() and from_root.exists():
106
+ target = from_root
100
107
  try:
101
108
  rel = target.relative_to(package.resolve())
102
109
  except ValueError:
@@ -74,6 +74,26 @@ class RefsWorkOnceServed(unittest.TestCase):
74
74
  self.assertEqual(itp.place_ref(self.pkg, src, "u.parquet"),
75
75
  (str(self.pkg / "models" / "u.parquet"), None))
76
76
 
77
+ def test_a_ref_written_from_the_package_root_is_not_resolved_against_its_own_folder(self):
78
+ # A model in `_shared/` names its data relative to the package root:
79
+ # `_shared/data/u.parquet`. Resolved against `_shared/` itself that is
80
+ # `_shared/_shared/data/u.parquet`, which does not exist.
81
+ (self.pkg / "_shared" / "data").mkdir(parents=True)
82
+ (self.pkg / "_shared" / "data" / "u.parquet").write_text("")
83
+ (self.pkg / "_shared" / "x.malloy").write_text("")
84
+ src = self.pkg / "_shared" / "x.malloy"
85
+ served, _ = itp.place_ref(self.pkg, src, "_shared/data/u.parquet")
86
+ self.assertEqual(pathlib.Path(served),
87
+ self.pkg / "_shared" / "data" / "u.parquet")
88
+
89
+ def test_a_ref_beside_its_model_still_resolves_against_that_folder(self):
90
+ (self.pkg / "models").mkdir()
91
+ (self.pkg / "models" / "x.malloy").write_text("")
92
+ (self.pkg / "models" / "u.parquet").write_text("")
93
+ served, _ = itp.place_ref(self.pkg, self.pkg / "models" / "x.malloy",
94
+ "u.parquet")
95
+ self.assertEqual(pathlib.Path(served), self.pkg / "models" / "u.parquet")
96
+
77
97
  def test_a_warehouse_table_is_left_alone(self):
78
98
  self.assertEqual(itp.place_ref(self.pkg, self.pkg / "m.malloy",
79
99
  "analytics.public.orders"),
@@ -65,6 +65,7 @@ EVENTS: dict[str, dict[str, set[str]]] = {
65
65
  "optional": {"question_sha", "servedRevision", "n_get_context",
66
66
  "n_execute", "n_execute_errors", "host_tool_uses",
67
67
  "mcp_tool_uses", "final_query_source",
68
+ "final_givens",
68
69
  "reported_calls", "contaminated", "contamination_reasons",
69
70
  "input_tokens", "output_tokens", "cache_read_tokens",
70
71
  "cache_write_tokens", "skills_invoked",
@@ -80,9 +81,11 @@ EVENTS: dict[str, dict[str, set[str]]] = {
80
81
  # either. Which retriever answered decides whether two runs are
81
82
  # comparable at all, and it is only knowable from the response that
82
83
  # answered.
83
- # `query`, `modelPath` and `filterParams` are one fact about one
84
- # call: the text that ran, the file it was written against, and the
85
- # filter values it ran under. Recorded apart they drift, and two of
84
+ # `query`, `modelPath`, `givens` and `filterParams` are one fact about
85
+ # one call: the text that ran, the file it was written against, and
86
+ # the runtime parameter values it ran under (`givens` is what the
87
+ # harness captures from the answerer's call; `filterParams` is the
88
+ # legacy `#(filter)` mechanism). Recorded apart they drift, and two of
86
89
  # the three drift SILENTLY -- a replay against the wrong file at least
87
90
  # errors, while a replay under another report's filter values returns
88
91
  # real rows for the wrong population. Keeping all three on the event
@@ -94,7 +97,8 @@ EVENTS: dict[str, dict[str, set[str]]] = {
94
97
  # transcripts, and transcripts get pruned.
95
98
  "optional": {"targets", "target_shapes", "scopes", "rankedSummary",
96
99
  "error", "traceId",
97
- "query", "modelPath", "filterParams", "retrieval_mode",
100
+ "query", "modelPath", "givens", "filterParams",
101
+ "retrieval_mode",
98
102
  "at"},
99
103
  },
100
104
  "score": {
@@ -178,6 +178,17 @@ class WriteContract(unittest.TestCase):
178
178
  filterParams={"report_id": "123"})
179
179
  self.assertEqual(e["filterParams"], {"report_id": "123"})
180
180
 
181
+ def test_a_tool_call_and_an_attempt_may_carry_givens(self):
182
+ g = {"region": "West"}
183
+ e = ledger.event("tool_call", qid="q1", sample=0, phase="baseline",
184
+ tool="execute_query", query="run: x -> y",
185
+ modelPath="a.malloy", givens=g)
186
+ self.assertEqual(e["givens"], g)
187
+ e = ledger.event("attempt", qid="q1", sample=0, phase="baseline",
188
+ submitted=True, final_query="run: x -> y",
189
+ answer_text="a", transcriptPath="t", final_givens=g)
190
+ self.assertEqual(e["final_givens"], g)
191
+
181
192
  def test_an_attempt_may_carry_cache_write_tokens(self):
182
193
  # The fourth token column. Without it the ledger held a cache-inclusive
183
194
  # cost_usd and a breakdown that could not reproduce it.
@@ -105,18 +105,28 @@ def rows_from_result(body: Any) -> list[dict[str, Any]]:
105
105
 
106
106
 
107
107
  def query(base: str, environment: str, package: str, model: str, malloy: str,
108
- timeout: int = 180) -> list[dict[str, Any]]:
109
- """Run a Malloy query. Raises on transport, HTTP or parse failure."""
108
+ timeout: int = 180, givens: dict[str, Any] | None = None
109
+ ) -> list[dict[str, Any]]:
110
+ """Run a Malloy query. Raises on transport, HTTP or parse failure.
111
+
112
+ `givens` are the model's runtime parameters, sent as the request's own
113
+ `givens` field. A query written against a given and run without it returns
114
+ unfiltered rows, so a caller re-running someone else's query passes theirs.
115
+ """
116
+ body: dict[str, Any] = {"query": malloy, "compactJson": True}
117
+ if givens:
118
+ body["givens"] = givens
110
119
  req = urllib.request.Request(
111
120
  _query_url(base, environment, package, model),
112
- data=json.dumps({"query": malloy, "compactJson": True}).encode(),
121
+ data=json.dumps(body).encode(),
113
122
  headers={"Content-Type": "application/json"})
114
123
  with urllib.request.urlopen(req, timeout=timeout) as r:
115
124
  return rows_from_result(json.loads(r.read().decode()))
116
125
 
117
126
 
118
127
  def try_query(base: str, environment: str, package: str, model: str,
119
- malloy: str, timeout: int = 120
128
+ malloy: str, timeout: int = 120,
129
+ givens: dict[str, Any] | None = None
120
130
  ) -> tuple[list[dict[str, Any]], str | None]:
121
131
  """Run a Malloy query. Returns (rows, error); never raises.
122
132
 
@@ -125,7 +135,7 @@ def try_query(base: str, environment: str, package: str, model: str,
125
135
  """
126
136
  try:
127
137
  return query(base, environment, package, model, malloy,
128
- timeout=timeout), None
138
+ timeout=timeout, givens=givens), None
129
139
  except urllib.error.HTTPError as exc:
130
140
  return [], f"HTTP {exc.code}: {exc.read().decode()[:300]}"
131
141
  except Exception as exc: # noqa: BLE001
@@ -111,6 +111,26 @@ class CompactJson(unittest.TestCase):
111
111
  self.assertIs(payload["compactJson"], True)
112
112
  self.assertEqual(payload["query"], "run: x -> { ... }")
113
113
 
114
+ def test_givens_travel_in_the_request_body(self):
115
+ seen = {}
116
+
117
+ class Resp:
118
+ def read(self_): return b'{"result": "[]"}'
119
+ def __enter__(self_): return self_
120
+ def __exit__(self_, *a): return False
121
+
122
+ def fake(req, timeout=None):
123
+ seen["payload"] = json.loads(req.data.decode())
124
+ return Resp()
125
+
126
+ with unittest.mock.patch.object(publisher_rest.urllib.request,
127
+ "urlopen", fake):
128
+ publisher_rest.query("http://x", "e", "p", "m.malloy", "run: x",
129
+ givens={"region": "West"})
130
+ self.assertEqual(seen["payload"]["givens"], {"region": "West"})
131
+ publisher_rest.query("http://x", "e", "p", "m.malloy", "run: x")
132
+ self.assertNotIn("givens", seen["payload"])
133
+
114
134
  def test_a_nest_keeps_its_field_names(self):
115
135
  # What the server returns under compactJson: `result` is a JSON
116
136
  # STRING of plain rows, nested names intact.