@malloy-publisher/skills 0.1.20 → 0.1.22

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 (80) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +45 -5
  3. package/skills/eval-answer/reference/definition-ledger.md +305 -0
  4. package/skills/eval-answer/reference/ledger-schema.md +36 -7
  5. package/skills/eval-answer/scripts/check_coverage.py +12 -21
  6. package/skills/eval-answer/scripts/check_coverage_test.py +22 -0
  7. package/skills/eval-answer/scripts/check_findable.py +386 -0
  8. package/skills/eval-answer/scripts/check_findable_test.py +326 -0
  9. package/skills/eval-answer/scripts/json_scan.py +43 -0
  10. package/skills/eval-answer/scripts/ledger.py +63 -7
  11. package/skills/eval-answer/scripts/ledger_test.py +7 -0
  12. package/skills/eval-answer/scripts/mcp_payload.py +111 -2
  13. package/skills/eval-answer/scripts/mcp_payload_test.py +73 -2
  14. package/skills/eval-answer/scripts/publisher_rest.py +10 -4
  15. package/skills/eval-answer/scripts/publisher_rest_test.py +56 -0
  16. package/skills/eval-answer/scripts/score_retrieval.py +326 -41
  17. package/skills/eval-answer/scripts/score_retrieval_test.py +537 -15
  18. package/skills/eval-answer/scripts/verify_definitions.py +752 -0
  19. package/skills/eval-answer/scripts/verify_definitions_test.py +820 -0
  20. package/skills/eval-answer/scripts/verify_goldens.py +336 -22
  21. package/skills/eval-answer/scripts/verify_goldens_test.py +261 -2
  22. package/skills/eval-diagnose/SKILL.md +47 -2
  23. package/skills/eval-diagnose/scripts/cluster_failures.py +57 -14
  24. package/skills/eval-diagnose/scripts/cluster_failures_test.py +127 -0
  25. package/skills/eval-diagnose/scripts/diagnose.py +471 -42
  26. package/skills/eval-diagnose/scripts/diagnose_test.py +421 -0
  27. package/skills/eval-import/SKILL.md +180 -1
  28. package/skills/eval-import/reference/case-format.md +1 -1
  29. package/skills/eval-improve/SKILL.md +1 -1
  30. package/skills/eval-improve/scripts/improve.py +29 -3
  31. package/skills/eval-improve/scripts/improve_test.py +51 -0
  32. package/skills/eval-judge/SKILL.md +1 -1
  33. package/skills/eval-loop/SKILL.md +112 -3
  34. package/skills/eval-loop/reference/measurement.md +44 -4
  35. package/skills/eval-loop/reference/running-a-run.md +5 -1
  36. package/skills/eval-loop/scripts/agent_harness.py +21 -20
  37. package/skills/eval-loop/scripts/agent_harness_test.py +136 -0
  38. package/skills/eval-loop/scripts/build_run_package.py +9 -3
  39. package/skills/eval-loop/scripts/flip_table.py +107 -2
  40. package/skills/eval-loop/scripts/flip_table_test.py +165 -0
  41. package/skills/eval-loop/scripts/run_baseline.py +956 -97
  42. package/skills/eval-loop/scripts/run_baseline_test.py +799 -1
  43. package/skills/eval-loop/templates/eval-run-package/README.md +19 -5
  44. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +48 -19
  45. package/skills/eval-loop/templates/eval-run-package/eval_run.malloynb +11 -4
  46. package/skills/eval-loop/templates/eval-run-package/public/app.js +12 -4
  47. package/skills/eval-report/SKILL.md +362 -0
  48. package/skills/malloy/SKILL.md +2 -1
  49. package/skills/malloy-analysis/SKILL.md +1 -1
  50. package/skills/malloy-analysis-pitfalls/SKILL.md +13 -1
  51. package/skills/malloy-analysis-report/SKILL.md +1 -1
  52. package/skills/malloy-analyze/SKILL.md +1 -1
  53. package/skills/malloy-charts/SKILL.md +1 -1
  54. package/skills/malloy-dashboards/SKILL.md +1 -1
  55. package/skills/malloy-debug/SKILL.md +1 -1
  56. package/skills/malloy-define/SKILL.md +1 -1
  57. package/skills/malloy-discover/SKILL.md +1 -1
  58. package/skills/malloy-document/SKILL.md +1 -1
  59. package/skills/malloy-getting-started/SKILL.md +1 -1
  60. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  61. package/skills/malloy-gotchas-queries/SKILL.md +43 -24
  62. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  63. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  64. package/skills/malloy-html-data-app-runtime/SKILL.md +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-lookml-review/reference/curate-visibility.md +1 -1
  68. package/skills/malloy-materialization/SKILL.md +3 -3
  69. package/skills/malloy-materialization-tuning/SKILL.md +2 -2
  70. package/skills/malloy-model/SKILL.md +51 -17
  71. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  72. package/skills/malloy-modeling/SKILL.md +1 -1
  73. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  74. package/skills/malloy-notebooks/SKILL.md +2 -2
  75. package/skills/malloy-patterns/SKILL.md +1 -1
  76. package/skills/malloy-phrase-detection/SKILL.md +3 -2
  77. package/skills/malloy-publish/SKILL.md +2 -2
  78. package/skills/malloy-queries/SKILL.md +11 -12
  79. package/skills/malloy-review/SKILL.md +1 -1
  80. package/skills/malloy-scope/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.20",
4
+ "version": "0.1.22",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.20
4
+ version: 0.1.22
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -123,11 +123,16 @@ a returned source's documentation -- text the answerer reads and acts on. Only
123
123
  `missing` is a retrieval miss; the route per entity is recorded so the strict
124
124
  count is still there.
125
125
 
