@malloy-publisher/server 0.8.5 → 0.9.0

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 (24) hide show
  1. package/README.docker.md +45 -2
  2. package/dist/app/assets/{EnvironmentPage-D0689eMH.js → EnvironmentPage-UMS7Py4Q.js} +1 -1
  3. package/dist/app/assets/{HomePage-CQwxmRju.js → HomePage-D3MvQaZn.js} +1 -1
  4. package/dist/app/assets/{LightMode-DzUuDGQu.js → LightMode-e1lNBfpL.js} +1 -1
  5. package/dist/app/assets/{MainPage-CWebuoie.js → MainPage-B0ozqtQO.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DddcQtR_.js → ModelPage-lQj4Axqu.js} +2 -2
  7. package/dist/app/assets/{PackagePage-B_6x-Kp1.js → PackagePage-BT0YKNKU.js} +1 -1
  8. package/dist/app/assets/{RenderedResult-BRgIgxWh.es-B6GsiQaB.js → RenderedResult-BRgIgxWh.es-DiE7kPNX.js} +2 -2
  9. package/dist/app/assets/{RouteError-D85c0IXR.js → RouteError-BQnzPJvb.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-DPFZnuXS.js → ThemeEditorPage-D6t7w9Js.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-B2KkD7kC.js → ToggleButtonGroup-CJT2JKTC.js} +1 -1
  12. package/dist/app/assets/index-ByieDZlo.js +23 -0
  13. package/dist/app/assets/{index-BbrJd6fQ.js → index-CCNqF0-G.js} +1 -1
  14. package/dist/app/assets/{index-125-Lr5Z.js → index-CKmZ4HG7.js} +388 -388
  15. package/dist/app/assets/{index-mS-LEGFh.js → index-CdExa3Pk.js} +1 -1
  16. package/dist/app/assets/{index-BSBGZfIc.js → index-TiWjmdoW.js} +4 -4
  17. package/dist/app/assets/{index-DSoCUF-f.js → index-ireHUsv_.js} +419 -422
  18. package/dist/app/assets/{index-BK_RS5eN.js → index-ufFZR_K-.js} +1 -1
  19. package/dist/app/assets/{index.es-Dixkq2YH.js → index.es-iUV0gCYk.js} +4 -4
  20. package/dist/app/index.html +1 -1
  21. package/dist/package_load_worker.mjs +11 -66
  22. package/dist/server.mjs +326 -675
  23. package/package.json +14 -14
  24. package/dist/app/assets/index-w_0OQJgZ.js +0 -23
