@malloy-publisher/skills 0.1.23 → 0.1.27

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 (61) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +9 -4
  3. package/skills/eval-answer/reference/coverage-limits.md +7 -4
  4. package/skills/eval-answer/reference/definition-ledger.md +2 -2
  5. package/skills/eval-answer/reference/ledger-schema.md +2 -1
  6. package/skills/eval-answer/scripts/check_coverage.py +175 -42
  7. package/skills/eval-answer/scripts/check_coverage_test.py +244 -52
  8. package/skills/eval-answer/scripts/init_truth_package.py +14 -1
  9. package/skills/eval-answer/scripts/ledger.py +1 -1
  10. package/skills/eval-answer/scripts/score_retrieval.py +4 -4
  11. package/skills/eval-answer/scripts/score_retrieval_test.py +8 -8
  12. package/skills/eval-answer/scripts/verify_goldens.py +217 -10
  13. package/skills/eval-answer/scripts/verify_goldens_test.py +237 -0
  14. package/skills/eval-diagnose/SKILL.md +6 -6
  15. package/skills/eval-diagnose/scripts/diagnose.py +3 -1
  16. package/skills/eval-diagnose/scripts/diagnose_test.py +12 -8
  17. package/skills/eval-import/SKILL.md +1 -1
  18. package/skills/eval-import/reference/case-format.md +19 -1
  19. package/skills/eval-improve/SKILL.md +1 -1
  20. package/skills/eval-judge/SKILL.md +25 -2
  21. package/skills/eval-judge/reference/writing-rubrics.md +8 -0
  22. package/skills/eval-loop/SKILL.md +1 -1
  23. package/skills/eval-loop/reference/running-a-run.md +4 -0
  24. package/skills/eval-loop/scripts/agent_harness.py +5 -0
  25. package/skills/eval-loop/scripts/agent_harness_test.py +3 -1
  26. package/skills/eval-loop/scripts/run_baseline.py +75 -3
  27. package/skills/eval-loop/scripts/run_baseline_test.py +92 -0
  28. package/skills/eval-report/SKILL.md +4 -4
  29. package/skills/malloy/SKILL.md +1 -1
  30. package/skills/malloy-analysis/SKILL.md +1 -1
  31. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  32. package/skills/malloy-analysis-report/SKILL.md +1 -1
  33. package/skills/malloy-analyze/SKILL.md +1 -1
  34. package/skills/malloy-charts/SKILL.md +1 -1
  35. package/skills/malloy-dashboards/SKILL.md +29 -31
  36. package/skills/malloy-debug/SKILL.md +1 -1
  37. package/skills/malloy-define/SKILL.md +1 -1
  38. package/skills/malloy-discover/SKILL.md +1 -1
  39. package/skills/malloy-document/SKILL.md +1 -1
  40. package/skills/malloy-getting-started/SKILL.md +1 -1
  41. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  42. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  43. package/skills/malloy-gotchas-rendering/SKILL.md +1 -1
  44. package/skills/malloy-html-data-app-embedding/SKILL.md +3 -1
  45. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  46. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  47. package/skills/malloy-lookml-review/SKILL.md +1 -1
  48. package/skills/malloy-materialization/SKILL.md +9 -7
  49. package/skills/malloy-materialization-tuning/SKILL.md +3 -2
  50. package/skills/malloy-model/SKILL.md +3 -4
  51. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  52. package/skills/malloy-modeling/SKILL.md +2 -2
  53. package/skills/malloy-notebook-chat/SKILL.md +1 -1
  54. package/skills/malloy-notebooks/SKILL.md +3 -1
  55. package/skills/malloy-patterns/SKILL.md +1 -1
  56. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  57. package/skills/malloy-publish/SKILL.md +13 -12
  58. package/skills/malloy-queries/SKILL.md +1 -1
  59. package/skills/malloy-review/SKILL.md +1 -1
  60. package/skills/malloy-scope/SKILL.md +1 -1
  61. package/skills/malloy-source-unreachable/SKILL.md +14 -3
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.23",
4
+ "version": "0.1.27",
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.23
4
+ version: 0.1.27
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -146,12 +146,17 @@ recall is scoring search against an expectation that was never satisfiable.
146
146
  Because it runs from the model text alone it is cheap enough to point at every