126
- Recall 1.0 with a wrong answer exonerates retrieval: the failure is in the query.
126
+ Recall 1.0 with a wrong answer exonerates retrieval: everything arrived. Whether
127
+ the agent misused it or the docs never said how to use it is `eval-diagnose`'s
128
+ call, sufficiency first, so the row reads `delivered, wrong` and names no owner.
127
129
  Recall below 1.0 and `coverage: covered` means the entity existed and search did
128
- not surface it; `derivable` or `absent` means there was nothing to surface. Those
129
- look identical in an answer score and have opposite owners, which is what makes
130
- this number worth having. It uses the search terms the answerer chose, so it
130
+ not surface it -- a documentation finding, because the retrieval algorithm is
131
+ fixed (semantic search over doc strings) and an entity that exists and does not
132
+ come back is one whose docs do not say what people ask; `eval-diagnose` calls it
133
+ `NOT-RETURNED`, owner model. `derivable` or `absent` means there was nothing to
134
+ surface. Those look identical in an answer score and have different owners,
135
+ which is what makes this number worth having. It uses the search terms the answerer chose, so it
131
136
  attributes a failure *within* an arm and does not compare retrieval across arms
132
137
  -- that is the engine-side `eval-retrieval` skill, which does not ship here.
133
138
 
@@ -155,6 +160,13 @@ How to invoke it. Either `--model <file-or-dir>` for a local package or
155
160
  python3 check_coverage.py --set evals/ecommerce --model model.malloy --version 0.0.58
