@malloy-publisher/skills 0.1.38 → 0.1.40

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 (44) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-answer/reference/ledger-schema.md +4 -3
  4. package/skills/eval-answer/scripts/ledger.py +5 -1
  5. package/skills/eval-answer/scripts/publisher_rest.py +39 -0
  6. package/skills/eval-diagnose/SKILL.md +43 -18
  7. package/skills/eval-diagnose/scripts/cluster_failures.py +2 -0
  8. package/skills/eval-diagnose/scripts/diagnose.py +11 -4
  9. package/skills/eval-import/SKILL.md +1 -1
  10. package/skills/eval-improve/SKILL.md +46 -6
  11. package/skills/eval-improve/scripts/improve.py +45 -16
  12. package/skills/eval-improve/scripts/improve_test.py +35 -0
  13. package/skills/eval-judge/SKILL.md +1 -1
  14. package/skills/eval-loop/SKILL.md +3 -2
  15. package/skills/eval-loop/reference/acceptance-check.md +12 -0
  16. package/skills/eval-loop/scripts/agent_harness.py +38 -1
  17. package/skills/eval-loop/scripts/agent_harness_test.py +12 -1
  18. package/skills/eval-loop/scripts/package_skills_test.py +263 -0
  19. package/skills/eval-loop/scripts/run_baseline.py +185 -16
  20. package/skills/eval-report/SKILL.md +1 -1
  21. package/skills/malloy-analysis/SKILL.md +1 -1
  22. package/skills/malloy-analysis-report/SKILL.md +1 -1
  23. package/skills/malloy-charts/SKILL.md +1 -1
  24. package/skills/malloy-dashboards/SKILL.md +1 -1
  25. package/skills/malloy-define/SKILL.md +1 -1
  26. package/skills/malloy-discover/SKILL.md +1 -1
  27. package/skills/malloy-document/SKILL.md +1 -1
  28. package/skills/malloy-getting-started/SKILL.md +2 -2
  29. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  30. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  31. package/skills/malloy-lookml-review/SKILL.md +1 -1
  32. package/skills/malloy-materialization/SKILL.md +1 -1
  33. package/skills/malloy-model/SKILL.md +2 -2
  34. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  35. package/skills/malloy-modeling/SKILL.md +1 -1
  36. package/skills/malloy-notebooks/SKILL.md +1 -1
  37. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  38. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  39. package/skills/malloy-publish/SKILL.md +1 -1
  40. package/skills/malloy-queries/SKILL.md +2 -2
  41. package/skills/malloy-reports/SKILL.md +1 -1
  42. package/skills/malloy-review/SKILL.md +1 -1
  43. package/skills/malloy-run-agent/SKILL.md +58 -0
  44. package/skills/malloy-source-unreachable/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.38",
4
+ "version": "0.1.40",
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.38
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -153,7 +153,8 @@ on that axis.
153
153
  | `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. |
154
154
  | `staleEntityNames` | The `expectedEntities` ids the compiled served model does not declare, from the in-arm lint (bare names from a search of the model text when the compiled model could not be read). 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 `[]`. |
155
155
  | `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. |
156
- | `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. |
156
+ | `packageSha` / `servedRevision` *(pins)* | Taken from the server, not recomputed: `sourceContentSha` is a content hash over EVERY model path in the package AND its `skills/` files AND its declared agents (their files and the `publisher.json` declaration), so an edit to an imported file or to a skill 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. |
157
+ | `packageSkillsMode` / `packageSkillsSha` *(pins)* | How the package's own skills reached the answerer (`install` copied them into its workspace, `tool` left it to fetch them with `get_skill`, `off` withheld them, which is also what a package that ships none is recorded as), and a content hash of that `skills/` tree. A run that spawns no answerer (`--rebuild`, `--rejudge`, `--from`) carries the prior run's values, and a platform run records null. Two runs can differ only in guidance; without these the ledger cannot tell them apart, and a flip table between them would look like noise. |
157
158
  | `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. |
158
159
  | `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. |
159
160
  | `mode` / `setName` / `serverVersion` / `traceMode` / `callBudget` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. (`targetVersion` and `status` were in this list and are both written now.) |
@@ -357,7 +358,7 @@ rejected direction keeps its record.
357
358
  | `issue_ids` | |
358
359
  | `files` | Paths the edit touched. |
359
360
  | `diffSummary` | One line per file. |
360
- | `probes` | Query and result for each factual claim. |
361
+ | `probes` | Query and result for each factual claim. May be empty for a `class: skill` candidate that asserts nothing about the data: a guide making no factual claim has nothing to probe. Empty is not a missing receipt there; a guide that DOES quote a number still needs one. |
361
362
  | `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |
362
363
  | `goldenSuspect` | Each `{qid, entity, stored, rederived}`: a golden this edit may have invalidated. Reported by the improver, never repaired by it. **Non-empty halts the acceptance check** until adjudicated through the golden side door. |