147
147
  published version and read as a trend, which is what it is for. Its verdicts are
148
148
  `eval-diagnose`'s codes verbatim, validated against that table at startup, so
149
- `COVERAGE`, `AMBIGUOUS`, `NO-DISAMBIG` and `CONVENTION` mean there exactly what
150
- they mean here. It writes nothing: not a golden, not the case-level `coverage`
149
+ `MISSING`, `AMBIGUOUS`, `RULE_UNWRITTEN` and `UNDERSPECIFIED` mean there exactly
150
+ what they mean here. A pass is `MODELLED`. It writes nothing: not a golden, not the case-level `coverage`
151
151
  field, not a run directory. Where its verdict and that field disagree is where a
152
152
  version regressed, and the field is the standing judgement about the question
153
153
  while this is a measurement against one build.
154
154
 
155
+ Its `--out` report records `compiledSurface`: `read`, or why the compiled field
156
+ list was not read. Without that list the judge calls a column a source exposes
157
+ implicitly absent, so check the field before comparing two runs. A `--model`
158
+ run never has it.
159
+
155
160
  How to invoke it. Either `--model <file-or-dir>` for a local package or
156
161
  `--publisher <url> --package <pkg>` for a served one, plus `--set <dir>`, and
157
162
  `--version <label>` to stamp the report so a trend has an x-axis:
@@ -199,7 +204,7 @@ purpose. Separately, the whole model goes into one prompt per case, so past a
199
204
  few thousand lines it stops running on Linux and above about 100 KB the verdict
200
205
  stops being stable. Do not take the over-size message's advice to narrow
201
206
  `--model` to one file, which drops every imported source and manufactures
202
- `COVERAGE` verdicts.
207
+ `MISSING` verdicts.
203
208
 
204
209
  ## Validate the definitions, not every answer
205
210
 
@@ -15,6 +15,10 @@ was. Separately, the whole model goes into one prompt per case, which caps the
15
15
  package size it can run on at all and destabilises the verdict well before that
16
16
  cap.
17
17
 
18
+ The measurements below predate the renamed verdicts, and their tables keep the
19
+ names they were taken under: `ok` is now `MODELLED`, `COVERAGE` is `MISSING`,
20
+ `NO-DISAMBIG` is merged into `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`.
21
+
18
22
  ## Measured against a labelled set: the label drifts too
19
23
 
20
24
  The `evals/ecommerce` set in `credibledata/malloy-samples` labels every case
@@ -158,7 +162,7 @@ concatenates every `*.malloy` under the tree and does **not** resolve `import`;
158
162
  real packages split across files exactly that way (in
159
163
  `credibledata/malloy-samples`, `ecommerce/brand_synergy.malloy` opens with
160
164
  `import "ecommerce.malloy"`). Narrowing to one file drops every imported source,
161
- so the judge correctly answers `COVERAGE`, no entity represents the concept
165
+ so the judge correctly answers `MISSING`, no entity represents the concept
162
166
  anywhere, for cases the model answers perfectly well. That is a false gap
163
167
  indistinguishable from a real one, and it lands in the numerator of a published
164
168
  trend.
@@ -206,10 +210,9 @@ format address the cost, the ceiling and `json_objects()` together. Stdlib
206
210
  `urllib` keeps the stdlib-only promise; the real cost is auth, since `claude -p`
207
211
  rides the CLI's credentials.
208
212
 
209
- **And for a large package, stop sending the whole model.** Rule out `COVERAGE`
213
+ **And for a large package, stop sending the whole model.** Rule out `MISSING`
210
214
  against a complete inventory of names and one-line docs, a small fraction of the
211
215
  bytes, which keeps "no candidate anywhere" decidable; then send full text for
212
- the candidate sources only, to settle `AMBIGUOUS`, `NO-DISAMBIG` and
213
- `CONVENTION`. Deliberately NOT retrieval ranking: folding retrieval into this
216
+ the candidate sources only, to settle `AMBIGUOUS` and `RULE_UNWRITTEN`. Deliberately NOT retrieval ranking: folding retrieval into this
214
217
  number would collapse the distinction from recall that the metric exists to