156
161
  ```
157
162
 
163
+ Then hand the report back to the run: `run_baseline.py --coverage <report>`.
164
+ Its per-case verdict beats the case's authored `coverage` label in retrieval
165
+ attribution, `run.json` records which report was read, and each retrieval row
166
+ says whether `measured`, `authored` or `none` charged the failure. Without a
167
+ report, a case with no label is attributed to nobody rather than to the model,
168
+ which is what used to happen.
169
+
158
170
  Two flags change what the number means, so choose them rather than inheriting
159
171
  them. `--repeat N` samples each case N times and takes the majority; it defaults
160
172
  to **1 for a set**, because the score is a trend over many cases rather than a
@@ -189,6 +201,34 @@ stops being stable. Do not take the over-size message's advice to narrow
189
201
  `--model` to one file, which drops every imported source and manufactures
190
202
  `COVERAGE` verdicts.
191
203
 
204
+ ## Validate the definitions, not every answer
205
+
206
+ A golden must not be derived through the definitions the question TESTS. It may
207
+ reuse everything the question does not turn on -- joins, base sources, date
208
+ handling -- because circularity only bites where the key and the answer share the
209
+ step under measurement. That is what makes this affordable on a model too large
210
+ to reimplement.
211
+
212
+ `scripts/verify_definitions.py` checks each definition once against the layer
213
+ directly beneath it, and every case depending on it inherits the result. A golden
214
+ is trustworthy if it was derived independently, OR if every definition it tests
215
+ has itself been validated.
216
+
217
+ Two things it will not do, and both matter more than what it does. It never
218
+ reports a definition reaching through a join as validated, because fanout
219
+ inflates the measure and the control expression equally and the comparison stays
220
+ green on a broken join. And building the ledger without a server exits 3, not 0:
221
+ nothing was checked, and a caller must not read that as a pass.
222
+
223
+ A raw check -- a definition that reaches through a join -- is an authored
224
+ control: a person writes the population down as a query on the ledger record and
225
+ the tool re-runs it every time. It is not found unaided, and the docs say so.
226
+ `verify_goldens.py --definitions <ledger>` then applies the composition rule at
227
+ the gate: a set with no truth package exits 0 when every value-bearing case's
228
+ tested definitions are validated, and names the cases that are not.
229
+
230
+ **Read `reference/definition-ledger.md` before building or quoting one.**
231
+
192
232
  ## Step 5: Distrust the golden
193
233
 
194
234
  A reference answer can be wrong (parent-column fanout, a join on a shared
@@ -0,0 +1,305 @@
1
+ <!-- Validating a model's definitions once, so answer keys need not be re-derived per case. Read before building or reading a ledger. -->
2
+
3
+ # The definition ledger
4
+
5
+ ## Why it exists
6
+
7
+ The loop's standing rule is that a golden must never be derived through the model
8
+ under test, because a model bug would otherwise certify its own golden. That rule
9
+ is sound, and it does not scale: on a model with real transformation, deriving
10
+ every key independently means reimplementing the model. You end up with two
11
+ implementations and no reason to trust either, and the eval task becomes *rebuild
12
+ the model to verify the model*.
13
+
14
+ The ledger is the other half. **A definition is checked once, against the layer
15
+ directly beneath it, and every case depending on it inherits the result.** Work
16
+ scales with the number of definitions rather than the number of cases, and stops
17
+ growing as cases are added.
18
+
19
+ The rule this replaces:
20
+
21
+ > A key must be independent of **the definitions the question tests**, not of the
22
+ > whole model.
23
+
24
+ And the rule that makes that sound:
25
+
26
+ > A golden is trustworthy if it was derived independently, **or** if every
27
+ > definition it tests has itself been validated.
28
+
29
+ ## Deep models are cheaper per case, not dearer
30
+
31
+ The tractability argument is about definitions being **shared**, not about models
32
+ being shallow. An earlier draft claimed every measure sits one or two hops from a
33
+ raw column; measured across four models, that is false:
34
+
35
+ | Model | Definitions | within 2 hops | deepest chain |
36
+ |---|---|---|---|
37
+ | ecommerce (sample) | 32 | 90.6% | 3 |
38
+ | storefront (sample) | 19 | 94.7% | 3 |
39
+ | a 492-definition benchmark | 492 | 100% | 2 |
40
+ | a real 548-definition customer model | 548 | **42.5%** | **12** |
41
+
42
+ On that customer model, the twenty deepest measures close over **812**
43
+ definition-checks with no reuse and **116** with the ledger: an 86% saving.
44
+ Reuse pays off *more* as chains deepen, because deep chains share their lower
45
+ layers. Depth is what makes the ledger worth having.
46
+
47
+ ## What a record holds
48
+
49
+ ```json
50
+ {
51
+ "entityId": "measure:order_items:total_sales",
52
+ "kind": "measure",
53
+ "source": "order_items",
54
+ "name": "total_sales",
55
+ "expr": "sale_price.sum()",
56
+ "exprSha": "3f9a…",
57
+ "depends": ["measure:order_items:order_item_count"],
58
+ "check": {"kind": "within_model", "slice": "first 200 rows"},
59
+ "verdict": "agrees",
60
+ "needs": null
61
+ }
62
+ ```
63
+
64
+ `exprSha` is a **Merkle chain**, not a hash of the one line: it covers the
65
+ definition and everything beneath it. A measure whose own text never moved is
66
+ still stale once something it builds on changes, and a flat hash would report it
67
+ as current. `packageSha` exists already but is whole-package, so it invalidates
68
+ everything on any edit and distinguishes nothing.
69
+
70
+ ## The two check kinds
71
+
72
+ ### `within_model` -- no raw access, no truth package
73
+
74
+ Ask the model for the entity and for the expression it **claims** to be, in one
75
+ query, and compare:
76
+
77
+ ```malloy
78
+ run: order_items -> {
79
+ aggregate:
80
+ stated is total_sales
81
+ control is sale_price.sum()
82
+ }
83
+ ```
84
+
85
+ Both sides come from **one** query against one source, so they see identical
86
+ rows: a difference is the definition, not a moving population.
87
+
88
+ A dimension is scalar, and `aggregate:` rejects it outright ("Cannot use a scalar
89
+ field in an aggregate"), so a dimension is compared row by row over a slice. The
90
+ slice is recorded, because a slice is weaker evidence than a whole table and the
91
+ report must not imply otherwise.
92
+
93
+ This catches a definition that is not what it says: a filter nobody mentioned, a
94
+ renamed column, the wrong aggregate, or a served model that has moved away from
95
+ the text the ledger recorded.
96
+
97
+ ### `raw` -- needs a lens onto the base tables
98
+
99
+ **A within-model check is blind to fanout.** A join that duplicates rows inflates
100
+ the stated measure and the control expression equally, and the comparison stays
101
+ green on a genuinely broken join. So an expression reaching through a join records
102
+ `unchecked` with a reason, **never `agrees`** -- reporting it as validated would be
103
+ the tool committing the exact error it exists to find.
104
+
105
+ The exception is aggregates that survive uniform duplication: `AVG`, `STDDEV`,
106
+ `MIN`, `MAX`. Those stay checkable across a join.
107
+
108
+ Detection is by join **name**, not by spotting a dot. `sale_price.sum()` is a
109
+ method call on a column in the same source and is perfectly checkable;
110
+ `inventory_items.cost.sum()` traverses a join and is not. A first implementation
111
+ matched any `word.word` and held back `total_sales` -- the measure twelve of the
112
+ ecommerce set's cases depend on -- for a join it does not cross.
113
+
114
+ ## A raw check is a control a person wrote
115
+
116
+ An earlier version of this page, and the plan behind it, said raw checks would
117
+ "catch a definition wrong about the business, like the cogs case." **That is
118
+ retracted.** Re-deriving `total_gross_margin`'s own expression over the base
119
+ tables -- reconstructing the model's join from its declared key -- computes the
120
+ same quantity, cancelled lines included, and agrees. Mechanically that catches
121
+ only fanout from a mis-declared join, which Malloy's symmetric aggregates already
122
+ mostly prevent. It cannot see that the population is wrong.
123
+
124
+ What caught the cogs case was a person who read the `status` doc ("Cancelled
125
+ lines never ship and are not sales") and wrote `where: status != 'Cancelled'`
126
+ into the golden's query. That is an **authored assertion**. So a raw check is
127
+ exactly that, made durable: the conductor writes a control query onto the ledger
128
+ record, and `verify_definitions.py` re-runs and compares it on every rebuild.
129
+
130
+ ```json
131
+ {
132
+ "entityId": "measure:order_items:total_gross_margin",
133
+ "check": {
134
+ "kind": "raw",
135
+ "against": "model",
136
+ "query": "run: order_items -> { aggregate: control is sale_price.sum() { where: status != 'Cancelled' } - inventory_items.cost.sum() { where: status != 'Cancelled' } }",
137
+ "note": "excludes Cancelled, per status's own doc and units_sold"
138
+ },
139
+ "cause": "CONVENTION"
140
+ }
141
+ ```
142
+
143
+ A control may be authored on **any** measure record, not only a `raw` one, and
144
+ it supersedes the within-model check. That check can only say a measure is what
145
+ its expression says; a person can say what its population should be. `total_sales
146
+ is sale_price.sum()` agrees with itself while including the cancelled lines its
147
+ own docs exclude -- the within-model check reads `agrees`, and it is the measure
148
+ twelve of the sample set's cases depend on. A one-line control on it is the
149
+ whole difference.
150
+
151
+ The control returns one row with a `control` column. `against: model` runs it on
152
+ the same server as the measure and is the default -- it reuses the model's joins
153
+ and columns and asserts only the population. `against: truth` runs it on a truth
154
+ package of raw tables (`--truth-publisher`, `--truth-package`) for a fully
155
+ independent derivation, join included. The measure's own value is always fetched
156
+ through the model, separately, because the control is *supposed* to differ in
157
+ population and may run elsewhere.
158
+
159
+ Authoring a control is judgement, and the same rules as authoring a golden apply:
160
+ where two populations are both defensible from the docs, do not pick one --
161
+ record the ambiguity in `note` and leave the record `unchecked`. `cause` is the
162
+ `eval-diagnose` code the author expects a disagreement to mean (the cogs case is
163
+ `CONVENTION`); it is carried onto the finding, not inferred.
164
+
165
+ This is **validate what someone asserted**, not **find business-wrong definitions
166
+ unaided**. It turns "I believe `total_gross_margin` should exclude cancelled
167
+ lines" into a checked, re-runnable assertion with a sha, so the belief is tested
168
+ on every rebuild instead of once in someone's head. The difference is the whole
169
+ point, and the run report must not blur it.
170
+
171
+ ### Build once, then maintain
172
+
173
+ `--out` writes a fresh ledger. `--ledger <file>` reads an existing one, keeps
174
+ every field a person wrote (`check.query`, `check.against`, `check.note`,
175
+ `cause`), re-parses the model, re-runs every check, and writes it back. Nothing
176
+ else is carried: a verdict is never inherited from a definition that may have
177
+ moved, and a record whose definition the model no longer declares is dropped,
178
+ control and all -- a control for a measure that does not exist is not a
179
+ validated measure.
180
+
181
+ ## What it will not claim
182
+
183
+ Every one of these records `unchecked`, and a reader must treat `unchecked` as
184
+ "nobody has established this", never as "fine":
185
+
186
+ | Situation | Why not checked |
187
+ |---|---|
188
+ | reaches through a join, and no control has been authored | fanout moves both sides of a within-model check equally, and nobody has written down what the population should be |
189
+ | spans more than one line | only the first line was read, so the recorded expression is a fragment |
190
+ | the query failed | a check that could not run is not a check that passed |
191
+ | no rows came back | nothing to compare |
192
+ | every sampled row null on both sides | proves nothing about the definition |
193
+ | no enclosing source | nothing to query it against |
194
+
195
+ ## Exit codes
196
+
197
+ Matching `verify_goldens.py`, deliberately:
198
+
199
+ | | |
200
+ |---|---|
201
+ | **0** | every check that could run ran, and agreed |
202
+ | **1** | a definition disagrees with its own stated expression |
203
+ | **3** | could not run: no model, no server, or a ledger built and never checked |
204
+
205
+ **3 is load-bearing.** Building the ledger without `--publisher` returns 3, not 0:
206
+ it validated nothing, and a caller must never read that as a pass.
207
+
208
+ ## Running it
209
+
210
+ ```bash
211
+ # build the ledger only (exits 3: nothing was checked)
212
+ python3 verify_definitions.py --model ecommerce.malloy --out evals/definitions/ecommerce.jsonl
213
+
214
+ # build and check
215
+ python3 verify_definitions.py --model ecommerce.malloy \
216
+ --publisher http://localhost:4811 --environment samples \
217
+ --package ecommerce --model-path ecommerce.malloy \
218
+ --out evals/definitions/ecommerce.jsonl
219
+ ```
220
+
221
+ Measured on the ecommerce sample: **28 definitions, 15 agree, 13 unchecked**, of
222
+ which 4 need a raw check. The remainder are multi-line definitions and fields
223
+ declared inside a nested block, which the line scanner attributes to the
224
+ enclosing source and cannot address.
225
+
226
+ ## What a run reports
227
+
228
+ `run_baseline.py --definitions <ledger>` adds an EVIDENCE block saying what the
229
+ pass rate rests on, per case:
230
+
231
+ ```
232
+ EVIDENCE
233
+ basis 5 independent, 8 definitions, 36 unchecked
234
+ ! those cases rest on a definition nobody has validated. Not a
235
+ failure, and not a pass either.
236
+ ```
237
+
238
+ | basis | meaning |
239
+ |---|---|
240
+ | `independent` | the key was established outside the model: two differently shaped derivations agree, or it holds no value at all (`criteria`, `unanswerable`) |
241
+ | `definitions` | the key went through the model, and every definition it tests is validated |
242
+ | `unchecked` | at least one tested definition is unvalidated, stale, or absent from the ledger |
243
+ | `disagrees` | a definition the case depends on contradicts its own expression, so the model is wrong before the answer is |
244
+
245
+ Independence is read from `golden.verification` (or `gold/<qid>.json`) through
246
+ `verify_goldens.has_second_derivation` -- the structured record of "two
247
+ differently shaped derivations agree". **Not from `verifiedBy`,** which is free
248
+ text: a first implementation matched it by prefix and classified 34 goldens of
249
+ the ecommerce set as `unchecked` when every one of them reads "authored and
250
+ re-derived against ecommerce-truth". A well-founded set reading as unvalidated
251
+ is the same over-claim as an unfounded one reading as validated, pointed the
252
+ other way.
253
+
254
+ Which definitions a case tests comes from `expectedEntities`, which already
255
+ names entities in the `kind:source:name` form the ledger keys on. No new
256
+ hand-maintained field: a wrong entity id is the failure mode that cost a real
257
+ set two days, so this reuses a link the set already maintains and that
258
+ `verify_goldens` check 5 audits against the model.
259
+
260
+ **Without `--definitions` there is no EVIDENCE block at all.** Absent is a
261
+ different fact from checked, and a reassuring empty block would be the
262
+ over-claim this whole direction exists to remove.
263
+
264
+ Staleness is a hash comparison against the run's own `model.malloy` snapshot --
265
+ the bytes that actually answered -- so it costs nothing and runs every arm.
266
+
267
+ ## The composition rule at the gate
268
+
269
+ `verify_goldens.py` used to exit 3 -- "could not run" -- on any set that named no
270
+ truth package, because no golden value could be re-derived. That was right when
271
+ re-derivation was the only evidence there was. With a ledger it is one of two:
272
+
273
+ > A golden is trustworthy if it was derived independently, **or** if every
274
+ > definition it tests has itself been validated.
275
+
276
+ `verify_goldens.py --definitions <ledger>` applies the second half. For each
277
+ value-bearing case it takes the entities the case names in `expectedEntities` --
278
+ the same ids the ledger is keyed on -- and asks whether every one is `agrees` in
279
+ the ledger and not stale against `--model`. If every case passes, the absent truth
280
+ server is not a check that failed to happen, and the audit exits 0 with a line
281
+ saying what validated it; `--promote` may then promote a `provisional` golden that
282
+ also carries a second derivation, and `verifiedBy` names the ledger. If any case
283
+ does not pass, the exit stays 3 and the message names the cases and the
284
+ definitions that let them down. Nothing is inferred about a case that names no
285
+ entities: it is unvalidated, and it says so.
286
+
287
+ `improve.py` passes `--definitions` through when it has one, so the acceptance
288
+ check can pass on a set that never had a truth package.
289
+
290
+ ## Where it goes
291
+
292
+ `evals/definitions/<package>.jsonl` -- keyed by **package, not by set**, because
293
+ definitions belong to the model and outlive any one set, and several sets against
294
+ the same package share them. Never under the served package tree; the golden side
295
+ door forbids eval artifacts there.
296
+
297
+ ## What this is not
298
+
299
+ It is not a unit test. Publisher's roadmap plans fixture-based unit tests (a CSV
300
+ per source plus an expected result) alongside data tests in `malloy-pub lint`.
301
+ A fixture unit test proves an expression computes what you wrote -- so it would
302
+ **not** catch a measure whose expression is correct and whose *population* is
303
+ wrong, which is the defect class this exists for. The ledger is a data test, and
304
+ it should hand off to `lint` when that governance work lands rather than becoming
305
+ a permanent parallel mechanism.
@@ -87,7 +87,7 @@ One JSON object per line:
87
87
  | `golden.rubric` | **On the golden, not on the case.** The prose the judge is shown as `CASE RUBRIC`, naming what counts as correct and what does not. `run_baseline.py` reads `golden.rubric`; a rubric written at the case's top level is silently not passed, the judge is told `CASE RUBRIC: none`, and it then scores from the golden value and the answer alone -- which reads as a judge that ignores its instructions. |
88
88
  | `golden.mustState` | **On the golden, not on the case.** What the answer has to say out loud. Read from `golden.mustState` by the run-package builder. |
89
89
  | `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |
90
- | `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers diagnosis to "retrieval ranking" for a failure that was never retrieval's. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |
90
+ | `expectedEntities` | `required`: entity ids (`kind:source:name`) the answer cannot be produced without. `requiredAnyOf`: a list of **groups**, each a list of ids of which any one suffices -- for a case the model can answer through more than one route. Naming only one route scores the other as a retrieval miss and steers the fix to the docs for an entity whose docs were never the problem. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |
91
91
 
92
92
  ## Term files (`eval-retrieval` only)
93
93
 
@@ -116,6 +116,7 @@ on that axis.
116
116
  | `answererModel` *(required)* | |
117
117
  | `phase` / `started` *(required)* | |
118
118
  | `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |
119
+ | `judgePromptSha` *(pin)* | Content sha of the prompt the judge is SHOWN, which is the harness's own template and not the skill file `rubricSha` covers. The template carries the golden rendering, the evidence blocks and the tie-breaking rules, so two runs whose prompts differed compared as the same judge -- the blind spot that let a golden-rendering defect run through three arms with every test passing, because the tests covered the code paths and not the text the model sees. Absent on runs written before it existed. |
119
120
  | `datasetVersion` *(pin)* | From `set.json` at run time. |
120
121
  | `modelSha` *(pin)* | Content sha of the `model.malloy` snapshot in the run directory -- the bytes the answerer actually queried. A git sha is not enough: a snapshot host serves a copy, often of a dirty tree no commit names. |