363
364
  | `goldenAudit` | The set's `verify_goldens.py` run against the edited model: `{ran, clean, model, tail}`. Catches drift and rubric-vs-model contradictions only; a golden whose value silently moved is invisible to it, which is why `goldenSuspect` exists alongside. |
@@ -373,7 +374,7 @@ Written by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint
373
374
  |---|---|
374
375
  | `issue_ids` | |
375
376
  | `decision` | `accepted` / `rejected`. |
376
- | `class` | `docs` / `definition` / `retrieval` / `justified`. |
377
+ | `class` | `docs` / `definition` / `skill` / `retrieval` / `justified`. `skill` is an edit to a package skill under the package's `skills/`; it never takes the documentation shortcut in the acceptance check. |
377
378
  | `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |
378
379
  | `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |
379
380
  | `holdoutDelta` | Confident-verdict delta on the holdout slice. |
@@ -217,7 +217,11 @@ RUN_OPTIONAL = {"label", "effort", "environment", "package", "modelPath",
217
217
  "staleEntityNames",
218
218
  "retrievalMode", "retrievalCalls", "retrievalGate",
219
219
  "reExecution", "coverageReport",
220
- "modelRepo"} | RUN_RECOMMENDED
220
+ "modelRepo",
221
+ # How the package's own skills reached the answerer, and a hash
222
+ # of the tree they came from. Two runs can differ ONLY in
223
+ # guidance, and without these the ledger cannot tell them apart.
224
+ "packageSkillsMode", "packageSkillsSha"} | RUN_RECOMMENDED
221
225
 
222
226
 
223
227
  def dataset_sha(set_dir: pathlib.Path) -> str | None:
@@ -148,6 +148,45 @@ def get_json(base: str, path: str, timeout: int = 30) -> Any:
148
148
  return json.loads(resp.read())
149
149
 
150
150
 
151
+ def served_package_dir(base: str, environment: str,
152
+ package: str) -> pathlib.Path | None:
153
+ """The directory the running Publisher serves this package FROM, or None.
154
+
155
+ A host commonly serves a copy rather than your working tree, so this is the
156
+ only path whose bytes are the ones answering queries.
157
+ """
158
+ try:
159
+ for proj in get_json(base, "api/v0/projects"):
160
+ if proj.get("name") == environment and proj.get("location"):
161
+ p = pathlib.Path(proj["location"]) / package
162
+ return p if p.is_dir() else None
163
+ except (OSError, ValueError): # unreachable or non-JSON: absence is reported
164
+ return None
165
+ return None
166
+
167
+
168
+ def package_skill_names(base: str, environment: str, package: str) -> list[str]:
169
+ """Skills the server says this package ships, or [] if it ships none.
170
+
171
+ Read from the server rather than from the working tree, because those are
172
+ two different questions: a host serves a COPY of the package. What the
173
+ answerer can actually reach is what this returns.
174
+
175
+ An unreachable or older server returns [] rather than raising: the caller
176
+ decides whether "the server serves none" contradicts what it asked for.
177
+ """
178
+ try:
179
+ rows = get_json(
180
+ base,
181
+ f"api/v0/environments/{urllib.parse.quote(environment)}"
182
+ f"/packages/{urllib.parse.quote(package)}/skills")
183
+ except (OSError, ValueError): # unreachable or non-JSON
184
+ return []
185
+ return sorted(r["name"] for r in rows
186
+ if isinstance(r, dict) and r.get("origin") == "package"
187
+ and isinstance(r.get("name"), str))
188
+
189
+
151
190
  def served_model_path(base: str, environment: str, package: str,
152
191
  model_path: str) -> pathlib.Path | None:
153
192
  """Where the running Publisher reads this model from, or None.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.38
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -68,9 +68,19 @@ never "C1" / "C2" / "C3":
68
68
  | `construction` | Did sufficient context arrive, and the agent still built the wrong query? |
69
69
  | `model-definition` | Is a measure, join, filter convention, or source semantically wrong? |
70
70
 
71
- `owner` is separate: `model`, `retrieval`, `agent-skill`, or `dataset`. There
72
- is no environment owner: an environment failure stops the run before
73
- diagnosis (see the boundary above), so no issue can carry it.
71
+ `owner` is separate: `model`, `package-skill`, `retrieval`, `agent-skill`, or
72
+ `dataset`. There is no environment owner: an environment failure stops the run
73
+ before diagnosis (see the boundary above), so no issue can carry it.
74
+
75
+ `package-skill` and `agent-skill` are both "the guidance was wrong", split by
76
+ who can fix it here. A package can ship its own guides under `skills/`, and one
77
+ whose name matches a built-in REPLACES it for that package, so a fix scoped to
78
+ this package is a file in the model repo and lands in the same commit as a model
79
+ edit. `agent-skill` is the shared skill, outside this repo and outside this
80
+ loop. Choose `package-skill` only when the fix is genuinely specific to this
81
+ package: this customer's vocabulary, this model's conventions, a trap only these
82
+ sources have. A gap every package would hit is `agent-skill`, and widening a
83
+ shared skill by editing one package's copy hides it from every other package.
74
84
 
75
85
  ### Assigning owner: one question
76
86
 
@@ -202,14 +212,20 @@ Prior `score` events are not rewritten. They keep the old `golden_revision`.
202
212
  | Code | When | Owner |
203
213
  |---|---|---|
204
214
  | `NEVER-ASKED` | no utterance targeted a needed concept | agent-skill, and model if nothing would have prompted the ask |
205
- | `VAGUE` | compound or generic utterances, so nothing could rank | agent-skill |
206
- | `QUESTION-VOCAB` | utterances parroted the question where the data uses other words | agent-skill, and model if that vocabulary is undocumented |
207
- | `RULE_UNWRITTEN` | the data is present and the model does not encode the rule for combining or filtering it, so whoever answers invents one. Qualified `(guessable)` when the data forces the rule -- list price minus sale price -- and `(arbitrary)` when it is a business decision nobody could derive, such as a season window or whether revenue is net of tax. Only the stated conventions tell those apart: from the model alone the two look identical, and an unqualified reading defaults to the harmless one | model: write the rule down |
215
+ | `VAGUE` | compound or generic utterances, so nothing could rank | agent-skill: decomposition is a general skill. package-skill only if this package's concepts are what defeated it |
216
+ | `QUESTION-VOCAB` | utterances parroted the question where the data uses other words | package-skill: map this business's words to the model's. model if the vocabulary belongs on the entity itself |
217
+ | `RULE_UNWRITTEN` | the data is present and the model does not encode the rule for combining or filtering it, so whoever answers invents one. Qualified `(guessable)` when the data forces the rule -- list price minus sale price -- and `(arbitrary)` when it is a business decision nobody could derive, such as a season window or whether revenue is net of tax. Only the stated conventions tell those apart: from the model alone the two look identical, and an unqualified reading defaults to the harmless one | model: write the rule down, or expose a named measure. package-skill when no measure or doc can carry it, e.g. which population to default to |
208
218
  | `ASSUMED` | assumed a scope or convention instead of checking | model if nothing warned; agent-skill otherwise |
209
219
  | `WRONG-TYPE-OR-SCOPE` | asked, but with the wrong target type or an empty/wrong scope | agent-skill |
210
220
 
211
221
  If the agent could not reasonably have known to ask, that is a model gap.
212
222
 
223
+ Between `package-skill` and `model` on any of these rows: if the fact belongs to
224
+ one entity, put it on that entity's `#(doc)`, because a doc reaches every agent
225
+ whether or not it loaded a guide. Reach for `package-skill` when the fact spans
226
+ entities or is a rule rather than a property: which source to prefer, what the
227
+ house default population is, what this business calls things.
228
+
213
229
  ### get_context / model
214
230
 
215
231
  | Code | When | Owner |
@@ -218,8 +234,8 @@ If the agent could not reasonably have known to ask, that is a model gap.
218
234
  | `NOT-RETURNED` | it exists, the ask was on target, it never came back | model: labels, docs, synonyms, index |
219
235
  | `LOW-RANK` | returned, buried under noise the agent reasonably skipped | model |
220
236
  | `AMBIGUOUS` | several candidates and nothing says which this question means. Judged over the entities AND their docs: a `#(doc)` that resolves the choice makes it `MODELLED`, which is why a sentence of documentation is usually the whole fix | model: "use X for …, Y when …" |
221
- | `GUIDANCE-NOT-RETRIEVED` | entities came back, governing guidance did not | model: put guidance on the entities agents search for |
222
- | `GUIDANCE-DECLINED` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat |
237
+ | `GUIDANCE-NOT-RETRIEVED` | entities came back, governing guidance did not | model: put guidance on the entities agents search for. package-skill when it governs several sources and belongs on none |
238
+ | `GUIDANCE-DECLINED` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat. package-skill if the default is a house rule rather than a property of one field |
223
239
 
224
240
  **Before diagnosing a `NOT-RETURNED`, check the call actually returned
225
241
  nothing.** The `tool_call` event carries `retrieval_mode` and `rankedSummary`.
@@ -254,7 +270,7 @@ with both queries. Otherwise it is still `NOT-RETURNED` / `LOW-RANK`.
254
270
 
255
271
  | Code | When | Owner |
256
272
  |---|---|---|
257
- | `WRONG-PICK` | needed entity returned, used a different one | model if indistinguishable; agent-skill if docs distinguished them |
273
+ | `WRONG-PICK` | needed entity returned, used a different one | model if indistinguishable; package-skill if choosing between them is a rule of this package; agent-skill if the docs already distinguished them |
258
274
  | `SCOPE` | right entities, wrong population | model if the scope rule was undocumented |
259
275
  | `GRAIN` | right entities, wrong grain | model or agent-skill |
260
276
  | `FILTER-LITERAL` | filter literal did not match stored values | model (document the stored form) and agent-skill |
@@ -352,9 +368,10 @@ convention explains both. Same `owner` and same `component` is a hint, never a
352
368
  criterion: two `MISSING` issues about different missing entities are two
353
369
  clusters, and merging them produces an edit that fixes neither cleanly.
354
370
 
355
- Order clusters by how many cases they would fix. Cluster the non-model owners
356
- too, in their own clusters, so nothing is lost on the way to the backlog --
357
- but keep them separate, because only `owner: model` may proceed to an edit.
371
+ Order clusters by how many cases they would fix. Cluster every owner in its own
372
+ clusters, so nothing is lost on the way to the backlog, and keep the owners
373
+ separate: `model` and `package-skill` each license a different edit, and the
374
+ rest license none here.
358
375
 
359
376
  Say what you considered merging and chose not to. A cluster is a claim that one
360
377
  change fixes N cases, and the near-misses are what a reviewer needs to falsify
@@ -381,13 +398,20 @@ can design the edit is the whole job here; the edit itself is
381
398
  `skill:eval-improve`, working from this. A cluster carrying an honest open
382
399
  question is more useful than one carrying a remedy nobody probed.
383
400
 
384
- **Only `owner: model` proceeds to `eval-improve`.** Skill findings go back into
385
- the analysis or phrase-detection skill. Retrieval findings go to the tool.
401
+ **`owner: model` and `owner: package-skill` proceed to `eval-improve`.** Both
402
+ are fixable in the model repo, so both are checkpointed and rolled back by the
403
+ same git commit. They are still separate owners, because the edit they license
404
+ is different and the acceptance check treats them differently: a model edit
405
+ changes what the data means, a package-skill edit changes what the agent is told.
406
+
407
+ `owner: agent-skill` findings go back into the shared analysis or
408
+ phrase-detection skill, outside this loop. Retrieval findings go to the tool.
386
409
  `BAD-REFERENCE` and `AMBIGUOUS-REFERENCE` go to the golden side door in
387
410
  `skill:eval-loop` (repair or hold). Do not send them to improve. Other dataset
388
411
  findings (a bad question, a case worth excluding) go back to the case in
389
- `cases.jsonl` via the conductor. Routing a skill bug into the model is
390
- how models accumulate scar tissue.
412
+ `cases.jsonl` via the conductor. Routing a guidance bug into the model is how
413
+ models accumulate scar tissue: if the entity was there, correctly defined, and
414
+ the agent still misused it, the fix is guidance, not a new field.
391
415
 
392
416
  ## Anti-patterns
393
417
 
@@ -401,5 +425,6 @@ how models accumulate scar tissue.
401
425
  ## Related skills
402
426
 
403
427
  - `skill:eval-answer`: the score this consumes.
404
- - `skill:eval-improve`: smallest model edit, `owner: model` only.
428
+ - `skill:eval-improve`: the smallest edit, for `owner: model` and
429
+ `owner: package-skill`.
405
430
  - `skill:eval-loop`: golden hold/repair, the acceptance check, and checkpoint.
@@ -76,6 +76,7 @@ WHERE_BY_OWNER = {
76
76
  "retrieval": NOT_RETURNED[2],
77
77
  "agent-skill": DELIVERED[2],
78
78
  "dataset": "dataset",
79
+ "package-skill": "package guidance",
79
80
  }
80
81
  # Which artifact the edit lands in. An owner nobody has named yet -- eval-
81
82
  # diagnose has not run, or it ran and declined (`undecided` for a
@@ -87,6 +88,7 @@ LEVER_BY_OWNER = {
87
88
  "retrieval": "retrieval",
88
89
  "agent-skill": "skill",
89
90
  "dataset": "dataset",
91
+ "package-skill": "package-skill",
90
92
  }
91
93
 
92
94
 
@@ -94,7 +94,7 @@ READ_ONLY = (*NO_EDITS, *NO_SHELL)
94
94
  CODE_IN_TABLE = re.compile(r"^\|\s*`([A-Z][A-Z_-]+)`\s*\|", re.M)
95
95
  COMPONENTS = ("dataset", "agent-call", "get_context/model",
96
96
  "get_context/retrieval", "construction", "model-definition")
97
- OWNERS = ("model", "retrieval", "agent-skill", "dataset")
97
+ OWNERS = ("model", "package-skill", "retrieval", "agent-skill", "dataset")
98
98
  SUFFICIENCY = ("sufficient", "insufficient", "unknown")
99
99
  SEVERITY = ("low", "medium", "high")
100
100
 
@@ -1108,11 +1108,18 @@ def main(argv: list[str] | None = None) -> int:
1108
1108
  if invalid:
1109
1109
  print(f"{len(invalid)} broke the skill's vocabulary "
1110
1110
  f"(see `_invalid` in diagnoses.jsonl)")
1111
+ # Both owners are fixable in the model repo, so both are what improve may
1112
+ # touch. Counted separately because they license different edits and the
1113
+ # acceptance check treats them differently.
1114
+ fixable = ("model", "package-skill")
1111
1115
  actionable = sum(len(c.get("qids", []))
1112
1116
  for c in clusters.get("clusters", [])
1113
- if c.get("owner") == "model")
1114
- print(f"{actionable} cases sit behind a model-owned cluster "
1115
- f"(the only ones eval-improve may touch)")
1117
+ if c.get("owner") in fixable)
1118
+ by_owner = ", ".join(
1119
+ f"{o}: {sum(len(c.get('qids', [])) for c in clusters.get('clusters', []) if c.get('owner') == o)}"
1120
+ for o in fixable)
1121
+ print(f"{actionable} cases sit behind a cluster eval-improve may touch "
1122
+ f"({by_owner})")
1116
1123
  print(f"cost ${spend:.2f}")
1117
1124
  return 0
1118
1125
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.38
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -1,13 +1,27 @@
1
1
  ---
2
2
  name: eval-improve
3
- description: 'Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.38
3
+ description: 'Make the smallest safe edit that closes a diagnosed gap the model repo owns: a Malloy model edit, or a package skill under skills/. Every factual claim carries a probe receipt. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # Improve the Model
8
8
 
9
- Takes an issue with `owner: model` and produces **one smallest edit** that closes
10
- the gap. Every factual claim is backed by a query you ran.
9
+ Takes an issue with `owner: model` or `owner: package-skill` and produces **one
10
+ smallest edit** that closes the gap. Every factual claim is backed by a query
11
+ you ran.
12
+
13
+ The two owners license different edits and are never interchangeable:
14
+
15
+ - **`model`** changes what the data means or how it is described:
16
+ a `.malloy` file.
17
+ - **`package-skill`** changes what the agent is told about using this package:
18
+ a file under the package's `skills/`. Use it for a rule that spans entities or
19
+ is not a property of any one of them. A fact about a single field belongs in
20
+ that field's `#(doc)`, which reaches every agent whether or not it loaded a
21
+ guide.
22
+
23
+ Both live in the model repo, so one git commit checkpoints and rolls back
24
+ either.
11
25
 
12
26
  **Two hard boundaries:**
13
27
 
@@ -30,11 +44,18 @@ the gap. Every factual claim is backed by a query you ran.
30
44
  | Doubt only, or retrieval-only (no verdict) | Docs, labels, index only, and only where the transcript shows a concrete confusion. |
31
45
  | Silence | **No edit.** |
32
46
 
47
+ A `package-skill` edit sits at the docs tier throughout: it is permitted
48
+ wherever the table permits docs, and never on silence. It is the easiest artifact
49
+ in this loop to overfit, because it is prompt text sitting next to the question
50
+ it just failed, so the expert test in Step 1 applies to it word for word.
51
+
33
52
  Do not edit for `BAD-REFERENCE` or `AMBIGUOUS-REFERENCE`. Those are the
34
53
  golden side door in `skill:eval-loop`: repair or hold the golden, bump
35
54
  `goldenRevision` on the case, and open a new baseline run. Being right and unmatched
36
- beats encoding a defect or an unsettled key. Do not edit for a skill,
37
- retrieval, or dataset owner.
55
+ beats encoding a defect or an unsettled key. Do not edit for an `agent-skill`,
56
+ retrieval, or dataset owner: `agent-skill` means the shared skill, which is not
57
+ in this repo, and widening it by editing one package's copy hides the gap from
58
+ every other package.
38
59
 
39
60
  ## Step 1: What a correct answer may teach
40
61
 
@@ -66,6 +87,20 @@ A false `primary_key` compiles and silently corrupts every aggregate. Of one
66
87
  pilot's 11 accepted edits, 4 of 5 wrong ones died to a single
67
88
  `COUNT(*)` vs `COUNT(DISTINCT …)` probe that was never run.
68
89
 
90
+ **A package-skill edit still needs probes, for a different reason.** It makes no
91
+ claim about a join key, so there is nothing structural to verify, and the
92
+ `probes` array can legitimately be short. But a guide that states a fact about
93
+ the data is asserting it to every agent that reads it, with no compiler and no
94
+ query to contradict it. Probe every number and every claim about what a measure
95
+ counts before writing it into a guide, exactly as you would before writing it
96
+ into a `#(doc)`. A guide that quotes a stale figure is worse than one that
97
+ quotes none: it is confidently wrong at the moment of use.
98
+
99
+ Its reload receipt is the ordinary one. Skill files are part of the package's
100
+ content hash, so a skill-only edit moves `sourceContentSha` and an unchanged sha
101
+ means the edit did not reach what is served. After the reload, read the guide
102
+ back from the server and confirm it is your text.
103
+
69
104
  Compile-check the edit before saving (scope `file` for an edit), then reload
70
105
  the package. Confirm it is not serving a stale model.
71
106
 
@@ -98,6 +133,11 @@ displace answers that already worked.
98
133
  | 4 | Declared join on a *probed* key |
99
134
  | 5 | A new source: last resort, at most one |
100
135
 
136
+ For a `package-skill` issue the ranking is the same shape, and rung 1 is still
137
+ the model: if the fact fits on one entity's `#(doc)`, put it there instead. A
138
+ guide is the right home only for what a doc cannot hold, and it reaches only the
139
+ agents that read it.
140
+
101
141
  ### Four ways a doc edit backfires, each measured
102
142
 
103
143
  **Do not put an emphatic claim next to an unsettled one.** A dimension doc was
@@ -4,9 +4,10 @@
4
4
  python improve.py --run <workdir>/runs/<label> --set <set-dir> --watch-mode
5
5
  # the model directory comes from [model] repo in the set's eval.toml
6
6
 
7
- One agent per `owner: model` cluster, each holding `skill:eval-improve`, each
8
- producing at most one smallest edit with probe receipts. Appends one `candidate`
9
- event per cluster to the run's `events.jsonl`.
7
+ One agent per fixable cluster (`owner: model` or `owner: package-skill`), each
8
+ holding `skill:eval-improve`, each producing at most one smallest edit with
9
+ probe receipts. Appends one `candidate` event per cluster to the run's
10
+ `events.jsonl`.
10
11
 
11
12
  THIS SCRIPT CONTAINS NO MODELING DOCTRINE
12
13
 
@@ -71,13 +72,13 @@ IMPROVE_TOOLS = ("mcp__publisher__get_context",
71
72
  "mcp__publisher__execute_query",
72
73
  "mcp__publisher__compile_model",
73
74
  "mcp__publisher__reload_package",
75
+ "mcp__publisher__get_skill",
76
+ "mcp__publisher__get_status",
74
77
  "Read", "Edit", "Write", "Grep", "Glob",
75
78
  "Bash(bash ./sync_and_reload.sh)")
76
79
 
77
- RELOAD = """curl -s -m 60 -X POST "{mcp_url}" \\
78
- -H "Content-Type: application/json" \\
79
- -H "Accept: application/json, text/event-stream" \\
80
- -d '{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"reload_package","arguments":{{"environmentName":"{environment}","packageName":"{package}"}}}}}}' \\
80
+ # REST, not MCP: an MCP reply escapes its inner JSON, so grepping it for the sha matches nothing.
81
+ RELOAD = """curl -s -m 60 "{rest_url}/api/v0/environments/{environment}/packages/{package}?reload=true" \\
81
82
  | grep -o '"sourceContentSha":"[^"]*"' | head -1"""
