@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,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for the findability audit. No server: `get_context` is substituted.
|
|
3
|
+
|
|
4
|
+
The audit exists because `expectedEntities.required` is what "did the agent ask
|
|
5
|
+
for it" and "was it retrieved" are both computed against, so an id retrieval
|
|
6
|
+
cannot deliver makes both numbers fiction -- and the case then reports a
|
|
7
|
+
retrieval miss forever, which reads as the model's fault rather than the key's.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import check_findable # noqa: E402
|
|
17
|
+
|
|
18
|
+
MODEL = {
|
|
19
|
+
("measure", "flight count"): "measure:flights:flight_count",
|
|
20
|
+
("measure", "average plane size"): "measure:flights:average_plane_size",
|
|
21
|
+
("dimension", "state"): "dimension:airports:state",
|
|
22
|
+
("source", "flights"): "source:flights:flights",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fake_get_context(_url, targets, _env, _pkg, timeout=60):
|
|
27
|
+
"""A model that answers only a right-typed search for a name it has."""
|
|
28
|
+
t = targets[0]
|
|
29
|
+
eid = MODEL.get((t["target_type"], t["search_text"]))
|
|
30
|
+
ents = ([{"entity_id": eid, "relevance": 1.0}]
|
|
31
|
+
if eid and not eid.startswith("source:") else [])
|
|
32
|
+
return {"sources": [{"source_info": {"resource_id": {"source": "flights"}},
|
|
33
|
+
"entities": ents}]}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def case(qid, required=(), any_of=()):
|
|
37
|
+
return {"qid": qid, "expectedEntities": {"required": list(required),
|
|
38
|
+
"requiredAnyOf": [list(g) for g in any_of]}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Findable(unittest.TestCase):
|
|
42
|
+
def setUp(self):
|
|
43
|
+
self._real = check_findable.get_context
|
|
44
|
+
check_findable.get_context = fake_get_context
|
|
45
|
+
|
|
46
|
+
def tearDown(self):
|
|
47
|
+
check_findable.get_context = self._real
|
|
48
|
+
|
|
49
|
+
def run_check(self, cases):
|
|
50
|
+
return check_findable.check(cases, "http://x/mcp", "e", "p")
|
|
51
|
+
|
|
52
|
+
def test_a_reachable_entity_is_no_finding(self):
|
|
53
|
+
f, rows = self.run_check([case("q", ["measure:flights:flight_count"])])
|
|
54
|
+
self.assertEqual(f, [])
|
|
55
|
+
self.assertTrue(rows[0]["found"])
|
|
56
|
+
|
|
57
|
+
def test_an_entity_the_model_does_not_have_is_caught(self):
|
|
58
|
+
f, _ = self.run_check([case("q", ["measure:flights:revenue_per_mile"])])
|
|
59
|
+
self.assertEqual(len(f), 1)
|
|
60
|
+
self.assertIn("retrieval miss forever", f[0])
|
|
61
|
+
|
|
62
|
+
def test_a_real_name_under_the_wrong_kind_is_caught(self):
|
|
63
|
+
# The case a text grep cannot catch: `flight_count` IS in the model, so
|
|
64
|
+
# checking the name against the source passes it. Only a search of the
|
|
65
|
+
# declared kind shows that no `dimension` request can return it.
|
|
66
|
+
f, _ = self.run_check([case("q", ["dimension:flights:flight_count"])])
|
|
67
|
+
self.assertEqual(len(f), 1)
|
|
68
|
+
self.assertIn("`dimension` search", f[0])
|
|
69
|
+
|
|
70
|
+
def test_a_malformed_id_is_caught_before_any_search(self):
|
|
71
|
+
f, rows = self.run_check([case("q", ["flight_count"])])
|
|
72
|
+
self.assertEqual(len(f), 1)
|
|
73
|
+
self.assertIn("malformed", f[0])
|
|
74
|
+
self.assertEqual(rows, [])
|
|
75
|
+
|
|
76
|
+
def test_an_unknown_kind_is_caught(self):
|
|
77
|
+
f, _ = self.run_check([case("q", ["metric:flights:x"])])
|
|
78
|
+
self.assertIn("unknown entity kind", f[0])
|
|
79
|
+
|
|
80
|
+
def test_requiredanyof_members_are_checked_too(self):
|
|
81
|
+
# A group is satisfied by any member, so a dead member is not fatal to
|
|
82
|
+
# the case -- but it is still an id nothing can deliver, and a group
|
|
83
|
+
# whose every member is dead is a case that can never score.
|
|
84
|
+
f, rows = self.run_check([case("q", any_of=[
|
|
85
|
+
["measure:flights:flight_count", "measure:flights:gone"]])])
|
|
86
|
+
# Both members are searched; only the dead one is a finding.
|
|
87
|
+
self.assertEqual(len(rows), 2)
|
|
88
|
+
self.assertEqual(len(f), 1)
|
|
89
|
+
self.assertIn("gone", f[0])
|
|
90
|
+
|
|
91
|
+
def test_one_entity_required_by_six_cases_is_searched_once(self):
|
|
92
|
+
cases = [case(f"q{i}", ["measure:flights:flight_count"]) for i in range(6)]
|
|
93
|
+
_, rows = self.run_check(cases)
|
|
94
|
+
self.assertEqual(len(rows), 1)
|
|
95
|
+
self.assertEqual(len(rows[0]["requiredBy"]), 6)
|
|
96
|
+
|
|
97
|
+
def test_findings_never_outnumber_the_required_ids(self):
|
|
98
|
+
# `rows` holds only the ids that reached a search; a malformed id is a
|
|
99
|
+
# finding that never becomes a row. Reporting `len(rows) - len(f)`
|
|
100
|
+
# subtracted findings that were never in the total and printed
|
|
101
|
+
# "-1 of 2 required entities are retrievable".
|
|
102
|
+
cases = [case("q", ["flight_count", # malformed
|
|
103
|
+
"measure:flights:gone", # genuine miss
|
|
104
|
+
"measure:flights:also_gone"])] # genuine miss
|
|
105
|
+
f, rows = self.run_check(cases)
|
|
106
|
+
total = len(check_findable.required_ids(cases))
|
|
107
|
+
self.assertEqual(total, 3)
|
|
108
|
+
self.assertEqual(len(rows), 2)
|
|
109
|
+
self.assertEqual(len(f), 3)
|
|
110
|
+
self.assertGreaterEqual(total - len(f), 0)
|
|
111
|
+
|
|
112
|
+
def test_the_finding_names_every_case_that_would_be_affected(self):
|
|
113
|
+
f, _ = self.run_check([case("q1", ["measure:flights:gone"]),
|
|
114
|
+
case("q2", ["measure:flights:gone"])])
|
|
115
|
+
self.assertIn("q1, q2", f[0])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Phrase(unittest.TestCase):
|
|
119
|
+
def test_an_identifier_becomes_the_easiest_possible_query(self):
|
|
120
|
+
self.assertEqual(check_findable.phrase_for("average_plane_size"),
|
|
121
|
+
"average plane size")
|
|
122
|
+
|
|
123
|
+
def test_a_join_path_is_flattened(self):
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
check_findable.phrase_for("aircraft.aircraft_models.seats"),
|
|
126
|
+
"aircraft aircraft models seats")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class KindMap(unittest.TestCase):
|
|
130
|
+
def test_it_inverts_the_forward_map_the_server_pins(self):
|
|
131
|
+
# `score_retrieval.KINDS_BY_TARGET` is pinned against the server's own
|
|
132
|
+
# table. This is its inverse and must not drift from it.
|
|
133
|
+
import score_retrieval
|
|
134
|
+
for target, kinds in score_retrieval.KINDS_BY_TARGET.items():
|
|
135
|
+
for kind in kinds:
|
|
136
|
+
self.assertEqual(check_findable.TARGET_FOR_KIND.get(kind),
|
|
137
|
+
target,
|
|
138
|
+
f"{kind} should be searched as {target}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CompiledModel(unittest.TestCase):
|
|
142
|
+
"""The compiled model is the authority on existence AND kind.
|
|
143
|
+
|
|
144
|
+
A grep over the `.malloy` text is neither, and is wrong in both directions:
|
|
145
|
+
it passes a real name under the wrong kind, and it fails a column the
|
|
146
|
+
source exposes implicitly.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
DECLARED = {
|
|
150
|
+
"flights": {"source:flights", "measure:flight_count",
|
|
151
|
+
"dimension:distance"},
|
|
152
|
+
"airports": {"source:airports", "measure:airport_count",
|
|
153
|
+
"dimension:state", "dimension:own_type"},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def find(self, *ids):
|
|
157
|
+
cases = [{"qid": "q", "expectedEntities": {"required": list(ids)}}]
|
|
158
|
+
return check_findable.declared_findings(cases, self.DECLARED)
|
|
159
|
+
|
|
160
|
+
def test_a_real_name_under_the_wrong_kind_names_the_real_kind(self):
|
|
161
|
+
f = self.find("dimension:flights:flight_count")
|
|
162
|
+
self.assertEqual(len(f), 1)
|
|
163
|
+
self.assertIn("declared measure, not dimension", f[0])
|
|
164
|
+
self.assertIn("hard filter", f[0])
|
|
165
|
+
|
|
166
|
+
def test_an_implicit_column_is_NOT_a_finding(self):
|
|
167
|
+
# The grep false positive: `own_type` appears zero times in the
|
|
168
|
+
# .malloy and is exposed by the source. Acting on that finding deletes
|
|
169
|
+
# a good entity from the key.
|
|
170
|
+
self.assertEqual(self.find("dimension:airports:own_type"), [])
|
|
171
|
+
|
|
172
|
+
def test_a_field_that_does_not_exist_is_a_finding(self):
|
|
173
|
+
f = self.find("measure:flights:revenue_per_mile")
|
|
174
|
+
self.assertIn("declares no field", f[0])
|
|
175
|
+
|
|
176
|
+
def test_an_unknown_source_is_a_finding(self):
|
|
177
|
+
f = self.find("measure:gone:x")
|
|
178
|
+
self.assertIn("no source", f[0])
|
|
179
|
+
|
|
180
|
+
def test_a_correct_id_is_silent(self):
|
|
181
|
+
self.assertEqual(self.find("measure:flights:flight_count",
|
|
182
|
+
"dimension:airports:state"), [])
|
|
183
|
+
|
|
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"), [])
|
|
188
|
+
|
|
189
|
+
def test_a_finding_is_identified_by_its_whole_entity_id(self):
|
|
190
|
+
# Splitting on the bare colon returns the KIND, and deduplicating the
|
|
191
|
+
# search findings against the compiled-model ones on that basis
|
|
192
|
+
# silently deleted an unrelated entity's genuine finding -- which can
|
|
193
|
+
# flip the exit code from 1 to 0.
|
|
194
|
+
self.assertEqual(
|
|
195
|
+
check_findable.finding_id(
|
|
196
|
+
"measure:flights:distinct_planes: a `measure` search for "
|
|
197
|
+
"'distinct planes' does not return it (required by q2)"),
|
|
198
|
+
"measure:flights:distinct_planes")
|
|
199
|
+
|
|
200
|
+
def test_dedup_keeps_a_finding_that_only_shares_a_kind(self):
|
|
201
|
+
declared = ["measure:orders:total_sales: the compiled model has no "
|
|
202
|
+
"source 'orders' (required by q1)"]
|
|
203
|
+
found = ["measure:flights:distinct_planes: a `measure` search does "
|
|
204
|
+
"not return it (required by q2)"]
|
|
205
|
+
already = {check_findable.finding_id(f) for f in declared}
|
|
206
|
+
kept = declared + [f for f in found
|
|
207
|
+
if check_findable.finding_id(f) not in already]
|
|
208
|
+
self.assertEqual(len(kept), 2)
|
|
209
|
+
|
|
210
|
+
def test_an_unreachable_server_is_not_read_as_an_empty_model(self):
|
|
211
|
+
# None means "not checked". Treating it as {} would report every id in
|
|
212
|
+
# the set as missing.
|
|
213
|
+
self.assertIsNone(check_findable.compiled_entities(
|
|
214
|
+
"http://127.0.0.1:9", "e", "p"))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def fake_model_doc(queries=(), fields=(("carrier", "dimension"),)):
|
|
218
|
+
"""One model path, one source, with whatever fields and queries are asked
|
|
219
|
+
for. Mirrors the REST shape: `queries` is a SIBLING of `sourceInfos`."""
|
|
220
|
+
return {"sourceInfos": [{"name": "flights",
|
|
221
|
+
"schema": {"fields": [{"name": n, "kind": k}
|
|
222
|
+
for n, k in fields]}}],
|
|
223
|
+
"queries": [dict(q) for q in queries]}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class CompiledQueries(unittest.TestCase):
|
|
227
|
+
"""A model-level named query is not a field and never appears under
|
|
228
|
+
`sourceInfos`; `FieldInfoType` has no `query`. Read from the response's own
|
|
229
|
+
`queries` array or every `query:` id reads as a field that does not exist --
|
|
230
|
+
the exact false positive `compiled_entities` exists to end."""
|
|
231
|
+
|
|
232
|
+
def declared_from(self, doc):
|
|
233
|
+
"""`compiled_entities`' per-doc walk, with the two HTTP calls stubbed."""
|
|
234
|
+
import json as _json
|
|
235
|
+
import urllib.request as _u
|
|
236
|
+
real = _u.urlopen
|
|
237
|
+
|
|
238
|
+
class Resp:
|
|
239
|
+
def __init__(self, payload): self.payload = payload
|
|
240
|
+
def read(self): return _json.dumps(self.payload).encode()
|
|
241
|
+
def __enter__(self): return self
|
|
242
|
+
def __exit__(self, *a): return False
|
|
243
|
+
|
|
244
|
+
def fake(req, timeout=30):
|
|
245
|
+
url = req.full_url if hasattr(req, "full_url") else str(req)
|
|
246
|
+
return Resp([{"path": "m.malloy"}] if url.endswith("/models")
|
|
247
|
+
else doc)
|
|
248
|
+
|
|
249
|
+
_u.urlopen = fake
|
|
250
|
+
try:
|
|
251
|
+
return check_findable.compiled_entities("http://x", "e", "p")
|
|
252
|
+
finally:
|
|
253
|
+
_u.urlopen = real
|
|
254
|
+
|
|
255
|
+
def test_a_named_query_is_declared(self):
|
|
256
|
+
declared = self.declared_from(fake_model_doc(
|
|
257
|
+
queries=[{"name": "top_carriers", "sourceName": "flights"}]))
|
|
258
|
+
self.assertIn("query:top_carriers", declared["flights"])
|
|
259
|
+
|
|
260
|
+
def test_a_named_query_raises_no_finding(self):
|
|
261
|
+
declared = self.declared_from(fake_model_doc(
|
|
262
|
+
queries=[{"name": "top_carriers", "sourceName": "flights"}]))
|
|
263
|
+
cases = [case("q1", required=["query:flights:top_carriers"])]
|
|
264
|
+
self.assertEqual(check_findable.declared_findings(cases, declared), [])
|
|
265
|
+
|
|
266
|
+
def test_without_the_queries_array_it_would_have_been_a_false_finding(self):
|
|
267
|
+
# The regression this pins: reading only `sourceInfos` reports a real,
|
|
268
|
+
# retrievable query as a field the source does not declare, and that
|
|
269
|
+
# finding survives main()'s dedup and exits 1.
|
|
270
|
+
declared = self.declared_from(fake_model_doc(queries=[]))
|
|
271
|
+
cases = [case("q1", required=["query:flights:top_carriers"])]
|
|
272
|
+
out = check_findable.declared_findings(cases, declared)
|
|
273
|
+
self.assertEqual(len(out), 1)
|
|
274
|
+
self.assertIn("declares no field", out[0])
|
|
275
|
+
|
|
276
|
+
def test_a_query_over_an_inline_source_stays_undeclared(self):
|
|
277
|
+
# It has no source name, and the server excludes it from the index for
|
|
278
|
+
# that reason, so an id naming it is genuinely unreachable.
|
|
279
|
+
declared = self.declared_from(fake_model_doc(
|
|
280
|
+
queries=[{"name": "adhoc"}]))
|
|
281
|
+
self.assertNotIn("query:adhoc", declared["flights"])
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Staleness(unittest.TestCase):
|
|
285
|
+
"""A stale package answers the models endpoint normally while describing
|
|
286
|
+
the compile BEFORE the last save, so the compiled model is not the
|
|
287
|
+
authority and must not be read as one."""
|
|
288
|
+
|
|
289
|
+
def status(self, payload):
|
|
290
|
+
import json as _json
|
|
291
|
+
import urllib.request as _u
|
|
292
|
+
real = _u.urlopen
|
|
293
|
+
|
|
294
|
+
class Resp:
|
|
295
|
+
def read(self): return _json.dumps(payload).encode()
|
|
296
|
+
_u.urlopen = lambda req, timeout=30: Resp()
|
|
297
|
+
try:
|
|
298
|
+
return check_findable.stale_packages("http://x")
|
|
299
|
+
finally:
|
|
300
|
+
_u.urlopen = real
|
|
301
|
+
|
|
302
|
+
def test_a_stale_package_is_named(self):
|
|
303
|
+
self.assertEqual(
|
|
304
|
+
self.status({"loadErrors": [{"environment": "e", "package": "p",
|
|
305
|
+
"stale": True}]}),
|
|
306
|
+
{("e", "p")})
|
|
307
|
+
|
|
308
|
+
def test_an_ordinary_load_failure_is_not_staleness(self):
|
|
309
|
+
# No `stale` flag: the package did not load at all, which is a
|
|
310
|
+
# different fact and is visible by the package simply being absent.
|
|
311
|
+
self.assertEqual(
|
|
312
|
+
self.status({"loadErrors": [{"environment": "e", "package": "p",
|
|
313
|
+
"message": "boom"}]}),
|
|
314
|
+
set())
|
|
315
|
+
|
|
316
|
+
def test_a_healthy_server_omits_the_field(self):
|
|
317
|
+
self.assertEqual(self.status({"operationalState": "serving"}), set())
|
|
318
|
+
|
|
319
|
+
def test_an_unreadable_status_is_not_read_as_healthy(self):
|
|
320
|
+
# None means "could not tell", which main() reports rather than
|
|
321
|
+
# silently treating the model as current.
|
|
322
|
+
self.assertIsNone(check_findable.stale_packages("http://127.0.0.1:9"))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
unittest.main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pull JSON objects out of a model's prose reply. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Both judges in this loop ask an agent for a JSON verdict and get back prose
|
|
5
|
+
with the object somewhere in it, and both must survive the agent quoting a
|
|
6
|
+
Malloy snippet on the way: `re.search(r"\\{.*\\}", re.S)` is greedy, spans the
|
|
7
|
+
FIRST brace in the document to the LAST, and hands `json.loads` a blob that
|
|
8
|
+
starts mid-query. A good reply then reads as unparseable, and the case leaves
|
|
9
|
+
every bucket -- including the denominator the pass rate is printed over.
|
|
10
|
+
|
|
11
|
+
It lives in its own module because the two callers cannot import each other:
|
|
12
|
+
`check_coverage` already reaches into `run_baseline` for `BLOCKED_TOOLS`, so
|
|
13
|
+
`run_baseline` importing `check_coverage` back is a cycle. They each had their
|
|
14
|
+
own scanner, written months apart, differing in algorithm and agreeing only by
|
|
15
|
+
luck -- the shape of drift the rest of this harness spent several commits
|
|
16
|
+
removing. One definition, imported by both, is the fix.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def json_objects(text: str) -> list[dict[str, Any]]:
|
|
25
|
+
"""Every JSON object in `text`, in the order they appear.
|
|
26
|
+
|
|
27
|
+
Decoded from each `{` with `raw_decode` rather than matched with a regex,
|
|
28
|
+
so a brace that opens no valid object costs one character of scanning
|
|
29
|
+
instead of corrupting the match. A caller that wants the verdict takes the
|
|
30
|
+
LAST object naming one: the judge is told to end with it, and prose
|
|
31
|
+
reasoning toward it may quote a fragment on the way.
|
|
32
|
+
"""
|
|
33
|
+
dec, out, i = json.JSONDecoder(), [], 0
|
|
34
|
+
while (i := text.find("{", i)) >= 0:
|
|
35
|
+
try:
|
|
36
|
+
v, end = dec.raw_decode(text, i)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(v, dict):
|
|
41
|
+
out.append(v)
|
|
42
|
+
i = max(end, i + 1)
|
|
43
|
+
return out
|
|
@@ -67,6 +67,7 @@ EVENTS: dict[str, dict[str, set[str]]] = {
|
|
|
67
67
|
"mcp_tool_uses", "final_query_source",
|
|
68
68
|
"reported_calls", "contaminated", "contamination_reasons",
|
|
69
69
|
"input_tokens", "output_tokens", "cache_read_tokens",
|
|
70
|
+
"cache_write_tokens", "skills_invoked",
|
|
70
71
|
"cost_usd", "num_turns", "wall_seconds", "run_error", "at"},
|
|
71
72
|
},
|
|
72
73
|
"tool_call": {
|
|
@@ -85,7 +86,13 @@ EVENTS: dict[str, dict[str, set[str]]] = {
|
|
|
85
86
|
# errors, while a replay under another report's filter values returns
|
|
86
87
|
# real rows for the wrong population. Keeping all three on the event
|
|
87
88
|
# is what lets a re-execution be audited from the ledger at all.
|
|
88
|
-
|
|
89
|
+
# `target_shapes` is one row per search target -- its type, and whether
|
|
90
|
+
# it carried search text -- beside `targets`, which is the terms
|
|
91
|
+
# searched for and drops a target that carried none. Without it the
|
|
92
|
+
# bare-target rate is not recomputable from a run directory, only from
|
|
93
|
+
# transcripts, and transcripts get pruned.
|
|
94
|
+
"optional": {"targets", "target_shapes", "scopes", "rankedSummary",
|
|
95
|
+
"error", "traceId",
|
|
89
96
|
"query", "modelPath", "filterParams", "retrieval_mode",
|
|
90
97
|
"at"},
|
|
91
98
|
},
|
|
@@ -97,7 +104,14 @@ EVENTS: dict[str, dict[str, set[str]]] = {
|
|
|
97
104
|
"optional": {"judge_version", "rubric_sha", "golden_revision",
|
|
98
105
|
"artifactPath", "confidence", "column_pairing",
|
|
99
106
|
"contaminated", "gold_status", "gold_note",
|
|
100
|
-
"must_not_use_hits", "judge_verdict",
|
|
107
|
+
"must_not_use_hits", "judge_verdict",
|
|
108
|
+
# What this case's judge call cost. Collected per case and
|
|
109
|
+
# discarded on the write path until now, so the run could
|
|
110
|
+
# report what judging cost in total and never which case
|
|
111
|
+
# was expensive -- and a case that spent its whole turn
|
|
112
|
+
# budget without producing a verdict is the one worth
|
|
113
|
+
# finding.
|
|
114
|
+
"judge_cost_usd", "at"},
|
|
101
115
|
},
|
|
102
116
|
"retrieval_score": {
|
|
103
117
|
"required": {"intentId", "term"},
|
|
@@ -157,17 +171,44 @@ RUN_RECOMMENDED = {"judgeModel", "judgeVersion", "datasetVersion", "modelSha",
|
|
|
157
171
|
RUN_OPTIONAL = {"label", "effort", "environment", "package", "modelPath",
|
|
158
172
|
"modelGitSha", "mcpUrl", "publisher", "predictionsReExecuted",
|
|
159
173
|
"serverVersion", "diagnoserModel", "improverModel",
|
|
160
|
-
"rubricSha",
|
|
174
|
+
"rubricSha",
|
|
175
|
+
# The prompt the judge was SHOWN, hashed. `rubricSha` covers
|
|
176
|
+
# the judge's skill file and not the harness's own prompt
|
|
177
|
+
# template, so two runs whose prompts differed compared as the
|
|
178
|
+
# same judge -- the blind spot that let a golden-rendering
|
|
179
|
+
# defect run for three arms with every test passing.
|
|
180
|
+
"judgePromptSha", "setName", "targetVersion", "scope", "mode",
|
|
161
181
|
"traceMode",
|
|
182
|
+
# The answerer's cap and timeout, as the run actually ran them.
|
|
183
|
+
# `eval-loop` step 6 lists "call budget" among the pins to
|
|
184
|
+
# freeze for a whole arm, and nothing wrote one, so no two runs
|
|
185
|
+
# could be compared on the setting that decides whether a case
|
|
186
|
+
# got to finish at all. `callBudget` stays for runs written
|
|
187
|
+
# before this, and is not the same field: it was never
|
|
188
|
+
# populated by this harness.
|
|
189
|
+
"maxTurns", "answererTimeout",
|
|
190
|
+
# How many attempts never reached a verdict for a reason that
|
|
191
|
+
# is the HARNESS's, not the model's. Both leave the denominator
|
|
192
|
+
# and both suppress the pass rate (`incomplete` below).
|
|
193
|
+
"truncated", "contaminated",
|
|
162
194
|
"callBudget", "status", "answererSkills",
|
|
163
195
|
"answererCostUsd", "judgeCostUsd", "goldenCheck",
|
|
196
|
+
# The run whose attempts this run's answerer cost was COPIED
|
|
197
|
+
# from, when it did not spawn an answerer at all (a re-judge, a
|
|
198
|
+
# rebuild, a `--from`). Null when this run paid. Summing
|
|
199
|
+
# `answererCostUsd` across run files double-counts without it:
|
|
200
|
+
# one re-judge reported $17.56 of answering that never happened.
|
|
201
|
+
"answererCostCopiedFrom",
|
|
202
|
+
# Set when a rebuild reused saved verdicts, so `judgeCostUsd`
|
|
203
|
+
# is the ORIGINAL judging spend rather than this run's zero.
|
|
204
|
+
"judgeCostCopiedFrom",
|
|
164
205
|
"skillsRoot", "harnessVersion",
|
|
165
206
|
"judgeSkills", "diagnoserManifest",
|
|
166
207
|
"improverManifest", "doubtedGoldens",
|
|
167
208
|
"packageSha", "servedRevision", "datasetSha",
|
|
168
209
|
"staleEntityNames",
|
|
169
210
|
"retrievalMode", "retrievalCalls", "retrievalGate",
|
|
170
|
-
"reExecution",
|
|
211
|
+
"reExecution", "coverageReport",
|
|
171
212
|
"modelRepo"} | RUN_RECOMMENDED
|
|
172
213
|
|
|
173
214
|
|
|
@@ -306,14 +347,29 @@ def skills_git_sha(root: pathlib.Path | None = None) -> str | None:
|
|
|
306
347
|
the `skillsVersion` pin. Defaults to this checkout; pass the external
|
|
307
348
|
--skills-root when the doctrine came from elsewhere (a Publisher checkout).
|
|
308
349
|
The skills ARE the doctrine the agents load, so a run that cannot name
|
|
309
|
-
their version cannot take part in a comparison.
|
|
310
|
-
|
|
350
|
+
their version cannot take part in a comparison.
|
|
351
|
+
|
|
352
|
+
`-dirty` is decided over the SKILLS PATH, not the whole repository. It used
|
|
353
|
+
to be the whole tree, so every run ever taken read `-dirty` -- including
|
|
354
|
+
runs taken before anything was edited -- because a checkout picks up stray
|
|
355
|
+
untracked files as a matter of course. A marker that cannot distinguish
|
|
356
|
+
"someone left a scratch file in the repo root" from "the judge prompt was
|
|
357
|
+
rewritten between these two arms" is not a pin, and the runs that carry it
|
|
358
|
+
cannot be told apart on the one thing it exists to record."""
|
|
359
|
+
# Resolved, always: the path is used BOTH as git's working directory and as
|
|
360
|
+
# its pathspec, and a relative one means different things in those two
|
|
361
|
+
# positions. Passed `skills`, git ran in `skills/` and then looked for
|
|
362
|
+
# `skills/skills`, which matches nothing -- so every tree read clean and
|
|
363
|
+
# the marker silently stopped working in the other direction.
|
|
364
|
+
d = (pathlib.Path(root) if root
|
|
365
|
+
else pathlib.Path(__file__).resolve().parent).resolve()
|
|
311
366
|
try:
|
|
312
367
|
head = subprocess.run(["git", "-C", str(d), "rev-parse", "HEAD"],
|
|
313
368
|
capture_output=True, text=True, timeout=10)
|
|
314
369
|
if head.returncode != 0:
|
|
315
370
|
return None
|
|
316
|
-
dirty = subprocess.run(["git", "-C", str(d), "status", "--porcelain"
|
|
371
|
+
dirty = subprocess.run(["git", "-C", str(d), "status", "--porcelain",
|
|
372
|
+
"--", str(d)],
|
|
317
373
|
capture_output=True, text=True, timeout=10)
|
|
318
374
|
return head.stdout.strip() + ("-dirty" if dirty.stdout.strip() else "")
|
|
319
375
|
except Exception: # noqa: BLE001
|
|
@@ -178,6 +178,13 @@ class WriteContract(unittest.TestCase):
|
|
|
178
178
|
filterParams={"report_id": "123"})
|
|
179
179
|
self.assertEqual(e["filterParams"], {"report_id": "123"})
|
|
180
180
|
|
|
181
|
+
def test_an_attempt_may_carry_cache_write_tokens(self):
|
|
182
|
+
# The fourth token column. Without it the ledger held a cache-inclusive
|
|
183
|
+
# cost_usd and a breakdown that could not reproduce it.
|
|
184
|
+
e = ledger.event("attempt", **ATTEMPT, cache_read_tokens=2435273,
|
|
185
|
+
cache_write_tokens=900000)
|
|
186
|
+
self.assertEqual(e["cache_write_tokens"], 900000)
|
|
187
|
+
|
|
181
188
|
def test_run_config_requires_identity(self):
|
|
182
189
|
with self.assertRaises(ValueError) as cm:
|
|
183
190
|
ledger.run_config(**{k: v for k, v in RUN.items()
|
|
@@ -73,14 +73,123 @@ def search_terms(tool_input: dict[str, Any]) -> list[str]:
|
|
|
73
73
|
return terms
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def entity_hits(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
77
|
+
"""Every returned entity with the provenance the response carries.
|
|
78
|
+
|
|
79
|
+
`entity_ids` answers "what came back". This answers "how", and the two
|
|
80
|
+
facts it adds are the ones the scorer needs and never had:
|
|
81
|
+
|
|
82
|
+
- `relevance`, the server's own score. On the semantic path it is a cosine
|
|
83
|
+
and IS comparable across targets, which is exactly why the server
|
|
84
|
+
publishes it there and withholds it on the lexical path, where a lunr
|
|
85
|
+
score is relative to its own query.
|
|
86
|
+
- `matched_targets`, naming WHICH of the caller's search targets matched
|
|
87
|
+
this entity and how well. Without it a required entity that never came
|
|
88
|
+
back is indistinguishable from one the agent never asked for -- the
|
|
89
|
+
distinction between a retrieval defect and a phrase-detection defect,
|
|
90
|
+
and the whole reason retrieval scoring could not attribute a miss.
|
|
91
|
+
|
|
92
|
+
`position` is the entity's index in the flattened response and is NOT a
|
|
93
|
+
rank. The response is sorted globally by relevance and then bucketed into
|
|
94
|
+
source cards, so flattening card by card interleaves. The harness used to
|
|
95
|
+
write `range(1, n+1)` here and call it a rank; read `relevance` instead.
|
|
96
|
+
|
|
97
|
+
Entities are the only thing that carries this. A source card's own target
|
|
98
|
+
attribution is discarded by the server before an entity object exists, so
|
|
99
|
+
a `source:` hit has none and says so with `matched_targets: None`.
|
|
100
|
+
"""
|
|
101
|
+
hits: list[dict[str, Any]] = []
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
|
|
104
|
+
def add(ident: str, source: str, ent: dict[str, Any] | None,
|
|
105
|
+
in_card: int | None) -> None:
|
|
106
|
+
if ident in seen:
|
|
107
|
+
return
|
|
108
|
+
seen.add(ident)
|
|
109
|
+
mt = (ent or {}).get("matched_targets")
|
|
110
|
+
hits.append({
|
|
111
|
+
"entity_id": ident,
|
|
112
|
+
"source": source,
|
|
113
|
+
"relevance": (ent or {}).get("relevance"),
|
|
114
|
+
# None and [] are different claims: None is "this path publishes
|
|
115
|
+
# no attribution" (a source hit, or a lexical run), [] is "the
|
|
116
|
+
# server attributed it to no target".
|
|
117
|
+
"matched_targets": ([{"search_text": m.get("search_text"),
|
|
118
|
+
"relevance": m.get("relevance")}
|
|
119
|
+
for m in mt if isinstance(m, dict)]
|
|
120
|
+
if isinstance(mt, list) else None),
|
|
121
|
+
"position": len(hits) + 1,
|
|
122
|
+
"position_in_source": in_card,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
for node in payload.get("sources") or []:
|
|
126
|
+
if not isinstance(node, dict):
|
|
127
|
+
continue
|
|
128
|
+
info = node.get("source_info")
|
|
129
|
+
rid = info.get("resource_id") if isinstance(info, dict) else None
|
|
130
|
+
src = rid.get("source") if isinstance(rid, dict) else None
|
|
131
|
+
if not isinstance(src, str) or not src:
|
|
132
|
+
continue
|
|
133
|
+
add(f"source:{src}:{src}", src, None, 0)
|
|
134
|
+
for i, ent in enumerate(node.get("entities") or [], 1):
|
|
135
|
+
if not isinstance(ent, dict):
|
|
136
|
+
continue
|
|
137
|
+
# The same id rule `entity_ids` uses, so the two cannot disagree
|
|
138
|
+
# about what an entity is called.
|
|
139
|
+
eid = ent.get("entity_id")
|
|
140
|
+
if not (isinstance(eid, str) and eid):
|
|
141
|
+
name, kind = ent.get("name"), ent.get("entity_type")
|
|
142
|
+
if not (isinstance(name, str) and name and isinstance(kind, str)):
|
|
143
|
+
continue
|
|
144
|
+
eid = f"{kind.lower()}:{src}:{name}"
|
|
145
|
+
add(eid, src, ent, i)
|
|
146
|
+
return hits
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def target_shapes(tool_input: dict[str, Any]) -> list[dict[str, Any]]:
|
|
150
|
+
"""Every search target's TYPE, and whether it carried search text.
|
|
151
|
+
|
|
152
|
+
Separate from `search_terms` because that function answers a different
|
|
153
|
+
question -- what did the answerer search FOR -- and to answer it, it drops
|
|
154
|
+
a target with no `search_text`: there is no term to record. A target with
|
|
155
|
+
no text is not a search, it is an enumeration of that type over the scope,
|
|
156
|
+
and dropping it means the ledger cannot express the single statistic the
|
|
157
|
+
"the agent enumerates instead of searching" argument is made of. Measured
|
|
158
|
+
on a real 10-case run, every attempt read `targetsWithoutSearchText: 0`
|
|
159
|
+
because a bare target had already been discarded before the ledger saw it.
|
|
160
|
+
|
|
161
|
+
So this keeps one row per target, text or not, and nothing else. Reading
|
|
162
|
+
the bare-target rate off transcripts instead works and is what has been
|
|
163
|
+
done; it means the number cannot be recomputed from a run directory after
|
|
164
|
+
the transcripts are pruned.
|
|
165
|
+
"""
|
|
166
|
+
out: list[dict[str, Any]] = []
|
|
167
|
+
for t in tool_input.get("search_targets") or []:
|
|
168
|
+
if not isinstance(t, dict):
|
|
169
|
+
continue
|
|
170
|
+
text = t.get("search_text") or t.get("text")
|
|
171
|
+
out.append({"type": t.get("target_type") or "?",
|
|
172
|
+
"has_text": bool(isinstance(text, str) and text.strip())})
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def entity_id(kind: str, source: str | None, name: str) -> str:
|
|
177
|
+
"""The `kind:source:name` id, minted in one place.
|
|
178
|
+
|
|
179
|
+
This file was already the only place ids are BUILT; naming the format as a
|
|
180
|
+
function keeps it that way now that the definition ledger needs to build
|
|
181
|
+
them too. `score_retrieval.split_entity()` is the matching reader.
|
|
182
|
+
"""
|
|
183
|
+
return f"{kind}:{source}:{name}" if source else f"{kind}:{name}"
|
|
184
|
+
|
|
185
|
+
|
|
76
186
|
def _ident(node: dict[str, Any]) -> str | None:
|
|
77
187
|
eid = node.get("entityId")
|
|
78
188
|
if isinstance(eid, str) and eid:
|
|
79
189
|
return eid
|
|
80
190
|
kind, name = node.get("kind"), node.get("name")
|
|
81
191
|
if kind in ENTITY_KINDS and isinstance(name, str) and name:
|
|
82
|
-
|
|
83
|
-
return f"{kind}:{src}:{name}" if src else f"{kind}:{name}"
|
|
192
|
+
return entity_id(kind, node.get("source"), name)
|
|
84
193
|
return None
|
|
85
194
|
|
|
86
195
|
|