package/dist/server.mjs CHANGED
@@ -206074,13 +206074,69 @@ var init_path_safety = __esm(() => {
206074
206074
 
206075
206075
  // src/pg_helpers.ts
206076
206076
  function redactPgSecrets(s2) {
206077
- return s2.replace(/([a-z][a-z0-9+.-]*:\/\/[^:/?#\s]*):([^/\s]+)@/gi, "$1:***@").replace(/((?:postgres|postgresql):\/\/[^:/?#\s]*):([^@\s]+)@/gi, "$1:***@").replace(/password=('(?:\\.|[^'\\])*'|"[^"]*"|\S+)/gi, "password=***");
206077
+ return redactUserinfoPasswords(redactUserinfoPasswords(s2, ANY_SCHEME_LAST_AT), PG_SCHEME_FIRST_AT).replace(/password=('(?:\\.|[^'\\])*'|"[^"]*"|\S+)/gi, "password=***");
206078
+ }
206079
+ function runEnd(run, s2, from) {
206080
+ run.lastIndex = from;
206081
+ run.exec(s2);
206082
+ return run.lastIndex;
206083
+ }
206084
+ function redactUserinfoPasswords(s2, pass) {
206085
+ let out = "";
206086
+ let copied = 0;
206087
+ let stop = -1;
206088
+ let sep2 = s2.indexOf("://");
206089
+ while (sep2 !== -1) {
206090
+ const userEnd = runEnd(USERNAME_RUN, s2, sep2 + 3);
206091
+ let at = -1;
206092
+ if (s2[userEnd] === ":" && pass.hasScheme(s2, sep2, copied)) {
206093
+ const from = userEnd + 1;
206094
+ if (stop < from)
206095
+ stop = runEnd(pass.passwordRun, s2, from);
206096
+ at = pass.passwordEnd(s2, from, stop);
206097
+ }
206098
+ if (at === -1) {
206099
+ sep2 = s2.indexOf("://", sep2 + 1);
206100
+ continue;
206101
+ }
206102
+ out += s2.slice(copied, userEnd + 1) + "***@";
206103
+ copied = at + 1;
206104
+ sep2 = s2.indexOf("://", copied);
206105
+ }
206106
+ return out + s2.slice(copied);
206078
206107
  }
206079
206108
  function redactConnectionSecretShapes(s2) {
206080
206109
  return redactPgSecrets(s2.replace(PEM_TERMINATED_PATTERN, "***").replace(PEM_UNTERMINATED_PATTERN, "***").replace(SECRET_QUOTED_PATTERN, "$1***").replace(SECRET_BARE_PATTERN, "$1***"));
206081
206110
  }
206082
- var SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
206111
+ var USERNAME_RUN, SCHEME_CHAR, SCHEME_LETTER, PG_SCHEMES, ANY_SCHEME_LAST_AT, PG_SCHEME_FIRST_AT, SECRET_FIELD_NAMES, NAME_ALTERNATION, SECRET_QUOTED_PATTERN, SECRET_BARE_PATTERN, PEM_TERMINATED_PATTERN, PEM_UNTERMINATED_PATTERN;
206083
206112
  var init_pg_helpers = __esm(() => {
206113
+ USERNAME_RUN = /[^:/?#\s]*/y;
206114
+ SCHEME_CHAR = /[a-z0-9+.-]/i;
206115
+ SCHEME_LETTER = /[a-z]/i;
206116
+ PG_SCHEMES = ["postgres", "postgresql"];
206117
+ ANY_SCHEME_LAST_AT = {
206118
+ hasScheme(s2, sep2, floor) {
206119
+ for (let i2 = sep2 - 1;i2 >= floor && SCHEME_CHAR.test(s2[i2]); i2--) {
206120
+ if (SCHEME_LETTER.test(s2[i2]))
206121
+ return true;
206122
+ }
206123
+ return false;
206124
+ },
206125
+ passwordRun: /[^/\s]*/y,
206126
+ passwordEnd(s2, from, stop) {
206127
+ const at = s2.slice(from, stop).lastIndexOf("@");
206128
+ return at > 0 ? from + at : -1;
206129
+ }
206130
+ };
206131
+ PG_SCHEME_FIRST_AT = {
206132
+ hasScheme(s2, sep2, floor) {
206133
+ return PG_SCHEMES.some((scheme) => sep2 - scheme.length >= floor && s2.slice(sep2 - scheme.length, sep2).toLowerCase() === scheme);
206134
+ },
206135
+ passwordRun: /[^@\s]*/y,
206136
+ passwordEnd(s2, from, stop) {
206137
+ return stop > from && s2[stop] === "@" ? stop : -1;
206138
+ }
206139
+ };
206084
206140
  SECRET_FIELD_NAMES = [
206085
206141
  "password",
206086
206142
  "privateKey",
@@ -308322,72 +308378,21 @@ function onMotlyRoute(text) {
308322
308378
  function hasEnvReference(annotation) {
308323
308379
  return annotation.includes("@env.");
308324
308380
  }
308325
- function pollutionTargets() {
308326
- const targets = [Object.prototype];
308327
- for (const key of Object.getOwnPropertyNames(Object.prototype)) {
308328
- const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, key);
308329
- if (!descriptor)
308330
- continue;
308331
- let value;
308332
- if ("value" in descriptor) {
308333
- value = descriptor.value;
308334
- } else {
308335
- if (BUILT_IN_PROTO_GETTER === undefined || descriptor.get !== BUILT_IN_PROTO_GETTER) {
308336
- return;
308337
- }
308338
- continue;
308339
- }
308340
- if ((typeof value === "object" || typeof value === "function") && value !== null && !targets.includes(value)) {
308341
- targets.push(value);
308342
- }
308343
- }
308344
- return targets;
308345
- }
308346
- function parseGuarded(texts) {
308347
- const targets = pollutionTargets();
308348
- if (targets === undefined) {
308349
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308350
- }
308351
- let unreadable = false;
308352
- const ownNames = (target) => {
308353
- try {
308354
- return Object.getOwnPropertyNames(target);
308355
- } catch {
308356
- unreadable = true;
308357
- return [];
308358
- }
308359
- };
308360
- const before = targets.map(ownNames);
308361
- if (unreadable)
308362
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308363
- const undoPollution = () => {
308364
- let polluted = false;
308365
- targets.forEach((target, index) => {
308366
- for (const key of ownNames(target)) {
308367
- if (before[index].includes(key))
308368
- continue;
308369
- polluted = true;
308370
- try {
308371
- delete target[key];
308372
- } catch {}
308373
- }
308374
- });
308375
- return polluted || unreadable;
308376
- };
308381
+ function parseBounded(texts) {
308377
308382
  if (texts.some((text) => text.length > MAX_ANNOTATION_CHARS)) {
308378
308383
  return { tag: undefined, messages: [ANNOTATION_TOO_LONG] };
308379
308384
  }
308380
308385
  try {
308381
308386
  const result = parseAnnotation([...texts]);
308382
- if (undoPollution())
308383
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308384
308387
  return {
308385
308388
  tag: result.tag,
308386
308389
  messages: result.log.map((error2) => error2.message)
308387
308390
  };
308388
- } catch {
308389
- undoPollution();
308390
- return { tag: undefined, messages: [UNSAFE_TO_PARSE] };
308391
+ } catch (error2) {
308392
+ return {
308393
+ tag: undefined,
308394
+ messages: [error2 instanceof Error ? error2.message : String(error2)]
308395
+ };
308391
308396
  }
308392
308397
  }
308393
308398
  function docCommentText(texts) {
@@ -308519,16 +308524,16 @@ function parseMotly(texts) {
308519
308524
  const envErrors = envDropped > 0 ? [ENV_REFERENCE_DROPPED] : [];
308520
308525
  if (motly.length === 0)
308521
308526
  return { tag: undefined, errors: envErrors };
308522
- const direct = parseGuarded(motly);
308527
+ const direct = parseBounded(motly);
308523
308528
  if (direct.messages.length === 0)
308524
308529
  return { tag: direct.tag, errors: envErrors };
308525
308530
  const rescued = motly.map((text) => {
308526
- if (parseGuarded([text]).messages.length === 0)
308531
+ if (parseBounded([text]).messages.length === 0)
308527
308532
  return text;
308528
308533
  const rewritten = quoteFilterLiterals(text);
308529
- return parseGuarded([rewritten]).messages.length === 0 ? rewritten : text;
308534
+ return parseBounded([rewritten]).messages.length === 0 ? rewritten : text;
308530
308535
  });
308531
- const after = parseGuarded(rescued);
308536
+ const after = parseBounded(rescued);
308532
308537
  return { tag: after.tag, errors: [...envErrors, ...after.messages] };
308533
308538
  }
308534
308539
  function tagText(tag, ...path7) {
@@ -308594,9 +308599,8 @@ function readStartingGivens(tag, declaredType) {
308594
308599
  }
308595
308600
  return Object.keys(collected).length > 0 ? collected : undefined;
308596
308601
  }
308597
- var BUILT_IN_PROTO_GETTER, MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference", UNSAFE_TO_PARSE = "annotation could not be parsed safely";
308602
+ var MAX_ANNOTATION_CHARS = 8192, ANNOTATION_TOO_LONG, ENV_REFERENCE_DROPPED = "annotation dropped for carrying an @env. reference";
308598
308603
  var init_motly = __esm(() => {
308599
- BUILT_IN_PROTO_GETTER = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")?.get;
308600
308604
  ANNOTATION_TOO_LONG = `annotation exceeds ${MAX_ANNOTATION_CHARS} characters and was not parsed`;
308601
308605
  });
308602
308606
 
@@ -316329,9 +316333,6 @@ function lintDashboard(facts, manifest) {
316329
316333
  return findings;
316330
316334
  }
316331
316335
  function describeParseFailure(message) {
316332
- if (message === UNSAFE_TO_PARSE) {
316333
- return `was refused rather than parsed (${message}), which can be caused by something outside this file`;
316334
- }
316335
316336
  if (message === ENV_REFERENCE_DROPPED) {
316336
316337
  return `was dropped rather than parsed (${message}), so nothing on that line took effect`;
316337
316338
  }
@@ -376723,7 +376724,7 @@ ${source}` : source ?? "";
376723
376724
  }
376724
376725
  }
376725
376726
  // package.json
376726
- var version2 = "0.8.5";
376727
+ var version2 = "0.9.0";
376727
376728
 
376728
376729
  // src/version.ts
376729
376730
  var SERVER_VERSION = version2;
@@ -383628,7 +383629,8 @@ otherwise a modelling agent is dispatched to fix a model that is already right.
383628
383629
 
383629
383630
  ## Step 6: Append events, then stop
383630
383631
 
383631
- Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`caseId\` set. Shapes
383632
+ Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`caseId\` set (the run
383633
+ directory \`eval.py run\` printed; the workdir is the set's, from \`eval.toml\`). Shapes
383632
383634
  live in \`reference/ledger-schema.md\`.
383633
383635
 
383634
383636
  1. \`attempt\`: qid, sample, phase, question_sha, submitted, final_query,
@@ -384203,7 +384205,7 @@ a permanent parallel mechanism.`
384203
384205
  {
384204
384206
  name: "eval-answer/ledger-schema",
384205
384207
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
384206
- body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/\n set.json # set metadata (below)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the ledger together. Never\nplace `evals/` inside the directory tree the answerer's package serves: gold\nin the served tree is a contamination path.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `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. |\n| `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. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `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`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `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. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `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. |\n| `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. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `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. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **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. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `effort` | |\n| `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. |\n| `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`. |\n| `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. |\n| `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. |\n| `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. |\n| `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 `[]`. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `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.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `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. |\n| `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. |\n| `contamination_reasons` | list | Empty when clean. |\n| `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. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `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. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `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`. |\n| `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\". |\n| `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. |\n| `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. |\n| `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. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `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. |\n| `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. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `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. |\n| `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`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `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. |\n| `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`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `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. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `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. |\n| `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. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
384208
+ body: "# The eval ledger: files and events\n\nThe ledger is plain files in the model package's git repository. There is no\neval API and no eval database. The conductor (`skill:eval-loop`) reads and\nwrites these files directly; the stages share them as their contract.\n`eval-answer` writes `attempt`, `tool_call`, and `score`. An engine-side\n`eval-retrieval` skill, which does not ship here, writes `retrieval_score` and\n`probe`; both stay in this contract so one validator covers every run\ndirectory.\n`eval-diagnose` writes `issue` and `issue_status`. `eval-improve` writes\n`candidate`. `eval-loop` writes `acceptance_check`, further `issue_status`, and\n`checkpoint`.\n\n**The contract is code: `eval-answer/scripts/ledger.py`.** Every script that\nwrites `run.json` or `events.jsonl` builds its lines through that module, so a\nfield rename that reaches only one writer fails at write time. This document\nis the human-readable rendering; where the two disagree, the module is right\nand this file is the bug. Check any run directory with:\n\n```\npython3 skills/eval-answer/scripts/ledger.py validate <runDir>\n```\n\nErrors are broken identity (missing `run.json`, a missing required field, two\nscores for one attempt); warnings are missing comparison pins (a run without\n`skillsVersion` still measures, but cannot anchor an A/B on that pin) and\ngrandfathered unknown fields on old runs.\n\n## Layout\n\n```\nevals/<set>/ # the set, in the model's repository\n set.json # set metadata (below)\n eval.toml # servers and paths for eval.py (config.py)\n cases.jsonl # one case per line\n judge-regressions.jsonl # judge verdicts a human overruled\n\n<workdir>/ # [paths] workdir, default ~/.malloy-eval/<set>-<hash>/\n runs/<runId>/\n run.json # run config, the attribution pins\n events.jsonl # append-only event lines\n artifacts/ # prediction CSVs, judge outputs, transcripts\n packages/ # built report packages\n servers/<role>/ # server roots for eval.py serve\n```\n\nThe set directory lives in the SAME git repository as the model it evaluates,\nso a checkpoint (a git commit) pins the model and the answer key together.\nThe workdir is never inside the model package: a run holds a `model.malloy`\nsnapshot and a report is a Malloy package, and either one nested there puts\nthe package into `loadErrors`. For a checkpoint to pin the ledger as well,\npoint the workdir at a directory in the repository outside the package, and\ngitignore its `servers/` and `packages/`.\n\nRules that make the ledger trustworthy:\n\n- `events.jsonl` is append-only. Never edit a line. To void one, append a new\n event whose payload marks the old one `voided`. One sanctioned exception: a\n re-runnable stage (diagnose, improve) replaces ITS OWN prior events, keyed by\n issue id, through `ledger.replace_events()` -- so a crash-and-rerun does not\n duplicate issues. A stage never touches another stage's lines.\n- A stage never rewrites another stage's fields.\n- One artifact directory per attempt (`qid` plus `sample`). Never overwrite a\n previous attempt's prediction CSV.\n- End-of-run numbers come from counting event lines (`jq` over\n `events.jsonl`), never from arithmetic recalled in prose.\n\n## `set.json`\n\n| Field | Notes |\n|---|---|\n| `name` | Set name; also the directory name. |\n| `description` | |\n| `datasetVersion` | Integer. Bump on any golden repair or case change. Runs record the version they scored against. |\n| `targetModelPath` | Model path within the package. |\n| `truthPackage` | Name of the package holding the semantics-free sources every golden is re-derived from -- a package NAME, not an object. Without it `verify_goldens.py` re-derives nothing: it reports \"nothing to re-derive against\" and **exits 3**, which a caller must read as unverified rather than as a pass. The checks that need no truth server -- rubric figures, verification axis, stale rubric claims, set names -- still run and still report. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\n| `targetPackage` | Name of the package UNDER TEST. `verify_goldens.py` refuses a \"truth\" server that also serves it, because an answerer on that server can retrieve the raw truth sources beside the model and quietly change what is under test. `run_baseline.py` and `improve.py` pass the package they were given, so this field is the fallback for a direct call; without either the guard cannot fire. |\n| `truthTableRewrite` | Boolean, default false. Rewrites `duckdb.table('data/x.parquet')` refs to bare `x` in canonical queries, for a truth package whose tables are registered rather than read from files. |\n\n## `cases.jsonl`\n\nOne JSON object per line:\n\n| Field | Notes |\n|---|---|\n| `qid` | Stable case id. |\n| `question` | Exact text the answerer will see. |\n| `split` | `dev` or `holdout`. Frozen at import. Diagnose and improve read `dev` only; the acceptance check runs both. |\n| `tags` | list |\n| `state` | `candidate` / `selected` / `excluded`. |\n| `source` | Where the case came from. |\n| `golden` | `status` (`verified` / `provisional` / `invalid` / `ambiguous` / `verified_wrong`, the last being a key a person has established is wrong, which `run_baseline.py` drops from every aggregate), `kind`, `value` or `path` (artifact under the set directory; a `scalar` value is an object naming the canonical query's column, `{\"net_sales\": 834216}`, never a bare number), `canonicalQuery` (runs against the **truth** package, never the model under test), `verifiedBy`, and `verification` -- `{primaryAxis, variesAxis, note}` naming what the second derivation varied. **A golden is written only after two differently shaped derivations agree** (the truth query and a second one that varies a different axis -- group by something else, sum a different way, take a different route through the raw tables); `gold/<qid>.json` holds both (`truthRows`, `verifyRows`, `agreement`). Two derivations that vary the same axis share every other blind spot: that is how a golden that summed three overlapping time slices, 3x too high, passed its check. `verify_goldens.py` re-derives the value, reads the accepting clause of the rubric against the rows, and flags a second derivation that names no axis. **The question asks for data, never for an interpretation of it**, and either fixes the grain or the rubric accepts a correct figure at any stated grain (`skill:eval-judge` rules 10 and 11). |\n| `golden.kind` | What the key IS. A value (`scalar`, rows), `unanswerable`, or `criteria`: prose with no value, where the author's criteria are the whole key. `criteria` is the one kind that is legitimately `verified` on arrival, because there is no derivation to perform and nothing that can be numerically wrong; an imported key holding a VALUE is `provisional` until re-derived (`skill:eval-import`). **`verify_goldens.py --promote` is the only thing that writes `golden.status`**: it marks a `provisional` golden `verified` when its value re-derived cleanly from the truth package AND a second derivation exists. `--refresh` rewrites a drifted VALUE and never changes a status; `invalid` and `ambiguous` are settled by a person through the golden side door. |\n| `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. |\n| `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. |\n| `goldenRevision` | Integer. Bump on every golden change; `score` events stamp the revision they compared against. |\n| `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`. |\n\n## Term files (`eval-retrieval` only)\n\nA customer set has no term file. The engine-side `eval-retrieval` skill keeps\n`intents.jsonl` fixtures -- search term, entity type, a description of what the\nsearcher meant, a validity flag -- beside its own scripts, for evaluating the\nengine against fixed inputs. Their shape and the `retrieval_score` events they\nproduce are documented there and validated here.\n\n## `run.json`\n\nThe attribution pins. Two runs are comparable only when these match where it\nmatters. Two runs with different `target` values are comparable on answer\nverdicts only if the same model version reached both, which is rarely worth\nassuming:\n\nRequired fields are the run's **identity** -- every run ever written carries\nthem. The rest are optional in the schema, but the **comparison pins** are\nwarned about when absent, because a run missing one cannot take part in an A/B\non that axis.\n\n| Field | Notes |\n|---|---|\n| `runId` *(required)* | Directory name. |\n| `target` *(required)* | Which server answered, and how it reached the data: `local`, `local-proxied`, or `platform`. Decides what an improve step is even allowed to do (see `skill:eval-loop`). |\n| `answererModel` *(required)* | |\n| `phase` / `started` *(required)* | |\n| `judgeModel` / `judgeVersion` / `rubricSha` *(pins)* | The judge's model, and the version + content sha of `skill:eval-judge`. |\n| `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. |\n| `datasetVersion` *(pin)* | From `set.json` at run time. |\n| `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. |\n| `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. |\n| `skillsRoot` / `harnessVersion` | Which checkout supplied the doctrine (`--skills-root`, e.g. a Publisher checkout for the open-source skills; default this one), and this checkout's own HEAD. The eval-* skills always come from the harness checkout, whatever `skillsRoot` says. Two runs whose `skillsRoot` differ are a skills A/B only if their manifests name the same skills. |\n| `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. |\n| `modelGitSha` | Commit of the model repo, from `--model-repo`; `-dirty` suffix when `--model-dir` (or the whole repo, without it) had uncommitted changes -- fine for a band measurement, not for an A/B pin. Null when git cannot answer for the scope (a path outside the repo): absent beats wrong. **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. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `evalConfig` | The set's `eval.toml` (or `eval.json`) the run read, as `{path, values}`; `path` is null when the set has none. |\n| `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |\n| `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `effort` | |\n| `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. |\n| `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`. |\n| `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. |\n| `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. |\n| `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. |\n| `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 `[]`. |\n| `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. |\n| `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. |\n| `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. |\n| `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. |\n| `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.) |\n\n## Events\n\nEvery line in `events.jsonl` is **flat**: `{ \"kind\": ..., <fields> }`, one\nJSON object per line, with an optional `at` ISO timestamp. Case-scoped kinds\n(`attempt`, `tool_call`, `score`) carry `qid`, `sample`, `phase` on the line;\nrun-level kinds do not. (An earlier draft of this document nested fields under\na `payload` with a `caseId`; no writer ever did that, and 23 run directories\nexist in the flat shape, so the flat shape is the contract.) `kind` is one of:\n`attempt`, `tool_call`, `score`, `retrieval_score`, `issue`, `issue_status`,\n`candidate`, `acceptance_check`, `checkpoint`.\n\n### `attempt`\n\n| Field | Type | Notes |\n|---|---|---|\n| `qid` | string | |\n| `sample` | int or null | Which repeat. Required even when null. |\n| `phase` | string | `baseline` / `loop` / `blind_gate` / `canary` / `final`. `phase` lives here, on the attempt, not in run config. |\n| `question_sha` | string | Hash of the exact text the answerer saw, so two runs can be compared on whether they asked the same thing. A case may carry `questionSha`, the seal stamped at conversion, and that wins when present; the ecommerce set writes a 16-char prefix of it, and a set without one left this null. Neither form proves the question was not edited, since both follow the case as it now reads: that comparison is check 6 of `verify_goldens.py`. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. A named view is recorded as the Malloy it stands for, `run: <source> -> <view>`, so every consumer sees one shape and the query re-executes. |\n| `final_query_source` | string or null | How `final_query` was chosen: `declared` (the answer printed it), `last_ok` (the last call the server answered) or `last`. `last` is a warning: a trailing sanity probe may be standing in for the answer's own query. |\n| `servedRevision` | string or null | From the package actually queried. |\n| `n_get_context` / `n_execute` / `n_execute_errors` | int | |\n| `host_tool_uses` | int | EVERY tool use the host logged, MCP calls included. It counted only the non-MCP ones until 2026-09-03, which made the under-report check below true of almost every clean attempt. |\n| `mcp_tool_uses` | int or null | The MCP subset: `get_context` plus `execute_query`. Null on a run written before the split. |\n| `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. |\n| `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. |\n| `contamination_reasons` | list | Empty when clean. |\n| `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. |\n| `cost_usd` | float or null | Answerer cost for this attempt. |\n| `num_turns` / `wall_seconds` | int, float or null | |\n| `answer_text` | string or null | The answer the judge scored. Kept so a verdict can be re-read without the transcript. |\n| `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. |\n| `transcriptPath` | string | The answerer's transcript under `artifacts/`. |\n\nToken counts sit here rather than being derived later because the claim a\nsemantic model makes is about cost as well as correctness -- that a documented\nmodel reaches a good answer in fewer turns and tokens than working from raw\nschema. A ledger that counts calls but not tokens can state half of that.\n\n### `tool_call`\n\nOne event per MCP `get_context` or `execute_query` the attempt made.\n\n| Field | Type | Notes |\n|---|---|---|\n| `tool` | string | `get_context` or `execute_query`. |\n| `traceId` | string or null | `get_context` only; look up in your host's trace store. |\n| `targets` | string, list or null | **What the answerer asked for**: the search terms it sent to `get_context`. Null for `execute_query`. |\n| `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`. |\n| `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\". |\n| `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. |\n| `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. |\n| `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. |\n| `error` | string or null | |\n\nNever persist `execute_query` result rows, givens, or credentials.\n\n`targets` records the request; `rankedSummary` records the response. Without\nboth, a low per-attempt recall has two readings that cannot be told apart: the\nanswerer searched for the wrong thing, or it searched well and retrieval ranked\nthe right entity too low. Those have opposite owners -- `agent-skill` and\n`retrieval` -- so a ledger holding only the response cannot attribute the\nfailure, and any recall computed from it is a blend of answerer behaviour and\nretrieval quality.\n\nThat blend is also why per-attempt recall is **not** comparable across arms: a\nstronger answerer searches better and scores higher retrieval recall without\nretrieval having changed. Use it for attribution within an arm. Comparing\nretrieval itself between engine versions is `eval-retrieval`'s job, with fixed\nterms, and is not something a customer run reports.\n\n### `score`\n\nThe answer judge's verdict for one attempt (protocol in\n`skill:eval-judge`). Every attempt in a scored run gets exactly one.\n\n| Field | Type | Notes |\n|---|---|---|\n| `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. |\n| `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. |\n| `confidence` | int or null | 1 to 10. Confidence of 5 or lower forces `needs_human`. |\n| `column_pairing` | object or null | The judge's named gold-to-prediction column correspondence. |\n| `judge_version` / `rubric_sha` | string | Pins which rubric produced this verdict. |\n| `golden_revision` | int | From the case at score time. |\n| `contaminated` | bool or `\"unknown\"` | Copied from the attempt; true or unknown means `verdict: null`. |\n| `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. |\n| `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`. |\n| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `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. |\n| `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`. |\n\nA `golden_*` reason means the ANSWER KEY is not something a verdict can be\nissued against. A golden the set stamps `provisional`, `invalid` or `ambiguous`\nis refused, as is a case with no golden at all and one the set marks\n`verified_wrong`; a golden with NO `status` is unguarded rather than\nunscorable, because sets that predate the field would otherwise score zero of\nzero. A golden holding no value is not refused either: that is how a\n`kind: unanswerable` case and a `kind: criteria` golden are written. This\nmatters most for an imported set, because `skill:eval-import` stamps\n`provisional` on every golden it writes and enforces it -- so a set is\nunscorable by construction until its keys are re-derived, and `run_baseline`\nrefuses to spend at all when every case in a set is in that state.\n\n`not_submitted` means the attempt produced NOTHING to judge: no answer text and\nno query. An attempt that wrote prose without querying **is judged**, and\nagainst a golden that holds a value an answer containing none of it is\n`no_match` (`skill:eval-judge`, `reference/refusal.md` rule 10). Excusing those\nas unscorable dropped a confident refusal on an answerable case out of the pass\nrate, which is the one thing the answerable-sounds-unanswerable cases exist to\nmeasure. A refusal is exempt only where `golden.kind` is `unanswerable`, and\nthere naming the gap is the pass and a confident number is the fail.\n\n`answerer_truncated` means the answerer hit the run's `maxTurns` cap and the\nCLI returned whatever it had, which is usually a sentence stopping mid-thought.\nIt is refused BEFORE the golden gate and before a saved verdict is reused, so\n`--rebuild` over a run made without this re-derives the right ledger from its\ntranscripts. A cap is a harness setting, so a case cut off by one says nothing\nabout whether the model could answer it: measured on one 29-case arm, four\nattempts ended at exactly 31 turns and each was judged as a wrong answer, one\nof them on the text \"Let me build the correct monthly trend query.\"\n\n`contaminated` means the attempt breached isolation (`contamination_reasons`\nnames how) and the verdict is withheld rather than lost. The judge's own read\nmoves to `judge_verdict`. Before this reason was written, a voided verdict kept\nthe judge's prose in `reason` beside `verdict: null`, and a run report\nreasonably concluded the ledger was dropping the field -- it recovered nine\n\"lost\" verdicts by re-parsing the stored judge replies, every one of which had\nbeen voided on purpose.\n\nBoth are HARNESS-owned, not model-owned, and a run containing either reports\n`status: incomplete` in `run.json` and **prints no pass rate**. `unscorable`\n(no established golden) and `judge_unparseable` do not suppress the rate: the\nfirst is a dataset state no arm can fix, and the second already has a retry.\n\nAggregates count decided verdicts only. `match` and `no_match` are the pass and\nthe fail; **`near_match`, `needs_human` and null are none of the above** and stay\nout of acceptance arithmetic. `near_match` is excluded because it means \"defensibly\ndifferent\", and an arguable verdict that moves a pass rate is a measurement\nartefact rather than a result (`skill:eval-judge` rule 7). Report its count:\nit rising is how a set tells you its rubrics are going vague.\n\nThis applies to `score`. On `retrieval_score` below, `near_match` **is** counted\ntowards recall and precision, because an overlapping entity is a genuine\nretrieval success. The two are different questions that share a word.\n\n### `retrieval_score` (written by `eval-retrieval`, not by a customer run)\n\nOne per term judged in a fixed-term replay (run-level; no `caseId`).\n\n| Field | Type | Notes |\n|---|---|---|\n| `intentId` / `term` / `entityType` | string | From the term file. |\n| `in_scope` | bool | Does THIS model version represent the concept? False is a coverage gap, not a retrieval failure. |\n| `judgments` | list | Per returned entity: `entityId`, `rank`, `level` (`match` / `near_match` / `no_match`), `confidence`, `why`. Empty when nothing returned. |\n| `judge_version` / `rubric_sha` | string | |\n| `traceId` | string or null | The `get_context` call judged. |\n\nRun-level metrics fall out by counting:\n\n- `coverage` = in-scope terms / valid terms -- a property of the model and its\n data, reported on its own, never as a retrieval number.\n- `recall` (on in-scope terms) = fraction whose judgments contain a `match`.\n- `precision@N` = `match` judgments / judged, with N stated.\n\n### `issue`\n\n| Field | Type | Notes |\n|---|---|---|\n| `issue_id` | string | Stable across status events. |\n| `qids` | list | Affected cases. |\n| `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. |\n| `component` | string | `dataset` / `agent-call` / `get_context/model` / `get_context/retrieval` / `construction` / `model-definition`. |\n| `owner` | string | `model` / `retrieval` / `agent-skill` / `dataset`. Environment failures stop the run; they are never diagnosed, so there is no environment owner. |\n| `severity` / `confidence` | string | |\n| `sufficiency` | string | `sufficient` / `insufficient` / `unknown`. |\n| `traceIds` | list | |\n| `diagnosis` | string | Written before any edit exists. |\n\n### `issue_status`\n\n`issue_id` plus `status`: `open` / `batched` / `fixed` / `rejected` /\n`deferred`. Readers take the latest event for that `issue_id`. Do not invent\na status column.\n\n### `candidate`\n\nWritten by `eval-improve`, for every proposed edit, accepted or not. A\nrejected direction keeps its record.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `files` | Paths the edit touched. |\n| `diffSummary` | One line per file. |\n| `probes` | Query and result for each factual claim. |\n| `meaningChanged` | Entities whose *meaning* the edit changed; `[]` for a docs-only edit. |\n| `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. |\n| `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. |\n\n### `acceptance_check`\n\nCalled `gate` before 2026-09-03. `ledger.py` reads the old kind as this one, so\nrun directories written earlier still validate and nothing rewrites them.\n\nWritten by `eval-loop`, one per acceptance check decision, BEFORE any checkpoint commit.\n\n| Field | Notes |\n|---|---|\n| `issue_ids` | |\n| `decision` | `accepted` / `rejected`. |\n| `class` | `docs` / `definition` / `retrieval` / `justified`. |\n| `baselineRunId` / `finalRunIds` | Plural: acceptance needs two independent runs. |\n| `regressions` | Case ids whose verdict got worse vs baseline. Must be empty to accept. |\n| `holdoutDelta` | Confident-verdict delta on the holdout slice. |\n| `reason` | Including independent deterministic justification when that is the basis. |\n\n### `checkpoint`\n\nWritten by `eval-loop` after an accepted acceptance check, or when a restore runs. The\nmodel bytes live in git, not in this payload.\n\n| Field | Notes |\n|---|---|\n| `action` | `created` / `restored`. |\n| `label` | |\n| `modelGitSha` | The commit this checkpoint names (create), or the commit restored to. |\n| `issueIds` | Issues the accepted edit closed. Empty on restore. |\n\n## `judge-regressions.jsonl`\n\nAppend a line whenever a human overrules a judge verdict: the case or intent,\nthe judge's verdict, the human's, and why. Re-run this file against the judge\nwhenever `skill:eval-judge` or the judge model changes; a rubric change that\nflips old human-settled verdicts is a judge regression, not new truth."
384207
384209
  },
384208
384210
  {
384209
384211
  name: "eval-diagnose",
@@ -384526,7 +384528,7 @@ model-owned finding deserves a probe of its own before it justifies an edit.
384526
384528
 
384527
384529
  ## Step 4: Append issue events, then stop
384528
384530
 
384529
- Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`kind: issue\`
384531
+ Append to \`<workdir>/runs/<runId>/events.jsonl\` with \`kind: issue\`
384530
384532
  (shapes in \`skill:eval-answer\` \`reference/ledger-schema.md\`):
384531
384533
 
384532
384534
  - \`issue_id\`, affected \`qids\`, \`primary_code\`, \`contributing_codes\`
@@ -385237,7 +385239,7 @@ Yours, after applying step 3 and step 4:
385237
385239
  \`\`\`json
385238
385240
  {"qid": "ecom-q-014", "question": "How did denim sell last quarter versus the one before?",
385239
385241
  "questionSha": "<stamped>", "split": "dev", "source": "questions.jsonl",
385240
- "golden": {"status": "provisional", "kind": "scalar", "value": 1830000,
385242
+ "golden": {"status": "provisional", "kind": "scalar", "value": {"denim_change": 1830000},
385241
385243
  "canonicalQuery": "SELECT ...", "verifiedBy": "authored_query",
385242
385244
  "rubric": "Reports the quarter-over-quarter change for denim, and breaks the figure out by channel."},
385243
385245
  "sourceFields": {"difficulty": "hard"}}
@@ -386031,547 +386033,8 @@ can never fire.`
386031
386033
  },
386032
386034
  {
386033
386035
  name: "eval-loop",
386034
- 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 under the model package''s evals/ directory; checkpoints are git commits of the model repo. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.",
386035
- body: `# The Evaluation Loop
386036
-
386037
- You conduct this loop. There is no batch orchestrator to start, no eval API,
386038
- and no eval MCP tools. The ledger is plain files in the model package's git
386039
- repository (\`reference/ledger-schema.md\` in \`skill:eval-answer\` defines every
386040
- file and event). Scoring is an LLM judge you spawn per case. There is no
386041
- scripted scorer, and there will not be one: a script that can pass a wrong
386042
- answer is worse than none. The scripts under \`scripts/\` run the loop -- they
386043
- answer, re-execute, spawn the judge, compare runs, and write the ledger -- but
386044
- none of them decides whether an answer was right.
386045
-
386046
- \`\`\`
386047
- scrape/run -> eval -> diagnose -> improve -> checkpoint
386048
- \`\`\`
386049
-
386050
- **This skill conducts; it does not restate.** Scoring lives in
386051
- \`skill:eval-answer\`. Components and owners live in \`skill:eval-diagnose\`.
386052
- Edit rules live in \`skill:eval-improve\`.
386053
-
386054
- Do not merge **eval** into **diagnose**. A conductor who scores while
386055
- explaining writes the explanation into the score. Do not skip the **acceptance
386056
- check** inside improve. The acceptance check decides whether *this* edit
386057
- stays. **Checkpoint** decides whether a *sequence* of accepted edits can be
386058
- undone.
386059
-
386060
- ## Where the rest of this lives
386061
-
386062
- This file is the procedure. Five things it used to carry inline are files beside
386063
- it now, because each is needed at one moment rather than every run, and loading
386064
- all of them for every run is how a skill stops being read.
386065
-
386066
- | When | Read |
386067
- |---|---|
386068
- | about to run one | \`reference/running-a-run.md\` |
386069
- | a golden is wrong, doubted, or out of step with the model | \`reference/golden-side-door.md\` |
386070
- | auditing a key you doubt, or a set you did not author | \`reference/auditing-an-answer-key.md\` |
386071
- | deciding whether an edit stays | \`reference/acceptance-check.md\` |
386072
- | about to quote a number, set the band, or read a set's flips | \`reference/measurement.md\` |
386073
- | the run finished and someone has to read it | \`skill:eval-report\` |
386074
- | you changed judge doctrine or its inputs | \`reference/checking-the-judge.md\` |
386075
-
386076
- Read the file, do not work from the summary here. The acceptance-check rules and
386077
- the golden side door are both places where acting on a half-memory of the rule
386078
- produces a confident wrong answer rather than an error.
386079
-
386080
- ## The five steps
386081
-
386082
- | Step | Job | Writes |
386083
- |---|---|---|
386084
- | **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; \`attempt\`, \`tool_call\` |
386085
- | **b. eval** | Judge the answer; score which required entities retrieval delivered | \`score\` |
386086
- | **c. diagnose** | Why it failed, who owns it | \`issue\` / \`issue_status\`. Stop. Do not edit. |
386087
- | **d. improve** | One smallest model edit, then the acceptance check | improve writes \`candidate\`; you write \`acceptance_check\`. Revert on reject. |
386088
- | **e. checkpoint** | Git commit after an accepted acceptance check | \`checkpoint\` event, then the commit |
386089
-
386090
- Every run then **reports** (below). A run whose result exists only as JSONL and
386091
- a scrolled-away console summary has not been delivered.
386092
-
386093
- **scrape** and **run** share a letter but are not the same job. Scrape writes
386094
- cases. Run writes attempts. Do not invent questions and score
386095
- them in one breath.
386096
-
386097
- **Step d is \`skill:eval-improve\`, not a hand edit plus another arm.** The
386098
- failure mode is specific and it has happened: the edits were made by hand,
386099
- published, and validated by re-running the whole arm -- with the answer key
386100
- repaired in the same window. No \`candidate\`, \`acceptance_check\` or \`checkpoint\`
386101
- event exists in that run's ledger, and the resulting move cannot be attributed
386102
- to the model, the key, or the answerer. Re-running a whole arm after changing
386103
- two things is the one thing the acceptance check exists to replace: it scores
386104
- the edit against dev AND holdout, and it is cheaper than the arm.
386105
-
386106
- ### Scrape, minimally
386107
-
386108
- Importing an existing corpus IS the scrape step: copy the set from its home
386109
- (for example a benchmarks checkout) into \`evals/<set>/\` and convert to the
386110
- ledger shapes. **\`skill:eval-import\` is that job in full** -- how to classify
386111
- what arrived with each question, why nothing imports as a verified value, and
386112
- the seal that makes a later edit to a question detectable. Read it whenever the
386113
- questions came from outside, which is most of the time. While importing:
386114
-
386115
- - Freeze each case's \`split\`: \`dev\` or \`holdout\`. Diagnose and improve read
386116
- dev cases only; the acceptance check runs both. A set that is all dev cannot defend an
386117
- accept.
386118
- - Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a
386119
- fixed bug cannot silently return.
386120
-
386121
- Scraping from production logs (chat transcripts, retrieval traces) is the
386122
- other supported source, and usually the better one: real traffic asks what
386123
- people actually ask. Where your logs physically live is a host concern; look
386124
- for a host-specific log-fetching skill. \`skill:eval-import\` takes over once
386125
- you have the text, and its \`reference/case-format.md\` covers what a log pull
386126
- needs that a question list does not.
386127
-
386128
- Prefer variety over volume when you sample, from either source. Cases that
386129
- differ in grain, source, filter shape, and phrasing are what move a
386130
- measurement; a second sample of the same case is nearly free of new
386131
- information.
386132
-
386133
- ### Mode aliases
386134
-
386135
- Older mode names still work as aliases for how far one run walks:
386136
-
386137
- | Alias | Steps |
386138
- |---|---|
386139
- | \`measure\` | scrape/run + eval |
386140
- | \`triage\` | plus diagnose |
386141
- | \`improve\` | plus improve + acceptance check + checkpoint on accept |
386142
-
386143
- Say which alias (or which steps) you are running before the first question.
386144
- Record it in \`run.json\`. Do not mix steps in a way that lets the answerer see
386145
- gold, issues, or the model file.
386146
-
386147
- Most runs should stop after eval. Diagnose when you need a histogram of
386148
- components and owners. Improve only for diagnosed *model* gaps, one batch at
386149
- a time. Checkpoint only after the acceptance check **accepts**.
386150
-
386151
- ## Roles
386152
-
386153
- | Role | Sees |
386154
- |---|---|
386155
- | **Answerer** | The question and the Malloy tools. Never the golden, \`evals/\`, the model file, or any hint it is being evaluated. |
386156
- | **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (\`skill:eval-judge\`). |
386157
- | **You (conductor / improver)** | Everything, including goldens and traces. |
386158
- | **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |
386159
-
386160
- The answerer stays blind. That is not optional. A grader-visible answerer
386161
- writes toward the expected answer, and the score is fiction.
386162
-
386163
- There are no eval MCP tools on purpose. The answerer inherits your tools,
386164
- including Shell and Read, so any eval convenience surface would also be a
386165
- gold path for it. Blindness is prevention plus detection, not a guarantee:
386166
- \`eval-answer\` runs the contamination checklist on every attempt, which is
386167
- why you keep a host-side tool-use log per answerer.
386168
-
386169
- ## Pick the target first
386170
-
386171
- Both a local model server and a hosted platform expose the same two tools the
386172
- answerer needs, \`get_context\` and \`execute_query\`, so the loop runs against
386173
- either. What differs is which model is answering and whose data it reads, and
386174
- those are two separate axes:
386175
-
386176
- | Target | Model under test | Data | Can edit and re-test? |
386177
- |---|---|---|---|
386178
- | **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |
386179
- | **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |
386180
- | **Remote** | the published version, through the platform's hosted \`get_context\`/\`execute_query\` | the platform's | no, publishing is not an eval action |
386181
-
386182
- The middle row is the one worth knowing about: it decouples the two axes, so you
386183
- can evaluate a model you are still editing against the customer's real data. It
386184
- is a connection configuration, not a feature.
386185
-
386186
- ### Three ways to reach the tools
386187
-
386188
- That table is about which MODEL answers. A second, independent choice is where
386189
- the answerer's MCP tools come from, and \`--mcp-url\` is the whole of it:
386190
-
386191
- | | \`--target\` | \`--mcp-url\` | Auth |
386192
- |---|---|---|---|
386193
- | **1. Local Publisher** | \`local\` | \`http://localhost:4040/mcp\` (default) | none |
386194
- | **2. Hosted, through an editor extension's local bridge** | \`platform\` | the localhost URL the extension prints | none: the extension holds the credential |
386195
- | **3. Hosted, directly** | \`platform\` | the host's \`https\` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |
386196
-
386197
- \`\`\`bash
386198
- # 1. local
386199
- --target local # --mcp-url defaults to the local Publisher
386200
-
386201
- # 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:
386202
- # the same proxy may front a local Publisher instead
386203
- --target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\
386204
- --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
386205
-
386206
- # 3. hosted directly -- authenticate first, under the SAME server name
386207
- claude mcp add --transport http <name> <scoped-url>
386208
- claude # /mcp -> <name> -> Authenticate
386209
- --target platform --mcp-url <scoped-url> \\
386210
- --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>
386211
- \`\`\`
386212
-
386213
- All three hand the answerer the same three capabilities (\`get_context\`,
386214
- \`execute_query\`, and the docs search), so a comparison between them is between
386215
- agents that could do the same things; \`test_the_two_arms_hold_the_same_capabilities\`
386216
- pins it. Modes 2 and 3 take those names from \`--hosted-tools\`, which defaults to
386217
- the bare trio; pass it only if this host names them differently.
386218
-
386219
- A platform run left on the local default \`--mcp-url\` is refused rather than
386220
- probed, because pointing every answerer at a local Publisher measures a
386221
- different model over different data than the run claims.
386222
-
386223
- Two rules follow, and both are the kind of mistake that produces confident
386224
- nonsense rather than an error:
386225
-
386226
- - **The answerer and the conductor must hit the same target.** If the answerer
386227
- queries the published model and you re-execute its query against your edited
386228
- local copy, the score describes neither. Decide the target before the first
386229
- question and record it.
386230
- - **Pin the version the target actually served, not the one you happen to have.**
386231
- A local target pins a commit; a platform target pins the published version.
386232
- Recording a local commit for a run that queried a published model is a pin
386233
- that means nothing.
386234
-
386235
- Which target for which job:
386236
-
386237
- - **Baseline what customers experience:** Remote. It is the deployed model
386238
- through the deployed engine, which is the thing they actually hit. The judge
386239
- sees no re-executed rows on a Remote run (there is no local copy of the
386240
- bytes), so its verdicts rest on the answer text and the golden; say so.
386241
- - **Improve and accept:** local, because the acceptance check needs compile,
386242
- reload, and a fresh re-answer between edits. Publishing to a customer
386243
- environment to score an edit is not something this loop does. Where the host
386244
- offers draft execution, that counts as local for this purpose.
386245
- - **Measure real data without touching production:** local proxied.
386246
-
386247
- So a measure-only run can use any target; a run that includes **improve** needs
386248
- a local one.
386249
-
386250
- Two things to check before a platform run, because neither errors and both make
386251
- the run measure something other than what it names:
386252
-
386253
- - **The answerer's skills must be written for THIS host.** A shared skill names
386254
- an MCP tool by its bare name (\`get_context\`) so it reads correctly anywhere,
386255
- but a host/router skill names its own host's tools directly. Install the
386256
- latter for the wrong host and the answerer is told to call tools it does not
386257
- have. \`run_baseline.py\` warns when the manifest it loaded names Publisher-only
386258
- tools on a platform target; point \`--answerer-manifest\`, or \`--skills-root\`,
386259
- at the checkout that ships this host's manifest.
386260
- - **The tool names are configuration.** \`--hosted-mcp-server\` is both the
386261
- \`mcp__<server>__<tool>\` prefix and the OAuth cache key, so it has to match the
386262
- name the answerer authenticated under, and \`--hosted-tools\` lists the bare
386263
- tools that host exposes.
386264
- - **Get the hosted tools in front of a headless answerer, one of two ways.**
386265
- A spawned answerer cannot complete an OAuth flow, so the tools have to be
386266
- reachable before the run starts. \`run_baseline.py\` proves it with one cheap
386267
- probe and refuses to spend an arm otherwise -- a run whose answerers have no
386268
- tools does not error, it reads as a terrible model.
386269
-
386270
- 1. **Authenticate once, interactively.** Works anywhere, including a plain
386271
- CLI install, and is the route to assume unless you know otherwise. The
386272
- token is cached per server NAME, so authenticate under the same name the
386273
- run passes to \`--hosted-mcp-server\`:
386274
-
386275
- \`\`\`bash
386276
- claude mcp add --transport http <name> <scoped-url>
386277
- claude # then /mcp -> <name> -> Authenticate
386278
- \`\`\`
386279
-
386280
- Then come back and run. This is a hand-off to a person; there is no
386281
- headless equivalent, so plan for it rather than discovering it mid-run.
386282
-
386283
- 2. **A local proxy that already holds the credential.** Some hosts ship an
386284
- editor extension whose local MCP proxy can expose the hosted
386285
- \`get_context\` / \`execute_query\` -- often behind a setting that is off by
386286
- default. Where that exists, point \`--mcp-url\` at the proxy on localhost
386287
- and no OAuth step is needed, because the extension holds it. Check what
386288
- the proxy actually exposes before relying on it: the same proxy may serve
386289
- a local Publisher's tools instead, and then \`--hosted-tools\` is
386290
- naming tools that are not there. This route is not available to someone
386291
- running the CLI alone.
386292
-
386293
- - **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is
386294
- usually reachable two ways: a global endpoint where every call carries an
386295
- organization and workspace, and a scoped one where the URL itself is the
386296
- scope. \`--scope\` and the prompt can only ASK an answerer to stay in one
386297
- package; a scoped URL enforces it. For an agent being measured that is the
386298
- difference between a case answered against the package it names and one
386299
- answered against whatever else the account can see. Authenticate once
386300
- interactively (\`claude\`, \`/mcp\`) under the same server name the run will use;
386301
- the token is cached per name, and a spawned headless answerer cannot complete
386302
- an OAuth flow.
386303
-
386304
- - **Pin the VERSION in the scope, not just the package.** Write
386305
- \`--scope <env>/<package>@<version>\`. Both hosted tools take a version and both
386306
- document the same default for an omitted one: the PINNED version, which is
386307
- whatever the workspace serves at the moment of the call. So an unversioned run
386308
- records \`targetVersion\` in \`run.json\` and then answers from whatever is
386309
- current, and the two part company the moment anyone publishes -- including
386310
- mid-run, which measures two builds under one label. \`--target-version\` fills
386311
- the version in when the scope omits it, so a platform run is pinned without
386312
- opting in; a scope naming a different version is refused rather than taken as
386313
- an override.
386314
-
386315
- ## Before you start
386316
-
386317
- 1. The model package under evaluation must live in a git repository, with
386318
- \`evals/<set>/\` in the package, beside the model files. Git is the checkpoint
386319
- mechanism; without it there is no rollback and no run can include improve.
386320
-
386321
- Keeping the set IN the package is what stops a model edit and its answer key
386322
- drifting apart: they move in one commit, so fixing a measure and forgetting
386323
- the golden that depended on it stops being possible. It is safe -- measured
386324
- on a running server, a \`cases.jsonl\` inside a package appears in no model
386325
- listing, no notebook listing, no package resource, and 404s over HTTP, so an
386326
- MCP-only answerer has no route to it.
386327
-
386328
- What it buys differs by target. On a LOCAL Publisher it does not get you free
386329
- versioning -- \`sourceContentSha\` hashes model paths only, so the set needs
386330
- its own \`datasetSha\`. On a hosted target that publishes the whole package
386331
- directory as an IMMUTABLE version, the set rides inside that version and
386332
- \`targetVersion\` pins model and answer key together; nothing can be edited
386333
- under a published version, which is what makes it a pin. Check which you have
386334
- before deciding how much of this you need.
386335
-
386336
- **Look for a set and for prior runs before you author either.** A minute of
386337
- \`find . -name cases.jsonl\`, a glance at \`evals/*/runs/\` and at your host's
386338
- own transcripts for this repo. Two sessions fourteen minutes apart built the
386339
- same 29-case answer key from scratch, because the first had committed
386340
- nothing before it was deleted and the second had no way to know it existed.
386341
- Roughly a working day was spent twice, and three specific things were
386342
- rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a
386343
- judge-rendering bug that had already cost $17 of arm once. The two keys,
386344
- independently authored from the same questions, differ by about ten points
386345
- on comparable answers -- which is the available measure of how much a key
386346
- depends on its author, and a reason to reuse one rather than rebuild it.
386347
-
386348
- **Audit the set's entity ids before the first arm.** \`verify_goldens.py\`
386349
- checks that each id names something in the model; \`check_findable.py\` checks
386350
- that a search of its own kind actually returns it. Both are free of model
386351
- calls. An id that fails either scores a retrieval miss on every run, and the
386352
- miss reads as the model's fault.
386353
-
386354
- **Commit the set before you spend money on an arm**, and keep durable
386355
- outputs in the repository. A findings document in \`~/Downloads\` is gone the
386356
- first time somebody tidies up; the set, the run directory and the write-up
386357
- belong in git beside the model.
386358
-
386359
- 2. The server must be up. Where your host offers retrieval tracing, turn it on
386360
- and confirm a trace lookup answers, so a call's ranked results can be
386361
- recovered afterwards.
386362
-
386363
- **Open-source Publisher has none.** \`PUBLISHER_MCP_TRACE=retrieval\` is set by
386364
- \`serve.py\` and read by nothing: the trace store was written and never merged,
386365
- so there is no trace tool and \`traceId\` is null on every local attempt. This
386366
- does not block a scored run, because attribution never depended on it:
386367
- \`rankedSummary\` is copied onto the \`tool_call\` event at capture, precisely so
386368
- the evidence survives without a store. So do not refuse a local run for want
386369
- of tracing -- an earlier version of this rule did, and it refused every local
386370
- run there has ever been. Refuse one whose \`tool_call\` events carry no
386371
- \`rankedSummary\`, which is what attribution actually reads.
386372
-
386373
- 3. Health-check: your host's status check until it reports serving, and inspect
386374
- \`loadErrors\`. A dead database that still answers HTTP is an environment
386375
- failure, not a model failure. Stop and fix it. Four consecutive
386376
- environment or no-result attempts means stop the run.
386377
-
386378
- 4. Load the set: scrape/import as above, or reuse an existing \`evals/<set>/\`.
386379
- Never keep two live copies of one set; the set directory in the model repo
386380
- is the single source of truth, versioned by \`datasetVersion\` in
386381
- \`set.json\`.
386382
-
386383
- 5. Review goldens before you score. **Check how many cases can take a verdict
386384
- at all**, not just how many cases there are: a golden the set stamps
386385
- \`provisional\`, \`invalid\` or \`ambiguous\`, or a case with no golden, scores
386386
- \`verdict: null\` and stays out of the pass rate. An imported set is
386387
- \`provisional\` throughout by design (\`skill:eval-import\`), and the only thing
386388
- that changes that is \`verify_goldens.py --promote\` after a re-derivation
386389
- through the truth package. A run whose every key is underived is refused
386390
- rather than spent; a set of bare questions runs, because its answers are
386391
- what keys get derived from. A verified golden that holds a value but
386392
- no local artifact stays verified by provenance and is not scorable until you
386393
- have rows or a scalar to compare (the judge needs both sides). A \`criteria\`
386394
- golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks
386395
- \`BAD-REFERENCE\` or \`AMBIGUOUS-REFERENCE\`, follow
386396
- \`reference/golden-side-door.md\`. Both are expected in the wild; both
386397
- are the golden side door below, not improve, and not a sixth step.
386398
-
386399
- 6. Create \`runs/<runId>/run.json\` with the attribution pins
386400
- (\`reference/ledger-schema.md\`): mode, dataset version, **the target and the
386401
- version it served** (a local target pins a commit, so commit or stash first;
386402
- answering from a dirty tree pins nothing), server version, judge version and
386403
- rubric sha, answerer model, call budget, trace mode. Freeze those for the
386404
- whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
386405
- model.
386406
-
386407
- The call budget is \`--max-turns\`, written to \`run.json\` as \`maxTurns\`.
386408
- **Size it from a pilot rather than taking the default of 30.** Run the three
386409
- cheapest cases uncapped (\`--max-turns 100\`) and set the cap at twice their
386410
- maximum. A set whose questions need two or three sources joined does not fit
386411
- a cap sized for single-source lookups: on one such set the completed
386412
- attempts had a median of 17 turns and a 90th percentile of 26 against a cap
386413
- of 30, and four cases died at it. A cap 15% above the 90th percentile of
386414
- completed work is not a safety margin. Note also that \`malloy-analysis\` tells
386415
- the answerer to persist -- retry a phrasing, let a small query settle whether
386416
- a field exists -- so a tight cap and that instruction are in direct conflict.
386417
-
386418
- **Do not change the model and the measuring instrument in the same step.**
386419
- Those are two different axes and only one of them is cheap to separate.
386420
-
386421
- Batching MODEL edits is fine and expected. Clustering exists so that one
386422
- edit closes several cases, and an arm per fix does not survive contact with
386423
- arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured
386424
- $0.33 a case on a proxied warehouse that is $660 of answering to attribute
386425
- what the acceptance check attributes for the price of the affected cases
386426
- plus holdout. Budget five arms for a defensible claim (a baseline, two for
386427
- the A/A, two post-edit), not one per edit, and let \`skill:eval-improve\`
386428
- carry each cluster.
386429
-
386430
- The instrument is the other axis: the answer key, the judge and its prompt,
386431
- the answerer model, the skills. Move one of those together with the model
386432
- and there is nothing left holding still, so the result measures neither. On
386433
- the run this comes from, nine model commits and a re-derived answer key
386434
- landed between two arms, and the move from 20% to 39% belongs to no one --
386435
- not because two model edits were batched, but because the ruler changed at
386436
- the same time as the thing being measured. A key repair mid-improve is not
386437
- forbidden; it ends that comparison, so re-baseline rather than quoting a
386438
- delta across it.
386439
-
386440
- The answerer model is instrument too, and it is the one most often left
386441
- unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one
386442
- model and 8 / 4 / 15 on a smaller one. No statement about "the agent's"
386443
- capability means anything until two arms name the same answerer.
386444
-
386445
- 7. Generate every answerer prompt from the stored case in \`cases.jsonl\`.
386446
- Never retype the question. A truncated retype is indistinguishable from a
386447
- real question downstream.
386448
-
386449
- ## Per question
386450
-
386451
- 1. Health-check again.
386452
- 2. Spawn a *fresh* blind subagent. Give it only the question text and the
386453
- Malloy analysis tools. Tell it to follow the \`malloy-analysis\` skill. Do not
386454
- mention eval, gold, scoring, or this skill.
386455
- 3. Keep a host-side tool-use log for that subagent (name, input path or
386456
- command, MCP tool name). Publisher traces see MCP only; a Read of a gold
386457
- CSV is invisible server-side.
386458
- 4. \`skill:eval-answer\`: contamination first, then re-execute, then the judge,
386459
- then events.
386460
- 5. \`skill:eval-diagnose\` only when this run includes diagnose, only on dev
386461
- cases, and only after the score event exists.
386462
- 6. \`skill:eval-improve\` only when this run includes improve, and only for
386463
- \`owner: model\`. Then run the acceptance check. On accept, checkpoint.
386464
-
386465
- ## Report the run, or nobody can read it
386466
-
386467
- A run directory is JSONL. It is a record, not a result, and the console summary
386468
- scrolls away. **Every run ends by producing something a person can open**, and
386469
- that job is \`skill:eval-report\`: it builds the servable run package (the case
386470
- matrix app and the aggregate notebook) and gives the template for the write-up.
386471
-
386472
- Read it at the end of every run, including a run that failed. The standing
386473
- complaint about this loop is that "a bunch of stuff happens and it is hard to
386474
- know the actual results", and a ledger nobody renders is why.
386475
-
386476
- Two rules from it are worth repeating here, because they are the ones a
386477
- conductor skips:
386478
-
386479
- - **Separate a MODEL failure from an EVAL failure.** A wrong answer and a
386480
- broken measurement look identical in a pass rate and have nothing else in
386481
- common. An arm holding a truncated, contaminated or environment-failed
386482
- attempt has no rate to quote at all.
386483
- - **Say when a step did not run, and why.** \`improve\` not running because every
386484
- cluster came back \`owner: agent-skill\` is a RESULT, and it reads identically
386485
- to having forgotten unless it is written down.
386486
-
386487
- Keep the write-up in the repository beside the set, not in a chat log and not
386488
- in \`~/Downloads\`.
386489
-
386490
- ## Checkpoint
386491
-
386492
- A checkpoint is a git commit of the model repository, taken after an acceptance check
386493
- accepts, so a bad improve direction can be rolled back. It is not a report,
386494
- and it is not a remote publish.
386495
-
386496
- 1. Commit the model files AND the set's ledger in one commit; put the label
386497
- and the closed issue ids in the message.
386498
- 2. Append the \`checkpoint\` event (\`action: created\`, label, \`modelGitSha\`
386499
- from the commit you just made, issueIds). The event line itself rides in
386500
- the next commit; append-only logs trail by one commit and that is fine.
386501
- 3. Confirm \`git status\` is clean for the model files.
386502
-
386503
- **Restore**: \`git checkout <sha> -- <model files>\` (or \`git revert\` the
386504
- checkpoint commits), then reload the package, then append a \`checkpoint\`
386505
- event with \`action: restored\` and the sha. Readers return to the model that
386506
- existed before the bad direction.
386507
-
386508
- Take a checkpoint of the current model *before* the first improve batch if no
386509
- commit pins it yet. Rolling back by hand is guesswork.
386510
-
386511
- If reload reports \`mode: reinstalled\`, the package was re-fetched from its
386512
- install location and may have overwritten the restored files. Prefer in-place
386513
- / watch-mounted packages for this loop.
386514
-
386515
- ## Out of scope
386516
-
386517
- This loop is local. The ledger is files, the checkpoints are git, you are the
386518
- conductor. Do not:
386519
-
386520
- - publish the model to a hosted platform as a "true" checkpoint or learning
386521
- curve
386522
- - start a Python orchestrator (\`loop.py\`, \`run_all.py\`, \`improve_batch.py\`)
386523
- that runs the five steps end to end unattended. You conduct; the scripts are
386524
- the steps, not the sequencing. There are more than twenty of them and they
386525
- are not the exception to this: each does one step you invoke and hands back a
386526
- result you read. What is forbidden is a script that decides what to do next,
386527
- because every judgement this loop protects lives in that decision
386528
- - score by string-diffing rows instead of judging them, or reintroduce a
386529
- scripted row oracle: one that can pass a wrong answer is worse than none
386530
- - wait for a bigger gold set before the loop can run; dev/holdout on what
386531
- exists beats waiting
386532
- - register eval MCP tools or stand up an eval API
386533
- - encode unsettled goldens into the model
386534
-
386535
- ## Prime directives
386536
-
386537
- - The model is the only thing improve edits. No question text, qids, or
386538
- expected values in any name, doc, or comment.
386539
- - **You are measuring a model, not reviewing this harness.** Read a script when
386540
- a number you have to report cannot be explained otherwise, and stop there. Do
386541
- not audit the scripts, propose fixes to them, or hand back tooling critique
386542
- in place of a result -- a run that ends in harness feedback has not answered
386543
- the question it was asked. A defect that changed a number gets one sentence
386544
- in the report; a defect that changed nothing gets one line in chat, once.
386545
- Fixing it is a different task, and the user starts it.
386546
- - When the environment misbehaves, stop. Never diagnose a sick system. The
386547
- harness is part of the environment: a known-broken measurement does not
386548
- become quotable by being finished. Measured against this directive, an arm
386549
- already paid for gets quoted anyway -- it happened three times in one run,
386550
- once with the confound stated in the same message that started the arm -- so
386551
- the harness now enforces the parts it can. A run with a truncated or
386552
- contaminated attempt prints no pass rate and records \`status: incomplete\`,
386553
- and \`flip_table.py\` names what one arm left unscored that the other did not.
386554
- Read \`run_error\` on the attempts
386555
- and the INCOMPLETE line before quoting any number.
386556
- - When a subagent disagrees with you, probe. Do not win by authority.
386557
- - When a rule here is wrong, change this file and note it on the run.
386558
-
386559
- ## Related skills
386560
-
386561
- - \`skill:eval-answer\`: contamination, judge protocol, events. Its
386562
- \`reference/ledger-schema.md\` is the file contract; \`skill:eval-judge\` is
386563
- the judge.
386564
- - \`skill:eval-diagnose\`: component, owner, issue events. No edit.
386565
- - \`skill:eval-improve\`: smallest model edit, probe receipts, no self-accept.
386566
- - \`skill:eval-report\`: the run package and the write-up a person reads.
386567
- - The \`malloy-analysis\` skill: what the blind answerer follows. It is installed
386568
- from the \`analysis\` manifest group, not the \`eval\` group.
386569
-
386570
- ## Reference files over MCP
386571
-
386572
- This skill's \`reference/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`reference/<name>.md\`, get the prompt named \`eval-loop/<name>\` instead.
386573
-
386574
- Available: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run.`
386036
+ 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.",
386037
+ body: "# The Evaluation Loop\n\nYou conduct this loop. There is no batch orchestrator to start, no eval API,\nand no eval MCP tools. The ledger is plain files: the set in the model\npackage's git repository, and each run in the set's workdir\n(`reference/ledger-schema.md` in `skill:eval-answer` defines every file and\nevent). `scripts/eval.py` runs each step from the set's `eval.toml`;\n`reference/running-a-run.md` has the commands. Scoring is an LLM judge you spawn per case. There is no\nscripted scorer, and there will not be one: a script that can pass a wrong\nanswer is worse than none. The scripts under `scripts/` run the loop -- they\nanswer, re-execute, spawn the judge, compare runs, and write the ledger -- but\nnone of them decides whether an answer was right.\n\n```\nscrape/run -> eval -> diagnose -> improve -> checkpoint\n```\n\n**This skill conducts; it does not restate.** Scoring lives in\n`skill:eval-answer`. Components and owners live in `skill:eval-diagnose`.\nEdit rules live in `skill:eval-improve`.\n\nDo not merge **eval** into **diagnose**. A conductor who scores while\nexplaining writes the explanation into the score. Do not skip the **acceptance\ncheck** inside improve. The acceptance check decides whether *this* edit\nstays. **Checkpoint** decides whether a *sequence* of accepted edits can be\nundone.\n\n## Where the rest of this lives\n\nThis file is the procedure. The things it used to carry inline are files beside\nit now, because each is needed at one moment rather than every run, and loading\nall of them for every run is how a skill stops being read.\n\n| When | Read |\n|---|---|\n| the set does not exist yet, or has never run | `reference/setting-up-a-set.md` |\n| about to run one | `reference/running-a-run.md` |\n| a golden is wrong, doubted, or out of step with the model | `reference/golden-side-door.md` |\n| auditing a key you doubt, or a set you did not author | `reference/auditing-an-answer-key.md` |\n| deciding whether an edit stays | `reference/acceptance-check.md` |\n| about to quote a number, set the band, or read a set's flips | `reference/measurement.md` |\n| the run finished and someone has to read it | `skill:eval-report` |\n| you changed judge doctrine or its inputs | `reference/checking-the-judge.md` |\n\nRead the file, do not work from the summary here. The acceptance-check rules and\nthe golden side door are both places where acting on a half-memory of the rule\nproduces a confident wrong answer rather than an error.\n\n## The five steps\n\n| Step | Job | Writes |\n|---|---|---|\n| **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; `attempt`, `tool_call` |\n| **b. eval** | Judge the answer; score which required entities retrieval delivered | `score` |\n| **c. diagnose** | Why it failed, who owns it | `issue` / `issue_status`. Stop. Do not edit. |\n| **d. improve** | One smallest model edit, then the acceptance check | improve writes `candidate`; you write `acceptance_check`. Revert on reject. |\n| **e. checkpoint** | Git commit after an accepted acceptance check | `checkpoint` event, then the commit |\n\nEvery run then **reports** (below). A run whose result exists only as JSONL and\na scrolled-away console summary has not been delivered.\n\n**scrape** and **run** share a letter but are not the same job. Scrape writes\ncases. Run writes attempts. Do not invent questions and score\nthem in one breath.\n\n**Step d is `skill:eval-improve`, not a hand edit plus another arm.** The\nfailure mode is specific and it has happened: the edits were made by hand,\npublished, and validated by re-running the whole arm -- with the answer key\nrepaired in the same window. No `candidate`, `acceptance_check` or `checkpoint`\nevent exists in that run's ledger, and the resulting move cannot be attributed\nto the model, the key, or the answerer. Re-running a whole arm after changing\ntwo things is the one thing the acceptance check exists to replace: it scores\nthe edit against dev AND holdout, and it is cheaper than the arm.\n\n### Scrape, minimally\n\nImporting an existing corpus IS the scrape step: copy the set from its home\n(for example a benchmarks checkout) into `evals/<set>/` and convert to the\nledger shapes. **`skill:eval-import` is that job in full** -- how to classify\nwhat arrived with each question, why nothing imports as a verified value, and\nthe seal that makes a later edit to a question detectable. Read it whenever the\nquestions came from outside, which is most of the time. While importing:\n\n- Freeze each case's `split`: `dev` or `holdout`. Diagnose and improve read\n dev cases only; the acceptance check runs both. A set that is all dev cannot defend an\n accept.\n- Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a\n fixed bug cannot silently return.\n\nScraping from production logs (chat transcripts, retrieval traces) is the\nother supported source, and usually the better one: real traffic asks what\npeople actually ask. Where your logs physically live is a host concern; look\nfor a host-specific log-fetching skill. `skill:eval-import` takes over once\nyou have the text, and its `reference/case-format.md` covers what a log pull\nneeds that a question list does not.\n\nPrefer variety over volume when you sample, from either source. Cases that\ndiffer in grain, source, filter shape, and phrasing are what move a\nmeasurement; a second sample of the same case is nearly free of new\ninformation.\n\n### Mode aliases\n\nOlder mode names still work as aliases for how far one run walks:\n\n| Alias | Steps |\n|---|---|\n| `measure` | scrape/run + eval (`eval.py package` then needs `--without-diagnosis`) |\n| `triage` | plus diagnose |\n| `improve` | plus improve + acceptance check + checkpoint on accept |\n\nSay which alias (or which steps) you are running before the first question.\nRecord it in `run.json`. Do not mix steps in a way that lets the answerer see\ngold, issues, or the model file.\n\nMost runs should stop after eval. Diagnose when you need a histogram of\ncomponents and owners. Improve only for diagnosed *model* gaps, one batch at\na time. Checkpoint only after the acceptance check **accepts**.\n\n## Roles\n\n| Role | Sees |\n|---|---|\n| **Answerer** | The question and the Malloy tools. Never the golden, `evals/`, the model file, or any hint it is being evaluated. |\n| **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (`skill:eval-judge`). |\n| **You (conductor / improver)** | Everything, including goldens and traces. |\n| **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |\n\nThe answerer stays blind. That is not optional. A grader-visible answerer\nwrites toward the expected answer, and the score is fiction.\n\nThere are no eval MCP tools on purpose. The answerer inherits your tools,\nincluding Shell and Read, so any eval convenience surface would also be a\ngold path for it. Blindness is prevention plus detection, not a guarantee:\n`eval-answer` runs the contamination checklist on every attempt, which is\nwhy you keep a host-side tool-use log per answerer.\n\n## Pick the target first\n\nBoth a local model server and a hosted platform expose the same two tools the\nanswerer needs, `get_context` and `execute_query`, so the loop runs against\neither. What differs is which model is answering and whose data it reads, and\nthose are two separate axes:\n\n| Target | Model under test | Data | Can edit and re-test? |\n|---|---|---|---|\n| **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |\n| **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |\n| **Remote** | the published version, through the platform's hosted `get_context`/`execute_query` | the platform's | no, publishing is not an eval action |\n\nThe middle row is the one worth knowing about: it decouples the two axes, so you\ncan evaluate a model you are still editing against the customer's real data. It\nis a connection configuration, not a feature.\n\n### Three ways to reach the tools\n\nThat table is about which MODEL answers. A second, independent choice is where\nthe answerer's MCP tools come from, and `--mcp-url` is the whole of it:\n\n| | `--target` | `--mcp-url` | Auth |\n|---|---|---|---|\n| **1. Local Publisher** | `local` | `http://localhost:4040/mcp` (default) | none |\n| **2. Hosted, through an editor extension's local bridge** | `platform` | the localhost URL the extension prints | none: the extension holds the credential |\n| **3. Hosted, directly** | `platform` | the host's `https` endpoint, scoped if it offers one | a cached OAuth login, once, interactively |\n\n```bash\n# 1. local\n--target local # --mcp-url defaults to the local Publisher\n\n# 2. hosted via the extension's bridge -- no OAuth, but check what it exposes:\n# the same proxy may front a local Publisher instead\n--target platform --mcp-url http://localhost:<port-the-extension-prints>/mcp \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n\n# 3. hosted directly -- authenticate first, under the SAME server name\nclaude mcp add --transport http <name> <scoped-url>\nclaude # /mcp -> <name> -> Authenticate\n--target platform --mcp-url <scoped-url> \\\n --hosted-mcp-server <name> --target-version <v> --scope <env>/<pkg>@<v>\n```\n\nAll three hand the answerer the same three capabilities (`get_context`,\n`execute_query`, and the docs search), so a comparison between them is between\nagents that could do the same things; `test_the_two_arms_hold_the_same_capabilities`\npins it. Modes 2 and 3 take those names from `--hosted-tools`, which defaults to\nthe bare trio; pass it only if this host names them differently.\n\nA platform run left on the local default `--mcp-url` is refused rather than\nprobed, because pointing every answerer at a local Publisher measures a\ndifferent model over different data than the run claims.\n\nTwo rules follow, and both are the kind of mistake that produces confident\nnonsense rather than an error:\n\n- **The answerer and the conductor must hit the same target.** If the answerer\n queries the published model and you re-execute its query against your edited\n local copy, the score describes neither. Decide the target before the first\n question and record it.\n- **Pin the version the target actually served, not the one you happen to have.**\n A local target pins a commit; a platform target pins the published version.\n Recording a local commit for a run that queried a published model is a pin\n that means nothing.\n\nWhich target for which job:\n\n- **Baseline what customers experience:** Remote. It is the deployed model\n through the deployed engine, which is the thing they actually hit. The judge\n sees no re-executed rows on a Remote run (there is no local copy of the\n bytes), so its verdicts rest on the answer text and the golden; say so.\n- **Improve and accept:** local, because the acceptance check needs compile,\n reload, and a fresh re-answer between edits. Publishing to a customer\n environment to score an edit is not something this loop does. Where the host\n offers draft execution, that counts as local for this purpose.\n- **Measure real data without touching production:** local proxied.\n\nSo a measure-only run can use any target; a run that includes **improve** needs\na local one.\n\nTwo things to check before a platform run, because neither errors and both make\nthe run measure something other than what it names:\n\n- **The answerer's skills must be written for THIS host.** A shared skill names\n an MCP tool by its bare name (`get_context`) so it reads correctly anywhere,\n but a host/router skill names its own host's tools directly. Install the\n latter for the wrong host and the answerer is told to call tools it does not\n have. `run_baseline.py` warns when the manifest it loaded names Publisher-only\n tools on a platform target; point `--answerer-manifest`, or `--skills-root`,\n at the checkout that ships this host's manifest.\n- **The tool names are configuration.** `--hosted-mcp-server` is both the\n `mcp__<server>__<tool>` prefix and the OAuth cache key, so it has to match the\n name the answerer authenticated under, and `--hosted-tools` lists the bare\n tools that host exposes.\n- **Get the hosted tools in front of a headless answerer, one of two ways.**\n A spawned answerer cannot complete an OAuth flow, so the tools have to be\n reachable before the run starts. `run_baseline.py` proves it with one cheap\n probe and refuses to spend an arm otherwise -- a run whose answerers have no\n tools does not error, it reads as a terrible model.\n\n 1. **Authenticate once, interactively.** Works anywhere, including a plain\n CLI install, and is the route to assume unless you know otherwise. The\n token is cached per server NAME, so authenticate under the same name the\n run passes to `--hosted-mcp-server`:\n\n ```bash\n claude mcp add --transport http <name> <scoped-url>\n claude # then /mcp -> <name> -> Authenticate\n ```\n\n Then come back and run. This is a hand-off to a person; there is no\n headless equivalent, so plan for it rather than discovering it mid-run.\n\n 2. **A local proxy that already holds the credential.** Some hosts ship an\n editor extension whose local MCP proxy can expose the hosted\n `get_context` / `execute_query` -- often behind a setting that is off by\n default. Where that exists, point `--mcp-url` at the proxy on localhost\n and no OAuth step is needed, because the extension holds it. Check what\n the proxy actually exposes before relying on it: the same proxy may serve\n a local Publisher's tools instead, and then `--hosted-tools` is\n naming tools that are not there. This route is not available to someone\n running the CLI alone.\n\n- **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is\n usually reachable two ways: a global endpoint where every call carries an\n organization and workspace, and a scoped one where the URL itself is the\n scope. `--scope` and the prompt can only ASK an answerer to stay in one\n package; a scoped URL enforces it. For an agent being measured that is the\n difference between a case answered against the package it names and one\n answered against whatever else the account can see. Authenticate once\n interactively (`claude`, `/mcp`) under the same server name the run will use;\n the token is cached per name, and a spawned headless answerer cannot complete\n an OAuth flow.\n\n- **Pin the VERSION in the scope, not just the package.** Write\n `--scope <env>/<package>@<version>`. Both hosted tools take a version and both\n document the same default for an omitted one: the PINNED version, which is\n whatever the workspace serves at the moment of the call. So an unversioned run\n records `targetVersion` in `run.json` and then answers from whatever is\n current, and the two part company the moment anyone publishes -- including\n mid-run, which measures two builds under one label. `--target-version` fills\n the version in when the scope omits it, so a platform run is pinned without\n opting in; a scope naming a different version is refused rather than taken as\n an override.\n\n## Before you start\n\n1. The model package under evaluation must live in a git repository, with\n `evals/<set>/` in the package, beside the model files. Git is the checkpoint\n mechanism; without it there is no rollback and no run can include improve.\n\n Keeping the set IN the package is what stops a model edit and its answer key\n drifting apart: they move in one commit, so fixing a measure and forgetting\n the golden that depended on it stops being possible. It is safe -- measured\n on a running server, a `cases.jsonl` inside a package appears in no model\n listing, no notebook listing, no package resource, and 404s over HTTP, so an\n MCP-only answerer has no route to it.\n\n What it buys differs by target. On a LOCAL Publisher it does not get you free\n versioning -- `sourceContentSha` hashes model paths only, so the set needs\n its own `datasetSha`. On a hosted target that publishes the whole package\n directory as an IMMUTABLE version, the set rides inside that version and\n `targetVersion` pins model and answer key together; nothing can be edited\n under a published version, which is what makes it a pin. Check which you have\n before deciding how much of this you need.\n\n **Look for a set and for prior runs before you author either.** A minute of\n `find . -name cases.jsonl`, a glance at the set's workdir (`<workdir>/runs/`,\n `~/.malloy-eval/<set>-<hash>/runs/` unless `eval.toml` moves it) and at your host's\n own transcripts for this repo. Two sessions fourteen minutes apart built the\n same 29-case answer key from scratch, because the first had committed\n nothing before it was deleted and the second had no way to know it existed.\n Roughly a working day was spent twice, and three specific things were\n rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a\n judge-rendering bug that had already cost $17 of arm once. The two keys,\n independently authored from the same questions, differ by about ten points\n on comparable answers -- which is the available measure of how much a key\n depends on its author, and a reason to reuse one rather than rebuild it.\n\n **Audit the set's entity ids before the first arm.** `verify_goldens.py`\n checks that each id names something in the model; `check_findable.py` checks\n that a search of its own kind actually returns it. Both are free of model\n calls. An id that fails either scores a retrieval miss on every run, and the\n miss reads as the model's fault.\n\n **Commit the set before you spend money on an arm**, and keep durable\n outputs in the repository. A findings document in `~/Downloads` is gone the\n first time somebody tidies up; the set and the write-up belong in git\n beside the model.\n\n **Runs go in the set's workdir, never inside the model package.** A run\n directory holds a `model.malloy` snapshot, and a built report is a Malloy\n package; inside the package under test, either puts that package into\n `loadErrors`. The default workdir, `~/.malloy-eval/<set>-<hash>/`, is outside git,\n which suits a measure-only run. A run that will improve and checkpoint needs\n its ledger kept: set `[paths] workdir` in `eval.toml` to a directory in the\n model's repository but outside the package, and gitignore its `servers/`\n and `packages/`, which are a server's database and rebuilt reports.\n\n2. The server must be up. Where your host offers retrieval tracing, turn it on\n and confirm a trace lookup answers, so a call's ranked results can be\n recovered afterwards.\n\n **Open-source Publisher has none.** `PUBLISHER_MCP_TRACE=retrieval` is set by\n `serve.py` and read by nothing: the trace store was written and never merged,\n so there is no trace tool and `traceId` is null on every local attempt. This\n does not block a scored run, because attribution never depended on it:\n `rankedSummary` is copied onto the `tool_call` event at capture, precisely so\n the evidence survives without a store. So do not refuse a local run for want\n of tracing -- an earlier version of this rule did, and it refused every local\n run there has ever been. Refuse one whose `tool_call` events carry no\n `rankedSummary`, which is what attribution actually reads.\n\n3. Health-check: your host's status check until it reports serving, and inspect\n `loadErrors`. A dead database that still answers HTTP is an environment\n failure, not a model failure. Stop and fix it. Four consecutive\n environment or no-result attempts means stop the run.\n\n4. Load the set: scrape/import as above, or reuse an existing `evals/<set>/`.\n `eval.py check --set <set>` names every gap in it before anything starts.\n Never keep two live copies of one set; the set directory in the model repo\n is the single source of truth, versioned by `datasetVersion` in\n `set.json`.\n\n5. Review goldens before you score. **Check how many cases can take a verdict\n at all**, not just how many cases there are: a golden the set stamps\n `provisional`, `invalid` or `ambiguous`, or a case with no golden, scores\n `verdict: null` and stays out of the pass rate. An imported set is\n `provisional` throughout by design (`skill:eval-import`), and the only thing\n that changes that is `verify_goldens.py --promote` after a re-derivation\n through the truth package. A run whose every key is underived is refused\n rather than spent; a set of bare questions runs, because its answers are\n what keys get derived from. A verified golden that holds a value but\n no local artifact stays verified by provenance and is not scorable until you\n have rows or a scalar to compare (the judge needs both sides). A `criteria`\n golden is the exception: it holds no value, so its clauses are both sides. If diagnosis later marks\n `BAD-REFERENCE` or `AMBIGUOUS-REFERENCE`, follow\n `reference/golden-side-door.md`. Both are expected in the wild; both\n are the golden side door below, not improve, and not a sixth step.\n\n6. `eval.py run` creates `<workdir>/runs/<runId>/run.json` with the attribution pins\n (`reference/ledger-schema.md`): mode, dataset version, **the target and the\n version it served** (a local target pins a commit, so commit or stash first;\n answering from a dirty tree pins nothing), server version, judge version and\n rubric sha, answerer model, call budget, trace mode. Freeze those for the\n whole run. Raising a call budget mid-run moved mean outcomes on an unchanged\n model.\n\n The call budget is `--max-turns`, written to `run.json` as `maxTurns`.\n **Size it from a pilot rather than taking the default of 30.** Run the three\n cheapest cases uncapped (`--max-turns 100`) and set the cap at twice their\n maximum. A set whose questions need two or three sources joined does not fit\n a cap sized for single-source lookups: on one such set the completed\n attempts had a median of 17 turns and a 90th percentile of 26 against a cap\n of 30, and four cases died at it. A cap 15% above the 90th percentile of\n completed work is not a safety margin. Note also that `malloy-analysis` tells\n the answerer to persist -- retry a phrasing, let a small query settle whether\n a field exists -- so a tight cap and that instruction are in direct conflict.\n\n **Do not change the model and the measuring instrument in the same step.**\n Those are two different axes and only one of them is cheap to separate.\n\n Batching MODEL edits is fine and expected. Clustering exists so that one\n edit closes several cases, and an arm per fix does not survive contact with\n arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured\n $0.33 a case on a proxied warehouse that is $660 of answering to attribute\n what the acceptance check attributes for the price of the affected cases\n plus holdout. Budget five arms for a defensible claim (a baseline, two for\n the A/A, two post-edit), not one per edit, and let `skill:eval-improve`\n carry each cluster.\n\n The instrument is the other axis: the answer key, the judge and its prompt,\n the answerer model, the skills. Move one of those together with the model\n and there is nothing left holding still, so the result measures neither. On\n the run this comes from, nine model commits and a re-derived answer key\n landed between two arms, and the move from 20% to 39% belongs to no one --\n not because two model edits were batched, but because the ruler changed at\n the same time as the thing being measured. A key repair mid-improve is not\n forbidden; it ends that comparison, so re-baseline rather than quoting a\n delta across it.\n\n The answerer model is instrument too, and it is the one most often left\n unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one\n model and 8 / 4 / 15 on a smaller one. No statement about \"the agent's\"\n capability means anything until two arms name the same answerer.\n\n7. Generate every answerer prompt from the stored case in `cases.jsonl`.\n Never retype the question. A truncated retype is indistinguishable from a\n real question downstream.\n\n## Per question\n\n1. Health-check again.\n2. Spawn a *fresh* blind subagent. Give it only the question text and the\n Malloy analysis tools. Tell it to follow the `malloy-analysis` skill. Do not\n mention eval, gold, scoring, or this skill.\n3. Keep a host-side tool-use log for that subagent (name, input path or\n command, MCP tool name). Publisher traces see MCP only; a Read of a gold\n CSV is invisible server-side.\n4. `skill:eval-answer`: contamination first, then re-execute, then the judge,\n then events.\n5. `skill:eval-diagnose` only when this run includes diagnose, only on dev\n cases, and only after the score event exists.\n6. `skill:eval-improve` only when this run includes improve, and only for\n `owner: model`. Then run the acceptance check. On accept, checkpoint.\n\n## Report the run, or nobody can read it\n\nA run directory is JSONL. It is a record, not a result, and the console summary\nscrolls away. **Every run ends by producing something a person can open**, and\nthat job is `skill:eval-report`: it builds the servable run package (the case\nmatrix app and the aggregate notebook) and gives the template for the write-up.\n\nRead it at the end of every run, including a run that failed. The standing\ncomplaint about this loop is that \"a bunch of stuff happens and it is hard to\nknow the actual results\", and a ledger nobody renders is why.\n\nTwo rules from it are worth repeating here, because they are the ones a\nconductor skips:\n\n- **Separate a MODEL failure from an EVAL failure.** A wrong answer and a\n broken measurement look identical in a pass rate and have nothing else in\n common. An arm holding a truncated, contaminated or environment-failed\n attempt has no rate to quote at all.\n- **Say when a step did not run, and why.** `improve` not running because every\n cluster came back `owner: agent-skill` is a RESULT, and it reads identically\n to having forgotten unless it is written down.\n\nKeep the write-up in the repository beside the set, not in a chat log and not\nin `~/Downloads`.\n\n## Checkpoint\n\nA checkpoint is a git commit of the model repository, taken after an acceptance check\naccepts, so a bad improve direction can be rolled back. It is not a report,\nand it is not a remote publish.\n\n1. Commit the model files AND the set's ledger in one commit; put the label\n and the closed issue ids in the message. The run's ledger is in that commit\n only when the workdir is in the repository (Before you start, item 1).\n2. Append the `checkpoint` event (`action: created`, label, `modelGitSha`\n from the commit you just made, issueIds). The event line itself rides in\n the next commit; append-only logs trail by one commit and that is fine.\n3. Confirm `git status` is clean for the model files.\n\n**Restore**: `git checkout <sha> -- <model files>` (or `git revert` the\ncheckpoint commits), then reload the package, then append a `checkpoint`\nevent with `action: restored` and the sha. Readers return to the model that\nexisted before the bad direction.\n\nTake a checkpoint of the current model *before* the first improve batch if no\ncommit pins it yet. Rolling back by hand is guesswork.\n\nIf reload reports `mode: reinstalled`, the package was re-fetched from its\ninstall location and may have overwritten the restored files. Prefer in-place\n/ watch-mounted packages for this loop.\n\n## Out of scope\n\nThis loop is local. The ledger is files, the checkpoints are git, you are the\nconductor. Do not:\n\n- publish the model to a hosted platform as a \"true\" checkpoint or learning\n curve\n- start a Python orchestrator (`loop.py`, `run_all.py`, `improve_batch.py`)\n that runs the five steps end to end unattended. You conduct; the scripts are\n the steps, not the sequencing. There are more than twenty of them and they\n are not the exception to this: each does one step you invoke and hands back a\n result you read. What is forbidden is a script that decides what to do next,\n because every judgement this loop protects lives in that decision\n- score by string-diffing rows instead of judging them, or reintroduce a\n scripted row oracle: one that can pass a wrong answer is worse than none\n- wait for a bigger gold set before the loop can run; dev/holdout on what\n exists beats waiting\n- register eval MCP tools or stand up an eval API\n- encode unsettled goldens into the model\n\n## Prime directives\n\n- The model is the only thing improve edits. No question text, qids, or\n expected values in any name, doc, or comment.\n- **You are measuring a model, not reviewing this harness.** Read a script when\n a number you have to report cannot be explained otherwise, and stop there. Do\n not audit the scripts, propose fixes to them, or hand back tooling critique\n in place of a result -- a run that ends in harness feedback has not answered\n the question it was asked. A defect that changed a number gets one sentence\n in the report; a defect that changed nothing gets one line in chat, once.\n Fixing it is a different task, and the user starts it.\n- When the environment misbehaves, stop. Never diagnose a sick system. The\n harness is part of the environment: a known-broken measurement does not\n become quotable by being finished. Measured against this directive, an arm\n already paid for gets quoted anyway -- it happened three times in one run,\n once with the confound stated in the same message that started the arm -- so\n the harness now enforces the parts it can. A run with a truncated or\n contaminated attempt prints no pass rate and records `status: incomplete`,\n and `flip_table.py` names what one arm left unscored that the other did not.\n Read `run_error` on the attempts\n and the INCOMPLETE line before quoting any number.\n- When a subagent disagrees with you, probe. Do not win by authority.\n- When a rule here is wrong, change this file and note it on the run.\n\n## Related skills\n\n- `skill:eval-answer`: contamination, judge protocol, events. Its\n `reference/ledger-schema.md` is the file contract; `skill:eval-judge` is\n the judge.\n- `skill:eval-diagnose`: component, owner, issue events. No edit.\n- `skill:eval-improve`: smallest model edit, probe receipts, no self-accept.\n- `skill:eval-report`: the run package and the write-up a person reads.\n- The `malloy-analysis` skill: what the blind answerer follows. It is installed\n from the `analysis` manifest group, not the `eval` group.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `eval-loop/<name>` instead.\n\nAvailable: acceptance-check, auditing-an-answer-key, checking-the-judge, golden-side-door, measurement, running-a-run, setting-up-a-set."
386575
386038
  },
386576
386039
  {
386577
386040
  name: "eval-loop/acceptance-check",
@@ -387328,25 +386791,58 @@ model score.`
387328
386791
  one fresh answerer per case with only the Publisher MCP tools, a contamination
387329
386792
  check, a judge, and a conformant \`events.jsonl\`.
387330
386793
 
386794
+ Every step reads the set's \`eval.toml\`: the model server's environment,
386795
+ package, repo and ports, and the truth server's. Write it once per set, beside
386796
+ \`set.json\`. Relative paths resolve against the file. A set that does not exist
386797
+ yet starts at \`setting-up-a-set.md\`, beside this file.
386798
+
386799
+ \`\`\`toml
386800
+ [model] # the Publisher the answerer queries
386801
+ environment = "<env>"
386802
+ package = "<pkg>"
386803
+ repo = "<path to the model package>"
386804
+ port = 4811
386805
+ mcp_port = 4812
386806
+
386807
+ [truth] # a second Publisher, serving only the truth package
386808
+ environment = "truth"
386809
+ port = 4881
386810
+ mcp_port = 4882
386811
+ \`\`\`
386812
+
386813
+ No script defaults to any particular set's environment or package. A missing
386814
+ one stops with the key to add. Runs, built packages and server roots go under
386815
+ \`~/.malloy-eval/<set>-<hash>/\` unless \`[paths] workdir\` says otherwise, never inside
386816
+ the repository: a run holds a \`model.malloy\` snapshot and a built package is a
386817
+ Malloy package, and nested in the package under test either can put that
386818
+ package into \`loadErrors\`. The hash is of the set's path, so two sets with one
386819
+ name never share a \`runs/\`. Each command below is \`python3 skills/eval-loop/scripts/eval.py\`,
386820
+ and any flag the underlying script takes passes through.
386821
+
387331
386822
  \`\`\`bash
387332
- # 1. serve the model under test -- in its own session, so the shell's exit
387333
- # cannot take it down, and returning only once it answers a query.
386823
+ # 0. every gap in the set at once, before anything starts: missing files, a
386824
+ # case the importer refuses, a truth package the model server would serve,
386825
+ # a table path that resolves to nothing once served, a port already held.
386826
+ # Free, and it starts nothing. Exits 1 until nothing blocks a run.
386827
+ eval.py check --set <set>
386828
+
386829
+ # 1. serve the model under test, and the TRUTH package on a second server the
386830
+ # answerer has no route to. Each runs in its own session, so the shell's
386831
+ # exit cannot take it down, and returns only once it answers a query. The
386832
+ # script writes each server's publisher.config.json from eval.toml, and
386833
+ # refuses a truth server on the model server's ports.
387334
386834
  # --warm-retrieval also waits for the embedding index to settle and exits 3
387335
386835
  # if it does not reach \`ready\`: the sync is lazy, so without it the first
387336
386836
  # cases are answered LEXICALLY and the run reports that as the model's
387337
386837
  # number.
387338
386838
  # Semantic retrieval needs EMBEDDING_API_KEY in the server's environment
387339
- # (the Publisher does not read OPENAI_API_KEY); without it every ranking is
387340
- # lexical, the warm-up reports \`cooldown\`, and the provider's own error is
387341
- # only in publisher.log (grep Embedding).
387342
- python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
387343
- --server-root <root> --port 4811 --mcp-port 4040 --trace-retrieval \\
387344
- --warm-retrieval --environment <env> --package <pkg> \\
386839
+ # (the Publisher does not read OPENAI_API_KEY). The script warns when it is
386840
+ # unset; without it every ranking is lexical, the warm-up reports
386841
+ # \`cooldown\`, and the provider's own error is only in publisher.log
386842
+ # (grep Embedding).
386843
+ eval.py serve model --set <set> --warm-retrieval \\
387345
386844
  [--allow-proxy] # required for a \`publisher\`-type (proxied) connection
387346
- # a second server for the TRUTH package, on other ports, that the answerer
387347
- # has no route to:
387348
- python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
387349
- --server-root <truthroot> --port 4881 --mcp-port 4882 [--allow-proxy]
386845
+ eval.py serve truth --set <set> [--allow-proxy]
387350
386846
 
387351
386847
  # 2a. audit the set against the package it is about to be scored on. Free, and
387352
386848
  # it catches the failure that reads as a model regression: an entity id
@@ -387358,10 +386854,9 @@ python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/s
387358
386854
  # --definitions <ledger> (verify_definitions.py), a set whose every
387359
386855
  # value-bearing case rests on validated definitions exits 0 instead:
387360
386856
  # the composition rule, values not re-derived but their definitions
387361
- # checked.
387362
- python3 skills/eval-answer/scripts/verify_goldens.py \\
387363
- --set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
387364
- --model <repo>/ecommerce --target-package ecommerce
386857
+ # checked. --model adds the entity-id audit; it greps the model text, so a
386858
+ # column a source exposes implicitly reads as missing.
386859
+ eval.py verify --set <set> [--model <package>]
387365
386860
 
387366
386861
  # 2b. ONLY on a set whose keys are still provisional -- an imported one is,
387367
386862
  # throughout, by design. Nothing else in this toolchain writes
@@ -387370,59 +386865,50 @@ python3 skills/eval-answer/scripts/verify_goldens.py \\
387370
386865
  # AND carries a second derivation, and prints the reason for every golden
387371
386866
  # it left alone. \`--refresh\` is NOT this: it rewrites a drifted value and
387372
386867
  # never touches a status.
387373
- python3 skills/eval-answer/scripts/verify_goldens.py \\
387374
- --set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
387375
- --target-package ecommerce --promote
386868
+ eval.py verify --set <set> --promote
387376
386869
 
387377
386870
  # 2. smoke one case first ($0.13), then the arm. Goldens are re-derived from
387378
386871
  # the truth server before either starts; a drifted set refuses to run.
387379
- python3 skills/eval-loop/scripts/run_baseline.py \\
387380
- --set <repo>/evals/ecommerce --out results/smoke --only <qid> --no-judge \\
387381
- --truth-publisher http://localhost:4881
387382
- python3 skills/eval-loop/scripts/run_baseline.py \\
387383
- --set <repo>/evals/ecommerce --out results/<arm> \\
387384
- --parallel 4 --truth-publisher http://localhost:4881 \\
387385
- --model-repo <repo> --model-dir <package> # the checkout the MODEL is
387386
- # versioned in, recorded as modelGitSha, and the package
387387
- # directory inside it the -dirty marker is decided over
387388
- # (relative to the repo). Without --model-dir any
387389
- # untracked file anywhere in the repo marks the model
387390
- # dirty. The repo cannot be inferred: Publisher serves a
387391
- # copy under publisher_data/, whose surrounding tree is
387392
- # the server's storage, not the model's history.
386872
+ # [model] repo is the model PACKAGE directory. The run records the commit
386873
+ # of the checkout it sits in as modelGitSha, and decides the -dirty marker
386874
+ # over that package directory only, so an untracked file elsewhere in the
386875
+ # repository does not mark the model dirty. It cannot be inferred:
386876
+ # Publisher serves a copy under publisher_data/, whose surrounding tree is
386877
+ # the server's storage, not the model's history.
386878
+ eval.py run --set <set> --label smoke --only <qid> --no-judge
386879
+ eval.py run --set <set> --parallel 4
387393
386880
  # the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
387394
- # for the second arm of the A/A). Pass --label only for a run that needs a
387395
- # human name; hand-typed arm names stop being readable within an afternoon.
386881
+ # for the second arm of the A/A) and prints its directory. Pass --label
386882
+ # only for a run that needs a human name; hand-typed arm names stop being
386883
+ # readable within an afternoon.
387396
386884
 
387397
386885
  # 3. compare two arms, or two runs of one arm. It exits 2 when the arms used
387398
386886
  # different retrievers, because those flips measure the retriever.
387399
- python3 skills/eval-loop/scripts/flip_table.py --a results/<a> --b results/<b>
386887
+ python3 skills/eval-loop/scripts/flip_table.py --a <run a> --b <run b>
387400
386888
 
387401
386889
  # 3a. on an A/A, write the band into the set's calibration record. Nothing
387402
386890
  # else may quote a band, and a band only covers runs whose pins match.
387403
386891
  python3 skills/eval-loop/scripts/flip_table.py \\
387404
- --a results/aa-1 --b results/aa-2 --calibration \\
387405
- >> <repo>/evals/ecommerce/CALIBRATION.md
386892
+ --a <aa-1 run> --b <aa-2 run> --calibration >> <set>/CALIBRATION.md
387406
386893
 
387407
386894
  # 3b. check the JUDGE, not the model, after any change to the judge skill, a
387408
386895
  # rubric, or what the judge is shown. It also reports which fixtures are
387409
386896
  # unpinned and which decision classes nothing covers.
387410
- python3 skills/eval-loop/scripts/check_judge.py \\
387411
- --set <repo>/evals/ecommerce --repeat 3
386897
+ python3 skills/eval-loop/scripts/check_judge.py --set <set> --repeat 3
387412
386898
 
387413
386899
  # 4. FIRST: any golden in doubt, from the judge or the set. \`jq .doubtedGoldens
387414
- # results/<arm>/run.json\` -- non-empty means settle those through the golden
387415
- # side door before diagnosing, or you send a modelling agent at a model that
387416
- # is already right.
387417
- python3 skills/eval-diagnose/scripts/diagnose.py \\
387418
- --run results/<arm> --set <repo>/evals/ecommerce --model-dir <package>
386900
+ # <run>/run.json\` -- non-empty means settle those through the golden side
386901
+ # door before diagnosing, or you send a modelling agent at a model that is
386902
+ # already right. It probes the server the run recorded.
386903
+ eval.py diagnose --set <set> --label <label>
387419
386904
  # (cluster_failures.py gives a free mechanical first look, as
387420
386905
  # clusters-mechanical.jsonl; it groups by retrieval outcome and is not a
387421
386906
  # diagnosis)
387422
386907
 
387423
- # 5. build the browsable package
387424
- python3 skills/eval-loop/scripts/build_run_package.py \\
387425
- --run results/<a> --run results/<b> --set <repo>/evals/ecommerce --out <pkg>
386908
+ # 5. build the browsable package. Refused until every run has a diagnosis
386909
+ # (--without-diagnosis overrides); a run where everything passed records an
386910
+ # empty one. Pass --run twice for an A/B.
386911
+ eval.py package --set <set> --label <label>
387426
386912
  \`\`\`
387427
386913
 
387428
386914
  ### What the run prints at the end
@@ -387442,12 +386928,12 @@ Three layers, in this order, and the order is what makes it readable:
387442
386928
  report, so this layer is the two commands that turn it into something you
387443
386929
  read: \`build_run_package.py\` (a Malloy model over the run's CSVs,
387444
386930
  \`notebooks/eval_run.malloy\` for the aggregate tables, and an in-package HTML app for
387445
- the case matrix and its per-case drawer), then a \`POST .../packages\` that
387446
- registers it on the Publisher already running, with no restart. It prints
387447
- the resulting URL with the run's own paths filled in.
387448
-
387449
- The POST lands the package in the environment the run used. Point it at
387450
- another if you would rather that package listing stay untouched.
386931
+ the case matrix and its per-case drawer), registered on a Publisher that
386932
+ is already running, with no restart. \`eval.py package\` builds it,
386933
+ registers it on the TRUTH server, and prints both URLs; the package holds
386934
+ the answer key, and that is the server the answerer has no route to. The
386935
+ registration command is also written into the package's README.md, for
386936
+ serving it again later; \`--no-register\` builds without registering.
387451
386937
 
387452
386938
  Order of magnitude for planning, **calibrated on ecommerce over local duckdb**:
387453
386939
  a Sonnet arm over a few dozen cases costs single-digit dollars and finishes in
@@ -387485,6 +386971,163 @@ Two failure modes worth pre-empting, because both produce a clean-looking run:
387485
386971
  - **Check the served revision is the one you edited.** Publisher serves a
387486
386972
  snapshot copy, so a model fix can be absent from the run that is supposed to
387487
386973
  measure it. Query the changed measure once before spending an arm on it.`
386974
+ },
386975
+ {
386976
+ name: "eval-loop/setting-up-a-set",
386977
+ description: "Setting up a set on your own model. Reference detail for the eval-loop skill.",
386978
+ body: `<!-- The first run of your own set, in order. Read it when a set does not exist yet. -->
386979
+
386980
+ # Setting up a set on your own model
386981
+
386982
+ \`examples/storefront/evals/storefront-tour\` is a finished set. This is the path
386983
+ to one of your own: which files a set needs, what writes each one, and the
386984
+ order they go in. Every command is \`python3 skills/eval-loop/scripts/eval.py\`,
386985
+ run from the Publisher clone, unless it names another script.
386986
+
386987
+ \`eval.py check --set <set>\` reads everything below and names every gap at once,
386988
+ without starting a server or calling a model. Run it after each step; the set is
386989
+ ready when it prints \`ready: nothing blocks a run\`.
386990
+
386991
+ ## What you need first
386992
+
386993
+ - A Malloy package with a \`publisher.json\`, in a git repository. Git is how a
386994
+ run pins the model it measured and how an improve step is rolled back.
386995
+ - A built Publisher clone (\`bun install && bun run build\`), Python 3.11+, and a
386996
+ Java runtime for the SDK build.
386997
+ - \`EMBEDDING_API_KEY\` exported, for semantic retrieval. Without it the model
386998
+ server ranks lexically and says so on start.
386999
+
387000
+ ## 1. Make the set directory
387001
+
387002
+ One directory per set, in the same repository as the model package.
387003
+ \`skill:eval-import\` "Where the set goes" has the rule about how close to the
387004
+ package it may sit. It holds:
387005
+
387006
+ | File | What it is | Written by |
387007
+ |---|---|---|
387008
+ | \`set.json\` | the set's name, the package and model under test, the truth package | you |
387009
+ | \`cases.jsonl\` | one case per question: text, split, golden | \`skill:eval-import\` |
387010
+ | \`as-received/\` | the questions exactly as they arrived | you, untouched |
387011
+ | \`eval.toml\` | where each step finds its servers | you |
387012
+ | \`gold/<qid>.json\` | a golden's second derivation | you, when you derive one |
387013
+
387014
+ \`set.json\`, at minimum (\`reference/ledger-schema.md\` in \`skill:eval-answer\`
387015
+ has every field):
387016
+
387017
+ \`\`\`json
387018
+ {"name": "<set>", "description": "<what it measures>", "datasetVersion": 1,
387019
+ "targetPackage": "<package name>", "targetModelPath": "<model>.malloy"}
387020
+ \`\`\`
387021
+
387022
+ ## 2. Import the questions
387023
+
387024
+ \`skill:eval-import\` turns questions in any shape into \`cases.jsonl\` and says what
387025
+ each one is worth. A set with no questions yet has nothing to import: write the
387026
+ questions a person would actually ask into \`as-received/\` first, then import
387027
+ them, rather than writing cases by hand. Then stamp and validate:
387028
+
387029
+ \`\`\`bash
387030
+ python3 skills/eval-import/scripts/import_cases.py --set <set> --stamp
387031
+ \`\`\`
387032
+
387033
+ It refuses what later steps would fail on, with the fix: no \`split\`, a value
387034
+ claiming \`verified\` on arrival, a \`scalar\` value that is a bare number rather
387035
+ than \`{"<column>": <value>}\`. A set of bare questions is valid. It runs and
387036
+ measures coverage, and cannot score accuracy until keys exist.
387037
+
387038
+ ## 3. Build the truth package, if any case holds a value
387039
+
387040
+ A golden holding a value is re-derived before every run from a truth package:
387041
+ the raw tables your model reads, with no modelling, served on a second server
387042
+ the answerer cannot reach. Scaffold it OUTSIDE the model package, because
387043
+ Publisher serves every \`.malloy\` under a package directory:
387044
+
387045
+ \`\`\`bash
387046
+ python3 skills/eval-answer/scripts/init_truth_package.py \\
387047
+ --package <model package> --out <outside it>/<set>-truth --name <set>-truth
387048
+ \`\`\`
387049
+
387050
+ It writes one source per raw table and links the model's data directory in, so
387051
+ file paths still resolve once Publisher serves its copy of the package. The link
387052
+ holds this machine's path; to commit the truth package, copy the data in
387053
+ instead, as \`examples/storefront-tour-truth/\` does. Add the package's scope
387054
+ filters by hand, as its header says, and nothing else. Then set
387055
+ \`"truthPackage": "<set>-truth"\` in \`set.json\`.
387056
+
387057
+ A set of criteria goldens or bare questions needs no truth package; \`check\`
387058
+ notes its absence and moves on.
387059
+
387060
+ ## 4. Write eval.toml
387061
+
387062
+ \`\`\`toml
387063
+ [model] # the Publisher the answerer queries
387064
+ environment = "<env>"
387065
+ package = "<package name>"
387066
+ repo = "<path to the model package>" # relative to this file
387067
+ port = 4811
387068
+ mcp_port = 4812
387069
+
387070
+ [truth] # only with a truth package; an empty [truth] takes 4881/4882
387071
+ environment = "truth"
387072
+ package_dir = "<path to the truth package>"
387073
+ port = 4881
387074
+ mcp_port = 4882
387075
+ \`\`\`
387076
+
387077
+ \`package\` here and \`targetPackage\` in \`set.json\` must name the same package.
387078
+ Where both are set, \`eval.toml\` wins, and \`check\` refuses the set until they
387079
+ agree.
387080
+
387081
+ Pick ports nothing else on the machine holds; \`check\` names any that are taken.
387082
+ Every later step reads them from this file, so change them here rather than
387083
+ passing \`--port\`, which \`serve\` refuses when it disagrees with the file.
387084
+
387085
+ ## 5. Check, serve, verify
387086
+
387087
+ \`\`\`bash
387088
+ eval.py check --set <set> # until: ready
387089
+ eval.py serve model --set <set> --warm-retrieval
387090
+ eval.py serve truth --set <set> # with a truth package
387091
+ eval.py verify --set <set> # free: re-derives every golden
387092
+ \`\`\`
387093
+
387094
+ \`verify\` exits 0 when every golden re-derives, 1 when one drifted, and 3 when it
387095
+ could not check (no truth package, or no truth server). Read 3 as "not run",
387096
+ never as a pass.
387097
+
387098
+ ## 6. Turn provisional keys into scorable ones
387099
+
387100
+ An imported value is \`provisional\`, and a provisional golden takes no verdict.
387101
+ \`--promote\` marks one \`verified\` only when it re-derived cleanly through the
387102
+ truth package AND a second, differently shaped derivation exists: a
387103
+ \`golden.verification\` block, or \`gold/<qid>.json\` holding \`verifyRows\`
387104
+ (\`reference/ledger-schema.md\`, the \`golden\` row, says what the second one must
387105
+ vary).
387106
+
387107
+ \`\`\`bash
387108
+ eval.py verify --set <set> --promote
387109
+ \`\`\`
387110
+
387111
+ It prints why it left each golden alone. \`check\` shows the scorable count.
387112
+ Where a person has checked a value by hand instead of deriving it a second way,
387113
+ \`--promote --attest "<who checked, when, how>"\` promotes it and writes that text
387114
+ on the golden, so a reader sees a person stood behind it rather than a query.
387115
+
387116
+ ## 7. The first run
387117
+
387118
+ \`\`\`bash
387119
+ eval.py run --set <set> --label smoke --only <qid>[,<qid>...] --max-turns 40 # a case or two first
387120
+ eval.py run --set <set> --label baseline-01 --max-turns 40
387121
+ eval.py diagnose --set <set> --label baseline-01
387122
+ eval.py package --set <set> --label baseline-01
387123
+ \`\`\`
387124
+
387125
+ Then \`reference/running-a-run.md\` for what the run prints and how to read it,
387126
+ and \`skill:eval-report\` for the write-up.
387127
+
387128
+ Measured on the tour's local DuckDB data, with sonnet answering and judging:
387129
+ about $0.35 a case to run, and $0.60 and three minutes to diagnose one failed
387130
+ case. A warehouse over a proxy costs several times more per case.`
387488
387131
  },
387489
387132
  {
387490
387133
  name: "eval-report",
@@ -387538,15 +387181,23 @@ different task with its own turn.
387538
387181
  ## Step 1: build the artifacts, before writing a word
387539
387182
 
387540
387183
  \`\`\`bash
387541
- python3 skills/eval-loop/scripts/build_run_package.py \\
387542
- --run <run-dir> --set <set-dir> --out /tmp/eval-<label>
387543
- curl -sS -X POST http://<publisher>/api/v0/environments/<env>/packages \\
387544
- -H 'content-type: application/json' \\
387545
- -d '{"name":"eval-<label>","location":"/tmp/eval-<label>"}'
387184
+ python3 skills/eval-loop/scripts/eval.py package --set <set-dir> --label <label>
387546
387185
  \`\`\`
387547
387186
 
387548
- That builds a Malloy package over the run's own CSVs and registers it with no
387549
- restart. It gives you two things to link:
387187
+ That builds a Malloy package over the run's own CSVs, registers it with no
387188
+ restart, and prints the two URLs below. It registers on the TRUTH server,
387189
+ because the package holds the answer key and the answerer must not reach it.
387190
+ If the truth server is not running it says so and prints the \`curl\` to run
387191
+ once it is. A set with no truth server gets no registration: the only
387192
+ Publisher is the answerer's. Add a \`[truth]\` section and
387193
+ \`eval.py serve truth\`, or pass \`--on-model-server\`, which prints the \`curl\`
387194
+ and the \`DELETE\` to run before the next run.
387195
+
387196
+ It refuses a run with no diagnosis, since the report's cluster views would be
387197
+ empty. Run \`eval.py diagnose\` first; when this run skipped diagnosis on purpose,
387198
+ pass \`--without-diagnosis\` and say in the report that it has no clusters.
387199
+
387200
+ It gives you two things to link:
387550
387201
 
387551
387202
  | Artifact | What it is | URL |
387552
387203
  |---|---|---|