82
83
 
83
84
  SYNC_SCRIPT = """#!/bin/bash
@@ -87,11 +88,31 @@ echo "reloaded; if sourceContentSha did not change, the edit did not reach the s
87
88
  """
88
89
 
89
90
 
91
+ SYNC_HELPER = "sync_and_reload.sh"
92
+
93
+
90
94
  def git(args: list[str], cwd: pathlib.Path) -> str:
91
95
  return subprocess.run(["git", *args], cwd=cwd, capture_output=True,
92
96
  text=True).stdout
93
97
 
94
98
 
99
+ def candidate_diff(model_dir: pathlib.Path, extra: tuple[str, ...] = ()) -> str:
100
+ """The agent's edit: tracked changes plus new files, minus our own helper.
101
+
102
+ `git diff` alone omits untracked files, so a new skills/ file left an empty
103
+ patch that `git clean` then deleted. `--no-index` diffs each against
104
+ /dev/null without touching the index.
105
+ """
106
+ out = git(["diff", *extra, "--", "."], model_dir)
107
+ new = git(["ls-files", "--others", "--exclude-standard", "--", "."],
108
+ model_dir).splitlines()
109
+ for f in new:
110
+ if f != SYNC_HELPER:
111
+ out += git(["diff", "--no-index", *extra, "--", "/dev/null", f],
112
+ model_dir)
113
+ return out
114
+
115
+
95
116
  # Thin on purpose: the cluster, the cases, the one operational fact the skill
