@malloy-publisher/skills 0.1.19 → 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 +6 -7
  49. package/skills/malloy-analysis/SKILL.md +22 -45
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +21 -6
  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 +2 -2
  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 +2 -2
  61. package/skills/malloy-gotchas-queries/SKILL.md +2 -2
  62. package/skills/malloy-gotchas-rendering/SKILL.md +2 -2
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +2 -2
  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 +2 -2
  75. package/skills/malloy-phrase-detection/SKILL.md +12 -7
  76. package/skills/malloy-publish/SKILL.md +1 -1
  77. package/skills/malloy-queries/SKILL.md +2 -2
  78. package/skills/malloy-review/SKILL.md +1 -1
  79. package/skills/malloy-scope/SKILL.md +7 -4
@@ -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
- "optional": {"targets", "rankedSummary", "error", "traceId",
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", "at"},
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", "setName", "targetVersion", "scope", "mode",
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
- d = pathlib.Path(root) if root else pathlib.Path(__file__).resolve().parent
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
- src = node.get("source")
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