@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.
- package/package.json +1 -1
- package/skills/eval-answer/SKILL.md +45 -5
- package/skills/eval-answer/reference/definition-ledger.md +305 -0
- package/skills/eval-answer/reference/ledger-schema.md +36 -7
- package/skills/eval-answer/scripts/check_coverage.py +12 -21
- package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
- package/skills/eval-answer/scripts/check_findable.py +386 -0
- package/skills/eval-answer/scripts/check_findable_test.py +326 -0
- package/skills/eval-answer/scripts/json_scan.py +43 -0
- package/skills/eval-answer/scripts/ledger.py +63 -7
- package/skills/eval-answer/scripts/ledger_test.py +7 -0
- package/skills/eval-answer/scripts/mcp_payload.py +111 -2
- package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
- package/skills/eval-answer/scripts/publisher_rest.py +10 -4
- package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
- package/skills/eval-answer/scripts/score_retrieval.py +326 -41
- package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
- package/skills/eval-answer/scripts/verify_definitions.py +752 -0
- package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
- package/skills/eval-answer/scripts/verify_goldens.py +336 -22
- package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
- package/skills/eval-diagnose/SKILL.md +47 -2
- package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
- package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
- package/skills/eval-diagnose/scripts/diagnose.py +471 -42
- package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
- package/skills/eval-import/SKILL.md +180 -1
- package/skills/eval-import/reference/case-format.md +1 -1
- package/skills/eval-improve/SKILL.md +1 -1
- package/skills/eval-improve/scripts/improve.py +29 -3
- package/skills/eval-improve/scripts/improve_test.py +51 -0
- package/skills/eval-judge/SKILL.md +1 -1
- package/skills/eval-loop/SKILL.md +112 -3
- package/skills/eval-loop/reference/measurement.md +44 -4
- package/skills/eval-loop/reference/running-a-run.md +5 -1
- package/skills/eval-loop/scripts/agent_harness.py +21 -20
- package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
- package/skills/eval-loop/scripts/build_run_package.py +9 -3
- package/skills/eval-loop/scripts/flip_table.py +107 -2
- package/skills/eval-loop/scripts/flip_table_test.py +165 -0
- package/skills/eval-loop/scripts/run_baseline.py +956 -97
- package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
- package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
- package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
- package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
- package/skills/eval-report/SKILL.md +362 -0
- package/skills/malloy/SKILL.md +2 -1
- package/skills/malloy-analysis/SKILL.md +1 -1
- package/skills/malloy-analysis-pitfalls/SKILL.md +13 -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-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 +3 -2
- 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
|
@@ -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())
|