96
117
  # cannot know (how to reach the server), and the output shape. Everything about
97
118
  # how to choose an edit is in the loaded skill.
@@ -111,7 +132,8 @@ MAKING YOUR EDIT REACH THE SERVER
111
132
 
112
133
  After each edit run `bash ./sync_and_reload.sh`. It prints `sourceContentSha`.
113
134
  If that value does not change, your edit did NOT reach the server and every
114
- probe you run afterwards is testing the old model.
135
+ probe you run afterwards is testing the old model. This covers a package skill
136
+ under skills/ as well as a .malloy file: both are part of the hash.
115
137
 
116
138
  One edit for the cluster's shared root cause, not one per case.
117
139
 
@@ -259,7 +281,8 @@ def improve_cluster(issue: dict[str, Any], cases: dict[str, Any],
259
281
  return {**json.loads(out.read_text()), "_cached": True}
260
282
  d.mkdir(parents=True, exist_ok=True)
261
283
 
262
- reload_cmd = RELOAD.format(mcp_url=a.mcp_url, environment=a.environment,
284
+ reload_cmd = RELOAD.format(rest_url=a.rest_url.rstrip("/"),
285
+ environment=a.environment,
263
286
  package=a.package)
264
287
  sync = ""
265
288
  if not a.watch_mode:
@@ -268,7 +291,7 @@ def improve_cluster(issue: dict[str, Any], cases: dict[str, Any],
268
291
  if served:
269
292
  sync = (f'rsync -a --delete --exclude .git '
270
293
  f'"{a.model_dir.resolve()}/" "{served.resolve()}/"\n')
271
- helper = a.model_dir / "sync_and_reload.sh"
294
+ helper = a.model_dir / SYNC_HELPER
272
295
  helper.write_text(SYNC_SCRIPT.format(sync=sync, reload=reload_cmd))
273
296
  helper.chmod(0o755)
274
297
 
@@ -298,9 +321,9 @@ def improve_cluster(issue: dict[str, Any], cases: dict[str, Any],
298
321
  save_transcript=d / "improver.jsonl")
299
322
  (d / "improver.md").write_text(r.text)
300
323
 
301
- diff = git(["diff", "--", "."], a.model_dir)
324
+ diff = candidate_diff(a.model_dir)
302
325
  (d / "edit.patch").write_text(diff)
303
- stat = git(["diff", "--stat", "--", "."], a.model_dir).strip()
326
+ stat = candidate_diff(a.model_dir, ("--stat",)).strip()
304
327
 
305
328
  # While the edit is still in the tree: the mechanical half of Step 4. Only
306
329
  # catches drift and rubric sentences that contradict the model -- a golden
@@ -347,6 +370,10 @@ def main(argv: list[str] | None = None) -> int:
347
370
  ap.add_argument("--environment", default=None)
348
371
  ap.add_argument("--package", default=None)
349
372
  ap.add_argument("--mcp-url", default=None)
373
+ ap.add_argument("--rest-url", default=None,
374
+ help="REST base; the reload receipt is read from here, "
375
+ "because the MCP reply double-encodes its JSON. "
376
+ "Default: the [model] server in the set's eval.toml")
350
377
  # No built-in port: a fixed default once named the server holding the
351
378
  # model under test, which cannot verify its own goldens. The fallback is
352
379
  # the [truth] section of the set's eval.toml. With neither, the value
@@ -406,6 +433,7 @@ def main(argv: list[str] | None = None) -> int:
406
433
  "--environment")
407
434
  a.package = cfg.need(a.package, "model", "package", "--package")
408
435
  a.mcp_url = a.mcp_url or cfg.model_mcp_url()
436
+ a.rest_url = a.rest_url or cfg.model_publisher()
409
437
  a.truth_publisher = a.truth_publisher or cfg.truth_publisher()
410
438
  a.truth_environment = a.truth_environment or cfg.get("truth", "environment")
411
439
  a.skills_root = a.skills_root or cfg.get("paths", "skills_root")
@@ -439,7 +467,7 @@ def main(argv: list[str] | None = None) -> int:
439
467
  if e.get("kind") == "issue_status":
440
468
  status[e["issue_id"]] = e["status"]
441
469
  issues = [e for e in events if e.get("kind") == "issue"
442
- and e.get("owner") == "model"
470
+ and e.get("owner") in ("model", "package-skill")
443
471
  and status.get(e["issue_id"]) == "open"]
444
472
  if a.only:
445
473
  want = {x.strip() for x in a.only.split(",")}
@@ -450,13 +478,14 @@ def main(argv: list[str] | None = None) -> int:
450
478
  if a.limit is not None:
451
479
  issues = issues[:a.limit]
452
480
  if not issues:
453
- print("no open model-owned issues; run diagnose.py first, or every "
454
- "cluster is owned by retrieval, agent-skill, or dataset")
481
+ print("no open model- or package-skill-owned issues; run diagnose.py "
482
+ "first, or every cluster is owned by retrieval, agent-skill, "
483
+ "or dataset")
455
484
  return 0
456
485
 
457
486
  art = a.run / "artifacts"
458
487
  art.mkdir(parents=True, exist_ok=True)
459
- print(f"{len(issues)} model-owned clusters, agent {a.model}, "
488
+ print(f"{len(issues)} model/package-skill clusters, agent {a.model}, "
460
489
  f"{'isolated' if a.isolate else 'cumulative'}, "
461
490
  f"{'watch mode' if a.watch_mode else 'snapshot sync'}")
462
491
 
@@ -247,5 +247,40 @@ class ClusterMembers(unittest.TestCase):
247
247
  "golden": [{"region": "West", "n": 3}],
248
248
  "rubric": "West has 3."}])
