@malloy-publisher/skills 0.1.35 → 0.1.36
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/README.md +1 -2
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +2 -3
- package/skills/eval-answer/reference/ledger-schema.md +5 -2
- package/skills/eval-answer/scripts/golden_rows.py +164 -0
- package/skills/eval-answer/scripts/golden_rows_test.py +178 -0
- package/skills/eval-answer/scripts/init_truth_package.py +7 -0
- package/skills/eval-answer/scripts/init_truth_package_test.py +20 -0
- package/skills/eval-answer/scripts/ledger.py +8 -4
- package/skills/eval-answer/scripts/ledger_test.py +11 -0
- package/skills/eval-answer/scripts/publisher_rest.py +15 -5
- package/skills/eval-answer/scripts/publisher_rest_test.py +20 -0
- package/skills/eval-answer/scripts/verify_goldens.py +39 -5
- package/skills/eval-answer/scripts/verify_goldens_test.py +102 -0
- package/skills/eval-diagnose/SKILL.md +1 -1
- package/skills/eval-diagnose/scripts/diagnose.py +16 -5
- package/skills/eval-diagnose/scripts/diagnose_test.py +19 -0
- package/skills/eval-import/SKILL.md +1 -1
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-improve/scripts/improve.py +18 -4
- package/skills/eval-improve/scripts/improve_test.py +19 -0
- package/skills/eval-judge/SKILL.md +1 -1
- package/skills/eval-loop/SKILL.md +1 -1
- package/skills/eval-loop/scripts/agent_harness.py +1 -1
- package/skills/eval-loop/scripts/build_run_package.py +21 -6
- package/skills/eval-loop/scripts/build_run_package_test.py +38 -1
- package/skills/eval-loop/scripts/check_judge.py +16 -3
- package/skills/eval-loop/scripts/check_judge_test.py +15 -0
- package/skills/eval-loop/scripts/flip_table.py +14 -3
- package/skills/eval-loop/scripts/flip_table_test.py +12 -1
- package/skills/eval-loop/scripts/run_baseline.py +220 -43
- package/skills/eval-loop/scripts/run_baseline_test.py +370 -0
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +3 -0
- package/skills/eval-loop/templates/eval-run-package/public/app.js +2 -2
- package/skills/eval-report/SKILL.md +1 -1
- package/skills/malloy-analysis/SKILL.md +38 -34
- package/skills/malloy-analysis-report/SKILL.md +1 -1
- package/skills/malloy-charts/SKILL.md +25 -11
- package/skills/malloy-dashboards/SKILL.md +4 -5
- package/skills/malloy-define/SKILL.md +24 -6
- package/skills/malloy-discover/SKILL.md +23 -11
- package/skills/malloy-document/SKILL.md +1 -1
- package/skills/malloy-getting-started/SKILL.md +13 -2
- package/skills/malloy-gotchas-modeling/SKILL.md +34 -8
- package/skills/malloy-html-data-apps/SKILL.md +12 -12
- package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/archetypes.md +1 -1
- package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/depth-patterns.md +1 -1
- package/skills/{malloy-data-app-design/SKILL.md → malloy-html-data-apps/reference/design.md} +0 -5
- package/skills/{malloy-html-data-app-embedding/SKILL.md → malloy-html-data-apps/reference/embedding.md} +0 -5
- package/skills/{malloy-html-data-app-runtime/SKILL.md → malloy-html-data-apps/reference/runtime.md} +0 -5
- package/skills/malloy-html-data-apps/reference/verification-harness.md +2 -2
- package/skills/malloy-lookml-review/SKILL.md +1 -1
- package/skills/malloy-lookml-review/reference/propose-fields.md +1 -1
- package/skills/malloy-materialization/SKILL.md +4 -2
- package/skills/{malloy-materialization-tuning/SKILL.md → malloy-materialization/reference/tuning.md} +1 -7
- package/skills/malloy-model/SKILL.md +3 -4
- package/skills/malloy-model/reference/analysis-to-model.md +1 -1
- package/skills/malloy-model/reference/bridge-tables.md +13 -4
- package/skills/malloy-model-as-you-go/SKILL.md +5 -5
- package/skills/malloy-modeling/SKILL.md +14 -14
- package/skills/malloy-notebooks/SKILL.md +2 -2
- package/skills/malloy-phrase-detection/SKILL.md +2 -2
- package/skills/malloy-powerbi-review/SKILL.md +1 -1
- package/skills/malloy-publish/SKILL.md +5 -5
- package/skills/malloy-queries/SKILL.md +50 -4
- package/skills/malloy-review/SKILL.md +1 -1
- package/skills/malloy-review/reference/rubric-correctness.md +6 -6
- package/skills/malloy-review/reference/rubric-documentation.md +1 -1
- package/skills/malloy-review/reference/rubric-queries.md +3 -3
- package/skills/malloy-review/reference/rubric-rendering.md +6 -6
- package/skills/malloy-review/reference/severity-taxonomy.md +1 -1
- package/skills/malloy-source-unreachable/SKILL.md +2 -2
- package/skills/malloy/SKILL.md +0 -103
- package/skills/malloy-analysis-pitfalls/SKILL.md +0 -89
- package/skills/malloy-analyze/SKILL.md +0 -271
- package/skills/malloy-debug/SKILL.md +0 -129
- package/skills/malloy-gotchas-queries/SKILL.md +0 -211
- package/skills/malloy-gotchas-rendering/SKILL.md +0 -129
- package/skills/malloy-patterns/SKILL.md +0 -122
- package/skills/malloy-scope/SKILL.md +0 -112
- /package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/token-contract.md +0 -0
- /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
|
|
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: 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.
|
|
4
|
+
version: 0.1.36
|
|
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
|
-
-
|
|
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` (
|
|
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
|
|
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
|
|
84
|
-
# call: the text that ran, the file it was written against, and
|
|
85
|
-
#
|
|
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", "
|
|
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
|
|
109
|
-
|
|
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(
|
|
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.
|