@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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +3 -2
  3. package/skills/eval-answer/reference/ledger-schema.md +15 -6
  4. package/skills/eval-answer/scripts/check_coverage.py +25 -11
  5. package/skills/eval-answer/scripts/check_coverage_test.py +9 -6
  6. package/skills/eval-answer/scripts/check_findable.py +85 -48
  7. package/skills/eval-answer/scripts/check_findable_test.py +58 -17
  8. package/skills/eval-answer/scripts/config.py +317 -0
  9. package/skills/eval-answer/scripts/config_test.py +171 -0
  10. package/skills/eval-answer/scripts/init_truth_package.py +78 -18
  11. package/skills/eval-answer/scripts/init_truth_package_test.py +61 -0
  12. package/skills/eval-answer/scripts/ledger.py +3 -0
  13. package/skills/eval-answer/scripts/verify_definitions.py +11 -5
  14. package/skills/eval-answer/scripts/verify_definitions_test.py +3 -2
  15. package/skills/eval-answer/scripts/verify_goldens.py +34 -15
  16. package/skills/eval-answer/scripts/verify_goldens_test.py +24 -1
  17. package/skills/eval-diagnose/SKILL.md +2 -2
  18. package/skills/eval-diagnose/scripts/diagnose.py +23 -5
  19. package/skills/eval-diagnose/scripts/diagnose_test.py +36 -0
  20. package/skills/eval-import/SKILL.md +1 -1
  21. package/skills/eval-import/reference/case-format.md +1 -1
  22. package/skills/eval-import/scripts/import_cases.py +8 -0
  23. package/skills/eval-import/scripts/import_cases_test.py +25 -17
  24. package/skills/eval-improve/SKILL.md +1 -1
  25. package/skills/eval-improve/scripts/improve.py +37 -22
  26. package/skills/eval-judge/SKILL.md +1 -1
  27. package/skills/eval-loop/SKILL.md +27 -12
  28. package/skills/eval-loop/reference/running-a-run.md +77 -54
  29. package/skills/eval-loop/reference/setting-up-a-set.md +153 -0
  30. package/skills/eval-loop/scripts/build_run_package.py +115 -8
  31. package/skills/eval-loop/scripts/build_run_package_test.py +141 -2
  32. package/skills/eval-loop/scripts/check_judge.py +15 -6
  33. package/skills/eval-loop/scripts/check_set.py +201 -0
  34. package/skills/eval-loop/scripts/check_set_test.py +121 -0
  35. package/skills/eval-loop/scripts/eval.py +136 -0
  36. package/skills/eval-loop/scripts/eval_test.py +84 -0
  37. package/skills/eval-loop/scripts/run_baseline.py +145 -63
  38. package/skills/eval-loop/scripts/run_baseline_test.py +112 -23
  39. package/skills/eval-loop/scripts/serve.py +273 -24
  40. package/skills/eval-loop/scripts/serve_test.py +190 -0
  41. package/skills/eval-loop/templates/eval-run-package/README.md +12 -5
  42. package/skills/eval-report/SKILL.md +16 -8
  43. package/skills/malloy/SKILL.md +1 -1
  44. package/skills/malloy-analysis/SKILL.md +1 -1
  45. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  46. package/skills/malloy-analysis-report/SKILL.md +1 -1
  47. package/skills/malloy-analyze/SKILL.md +1 -1
  48. package/skills/malloy-charts/SKILL.md +1 -1
  49. package/skills/malloy-dashboards/SKILL.md +1 -1
  50. package/skills/malloy-data-app-design/SKILL.md +1 -1
  51. package/skills/malloy-debug/SKILL.md +1 -1
  52. package/skills/malloy-define/SKILL.md +1 -1
  53. package/skills/malloy-discover/SKILL.md +1 -1
  54. package/skills/malloy-document/SKILL.md +1 -1
  55. package/skills/malloy-getting-started/SKILL.md +1 -1
  56. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  57. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  58. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  59. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  60. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  61. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  62. package/skills/malloy-lookml-review/SKILL.md +1 -1
  63. package/skills/malloy-materialization/SKILL.md +1 -1
  64. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  65. package/skills/malloy-model/SKILL.md +1 -1
  66. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  67. package/skills/malloy-modeling/SKILL.md +1 -1
  68. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  69. package/skills/malloy-notebooks/SKILL.md +1 -1
  70. package/skills/malloy-patterns/SKILL.md +1 -1
  71. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  72. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  73. package/skills/malloy-publish/SKILL.md +1 -1
  74. package/skills/malloy-queries/SKILL.md +1 -1
  75. package/skills/malloy-review/SKILL.md +1 -1
  76. package/skills/malloy-scope/SKILL.md +1 -1
  77. package/skills/malloy-source-unreachable/SKILL.md +1 -1
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.31",
4
+ "version": "0.1.33",
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.31
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 `evals/<set>/runs/<runId>/events.jsonl` with `caseId` set. Shapes
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 ledger together. Never
44
- place `evals/` inside the directory tree the answerer's package serves: gold
45
- in the served tree is a contamination path.
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` names that appear nowhere in the served model, from the in-arm lint. 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 `[]`. |
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 samples --package ecommerce --version 0.0.58
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 compiled_entities # noqa: E402
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, which is honest: the
162
- prompt then says the list is unavailable rather than implying the text is
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 = compiled_entities(base, environment, package)
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 = sorted(f for f in declared[src] if not f.startswith("source:"))
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="samples")
698
- ap.add_argument("--package", default=None)
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 and a.package:
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 with --package")
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, "compiled_entities", return_value={
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, "compiled_entities", return_value=None):
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, "compiled_entities", entities), \
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
- "--package", "pkg", "--parallel", "1",
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
- for f in fields:
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
- continue # malformed; `check` reports it on its own
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, not a field name. `declared[src]` holds
243
- # the source's OWN fields, so `dimension:order_items:products.brand`
244
- # never matched and every dotted id was reported as a missing field --
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
- hop = path[-1]
251
- if hop in declared and f"{kind}:{leaf}" in declared[hop]:
252
- continue
253
- if hop not in declared:
254
- out.append(f"{eid}: the join path names {hop!r}, which the "
255
- f"compiled model has no source for "
256
- f"(required by {', '.join(qids)})")
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 compiled {hop} source declares no "
259
- f"{kind} {leaf!r} to reach through that join "
260
- f"(required by {', '.join(qids)})")
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", required=True,
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
- ap.add_argument("--environment", required=True)
348
- ap.add_argument("--package", required=True)
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
- # Staleness first. A stale package answers the models endpoint normally
423
- # while describing the compile BEFORE the last save, so reading it as
424
- # the authority prints findings nobody can act on: the id may be fine
425
- # and the served model simply old. Refusing to check says so; checking
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 test_a_malformed_id_is_left_to_the_other_check(self):
185
- # `check` reports it before any search; reporting it twice reads as
186
- # two defects.
187
- self.assertEqual(self.find("flight_count"), [])
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, resolved on the joined source.
347
+ """A dotted name is a join path, checked hop by hop against the joins the
348
+ source declares.
327
349
 
328
- `declared[src]` holds a source's OWN fields, so every dotted id read as a
329
- field the source does not declare. On the storefront tour set that was five
330
- of eight reported misses, each one an id get_context returns as its top
331
- result -- and the check ends by telling you to fix the key, so a false miss
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
- DECLARED = {
336
- "order_items": {"source:order_items", "measure:total_sales"},
337
- "products": {"source:products", "dimension:brand", "measure:product_count"},
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, self.DECLARED)
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("declares no measure 'brand'", out[0])
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()