215
218
  draw.
@@ -136,7 +136,7 @@ record, and `verify_definitions.py` re-runs and compares it on every rebuild.
136
136
  "query": "run: order_items -> { aggregate: control is sale_price.sum() { where: status != 'Cancelled' } - inventory_items.cost.sum() { where: status != 'Cancelled' } }",
137
137
  "note": "excludes Cancelled, per status's own doc and units_sold"
138
138
  },
139
- "cause": "CONVENTION"
139
+ "cause": "RULE_UNWRITTEN"
140
140
  }
141
141
  ```
142
142
 
@@ -160,7 +160,7 @@ Authoring a control is judgement, and the same rules as authoring a golden apply
160
160
  where two populations are both defensible from the docs, do not pick one --
161
161
  record the ambiguity in `note` and leave the record `unchecked`. `cause` is the
162
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.
163
+ `RULE_UNWRITTEN`); it is carried onto the finding, not inferred.
164
164
 
165
165
  This is **validate what someone asserted**, not **find business-wrong definitions
166
166
  unaided**. It turns "I believe `total_gross_margin` should exclude cancelled
@@ -124,6 +124,7 @@ on that axis.
124
124
  | `diagnoserModel` / `improverModel` | Written by `diagnose.py` / `improve.py` when those stages run, so the run names every LLM that touched it. Absent on a run that was only answered and judged. |
125
125
  | `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |
126
126
  | `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |
127
+ | `modelDir` | The package directory `--model-dir` named, when it was: the path the `-dirty` marker on `modelGitSha` was decided over. Absent means the whole repo, where any unrelated untracked file marks the model dirty. |
127
128
  | `environment` / `package` / `modelPath` | What was served, and from where. On a platform target `environment` is the organization and `package` is the workspace the MCP URL is scoped to. |
128
129
  | `scope` | Platform target only: the `environment/package[@version]` the answerer was told to pass as an explicit `scopes` entry on every `get_context` / `execute_query` call. A workspace can serve many packages (a personal workspace serves every package the user can read), so without this the run also measures whether retrieval picks the right package -- a different measurement. Absent means unscoped. |
129
130
  | `targetVersion` *(pin)* | Platform target only: the published version the answers came from. Both hosted tools default an omitted version to the PINNED one, meaning whatever the workspace serves at the moment of the call, so this only describes the run when `--scope` carries it to every call. `--target-version` supplies it when the scope omits it; a scope naming a different version is refused rather than treated as an override, because a run cannot say which build answered if two pins disagree. |
@@ -320,7 +321,7 @@ Run-level metrics fall out by counting:
320
321
  |---|---|---|
321
322
  | `issue_id` | string | Stable across status events. |
322
323
  | `qids` | list | Affected cases. |
323
- | `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. |
324
+ | `primary_code` / `contributing_codes` | string / list | From `skill:eval-diagnose`, verbatim. An event written before the codes were renamed may carry a retired one: `COVERAGE` is now `MISSING`, `NO-DISAMBIG` is `AMBIGUOUS`, and `CONVENTION` is `RULE_UNWRITTEN`. Read those as the new name; nothing rewrites old events. |
324
325
  | `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |
325
326
  | `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |
326
327
  | `severity` / `confidence` | string | |
@@ -28,13 +28,13 @@ The verdicts are `eval-diagnose`'s cause codes, verbatim and validated against
28
28
  its own table at startup, so a renamed code fails loudly here instead of
29
29
  quietly meaning nothing:
30
30
 
31
- ok a correct answer is expressible
32
- COVERAGE no representing entity anywhere
33
- AMBIGUOUS several near-identical candidates
34
- NO-DISAMBIG two plausible candidates, never resolved
35
- CONVENTION right data, wrong statistical or business convention: the
36
- underlying numbers are present, no named measure expresses the
37
- convention the question needs
31
+ MODELLED one entity expresses the concept, unambiguously
32
+ MISSING no query over this model could produce it
33
+ AMBIGUOUS several candidates and no doc says which
34
+ RULE_UNWRITTEN the data is there, the rule for combining it is not.
35
+ (guessable) when the data forces the rule, (arbitrary) when
36
+ it is a business decision nobody could derive
37
+ UNDERSPECIFIED the QUESTION is unclear, not the model
38
38
 
