@malloy-publisher/skills 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +3 -2
- package/skills/eval-answer/reference/ledger-schema.md +15 -6
- package/skills/eval-answer/scripts/check_coverage.py +25 -11
- package/skills/eval-answer/scripts/check_coverage_test.py +9 -6
- package/skills/eval-answer/scripts/check_findable.py +85 -48
- package/skills/eval-answer/scripts/check_findable_test.py +58 -17
- package/skills/eval-answer/scripts/config.py +317 -0
- package/skills/eval-answer/scripts/config_test.py +171 -0
- package/skills/eval-answer/scripts/init_truth_package.py +78 -18
- package/skills/eval-answer/scripts/init_truth_package_test.py +61 -0
- package/skills/eval-answer/scripts/ledger.py +3 -0
- package/skills/eval-answer/scripts/verify_definitions.py +11 -5
- package/skills/eval-answer/scripts/verify_definitions_test.py +3 -2
- package/skills/eval-answer/scripts/verify_goldens.py +34 -15
- package/skills/eval-answer/scripts/verify_goldens_test.py +24 -1
- package/skills/eval-diagnose/SKILL.md +2 -2
- package/skills/eval-diagnose/scripts/diagnose.py +23 -5
- package/skills/eval-diagnose/scripts/diagnose_test.py +36 -0
- package/skills/eval-import/SKILL.md +1 -1
- package/skills/eval-import/reference/case-format.md +1 -1
- package/skills/eval-import/scripts/import_cases.py +8 -0
- package/skills/eval-import/scripts/import_cases_test.py +25 -17
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-improve/scripts/improve.py +37 -22
- package/skills/eval-judge/SKILL.md +1 -1
- package/skills/eval-loop/SKILL.md +27 -12
- package/skills/eval-loop/reference/running-a-run.md +77 -54
- package/skills/eval-loop/reference/setting-up-a-set.md +153 -0
- package/skills/eval-loop/scripts/build_run_package.py +115 -8
- package/skills/eval-loop/scripts/build_run_package_test.py +141 -2
- package/skills/eval-loop/scripts/check_judge.py +15 -6
- package/skills/eval-loop/scripts/check_set.py +201 -0
- package/skills/eval-loop/scripts/check_set_test.py +121 -0
- package/skills/eval-loop/scripts/eval.py +136 -0
- package/skills/eval-loop/scripts/eval_test.py +84 -0
- package/skills/eval-loop/scripts/run_baseline.py +145 -63
- package/skills/eval-loop/scripts/run_baseline_test.py +112 -23
- package/skills/eval-loop/scripts/serve.py +273 -24
- package/skills/eval-loop/scripts/serve_test.py +190 -0
- package/skills/eval-loop/templates/eval-run-package/README.md +12 -5
- package/skills/eval-report/SKILL.md +16 -8
- package/skills/malloy/SKILL.md +1 -1
- package/skills/malloy-analysis/SKILL.md +1 -1
- package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
- package/skills/malloy-analysis-report/SKILL.md +1 -1
- package/skills/malloy-analyze/SKILL.md +1 -1
- package/skills/malloy-charts/SKILL.md +1 -1
- package/skills/malloy-dashboards/SKILL.md +1 -1
- package/skills/malloy-data-app-design/SKILL.md +1 -1
- package/skills/malloy-debug/SKILL.md +1 -1
- package/skills/malloy-define/SKILL.md +1 -1
- package/skills/malloy-discover/SKILL.md +1 -1
- package/skills/malloy-document/SKILL.md +1 -1
- package/skills/malloy-getting-started/SKILL.md +1 -1
- package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
- package/skills/malloy-gotchas-queries/SKILL.md +1 -1
- package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
- package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
- package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
- package/skills/malloy-html-data-apps/SKILL.md +1 -1
- package/skills/malloy-lookml-review/SKILL.md +1 -1
- package/skills/malloy-materialization/SKILL.md +1 -1
- package/skills/malloy-materialization-tuning/SKILL.md +1 -1
- package/skills/malloy-model/SKILL.md +1 -1
- package/skills/malloy-model-as-you-go/SKILL.md +1 -1
- package/skills/malloy-modeling/SKILL.md +1 -1
- package/skills/malloy-notebook-chat/SKILL.md +1 -1
- package/skills/malloy-notebooks/SKILL.md +1 -1
- package/skills/malloy-patterns/SKILL.md +1 -1
- package/skills/malloy-phrase-detection/SKILL.md +1 -1
- package/skills/malloy-powerbi-review/SKILL.md +1 -1
- package/skills/malloy-publish/SKILL.md +1 -1
- package/skills/malloy-queries/SKILL.md +1 -1
- package/skills/malloy-review/SKILL.md +1 -1
- package/skills/malloy-scope/SKILL.md +1 -1
- package/skills/malloy-source-unreachable/SKILL.md +1 -1
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.33
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Evaluate One Answer
|
|
@@ -257,7 +257,8 @@ otherwise a modelling agent is dispatched to fix a model that is already right.
|
|
|
257
257
|
|
|
258
258
|
## Step 6: Append events, then stop
|
|
259
259
|
|
|
260
|
-
Append to
|
|
260
|
+
Append to `<workdir>/runs/<runId>/events.jsonl` with `caseId` set (the run
|
|
261
|
+
directory `eval.py run` printed; the workdir is the set's, from `eval.toml`). Shapes
|
|
261
262
|
live in `reference/ledger-schema.md`.
|
|
262
263
|
|
|
263
264
|
1. `attempt`: qid, sample, phase, question_sha, submitted, final_query,
|
|
@@ -29,20 +29,28 @@ grandfathered unknown fields on old runs.
|
|
|
29
29
|
## Layout
|
|
30
30
|
|
|
31
31
|
```
|
|
32
|
-
evals/<set>/
|
|
32
|
+
evals/<set>/ # the set, in the model's repository
|
|
33
33
|
set.json # set metadata (below)
|
|
34
|
+
eval.toml # servers and paths for eval.py (config.py)
|
|
34
35
|
cases.jsonl # one case per line
|
|
35
36
|
judge-regressions.jsonl # judge verdicts a human overruled
|
|
37
|
+
|
|
38
|
+
<workdir>/ # [paths] workdir, default ~/.malloy-eval/<set>-<hash>/
|
|
36
39
|
runs/<runId>/
|
|
37
40
|
run.json # run config, the attribution pins
|
|
38
41
|
events.jsonl # append-only event lines
|
|
39
42
|
artifacts/ # prediction CSVs, judge outputs, transcripts
|
|
43
|
+
packages/ # built report packages
|
|
44
|
+
servers/<role>/ # server roots for eval.py serve
|
|
40
45
|
```
|
|
41
46
|
|
|
42
47
|
The set directory lives in the SAME git repository as the model it evaluates,
|
|
43
|
-
so a checkpoint (a git commit) pins the model and the
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
so a checkpoint (a git commit) pins the model and the answer key together.
|
|
49
|
+
The workdir is never inside the model package: a run holds a `model.malloy`
|
|
50
|
+
snapshot and a report is a Malloy package, and either one nested there puts
|
|
51
|
+
the package into `loadErrors`. For a checkpoint to pin the ledger as well,
|
|
52
|
+
point the workdir at a directory in the repository outside the package, and
|
|
53
|
+
gitignore its `servers/` and `packages/`.
|
|
46
54
|
|
|
47
55
|
Rules that make the ledger trustworthy:
|
|
48
56
|
|
|
@@ -82,7 +90,7 @@ One JSON object per line:
|
|
|
82
90
|
| `tags` | list |
|
|
83
91
|
| `state` | `candidate` / `selected` / `excluded`. |
|
|
84
92
|
| `source` | Where the case came from. |
|
|
85
|
-
| `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), `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` (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). |
|
|
86
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. |
|
|
87
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. |
|
|
88
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. |
|
|
@@ -129,6 +137,7 @@ on that axis.
|
|
|
129
137
|
| `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |
|
|
130
138
|
| `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |
|
|
131
139
|
| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |
|
|
140
|
+
| `evalConfig` | The set's `eval.toml` (or `eval.json`) the run read, as `{path, values}`; `path` is null when the set has none. |
|
|
132
141
|
| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |
|
|
133
142
|
| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |
|
|
134
143
|
| `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |
|
|
@@ -142,7 +151,7 @@ on that axis.
|
|
|
142
151
|
| `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every "cost per arm" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |
|
|
143
152
|
| `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |
|
|
144
153
|
| `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |
|
|
145
|
-
| `staleEntityNames` | The `expectedEntities`
|
|
154
|
+
| `staleEntityNames` | The `expectedEntities` ids the compiled served model does not declare, from the in-arm lint (bare names from a search of the model text when the compiled model could not be read). Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |
|
|
146
155
|
| `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |
|
|
147
156
|
| `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |
|
|
148
157
|
| `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |
|
|
@@ -9,7 +9,7 @@ version and read as a trend, which is the point of it.
|
|
|
9
9
|
|
|
10
10
|
python check_coverage.py --set <set-dir> --model <package-dir-or-file>
|
|
11
11
|
python check_coverage.py --set <set-dir> --publisher http://localhost:4811 \
|
|
12
|
-
--environment
|
|
12
|
+
--version 0.0.58 # environment and package from the set's eval.toml
|
|
13
13
|
|
|
14
14
|
It answers a different question from retrieval recall, and the difference is the
|
|
15
15
|
reason it exists. Recall asks whether `get_context` surfaced the entities a case
|
|
@@ -57,9 +57,10 @@ HERE = pathlib.Path(__file__).resolve().parent
|
|
|
57
57
|
SKILLS_ROOT = HERE.parent.parent
|
|
58
58
|
sys.path.insert(0, str(HERE))
|
|
59
59
|
|
|
60
|
+
import config # noqa: E402
|
|
60
61
|
from json_scan import json_objects # noqa: E402
|
|
61
62
|
from verify_goldens import model_text as local_model_text # noqa: E402
|
|
62
|
-
from check_findable import
|
|
63
|
+
from check_findable import current_entities # noqa: E402
|
|
63
64
|
|
|
64
65
|
# The `claude -p` invocation lives once, in the harness, rather than being
|
|
65
66
|
# rewritten here. It costs this script a reach into a sibling skill, which the
|
|
@@ -158,16 +159,20 @@ def compiled_surface(base: str, environment: str, package: str) -> str:
|
|
|
158
159
|
reads those as absent and returns MISSING on a question the model answers
|
|
159
160
|
-- measured, two of this skill's three false gaps on one set.
|
|
160
161
|
|
|
161
|
-
Empty string when the compiled model cannot be read,
|
|
162
|
-
prompt then says the list is unavailable rather than implying
|
|
163
|
-
the whole surface.
|
|
162
|
+
Empty string when the compiled model cannot be read, or is stale, which is
|
|
163
|
+
honest: the prompt then says the list is unavailable rather than implying
|
|
164
|
+
the text is the whole surface.
|
|
164
165
|
"""
|
|
165
|
-
declared =
|
|
166
|
+
declared, warning = current_entities(base, environment, package)
|
|
167
|
+
if warning:
|
|
168
|
+
print(f"! {warning}", file=sys.stderr)
|
|
166
169
|
if not declared:
|
|
167
170
|
return ""
|
|
168
171
|
lines = []
|
|
169
172
|
for src in sorted(declared):
|
|
170
|
-
fields
|
|
173
|
+
# Own fields only: each joined source is listed under its own name.
|
|
174
|
+
fields = sorted(f for f in declared[src]
|
|
175
|
+
if not f.startswith("source:") and "." not in f)
|
|
171
176
|
if fields:
|
|
172
177
|
lines.append(f"{src}: " + ", ".join(fields))
|
|
173
178
|
return "\n".join(lines)
|
|
@@ -694,8 +699,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
694
699
|
ap.add_argument("--publisher", default=None,
|
|
695
700
|
help="read the model over REST instead, from a running "
|
|
696
701
|
"Publisher serving the version to measure")
|
|
697
|
-
ap.add_argument("--environment", default=
|
|
698
|
-
|
|
702
|
+
ap.add_argument("--environment", default=None,
|
|
703
|
+
help="with --publisher: the environment serving --package. "
|
|
704
|
+
"Default: [model] environment in the set's eval.toml")
|
|
705
|
+
ap.add_argument("--package", default=None,
|
|
706
|
+
help="with --publisher: the package to measure. Default: "
|
|
707
|
+
"[model] package in the set's eval.toml, then "
|
|
708
|
+
"set.json's targetPackage")
|
|
699
709
|
ap.add_argument("--model-path", dest="one_model", default=None,
|
|
700
710
|
help="with --publisher: measure one model file rather than "
|
|
701
711
|
"every model in the package")
|
|
@@ -754,7 +764,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
754
764
|
model = local_model_text(a.model_path)
|
|
755
765
|
if not model:
|
|
756
766
|
raise SystemExit(f"no .malloy text under {a.model_path}")
|
|
757
|
-
elif a.publisher
|
|
767
|
+
elif a.publisher:
|
|
768
|
+
cfg = config.load(a.set_dir)
|
|
769
|
+
a.environment = cfg.need(a.environment, "model", "environment",
|
|
770
|
+
"--environment")
|
|
771
|
+
a.package = cfg.need(a.package, "model", "package", "--package")
|
|
758
772
|
try:
|
|
759
773
|
model = rest_model_text(a.publisher, a.environment, a.package,
|
|
760
774
|
a.one_model)
|
|
@@ -764,7 +778,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
764
778
|
raise SystemExit(f"{a.publisher} served no model text for "
|
|
765
779
|
f"{a.environment}/{a.package}")
|
|
766
780
|
else:
|
|
767
|
-
raise SystemExit("pass --model <path>, or --publisher
|
|
781
|
+
raise SystemExit("pass --model <path>, or --publisher <url>")
|
|
768
782
|
|
|
769
783
|
# The compiled surface names the columns a source exposes without declaring
|
|
770
784
|
# them. Only REST can supply it; a --model run reads text off disk and has
|
|
@@ -440,10 +440,10 @@ class CompiledSurface(unittest.TestCase):
|
|
|
440
440
|
"""
|
|
441
441
|
|
|
442
442
|
def test_it_lists_fields_per_source(self):
|
|
443
|
-
with mock.patch.object(cc, "
|
|
443
|
+
with mock.patch.object(cc, "current_entities", return_value=({
|
|
444
444
|
"products": {"source:products", "dimension:retail_price",
|
|
445
445
|
"dimension:cost"},
|
|
446
|
-
"customers": {"source:customers", "dimension:signup_date"}}):
|
|
446
|
+
"customers": {"source:customers", "dimension:signup_date"}}, None)):
|
|
447
447
|
out = cc.compiled_surface("http://p", "env", "pkg")
|
|
448
448
|
self.assertIn("products: dimension:cost, dimension:retail_price", out)
|
|
449
449
|
self.assertIn("customers: dimension:signup_date", out)
|
|
@@ -451,7 +451,9 @@ class CompiledSurface(unittest.TestCase):
|
|
|
451
451
|
self.assertNotIn("source:products", out)
|
|
452
452
|
|
|
453
453
|
def test_an_unreadable_model_is_empty_not_a_guess(self):
|
|
454
|
-
with mock.patch.object(cc, "
|
|
454
|
+
with mock.patch.object(cc, "current_entities",
|
|
455
|
+
return_value=(None, "could not be read")), \
|
|
456
|
+
mock.patch("builtins.print"):
|
|
455
457
|
self.assertEqual(cc.compiled_surface("http://p", "env", "pkg"), "")
|
|
456
458
|
|
|
457
459
|
|
|
@@ -537,17 +539,18 @@ class MainWiring(unittest.TestCase):
|
|
|
537
539
|
prompts[qid] = prompt
|
|
538
540
|
return [{"type": "assistant"}], json.dumps(replies[qid]), "", 1, 0.1
|
|
539
541
|
|
|
540
|
-
entities = mock.Mock(side_effect=surface_raises, return_value=surface)
|
|
542
|
+
entities = mock.Mock(side_effect=surface_raises, return_value=(surface, None))
|
|
541
543
|
err = []
|
|
542
544
|
with mock.patch.object(cc, "rest_model_text",
|
|
543
545
|
return_value="source: products is t"), \
|
|
544
|
-
mock.patch.object(cc, "
|
|
546
|
+
mock.patch.object(cc, "current_entities", entities), \
|
|
545
547
|
mock.patch.object(cc, "run_cli", side_effect=fake_cli), \
|
|
546
548
|
mock.patch("builtins.print",
|
|
547
549
|
lambda *a, **k: err.append(" ".join(map(str, a)))
|
|
548
550
|
if k.get("file") is sys.stderr else None):
|
|
549
551
|
cc.main(["--set", str(d), "--publisher", "http://p",
|
|
550
|
-
"--
|
|
552
|
+
"--environment", "env", "--package", "pkg",
|
|
553
|
+
"--parallel", "1",
|
|
551
554
|
"--out", str(d / "out.json"), *extra])
|
|
552
555
|
return prompts, json.loads((d / "out.json").read_text()), "\n".join(err)
|
|
553
556
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Can a right-typed search actually retrieve each `required` entity? Stdlib only.
|
|
3
3
|
|
|
4
|
+
python3 check_findable.py --set <set-dir> # server and names from its eval.toml
|
|
4
5
|
python3 check_findable.py --set evals/faa-v1 --mcp-url http://localhost:4045/mcp \
|
|
5
6
|
--environment samples --package faa
|
|
6
7
|
|
|
@@ -58,6 +59,7 @@ import urllib.request
|
|
|
58
59
|
from typing import Any
|
|
59
60
|
|
|
60
61
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
62
|
+
import config # noqa: E402
|
|
61
63
|
from mcp_payload import entity_hits # noqa: E402
|
|
62
64
|
|
|
63
65
|
# An entity kind, and the target type that can return it. The inverse of the
|
|
@@ -104,6 +106,24 @@ def get_context(mcp_url: str, targets: list[dict[str, str]],
|
|
|
104
106
|
return json.loads(text)
|
|
105
107
|
|
|
106
108
|
|
|
109
|
+
def add_fields(bucket: set[str], fields: list[Any], prefix: str = "") -> None:
|
|
110
|
+
"""Every field as `kind:name`, and every field reached through a join as
|
|
111
|
+
`kind:join.name`, the dotted path get_context names it by.
|
|
112
|
+
|
|
113
|
+
A join field carries the joined source's schema, so each hop of a path is
|
|
114
|
+
checked against the join the source actually declares. Looking the last
|
|
115
|
+
hop up as a source name instead passed a bogus first hop, and failed a
|
|
116
|
+
join whose name is not its source's (`join_one: buyer is customers`).
|
|
117
|
+
"""
|
|
118
|
+
for f in fields:
|
|
119
|
+
if not (isinstance(f, dict) and f.get("name") and f.get("kind")):
|
|
120
|
+
continue
|
|
121
|
+
bucket.add(f"{f['kind']}:{prefix}{f['name']}")
|
|
122
|
+
if f["kind"] == "join":
|
|
123
|
+
add_fields(bucket, (f.get("schema") or {}).get("fields") or [],
|
|
124
|
+
f"{prefix}{f['name']}.")
|
|
125
|
+
|
|
126
|
+
|
|
107
127
|
def compiled_entities(rest_url: str, environment: str,
|
|
108
128
|
package: str) -> dict[str, set[str]] | None:
|
|
109
129
|
"""`{source: {"kind:name", ...}}` from the COMPILED model, or None.
|
|
@@ -161,9 +181,7 @@ def compiled_entities(rest_url: str, environment: str,
|
|
|
161
181
|
continue
|
|
162
182
|
bucket = out.setdefault(src, set())
|
|
163
183
|
bucket.add(f"source:{src}")
|
|
164
|
-
|
|
165
|
-
if isinstance(f, dict) and f.get("name") and f.get("kind"):
|
|
166
|
-
bucket.add(f"{f['kind']}:{f['name']}")
|
|
184
|
+
add_fields(bucket, fields)
|
|
167
185
|
for q in doc.get("queries") or []:
|
|
168
186
|
if not isinstance(q, dict):
|
|
169
187
|
continue
|
|
@@ -224,14 +242,46 @@ def stale_packages(rest_url: str, timeout: int = 30) -> set[tuple[str, str]] | N
|
|
|
224
242
|
if isinstance(e, dict) and e.get("stale")}
|
|
225
243
|
|
|
226
244
|
|
|
245
|
+
def current_entities(rest_url: str, environment: str,
|
|
246
|
+
package: str) -> tuple[dict[str, set[str]] | None, str | None]:
|
|
247
|
+
"""`compiled_entities`, unless the package is stale, and a warning if any.
|
|
248
|
+
|
|
249
|
+
Staleness first. A stale package answers the models endpoint normally
|
|
250
|
+
while describing the compile BEFORE the last save, so an entity deleted
|
|
251
|
+
since then reads as declared. Refusing to check says so; checking anyway
|
|
252
|
+
would be one more number nobody had earned. Every caller that treats the
|
|
253
|
+
compiled model as the authority goes through here.
|
|
254
|
+
"""
|
|
255
|
+
stale = stale_packages(rest_url)
|
|
256
|
+
if stale and (environment, package) in stale:
|
|
257
|
+
return None, (f"{environment}/{package} is serving a STALE model (its "
|
|
258
|
+
f"last reload failed to compile), so the compiled model "
|
|
259
|
+
f"is not the authority on what exists; existence and "
|
|
260
|
+
f"kind were NOT checked. Fix the model and reload, then "
|
|
261
|
+
f"re-run.")
|
|
262
|
+
declared = compiled_entities(rest_url, environment, package)
|
|
263
|
+
if declared is None:
|
|
264
|
+
return None, ("the compiled model could not be read; existence and "
|
|
265
|
+
"kind were NOT checked")
|
|
266
|
+
if stale is None:
|
|
267
|
+
return declared, ("the server's status could not be read, so "
|
|
268
|
+
"staleness is unknown; treating the compiled model "
|
|
269
|
+
"as current")
|
|
270
|
+
return declared, None
|
|
271
|
+
|
|
272
|
+
|
|
227
273
|
def declared_findings(cases: list[dict[str, Any]],
|
|
228
274
|
declared: dict[str, set[str]]) -> list[str]:
|
|
229
275
|
"""Ids the compiled model does not declare, or declares as another kind."""
|
|
230
276
|
out = []
|
|
231
277
|
for eid, qids in sorted(required_ids(cases).items()):
|
|
232
278
|
parts = eid.split(":", 2)
|
|
233
|
-
if len(parts) < 3:
|
|
234
|
-
|
|
279
|
+
if len(parts) < 3 or not all(parts):
|
|
280
|
+
# Reported here as well as by `check`: the in-run lint calls only
|
|
281
|
+
# this, and an id it skips is one no retrieval can ever match.
|
|
282
|
+
out.append(f"{eid}: not a kind:source:name id "
|
|
283
|
+
f"(required by {', '.join(qids)})")
|
|
284
|
+
continue
|
|
235
285
|
kind, src, name = parts
|
|
236
286
|
if src not in declared:
|
|
237
287
|
out.append(f"{eid}: the compiled model has no source {src!r} "
|
|
@@ -239,25 +289,23 @@ def declared_findings(cases: list[dict[str, Any]],
|
|
|
239
289
|
continue
|
|
240
290
|
if f"{kind}:{name}" in declared[src]:
|
|
241
291
|
continue
|
|
242
|
-
# A DOTTED name is a join path
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
# five of them on one set, against a get_context that returns each as
|
|
246
|
-
# its top result. Resolve it where it lives: the last segment is the
|
|
247
|
-
# field, the segment before it names the joined source.
|
|
292
|
+
# A DOTTED name is a join path. `compiled_entities` records every
|
|
293
|
+
# field reached through a join under its path, so a miss above means a
|
|
294
|
+
# hop or the leaf is wrong. Name the first hop that does not exist.
|
|
248
295
|
if "." in name:
|
|
249
296
|
*path, leaf = name.split(".")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
297
|
+
reached = ""
|
|
298
|
+
for hop in path:
|
|
299
|
+
if f"join:{reached}{hop}" not in declared[src]:
|
|
300
|
+
out.append(f"{eid}: the compiled {src} source has no join "
|
|
301
|
+
f"{reached + hop!r} (required by "
|
|
302
|
+
f"{', '.join(qids)})")
|
|
303
|
+
break
|
|
304
|
+
reached += hop + "."
|
|
257
305
|
else:
|
|
258
|
-
out.append(f"{eid}: the
|
|
259
|
-
f"{kind} {leaf!r}
|
|
260
|
-
f"
|
|
306
|
+
out.append(f"{eid}: the join {'.'.join(path)!r} from {src} "
|
|
307
|
+
f"reaches no {kind} {leaf!r} (required by "
|
|
308
|
+
f"{', '.join(qids)})")
|
|
261
309
|
continue
|
|
262
310
|
other = sorted(k.split(":", 1)[0] for k in declared[src]
|
|
263
311
|
if k.split(":", 1)[1] == name)
|
|
@@ -341,11 +389,15 @@ def check(cases: list[dict[str, Any]], mcp_url: str, environment: str,
|
|
|
341
389
|
def main(argv: list[str] | None = None) -> int:
|
|
342
390
|
ap = argparse.ArgumentParser()
|
|
343
391
|
ap.add_argument("--set", dest="set_dir", required=True, type=pathlib.Path)
|
|
344
|
-
ap.add_argument("--mcp-url",
|
|
392
|
+
ap.add_argument("--mcp-url", default=None,
|
|
345
393
|
help="the MCP endpoint of the model UNDER TEST, not the "
|
|
346
|
-
"truth server"
|
|
347
|
-
|
|
348
|
-
ap.add_argument("--
|
|
394
|
+
"truth server. Default: from [model] in the set's "
|
|
395
|
+
"eval.toml")
|
|
396
|
+
ap.add_argument("--environment", default=None,
|
|
397
|
+
help="Default: [model] environment in the set's eval.toml")
|
|
398
|
+
ap.add_argument("--package", default=None,
|
|
399
|
+
help="Default: [model] package in the set's eval.toml, then "
|
|
400
|
+
"set.json's targetPackage")
|
|
349
401
|
ap.add_argument("--publisher", default=None,
|
|
350
402
|
help="REST URL of the same server, e.g. "
|
|
351
403
|
"http://localhost:4811. With it, each id is also "
|
|
@@ -361,6 +413,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
361
413
|
"wait, which risks reading the lexical matcher as a "
|
|
362
414
|
"missing entity")
|
|
363
415
|
a = ap.parse_args(argv)
|
|
416
|
+
cfg = config.load(a.set_dir)
|
|
417
|
+
a.environment = cfg.need(a.environment, "model", "environment", "--environment")
|
|
418
|
+
a.package = cfg.need(a.package, "model", "package", "--package")
|
|
419
|
+
a.mcp_url = a.mcp_url or cfg.model_mcp_url()
|
|
364
420
|
|
|
365
421
|
f = a.set_dir / "cases.jsonl"
|
|
366
422
|
if not f.exists():
|
|
@@ -419,29 +475,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
419
475
|
|
|
420
476
|
declared_out: list[str] = []
|
|
421
477
|
if a.publisher:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
# anyway would be one more number the harness had not earned.
|
|
427
|
-
stale = stale_packages(a.publisher)
|
|
428
|
-
if stale is None:
|
|
429
|
-
print("! the server's status could not be read, so staleness is "
|
|
430
|
-
"unknown; treating the compiled model as current",
|
|
431
|
-
file=sys.stderr)
|
|
432
|
-
is_stale = bool(stale) and (a.environment, a.package) in stale
|
|
433
|
-
declared = (None if is_stale
|
|
434
|
-
else compiled_entities(a.publisher, a.environment, a.package))
|
|
435
|
-
if is_stale:
|
|
436
|
-
print(f"! {a.environment}/{a.package} is serving a STALE model "
|
|
437
|
-
f"(its last reload failed to compile), so the compiled model "
|
|
438
|
-
f"is not the authority on what exists; existence and kind "
|
|
439
|
-
f"were NOT checked. Fix the model and reload, then re-run.",
|
|
440
|
-
file=sys.stderr)
|
|
441
|
-
elif declared is None:
|
|
442
|
-
print("! the compiled model could not be read; existence and kind "
|
|
443
|
-
"were NOT checked", file=sys.stderr)
|
|
444
|
-
else:
|
|
478
|
+
declared, warning = current_entities(a.publisher, a.environment, a.package)
|
|
479
|
+
if warning:
|
|
480
|
+
print(f"! {warning}", file=sys.stderr)
|
|
481
|
+
if declared is not None:
|
|
445
482
|
declared_out = declared_findings(cases, declared)
|
|
446
483
|
print(f"compiled model: {sum(len(v) for v in declared.values())} "
|
|
447
484
|
f"entities across {len(declared)} sources")
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import os
|
|
12
12
|
import sys
|
|
13
13
|
import unittest
|
|
14
|
+
from unittest import mock
|
|
14
15
|
|
|
15
16
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
17
|
import check_findable # noqa: E402
|
|
@@ -181,10 +182,12 @@ class CompiledModel(unittest.TestCase):
|
|
|
181
182
|
self.assertEqual(self.find("measure:flights:flight_count",
|
|
182
183
|
"dimension:airports:state"), [])
|
|
183
184
|
|
|
184
|
-
def
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
self.
|
|
185
|
+
def test_a_malformed_id_is_reported(self):
|
|
186
|
+
# run_baseline's lint calls only this, so skipping it here dropped the
|
|
187
|
+
# id there. main() dedups on the id, so the CLI still says it once.
|
|
188
|
+
out = self.find("flight_count")
|
|
189
|
+
self.assertEqual(len(out), 1)
|
|
190
|
+
self.assertEqual(check_findable.finding_id(out[0]), "flight_count")
|
|
188
191
|
|
|
189
192
|
def test_a_finding_is_identified_by_its_whole_entity_id(self):
|
|
190
193
|
# Splitting on the bare colon returns the KIND, and deduplicating the
|
|
@@ -321,44 +324,82 @@ class Staleness(unittest.TestCase):
|
|
|
321
324
|
# silently treating the model as current.
|
|
322
325
|
self.assertIsNone(check_findable.stale_packages("http://127.0.0.1:9"))
|
|
323
326
|
|
|
327
|
+
def test_current_entities_refuses_a_stale_package(self):
|
|
328
|
+
# run_baseline read the compiled model with no staleness check, so an
|
|
329
|
+
# entity deleted since the last good compile read as declared.
|
|
330
|
+
with mock.patch.object(check_findable, "stale_packages",
|
|
331
|
+
return_value={("e", "p")}), \
|
|
332
|
+
mock.patch.object(check_findable, "compiled_entities") as compiled:
|
|
333
|
+
declared, warning = check_findable.current_entities("http://x", "e", "p")
|
|
334
|
+
self.assertIsNone(declared)
|
|
335
|
+
self.assertIn("STALE", warning)
|
|
336
|
+
compiled.assert_not_called()
|
|
337
|
+
|
|
338
|
+
def test_current_entities_passes_a_current_package_through(self):
|
|
339
|
+
with mock.patch.object(check_findable, "stale_packages", return_value=set()), \
|
|
340
|
+
mock.patch.object(check_findable, "compiled_entities",
|
|
341
|
+
return_value={"s": {"source:s"}}):
|
|
342
|
+
self.assertEqual(check_findable.current_entities("http://x", "e", "p"),
|
|
343
|
+
({"s": {"source:s"}}, None))
|
|
344
|
+
|
|
324
345
|
|
|
325
346
|
class DottedJoinPaths(unittest.TestCase):
|
|
326
|
-
"""A dotted name is a join path,
|
|
347
|
+
"""A dotted name is a join path, checked hop by hop against the joins the
|
|
348
|
+
source declares.
|
|
327
349
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
here sends you to break an answer key that is correct.
|
|
350
|
+
Reading only a source's OWN fields made every dotted id a miss: on the
|
|
351
|
+
storefront tour set, five of eight reported misses, each one an id
|
|
352
|
+
get_context returns as its top result. Resolving only the LAST hop, as a
|
|
353
|
+
source name, then passed a bogus first hop and failed an aliased join.
|
|
333
354
|
"""
|
|
334
355
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
356
|
+
PRODUCTS = [{"kind": "dimension", "name": "brand"},
|
|
357
|
+
{"kind": "measure", "name": "product_count"}]
|
|
358
|
+
# The shape the server returns: a join field carries the joined schema.
|
|
359
|
+
FIELDS = [{"kind": "measure", "name": "total_sales"},
|
|
360
|
+
{"kind": "join", "name": "products", "relationship": "one",
|
|
361
|
+
"schema": {"fields": PRODUCTS}},
|
|
362
|
+
{"kind": "join", "name": "buyer", "relationship": "one",
|
|
363
|
+
"schema": {"fields": [{"kind": "dimension", "name": "state"}]}}]
|
|
339
364
|
|
|
340
365
|
def findings(self, eid):
|
|
366
|
+
declared = {"order_items": {"source:order_items"}}
|
|
367
|
+
check_findable.add_fields(declared["order_items"], self.FIELDS)
|
|
341
368
|
cases = [{"qid": "q1", "expectedEntities": {"required": [eid]}}]
|
|
342
|
-
return check_findable.declared_findings(cases,
|
|
369
|
+
return check_findable.declared_findings(cases, declared)
|
|
343
370
|
|
|
344
371
|
def test_a_reachable_join_path_is_not_a_finding(self):
|
|
345
372
|
self.assertEqual(self.findings("dimension:order_items:products.brand"), [])
|
|
346
373
|
|
|
374
|
+
def test_a_join_named_other_than_its_source_is_reachable(self):
|
|
375
|
+
# `join_one: buyer is customers`: no source is called `buyer`.
|
|
376
|
+
self.assertEqual(self.findings("dimension:order_items:buyer.state"), [])
|
|
377
|
+
|
|
347
378
|
def test_the_kind_still_has_to_match_on_the_joined_source(self):
|
|
348
379
|
out = self.findings("measure:order_items:products.brand")
|
|
349
380
|
self.assertEqual(len(out), 1)
|
|
350
|
-
self.assertIn("
|
|
381
|
+
self.assertIn("reaches no measure 'brand'", out[0])
|
|
351
382
|
|
|
352
383
|
def test_an_unknown_hop_is_named_as_the_hop(self):
|
|
353
384
|
out = self.findings("dimension:order_items:suppliers.name")
|
|
354
385
|
self.assertEqual(len(out), 1)
|
|
355
|
-
self.assertIn("'suppliers'", out[0])
|
|
386
|
+
self.assertIn("has no join 'suppliers'", out[0])
|
|
387
|
+
|
|
388
|
+
def test_a_bogus_first_hop_is_caught_even_when_the_last_hop_exists(self):
|
|
389
|
+
out = self.findings("dimension:order_items:nowhere.products.brand")
|
|
390
|
+
self.assertEqual(len(out), 1)
|
|
391
|
+
self.assertIn("has no join 'nowhere'", out[0])
|
|
356
392
|
|
|
357
393
|
def test_a_field_missing_on_the_joined_source_is_a_finding(self):
|
|
358
394
|
out = self.findings("dimension:order_items:products.colour")
|
|
359
395
|
self.assertEqual(len(out), 1)
|
|
360
396
|
self.assertIn("'colour'", out[0])
|
|
361
397
|
|
|
398
|
+
def test_a_malformed_id_is_reported_not_dropped(self):
|
|
399
|
+
out = self.findings("dimension:brand")
|
|
400
|
+
self.assertEqual(len(out), 1)
|
|
401
|
+
self.assertIn("not a kind:source:name id", out[0])
|
|
402
|
+
|
|
362
403
|
|
|
363
404
|
if __name__ == "__main__":
|
|
364
405
|
unittest.main()
|