249
249
 
250
+ class CandidateDiff(unittest.TestCase):
251
+ """A new file is part of the edit, and our own helper is not."""
252
+
253
+ def setUp(self):
254
+ self.repo = pathlib.Path(tempfile.mkdtemp())
255
+ self.addCleanup(shutil.rmtree, self.repo, True)
256
+ for cmd in (["init", "-q"], ["config", "user.email", "t@t"],
257
+ ["config", "user.name", "t"]):
258
+ subprocess.run(["git", *cmd], cwd=self.repo, check=True)
259
+ (self.repo / "m.malloy").write_text("source: a is x\n")
260
+ subprocess.run(["git", "add", "."], cwd=self.repo, check=True)
261
+ subprocess.run(["git", "commit", "-qm", "init"], cwd=self.repo, check=True)
262
+
263
+ def test_a_new_skills_file_is_in_the_patch_and_the_helper_is_not(self):
264
+ (self.repo / "skills" / "s").mkdir(parents=True)
265
+ (self.repo / "skills" / "s" / "SKILL.md").write_text("NEW GUIDE\n")
266
+ (self.repo / improve.SYNC_HELPER).write_text("#!/bin/bash\n")
267
+ (self.repo / "m.malloy").write_text("source: a is y\n")
268
+ diff = improve.candidate_diff(self.repo)
269
+ self.assertIn("NEW GUIDE", diff)
270
+ self.assertIn("source: a is y", diff)
271
+ self.assertNotIn(improve.SYNC_HELPER, diff)
272
+ self.assertIn("SKILL.md", improve.candidate_diff(self.repo, ("--stat",)))
273
+
274
+ def test_only_the_helper_is_an_empty_diff(self):
275
+ (self.repo / improve.SYNC_HELPER).write_text("#!/bin/bash\n")
276
+ self.assertEqual(improve.candidate_diff(self.repo).strip(), "")
277
+
278
+
279
+ class Grants(unittest.TestCase):
280
+ def test_the_tools_the_skill_names_are_granted(self):
281
+ for t in ("get_skill", "get_status"):
282
+ self.assertIn(f"mcp__publisher__{t}", improve.IMPROVE_TOOLS)
283
+
284
+
250
285
  if __name__ == "__main__":