39
39
  Exit 0 when the measurement ran, whatever it found. A low score is the result,
40
40
  not a failure. Exit 1 if it could not run.
@@ -59,6 +59,7 @@ sys.path.insert(0, str(HERE))
59
59
 
60
60
  from json_scan import json_objects # noqa: E402
61
61
  from verify_goldens import model_text as local_model_text # noqa: E402
62
+ from check_findable import compiled_entities # noqa: E402
62
63
 
63
64
  # The `claude -p` invocation lives once, in the harness, rather than being
64
65
  # rewritten here. It costs this script a reach into a sibling skill, which the
@@ -85,9 +86,9 @@ JUDGE_BLOCKED = BLOCKED_TOOLS + ("Read", "Glob", "Grep", "Skill")
85
86
 
86
87
  # Parsed from the table rather than typed here, the same way diagnose.py does
87
88
  # it, so this cannot drift from the vocabulary it claims to reuse.
88
- CODE_IN_TABLE = re.compile(r"^\|\s*`([A-Z][A-Z-]+)`\s*\|", re.M)
89
- FAIL_VERDICTS = ("COVERAGE", "AMBIGUOUS", "NO-DISAMBIG", "CONVENTION")
90
- OK = "ok"
89
+ CODE_IN_TABLE = re.compile(r"^\|\s*`([A-Z][A-Z_-]+)`\s*\|", re.M)
90
+ FAIL_VERDICTS = ("MISSING", "AMBIGUOUS", "RULE_UNWRITTEN", "UNDERSPECIFIED")
91
+ OK = "MODELLED"
91
92
 
92
93
  # A prompt cap, not a limit anyone should hit. The model text goes in argv, and
93
94
  # an over-long argv fails as an opaque OSError from the exec rather than as
