@malloy-publisher/skills 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +45 -5
  3. package/skills/eval-answer/reference/definition-ledger.md +305 -0
  4. package/skills/eval-answer/reference/ledger-schema.md +36 -7
  5. package/skills/eval-answer/scripts/check_coverage.py +12 -21
  6. package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
  7. package/skills/eval-answer/scripts/check_findable.py +386 -0
  8. package/skills/eval-answer/scripts/check_findable_test.py +326 -0
  9. package/skills/eval-answer/scripts/json_scan.py +43 -0
  10. package/skills/eval-answer/scripts/ledger.py +63 -7
  11. package/skills/eval-answer/scripts/ledger_test.py +7 -0
  12. package/skills/eval-answer/scripts/mcp_payload.py +111 -2
  13. package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
  14. package/skills/eval-answer/scripts/publisher_rest.py +10 -4
  15. package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
  16. package/skills/eval-answer/scripts/score_retrieval.py +326 -41
  17. package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
  18. package/skills/eval-answer/scripts/verify_definitions.py +752 -0
  19. package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
  20. package/skills/eval-answer/scripts/verify_goldens.py +336 -22
  21. package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
  22. package/skills/eval-diagnose/SKILL.md +47 -2
  23. package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
  24. package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
  25. package/skills/eval-diagnose/scripts/diagnose.py +471 -42
  26. package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
  27. package/skills/eval-import/SKILL.md +180 -1
  28. package/skills/eval-import/reference/case-format.md +1 -1
  29. package/skills/eval-improve/SKILL.md +1 -1
  30. package/skills/eval-improve/scripts/improve.py +29 -3
  31. package/skills/eval-improve/scripts/improve_test.py +51 -0
  32. package/skills/eval-judge/SKILL.md +1 -1
  33. package/skills/eval-loop/SKILL.md +112 -3
  34. package/skills/eval-loop/reference/measurement.md +44 -4
  35. package/skills/eval-loop/reference/running-a-run.md +5 -1
  36. package/skills/eval-loop/scripts/agent_harness.py +21 -20
  37. package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
  38. package/skills/eval-loop/scripts/build_run_package.py +9 -3
  39. package/skills/eval-loop/scripts/flip_table.py +107 -2
  40. package/skills/eval-loop/scripts/flip_table_test.py +165 -0
  41. package/skills/eval-loop/scripts/run_baseline.py +956 -97
  42. package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
  43. package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
  44. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
  45. package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
  46. package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
  47. package/skills/eval-report/SKILL.md +362 -0
  48. package/skills/malloy/SKILL.md +2 -1
  49. package/skills/malloy-analysis/SKILL.md +1 -1
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +13 -1
  51. package/skills/malloy-analysis-report/SKILL.md +1 -1
  52. package/skills/malloy-analyze/SKILL.md +1 -1
  53. package/skills/malloy-charts/SKILL.md +1 -1
  54. package/skills/malloy-dashboards/SKILL.md +1 -1
  55. package/skills/malloy-debug/SKILL.md +1 -1
  56. package/skills/malloy-define/SKILL.md +1 -1
  57. package/skills/malloy-discover/SKILL.md +1 -1
  58. package/skills/malloy-document/SKILL.md +1 -1
  59. package/skills/malloy-getting-started/SKILL.md +1 -1
  60. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  61. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  62. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  65. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  66. package/skills/malloy-lookml-review/SKILL.md +1 -1
  67. package/skills/malloy-materialization/SKILL.md +1 -1
  68. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  69. package/skills/malloy-model/SKILL.md +1 -1
  70. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  71. package/skills/malloy-modeling/SKILL.md +1 -1
  72. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  73. package/skills/malloy-notebooks/SKILL.md +1 -1
  74. package/skills/malloy-patterns/SKILL.md +1 -1
  75. package/skills/malloy-phrase-detection/SKILL.md +3 -2
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +1 -1
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. package/skills/malloy-scope/SKILL.md +1 -1
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env python3
2
+ """Can a right-typed search actually retrieve each `required` entity? Stdlib only.
3
+
4
+ python3 check_findable.py --set evals/faa-v1 --mcp-url http://localhost:4045/mcp \
5
+ --environment samples --package faa
6
+
7
+ WHY THIS EXISTS, AND WHAT IT CATCHES THAT NOTHING ELSE DOES
8
+
9
+ `expectedEntities.required` is the answer key's claim about which entities an
10
+ answer depends on, and two separate measurements are computed against it:
11
+
12
+ did the agent ASK for it -- was a target of a type that can return this
13
+ entity kind ever issued
14
+ was it RETRIEVED -- did it come back
15
+
16
+ Both are fiction if the list names an entity retrieval cannot deliver. The case
17
+ then scores a retrieval miss on every run forever, and the miss reads as a
18
+ defect in the model or the agent rather than in the key. Five such ids, copied
19
+ from a sibling package, cost one set two days.
20
+
21
+ `verify_goldens.py` check 5 already reads the model TEXT and reports an id that
22
+ names nothing. That is necessary and not sufficient: an entity can be present in
23
+ the `.malloy` file and still be unreachable, because retrieval answers from an
24
+ index rather than from the source. An entity excluded from discovery, or
25
+ arriving before its index has built, passes check 5 and fails here.
26
+
27
+ WHAT IT DOES NOT PROVE
28
+
29
+ It searches for each entity by its OWN NAME, with the target type its kind
30
+ requires. That is the easiest possible query, so a pass means "reachable at
31
+ all", never "reachable by the words a question uses". A entity that answers only
32
+ to its own identifier, and not to how people ask, is a documentation finding
33
+ this check will happily pass -- that one shows up in a run as NOT-RETRIEVED.
34
+
35
+ So: a failure here is a hard defect in the key or the index. A pass is a floor,
36
+ not a verdict on the docs.
37
+
38
+ It is also how an author learns what a question's entities are searchable AS,
39
+ which is the thing they have to know in order to write `required` at all.
40
+
41
+ EXIT CODES
42
+
43
+ 0 every required entity is retrievable
44
+ 1 at least one is not
45
+ 2 usage error
46
+ 3 the check could not run (no server, no scope); says nothing either way
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import json
52
+ import pathlib
53
+ import sys
54
+ import urllib.error
55
+ import urllib.parse
56
+ import urllib.request
57
+ from typing import Any
58
+
59
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
60
+ from mcp_payload import entity_hits # noqa: E402
61
+
62
+ # An entity kind, and the target type that can return it. The inverse of the
63
+ # server's KINDS_BY_TARGET; `score_retrieval.KINDS_BY_TARGET` is the forward map
64
+ # and is pinned against the server, so this stays a one-line derivation of it
65
+ # rather than a third copy.
66
+ TARGET_FOR_KIND = {"measure": "measure", "dimension": "dimension",
67
+ "view": "view", "query": "view", "source": "source",
68
+ "join": "join"}
69
+
70
+
71
+ def phrase_for(name: str) -> str:
72
+ """The entity's own name as a search phrase.
73
+
74
+ `average_plane_size` -> "average plane size", and a join path
75
+ `aircraft.aircraft_models.seats` -> "aircraft aircraft models seats". The
76
+ easiest query that could find it, which is what makes a failure decisive.
77
+ """
78
+ return name.replace("_", " ").replace(".", " ").strip()
79
+
80
+
81
+ def get_context(mcp_url: str, targets: list[dict[str, str]],
82
+ environment: str, package: str, timeout: int = 60) -> dict[str, Any]:
83
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
84
+ "params": {"name": "get_context",
85
+ "arguments": {"search_targets": targets,
86
+ "scopes": [{"environment": environment,
87
+ "package": package}]}}}
88
+ req = urllib.request.Request(
89
+ mcp_url, data=json.dumps(body).encode(),
90
+ headers={"content-type": "application/json",
91
+ "accept": "application/json, text/event-stream"})
92
+ raw = urllib.request.urlopen(req, timeout=timeout).read().decode()
93
+ # The endpoint answers as SSE when the client accepts it; take the data line.
94
+ for line in raw.splitlines():
95
+ if line.startswith("data: "):
96
+ raw = line[len("data: "):]
97
+ break
98
+ d = json.loads(raw)
99
+ if "result" not in d:
100
+ raise RuntimeError(str(d.get("error"))[:200])
101
+ content = d["result"]["content"]
102
+ text = content[0].get("text") or content[0].get("resource", {}).get("text")
103
+ return json.loads(text)
104
+
105
+
106
+ def compiled_entities(rest_url: str, environment: str,
107
+ package: str) -> dict[str, set[str]] | None:
108
+ """`{source: {"kind:name", ...}}` from the COMPILED model, or None.
109
+
110
+ The authority on whether an entity exists and what kind it is. A grep over
111
+ the `.malloy` text is neither, and gets it wrong in both directions:
112
+
113
+ - it passes `dimension:flights:flight_count`, because `flight_count` is in
114
+ the file. The model declares it a MEASURE, `target_type` is a hard
115
+ filter, and no dimension request can ever return it.
116
+ - it fails `dimension:airports:own_type`, which appears zero times in the
117
+ file because the source exposes it implicitly from the parquet, and which
118
+ retrieves at relevance 1.0. Acting on that finding deletes a good entity.
119
+
120
+ The compiled model has both: `sourceInfos[].schema.fields[]` carries every
121
+ field the source actually exposes, each with its `kind`. One request per
122
+ model path settles every id at once.
123
+
124
+ A model-level named query is NOT a field, and never appears under
125
+ `sourceInfos`: `FieldInfoType` is dimension/measure/join/view/calculate, so
126
+ `query` cannot be a field kind even in principle. It arrives in the
127
+ response's own `queries` array instead, and is read from there. Without
128
+ that, every `query:` id read as a field the source does not declare -- the
129
+ same false positive this function exists to end, reintroduced for one kind.
130
+
131
+ None when the server cannot be reached or answers nothing usable, which is
132
+ "not checked" and must not read as "nothing exists".
133
+ """
134
+ try:
135
+ base = rest_url.rstrip("/")
136
+ req = urllib.request.Request(
137
+ f"{base}/api/v0/environments/{environment}/packages/{package}/models")
138
+ models = json.load(urllib.request.urlopen(req, timeout=30))
139
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
140
+ return None
141
+ out: dict[str, set[str]] = {}
142
+ for m in models if isinstance(models, list) else []:
143
+ path = m.get("path") if isinstance(m, dict) else None
144
+ if not path:
145
+ continue
146
+ try:
147
+ req = urllib.request.Request(
148
+ f"{base}/api/v0/environments/{environment}/packages/{package}"
149
+ f"/models/{urllib.parse.quote(path)}")
150
+ doc = json.load(urllib.request.urlopen(req, timeout=30))
151
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
152
+ continue
153
+ for si in doc.get("sourceInfos") or []:
154
+ info = json.loads(si) if isinstance(si, str) else si
155
+ if not isinstance(info, dict):
156
+ continue
157
+ src = info.get("name")
158
+ fields = (info.get("schema") or {}).get("fields") or []
159
+ if not src:
160
+ continue
161
+ bucket = out.setdefault(src, set())
162
+ bucket.add(f"source:{src}")
163
+ for f in fields:
164
+ if isinstance(f, dict) and f.get("name") and f.get("kind"):
165
+ bucket.add(f"{f['kind']}:{f['name']}")
166
+ for q in doc.get("queries") or []:
167
+ if not isinstance(q, dict):
168
+ continue
169
+ name, src = q.get("name"), q.get("sourceName")
170
+ # A query over an inline source has no source name, and the server
171
+ # excludes it from the index for exactly that reason
172
+ # (get_context_tool.ts). Leaving it out here keeps a `query:` id
173
+ # naming one reported as unreachable, which it genuinely is.
174
+ if name and src:
175
+ out.setdefault(src, set()).add(f"query:{name}")
176
+ return out or None
177
+
178
+
179
+ def stale_packages(rest_url: str, timeout: int = 30) -> set[tuple[str, str]] | None:
180
+ """`{(environment, package)}` serving a model older than their files.
181
+
182
+ A stale package answers every request, and answers from the compile BEFORE
183
+ its last save. `/api/v0/status`'s `loadErrors` is the only place that is
184
+ reported: the package's entry under `environments`, and its own package
185
+ resource, both read as serving. So the models endpoint this module treats
186
+ as "the authority" reads entirely normal while describing a model that is
187
+ no longer on disk -- which can fail a good id or pass a deleted one, with
188
+ nothing to say which happened.
189
+
190
+ None when the status could not be read at all, which is "not checked".
191
+ """
192
+ try:
193
+ req = urllib.request.Request(f"{rest_url.rstrip('/')}/api/v0/status")
194
+ doc = json.load(urllib.request.urlopen(req, timeout=timeout))
195
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
196
+ return None
197
+ return {(e.get("environment") or "", e.get("package") or "")
198
+ for e in doc.get("loadErrors") or []
199
+ if isinstance(e, dict) and e.get("stale")}
200
+
201
+
202
+ def declared_findings(cases: list[dict[str, Any]],
203
+ declared: dict[str, set[str]]) -> list[str]:
204
+ """Ids the compiled model does not declare, or declares as another kind."""
205
+ out = []
206
+ for eid, qids in sorted(required_ids(cases).items()):
207
+ parts = eid.split(":", 2)
208
+ if len(parts) < 3:
209
+ continue # malformed; `check` reports it on its own
210
+ kind, src, name = parts
211
+ if src not in declared:
212
+ out.append(f"{eid}: the compiled model has no source {src!r} "
213
+ f"(required by {', '.join(qids)})")
214
+ continue
215
+ if f"{kind}:{name}" in declared[src]:
216
+ continue
217
+ other = sorted(k.split(":", 1)[0] for k in declared[src]
218
+ if k.split(":", 1)[1] == name)
219
+ if other:
220
+ out.append(f"{eid}: {src}.{name} is declared "
221
+ f"{'/'.join(other)}, not {kind}. `target_type` is a hard "
222
+ f"filter, so no {kind} search can return it "
223
+ f"(required by {', '.join(qids)})")
224
+ else:
225
+ out.append(f"{eid}: the compiled {src} source declares no field "
226
+ f"{name!r} (required by {', '.join(qids)})")
227
+ return out
228
+
229
+
230
+ def required_ids(cases: list[dict[str, Any]]) -> dict[str, list[str]]:
231
+ """Every required id, and the qids that require it. Deduped: the same
232
+ entity named by six cases is one search, not six."""
233
+ out: dict[str, list[str]] = {}
234
+ for c in cases:
235
+ exp = c.get("expectedEntities") or {}
236
+ ids = list(exp.get("required") or [])
237
+ for group in exp.get("requiredAnyOf") or []:
238
+ ids += list(group)
239
+ for eid in ids:
240
+ out.setdefault(eid, []).append(c.get("qid", "?"))
241
+ return out
242
+
243
+
244
+ def finding_id(message: str) -> str:
245
+ """The entity id a finding is about.
246
+
247
+ Every finding here and in `declared_findings` is formatted `f"{eid}: ..."`,
248
+ and an entity id never contains a space, so the id is everything before the
249
+ first ": ". Splitting on the bare colon instead returns the KIND -- and
250
+ deduplicating on that silently dropped a genuine finding for
251
+ `measure:flights:distinct_planes` because an unrelated
252
+ `measure:orders:total_sales` had already been reported.
253
+ """
254
+ return message.split(": ", 1)[0]
255
+
256
+
257
+ def check(cases: list[dict[str, Any]], mcp_url: str, environment: str,
258
+ package: str) -> tuple[list[str], list[dict[str, Any]]]:
259
+ findings: list[str] = []
260
+ rows: list[dict[str, Any]] = []
261
+ for eid, qids in sorted(required_ids(cases).items()):
262
+ parts = eid.split(":", 2)
263
+ if len(parts) < 3:
264
+ findings.append(f"{eid}: malformed id, needs kind:source:name "
265
+ f"(required by {', '.join(qids)})")
266
+ continue
267
+ kind, _, name = parts
268
+ target = TARGET_FOR_KIND.get(kind)
269
+ if target is None:
270
+ findings.append(f"{eid}: unknown entity kind {kind!r} "
271
+ f"(required by {', '.join(qids)})")
272
+ continue
273
+ phrase = phrase_for(name)
274
+ try:
275
+ payload = get_context(
276
+ mcp_url, [{"target_type": target, "search_text": phrase}],
277
+ environment, package)
278
+ except (urllib.error.URLError, RuntimeError, OSError) as e:
279
+ raise SystemExit(f"get_context failed for {eid}: {e}\n"
280
+ f"The check did not run; this says nothing about "
281
+ f"the key.") from e
282
+ hit = next((h for h in entity_hits(payload)
283
+ if h["entity_id"] == eid), None)
284
+ rows.append({"entity_id": eid, "target": target, "search_text": phrase,
285
+ "found": bool(hit),
286
+ "relevance": (hit or {}).get("relevance"),
287
+ "requiredBy": qids})
288
+ if not hit:
289
+ findings.append(
290
+ f"{eid}: a `{target}` search for {phrase!r} does not return it, "
291
+ f"so every case requiring it scores a retrieval miss forever "
292
+ f"(required by {', '.join(qids)})")
293
+ return findings, rows
294
+
295
+
296
+ def main(argv: list[str] | None = None) -> int:
297
+ ap = argparse.ArgumentParser()
298
+ ap.add_argument("--set", dest="set_dir", required=True, type=pathlib.Path)
299
+ ap.add_argument("--mcp-url", required=True,
300
+ help="the MCP endpoint of the model UNDER TEST, not the "
301
+ "truth server")
302
+ ap.add_argument("--environment", required=True)
303
+ ap.add_argument("--package", required=True)
304
+ ap.add_argument("--publisher", default=None,
305
+ help="REST URL of the same server, e.g. "
306
+ "http://localhost:4811. With it, each id is also "
307
+ "checked against the COMPILED model, which knows both "
308
+ "that a field exists and what KIND it is. That is the "
309
+ "authority; a grep over the .malloy text is wrong in "
310
+ "both directions.")
311
+ ap.add_argument("--out", default=None,
312
+ help="write the per-entity rows as JSON")
313
+ a = ap.parse_args(argv)
314
+
315
+ f = a.set_dir / "cases.jsonl"
316
+ if not f.exists():
317
+ print(f"no cases.jsonl in {a.set_dir}", file=sys.stderr)
318
+ return 3
319
+ cases = [json.loads(line) for line in f.read_text().splitlines() if line.strip()]
320
+
321
+ declared_out: list[str] = []
322
+ if a.publisher:
323
+ # Staleness first. A stale package answers the models endpoint normally
324
+ # while describing the compile BEFORE the last save, so reading it as
325
+ # the authority prints findings nobody can act on: the id may be fine
326
+ # and the served model simply old. Refusing to check says so; checking
327
+ # anyway would be one more number the harness had not earned.
328
+ stale = stale_packages(a.publisher)
329
+ if stale is None:
330
+ print("! the server's status could not be read, so staleness is "
331
+ "unknown; treating the compiled model as current",
332
+ file=sys.stderr)
333
+ is_stale = bool(stale) and (a.environment, a.package) in stale
334
+ declared = (None if is_stale
335
+ else compiled_entities(a.publisher, a.environment, a.package))
336
+ if is_stale:
337
+ print(f"! {a.environment}/{a.package} is serving a STALE model "
338
+ f"(its last reload failed to compile), so the compiled model "
339
+ f"is not the authority on what exists; existence and kind "
340
+ f"were NOT checked. Fix the model and reload, then re-run.",
341
+ file=sys.stderr)
342
+ elif declared is None:
343
+ print("! the compiled model could not be read; existence and kind "
344
+ "were NOT checked", file=sys.stderr)
345
+ else:
346
+ declared_out = declared_findings(cases, declared)
347
+ print(f"compiled model: {sum(len(v) for v in declared.values())} "
348
+ f"entities across {len(declared)} sources")
349
+
350
+ findings, rows = check(cases, a.mcp_url, a.environment, a.package)
351
+ # An id the compiled model already rejected is not also reported as
352
+ # unretrievable: it is the same defect, and saying it twice reads as two.
353
+ # The compiled message is the useful one, because it names the real kind.
354
+ already = {finding_id(f) for f in declared_out}
355
+ findings = declared_out + [f for f in findings
356
+ if finding_id(f) not in already]
357
+
358
+ if a.out:
359
+ pathlib.Path(a.out).write_text(json.dumps(
360
+ {"set": str(a.set_dir), "environment": a.environment,
361
+ "package": a.package, "entities": rows}, indent=2))
362
+
363
+ # Every distinct required id, not `len(rows)`. `rows` holds only the ids
364
+ # that reached a search, while `findings` also carries the malformed and
365
+ # unknown-kind ids that never did, plus the compiled-model ones -- so
366
+ # subtracting one from the other counted ids that were never in the total
367
+ # and printed "-1 of 2 required entities are retrievable". Findings are
368
+ # deduplicated by entity id above, so this can no longer go negative.
369
+ total = len(required_ids(cases))
370
+ print(f"{total - len(findings)} of {total} required entities are "
371
+ f"retrievable by a search of their own kind")
372
+ if not findings:
373
+ print("A pass is a floor, not a verdict on the docs: each was searched "
374
+ "by its own name, the easiest query that could find it.")
375
+ return 0
376
+ print(f"\n{len(findings)} NOT retrievable:")
377
+ for x in findings:
378
+ print(f" {x}")
379
+ print("\nFix the key or the model before running an arm. Until then those "
380
+ "cases measure nothing: they report a retrieval miss on every run "
381
+ "and it reads as the model's fault.")
382
+ return 1
383
+
384
+
385
+ if __name__ == "__main__":
386
+ sys.exit(main())