251
286
  unittest.main()
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-judge
3
3
  description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
4
- version: 0.1.38
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # The judge
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-loop
3
3
  description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
4
- version: 0.1.38
4
+ version: 0.1.40
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -523,7 +523,8 @@ conductor. Do not:
523
523
 
524
524
  ## Prime directives
525
525
 
526
- - The model is the only thing improve edits. No question text, qids, or
526
+ - Improve edits the model repo and nothing else: a `.malloy` file, or a package
527
+ skill under the package's `skills/`. No question text, qids, or
527
528
  expected values in any name, doc, or comment.
528
529
  - **You are measuring a model, not reviewing this harness.** Read a script when
529
530
  a number you have to report cannot be explained otherwise, and stop there. Do
@@ -50,6 +50,18 @@ Acceptance rules (replacing any vague "results improve"):
50
50
  - Independent deterministic justification (a probed-wrong definition
51
51
  corrected) may accept without a measured win. Record that as the acceptance check
52
52
  `reason`.
53
+ - **Package-skill edits (`class: skill`) get the full rules, never the
54
+ documentation shortcut.** A `get_context` probe proves an entity is
55
+ discoverable. Nothing deterministic proves an agent read a guide and behaved
56
+ differently, so the only evidence a skill edit has is a fresh blind
57
+ re-answer: both splits, twice, clearing the flip-count bar. Treat the
58
+ documentation shortcut as unavailable here even when the edit reads like
59
+ documentation, because a guide is prompt text written next to the case it
60
+ failed and is the easiest artifact in this loop to overfit.
61
+
62
+ Read the diff before measuring it, too. A guide that names the case, quotes
63
+ its expected number, or encodes the shape of one question fails the expert
64
+ test in `skill:eval-improve` and is rejected without a run.
53
65
 
54
66
  Write the `acceptance_check` event (decision, class, baseline and final run ids,
55
67
  regressions, holdout delta, reason) BEFORE any commit, so a rejected