@@ -148,6 +149,54 @@ def rest_model_text(base: str, environment: str, package: str,
148
149
  return "\n\n".join(out)
149
150
 
150
151
 
152
+ def compiled_surface(base: str, environment: str, package: str) -> str:
153
+ """The fields the COMPILED model exposes, which the source text does not name.
154
+
155
+ A Malloy source picks up every column of its table without declaring one of
156
+ them, so `products.retail_price` and `customers.signup_date` appear nowhere
157
+ in the `.malloy` and are fully queryable. A judge shown only source text
158
+ reads those as absent and returns MISSING on a question the model answers
159
+ -- measured, two of this skill's three false gaps on one set.
160
+
161
+ Empty string when the compiled model cannot be read, which is honest: the
162
+ prompt then says the list is unavailable rather than implying the text is
163
+ the whole surface.
164
+ """
165
+ declared = compiled_entities(base, environment, package)
166
+ if not declared:
167
+ return ""
168
+ lines = []
169
+ for src in sorted(declared):
170
+ fields = sorted(f for f in declared[src] if not f.startswith("source:"))
171
+ if fields:
172
+ lines.append(f"{src}: " + ", ".join(fields))
173
+ return "\n".join(lines)
174
+
175
+
176
+ def set_conventions(set_dir: pathlib.Path) -> list[str]:
177
+ """`set.json`'s `conventions`: what the questions' words mean to the business.
178
+
179
+ A list of strings. A bare string is taken as a list of one, and anything
180
+ else, or an unreadable file, is no conventions rather than a crash.
181
+ """
182
+ f = set_dir / "set.json"
183
+ try:
184
+ conv = (json.loads(f.read_text()) or {}).get("conventions")
185
+ except (json.JSONDecodeError, OSError, AttributeError):
186
+ return []
187
+ if isinstance(conv, str):
188
+ conv = [conv]
189
+ return [c for c in conv if isinstance(c, str) and c.strip()] \
190
+ if isinstance(conv, list) else []
191
+
192
+
193
+ # A convention is a definition of one TERM, scoped to the questions that use
194
+ # it. Unscoped, the judge applied every definition to every question: nine of
195
+ # twelve cases came back as gaps. The sentence is prose, so the check is only
196
+ # that one exists.
197
+ CONVENTION_SCOPE = re.compile(r"(?i)\bapplies to\b")
198
+
199
+
151
200
  # --- the judgement -----------------------------------------------------------
152
201
 
153
202
  PROMPT = """You are measuring whether a semantic model can EXPRESS an answer to
@@ -157,21 +206,59 @@ there are no rows here and no way to run anything. Judge the model text only.
157
206
  THE MODEL (every source, dimension, measure and doc comment it defines):
158
207
  {model}
159
208
 
209
+ FIELDS THE COMPILED MODEL EXPOSES: {surface}
210
+
211
+ A Malloy source exposes every column of its table whether or not the text
212
+ declares it, so this list is the authority on what EXISTS and the text above is
213
+ the authority on what is DOCUMENTED. A field here but not in the text is real
214
+ and queryable -- it is undocumented, not absent, and undocumented is not
215
+ `MISSING`. When this list is unavailable, say so in your reasoning and do not
216
+ treat the text as the whole surface.
217
+
160
218
  THE QUESTION: {question}
161
219
 
220
+ WHAT THE BUSINESS MEANS BY IT: {conventions}
221
+
222
+ These are definitions of TERMS, not defaults for every question. Apply one only
223
+ when THIS question uses the term it defines, or asks for a quantity that cannot
224
+ be computed without it. A definition of "net" governs a question that says net;
225
+ it does not make every revenue question a net question. A definition of
226
+ "customer" governs a question that counts customers; it does not govern a
227
+ question about categories that happens to involve people. Check, for each
228
+ convention, whether this question actually invokes it, and ignore the ones it
229
+ does not -- applying them all to everything turns every question into a gap and
230
+ the measurement stops discriminating.
231
+
232
+ Where the question DOES invoke one, it is not a reading you may substitute a
233
+ more reasonable one for. If the convention names a window, a basis or a filter
234
+ that no entity in the model expresses, the model cannot answer the question
235
+ however well its parts are documented: `RULE_UNWRITTEN` when the underlying data
236
+ is present, `MISSING` when it is not. Judging by your own reading of a word the
237
+ business has already defined is how this check passes a question the model
238
+ demonstrably cannot answer.
239
+
162
240
  CONCEPTS THE QUESTION NEEDS: {concepts}
163
241
 
164
242
  Decide ONE verdict:
165
243
 
166
- - `ok` -- a correct answer is expressible. Name the entities that express it.
167
- - `COVERAGE` -- no entity represents a needed concept anywhere in the model.
168
- - `AMBIGUOUS` -- several near-identical candidates, so which one is meant is a
169
- coin toss.
170
- - `NO-DISAMBIG` -- two plausible candidates and nothing in the docs resolves
171
- which the question means. The docs should answer that, not the reader.
172
- - `CONVENTION` -- the underlying data is present, but no named measure
173
- expresses the statistical or business convention the question needs, so
174
- anyone answering has to pick one and the model does not say which.
244
+ - `MODELLED` -- one entity expresses it, unambiguously. Name that entity.
245
+ - `MISSING` -- no query over this model could produce the concept. Not merely
246
+ that no measure is named for it: if the parts are present and only the
247
+ formula is absent, that is `RULE_UNWRITTEN`.
248
+ - `AMBIGUOUS` -- several candidates and nothing says which this question means.
249
+ Judge the entities AND their docs: a `#(doc)` that resolves the choice makes
250
+ it `MODELLED`.
251
+ - `RULE_UNWRITTEN` -- the data is present and the model does not encode the
252
+ rule for combining or filtering it, so whoever answers invents one. Say which
253
+ kind in `rule_kind`: `guessable` when the data forces the rule (list price
254
+ minus sale price is a discount, and any careful reader gets it), `arbitrary`
255
+ when it is a business decision nobody could derive (a season window, whether
256
+ revenue is net of tax). If a stated convention above names the rule and the
257
+ model does not encode it, that is `arbitrary` -- you know the rule only
258
+ because you were told.
259
+ - `UNDERSPECIFIED` -- the QUESTION does not say what it means, so no model
260
+ could answer it as written. "Adjusted sales" with no statement of the
261
+ adjustment. Do not score this against the model.
175
262
 
176
263
  Work in this order. The enumeration is the job; skipping it is how this
177
264
  judgement goes wrong.
@@ -197,16 +284,14 @@ obvious the answer feels.
197
284
 
198
285
  Then the verdict follows mechanically:
199
286
 
200
- - Every quantity has exactly one candidate, or the model states which: `ok`.
201
- - Some quantity has no candidate at all: `COVERAGE`.
202
- - A quantity has several near-identical candidates: `AMBIGUOUS`.
203
- - A quantity has two plausible candidates and nothing resolves which:
204
- `NO-DISAMBIG`.
287
+ - Every quantity has exactly one candidate, or the model states which: `MODELLED`.
288
+ - Some quantity cannot be produced by any query over this model: `MISSING`.
289
+ - A quantity has several candidates and nothing resolves which: `AMBIGUOUS`.
205
290
  - The parts exist but the model names no measure for the combination the
206
291
  question asks for, so whoever answers must assemble it and choose a
207
- convention: `CONVENTION`.
292
+ convention: `RULE_UNWRITTEN`.
208
293
 
209
- Three rules about that, because each is a way to reach `ok` wrongly:
294
+ Three rules about that, because each is a way to reach `MODELLED` wrongly:
210
295
 
211
296
  1. **A question's words resembling a field's label is not the model resolving
212
297
  anything.** A question saying "total universe" and a measure labelled
@@ -214,8 +299,8 @@ Three rules about that, because each is a way to reach `ok` wrongly:
214
299
  also defensible and would give a materially different number, the model has
215
300
  not resolved it and you must not resolve it yourself.
216
301
  2. **Finding the numerator is not coverage.** If the numerator is named and the
217
- denominator must be assembled or chosen, that is `CONVENTION` or
218
- `NO-DISAMBIG`, never `ok`. Matching field names against the question is the
302
+ denominator must be assembled or chosen, that is `RULE_UNWRITTEN` or
303
+ `AMBIGUOUS`, never `MODELLED`. Matching field names against the question is the
219
304
  failure mode this measurement exists to remove.
220
305
  3. **A caveat is not a resolution.** A doc warning that a measure is easy to
221
306
  misuse still leaves the question open unless it says which reading this
@@ -227,12 +312,13 @@ Return ONLY a JSON object, no prose around it, keys in this order:
227
312
  "ruled_out": {{"<quantity>": "<the doc sentence eliminating the others, or null>"}},
228
313
  "resolved_by": "what in the model says which candidate, or null",
229
314
  "why": "one or two sentences naming the specific entity or the specific gap",
230
- "verdict": "ok|COVERAGE|AMBIGUOUS|NO-DISAMBIG|CONVENTION",
315
+ "verdict": "MODELLED|MISSING|AMBIGUOUS|RULE_UNWRITTEN|UNDERSPECIFIED",
316
+ "rule_kind": "guessable|arbitrary, only when verdict is RULE_UNWRITTEN, else null",
231
317
  "entities": ["entity names that express it, or [] when it is not expressible"]}}
232
318
 
233
319
  The enumeration comes first because the verdict follows from it. Before you emit
234
320
  `verdict`, re-read `quantities` and `ruled_out`: if any quantity holds more than
235
- one candidate and its `ruled_out` entry is null, the verdict is not `ok`.
321
+ one candidate and its `ruled_out` entry is null, the verdict is not `MODELLED`.
236
322
  """
237
323
 
238
324
 
@@ -271,7 +357,7 @@ def parse_reply(text: str, allowed: tuple[str, ...]) -> dict[str, Any]:
271
357
  unresolved = [q for q, c in quantities.items()
272
358
  if isinstance(c, list) and len(c) > 1]
273
359
  if unresolved:
274
- return {"verdict": "NO-DISAMBIG",
360
+ return {"verdict": "AMBIGUOUS",
275
361
  "why": f"[several candidates for {', '.join(unresolved)} "
276
362
  f"and nothing in the model resolves them] {why}",
277
363
  "entities": ents, "quantities": quantities,
@@ -280,10 +366,18 @@ def parse_reply(text: str, allowed: tuple[str, ...]) -> dict[str, Any]:
280
366
  # whole value of this metric is that a pass can be checked against the model.
281
367
  if got == OK and not ents:
282
368
  return {"verdict": None,
283
- "why": "ok without naming an entity that expresses the answer",
369
+ "why": "MODELLED without naming an entity that expresses it",
284
370
  "entities": [], "quantities": quantities,
285
371
  "resolved_by": resolved_by}
286
- return {"verdict": got, "why": why, "entities": ents,
372
+ # `arbitrary` is the consequential half and the one a judge under-reports,
373
+ # so an unqualified RULE_UNWRITTEN is recorded as unknown rather than
374
+ # silently counted as the harmless kind.
375
+ kind = v.get("rule_kind")
376
+ if got != "RULE_UNWRITTEN":
377
+ kind = None
378
+ elif kind not in ("guessable", "arbitrary"):
379
+ kind = "unstated"
380
+ return {"verdict": got, "why": why, "entities": ents, "rule_kind": kind,
287
381
  "quantities": quantities, "resolved_by": resolved_by}
288
382
 
289
383
 
@@ -294,9 +388,9 @@ def majority(rows: list[dict[str, Any]]) -> dict[str, Any]:
294
388
  is a property of the case rather than noise to average away. One sample
295
389
  cannot tell a stable judgement from a marginal one, so a repeated run
296
390
  records both the verdict and what the samples actually were. (The fixture
297
- below is not such a case: measured 2026-09-08 it read CONVENTION three times
391
+ below is not such a case: measured 2026-09-08 it read RULE_UNWRITTEN three times
298
392
  out of three. An EARLIER fixture, replaced when a customer model excerpt was
299
- scrubbed from this file, read CONVENTION twice and `ok` once, which is where
393
+ scrubbed from this file, read RULE_UNWRITTEN twice and `MODELLED` once, which is where
300
394
  the tie rule came from.)
301
395
  """
302
396
  counts: dict[str, int] = {}
@@ -329,13 +423,16 @@ def majority(rows: list[dict[str, Any]]) -> dict[str, Any]:
329
423
  def judge_case(case: dict[str, Any], model: str, a: argparse.Namespace,
330
424
  allowed: tuple[str, ...]) -> dict[str, Any]:
331
425
  concepts = case.get("requiresConcepts") or []
332
- prompt = PROMPT.format(model=model, question=case.get("question", ""),
426
+ prompt = PROMPT.format(model=model,
427
+ surface=getattr(a, "surface", "") or "(unavailable)",
428
+ conventions=getattr(a, "conventions", "") or "(none stated)",
429
+ question=case.get("question", ""),
333
430
  concepts=", ".join(concepts) or "(none named)")
334
431
  if len(prompt) > MAX_PROMPT:
335
432
  # The way out depends on the mode. `--model-path` narrows a
336
433
  # `--publisher` read to one model. A local `--model` read has no such
337
434
  # move: `model_text()` does not resolve `import`, so pointing it at one
338
- # file drops every imported source and the judge answers COVERAGE for
435
+ # file drops every imported source and the judge answers MISSING for
339
436
  # concepts the model does represent. That is a false gap that lands in
340
437
  # a published trend (reference/coverage-limits.md), and this message
341
438
  # used to recommend exactly that. The honest outcome is a failed
@@ -343,7 +440,7 @@ def judge_case(case: dict[str, Any], model: str, a: argparse.Namespace,
343
440
  narrow = ("pass --model-path to measure one model file" if a.publisher
344
441
  else "this is a failed measurement, not a number to rescue: "
345
442
  "do NOT narrow --model to one file, that drops imported "
346
- "sources and manufactures COVERAGE verdicts "
443
+ "sources and manufactures MISSING verdicts "
347
444
  "(reference/coverage-limits.md). Record coverage as "
348
445
  "not measured for this package version")
349
446
  return {"qid": case["qid"], "verdict": None,
@@ -551,10 +648,10 @@ FIXTURE_CASE = {
551
648
  "question": "What was our first contact resolution rate last quarter?",
552
649
  "requiresConcepts": ["first contact resolution", "ticket population"],
553
650
  }
554
- FIXTURE_EXPECTED = ("CONVENTION", "NO-DISAMBIG")
651
+ FIXTURE_EXPECTED = ("RULE_UNWRITTEN", "AMBIGUOUS")
555
652
  # Measured against THIS fixture on 2026-09-08, agent model sonnet, --repeat 3:
556
- # fixture_first_contact_resolution_rate: CONVENTION
557
- # (samples: CONVENTION, CONVENTION, CONVENTION)
653
+ # fixture_first_contact_resolution_rate: RULE_UNWRITTEN
654
+ # (samples: CONVENTION, CONVENTION, CONVENTION -- the retired name)
558
655
  # Re-measure when the fixture, the prompt or the verdict vocabulary changes.
559
656
  # The tests below pin the fixture's SHAPE and cannot pin its verdict: the judge
560
657
  # needs a live `claude -p`, so `--self-check` is the only thing that measures
@@ -669,6 +766,41 @@ def main(argv: list[str] | None = None) -> int:
669
766
  else:
670
767
  raise SystemExit("pass --model <path>, or --publisher with --package")
671
768
 
769
+ # The compiled surface names the columns a source exposes without declaring
770
+ # them. Only REST can supply it; a --model run reads text off disk and has
771
+ # no compiled model to ask, and the prompt is told so rather than being let
772
+ # to assume the text is everything.
773
+ #
774
+ # Whether it was read goes into the report, not only onto stderr. A
775
+ # --publisher run whose REST read failed is measuring with less than the
776
+ # judge is told it has, and without the record it reads as a normal run.
777
+ a.surface = ""
778
+ surface_status = "not read: --model has no compiled model to ask"
779
+ if a.publisher and a.package:
780
+ try:
781
+ a.surface = compiled_surface(a.publisher, a.environment, a.package)
782
+ surface_status = ("read" if a.surface else
783
+ "empty: the server returned no compiled fields")
784
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
785
+ surface_status = f"failed: {exc}"[:200]
786
+ if not a.surface:
787
+ print(f"! no compiled field list ({surface_status}): a column a source "
788
+ f"exposes implicitly will read as absent", file=sys.stderr)
789
+
790
+ # What the question's words MEAN, from the set. A convention the model does
791
+ # not encode is the thing this check exists to find, and a judge that does
792
+ # not know the business defines "summer" as a date window will substitute
793
+ # its own reading and pass the question.
794
+ conventions = set_conventions(a.set_dir)
795
+ a.conventions = "\n".join(f"- {c}" for c in conventions)
796
+ for i, c in enumerate(conventions, 1):
797
+ if not CONVENTION_SCOPE.search(c):
798
+ print(f"! set.json conventions[{i - 1}] states no scope. Without "
799
+ f"an \"Applies to questions that ...\" sentence the judge "
800
+ f"reads it as a rule for every question. Fix: end it with "
801
+ f"\"Applies to questions that ask about <term>.\"",
802
+ file=sys.stderr)
803
+
672
804
  # What was measured, pinned by content. Coverage is sold as a per-version
673
805
  # trend, and a trend needs each point tied to the bytes behind it: a
674
806
  # `--model <dir>` run stamped `version: null` and named no path, so two
@@ -706,7 +838,8 @@ def main(argv: list[str] | None = None) -> int:
706
838
  a.out.write_text(json.dumps(
707
839
  {"version": a.version, "set": str(a.set_dir),
708
840
  "modelSource": model_source, "modelSha256": model_sha,
709
- "agentModel": a.agent_model, **s, "cases_detail": rows,
841
+ "agentModel": a.agent_model, "compiledSurface": surface_status,
842
+ "conventions": conventions, **s, "cases_detail": rows,
710
843
  **({"labelComparison": serialisable(cmp)} if cmp else {})},
711
844
  indent=2))
712
845
  print(f"\n{a.out}")