121
122
  | `skillsVersion` *(pin)* | HEAD of the checkout the agents' skills were loaded from, dirty-marked (`ledger.skills_git_sha(root)`). The skills are the doctrine the agents load; a run that cannot name their version cannot anchor a skills A/B. |
@@ -132,12 +133,16 @@ on that axis.
132
133
  | `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |
133
134
  | `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |
134
135
  | `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |
136
+ | `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |
135
137
  | `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |
136
138
  | `effort` | |
137
- | `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every "cost per arm" quoted before then was the answerer alone. |
139
+ | `maxTurns` / `answererTimeout` *(pins)* | The answerer's turn cap and per-attempt timeout, as the arm ran them. `skill:eval-loop` step 6 lists the call budget among the pins to freeze for a whole run, and nothing wrote one, so no two runs could be compared on the setting that decides whether a case got to finish at all. Distinct from `callBudget`, which no harness has ever written. Size the cap from a pilot rather than taking the default: on a 29-case arm at the default 30, completed attempts had a median of 17 turns and a 90th percentile of 26, and the four cases that ran past it were recorded as wrong answers. |
140
+ | `truncated` / `contaminated` | The qids excluded for a HARNESS-owned reason: the cap cut the answer off, or the attempt breached isolation. Both leave the denominator, both are named here rather than only in the console, and either one makes `status` `incomplete`. |
141
+ | `answererCostUsd` / `judgeCostUsd` | What the arm cost, split by role. The judge's half was discarded until 2026-09-02, so every "cost per arm" quoted before then was the answerer alone. Per-case judge cost is on each `score` event. |
142
+ | `answererCostCopiedFrom` | The run whose attempts `answererCostUsd` was COPIED from, when this run spawned no answerer -- a `--rejudge`, a `--rebuild`, a `--from`. Those copy the source's attempt events verbatim, `cost_usd` included, so the figure is right about the attempts and wrong as this run's spend: one re-judge reported $17.56 of answering that never happened. Null when this run paid, so a total over several run files can skip the copies. |
138
143
  | `goldenCheck` | What `verify_goldens.py` said before the run started: `N ok, M drifted, K other finding(s)`, or why it did not run (no truth server on a platform target; `--skip-golden-check`; rebuild). A run that started on a drifted set says so here rather than pretending its verdicts mean something. `run_baseline` calls `verify()` without a model, so the set-name audit (check 5) does not run in-arm; the trailing `N stale entity name(s)` is the in-arm lint's own count, and `entity-name lint not run` says there was no model text to lint against. Without it the field read `0 other finding(s)` for a check that never happened. |
139
144
  | `staleEntityNames` | The `expectedEntities` names that appear nowhere in the served model, from the in-arm lint. Each one depresses the run's own recall on every case listing it, so a stale set reads as a retrieval failure until someone checks by hand. `null` when there was no model text to lint against (a platform target), which is a different fact from `[]`. |
140
- | `status` | `complete`, or `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust. |
145
+ | `status` | `complete`; `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust; or `incomplete` when the arm ran to the end and still cannot report a rate, because `truncated` or `contaminated` is non-empty. `incomplete` is a third state, not a soft `complete`: the arm did everything asked of it and the number is still unearned. It does NOT stop the run being compared: `flip_table.py` scores the cases both arms did, and names the ones this arm left unscored -- an excluded case carries no verdict, so it can never be counted as a flip. |
141
146
  | `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package, so an edit to an imported file moves it where a sha of the one `--model-path` does not; `servedRevision` is minted per load, so it identifies a load rather than content and is a poor pin alone. Measured: `publisher.json`'s `version` moves neither, and nothing in Publisher reads it -- it is not in the Package API schema and never returned, so it pins nothing. |
142
147
  | `datasetSha` *(pin)* | Content hash of `set.json` + `cases.jsonl`. Deliberately SEPARATE from the model's sha: a golden repair is not a model change, and one pin covering both would make every answer-key fix read as an edit to the model, which is the distinction an A/B rests on. Automatic, so nobody has to remember it; `datasetVersion` stays beside it as the human-readable sequence. **Local targets only.** A hosted target that publishes IMMUTABLE versions needs none of this: the set rides inside the version, and immutability -- not hashing -- is what makes a pin trustworthy. There, `targetVersion` alone identifies model and set together. |
143
148
  | `doubtedGoldens` | The cases whose golden is not believed: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`, and `declaredBy` (`judge` or `set`). A status the SET declared is a key somebody already settled, not an opinion the judge formed this run, and attributing both to the judge claimed a judgement that never happened. **Read this before diagnose.** These are dataset issues, not model failures, and they go through the golden side door in `skill:eval-loop`. Empty list when no key is in doubt from either source. Written from the same scan that prints the end-of-run warning, because a warning that lives only in console text is one scrollback away from sending a modelling agent at a model that is already right. |
@@ -172,10 +177,11 @@ exist in the flat shape, so the flat shape is the contract.) `kind` is one of:
172
177
  | `reported_calls` | int | MCP calls the answerer claimed. `reported_calls > host_tool_uses` is the under-report floor from `skill:eval-answer`: the answerer cannot have made more calls than the host logged. `validate_run` warns on it, and only for runs that carry `mcp_tool_uses`, because the field meant something narrower before. |
173
178
  | `contaminated` | bool or `"unknown"` | `"unknown"` when no host log exists. Read it with `ledger.is_contaminated`, never for truthiness: runs written before 2026-09-03 carry the strings `"true"`/`"false"`, and `bool("false")` is True. `ledger.event` now rejects those strings on write; `validate_run` grandfathers them on read as a warning. |
174
179
  | `contamination_reasons` | list | Empty when clean. |
175
- | `input_tokens` / `output_tokens` / `cache_read_tokens` | int or null | Answerer token usage. Null when the host does not report it. |
180
+ | `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` | int or null | Answerer token usage, the four counts that reprice a run from its own ledger. Null when the host does not report it. `cost_usd` beside them comes from the CLI's `total_cost_usd`, which prices cache reads and writes at their rates, so it was always cache-inclusive; what was missing was the breakdown that reproduces it. `cache_write_tokens` (`cache_creation_input_tokens` upstream) was never captured before 2026-09-15, and on one analysed run cache writes were 44% of the agent's cost, the single largest line. **Read `input_tokens` with care:** it counts only the tokens after the last cache breakpoint. One run recorded 224 for 24 questions beside 2.4M cache reads; the 224 is not the context volume, and a token total that omits the cache columns is not a size. |
176
181
  | `cost_usd` | float or null | Answerer cost for this attempt. |
177
182
  | `num_turns` / `wall_seconds` | int, float or null | |
178
183
  | `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |
184
+ | `skills_invoked` | list of string | The skills the answerer invoked through the `Skill` tool, deduplicated and sorted, counted from its own transcript. Always a list on a run that recorded it, so an EMPTY list is the finding: the arm granted a manifest and the agent reached for none of it. Absent on runs written before 2026-09-15, which is why every reader takes `or []`. Only explicit `Skill` calls are counted, and only skills the arm granted -- one outside `--answerer-skills` is a contamination breach and lands in `contamination_reasons` instead. What it is for: a manifest names what an arm GRANTED, and comparing two arms' doctrine is meaningless if the doctrine never loaded. |
179
185
  | `transcriptPath` | string | The answerer's transcript under `artifacts/`. |
180
186
 
181
187
  Token counts sit here rather than being derived later because the claim a
@@ -192,7 +198,8 @@ One event per MCP `get_context` or `execute_query` the attempt made.
192
198
  | `tool` | string | `get_context` or `execute_query`. |
193
199
  | `traceId` | string or null | `get_context` only; look up in your host's trace store. |
194
200
  | `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |
195
- | `rankedSummary` | object | Copied at capture from the trace so evidence survives trace eviction: `entityIds`, `ranks`, `resultCount`, and per-target `targets` with within-target ranks. |
201
+ | `rankedSummary` | object | Copied at capture so evidence survives trace eviction: `entityIds` (identity, in response order), `hits` (one row per entity: `entity_id`, `source`, `relevance`, `matched_targets`, `position`, `position_in_source`), `resultCount`, `docTokens`. **There is no rank.** This field previously documented `ranks` and "per-target `targets` with within-target ranks"; no writer ever wrote either, and what `ranks` did hold was `range(1, n+1)` over a list already flattened source-card by source-card -- a position, not a rank, and not even a relevance order, since the response sorts globally and then buckets into cards. Ask relevance questions of `hits[].relevance`. |
202
+ | `rankedSummary.hits[].matched_targets` | list or null | Which of the caller's search targets matched this entity, each with the server's own relevance. **`null` means no attribution was published**, which is not the same as `[]`: the server attaches it to entities only (a source card's attribution is discarded before an entity object exists) and only on the semantic path, because a lexical score is relative to its own query and comparing two of them means nothing. A lexical run therefore cannot say which target found what, and a reader must report that as unavailable rather than as "never asked". |
196
203
  | `retrieval_mode` | string or null | `get_context` only: the `retrieval` field of the response that answered, `semantic` or `lexical`. Null when the server named none, which means no embedding provider. Recorded per call because the semantic path can fall over partway through a run. |
197
204
  | `query` | string or null | `execute_query` only: the Malloy the call sent, so the final query can be chosen by which call the server actually answered. |
198
205
  | `modelPath` | string or null | `execute_query` only: the model file the call named. A source does not resolve outside the file that declares it, so re-execution needs this. |
@@ -222,14 +229,14 @@ The answer judge's verdict for one attempt (protocol in
222
229
  | Field | Type | Notes |
223
230
  |---|---|---|
224
231
  | `verdict` | string or null | `match` / `near_match` / `no_match` / `needs_human`; null when the attempt is not scorable. Only `match` and `no_match` are decisions; see below. |
225
- | `reason` | string | Why, from the judge; for a null verdict, why not scorable: `not_submitted`, `contaminated`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. The `golden_*` reasons are decided before the judge is called, so an unestablished key costs nothing to refuse. |
232
+ | `reason` | string | Why, from the judge; for a null verdict, why not scorable, and it is ALWAYS one of these: `not_submitted`, `answerer_truncated`, `contaminated`, `judge_unparseable`, `no_saved_verdict`, `judge_error: <text>`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. A null verdict never arrives without one: a nulling that does not say which of these it was is indistinguishable from the field being dropped on the write path, and was read as exactly that from a real run. The `golden_*` reasons and `answerer_truncated` are decided before the judge is called, so neither an unestablished key nor a cut-off attempt costs a judge call. |
226
233
  | `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |
227
234
  | `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |
228
235
  | `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |
229
236
  | `golden_revision` | int | From the case at score time. |
230
237
  | `contaminated` | bool or `"unknown"` | Copied from the attempt; true or unknown means `verdict: null`. |
231
238
  | `must_not_use_hits` | list or null | Entries of `golden.mustNotUse` a script found in the final query. A hit forces `verdict: no_match`. Decided by `check_must_not_use.py`, never by the judge: it is a question about query text. Only a BARE name vetoes; an entry with a connective (`X as ...`, `X through ...`) objects to a use of X rather than to X, and goes to the judge with the prose entries and a path's bare leaf. |
232
- | `judge_verdict` | string or null | What the judge said when a `must_not_use_hits` veto overrode it. Null otherwise, so the judge's own agreement rate stays measurable across vetoes. |
239
+ | `judge_verdict` | string or null | What the judge said when a MECHANICAL override replaced it: a `must_not_use_hits` veto, or contamination voiding the verdict. Null otherwise, so the judge's own agreement rate stays measurable across both. When a case is vetoed AND contaminated this still holds the judge's read, not the veto's `no_match`. |
233
240
  | `artifactPath` | string | The full judge output under `artifacts/`. |
234
241
  | `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`, and never anything else: the case file's `golden.status` is a DIFFERENT vocabulary that shares only `verified` and `verified_wrong`, and only those two fall back into this field. A `provisional` / `invalid` / `ambiguous` key is reported through the verdict's `reason` (`golden_<status>`) instead, so nothing downstream has to match on two vocabularies in one field. **From the judge**, which scored against the golden as written and reports separately whether it believes it; falls back to the case's standing `golden.status` when the judge does not say, written onto the verdict itself so the run summary and the aggregates see it too. `verified_wrong` excludes the case from run aggregates -- from the judge OR from the case file; assigning the fallback into a copy of the verdict meant a key the set had marked wrong stayed in the aggregates unless the judge independently agreed. `suspect` and `verified_wrong` route to the golden side door as `dataset` issues, never to improve. |
235
242
  | `gold_note` | string or null | The judge's evidence for a non-`verified` status: the two values, or the model line against the rubric sentence. Null when `verified`. |
@@ -255,6 +262,28 @@ rate, which is the one thing the answerable-sounds-unanswerable cases exist to
255
262
  measure. A refusal is exempt only where `golden.kind` is `unanswerable`, and
256
263
  there naming the gap is the pass and a confident number is the fail.
257
264
 
265
+ `answerer_truncated` means the answerer hit the run's `maxTurns` cap and the
266
+ CLI returned whatever it had, which is usually a sentence stopping mid-thought.
267
+ It is refused BEFORE the golden gate and before a saved verdict is reused, so
268
+ `--rebuild` over a run made without this re-derives the right ledger from its
269
+ transcripts. A cap is a harness setting, so a case cut off by one says nothing
270
+ about whether the model could answer it: measured on one 29-case arm, four
271
+ attempts ended at exactly 31 turns and each was judged as a wrong answer, one
272
+ of them on the text "Let me build the correct monthly trend query."
273
+
274
+ `contaminated` means the attempt breached isolation (`contamination_reasons`
275
+ names how) and the verdict is withheld rather than lost. The judge's own read
276
+ moves to `judge_verdict`. Before this reason was written, a voided verdict kept
277
+ the judge's prose in `reason` beside `verdict: null`, and a run report
278
+ reasonably concluded the ledger was dropping the field -- it recovered nine
279
+ "lost" verdicts by re-parsing the stored judge replies, every one of which had
280
+ been voided on purpose.
281
+
282
+ Both are HARNESS-owned, not model-owned, and a run containing either reports
283
+ `status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`
284
+ (no established golden) and `judge_unparseable` do not suppress the rate: the
285
+ first is a dataset state no arm can fix, and the second already has a retry.
286
+
258
287
  Aggregates count decided verdicts only. `match` and `no_match` are the pass and
259
288
  the fail; **`near_match`, `needs_human` and null are none of the above** and stay
260
289
  out of acceptance arithmetic. `near_match` is excluded because it means "defensibly
@@ -43,6 +43,7 @@ from __future__ import annotations
43
43
 
44
44
  import argparse
45
45
  import concurrent.futures as futures
46
+ import hashlib
46
47
  import json
47
48
  import pathlib
48
49
  import re
@@ -56,6 +57,7 @@ HERE = pathlib.Path(__file__).resolve().parent
56
57
  SKILLS_ROOT = HERE.parent.parent
57
58
  sys.path.insert(0, str(HERE))
58
59
 
60
+ from json_scan import json_objects # noqa: E402
59
61
  from verify_goldens import model_text as local_model_text # noqa: E402
60
62
 
61
63
  # The `claude -p` invocation lives once, in the harness, rather than being
@@ -234,27 +236,6 @@ one candidate and its `ruled_out` entry is null, the verdict is not `ok`.
234
236
  """
235
237
 
236
238
 
237
- def json_objects(text: str) -> list[dict[str, Any]]:
238
- """Every JSON object in `text`, in the order they appear.
239
-
240
- Decoded from each `{` rather than matched with `\\{.*\\}`, which is greedy
241
- and spans the FIRST brace to the LAST. The prompt hands the agent the model
242
- text, so one `extend { ... }` quoted back in the narration would swallow the
243
- verdict and a good reply would read as unparseable.
244
- """
245
- dec, out, i = json.JSONDecoder(), [], 0
246
- while (i := text.find("{", i)) >= 0:
247
- try:
248
- v, end = dec.raw_decode(text, i)
249
- except json.JSONDecodeError:
250
- i += 1
251
- continue
252
- if isinstance(v, dict):
253
- out.append(v)
254
- i = max(end, i + 1)
255
- return out
256
-
257
-
258
239
  def parse_reply(text: str, allowed: tuple[str, ...]) -> dict[str, Any]:
259
240
  """The agent's JSON, or a null verdict saying why not.
260
241
 
@@ -688,6 +669,15 @@ def main(argv: list[str] | None = None) -> int:
688
669
  else:
689
670
  raise SystemExit("pass --model <path>, or --publisher with --package")
690
671
 
672
+ # What was measured, pinned by content. Coverage is sold as a per-version
673
+ # trend, and a trend needs each point tied to the bytes behind it: a
674
+ # `--model <dir>` run stamped `version: null` and named no path, so two
675
+ # runs reading 38% and 62% could not afterwards be told apart. The sha is
676
+ # over the same text the agent is shown, which is already in memory.
677
+ model_source = (str(a.model_path.resolve()) if a.model_path
678
+ else f"{a.publisher} {a.environment}/{a.package}")
679
+ model_sha = hashlib.sha256(model.encode()).hexdigest()
680
+
691
681
  cases = [json.loads(l) for l in (a.set_dir / "cases.jsonl").read_text()
692
682
  .splitlines() if l.strip()]
693
683
  if a.only:
@@ -715,6 +705,7 @@ def main(argv: list[str] | None = None) -> int:
715
705
  if a.out:
716
706
  a.out.write_text(json.dumps(
717
707
  {"version": a.version, "set": str(a.set_dir),
708
+ "modelSource": model_source, "modelSha256": model_sha,
718
709
  "agentModel": a.agent_model, **s, "cases_detail": rows,
719
710
  **({"labelComparison": serialisable(cmp)} if cmp else {})},
720
711
  indent=2))
@@ -404,5 +404,27 @@ class TheComparisonSurvivesTheArtifactWrite(unittest.TestCase):
404
404
  # The console report still reads the tuple-keyed original.
405
405
  self.assertIn("covered", cc.label_report(cmp))
406
406
 
407
+ class ModelIdentity(unittest.TestCase):
408
+ """Coverage is sold as a per-version trend, and a trend needs each point
409
+ tied to the bytes behind it. A `--model <dir>` run stamped `version: null`
410
+ and named no path, so two runs reading 38% and 62% could not afterwards be
411
+ told apart -- the numbers became unciteable."""
412
+
413
+ SRC = (pathlib.Path(__file__).resolve().parent / "check_coverage.py").read_text()
414
+
415
+ def test_the_report_carries_the_model_source_and_sha(self):
416
+ self.assertIn('"modelSource": model_source', self.SRC)
417
+ self.assertIn('"modelSha256": model_sha', self.SRC)
418
+
419
+ def test_the_sha_is_over_the_text_the_agent_was_shown(self):
420
+ self.assertIn("hashlib.sha256(model.encode()).hexdigest()", self.SRC)
421
+
422
+ def test_a_local_run_names_the_resolved_path(self):
423
+ self.assertIn("str(a.model_path.resolve()) if a.model_path", self.SRC)
424
+
425
+ def test_a_served_run_names_the_publisher_and_package(self):
426
+ self.assertIn('f"{a.publisher} {a.environment}/{a.package}"', self.SRC)
427
+
428
+
407
429
  if __name__ == "__main__":
408
430
  unittest.main()