@malloy-publisher/server 0.5.0 → 0.5.1

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 (22) hide show
  1. package/dist/app/api-doc.yaml +103 -11
  2. package/dist/app/assets/{EnvironmentPage-cVoREk0T.js → EnvironmentPage-B02geyxh.js} +1 -1
  3. package/dist/app/assets/{HomePage-9sQJEb1e.js → HomePage-BdKzZEto.js} +1 -1
  4. package/dist/app/assets/{LightMode-C8uGSmfv.js → LightMode-BRAs1k98.js} +1 -1
  5. package/dist/app/assets/{MainPage-BIG2W9IX.js → MainPage-D9XZbGvk.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DKkVe4Md.js → ModelPage-B-U47DzF.js} +2 -2
  7. package/dist/app/assets/{PackagePage-Cax6RcJM.js → PackagePage-CDp9A3_k.js} +1 -1
  8. package/dist/app/assets/{RenderedResult-BB7eokcf.es-D-hyXuFF.js → RenderedResult-CTD2Ej2S.es-BZsZAiwo.js} +2 -2
  9. package/dist/app/assets/{RouteError-BzJ2T1nX.js → RouteError-CSsLG99c.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-Ce7Cp5zL.js → ThemeEditorPage-C4u5l49i.js} +1 -1
  11. package/dist/app/assets/{ToggleButtonGroup-DOVU2psA.js → ToggleButtonGroup-IBhUYkv_.js} +1 -1
  12. package/dist/app/assets/{index-BJv4aICx.js → index--DAcpR5Y.js} +1 -1
  13. package/dist/app/assets/{index-BOX8mX0R.js → index-BKNRDF5y.js} +1 -1
  14. package/dist/app/assets/{index-DycyDIlK.js → index-Bd7WYNw_.js} +4 -4
  15. package/dist/app/assets/{index-CPy5UJ3W.js → index-CshLRozl.js} +1 -1
  16. package/dist/app/assets/{index-DfULFk5d.js → index-DOXSuS-a.js} +1 -1
  17. package/dist/app/assets/{index.es-Doid8lz7.js → index.es-Bj77sE9l.js} +4 -4
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +1 -1
  20. package/dist/server.mjs +1704 -181
  21. package/package.json +20 -2
  22. package/dist/cpufeatures-1yrn0vtw.node +0 -0
package/dist/server.mjs CHANGED
@@ -232535,23 +232535,12 @@ var require_bcrypt_pbkdf = __commonJS((exports, module) => {
232535
232535
  };
232536
232536
  });
232537
232537
 
232538
- // ../../node_modules/cpu-features/build/Release/cpufeatures.node
232539
- var require_cpufeatures = __commonJS((exports, module) => {
232540
- module.exports = __require("./cpufeatures-1yrn0vtw.node");
232541
- });
232542
-
232543
- // ../../node_modules/cpu-features/lib/index.js
232544
- var require_lib8 = __commonJS((exports, module) => {
232545
- var binding = require_cpufeatures();
232546
- module.exports = binding.getCPUInfo;
232547
- });
232548
-
232549
232538
  // ../../node_modules/ssh2/lib/protocol/constants.js
232550
232539
  var require_constants42 = __commonJS((exports, module) => {
232551
232540
  var crypto3 = __require("crypto");
232552
232541
  var cpuInfo;
232553
232542
  try {
232554
- cpuInfo = require_lib8()();
232543
+ cpuInfo = (()=>{throw new Error("Cannot require module "+"cpu-features");})()();
232555
232544
  } catch {}
232556
232545
  var { bindingAvailable, CIPHER_INFO, MAC_INFO } = require_crypto();
232557
232546
  var eddsaSupported = (() => {
@@ -247194,7 +247183,7 @@ var require_keygen = __commonJS((exports, module) => {
247194
247183
  });
247195
247184
 
247196
247185
  // ../../node_modules/ssh2/lib/index.js
247197
- var require_lib9 = __commonJS((exports, module) => {
247186
+ var require_lib8 = __commonJS((exports, module) => {
247198
247187
  var {
247199
247188
  AgentProtocol,
247200
247189
  BaseAgent,
@@ -247377,7 +247366,7 @@ function openSshProxy(ssh, target) {
247377
247366
  var import_ssh2, ALLOW_UNVERIFIED_SSH_HOST_KEY_ENV = "PUBLISHER_ALLOW_UNVERIFIED_SSH_HOST_KEY", TRUTHY_ENV_VALUES, SSH_CONNECT_TIMEOUT_MS = 15000, SSH_KEEPALIVE_INTERVAL_MS = 15000;
247378
247367
  var init_proxy = __esm(() => {
247379
247368
  init_logger();
247380
- import_ssh2 = __toESM(require_lib9(), 1);
247369
+ import_ssh2 = __toESM(require_lib8(), 1);
247381
247370
  TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
247382
247371
  });
247383
247372
 
@@ -267880,7 +267869,7 @@ function readGivenControlSpec(annotationTexts2) {
267880
267869
  }
267881
267870
  function malloyGivenToApi(given) {
267882
267871
  const type = given.type;
267883
- const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type;
267872
+ const renderedType = type.type === "filter expression" ? `filter<${type.filterType}>` : type.type === "array" && type.elementTypeDef?.type ? `${type.elementTypeDef.type}[]` : type.type;
267884
267873
  const allNotes = given.annotations.forRoute(undefined);
267885
267874
  return {
267886
267875
  name: given.name,
@@ -270849,7 +270838,7 @@ var init_materialization_metrics = __esm(() => {
270849
270838
  storageTableRetainedCounter = lazyCounter3("publisher_storage_tables_retained_total", "Tables a FAILED run left in a storage= destination and deliberately did not " + "reclaim, because the source is refreshed incrementally and the name may be " + "the one it serves from. Label: destination. Not all of these are orphans — " + "a rebuild at a fresh generational name is, a seed on the live serving name " + "is not, and the manifest entry cannot separate them — so read this as an " + "upper bound on what is accumulating rather than a leak count. It is the " + "only accounting there is until reclaiming a destination exists, which is " + "why it is a counter and not just a log line: the question is a rate, not " + "whether it ever happened.");
270850
270839
  storageBuildFailureCounter = lazyCounter3("publisher_storage_build_failures_total", "storage= build failures (federation/passthrough/attach/CTAS), distinct from " + "in-warehouse build failures. Labels: destination (connection name), " + "reason ('build_failed'|'billed_read_not_captured'). The second is the " + "expensive one: the warehouse read ran and was charged, and the rows could " + "not be captured — so a re-drive pays for it again. Worth alerting on " + "separately from a failure that costs only a retry.");
270851
270840
  attributionSkippedCounter = lazyCounter3("publisher_storage_build_attribution_skipped_total", "storage= builds whose warehouse read went out UNATTRIBUTED while tagging was " + "on. Label: reason ('job_listing_unavailable'|'tag_failed'|" + "'read_row_not_found'|'read_row_ambiguous'|'cost_query_failed'). " + "The read still ran and the " + "build still succeeded — what was lost is the label in the customer's own " + "query history, and the cost on this side. Without this an operator who " + "turns tagging on and sees nothing has a single log line to go on.");
270852
- eligibilityRefusedCounter = lazyCounter3("publisher_materialization_eligibility_refused_total", "storage= materialization-eligibility refusals. Label: reason " + "('free_parameter'|'given'|'authorize'|'not_duckdb_portable'|" + "'public_surface_unknown').");
270841
+ eligibilityRefusedCounter = lazyCounter3("publisher_materialization_eligibility_refused_total", "materialization-eligibility refusals, both tiers. Label: reason " + "('free_parameter'|'given_in_persisted_query'|'dynamic_projection'|" + "'dynamic_join'|'dynamic_joined_where'|'partition_without_storage'|" + "'partition_column_unknown'|'partition_column_not_public'|" + "'merge_key_scope_unresolved'|'preaggregate_over_dynamic_source'|" + "'authorize'|'partition'|'not_duckdb_portable'|" + "'public_surface_unknown'). 'given' is retained on the enum for records " + "that carry it and is no longer produced: where a given sits decides the " + "outcome, so one reason covering every placement cannot be raised.");
270853
270842
  serveShapeTierDropCounter = lazyCounter3("publisher_storage_serve_shape_tier_drop_total", "storage serve-shape compile escalations: a refinement tier failed to " + "compile and the riskiest category was dropped. Label: tier (the failed " + "tier index, 0=full).");
270854
270843
  serveShapeTypeFallbackCounter = lazyCounter3("publisher_storage_serve_shape_type_fallback_total", "Captured DuckDB column types mapped to json in the serve shape (type " + "fidelity loss). Label: kind ('array'|'unrecognized').");
270855
270844
  chainedStorageBuildCounter = lazyCounter3("publisher_storage_chained_build_total", "Chained storage= source builds (a source reading a storage-materialized " + "upstream). Label: outcome ('parent_reuse'|'inline_fallback'|" + "'strict_refused'). The parent_reuse share is the headline signal for how " + "far the stack-on-the-parent path gets us vs recompute-from-raw.");
@@ -271489,12 +271478,28 @@ source: ${sourceName} is ${binding.destinationName}.virtual('${binding.virtualHa
271489
271478
  `;
271490
271479
  return { modelText, shapeTypeName };
271491
271480
  }
271481
+ function serveShapeGivenLine(given) {
271482
+ const suffix = given.defaultText !== undefined ? ` is ${given.defaultText}` : "";
271483
+ return ` ${given.name} :: ${given.type}${suffix}`;
271484
+ }
271492
271485
  function serveShapeFragment(binding) {
271493
271486
  const shapeTypeName = `${binding.sourceName}__shape`;
271494
271487
  const fields = binding.schema.map((c) => ` ${emitFieldName(c.name)}::${duckdbTypeToMalloy(c.type)}`).join(`,
271495
271488
  `);
271496
271489
  let source = `source: ${binding.sourceName} is ` + `${binding.destinationName}.virtual('${binding.virtualHandle}')::${shapeTypeName}`;
271497
- const refinements = binding.refinements ?? [];
271490
+ const lines = refinementLines(binding.refinements ?? []);
271491
+ if (lines.length > 0) {
271492
+ source += ` extend {
271493
+ ${lines.join(`
271494
+ `)}
271495
+ }`;
271496
+ }
271497
+ return `type: ${shapeTypeName} is {
271498
+ ${fields}
271499
+ }
271500
+ ${source}`;
271501
+ }
271502
+ function refinementLines(refinements) {
271498
271503
  const lines = [];
271499
271504
  for (const r of refinements) {
271500
271505
  if (r.kind === "join")
@@ -271513,16 +271518,7 @@ function serveShapeFragment(binding) {
271513
271518
  if (r.kind === "view")
271514
271519
  lines.push(` view: ${r.text}`);
271515
271520
  }
271516
- if (lines.length > 0) {
271517
- source += ` extend {
271518
- ${lines.join(`
271519
- `)}
271520
- }`;
271521
- }
271522
- return `type: ${shapeTypeName} is {
271523
- ${fields}
271524
- }
271525
- ${source}`;
271521
+ return lines;
271526
271522
  }
271527
271523
  function serveShapeDiagnostics(allBindings, freshBindings) {
271528
271524
  const shapeSources = freshBindings.map((b) => b.sourceName);
@@ -271532,20 +271528,104 @@ function serveShapeDiagnostics(allBindings, freshBindings) {
271532
271528
  staleSources: allBindings.map((b) => b.sourceName).filter((name) => !fresh.has(name))
271533
271529
  };
271534
271530
  }
271535
- function buildServeShapeModelForBindings(bindings, rollupGroups = []) {
271531
+ function derivedSourceFragment(lift) {
271532
+ const lines = refinementLines(lift.refinements);
271533
+ const body = lines.length > 0 ? ` extend {
271534
+ ${lines.join(`
271535
+ `)}
271536
+ }` : "";
271537
+ return `source: ${lift.sourceName} is ${lift.base}${body}`;
271538
+ }
271539
+ function optsOutOfPersist(def) {
271540
+ const notes = def.annotations?.blockNotes;
271541
+ return (notes ?? []).some((note) => typeof note?.text === "string" && /^\s*#@\s*-persist\b/.test(note.text));
271542
+ }
271543
+ function liftDerivedSources(ctx) {
271544
+ const lifts = [];
271545
+ const available = new Set(ctx.shapeSourceNames);
271546
+ const pending = Object.entries(ctx.contents).filter(([name, def]) => typeof def?.extends === "string" && !(ctx.boundSourceNames ?? ctx.shapeSourceNames).has(name) && def?.persistent !== true && !optsOutOfPersist(def));
271547
+ let progressed = true;
271548
+ while (progressed) {
271549
+ progressed = false;
271550
+ for (let i = 0;i < pending.length; i++) {
271551
+ const entry = pending[i];
271552
+ if (!entry)
271553
+ continue;
271554
+ const [name, def] = entry;
271555
+ const base = ctx.sourceNameById.get(def.extends);
271556
+ if (!base || !available.has(base))
271557
+ continue;
271558
+ const lift = liftOneDerivedSource(name, def, base, available, ctx);
271559
+ pending[i] = undefined;
271560
+ progressed = true;
271561
+ if (!lift)
271562
+ continue;
271563
+ lifts.push(lift);
271564
+ available.add(name);
271565
+ }
271566
+ }
271567
+ return lifts;
271568
+ }
271569
+ function liftOneDerivedSource(sourceName, def, base, available, ctx) {
271570
+ const baseDef = ctx.contents[base];
271571
+ const baseFieldNames = new Set((baseDef?.fields ?? []).map((f) => fieldKey(f)).filter(Boolean));
271572
+ const ownFields = (def.fields ?? []).filter((f) => !baseFieldNames.has(fieldKey(f)));
271573
+ const declaredJoins = ownFields.filter((f) => typeof f.join === "string").length;
271574
+ const joins = extractJoins(ownFields, {
271575
+ sourceNameById: ctx.sourceNameById,
271576
+ materializedSourceNames: available,
271577
+ liftText: ctx.liftText
271578
+ });
271579
+ if (joins.length !== declaredJoins)
271580
+ return;
271581
+ return {
271582
+ sourceName,
271583
+ base,
271584
+ refinements: [
271585
+ ...joins,
271586
+ ...extractRefinements(ownFields),
271587
+ ...extractSourceFilters(ownFilterList(def.filterList, baseDef?.filterList)),
271588
+ ...extractViews(ownFields, ctx.liftText)
271589
+ ]
271590
+ };
271591
+ }
271592
+ function fieldKey(field) {
271593
+ const f = field;
271594
+ if (typeof f?.as === "string")
271595
+ return f.as;
271596
+ return typeof f?.name === "string" ? f.name : "";
271597
+ }
271598
+ function ownFilterList(filterList, baseFilterList) {
271599
+ const own = filterList ?? [];
271600
+ const inherited = baseFilterList ?? [];
271601
+ let shared = 0;
271602
+ while (shared < inherited.length && shared < own.length && own[shared]?.code === inherited[shared]?.code) {
271603
+ shared++;
271604
+ }
271605
+ return own.slice(shared);
271606
+ }
271607
+ function buildServeShapeModelForBindings(bindings, rollupGroups = [], givens = [], derived = []) {
271536
271608
  const fragments = orderBindingsByJoinDeps(bindings).map(serveShapeFragment).join(`
271609
+ `);
271610
+ const lifted = derived.map(derivedSourceFragment).join(`
271537
271611
  `);
271538
271612
  const groups = rollupGroups.map(rollupServeShapeFragment).join(`
271539
271613
  `);
271540
- const flags = rollupGroups.length ? "##! experimental { virtual_source composite_sources }" : "##! experimental.virtual_source";
271541
- return {
271542
- modelText: groups ? `${flags}
271543
- ${fragments}
271544
- ${groups}
271545
- ` : `${flags}
271546
- ${fragments}
271547
- `
271548
- };
271614
+ const enabled = ["virtual_source"];
271615
+ if (rollupGroups.length)
271616
+ enabled.push("composite_sources");
271617
+ if (givens.length)
271618
+ enabled.push("givens");
271619
+ const flags = enabled.length === 1 ? "##! experimental.virtual_source" : `##! experimental { ${enabled.join(" ")} }`;
271620
+ const givenBlock = givens.length ? `given:
271621
+ ${givens.map(serveShapeGivenLine).join(`
271622
+ `)}
271623
+ ` : "";
271624
+ const body = [fragments, lifted, groups].filter(Boolean).join(`
271625
+ `);
271626
+ return { modelText: `${flags}
271627
+ ${givenBlock}${body}
271628
+ ` };
271549
271629
  }
271550
271630
  function buildServeShapeTiers(rollupGroups) {
271551
271631
  const always = [...NEVER_THINNED];
@@ -272514,6 +272594,209 @@ var init_gate_classification = __esm(() => {
272514
272594
  init_partition_annotation();
272515
272595
  });
272516
272596
 
272597
+ // src/service/persist_dynamic_terms.ts
272598
+ function givenNames(node) {
272599
+ const names = new Set;
272600
+ collectGivenRefs(node, names);
272601
+ return [...names];
272602
+ }
272603
+ function readsAGiven(node, seen, depth = 0) {
272604
+ if (depth > MAX_WALK_DEPTH) {
272605
+ throw new Error("given walk exceeded max depth");
272606
+ }
272607
+ if (node === null || typeof node !== "object")
272608
+ return false;
272609
+ if (seen.has(node))
272610
+ return false;
272611
+ seen.add(node);
272612
+ if (Array.isArray(node)) {
272613
+ return node.some((item) => readsAGiven(item, seen, depth + 1));
272614
+ }
272615
+ const record = node;
272616
+ if (record.node === "given" || record.node === "givenReference")
272617
+ return true;
272618
+ if (record.givenRef !== undefined)
272619
+ return true;
272620
+ const summary = record.refSummary;
272621
+ if (summary?.givenUsage && summary.givenUsage.length > 0)
272622
+ return true;
272623
+ if (Array.isArray(record.givenUsage) && record.givenUsage.length > 0) {
272624
+ return true;
272625
+ }
272626
+ return Object.values(record).some((v) => readsAGiven(v, seen, depth + 1));
272627
+ }
272628
+ function buildSubstitutesAGiven(persistSource) {
272629
+ try {
272630
+ const def = persistSource._sourceDef;
272631
+ if (def === null || typeof def !== "object") {
272632
+ throw new Error("compiled source definition is not readable");
272633
+ }
272634
+ const query = def.query;
272635
+ if (query !== null && typeof query === "object") {
272636
+ const usage = query.givenUsage;
272637
+ if (Array.isArray(usage) && usage.length > 0)
272638
+ return true;
272639
+ if (argumentBindsAGiven(query))
272640
+ return true;
272641
+ if (Array.isArray(usage))
272642
+ return false;
272643
+ }
272644
+ const {
272645
+ filterList: _readTimeFilters,
272646
+ fields: _readTimeFields,
272647
+ ...relation
272648
+ } = def;
272649
+ return readsAGiven(relation, new WeakSet);
272650
+ } catch {
272651
+ return true;
272652
+ }
272653
+ }
272654
+ function argumentBindsAGiven(node, depth = 0) {
272655
+ if (depth > MAX_WALK_DEPTH) {
272656
+ throw new Error("argument walk exceeded max depth");
272657
+ }
272658
+ if (node === null || typeof node !== "object")
272659
+ return false;
272660
+ if (Array.isArray(node)) {
272661
+ return node.some((item) => argumentBindsAGiven(item, depth + 1));
272662
+ }
272663
+ for (const [key, value] of Object.entries(node)) {
272664
+ if ((key === "arguments" || key === "sourceArguments") && readsAGiven(value, new WeakSet)) {
272665
+ return true;
272666
+ }
272667
+ if (argumentBindsAGiven(value, depth + 1))
272668
+ return true;
272669
+ }
272670
+ return false;
272671
+ }
272672
+ function classifyDynamicTerms(persistSource) {
272673
+ if (buildSubstitutesAGiven(persistSource)) {
272674
+ return {
272675
+ ok: false,
272676
+ reason: "given_in_persisted_query",
272677
+ detail: `a given is read while the persisted relation is BUILT, so its ` + `value is substituted at build time — from the declaration's ` + `default, the only value available then — and every caller is ` + `served that one slice. Move the given out of the persisted query ` + `and into the source's extend block (\`where: …\`), where it is ` + `left out of the build and applied per caller when the artifact ` + `is read`
272678
+ };
272679
+ }
272680
+ let def;
272681
+ try {
272682
+ def = persistSource._sourceDef;
272683
+ if (def === null || typeof def !== "object") {
272684
+ throw new Error("compiled source definition is not readable");
272685
+ }
272686
+ } catch (err) {
272687
+ return {
272688
+ ok: false,
272689
+ reason: "given_in_persisted_query",
272690
+ detail: `its given usage could not be determined ` + `(${err instanceof Error ? err.message : String(err)}), so the ` + `publisher cannot prove which givens the build would bake in`
272691
+ };
272692
+ }
272693
+ try {
272694
+ const fieldRefusal = classifyFields(def.fields);
272695
+ if (fieldRefusal)
272696
+ return fieldRefusal;
272697
+ const terms = collectDynamicTerms(def.filterList);
272698
+ const {
272699
+ filterList: _accounted,
272700
+ fields: _alsoAccounted,
272701
+ query: _cleared,
272702
+ ...rest
272703
+ } = def;
272704
+ if (readsAGiven(rest, new WeakSet)) {
272705
+ return {
272706
+ ok: false,
272707
+ reason: "given_in_persisted_query",
272708
+ detail: `a given sits in a part of the source this pass cannot place ` + `— it is neither an extend-block \`where:\` nor a field ` + `declared on the source — so the publisher cannot prove the ` + `build leaves it out`
272709
+ };
272710
+ }
272711
+ return { ok: true, terms };
272712
+ } catch (err) {
272713
+ return {
272714
+ ok: false,
272715
+ reason: "given_in_persisted_query",
272716
+ detail: `its given usage could not be determined ` + `(${err instanceof Error ? err.message : String(err)}), so the ` + `publisher cannot prove the build leaves every given out`
272717
+ };
272718
+ }
272719
+ }
272720
+ function classifyFields(fields) {
272721
+ if (!Array.isArray(fields))
272722
+ return;
272723
+ for (const field of fields) {
272724
+ if (field === null || typeof field !== "object")
272725
+ continue;
272726
+ const f = field;
272727
+ const label = typeof f.as === "string" ? f.as : typeof f.name === "string" ? f.name : "(unnamed)";
272728
+ if (f.join !== undefined) {
272729
+ if (readsAGiven(f.onExpression, new WeakSet)) {
272730
+ return {
272731
+ ok: false,
272732
+ reason: "dynamic_join",
272733
+ detail: `the join '${label}' has a given in its \`on:\` condition, ` + `which decides which rows join per caller`
272734
+ };
272735
+ }
272736
+ if (readsAGiven(f, new WeakSet)) {
272737
+ return {
272738
+ ok: false,
272739
+ reason: "dynamic_joined_where",
272740
+ detail: `the source joined as '${label}' is itself scoped by a ` + `given. Enter through a non-persisted extension that ` + `declares the join instead, so the given term is part of ` + `the query rather than of the artifact`
272741
+ };
272742
+ }
272743
+ continue;
272744
+ }
272745
+ if (readsAGiven(f, new WeakSet)) {
272746
+ return {
272747
+ ok: false,
272748
+ reason: "dynamic_projection",
272749
+ detail: `the field '${label}' reads a given, so its value would ` + `differ per caller`
272750
+ };
272751
+ }
272752
+ }
272753
+ return;
272754
+ }
272755
+ function localFieldNames(entry) {
272756
+ const usage = entry?.refSummary?.fieldUsage;
272757
+ if (!Array.isArray(usage))
272758
+ return [];
272759
+ const names = new Set;
272760
+ for (const use of usage) {
272761
+ const path9 = use?.path;
272762
+ if (!Array.isArray(path9) || path9.length !== 1 || typeof path9[0] !== "string") {
272763
+ return [];
272764
+ }
272765
+ names.add(path9[0]);
272766
+ }
272767
+ return [...names];
272768
+ }
272769
+ function readTimeDynamicTerms(persistSource) {
272770
+ try {
272771
+ const def = persistSource._sourceDef;
272772
+ return collectDynamicTerms(def?.filterList);
272773
+ } catch {
272774
+ return [{ code: "<unreadable>", givens: [], columns: [] }];
272775
+ }
272776
+ }
272777
+ function collectDynamicTerms(filterList) {
272778
+ if (!Array.isArray(filterList))
272779
+ return [];
272780
+ const terms = [];
272781
+ for (const entry of filterList) {
272782
+ if (entry === null || typeof entry !== "object")
272783
+ continue;
272784
+ if (!readsAGiven(entry, new WeakSet))
272785
+ continue;
272786
+ const code = entry.code;
272787
+ terms.push({
272788
+ code: typeof code === "string" ? code : "",
272789
+ givens: givenNames(entry),
272790
+ columns: localFieldNames(entry)
272791
+ });
272792
+ }
272793
+ return terms;
272794
+ }
272795
+ var MAX_WALK_DEPTH = 200;
272796
+ var init_persist_dynamic_terms = __esm(() => {
272797
+ init_given();
272798
+ });
272799
+
272517
272800
  // src/service/incremental_declaration.ts
272518
272801
  function safeTag(source) {
272519
272802
  try {
@@ -272582,6 +272865,11 @@ function readKey(tag, key) {
272582
272865
  function resolveIncrementalDeclaration(source, annotationFields) {
272583
272866
  const tag = safeTag(source);
272584
272867
  const columns = outputColumnTypes(source);
272868
+ const dynamicTerms = readTimeDynamicTerms(source);
272869
+ const scopeColumns = [
272870
+ ...new Set(dynamicTerms.flatMap((term) => term.columns))
272871
+ ].filter((name) => columns.has(name));
272872
+ const scopeIncomplete = dynamicTerms.some((term) => term.columns.length === 0) || dynamicTerms.some((term) => term.columns.some((name) => !columns.has(name)));
272585
272873
  const { aggregates, analytics } = queryDefinitionFieldKinds(source);
272586
272874
  const malformed = [];
272587
272875
  const rawRefresh = readKey(tag, "refresh");
@@ -272641,6 +272929,8 @@ function resolveIncrementalDeclaration(source, annotationFields) {
272641
272929
  watermark,
272642
272930
  watermarkOrderable,
272643
272931
  mergeKeys,
272932
+ scopeColumns,
272933
+ scopeIncomplete,
272644
272934
  watermarkInMergeKeys,
272645
272935
  strategy,
272646
272936
  malformed,
@@ -272652,6 +272942,7 @@ function resolveIncrementalDeclaration(source, annotationFields) {
272652
272942
  var REFRESH_MODES, RECOGNIZED_PERSIST_KEYS, ORDERABLE_TYPES;
272653
272943
  var init_incremental_declaration = __esm(() => {
272654
272944
  init_build_plan();
272945
+ init_persist_dynamic_terms();
272655
272946
  REFRESH_MODES = ["full", "incremental"];
272656
272947
  RECOGNIZED_PERSIST_KEYS = new Set([
272657
272948
  "persist",
@@ -272663,6 +272954,7 @@ var init_incremental_declaration = __esm(() => {
272663
272954
  "merge_key",
272664
272955
  "freshness",
272665
272956
  "queryMetadata",
272957
+ "partition",
272666
272958
  "sharing",
272667
272959
  "schedule"
272668
272960
  ]);
@@ -272674,8 +272966,70 @@ var init_incremental_declaration = __esm(() => {
272674
272966
  ]);
272675
272967
  });
272676
272968
 
272969
+ // src/service/persist_partition.ts
272970
+ function parsePartitionValue(raw) {
272971
+ return raw.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
272972
+ }
272973
+ function resolvePartitionColumns(persistSource, annotationFields) {
272974
+ const raw = annotationFields.partition;
272975
+ if (raw === undefined)
272976
+ return { ok: true, columns: [] };
272977
+ const columns = parsePartitionValue(raw);
272978
+ if (columns.length === 0)
272979
+ return { ok: true, columns: [] };
272980
+ if (!annotationFields.storage) {
272981
+ return {
272982
+ ok: false,
272983
+ reason: "partition_without_storage",
272984
+ detail: `'partition=' is declared without 'storage='. A colocated ` + `'#@ persist' writes into the source's own warehouse, where the ` + `table layout is that warehouse's own DDL — the publisher does ` + `not lay it out. Add 'storage=' to build into a storage ` + `destination, or drop 'partition='`
272985
+ };
272986
+ }
272987
+ const { publicNames, declaredNames } = columnSurface(persistSource);
272988
+ for (const column of columns) {
272989
+ if (publicNames.has(column))
272990
+ continue;
272991
+ if (declaredNames.has(column)) {
272992
+ return {
272993
+ ok: false,
272994
+ reason: "partition_column_not_public",
272995
+ detail: `'partition=' names '${column}', which the source hides. The ` + `stored table is narrowed to the source's public columns, so a ` + `hidden one is not there to partition by. Make it public, or ` + `partition by a column that is`
272996
+ };
272997
+ }
272998
+ return {
272999
+ ok: false,
273000
+ reason: "partition_column_unknown",
273001
+ detail: `'partition=' names '${column}', which is not a column of the ` + `source (a column removed with 'except:' is not one either). ` + `Partition by a column the source projects`
273002
+ };
273003
+ }
273004
+ return { ok: true, columns };
273005
+ }
273006
+ function columnSurface(persistSource) {
273007
+ const publicNames = new Set;
273008
+ const declaredNames = new Set;
273009
+ try {
273010
+ const def = persistSource._sourceDef;
273011
+ for (const field of def?.fields ?? []) {
273012
+ if (field === null || typeof field !== "object")
273013
+ continue;
273014
+ const f = field;
273015
+ if (f.join !== undefined)
273016
+ continue;
273017
+ const name = typeof f.as === "string" ? f.as : f.name;
273018
+ if (typeof name !== "string" || name.length === 0)
273019
+ continue;
273020
+ declaredNames.add(name);
273021
+ const access = f.accessModifier;
273022
+ if (access == null || access === "public")
273023
+ publicNames.add(name);
273024
+ }
273025
+ } catch {
273026
+ return { publicNames: new Set, declaredNames: new Set };
273027
+ }
273028
+ return { publicNames, declaredNames };
273029
+ }
273030
+
272677
273031
  // src/service/materialization_eligibility.ts
272678
- function assertMaterializationEligible(persistSource) {
273032
+ function assertMaterializationEligible(persistSource, annotationFields) {
272679
273033
  const sourceName = persistSource.name;
272680
273034
  let unbound;
272681
273035
  try {
@@ -272694,13 +273048,15 @@ function assertMaterializationEligible(persistSource) {
272694
273048
  message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination: it has unbound parameter(s) ` + `${unbound.map((p) => `'${p}'`).join(", ")}. A source with a free ` + `parameter is a template instantiated per query, so there is no ` + `single relation to materialize. Bind the parameter to a constant, ` + `or drop 'storage=' to serve it live from the source warehouse.`
272695
273049
  });
272696
273050
  }
272697
- if (referencesGiven(persistSource)) {
272698
- recordEligibilityRefused("given");
273051
+ const classified = classifyDynamicTerms(persistSource);
273052
+ if (!classified.ok) {
273053
+ recordEligibilityRefused(classified.reason);
272699
273054
  throw new MaterializationEligibilityError({
272700
- reason: "given",
272701
- message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination: it references a given. Givens bind per query and are ` + `used for row-level access control, so a materialized-once table ` + `served to everyone would leak filtered rows across tenants. This ` + `is refused for safety. Serve this source live (drop 'storage=').`
273055
+ reason: classified.reason,
273056
+ message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination: ${classified.detail}.`
272702
273057
  });
272703
273058
  }
273059
+ assertMergeKeyScopeResolvable(persistSource, readTimeDynamicTerms(persistSource), annotationFields, `Source '${sourceName}'`);
272704
273060
  if (referencesAuthorize(persistSource)) {
272705
273061
  recordEligibilityRefused("authorize");
272706
273062
  throw new MaterializationEligibilityError({
@@ -272715,8 +273071,39 @@ function assertMaterializationEligible(persistSource) {
272715
273071
  message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination: it declares a #(partition) marker. A partition ` + `filter binds a given at query time (the same mechanism as ` + `row-level access control), so a materialized-once table served ` + `frozen would leak every partition's rows to every caller. This ` + `is refused for safety. Serve this source live (drop 'storage=').`
272716
273072
  });
272717
273073
  }
273074
+ const partition = resolvePartitionColumns(persistSource, annotationFields);
273075
+ if (!partition.ok) {
273076
+ recordEligibilityRefused(partition.reason);
273077
+ throw new MaterializationEligibilityError({
273078
+ reason: partition.reason,
273079
+ message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination: ${partition.detail}.`
273080
+ });
273081
+ }
273082
+ return { terms: classified.terms, partitionColumns: partition.columns };
273083
+ }
273084
+ function assertMergeKeyScopeResolvable(persistSource, terms, annotationFields, what) {
273085
+ if (!annotationFields.merge_key?.trim())
273086
+ return;
273087
+ const stored = new Set(deriveColumns(persistSource).map((column) => column.name).filter((name) => typeof name === "string"));
273088
+ const unscopable = terms.filter((term) => term.columns.length === 0 || term.columns.some((name) => !stored.has(name)));
273089
+ if (unscopable.length === 0)
273090
+ return;
273091
+ recordEligibilityRefused("merge_key_scope_unresolved");
273092
+ throw new MaterializationEligibilityError({
273093
+ reason: "merge_key_scope_unresolved",
273094
+ message: `${what} cannot be materialized: it declares 'merge_key=' and is ` + `scoped by a term this pass cannot express as a predicate over the ` + `stored table's own columns (${unscopable.map((term) => `'${term.code}'`).join(", ")}). An incremental refresh matches rows by the merge ` + `key, and the stored table holds every caller's rows, so the match ` + `must also carry the scoping columns or it would update rows ` + `belonging to other callers. Scope this source with a term over its ` + `own columns, or drop 'merge_key=' to refresh by watermark range.`
273095
+ });
272718
273096
  }
272719
- function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = persistSource.name, origin2 = "persist", gateOutcome) {
273097
+ function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = persistSource.name, origin2 = "persist", gateOutcome, annotationFields = {}) {
273098
+ const partition = resolvePartitionColumns(persistSource, annotationFields);
273099
+ if (!partition.ok) {
273100
+ recordEligibilityRefused(partition.reason);
273101
+ const what2 = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}'` : `Source '${sourceName}'`;
273102
+ throw new MaterializationEligibilityError({
273103
+ reason: partition.reason,
273104
+ message: `${what2} cannot be materialized (colocated '#@ persist'): ` + `${partition.detail}.`
273105
+ });
273106
+ }
272720
273107
  if (referencesPartition(persistSource)) {
272721
273108
  recordEligibilityRefused("partition");
272722
273109
  const what2 = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}'` : `Source '${sourceName}'`;
@@ -272727,13 +273114,21 @@ function assertColocatedPersistNotAuthorizeGated(persistSource, sourceName = per
272727
273114
  });
272728
273115
  }
272729
273116
  if (buildSubstitutesAGiven(persistSource)) {
273117
+ if (origin2 === "preaggregate") {
273118
+ recordEligibilityRefused("preaggregate_over_dynamic_source");
273119
+ throw new MaterializationEligibilityError({
273120
+ reason: "preaggregate_over_dynamic_source",
273121
+ message: `Pre-aggregation rollup '${sourceName}' cannot be materialized: ` + `the source it rolls up is scoped by a given. Building the rollup ` + `reads that source, which applies its extend-block \`where:\` and ` + `substitutes the given's default — so the rollup would hold one ` + `caller's aggregate and serve it to everyone. Unlike a persisted ` + `source, a rollup has no read-time re-application to put the term ` + `back. Drop the '#@ preaggregate' from this measure, or roll up a ` + `source that is not caller-scoped.`
273122
+ });
273123
+ }
272730
273124
  recordEligibilityRefused("given_in_persisted_query");
272731
- const what2 = origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}'` : `Source '${sourceName}'`;
273125
+ const what2 = `Source '${sourceName}'`;
272732
273126
  throw new MaterializationEligibilityError({
272733
273127
  reason: "given_in_persisted_query",
272734
273128
  message: `${what2} cannot be materialized (colocated '#@ persist'): its ` + `persisted query references a given. A given the persisted query ` + `is built with is substituted at BUILD time, so the table would ` + `hold the default's rows and serve them to every caller, ignoring ` + `the value each one supplies. Keep the given OUT of the persisted ` + `query and apply it when the source is read — a \`where:\` in the ` + `source's extend block, or a dimension, measure or join declared ` + `there — where it binds per caller over the materialized rows. ` + `Otherwise stop persisting this source.`
272735
273129
  });
272736
273130
  }
273131
+ assertMergeKeyScopeResolvable(persistSource, readTimeDynamicTerms(persistSource), annotationFields, origin2 === "preaggregate" ? `Pre-aggregation rollup '${sourceName}'` : `Source '${sourceName}'`);
272737
273132
  if (!referencesAuthorize(persistSource))
272738
273133
  return;
272739
273134
  if (origin2 === "persist" && gateOutcome?.classification === "row_level" && gateOutcome.attributed) {
@@ -272768,91 +273163,6 @@ function unboundParameterNames(persistSource) {
272768
273163
  }
272769
273164
  return unbound;
272770
273165
  }
272771
- function referencesGiven(persistSource) {
272772
- try {
272773
- return walkForGiven(persistSource._sourceDef, new WeakSet, 0);
272774
- } catch {
272775
- return true;
272776
- }
272777
- }
272778
- function argumentBindsAGiven(node, depth = 0) {
272779
- if (depth > MAX_GIVEN_WALK_DEPTH) {
272780
- throw new Error("argument walk exceeded max depth");
272781
- }
272782
- if (node === null || typeof node !== "object")
272783
- return false;
272784
- if (Array.isArray(node)) {
272785
- return node.some((item) => argumentBindsAGiven(item, depth + 1));
272786
- }
272787
- for (const [key, value] of Object.entries(node)) {
272788
- if ((key === "arguments" || key === "sourceArguments") && walkForGiven(value, new WeakSet, 0)) {
272789
- return true;
272790
- }
272791
- if (argumentBindsAGiven(value, depth + 1))
272792
- return true;
272793
- }
272794
- return false;
272795
- }
272796
- function buildSubstitutesAGiven(persistSource) {
272797
- try {
272798
- const def = persistSource._sourceDef;
272799
- if (def === null || typeof def !== "object") {
272800
- throw new Error("compiled source definition is not readable");
272801
- }
272802
- const query = def.query;
272803
- if (query !== null && typeof query === "object") {
272804
- const usage = query.givenUsage;
272805
- if (Array.isArray(usage) && usage.length > 0)
272806
- return true;
272807
- if (argumentBindsAGiven(query))
272808
- return true;
272809
- if (Array.isArray(usage))
272810
- return false;
272811
- }
272812
- const {
272813
- filterList: _sourceFilters,
272814
- fields: _queryTimeFields,
272815
- ...relation
272816
- } = def;
272817
- return walkForGiven(relation, new WeakSet, 0);
272818
- } catch {
272819
- return true;
272820
- }
272821
- }
272822
- function walkForGiven(node, seen, depth) {
272823
- if (depth > MAX_GIVEN_WALK_DEPTH) {
272824
- throw new Error("given-usage walk exceeded max depth");
272825
- }
272826
- if (node === null || typeof node !== "object")
272827
- return false;
272828
- if (seen.has(node))
272829
- return false;
272830
- seen.add(node);
272831
- if (Array.isArray(node)) {
272832
- for (const item of node) {
272833
- if (walkForGiven(item, seen, depth + 1))
272834
- return true;
272835
- }
272836
- return false;
272837
- }
272838
- const record = node;
272839
- const nodeKind = record.node;
272840
- if (nodeKind === "given" || nodeKind === "givenReference")
272841
- return true;
272842
- if (record.givenRef !== undefined)
272843
- return true;
272844
- const refSummary = record.refSummary;
272845
- if (refSummary?.givenUsage && refSummary.givenUsage.length > 0)
272846
- return true;
272847
- if (Array.isArray(record.givenUsage) && record.givenUsage.length > 0) {
272848
- return true;
272849
- }
272850
- for (const value of Object.values(record)) {
272851
- if (walkForGiven(value, seen, depth + 1))
272852
- return true;
272853
- }
272854
- return false;
272855
- }
272856
273166
  function referencesPartition(persistSource) {
272857
273167
  try {
272858
273168
  return walkForPartition(persistSource._sourceDef, new WeakSet, 0);
@@ -272991,7 +273301,9 @@ var init_materialization_eligibility = __esm(() => {
272991
273301
  init_errors();
272992
273302
  init_materialization_metrics();
272993
273303
  init_authorize();
273304
+ init_build_plan();
272994
273305
  init_partition_annotation();
273306
+ init_persist_dynamic_terms();
272995
273307
  });
272996
273308
 
272997
273309
  // src/service/preaggregation_compile.ts
@@ -273055,6 +273367,21 @@ function deriveColumns(persistSource) {
273055
273367
  return [];
273056
273368
  }
273057
273369
  }
273370
+ function planDynamicFields(source, annotationFields) {
273371
+ const out = {};
273372
+ const partition = resolvePartitionColumns(source, annotationFields);
273373
+ if (partition.ok && partition.columns.length > 0) {
273374
+ out.partition = partition.columns;
273375
+ }
273376
+ const classified = classifyDynamicTerms(source);
273377
+ if (classified.ok && classified.terms.length > 0) {
273378
+ out.strippedTerms = classified.terms.map((term) => ({
273379
+ code: term.code,
273380
+ givens: term.givens
273381
+ }));
273382
+ }
273383
+ return out;
273384
+ }
273058
273385
  function publicColumnNames(persistSource) {
273059
273386
  let names;
273060
273387
  try {
@@ -273208,7 +273535,19 @@ function* iterGraphSources(graph, sources) {
273208
273535
  }
273209
273536
  }
273210
273537
  function computeSourceEntityId(source, connectionDigests) {
273211
- return source.makeBuildId(connectionDigests[source.connectionName], source.getSQL());
273538
+ return source.makeBuildId(connectionDigests[source.connectionName], addressPartitionLayout(source, source.getSQL()));
273539
+ }
273540
+ function addressPartitionLayout(source, sql) {
273541
+ let columns = [];
273542
+ try {
273543
+ const resolved = resolvePartitionColumns(source, deriveAnnotationFields(source));
273544
+ if (resolved.ok)
273545
+ columns = resolved.columns;
273546
+ } catch {}
273547
+ if (columns.length === 0)
273548
+ return sql;
273549
+ return `${sql}
273550
+ -- partitioned by: ${columns.join(", ")}`;
273212
273551
  }
273213
273552
  async function resolvePackageConnections(pkg, names) {
273214
273553
  const map = new Map;
@@ -273401,9 +273740,9 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
273401
273740
  if (!rollup) {
273402
273741
  try {
273403
273742
  if (declaresStorage) {
273404
- assertMaterializationEligible(source);
273743
+ assertMaterializationEligible(source, deriveAnnotationFields(source));
273405
273744
  } else {
273406
- assertColocatedPersistNotAuthorizeGated(source, source.name, "persist", options?.sourceGateOutcomes?.[sourceID]);
273745
+ assertColocatedPersistNotAuthorizeGated(source, source.name, "persist", options?.sourceGateOutcomes?.[sourceID], annotationFields);
273407
273746
  }
273408
273747
  } catch (err) {
273409
273748
  if (!(err instanceof MaterializationEligibilityError))
@@ -273421,10 +273760,10 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
273421
273760
  } else {
273422
273761
  let storageRefused = false;
273423
273762
  try {
273424
- assertColocatedPersistNotAuthorizeGated(source, source.name, "preaggregate", options?.sourceGateOutcomes?.[sourceID]);
273763
+ assertColocatedPersistNotAuthorizeGated(source, source.name, "preaggregate", options?.sourceGateOutcomes?.[sourceID], annotationFields);
273425
273764
  if (declaresStorage) {
273426
273765
  storageRefused = true;
273427
- assertMaterializationEligible(source);
273766
+ assertMaterializationEligible(source, deriveAnnotationFields(source));
273428
273767
  storageRefused = false;
273429
273768
  }
273430
273769
  } catch (err) {
@@ -273459,6 +273798,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
273459
273798
  freshness: resolveFreshness(source, packageMaterialization),
273460
273799
  queryMetadata: resolveQueryMetadata(source, packageMaterialization),
273461
273800
  columns: deriveColumns(source),
273801
+ ...planDynamicFields(source, annotationFields),
273462
273802
  annotationFields,
273463
273803
  modelPath: sourceModelPaths?.[sourceID]
273464
273804
  };
@@ -273507,7 +273847,7 @@ function collectSourceEligibility(sources) {
273507
273847
  const refused = {};
273508
273848
  for (const source of Object.values(sources)) {
273509
273849
  try {
273510
- assertMaterializationEligible(source);
273850
+ assertMaterializationEligible(source, deriveAnnotationFields(source));
273511
273851
  eligible.push(source.name);
273512
273852
  } catch (err) {
273513
273853
  refused[source.name] = errMessage(err);
@@ -273521,7 +273861,7 @@ function collectColocatedSourceEligibility(compiled) {
273521
273861
  for (const [sourceID, source] of Object.entries(compiled.sources)) {
273522
273862
  const origin2 = compiled.preaggregatePlans?.[sourceID] ? "preaggregate" : "persist";
273523
273863
  try {
273524
- assertColocatedPersistNotAuthorizeGated(source, source.name, origin2, compiled.sourceGateOutcomes?.[sourceID]);
273864
+ assertColocatedPersistNotAuthorizeGated(source, source.name, origin2, compiled.sourceGateOutcomes?.[sourceID], deriveAnnotationFields(source));
273525
273865
  } catch (err) {
273526
273866
  let sourceEntityId2;
273527
273867
  try {
@@ -273565,6 +273905,7 @@ var init_build_plan = __esm(() => {
273565
273905
  init_materialization_eligibility();
273566
273906
  init_model();
273567
273907
  init_preaggregation_compile();
273908
+ init_persist_dynamic_terms();
273568
273909
  init_quoting();
273569
273910
  FRESHNESS_FALLBACKS = ["live", "stale_ok", "fail"];
273570
273911
  });
@@ -275574,7 +275915,7 @@ var init_model = __esm(() => {
275574
275915
  freshnessFallback: b.freshnessFallback
275575
275916
  }, at) === "serve_table");
275576
275917
  }
275577
- async loadServeShapeQuery(queryString) {
275918
+ async loadServeShapeQuery(queryString, givens) {
275578
275919
  const freshBindings = this.freshServeBindings(Date.now());
275579
275920
  if (freshBindings.length === 0) {
275580
275921
  throw new Error("no fresh storage serve bindings for this query");
@@ -275600,7 +275941,7 @@ var init_model = __esm(() => {
275600
275941
  ...groups.flatMap((g) => g.members)
275601
275942
  ]);
275602
275943
  const runnable = this.serveShapeCache.materializer.loadRestrictedQuery(queryString);
275603
- const probeSQL = await runnable.getSQL({ virtualMap });
275944
+ const probeSQL = await runnable.getSQL({ virtualMap, givens });
275604
275945
  const rollupPaths = [
275605
275946
  ...buildVirtualMap(groups.flatMap((g) => g.members))
275606
275947
  ].flatMap(([, byHandle]) => [...byHandle.values()]);
@@ -275617,7 +275958,7 @@ var init_model = __esm(() => {
275617
275958
  ...b,
275618
275959
  refinements: b.refinements.filter((r) => keep.has(r.kind))
275619
275960
  } : b);
275620
- const materializer = this.buildServeShapeMaterializer(shaped, groups);
275961
+ const materializer = this.buildServeShapeMaterializer(shaped, groups, tier === 0 ? this.liftedDerivedSources(enriched) : []);
275621
275962
  if (tier === lastTier) {
275622
275963
  try {
275623
275964
  await materializer.getModel();
@@ -275678,8 +276019,23 @@ var init_model = __esm(() => {
275678
276019
  }
275679
276020
  return servable;
275680
276021
  }
275681
- buildServeShapeMaterializer(bindings, rollupGroups = []) {
275682
- const { modelText } = buildServeShapeModelForBindings(bindings, rollupGroups);
276022
+ serveShapeGivens() {
276023
+ const out = [];
276024
+ for (const given of this.givens ?? []) {
276025
+ if (typeof given.name !== "string" || given.name.length === 0)
276026
+ continue;
276027
+ if (typeof given.type !== "string" || given.type.length === 0)
276028
+ continue;
276029
+ out.push({
276030
+ name: given.name,
276031
+ type: given.type,
276032
+ defaultText: typeof given.default === "string" ? given.default : undefined
276033
+ });
276034
+ }
276035
+ return out;
276036
+ }
276037
+ buildServeShapeMaterializer(bindings, rollupGroups = [], derived = []) {
276038
+ const { modelText } = buildServeShapeModelForBindings(bindings, rollupGroups, this.serveShapeGivens(), derived);
275683
276039
  const root = "file:///storage-serve-shape/";
275684
276040
  const url2 = `${root}shape.malloy`;
275685
276041
  const runtime = new Runtime2({
@@ -275693,15 +276049,14 @@ var init_model = __esm(() => {
275693
276049
  importBaseURL: new URL(root)
275694
276050
  });
275695
276051
  }
275696
- serveBindingsWithRefinements(bindings) {
275697
- const contents = this.modelDef?.contents;
276052
+ authorModelLift() {
276053
+ const contents = this.modelDef?.contents ?? {};
275698
276054
  const sourceNameById = new Map;
275699
- for (const [name, def] of Object.entries(contents ?? {})) {
276055
+ for (const [name, def] of Object.entries(contents)) {
275700
276056
  if (typeof def?.sourceID === "string") {
275701
276057
  sourceNameById.set(def.sourceID, name);
275702
276058
  }
275703
276059
  }
275704
- const materializedSourceNames = new Set(bindings.map((b) => b.sourceName));
275705
276060
  const fileCache = new Map;
275706
276061
  const liftText = (location) => {
275707
276062
  if (!location?.url?.startsWith("file:"))
@@ -275716,6 +276071,21 @@ var init_model = __esm(() => {
275716
276071
  const text = fileCache.get(location.url);
275717
276072
  return text ? sliceSourceRange(text, location.range) : undefined;
275718
276073
  };
276074
+ return { contents, sourceNameById, liftText };
276075
+ }
276076
+ liftedDerivedSources(bindings) {
276077
+ const { contents, sourceNameById, liftText } = this.authorModelLift();
276078
+ return liftDerivedSources({
276079
+ contents,
276080
+ sourceNameById,
276081
+ shapeSourceNames: new Set(bindings.map((b) => b.sourceName)),
276082
+ boundSourceNames: new Set(this.serveBindings.map((b) => b.sourceName)),
276083
+ liftText
276084
+ });
276085
+ }
276086
+ serveBindingsWithRefinements(bindings) {
276087
+ const { contents, sourceNameById, liftText } = this.authorModelLift();
276088
+ const materializedSourceNames = new Set(bindings.map((b) => b.sourceName));
275719
276089
  return bindings.map((b) => {
275720
276090
  const fields = contents?.[b.sourceName]?.fields;
275721
276091
  const schema = narrowSchemaToPublic(b.schema, fields);
@@ -275811,7 +276181,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
275811
276181
  }
275812
276182
  if (storageRoutingPossible && !routingBlockedByRowLevelGate) {
275813
276183
  try {
275814
- const shaped = await this.loadServeShapeQuery(queryString);
276184
+ const shaped = await this.loadServeShapeQuery(queryString, querySurfaceGivens);
275815
276185
  runnable = shaped.runnable;
275816
276186
  serveVirtualMap = shaped.virtualMap;
275817
276187
  serveShapeBindings = shaped.bindings;
@@ -275889,7 +276259,7 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
275889
276259
  let executionTime = 0;
275890
276260
  let queryResults;
275891
276261
  let appliedQueryMetadata;
275892
- const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
276262
+ const effectiveGivens = querySurfaceGivens;
275893
276263
  try {
275894
276264
  const preparedResult = await runnable.getPreparedResult({
275895
276265
  givens: effectiveGivens,
@@ -308879,8 +309249,12 @@ function deltaStatements(params) {
308879
309249
  const q = (name) => quoteIdentifier(name, dialect);
308880
309250
  const columnList = columns.map(q).join(", ");
308881
309251
  if (mergeKeys.length > 0) {
309252
+ const matchNames = [
309253
+ ...mergeKeys,
309254
+ ...(params.scopeColumns ?? []).filter((c) => !mergeKeys.includes(c))
309255
+ ];
308882
309256
  const keys = new Set(mergeKeys);
308883
- const on = mergeKeys.map((k) => `(__t.${q(k)} = __s.${q(k)} OR (__t.${q(k)} IS NULL AND __s.${q(k)} IS NULL))`).join(" AND ");
309257
+ const on = matchNames.map((k) => `(__t.${q(k)} = __s.${q(k)} OR (__t.${q(k)} IS NULL AND __s.${q(k)} IS NULL))`).join(" AND ");
308884
309258
  const updates = columns.filter((c) => !keys.has(c)).map((c) => `${q(c)} = __s.${q(c)}`);
308885
309259
  const clauses = [
308886
309260
  `MERGE INTO ${quotedTablePath} AS __t`,
@@ -309088,6 +309462,7 @@ async function planIncrementalStep(inputs) {
309088
309462
  deltaSQL: await target.deltaRows(start, end.bound),
309089
309463
  columns: inputs.columns,
309090
309464
  mergeKeys: lineage.mergeKeys,
309465
+ scopeColumns: lineage.scopeColumns,
309091
309466
  watermarkName: lineage.watermarkName,
309092
309467
  start,
309093
309468
  end: end.bound
@@ -309827,6 +310202,7 @@ class Package {
309827
310202
  ...this.dashboardWarnings,
309828
310203
  ...this.storageWarnings(),
309829
310204
  ...this.droppedPersistWarnings(),
310205
+ ...this.persistRefusalWarnings(),
309830
310206
  ...this.emptyDiscoveryWarnings(),
309831
310207
  ...this.persistenceCollisionWarnings().map((message) => ({ message })),
309832
310208
  ...this.preaggregateAccessWarnings(),
@@ -310013,6 +310389,20 @@ class Package {
310013
310389
  message: `is annotated '#@ persist' but was not recognized as a ` + `materializable source, so nothing is materialized and it is served ` + `live. Only query/aggregate sources build; a filtered pass-through ` + `does not. Persist a query source, or invoke a parameterized source ` + `with a bound argument.`
310014
310390
  }));
310015
310391
  }
310392
+ persistRefusalWarnings() {
310393
+ const refused = this.buildPlan?.refusedSources;
310394
+ if (!refused)
310395
+ return [];
310396
+ return Object.entries(refused).map(([sourceID, source]) => {
310397
+ const subject = source.tier === "preaggregate" ? this.buildPlan?.sources?.[sourceID]?.preaggregate?.baseSourceName : source.name;
310398
+ const warning = { message: source.message };
310399
+ if (source.modelPath)
310400
+ warning.model = source.modelPath;
310401
+ if (subject)
310402
+ warning.subject = subject;
310403
+ return warning;
310404
+ });
310405
+ }
310016
310406
  storageWarnings() {
310017
310407
  const mode = getPersistStorageMode();
310018
310408
  if (mode === "on" || !this.buildPlan?.sources)
@@ -317433,11 +317823,16 @@ a returned source's documentation -- text the answerer reads and acts on. Only
317433
317823
  \`missing\` is a retrieval miss; the route per entity is recorded so the strict
317434
317824
  count is still there.
317435
317825
 
317436
- Recall 1.0 with a wrong answer exonerates retrieval: the failure is in the query.
317826
+ Recall 1.0 with a wrong answer exonerates retrieval: everything arrived. Whether
317827
+ the agent misused it or the docs never said how to use it is \`eval-diagnose\`'s
317828
+ call, sufficiency first, so the row reads \`delivered, wrong\` and names no owner.
317437
317829
  Recall below 1.0 and \`coverage: covered\` means the entity existed and search did
317438
- not surface it; \`derivable\` or \`absent\` means there was nothing to surface. Those
317439
- look identical in an answer score and have opposite owners, which is what makes
317440
- this number worth having. It uses the search terms the answerer chose, so it
317830
+ not surface it -- a documentation finding, because the retrieval algorithm is
317831
+ fixed (semantic search over doc strings) and an entity that exists and does not
317832
+ come back is one whose docs do not say what people ask; \`eval-diagnose\` calls it
317833
+ \`NOT-RETURNED\`, owner model. \`derivable\` or \`absent\` means there was nothing to
317834
+ surface. Those look identical in an answer score and have different owners,
317835
+ which is what makes this number worth having. It uses the search terms the answerer chose, so it
317441
317836
  attributes a failure *within* an arm and does not compare retrieval across arms
317442
317837
  -- that is the engine-side \`eval-retrieval\` skill, which does not ship here.
317443
317838
 
@@ -317465,6 +317860,13 @@ How to invoke it. Either \`--model <file-or-dir>\` for a local package or
317465
317860
  python3 check_coverage.py --set evals/ecommerce --model model.malloy --version 0.0.58
317466
317861
  \`\`\`
317467
317862
 
317863
+ Then hand the report back to the run: \`run_baseline.py --coverage <report>\`.
317864
+ Its per-case verdict beats the case's authored \`coverage\` label in retrieval
317865
+ attribution, \`run.json\` records which report was read, and each retrieval row
317866
+ says whether \`measured\`, \`authored\` or \`none\` charged the failure. Without a
317867
+ report, a case with no label is attributed to nobody rather than to the model,
317868
+ which is what used to happen.
317869
+
317468
317870
  Two flags change what the number means, so choose them rather than inheriting
317469
317871
  them. \`--repeat N\` samples each case N times and takes the majority; it defaults
317470
317872
  to **1 for a set**, because the score is a trend over many cases rather than a
@@ -317499,6 +317901,34 @@ stops being stable. Do not take the over-size message's advice to narrow
317499
317901
  \`--model\` to one file, which drops every imported source and manufactures
317500
317902
  \`COVERAGE\` verdicts.
317501
317903
 
317904
+ ## Validate the definitions, not every answer
317905
+
317906
+ A golden must not be derived through the definitions the question TESTS. It may
317907
+ reuse everything the question does not turn on -- joins, base sources, date
317908
+ handling -- because circularity only bites where the key and the answer share the
317909
+ step under measurement. That is what makes this affordable on a model too large
317910
+ to reimplement.
317911
+
317912
+ \`scripts/verify_definitions.py\` checks each definition once against the layer
317913
+ directly beneath it, and every case depending on it inherits the result. A golden
317914
+ is trustworthy if it was derived independently, OR if every definition it tests
317915
+ has itself been validated.
317916
+
317917
+ Two things it will not do, and both matter more than what it does. It never
317918
+ reports a definition reaching through a join as validated, because fanout
317919
+ inflates the measure and the control expression equally and the comparison stays
317920
+ green on a broken join. And building the ledger without a server exits 3, not 0:
317921
+ nothing was checked, and a caller must not read that as a pass.
317922
+
317923
+ A raw check -- a definition that reaches through a join -- is an authored
317924
+ control: a person writes the population down as a query on the ledger record and
317925
+ the tool re-runs it every time. It is not found unaided, and the docs say so.
317926
+ \`verify_goldens.py --definitions <ledger>\` then applies the composition rule at
317927
+ the gate: a set with no truth package exits 0 when every value-bearing case's
317928
+ tested definitions are validated, and names the cases that are not.
317929
+
317930
+ **Read \`reference/definition-ledger.md\` before building or quoting one.**
317931
+
317502
317932
  ## Step 5: Distrust the golden
317503
317933
 
317504
317934
  A reference answer can be wrong (parent-column fanout, a join on a shared
@@ -317561,7 +317991,7 @@ original answerer to "try again" with the new key in context.
317561
317991
 
317562
317992
  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-answer/<name>\` instead.
317563
317993
 
317564
- Available: coverage-limits, ledger-schema.`
317994
+ Available: coverage-limits, definition-ledger, ledger-schema.`
317565
317995
  },
317566
317996
  {
317567
317997
  name: "eval-answer/coverage-limits",
@@ -317781,11 +318211,320 @@ the candidate sources only, to settle \`AMBIGUOUS\`, \`NO-DISAMBIG\` and
317781
318211
  \`CONVENTION\`. Deliberately NOT retrieval ranking: folding retrieval into this
317782
318212
  number would collapse the distinction from recall that the metric exists to
317783
318213
  draw.`
318214
+ },
318215
+ {
318216
+ name: "eval-answer/definition-ledger",
318217
+ description: "The definition ledger. Reference detail for the eval-answer skill.",
318218
+ body: `<!-- Validating a model's definitions once, so answer keys need not be re-derived per case. Read before building or reading a ledger. -->
318219
+
318220
+ # The definition ledger
318221
+
318222
+ ## Why it exists
318223
+
318224
+ The loop's standing rule is that a golden must never be derived through the model
318225
+ under test, because a model bug would otherwise certify its own golden. That rule
318226
+ is sound, and it does not scale: on a model with real transformation, deriving
318227
+ every key independently means reimplementing the model. You end up with two
318228
+ implementations and no reason to trust either, and the eval task becomes *rebuild
318229
+ the model to verify the model*.
318230
+
318231
+ The ledger is the other half. **A definition is checked once, against the layer
318232
+ directly beneath it, and every case depending on it inherits the result.** Work
318233
+ scales with the number of definitions rather than the number of cases, and stops
318234
+ growing as cases are added.
318235
+
318236
+ The rule this replaces:
318237
+
318238
+ > A key must be independent of **the definitions the question tests**, not of the
318239
+ > whole model.
318240
+
318241
+ And the rule that makes that sound:
318242
+
318243
+ > A golden is trustworthy if it was derived independently, **or** if every
318244
+ > definition it tests has itself been validated.
318245
+
318246
+ ## Deep models are cheaper per case, not dearer
318247
+
318248
+ The tractability argument is about definitions being **shared**, not about models
318249
+ being shallow. An earlier draft claimed every measure sits one or two hops from a
318250
+ raw column; measured across four models, that is false:
318251
+
318252
+ | Model | Definitions | within 2 hops | deepest chain |
318253
+ |---|---|---|---|
318254
+ | ecommerce (sample) | 32 | 90.6% | 3 |
318255
+ | storefront (sample) | 19 | 94.7% | 3 |
318256
+ | a 492-definition benchmark | 492 | 100% | 2 |
318257
+ | a real 548-definition customer model | 548 | **42.5%** | **12** |
318258
+
318259
+ On that customer model, the twenty deepest measures close over **812**
318260
+ definition-checks with no reuse and **116** with the ledger: an 86% saving.
318261
+ Reuse pays off *more* as chains deepen, because deep chains share their lower
318262
+ layers. Depth is what makes the ledger worth having.
318263
+
318264
+ ## What a record holds
318265
+
318266
+ \`\`\`json
318267
+ {
318268
+ "entityId": "measure:order_items:total_sales",
318269
+ "kind": "measure",
318270
+ "source": "order_items",
318271
+ "name": "total_sales",
318272
+ "expr": "sale_price.sum()",
318273
+ "exprSha": "3f9a…",
318274
+ "depends": ["measure:order_items:order_item_count"],
318275
+ "check": {"kind": "within_model", "slice": "first 200 rows"},
318276
+ "verdict": "agrees",
318277
+ "needs": null
318278
+ }
318279
+ \`\`\`
318280
+
318281
+ \`exprSha\` is a **Merkle chain**, not a hash of the one line: it covers the
318282
+ definition and everything beneath it. A measure whose own text never moved is
318283
+ still stale once something it builds on changes, and a flat hash would report it
318284
+ as current. \`packageSha\` exists already but is whole-package, so it invalidates
318285
+ everything on any edit and distinguishes nothing.
318286
+
318287
+ ## The two check kinds
318288
+
318289
+ ### \`within_model\` -- no raw access, no truth package
318290
+
318291
+ Ask the model for the entity and for the expression it **claims** to be, in one
318292
+ query, and compare:
318293
+
318294
+ \`\`\`malloy
318295
+ run: order_items -> {
318296
+ aggregate:
318297
+ stated is total_sales
318298
+ control is sale_price.sum()
318299
+ }
318300
+ \`\`\`
318301
+
318302
+ Both sides come from **one** query against one source, so they see identical
318303
+ rows: a difference is the definition, not a moving population.
318304
+
318305
+ A dimension is scalar, and \`aggregate:\` rejects it outright ("Cannot use a scalar
318306
+ field in an aggregate"), so a dimension is compared row by row over a slice. The
318307
+ slice is recorded, because a slice is weaker evidence than a whole table and the
318308
+ report must not imply otherwise.
318309
+
318310
+ This catches a definition that is not what it says: a filter nobody mentioned, a
318311
+ renamed column, the wrong aggregate, or a served model that has moved away from
318312
+ the text the ledger recorded.
318313
+
318314
+ ### \`raw\` -- needs a lens onto the base tables
318315
+
318316
+ **A within-model check is blind to fanout.** A join that duplicates rows inflates
318317
+ the stated measure and the control expression equally, and the comparison stays
318318
+ green on a genuinely broken join. So an expression reaching through a join records
318319
+ \`unchecked\` with a reason, **never \`agrees\`** -- reporting it as validated would be
318320
+ the tool committing the exact error it exists to find.
318321
+
318322
+ The exception is aggregates that survive uniform duplication: \`AVG\`, \`STDDEV\`,
318323
+ \`MIN\`, \`MAX\`. Those stay checkable across a join.
318324
+
318325
+ Detection is by join **name**, not by spotting a dot. \`sale_price.sum()\` is a
318326
+ method call on a column in the same source and is perfectly checkable;
318327
+ \`inventory_items.cost.sum()\` traverses a join and is not. A first implementation
318328
+ matched any \`word.word\` and held back \`total_sales\` -- the measure twelve of the
318329
+ ecommerce set's cases depend on -- for a join it does not cross.
318330
+
318331
+ ## A raw check is a control a person wrote
318332
+
318333
+ An earlier version of this page, and the plan behind it, said raw checks would
318334
+ "catch a definition wrong about the business, like the cogs case." **That is
318335
+ retracted.** Re-deriving \`total_gross_margin\`'s own expression over the base
318336
+ tables -- reconstructing the model's join from its declared key -- computes the
318337
+ same quantity, cancelled lines included, and agrees. Mechanically that catches
318338
+ only fanout from a mis-declared join, which Malloy's symmetric aggregates already
318339
+ mostly prevent. It cannot see that the population is wrong.
318340
+
318341
+ What caught the cogs case was a person who read the \`status\` doc ("Cancelled
318342
+ lines never ship and are not sales") and wrote \`where: status != 'Cancelled'\`
318343
+ into the golden's query. That is an **authored assertion**. So a raw check is
318344
+ exactly that, made durable: the conductor writes a control query onto the ledger
318345
+ record, and \`verify_definitions.py\` re-runs and compares it on every rebuild.
318346
+
318347
+ \`\`\`json
318348
+ {
318349
+ "entityId": "measure:order_items:total_gross_margin",
318350
+ "check": {
318351
+ "kind": "raw",
318352
+ "against": "model",
318353
+ "query": "run: order_items -> { aggregate: control is sale_price.sum() { where: status != 'Cancelled' } - inventory_items.cost.sum() { where: status != 'Cancelled' } }",
318354
+ "note": "excludes Cancelled, per status's own doc and units_sold"
318355
+ },
318356
+ "cause": "CONVENTION"
318357
+ }
318358
+ \`\`\`
318359
+
318360
+ A control may be authored on **any** measure record, not only a \`raw\` one, and
318361
+ it supersedes the within-model check. That check can only say a measure is what
318362
+ its expression says; a person can say what its population should be. \`total_sales
318363
+ is sale_price.sum()\` agrees with itself while including the cancelled lines its
318364
+ own docs exclude -- the within-model check reads \`agrees\`, and it is the measure
318365
+ twelve of the sample set's cases depend on. A one-line control on it is the
318366
+ whole difference.
318367
+
318368
+ The control returns one row with a \`control\` column. \`against: model\` runs it on
318369
+ the same server as the measure and is the default -- it reuses the model's joins
318370
+ and columns and asserts only the population. \`against: truth\` runs it on a truth
318371
+ package of raw tables (\`--truth-publisher\`, \`--truth-package\`) for a fully
318372
+ independent derivation, join included. The measure's own value is always fetched
318373
+ through the model, separately, because the control is *supposed* to differ in
318374
+ population and may run elsewhere.
318375
+
318376
+ Authoring a control is judgement, and the same rules as authoring a golden apply:
318377
+ where two populations are both defensible from the docs, do not pick one --
318378
+ record the ambiguity in \`note\` and leave the record \`unchecked\`. \`cause\` is the
318379
+ \`eval-diagnose\` code the author expects a disagreement to mean (the cogs case is
318380
+ \`CONVENTION\`); it is carried onto the finding, not inferred.
318381
+
318382
+ This is **validate what someone asserted**, not **find business-wrong definitions
318383
+ unaided**. It turns "I believe \`total_gross_margin\` should exclude cancelled
318384
+ lines" into a checked, re-runnable assertion with a sha, so the belief is tested
318385
+ on every rebuild instead of once in someone's head. The difference is the whole
318386
+ point, and the run report must not blur it.
318387
+
318388
+ ### Build once, then maintain
318389
+
318390
+ \`--out\` writes a fresh ledger. \`--ledger <file>\` reads an existing one, keeps
318391
+ every field a person wrote (\`check.query\`, \`check.against\`, \`check.note\`,
318392
+ \`cause\`), re-parses the model, re-runs every check, and writes it back. Nothing
318393
+ else is carried: a verdict is never inherited from a definition that may have
318394
+ moved, and a record whose definition the model no longer declares is dropped,
318395
+ control and all -- a control for a measure that does not exist is not a
318396
+ validated measure.
318397
+
318398
+ ## What it will not claim
318399
+
318400
+ Every one of these records \`unchecked\`, and a reader must treat \`unchecked\` as
318401
+ "nobody has established this", never as "fine":
318402
+
318403
+ | Situation | Why not checked |
318404
+ |---|---|
318405
+ | reaches through a join, and no control has been authored | fanout moves both sides of a within-model check equally, and nobody has written down what the population should be |
318406
+ | spans more than one line | only the first line was read, so the recorded expression is a fragment |
318407
+ | the query failed | a check that could not run is not a check that passed |
318408
+ | no rows came back | nothing to compare |
318409
+ | every sampled row null on both sides | proves nothing about the definition |
318410
+ | no enclosing source | nothing to query it against |
318411
+
318412
+ ## Exit codes
318413
+
318414
+ Matching \`verify_goldens.py\`, deliberately:
318415
+
318416
+ | | |
318417
+ |---|---|
318418
+ | **0** | every check that could run ran, and agreed |
318419
+ | **1** | a definition disagrees with its own stated expression |
318420
+ | **3** | could not run: no model, no server, or a ledger built and never checked |
318421
+
318422
+ **3 is load-bearing.** Building the ledger without \`--publisher\` returns 3, not 0:
318423
+ it validated nothing, and a caller must never read that as a pass.
318424
+
318425
+ ## Running it
318426
+
318427
+ \`\`\`bash
318428
+ # build the ledger only (exits 3: nothing was checked)
318429
+ python3 verify_definitions.py --model ecommerce.malloy --out evals/definitions/ecommerce.jsonl
318430
+
318431
+ # build and check
318432
+ python3 verify_definitions.py --model ecommerce.malloy \\
318433
+ --publisher http://localhost:4811 --environment samples \\
318434
+ --package ecommerce --model-path ecommerce.malloy \\
318435
+ --out evals/definitions/ecommerce.jsonl
318436
+ \`\`\`
318437
+
318438
+ Measured on the ecommerce sample: **28 definitions, 15 agree, 13 unchecked**, of
318439
+ which 4 need a raw check. The remainder are multi-line definitions and fields
318440
+ declared inside a nested block, which the line scanner attributes to the
318441
+ enclosing source and cannot address.
318442
+
318443
+ ## What a run reports
318444
+
318445
+ \`run_baseline.py --definitions <ledger>\` adds an EVIDENCE block saying what the
318446
+ pass rate rests on, per case:
318447
+
318448
+ \`\`\`
318449
+ EVIDENCE
318450
+ basis 5 independent, 8 definitions, 36 unchecked
318451
+ ! those cases rest on a definition nobody has validated. Not a
318452
+ failure, and not a pass either.
318453
+ \`\`\`
318454
+
318455
+ | basis | meaning |
318456
+ |---|---|
318457
+ | \`independent\` | the key was established outside the model: two differently shaped derivations agree, or it holds no value at all (\`criteria\`, \`unanswerable\`) |
318458
+ | \`definitions\` | the key went through the model, and every definition it tests is validated |
318459
+ | \`unchecked\` | at least one tested definition is unvalidated, stale, or absent from the ledger |
318460
+ | \`disagrees\` | a definition the case depends on contradicts its own expression, so the model is wrong before the answer is |
318461
+
318462
+ Independence is read from \`golden.verification\` (or \`gold/<qid>.json\`) through
318463
+ \`verify_goldens.has_second_derivation\` -- the structured record of "two
318464
+ differently shaped derivations agree". **Not from \`verifiedBy\`,** which is free
318465
+ text: a first implementation matched it by prefix and classified 34 goldens of
318466
+ the ecommerce set as \`unchecked\` when every one of them reads "authored and
318467
+ re-derived against ecommerce-truth". A well-founded set reading as unvalidated
318468
+ is the same over-claim as an unfounded one reading as validated, pointed the
318469
+ other way.
318470
+
318471
+ Which definitions a case tests comes from \`expectedEntities\`, which already
318472
+ names entities in the \`kind:source:name\` form the ledger keys on. No new
318473
+ hand-maintained field: a wrong entity id is the failure mode that cost a real
318474
+ set two days, so this reuses a link the set already maintains and that
318475
+ \`verify_goldens\` check 5 audits against the model.
318476
+
318477
+ **Without \`--definitions\` there is no EVIDENCE block at all.** Absent is a
318478
+ different fact from checked, and a reassuring empty block would be the
318479
+ over-claim this whole direction exists to remove.
318480
+
318481
+ Staleness is a hash comparison against the run's own \`model.malloy\` snapshot --
318482
+ the bytes that actually answered -- so it costs nothing and runs every arm.
318483
+
318484
+ ## The composition rule at the gate
318485
+
318486
+ \`verify_goldens.py\` used to exit 3 -- "could not run" -- on any set that named no
318487
+ truth package, because no golden value could be re-derived. That was right when
318488
+ re-derivation was the only evidence there was. With a ledger it is one of two:
318489
+
318490
+ > A golden is trustworthy if it was derived independently, **or** if every
318491
+ > definition it tests has itself been validated.
318492
+
318493
+ \`verify_goldens.py --definitions <ledger>\` applies the second half. For each
318494
+ value-bearing case it takes the entities the case names in \`expectedEntities\` --
318495
+ the same ids the ledger is keyed on -- and asks whether every one is \`agrees\` in
318496
+ the ledger and not stale against \`--model\`. If every case passes, the absent truth
318497
+ server is not a check that failed to happen, and the audit exits 0 with a line
318498
+ saying what validated it; \`--promote\` may then promote a \`provisional\` golden that
318499
+ also carries a second derivation, and \`verifiedBy\` names the ledger. If any case
318500
+ does not pass, the exit stays 3 and the message names the cases and the
318501
+ definitions that let them down. Nothing is inferred about a case that names no
318502
+ entities: it is unvalidated, and it says so.
318503
+
318504
+ \`improve.py\` passes \`--definitions\` through when it has one, so the acceptance
318505
+ check can pass on a set that never had a truth package.
318506
+
318507
+ ## Where it goes
318508
+
318509
+ \`evals/definitions/<package>.jsonl\` -- keyed by **package, not by set**, because
318510
+ definitions belong to the model and outlive any one set, and several sets against
318511
+ the same package share them. Never under the served package tree; the golden side
318512
+ door forbids eval artifacts there.
318513
+
318514
+ ## What this is not
318515
+
318516
+ It is not a unit test. Publisher's roadmap plans fixture-based unit tests (a CSV
318517
+ per source plus an expected result) alongside data tests in \`malloy-pub lint\`.
318518
+ A fixture unit test proves an expression computes what you wrote -- so it would
318519
+ **not** catch a measure whose expression is correct and whose *population* is
318520
+ wrong, which is the defect class this exists for. The ledger is a data test, and
318521
+ it should hand off to \`lint\` when that governance work lands rather than becoming
318522
+ a permanent parallel mechanism.`
317784
318523
  },
317785
318524
  {
317786
318525
  name: "eval-answer/ledger-schema",
317787
318526
  description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
317788
- 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 diagnosis to \"retrieval ranking\" for a failure that was never retrieval's. `acceptable`: ids that are not noise if returned. An entity counts as delivered when returned under its exact id, under the same type and name on a sibling source, or by name in a returned source's documentation; `score_retrieval.py` records the route per entity as `delivery`. |\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| `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 that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\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| `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| `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. |\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`, or `aborted` when four consecutive attempts errored or found the server dead and the harness stopped rather than spend the rest of the budget on attempts nobody will trust. |\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` | int or null | Answerer token usage. Null when the host does not report it. |\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| `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 from the trace so evidence survives trace eviction: `entityIds`, `ranks`, `resultCount`, and per-target `targets` with within-target ranks. |\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: `not_submitted`, `contaminated`, or `golden_<status>` -- `golden_missing`, `golden_provisional`, `golden_invalid`, `golden_ambiguous`, `golden_verified_wrong`. The `golden_*` reasons are decided before the judge is called, so an unestablished key costs nothing to refuse. |\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 `must_not_use_hits` veto overrode it. Null otherwise, so the judge's own agreement rate stays measurable across vetoes. |\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\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. |\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."
318527
+ 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 that path had uncommitted changes -- fine for a band measurement, not for an A/B pin. **Null unless `--model-repo` names the repo.** It cannot be inferred: Publisher serves a COPY under `publisher_data/`, so the tree around the served file is the server's storage, not the model's history, and the field named that until 2026-09-03. `modelSha` above is the pin that always works; this is the pointer to where those bytes are versioned, and absent beats wrong. |\n| `modelRepo` | The path `--model-repo` named, so a reader can tell an absent pin from an unrecorded one. |\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. |\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."
317789
318528
  },
317790
318529
  {
317791
318530
  name: "eval-diagnose",
@@ -317810,6 +318549,35 @@ repair closes that. Take the stable list \`flip_table.py\` prints and pass
317810
318549
  \`--only <qids> --verdicts near_match\`. Never diagnose a one-armed \`near_match\`;
317811
318550
  that is noise, and it sends an agent to fix a model that is already right.
317812
318551
 
318552
+ **A correct answer can still carry a finding.** A case that answered right
318553
+ while a required entity never reached it is diagnosed too, for the retrieval
318554
+ miss alone, and \`diagnose.py\` selects it automatically. The answer was right by
318555
+ another route, and naming that route is the job: on the run this rule comes
318556
+ from it was always the same one, the agent rebuilding the model's own measure
318557
+ inline. That held while the measure was \`count()\` and failed the moment one
318558
+ carried a grain rule, producing the run's only wrong answer. Three of the four
318559
+ findings in that run sat on passing cases and, before this, produced nothing.
318560
+
318561
+ **"It worked anyway" is not a reason to leave the model or the skills
318562
+ unfixed.** An answer that is right without the model's own entity is right for
318563
+ now, not right by design. Write the issue against the miss, record that the
318564
+ answer was correct so nobody reads it as a wrong number, and do not soften the
318565
+ finding because the number came out right. \`--no-retrieval-misses\` opts out for
318566
+ a run that only wants answer failures.
318567
+
318568
+ Holdout is withheld so the acceptance check keeps something the improve step
318569
+ never saw. A **measure-only** run never reaches improve, so it is holding those
318570
+ cases back from nothing: pass \`--include-holdout\` there. The script refuses it
318571
+ on a run that already carries a \`candidate\`, because that run's holdout is the
318572
+ only thing left that can falsify the edit.
318573
+
318574
+ **Say what the clusters do not cover.** \`diagnose.py\` prints a coverage account
318575
+ of every non-passing case and which bucket it fell in -- holdout, contaminated,
318576
+ a verdict outside \`--verdicts\`, unscored, or selected and not diagnosed. Quote
318577
+ it whenever you report clusters. One run's six clusters were read as covering
318578
+ its failures; they covered 8 of 18, and each individual exclusion had been
318579
+ correct and added up nowhere.
318580
+
317813
318581
  ## Components, in order
317814
318582
 
317815
318583
  Walk **in this order** and stop at the first with positive evidence. A later
@@ -317821,7 +318589,7 @@ never "C1" / "C2" / "C3":
317821
318589
  | \`dataset\` | Bad question, bad or missing golden, or environment drift? |
317822
318590
  | \`agent-call\` | Did the agent ask for the needed concepts, with the right type and scope? |
317823
318591
  | \`get_context/model\` | Is the needed entity absent, undocumented, weakly labeled, duplicated, or missing guidance? |
317824
- | \`get_context/retrieval\` | Was an on-target request against a well-described entity ranked or grouped wrong? |
318592
+ | \`get_context/retrieval\` | Was an on-target, in-scope request against a well-described entity ranked or grouped wrong? **Check the call's \`scopes\` first**: a call pinned to one source cannot return another source's entity, and that miss is \`agent-call\`. |
317825
318593
  | \`construction\` | Did sufficient context arrive, and the agent still built the wrong query? |
317826
318594
  | \`model-definition\` | Is a measure, join, filter convention, or source semantically wrong? |
317827
318595
 
@@ -318102,6 +318870,22 @@ Say what you considered merging and chose not to. A cluster is a claim that one
318102
318870
  change fixes N cases, and the near-misses are what a reviewer needs to falsify
318103
318871
  it.
318104
318872
 
318873
+ **Falsify a behavioural cluster against the passes.** This skill reads failures
318874
+ only, so any behaviour common to the whole run looks causal from inside it.
318875
+ \`diagnose.py\` hands the clustering step a \`CONTROLS\` block: the same
318876
+ measurements -- retrieval calls, targets carrying no \`search_text\`, queries,
318877
+ skills opened, turns -- taken on the cases that PASSED. Before claiming a
318878
+ behaviour explains a cluster, compare it there. If it occurs at a similar rate
318879
+ in the passes it does not separate the groups: mark the cluster \`contributing\`
318880
+ rather than \`primary\` and do not route it to an edit as the root cause.
318881
+ Measured, on the run this comes from: the largest cluster said the agent
318882
+ "substitutes broad enumeration for targeted retrieval", and bare targets were
318883
+ 25% of all targets in the failures against 26% in the passes. What actually
318884
+ separated them was volume -- failures made about 50% more retrieval calls --
318885
+ and question difficulty explains that at least as well as call style does. With
318886
+ no controls at all, a behavioural cluster is unfalsified, which is a different
318887
+ claim from confirmed.
318888
+
318105
318889
  Still no patch. Naming the shared root cause precisely enough that someone else
318106
318890
  can design the edit is the whole job here; the edit itself is
318107
318891
  \`skill:eval-improve\`, working from this. A cluster carrying an honest open
@@ -318300,6 +319084,134 @@ back until somebody derives keys. A set of bare questions already measures
318300
319084
  whether the model can express an answer at all, and the answers it produces
318301
319085
  are what the keys get derived from.
318302
319086
 
319087
+ ### Deriving a key yourself, when nothing arrived with the question
319088
+
319089
+ The table above is about what ARRIVED. Sometimes nothing did, nobody knows the
319090
+ right number, and the set still needs keys. You may derive one, and the rules
319091
+ are the ones already stated rather than new ones:
319092
+
319093
+ - Use your full model access and query until you can defend the number. The
319094
+ agent that gets tested later holds only \`get_context\` and \`execute_query\`;
319095
+ you are not that agent, and the gap is the point.
319096
+ - Record the query as \`canonicalQuery\`. A number with no query is somebody's
319097
+ guess, which is the row above it in the table.
319098
+ - Record the entities you used to get there as \`expectedEntities.required\`, in
319099
+ the \`kind:source:name\` form: the measures, dimensions and views the answer
319100
+ cannot be produced without. \`reference/case-format.md\` says to leave this
319101
+ field empty at import because guessing it invents a retrieval expectation
319102
+ nobody stated; you are not guessing, you just used them. That is the only
319103
+ trustworthy producer this field has, and it is what makes retrieval
319104
+ measurable on a set nobody sent keys for. Where the model offers two
319105
+ legitimate routes, write a \`requiredAnyOf\` group rather than pick one.
319106
+ \`verify_goldens.py\` check 5 audits every id against the model, so a wrong one
319107
+ is a hard finding rather than a silent retrieval miss.
319108
+ - **Derive the list with the model in front of you, not from memory, and not
319109
+ with grep.** The model knows what each field IS; an author does not reliably.
319110
+ Ask the question of the model: which entities does an answer to this depend
319111
+ on, and is each a measure or a dimension. Confirm every id with \`get_context\`
319112
+ before writing it. A list written from recollection is the most common way a
319113
+ set acquires a retrieval expectation nobody can satisfy.
319114
+
319115
+ Measured: an agent given the model and one question found alternatives a
319116
+ hand-written key had missed on all three cases tried -- carrier name against
319117
+ nickname against code, \`destination_count\` against its underlying
319118
+ \`destination.airport_count\`. At roughly $0.13 a question. It is better than a
319119
+ person working from notes at finding what the model actually offers.
319120
+
319121
+ It is worse at deciding what to REQUIRE, and the two rules below are why. On
319122
+ the same three cases it proposed demoting two named measures on the grounds
319123
+ that a raw expression answered identically. One of those equivalences was
319124
+ false; the other was true today and is not a reason to demote anything.
319125
+
319126
+ - **But VERIFY every "this is not strictly required" claim by running both
319127
+ sides.** This is the rule that makes the step above safe, and it is not
319128
+ optional.
319129
+
319130
+ The same probe got one badly wrong. Told that a named measure is not required
319131
+ when a raw column plus a plain aggregate answers identically, it wrote that
319132
+ \`average_plane_size\` was "equally answerable by
319133
+ \`avg(aircraft.aircraft_models.seats)\`". Executed, those are 229 and 196.93.
319134
+ The measure is \`aircraft.avg(...)\`, an average over distinct aircraft, and the
319135
+ inline version averages over flights. The claim was false for exactly the
319136
+ measure whose entire purpose is to carry that distinction -- and it is the
319137
+ same mistake, on the same field, that produced the only wrong answer in the
319138
+ run this set came from.
319139
+
319140
+ Had that proposal been written into the key, the case would have stopped
319141
+ testing the thing it exists to test.
319142
+
319143
+ So: an equivalence is a claim about DATA, and reading a definition is not
319144
+ evidence. Run both expressions and compare the rows.
319145
+
319146
+ **But agreeing today is not a reason to demote the model's entity, and this
319147
+ is the part the probe got backwards.** \`airport_count is count()\` returns the
319148
+ same 984 as a bare \`count()\` right now, and stops doing so the moment anyone
319149
+ adds a null filter or a deduplication to it. The key would not notice: it
319150
+ would go on passing while testing nothing. That applies to every measure that
319151
+ is currently a thin wrapper, which is most of them until the day one is not.
319152
+
319153
+ **\`requiredAnyOf\` is for two routes the MODEL defines** -- a carrier's \`name\`
319154
+ and its \`nickname\`, \`destination_count\` and the \`destination.airport_count\`
319155
+ it wraps. Both are entities, both are retrievable, and an answer using either
319156
+ has gone through the semantic layer. That is a real choice and the group
319157
+ records it.
319158
+
319159
+ It is NOT for "the model's entity, or a raw expression that happens to
319160
+ agree". Those are not two routes: one is the route, the other is a bypass
319161
+ that currently works. Keeping the named entity in \`required\` is what makes
319162
+ the eval measure what the semantic layer is for, and finding it IS the
319163
+ retrieval behaviour under test -- an agent that searches for the pre-defined
319164
+ entity inherits its definition and its rules, while one that rebuilds from
319165
+ raw columns inherits whatever it thought of.
319166
+
319167
+ What the equivalence check is FOR is reading a run afterwards. When an answer
319168
+ came out right without the entity, comparing the two says whether it got
319169
+ lucky or got it wrong, and a set whose passes are merely lucky is a set of
319170
+ near-misses.
319171
+
319172
+ - **Then prove each one is retrievable, and record what finds it.**
319173
+ \`check_findable.py --set <set> --mcp-url <mcp> --publisher <rest>
319174
+ --environment <env> --package <pkg>\` runs two checks and needs no model call.
319175
+ With \`--publisher\` it reads the COMPILED model and settles whether each field
319176
+ exists and what KIND it is; then it searches for each entity to confirm the
319177
+ index can actually deliver it.
319178
+
319179
+ This matters more than it looks, because \`required\` is what BOTH retrieval
319180
+ numbers are computed against: whether the agent asked for the entity, and
319181
+ whether it came back. An id retrieval cannot deliver makes both fiction, and
319182
+ the case reports a retrieval miss on every run, which reads as a defect in
319183
+ the model or the agent rather than in the key.
319184
+
319185
+ **Prefer the compiled model over a grep, and prefer it in both directions.**
319186
+ Measured on one package: \`dimension:flights:flight_count\` names a field the
319187
+ model really has, so grepping the text passes it, and the compiled model says
319188
+ \`flight_count\` is a MEASURE, which no \`dimension\` request can return.
319189
+ Conversely \`dimension:airports:own_type\` appears zero times in the \`.malloy\`
319190
+ and the compiled model declares it, because the source exposes it implicitly
319191
+ from the data; the grep calls it missing and acting on that deletes a good
319192
+ entity. \`verify_goldens.py\` check 5 is the grep, it is free and needs no
319193
+ server, and it is not the authority.
319194
+
319195
+ A pass is a floor, not a verdict on the docs: each entity is searched by its
319196
+ OWN NAME, the easiest query that could find it. An entity that answers to its
319197
+ identifier and not to the words a question uses still fails at run time, as
319198
+ \`not retrieved\`.
319199
+
319200
+ Writing the list forces the question an author has to answer anyway: what
319201
+ would a reasonable agent search for, and of what type, to find this? If you
319202
+ cannot state that, the agent cannot be expected to guess it.
319203
+ - \`status: provisional\`, never \`verified\`, for the reason stated above: you
319204
+ derived it THROUGH the model under test, so a model bug would certify its
319205
+ own key. \`verify_goldens.py --promote\` is still the only way out.
319206
+ - **Where two readings are both defensible from the model, do not pick one.**
319207
+ A model that documents two conventions for the same population produces two
319208
+ honest numbers, and choosing quietly is how a confident wrong key gets
319209
+ written. Go to Hold an ambiguous golden in \`skill:eval-loop\`'s
319210
+ \`reference/golden-side-door.md\`: record the competing candidates rather than
319211
+ a new number.
319212
+ - What no query can settle is not a failure to derive. It is step 4's second
319213
+ kind, and those clauses score on day one.
319214
+
318303
319215
  ## Step 3: run their query, if they gave one
318304
319216
 
318305
319217
  Do it at import, before any run. It is the cheapest finding in the whole loop.
@@ -318356,6 +319268,57 @@ Where you cannot decide, mark the case and report it rather than guessing. A
318356
319268
  criterion nobody could classify is a question for its author, and
318357
319269
  \`skill:eval-loop\`'s golden side door is where it waits.
318358
319270
 
319271
+ ### Four rules for writing the rubric itself
319272
+
319273
+ Each of these cost a scored case on a real set, and none of them is obvious
319274
+ while you are writing one.
319275
+
319276
+ **The golden rows are the figures. The rubric's prose is a gloss on them, and
319277
+ where the two disagree the prose is what is stale.** On one set 26 of 29
319278
+ rubrics quoted a figure that appears nowhere in their own golden rows: the
319279
+ goldens were re-derived the next morning, the prose was not, and an agent that
319280
+ computed 747 and 370 -- the exact numbers in the golden JSON -- was failed
319281
+ against a rubric still saying 615 and 502. Say how to derive the figure, not
319282
+ what it equalled. \`verify_goldens.py\` check 2 reports figures in the accepting
319283
+ clause that are absent from the rows, but it reads only figures specific enough
319284
+ to be a quoted result -- a bare three-digit number is invisible to it, and 615
319285
+ is exactly that. So the check is a help, not a guarantee, and the rule above is
319286
+ yours to keep. The judge is told the same thing from the other side: where a
319287
+ rubric and a golden disagree about a figure, it scores against the golden.
319288
+
319289
+ **Do not assert the model's current behaviour.** "\`contract_terms\` cannot be
319290
+ used here at all -- it returns ZERO rows" was true when written and false four
319291
+ hours later, when a commit unblocked that view. Nothing linked the two, and the
319292
+ judge then reasoned from the stale claim against an answer that was right.
319293
+ Co-locating the set with the model makes such drift visible in a diff; it does
319294
+ not detect it. A rubric that describes a bug is a rubric with an expiry date:
319295
+ write the requirement, not the defect.
319296
+
319297
+ **When a question admits two honest populations, accept either and require the
319298
+ answer to name which.** "Products in a category" can mean listed-in or
319299
+ primary-category, and on one set the two readings differed by 4,070 against
319300
+ 2,707. Six or more cases turned on it and one rubric had been written to accept
319301
+ both. That one was right. The rest were repaired afterwards, having failed
319302
+ correct answers in the meantime.
319303
+
319304
+ **If the rubric accepts an alternative, the entity list must too.** The two
319305
+ halves of a key are read by different things: the rubric is prose for the judge,
319306
+ \`expectedEntities.required\` is ids for retrieval scoring. They can disagree
319307
+ without anything noticing. A rubric saying "either the full carrier name or the
319308
+ nickname is fine" beside a required list naming only \`dimension:carriers:name\`
319309
+ scores an answer that used the nickname CORRECT and docks it recall in the same
319310
+ run, and that lost recall then reads as a retrieval failure. Use a
319311
+ \`requiredAnyOf\` group, which is satisfied when any member is delivered.
319312
+ \`verify_goldens.py\` reports the mismatch as a review item, but the rule is
319313
+ yours: it is a heuristic over prose and cannot catch every phrasing.
319314
+
319315
+ **A trap note is not a requirement.** Notes that arrive beside the questions
319316
+ describe what their author thought was hard, and they mention things the
319317
+ question never asked for. Turning one into a rubric clause invents a
319318
+ requirement the answerer was never given, and it happened: a term appearing
319319
+ nowhere in any question became a clause an answer was marked down for missing.
319320
+ Convert a note only where it constrains the answer to the question as asked.
319321
+
318359
319322
  ## Step 5: never change a question, and seal it
318360
319323
 
318361
319324
  The question is the stimulus. It is what a human asked, and it is not yours to
@@ -318456,7 +319419,7 @@ Available: case-format.`
318456
319419
  {
318457
319420
  name: "eval-import/case-format",
318458
319421
  description: "Reading an arriving question list. Reference detail for the eval-import skill.",
318459
- body: "<!-- What an arriving item maps to, and how to read the four formats questions actually arrive in. The file shapes themselves are in eval-answer/reference/ledger-schema.md; this is only what is specific to an import. -->\n\n# Reading an arriving question list\n\n`reference/ledger-schema.md` in `skill:eval-answer` defines `set.json`,\n`cases.jsonl` and every golden field. This covers two things it does not: what\nan arriving item maps onto, and how the four formats break.\n\n## What an import fills in\n\nOn the case:\n\n| field | from the source | when it is missing |\n|---|---|---|\n| `qid` | their id, if they gave one, prefixed with the set name | derive from the question: a slug of its first words plus a counter. Stable, because scores are keyed on it |\n| `question` | the question text, byte for byte | it is not a case. Do not reconstruct a question from a criterion |\n| `questionSha` | never from the source | stamped at conversion by `import_cases.py --stamp` |\n| `split` | rarely present | you choose, and freeze it at import |\n| `source` | how it arrived: the filename, or the log query | say `unknown` rather than guessing a provenance |\n| `golden` | see the classification table in `SKILL.md` | absent. A case with no golden is legitimate |\n| `expectedEntities` | almost never present | leave it out. Guessing which fields an answer needs invents a retrieval expectation nobody stated, and it scores as a retrieval miss forever |\n\n`expectedEntities` is the one most worth leaving empty. A wrong `required` id\ncannot be delivered by any run, so it reads as a model failure on every case\nit touches: five copied ids cost a real set two days.\n\nOn `set.json`: `name`, `description`, `datasetVersion: 1`,\n`targetModelPath`, `targetPackage` (the package under test -- it arms the\nguard that refuses a \"truth\" server also serving it), and a `sourceNote` saying\nwhere the questions came from and what shape they arrived in. `truthPackage` is\nusually absent at import, and until it exists no golden can reach `verified`;\n`verify_goldens.py --promote` is what takes it there afterwards. `init_truth_package.py` in\n`skill:eval-answer` scaffolds one.\n\n## Their field names will not be your field names\n\nAn arriving JSONL is somebody else's schema. Map it explicitly rather than\ntrusting a name that looks familiar, and write down the mapping you used in\n`sourceNote`. Names seen in real sets:\n\n| theirs | usually means | trap |\n|---|---|---|\n| `prompt`, `query`, `input`, `nl_question` | the question | `query` is the trap: in half of these it is the SQL or Malloy they ran, not the question |\n| `answer`, `expected`, `output`, `ground_truth` | a golden value, prose, or both mixed in one string | a paragraph under `answer` is criteria, not a value. Classify it, do not store it as a number |\n| `sql`, `malloy`, `gold_query` | their derivation | run it (`SKILL.md` step 3). This is the field that upgrades a typed number into something checkable |\n| `criteria`, `rubric`, `assertions`, `must_include` | prose criteria | apply the query test in `SKILL.md` step 4 to EACH one, not to the field |\n| `id`, `case_id`, `uuid` | their id | keep it, prefixed. Two sets from one customer collide otherwise |\n| `difficulty`, `category`, `tags` | their own labels | keep as-is on the case for slicing. Never let one become `split` |\n\nA field you cannot map is a question for its author. Keep it verbatim under a\n`sourceFields` object on the case rather than discarding it or forcing it into\na field it does not mean.\n\n## The four formats\n\n**JSONL.** One object per line, and the easy one. The failures are all in the\nmapping above, plus two mechanical ones: a trailing incomplete line, and a\n`\\n` inside a question that a naive line split breaks. Parse it as JSON per\nline, and count the lines you produced against the lines they sent. Report the\ncount in the import summary: an import that silently dropped 3 of 50 is a\nmeasurement on 47 cases claiming to be one on 50.\n\n**CSV or a spreadsheet export.** Questions contain commas, quotes and newlines,\nso use a real CSV reader, never a split on `,`. Watch for a header row that is\nnot row one (an exported sheet often has a title and a blank line above it), for\nmerged cells arriving as empty strings that belong to the row above, and for a\nnumber that arrived as text with a thousands separator or a currency symbol. A\nnumber you cannot parse cleanly is a string; keep it as their text and say so,\nrather than reading `1,234` as `1.234` or as `1234` without checking.\n\n**A markdown doc or an email thread.** The questions are prose among other\nprose, so the hard part is deciding what IS a question. Rules that hold up:\na heading or a numbered list item is usually a case boundary; a sentence\nending in a question mark inside a paragraph about something else usually is\nnot. Criteria attached to a question tend to follow it as a bullet list or a\n\"should\" sentence. Quote a boundary you are unsure of and ask, rather than\nsplitting on your best guess: a question invented by a bad split is\nindistinguishable, later, from one a human asked. Nothing in this format is\nbyte-clean, which makes the verbatim copy in `as-received/` load-bearing.\n\n**A pull from production logs.** The best source, because real traffic asks\nwhat people actually ask, and the messiest. Three things to do:\nde-duplicate near-identical prompts before you import 40 of one question;\nstrip the parts that are not the question (a system preamble, a UI-injected\nscope line, a pasted table); and keep the trace or request id on the case as\n`source`, so a case can be taken back to the session it came from. Almost\nnone of these arrive with a golden, which is fine: bare questions are a\ncoverage measurement. Where the log also holds the query the agent ran, that\nis NOT a golden. It is what an unevaluated agent did, and importing it as a\nkey scores the model against its own past behavior.\n\n## A worked line\n\nTheirs:\n\n```json\n{\"case_id\": \"q-014\", \"prompt\": \"How did denim sell last quarter versus the one before?\",\n \"sql\": \"SELECT ...\", \"answer\": \"Up 12.4% to 1.83M. Should break out by channel.\",\n \"difficulty\": \"hard\"}\n```\n\nYours, after applying step 3 and step 4:\n\n```json\n{\"qid\": \"ecom-q-014\", \"question\": \"How did denim sell last quarter versus the one before?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"provisional\", \"kind\": \"scalar\", \"value\": 1830000,\n \"canonicalQuery\": \"SELECT ...\", \"verifiedBy\": \"authored_query\",\n \"rubric\": \"Reports the quarter-over-quarter change for denim, and breaks the figure out by channel.\"},\n \"sourceFields\": {\"difficulty\": \"hard\"}}\n```\n\nAnd one that arrived as criteria alone, with no number anywhere:\n\n```json\n{\"qid\": \"ecom-q-021\", \"question\": \"Which channels are underperforming?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"verified\", \"kind\": \"criteria\",\n \"verifiedBy\": \"authored_criteria\",\n \"rubric\": \"Names the channels and says what it measured underperformance against. Does not use list price.\",\n \"mustNotUse\": [\"retail_price\"]}}\n```\n\nNo query settles \"underperforming\", so there is nothing to derive and nothing\nthat can be numerically wrong: the clauses are the whole key, and this case\nscores on the first run.\n\n`criteria` and `unanswerable` are the two kinds an import may mark `verified`,\nand they arrive looking alike. A criterion reading \"PASS: a refusal that names\nthe missing data\" belongs to the second: the key is that the data is absent,\nso `kind: unanswerable`, and filing it as `criteria` beside a value would make\na correct refusal fail.\n\nWhat happened to that one `answer` string: the number became a provisional\nvalue, checkable because they sent the query that produced it; \"should break\nout by channel\" is a shape nothing can settle with a query, so it became\nrubric prose and scores on day one; and \"Up 12.4%\" is a second figure the\nrubric now has to state consistently with the value, which\n`verify_goldens.py` reads the accepting clause for."
319422
+ body: "<!-- What an arriving item maps to, and how to read the four formats questions actually arrive in. The file shapes themselves are in eval-answer/reference/ledger-schema.md; this is only what is specific to an import. -->\n\n# Reading an arriving question list\n\n`reference/ledger-schema.md` in `skill:eval-answer` defines `set.json`,\n`cases.jsonl` and every golden field. This covers two things it does not: what\nan arriving item maps onto, and how the four formats break.\n\n## What an import fills in\n\nOn the case:\n\n| field | from the source | when it is missing |\n|---|---|---|\n| `qid` | their id, if they gave one, prefixed with the set name | derive from the question: a slug of its first words plus a counter. Stable, because scores are keyed on it |\n| `question` | the question text, byte for byte | it is not a case. Do not reconstruct a question from a criterion |\n| `questionSha` | never from the source | stamped at conversion by `import_cases.py --stamp` |\n| `split` | rarely present | you choose, and freeze it at import |\n| `source` | how it arrived: the filename, or the log query | say `unknown` rather than guessing a provenance |\n| `golden` | see the classification table in `SKILL.md` | absent. A case with no golden is legitimate |\n| `expectedEntities` | almost never present | leave it out. Guessing which fields an answer needs invents a retrieval expectation nobody stated, and it scores as a retrieval miss forever. The one exception is a key you derived yourself (`SKILL.md`, Deriving a key yourself): then you are not guessing, you are recording what you used |\n\n`expectedEntities` is the one most worth leaving empty. A wrong `required` id\ncannot be delivered by any run, so it reads as a model failure on every case\nit touches: five copied ids cost a real set two days.\n\nOn `set.json`: `name`, `description`, `datasetVersion: 1`,\n`targetModelPath`, `targetPackage` (the package under test -- it arms the\nguard that refuses a \"truth\" server also serving it), and a `sourceNote` saying\nwhere the questions came from and what shape they arrived in. `truthPackage` is\nusually absent at import, and until it exists no golden can reach `verified`;\n`verify_goldens.py --promote` is what takes it there afterwards. `init_truth_package.py` in\n`skill:eval-answer` scaffolds one.\n\n## Their field names will not be your field names\n\nAn arriving JSONL is somebody else's schema. Map it explicitly rather than\ntrusting a name that looks familiar, and write down the mapping you used in\n`sourceNote`. Names seen in real sets:\n\n| theirs | usually means | trap |\n|---|---|---|\n| `prompt`, `query`, `input`, `nl_question` | the question | `query` is the trap: in half of these it is the SQL or Malloy they ran, not the question |\n| `answer`, `expected`, `output`, `ground_truth` | a golden value, prose, or both mixed in one string | a paragraph under `answer` is criteria, not a value. Classify it, do not store it as a number |\n| `sql`, `malloy`, `gold_query` | their derivation | run it (`SKILL.md` step 3). This is the field that upgrades a typed number into something checkable |\n| `criteria`, `rubric`, `assertions`, `must_include` | prose criteria | apply the query test in `SKILL.md` step 4 to EACH one, not to the field |\n| `id`, `case_id`, `uuid` | their id | keep it, prefixed. Two sets from one customer collide otherwise |\n| `difficulty`, `category`, `tags` | their own labels | keep as-is on the case for slicing. Never let one become `split` |\n\nA field you cannot map is a question for its author. Keep it verbatim under a\n`sourceFields` object on the case rather than discarding it or forcing it into\na field it does not mean.\n\n## The four formats\n\n**JSONL.** One object per line, and the easy one. The failures are all in the\nmapping above, plus two mechanical ones: a trailing incomplete line, and a\n`\\n` inside a question that a naive line split breaks. Parse it as JSON per\nline, and count the lines you produced against the lines they sent. Report the\ncount in the import summary: an import that silently dropped 3 of 50 is a\nmeasurement on 47 cases claiming to be one on 50.\n\n**CSV or a spreadsheet export.** Questions contain commas, quotes and newlines,\nso use a real CSV reader, never a split on `,`. Watch for a header row that is\nnot row one (an exported sheet often has a title and a blank line above it), for\nmerged cells arriving as empty strings that belong to the row above, and for a\nnumber that arrived as text with a thousands separator or a currency symbol. A\nnumber you cannot parse cleanly is a string; keep it as their text and say so,\nrather than reading `1,234` as `1.234` or as `1234` without checking.\n\n**A markdown doc or an email thread.** The questions are prose among other\nprose, so the hard part is deciding what IS a question. Rules that hold up:\na heading or a numbered list item is usually a case boundary; a sentence\nending in a question mark inside a paragraph about something else usually is\nnot. Criteria attached to a question tend to follow it as a bullet list or a\n\"should\" sentence. Quote a boundary you are unsure of and ask, rather than\nsplitting on your best guess: a question invented by a bad split is\nindistinguishable, later, from one a human asked. Nothing in this format is\nbyte-clean, which makes the verbatim copy in `as-received/` load-bearing.\n\n**A pull from production logs.** The best source, because real traffic asks\nwhat people actually ask, and the messiest. Three things to do:\nde-duplicate near-identical prompts before you import 40 of one question;\nstrip the parts that are not the question (a system preamble, a UI-injected\nscope line, a pasted table); and keep the trace or request id on the case as\n`source`, so a case can be taken back to the session it came from. Almost\nnone of these arrive with a golden, which is fine: bare questions are a\ncoverage measurement. Where the log also holds the query the agent ran, that\nis NOT a golden. It is what an unevaluated agent did, and importing it as a\nkey scores the model against its own past behavior.\n\n## A worked line\n\nTheirs:\n\n```json\n{\"case_id\": \"q-014\", \"prompt\": \"How did denim sell last quarter versus the one before?\",\n \"sql\": \"SELECT ...\", \"answer\": \"Up 12.4% to 1.83M. Should break out by channel.\",\n \"difficulty\": \"hard\"}\n```\n\nYours, after applying step 3 and step 4:\n\n```json\n{\"qid\": \"ecom-q-014\", \"question\": \"How did denim sell last quarter versus the one before?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"provisional\", \"kind\": \"scalar\", \"value\": 1830000,\n \"canonicalQuery\": \"SELECT ...\", \"verifiedBy\": \"authored_query\",\n \"rubric\": \"Reports the quarter-over-quarter change for denim, and breaks the figure out by channel.\"},\n \"sourceFields\": {\"difficulty\": \"hard\"}}\n```\n\nAnd one that arrived as criteria alone, with no number anywhere:\n\n```json\n{\"qid\": \"ecom-q-021\", \"question\": \"Which channels are underperforming?\",\n \"questionSha\": \"<stamped>\", \"split\": \"dev\", \"source\": \"questions.jsonl\",\n \"golden\": {\"status\": \"verified\", \"kind\": \"criteria\",\n \"verifiedBy\": \"authored_criteria\",\n \"rubric\": \"Names the channels and says what it measured underperformance against. Does not use list price.\",\n \"mustNotUse\": [\"retail_price\"]}}\n```\n\nNo query settles \"underperforming\", so there is nothing to derive and nothing\nthat can be numerically wrong: the clauses are the whole key, and this case\nscores on the first run.\n\n`criteria` and `unanswerable` are the two kinds an import may mark `verified`,\nand they arrive looking alike. A criterion reading \"PASS: a refusal that names\nthe missing data\" belongs to the second: the key is that the data is absent,\nso `kind: unanswerable`, and filing it as `criteria` beside a value would make\na correct refusal fail.\n\nWhat happened to that one `answer` string: the number became a provisional\nvalue, checkable because they sent the query that produced it; \"should break\nout by channel\" is a shape nothing can settle with a query, so it became\nrubric prose and scores on day one; and \"Up 12.4%\" is a second figure the\nrubric now has to state consistently with the value, which\n`verify_goldens.py` reads the accepting clause for."
318460
319423
  },
318461
319424
  {
318462
319425
  name: "eval-improve",
@@ -319204,7 +320167,8 @@ all of them for every run is how a skill stops being read.
319204
320167
  | a golden is wrong, doubted, or out of step with the model | \`reference/golden-side-door.md\` |
319205
320168
  | auditing a key you doubt, or a set you did not author | \`reference/auditing-an-answer-key.md\` |
319206
320169
  | deciding whether an edit stays | \`reference/acceptance-check.md\` |
319207
- | about to quote a number, or set the noise band | \`reference/measurement.md\` |
320170
+ | about to quote a number, set the band, or read a set's flips | \`reference/measurement.md\` |
320171
+ | the run finished and someone has to read it | \`skill:eval-report\` |
319208
320172
  | you changed judge doctrine or its inputs | \`reference/checking-the-judge.md\` |
319209
320173
 
319210
320174
  Read the file, do not work from the summary here. The acceptance-check rules and
@@ -319221,10 +320185,22 @@ produces a confident wrong answer rather than an error.
319221
320185
  | **d. improve** | One smallest model edit, then the acceptance check | improve writes \`candidate\`; you write \`acceptance_check\`. Revert on reject. |
319222
320186
  | **e. checkpoint** | Git commit after an accepted acceptance check | \`checkpoint\` event, then the commit |
319223
320187
 
320188
+ Every run then **reports** (below). A run whose result exists only as JSONL and
320189
+ a scrolled-away console summary has not been delivered.
320190
+
319224
320191
  **scrape** and **run** share a letter but are not the same job. Scrape writes
319225
320192
  cases. Run writes attempts. Do not invent questions and score
319226
320193
  them in one breath.
319227
320194
 
320195
+ **Step d is \`skill:eval-improve\`, not a hand edit plus another arm.** The
320196
+ failure mode is specific and it has happened: the edits were made by hand,
320197
+ published, and validated by re-running the whole arm -- with the answer key
320198
+ repaired in the same window. No \`candidate\`, \`acceptance_check\` or \`checkpoint\`
320199
+ event exists in that run's ledger, and the resulting move cannot be attributed
320200
+ to the model, the key, or the answerer. Re-running a whole arm after changing
320201
+ two things is the one thing the acceptance check exists to replace: it scores
320202
+ the edit against dev AND holdout, and it is cheaper than the arm.
320203
+
319228
320204
  ### Scrape, minimally
319229
320205
 
319230
320206
  Importing an existing corpus IS the scrape step: copy the set from its home
@@ -319455,6 +320431,29 @@ the run measure something other than what it names:
319455
320431
  under a published version, which is what makes it a pin. Check which you have
319456
320432
  before deciding how much of this you need.
319457
320433
 
320434
+ **Look for a set and for prior runs before you author either.** A minute of
320435
+ \`find . -name cases.jsonl\`, a glance at \`evals/*/runs/\` and at your host's
320436
+ own transcripts for this repo. Two sessions fourteen minutes apart built the
320437
+ same 29-case answer key from scratch, because the first had committed
320438
+ nothing before it was deleted and the second had no way to know it existed.
320439
+ Roughly a working day was spent twice, and three specific things were
320440
+ rediscovered at cost: the MCP login flow, the retrieval gate's 401, and a
320441
+ judge-rendering bug that had already cost $17 of arm once. The two keys,
320442
+ independently authored from the same questions, differ by about ten points
320443
+ on comparable answers -- which is the available measure of how much a key
320444
+ depends on its author, and a reason to reuse one rather than rebuild it.
320445
+
320446
+ **Audit the set's entity ids before the first arm.** \`verify_goldens.py\`
320447
+ checks that each id names something in the model; \`check_findable.py\` checks
320448
+ that a search of its own kind actually returns it. Both are free of model
320449
+ calls. An id that fails either scores a retrieval miss on every run, and the
320450
+ miss reads as the model's fault.
320451
+
320452
+ **Commit the set before you spend money on an arm**, and keep durable
320453
+ outputs in the repository. A findings document in \`~/Downloads\` is gone the
320454
+ first time somebody tidies up; the set, the run directory and the write-up
320455
+ belong in git beside the model.
320456
+
319458
320457
  2. The server must be up with retrieval tracing on, so a call's ranked results
319459
320458
  can be recovered afterwards (open-source Publisher: \`PUBLISHER_MCP_TRACE=retrieval\`).
319460
320459
  Confirm a trace lookup is available (absent means tracing is off).
@@ -319495,6 +320494,44 @@ the run measure something other than what it names:
319495
320494
  whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
319496
320495
  model.
319497
320496
 
320497
+ The call budget is \`--max-turns\`, written to \`run.json\` as \`maxTurns\`.
320498
+ **Size it from a pilot rather than taking the default of 30.** Run the three
320499
+ cheapest cases uncapped (\`--max-turns 100\`) and set the cap at twice their
320500
+ maximum. A set whose questions need two or three sources joined does not fit
320501
+ a cap sized for single-source lookups: on one such set the completed
320502
+ attempts had a median of 17 turns and a 90th percentile of 26 against a cap
320503
+ of 30, and four cases died at it. A cap 15% above the 90th percentile of
320504
+ completed work is not a safety margin. Note also that \`malloy-analysis\` tells
320505
+ the answerer to persist -- retry a phrasing, let a small query settle whether
320506
+ a field exists -- so a tight cap and that instruction are in direct conflict.
320507
+
320508
+ **Do not change the model and the measuring instrument in the same step.**
320509
+ Those are two different axes and only one of them is cheap to separate.
320510
+
320511
+ Batching MODEL edits is fine and expected. Clustering exists so that one
320512
+ edit closes several cases, and an arm per fix does not survive contact with
320513
+ arithmetic: 20 fixes over 100 cases is 2,000 answers, and at the measured
320514
+ $0.33 a case on a proxied warehouse that is $660 of answering to attribute
320515
+ what the acceptance check attributes for the price of the affected cases
320516
+ plus holdout. Budget five arms for a defensible claim (a baseline, two for
320517
+ the A/A, two post-edit), not one per edit, and let \`skill:eval-improve\`
320518
+ carry each cluster.
320519
+
320520
+ The instrument is the other axis: the answer key, the judge and its prompt,
320521
+ the answerer model, the skills. Move one of those together with the model
320522
+ and there is nothing left holding still, so the result measures neither. On
320523
+ the run this comes from, nine model commits and a re-derived answer key
320524
+ landed between two arms, and the move from 20% to 39% belongs to no one --
320525
+ not because two model edits were batched, but because the ruler changed at
320526
+ the same time as the thing being measured. A key repair mid-improve is not
320527
+ forbidden; it ends that comparison, so re-baseline rather than quoting a
320528
+ delta across it.
320529
+
320530
+ The answerer model is instrument too, and it is the one most often left
320531
+ unstated. The same 23-case set read 12 match / 11 near / 0 no_match on one
320532
+ model and 8 / 4 / 15 on a smaller one. No statement about "the agent's"
320533
+ capability means anything until two arms name the same answerer.
320534
+
319498
320535
  7. Generate every answerer prompt from the stored case in \`cases.jsonl\`.
319499
320536
  Never retype the question. A truncated retype is indistinguishable from a
319500
320537
  real question downstream.
@@ -319515,6 +320552,31 @@ the run measure something other than what it names:
319515
320552
  6. \`skill:eval-improve\` only when this run includes improve, and only for
319516
320553
  \`owner: model\`. Then run the acceptance check. On accept, checkpoint.
319517
320554
 
320555
+ ## Report the run, or nobody can read it
320556
+
320557
+ A run directory is JSONL. It is a record, not a result, and the console summary
320558
+ scrolls away. **Every run ends by producing something a person can open**, and
320559
+ that job is \`skill:eval-report\`: it builds the servable run package (the case
320560
+ matrix app and the aggregate notebook) and gives the template for the write-up.
320561
+
320562
+ Read it at the end of every run, including a run that failed. The standing
320563
+ complaint about this loop is that "a bunch of stuff happens and it is hard to
320564
+ know the actual results", and a ledger nobody renders is why.
320565
+
320566
+ Two rules from it are worth repeating here, because they are the ones a
320567
+ conductor skips:
320568
+
320569
+ - **Separate a MODEL failure from an EVAL failure.** A wrong answer and a
320570
+ broken measurement look identical in a pass rate and have nothing else in
320571
+ common. An arm holding a truncated, contaminated or environment-failed
320572
+ attempt has no rate to quote at all.
320573
+ - **Say when a step did not run, and why.** \`improve\` not running because every
320574
+ cluster came back \`owner: agent-skill\` is a RESULT, and it reads identically
320575
+ to having forgotten unless it is written down.
320576
+
320577
+ Keep the write-up in the repository beside the set, not in a chat log and not
320578
+ in \`~/Downloads\`.
320579
+
319518
320580
  ## Checkpoint
319519
320581
 
319520
320582
  A checkpoint is a git commit of the model repository, taken after an acceptance check
@@ -319564,7 +320626,16 @@ conductor. Do not:
319564
320626
 
319565
320627
  - The model is the only thing improve edits. No question text, qids, or
319566
320628
  expected values in any name, doc, or comment.
319567
- - When the environment misbehaves, stop. Never diagnose a sick system.
320629
+ - When the environment misbehaves, stop. Never diagnose a sick system. The
320630
+ harness is part of the environment: a known-broken measurement does not
320631
+ become quotable by being finished. Measured against this directive, an arm
320632
+ already paid for gets quoted anyway -- it happened three times in one run,
320633
+ once with the confound stated in the same message that started the arm -- so
320634
+ the harness now enforces the parts it can. A run with a truncated or
320635
+ contaminated attempt prints no pass rate and records \`status: incomplete\`,
320636
+ and \`flip_table.py\` names what one arm left unscored that the other did not.
320637
+ Read \`run_error\` on the attempts
320638
+ and the INCOMPLETE line before quoting any number.
319568
320639
  - When a subagent disagrees with you, probe. Do not win by authority.
319569
320640
  - When a rule here is wrong, change this file and note it on the run.
319570
320641
 
@@ -319575,6 +320646,7 @@ conductor. Do not:
319575
320646
  the judge.
319576
320647
  - \`skill:eval-diagnose\`: component, owner, issue events. No edit.
319577
320648
  - \`skill:eval-improve\`: smallest model edit, probe receipts, no self-accept.
320649
+ - \`skill:eval-report\`: the run package and the write-up a person reads.
319578
320650
  - The \`malloy-analysis\` skill: what the blind answerer follows. It is installed
319579
320651
  from the \`analysis\` manifest group, not the \`eval\` group.
319580
320652
 
@@ -320121,7 +321193,7 @@ If later evidence makes one replacement obvious, then Repair a bad golden.`
320121
321193
  {
320122
321194
  name: "eval-loop/measurement",
320123
321195
  description: "Measurement. Reference detail for the eval-loop skill.",
320124
- body: `<!-- Sampling, the flip-count bar, the A/A noise band, and targeted fixes. Read this before quoting any number. -->
321196
+ body: `<!-- Sampling, the flip-count bar, the A/A band, reading the disagreement set, and targeted fixes. Read this before quoting any number. -->
320125
321197
 
320126
321198
  # Measurement
320127
321199
 
@@ -320156,9 +321228,49 @@ measure a baseline and diagnose failures but cannot defend an edit.
320156
321228
  This table was asserted, not measured, and the number it needs is a property of
320157
321229
  your harness and your set -- not of this skill. Measure it with an **A/A run**:
320158
321230
  the same model, same config, same set, twice, compared with
320159
- \`scripts/flip_table.py\`. Every flip it reports is noise by construction, since
320160
- nothing changed. Record the result with the set, in \`CALIBRATION.md\`, and cite
320161
- that file when you quote a band.
321231
+ \`scripts/flip_table.py\`. Record the result with the set, in \`CALIBRATION.md\`,
321232
+ and cite that file when you quote a band.
321233
+
321234
+ An A/A flip is unattributable to a CHANGE, because there was no change. That
321235
+ is not the same as saying it carries no information, and reading it as "noise
321236
+ by construction" throws away the most useful evidence in the run. See below.
321237
+
321238
+ ## Read the disagreement set before you average it
321239
+
321240
+ **First move on a flip set: read the flipping cases. Second move: establish
321241
+ the band.** Both, in that order. The band alone is half the information.
321242
+
321243
+ A case that flips is a case where the agent found two paths and the model did
321244
+ not make one of them obviously right. That is a MODEL-quality signal, and
321245
+ often a cheaper one than a stable failure, because two runs hand you a matched
321246
+ pair: same question, same model, one right answer and one wrong one, with the
321247
+ diff between them isolating the cause.
321248
+
321249
+ Measured, on a real set. Two arms of one model over 28 cases disagreed on
321250
+ five. Read as a floor, that is "this set has 18% churn, plan around it". Read
321251
+ as evidence, it was a single named model defect:
321252
+
321253
+ - all five were the same question -- does a blank field on a fee rule match
321254
+ every value?
321255
+ - the two arms produced two discrete query idioms, not scattered answers
321256
+ - the wrong idiom returned 2 fee IDs where the answer has 443, and 1 where it
321257
+ has 390
321258
+ - the two runs swapped: run 1 was right on one case and wrong on the other,
321259
+ run 2 the reverse
321260
+
321261
+ A model that documents a rule in prose while leaving an inviting wrong path
321262
+ reachable produces exactly this: a bimodal coin flip that averages to "noise".
321263
+ The variance WAS the finding. Averaging the five would have hidden it, and
321264
+ reading them took a day of manual transcript work that the table now does --
321265
+ \`flip_table.py\` prints the two final queries side by side for every flipped
321266
+ case, and \`diagnose.py --compare-run <other arm>\` hands the pair to the
321267
+ diagnosing agent.
321268
+
321269
+ Some flips really are noise, and a set with no band cannot tell an improvement
321270
+ from a wobble, so keep the band. What changes is which you do first, and what
321271
+ you call the thing before you have looked at it. Prefer **disagreement set**
321272
+ or **unstable cases** to "churn": the first two name a question, the last
321273
+ names an answer you have not earned yet.
320162
321274
 
320163
321275
  Re-measure whenever the model, judge, or set changes. This is not a formality:
320164
321276
  observed bands have moved by a factor of three across a fortnight of ordinary
@@ -320321,7 +321433,11 @@ python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/s
320321
321433
  # every run. Pass --model on a platform target too, from a checkout of the
320322
321434
  # served version, since the harness cannot see the model text there.
320323
321435
  # Exits 3 when set.json names no truthPackage: the audits ran, no
320324
- # golden was re-derived, and 0 would have claimed otherwise.
321436
+ # golden was re-derived, and 0 would have claimed otherwise. With
321437
+ # --definitions <ledger> (verify_definitions.py), a set whose every
321438
+ # value-bearing case rests on validated definitions exits 0 instead:
321439
+ # the composition rule, values not re-derived but their definitions
321440
+ # checked.
320325
321441
  python3 skills/eval-answer/scripts/verify_goldens.py \\
320326
321442
  --set <repo>/evals/ecommerce --publisher http://localhost:4881 \\
320327
321443
  --model <repo>/ecommerce --target-package ecommerce
@@ -320444,6 +321560,366 @@ Two failure modes worth pre-empting, because both produce a clean-looking run:
320444
321560
  - **Check the served revision is the one you edited.** Publisher serves a
320445
321561
  snapshot copy, so a model fix can be absent from the run that is supposed to
320446
321562
  measure it. Query the changed measure once before spending an arm on it.`
321563
+ },
321564
+ {
321565
+ name: "eval-report",
321566
+ description: "Write the summary a person reads after an evaluation run: what ran, what it scored, which failures were the MODEL and which were the EVAL itself (out of turns, contaminated, unestablished golden, unreadable judge), what was learned, and what to do next. Every claim links to the artifact behind it. Use at the end of any run, before quoting a number to anyone. Never scores an answer (eval-answer) or decides who owns a failure (eval-diagnose).",
321567
+ body: `# Report a Run
321568
+
321569
+ A run directory is JSONL and a console summary that scrolls away. Neither is a
321570
+ result. This skill turns one into something a person can open, and it is the
321571
+ last step of every run, including a run that failed.
321572
+
321573
+ The complaint this exists to answer, in a reviewer's words: *"eval runs and a
321574
+ bunch of stuff happens and it is hard to know the actual results."*
321575
+
321576
+ **Scope boundary:** this skill reports what the ledger already says. It does not
321577
+ score an answer (\`skill:eval-answer\`), decide who owns a failure
321578
+ (\`skill:eval-diagnose\`), or edit a model (\`skill:eval-improve\`). If a number is
321579
+ not in the ledger, do not put it in the report.
321580
+
321581
+ **The report is about THIS RUN, not about the harness.** Defects you find in
321582
+ the eval tooling while running it are real and worth filing, and they do not
321583
+ belong here: the reader wants to know what their model scored and why, not what
321584
+ is wrong with the thing that measured it. File those against the harness. The
321585
+ one exception is anything that qualifies THIS run's number -- a truncated
321586
+ attempt, a contaminated one, an unestablished answer key -- which the "Eval
321587
+ failures" section exists for.
321588
+
321589
+ ## Step 1: build the artifacts, before writing a word
321590
+
321591
+ \`\`\`bash
321592
+ python3 skills/eval-loop/scripts/build_run_package.py \\
321593
+ --run <run-dir> --set <set-dir> --out /tmp/eval-<label>
321594
+ curl -sS -X POST http://<publisher>/api/v0/environments/<env>/packages \\
321595
+ -H 'content-type: application/json' \\
321596
+ -d '{"name":"eval-<label>","location":"/tmp/eval-<label>"}'
321597
+ \`\`\`
321598
+
321599
+ That builds a Malloy package over the run's own CSVs and registers it with no
321600
+ restart. It gives you two things to link:
321601
+
321602
+ | Artifact | What it is | URL |
321603
+ |---|---|---|
321604
+ | The case matrix app | Every question, its verdict, which needed entities retrieval delivered, and a drawer per case holding the reference answer, the judge's reasoning, the re-executed rows and every query the answerer ran | \`<publisher>/environments/<env>/packages/eval-<label>/\` |
321605
+ | \`eval_run.malloynb\` | The aggregate tables: pass rate, effort, cost, most-missed entities, the backlog | \`<publisher>/<env>/eval-<label>/eval_run.malloynb\` |
321606
+
321607
+ **Those two URLs are in different path spaces, and guessing costs a 404.** The
321608
+ app is served by the in-package \`public/\` handler, which owns
321609
+ \`/environments/<env>/packages/<pkg>/<file>\`. The notebook is a MODEL, rendered
321610
+ by the Console, whose routes are \`/<env>/<package>/<model path>\` with no
321611
+ \`environments\` or \`packages\` segment at all. Putting the notebook under the
321612
+ app's prefix 404s, because the public-file handler answers there and the
321613
+ notebook is not in \`public/\`. Verified both, on a real package, by opening
321614
+ them.
321615
+
321616
+ Pass \`--run\` more than once to put two arms side by side.
321617
+
321618
+ ## Step 2: write the report
321619
+
321620
+ Put it in the repository beside the set, as \`RESULTS.md\`. Not in a chat log,
321621
+ not in \`~/Downloads\`, both of which have lost a findings document before.
321622
+
321623
+ **Derive every number from \`events.jsonl\` and \`run.json\`, not from memory or
321624
+ from the console.** A figure retyped from scrollback is a figure nobody can
321625
+ check, and the console rounds.
321626
+
321627
+ ### The template
321628
+
321629
+ \`\`\`markdown
321630
+ # <set> -- <label>
321631
+
321632
+ <one sentence: what was being measured, against what, and why now>
321633
+
321634
+ ## What ran
321635
+
321636
+ - Set \`<name>\` v\`<datasetVersion>\`, N cases (N dev, N holdout)
321637
+ - Target \`<env>/<package>\`, model \`<modelPath>\`, pinned \`<modelSha or targetVersion>\`
321638
+ - Answerer \`<model>\`, judge \`<model>\`, cap \`<maxTurns>\` turns
321639
+ - Steps run: scrape/run, eval<, diagnose><, improve>
321640
+ - Cost $X answerer + $Y judge
321641
+
321642
+ ## The result
321643
+
321644
+ **N of M decided (P%).** <-- or: **No pass rate.** See "Eval failures" below.
321645
+
321646
+ | qid | question | verdict |
321647
+ |---|---|---|
321648
+
321649
+ [case matrix](<url>) - [aggregate tables](<url>)
321650
+
321651
+ ## Retrieval and coverage
321652
+
321653
+ Covered? -> Retrieved? -> Correct?, with the per-arm numbers under each.
321654
+
321655
+ ## Model failures
321656
+
321657
+ One entry per wrong answer. What it got wrong in plain words, then the
321658
+ mechanism, then the link.
321659
+
321660
+ ## Eval failures
321661
+
321662
+ What went wrong with the MEASUREMENT rather than the model. Empty is a real
321663
+ and good answer; say "none" rather than dropping the section.
321664
+
321665
+ ## Query errors
321666
+
321667
+ Malloy the answerer wrote that would not run, and whether it recovered.
321668
+
321669
+ ## What this run taught us
321670
+
321671
+ ## What to do next
321672
+ \`\`\`
321673
+
321674
+ ### "What to do next" is a checklist, and most of it is derived
321675
+
321676
+ Do not invent this section. Most of it falls out of what the run did NOT do,
321677
+ and a reader should be able to see that nothing was skipped silently. Walk
321678
+ these in order and put every one that fires into the list, with its command:
321679
+
321680
+ | If the run shows | Then the next step is |
321681
+ |---|---|
321682
+ | \`coverage: unmeasured\` | run \`check_coverage.py --set <set> --model <pkg> --out coverage.json\`, then re-run with \`--coverage\` so it charges the failures |
321683
+ | \`goldenCheck: skipped\` or the set names no \`truthPackage\` | build one with \`init_truth_package.py\`; until then the goldens were derived through the model under test and certify themselves |
321684
+ | any golden still \`provisional\` | re-derive and \`verify_goldens.py --promote\` |
321685
+ | a stale entity name warning | fix \`expectedEntities\`; it scores as a retrieval miss on every run until you do |
321686
+ | a passing case with recall below 1.0 | check whether \`required\` over-specifies one path |
321687
+ | \`truncated\` non-empty | re-run those cases at a higher cap with \`--from\` |
321688
+ | diagnose did not run | run it, or say the failures have no owner yet |
321689
+ | a cluster with \`owner: model\` | \`skill:eval-improve\`, then the acceptance check |
321690
+ | a cluster with \`owner: agent-skill\` | **edit that skill.** This is NOT a dead end |
321691
+ | a cluster with \`owner: dataset\` | the golden side door in \`skill:eval-loop\` |
321692
+ | only one arm exists | note that no noise band has been measured for this set yet |
321693
+
321694
+ **An \`agent-skill\` cluster is work, not an absence of work.** \`eval-improve\`
321695
+ may not touch it, and writing "nothing to do, the model is fine" there is how a
321696
+ real defect gets closed as a non-finding. Name the skill, name the rule to add
321697
+ or change, and say who owns it. The model being innocent is a statement about
321698
+ the model, never about the run.
321699
+
321700
+ ### Write it for someone who was not there
321701
+
321702
+ The reader is a colleague who did not run this and does not know the loop's
321703
+ vocabulary. Two rules, both learned by handing a report to one:
321704
+
321705
+ - **Never make a bare count carry the meaning.** "The only cluster is
321706
+ \`owner: agent-skill\`" tells a reader nothing: what is a cluster, and what
321707
+ follows from it? Write what happened and what to do: "The one wrong answer
321708
+ came from the agent picking the wrong kind of field, so the fix is in the
321709
+ analysis skill, not in the model."
321710
+ - **Do not open with a negation of something you just reported.** A section
321711
+ that says "nothing to do" directly under a section reporting a wrong answer
321712
+ reads as a contradiction, and the reader stops trusting both. Lead with the
321713
+ wrong answer and what closes it; put anything genuinely needing no action
321714
+ after that, and say why.
321715
+
321716
+ ## The two failure sections, and why they are separate
321717
+
321718
+ This is the part most reports get wrong. A wrong answer and a broken
321719
+ measurement look identical in a pass rate and have nothing else in common: one
321720
+ is work for the model owner, the other is work for whoever runs the harness.
321721
+ Mixing them sends a modelling agent to fix a model that is fine.
321722
+
321723
+ **Model failures** are cases that were fairly asked, fairly answered and got the
321724
+ wrong answer. One entry each: what the answer said, what was right, and the
321725
+ mechanism in one sentence. Do not write a cluster id here; write what it got
321726
+ wrong.
321727
+
321728
+ **Eval failures** are everything that stopped the run measuring the model.
321729
+ Report every one that occurred, with its count and its qids, and say plainly
321730
+ that these are NOT evidence about the model:
321731
+
321732
+ | What happened | How it reads in the ledger | Who fixes it |
321733
+ |---|---|---|
321734
+ | Answerer ran out of turns | \`reason: answerer_truncated\`, and \`run.json\` \`truncated\` | raise \`--max-turns\`, re-run those cases with \`--from\` |
321735
+ | Isolation breached | \`reason: contaminated\`, \`contamination_reasons\` on the attempt | harness config; the answerer held a tool it should not |
321736
+ | Harness or server failed | \`reason: environment_failure: <what>\` | fix the environment, re-run the arm |
321737
+ | Answer key not established | \`reason: golden_provisional\` / \`_invalid\` / \`_ambiguous\` / \`_missing\` | derive the key (\`skill:eval-import\`), or the golden side door |
321738
+ | Judge reply unreadable | \`reason: judge_unparseable\` | it already retried; read \`artifacts/<qid>/judge.md\` |
321739
+ | Judge doubted the key | \`gold_status\` \`suspect\` / \`verified_wrong\`, \`run.json\` \`doubtedGoldens\` | the golden side door in \`skill:eval-loop\`, NOT improve |
321740
+ | Rubric quoted a stale figure | \`verify_goldens.py\` check 2 review items | repair the rubric against its own golden rows |
321741
+ | Arm stopped early | \`run.json\` \`status: aborted\` | four consecutive dead attempts; fix and re-run |
321742
+
321743
+ **If any of the first three occurred, there is no pass rate to quote.** The
321744
+ harness prints \`INCOMPLETE\` and withholds the percentage; the report does the
321745
+ same. Give the counts and the re-run command instead of a number with a caveat,
321746
+ because the number is what gets repeated and the caveat is what gets dropped.
321747
+
321748
+ ## Retrieval and coverage belong in every report
321749
+
321750
+ A pass rate says an answer was wrong. It does not say WHERE, and the three
321751
+ metrics that do are already in the run summary and the notebook. Report them,
321752
+ because a report that omits them makes every failure look like the model's:
321753
+
321754
+ **Copy the cascade the run printed. Do not re-derive it.** \`cascade_lines()\`
321755
+ in \`run_baseline.py\` already renders every rung, and it carries one field a
321756
+ hand-made table keeps losing: how many cases **answered correctly anyway**
321757
+ after stopping on that rung. Paste its block into the report.
321758
+
321759
+ **A finer measurement must never revise the headline result.** This is the rule
321760
+ the re-derivation breaks. Written as a funnel with a shrinking denominator --
321761
+ \`5 of 10 covered\`, then \`3 of the 5\`, then \`3 of the 3\` -- the last rung reads
321762
+ as "only 3 of 10 succeeded" on a run where **all ten answers were correct**.
321763
+ That report went to a reviewer, and the objection was the right one: adding
321764
+ detail about retrieval cannot turn a 10 of 10 into a 3.
321765
+
321766
+ Two things stop it:
321767
+
321768
+ - **Report every rung over all N cases**, never over the survivors of the rung
321769
+ above. The rungs are three measurements of the same cases, not a narrowing of
321770
+ them.
321771
+ - **\`delivered, right\` is NOT the pass rate.** The passing cases are
321772
+ \`delivered, right\` plus \`passed_not_covered\` plus \`passed_not_retrieved\`. A
321773
+ report that omits those last two has dropped the only numbers that reconcile
321774
+ the cascade with the score.
321775
+
321776
+ **Label a rung for what it measures, not for what a reader will assume.**
321777
+ "Can the model express an answer?" is wrong for most of what lands on the \`no\`
321778
+ side of the first rung. \`CONVENTION\` means the data is present and no named
321779
+ measure encodes the statistical or business convention; \`NO-DISAMBIG\` means two
321780
+ plausible candidates and no doc saying which the question means. The model can
321781
+ express an answer in both -- it does not say WHICH answer is meant. So the rung
321782
+ asks whether the model NAMES what the question needs.
321783
+
321784
+ **Give the per-entity number too, and label both.**
321785
+
321786
+ - per CASE, the cascade: did this case get everything it needed
321787
+ - per ENTITY, recall: of the N entities the answers depended on, how many were
321788
+ delivered
321789
+
321790
+ They answer different questions, and the entity number is the more natural read
321791
+ of "how did search do". One run reported \`3 of 5\` cases and buried \`16 required
321792
+ entities, 80% delivered\`, and the case number was the only thing a reader saw.
321793
+
321794
+ - **Covered?** Can the model express a correct answer at all? Not computed by
321795
+ the run: \`check_coverage.py\` reads the MODEL rather than the answers, and the
321796
+ run consumes its report through \`--coverage\`. **If it was not run, say
321797
+ \`unmeasured\` rather than leaving the row out** -- an unmeasured coverage
321798
+ label is not evidence that the model covers the question.
321799
+ - **Retrieved?** Of the entities the golden answer depends on, how many did
321800
+ \`get_context\` hand back. Quote \`required_count\`, \`delivered_count\` and how
321801
+ many were ranked rather than merely mentioned in a returned source's docs.
321802
+ - **Correct?** The pass rate, which is the rung the other two qualify.
321803
+
321804
+ Two numbers here are routinely misread, so qualify them in the report or leave
321805
+ them out:
321806
+
321807
+ - **Entity precision is a PAYLOAD number, not a retrieval-quality one.** Report
321808
+ it as what it measures: how much context the agent was handed against how
321809
+ much it needed. 52 entities returned per attempt for the 2 an answer used is
321810
+ a real cost, in tokens and in attention, and it is worth tracking. What it
321811
+ cannot tell you is whether retrieval worked, because the denominator is
321812
+ everything returned and it ignores rank: an entity that came back at rank 3
321813
+ of 51 scores identically to one at rank 51.
321814
+ - **For quality, report rank.** "Required entities came back at median rank 3,
321815
+ 9 of 16 in the top 5" is a statement about retrieval. Precision alone reads
321816
+ as an indictment of it and is mostly a statement about how many fields the
321817
+ package has.
321818
+ - **Report the misses split by cause, not as one recall figure.** The run
321819
+ attributes each to \`never asked\` (no target of a type that could return it --
321820
+ deterministic, because \`target_type\` is a hard filter on the server, so no
321821
+ documentation could have delivered it) or \`not retrieved\` (a compatible target
321822
+ was issued and it still did not come back, which is the docs or the wording,
321823
+ and diagnose separates them). Those have different owners and one recall
321824
+ number hides which you have.
321825
+ - **A miss on a case that PASSED is still a miss, and the report says so.** The
321826
+ answer was right by another route; name the route. Measured on one run, 3 of
321827
+ 4 findings were on passing cases and the route was always the same: the agent
321828
+ rebuilding the model's own measure inline, which held for \`count()\` and failed
321829
+ on the one measure carrying a grain rule.
321830
+ - **Read the entities that did NOT come back, and what was asked for.** That is
321831
+ where the retrieval signal actually is, and the misses are rarely independent.
321832
+ Measured on one run: 5 required entities never came back as ranked results,
321833
+ and 4 of the 5 had the same cause -- the agent sent only \`source\` and
321834
+ \`dimension\` targets, and \`target_type\` is a hard filter, so no measure could
321835
+ be returned however well the model documents it. One defect, five symptoms,
321836
+ and the same one that produced the run's only wrong answer. A per-case list of
321837
+ misses beside what was asked for would have shown it in a glance.
321838
+ - **A PASSING case with recall below 1.0 is usually an expectation defect**, not
321839
+ a retrieval miss: the set named one path to an answer the agent reached by
321840
+ another. Report the count and read it as a prompt to check \`required\`.
321841
+
321842
+ ## Query errors are worth a section of their own
321843
+
321844
+ Malloy the answerer wrote that would not compile or run, whether or not the
321845
+ case passed. These sit on \`tool_call\` events as \`error\`, and nothing else
321846
+ surfaces them: a case that errored twice and then recovered scores exactly like
321847
+ one that got it right first time, so the effort disappears.
321848
+
321849
+ They are the sharpest available evidence on whether the skills and the docs are
321850
+ leading agents astray, because each one names a specific thing the agent
321851
+ believed and the language does not support. Report the count, the distinct
321852
+ error kinds, and for each whether an existing skill already covers it. An error
321853
+ whose fix IS documented, in a skill the answerer did not open, is a finding
321854
+ about the skills rather than about the agent.
321855
+
321856
+ \`eval-diagnose\` does not see these today: it reads failing cases only, so an
321857
+ error inside a passing case is invisible to it. Say so rather than implying
321858
+ they were triaged.
321859
+
321860
+ ## Say what did not run, and why
321861
+
321862
+ A step that did not run is a result, and it is indistinguishable from having
321863
+ forgotten unless it is written down. The common one: \`improve\` does not run when
321864
+ every cluster came back \`owner: agent-skill\` or \`owner: dataset\`, because
321865
+ \`skill:eval-improve\` may only touch \`owner: model\`. That means the model is not
321866
+ at fault, which is worth a sentence rather than a silence.
321867
+
321868
+ Same for diagnose. If it did not run, no failure in this report has an owner
321869
+ yet, and the report says so rather than implying the failures are the model's.
321870
+
321871
+ ## Findings and improvement ideas
321872
+
321873
+ Two different things, kept apart.
321874
+
321875
+ - **Findings** are what this run established, each with the evidence beside it.
321876
+ A finding with no artifact behind it is an opinion.
321877
+ - **Improvement ideas** are what somebody might do about them, tagged with who
321878
+ owns each: the model, the answer key, the analysis skills, or the harness.
321879
+ An idea is a proposal, not a decision, and the report does not pretend a
321880
+ cluster has been triaged when it has not.
321881
+
321882
+ Rank by how many cases each would move, and say when the answer is "nothing":
321883
+ a 90% run whose one failure is a known fan-out trap may need no action at all.
321884
+
321885
+ **Report facts, do not instruct the reader.** "This set has had one arm, so no
321886
+ noise band exists for it" is a fact they can act on. "Do not quote 90% until X"
321887
+ is a lecture about their own number, and they did not ask for one. State what
321888
+ was and was not measured; what to do with it is theirs.
321889
+
321890
+ ## Make the links clickable
321891
+
321892
+ A report whose evidence cannot be opened is a report nobody checks.
321893
+
321894
+ - **Absolute paths**, never relative ones. A reader is not in your working
321895
+ directory, and a terminal only makes an absolute path clickable.
321896
+ - **The served URL** for the app and the notebook, with the real host and port
321897
+ the run used, not a placeholder.
321898
+ - Per case, link the FILES, not the directory: \`artifacts/<qid>/answer.md\`,
321899
+ \`artifacts/<qid>/judge.md\`, \`artifacts/<qid>/answerer.jsonl\`. A \`file://\`
321900
+ link to a directory opens nothing in most editors, which is a dead link that
321901
+ looks live.
321902
+ - **Move the run somewhere durable before you link it.** A run written under a
321903
+ session scratch directory (\`/tmp/...\`, a host's per-session sandbox) is
321904
+ deleted when the session ends, and many editors will not linkify a path there
321905
+ even while it exists. Copy the run directory, artifacts included, next to the
321906
+ report under the set, then link that. A report whose evidence is in a temp
321907
+ directory is a report with no evidence by next week. This was caught twice by
321908
+ a reader clicking and getting nothing.
321909
+ - Write a local path bare, as \`/abs/path/to/file\`, not as \`[text](file:///...)\`.
321910
+ Terminals and editors linkify a bare absolute path; a \`file://\` markdown link
321911
+ is frequently inert, and an inert link is worse than the path in plain text
321912
+ because the reader has nothing to copy.
321913
+ - Never link a run LABEL or a qid as though it were a path. \`faa-v1-baseline-01\`
321914
+ is a label; the path is the run directory. A backticked label that looks like
321915
+ a link and resolves to nothing is worse than plain text, and it has already
321916
+ wasted a reader's time.
321917
+
321918
+ ## Related skills
321919
+
321920
+ - \`skill:eval-loop\`: conducts the run this reports on.
321921
+ - \`skill:eval-answer\`: the verdicts and the ledger schema this reads.
321922
+ - \`skill:eval-diagnose\`: owners and clusters, when it ran.`
320447
321923
  },
320448
321924
  {
320449
321925
  name: "malloy",
@@ -320502,6 +321978,7 @@ This table is a catalogue of what exists, not of what is loaded. A host that ins
320502
321978
  | \`eval-improve\` | The smallest model edit for a diagnosed cluster, with probe receipts |
320503
321979
  | \`eval-judge\` | Scoring one attempt against its golden, and saying whether the golden itself is believed |
320504
321980
  | \`eval-import\` | Turning questions that arrived from outside into a set, before the first run against it |
321981
+ | \`eval-report\` | Turning a finished run into something a person can open, and saying what it did and did not measure |
320505
321982
 
320506
321983
  **Writing correct Malloy** (read before writing, not after failing)
320507
321984
 
@@ -320654,7 +322131,7 @@ End with a short **Next steps**: one or two specific deeper analyses the data co
320654
322131
  {
320655
322132
  name: "malloy-analysis-pitfalls",
320656
322133
  description: "Checks to run on a query and its numbers before a result is presented - wrong grain, fan-out, invented field names, mismatched filters, count vs distinct. The common data-analysis pitfalls.",
320657
- body: "# Data Analysis Pitfalls\n\nWatch for these common mistakes throughout the analysis workflow. When you encounter one, fix it before presenting results.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Query Construction\n\n### Wrong grain / fan-out\nUsing dimensions or measures from a joined source that has a finer grain than the base source can silently multiply rows, inflating aggregates. For example, aggregating revenue while grouping by a line-item field may double- or triple-count totals. If your query touches fields from a joined source, compare `count(key_field)` to `count()`: if the row count is significantly higher than the distinct key count, you likely have fan-out.\n\n### Invented entity names\nNever guess field names. Use only the exact field paths defined in the model (find them with `get_context`). A plausible-sounding name that does not exist in the model will produce an error, or worse, silently reference the wrong field.\n\n### Mismatched filter values\nDimensional values are case-sensitive and format-specific. Common mismatches include case differences (\"Nike\" vs \"NIKE\" vs \"nike, inc.\"), partial matches (\"New York\" when the data has \"New York City\"), and aliased values (\"USA\" vs \"United States\"). A filter on a value that doesn't exist in the data silently returns zero rows without erroring. Always use the exact dimensional values from retrieval results, and if in doubt, run a distinct-values query on the dimension to confirm.\n\n### Filtering on the wrong field\nIf the user asks to filter by \"brand\", confirm which dimension corresponds to \"brand\" in the model. There may be multiple fields with similar names at different levels of the hierarchy.\n\n### Missing filters\nIf the user asks about \"last quarter\" but you don't apply a time filter, you are returning all-time data. Always check whether the question implies filters you have not yet applied.\n\n### Misinterpreted entities\nA field can exist in the model and still be the wrong choice. For example, using `revenue` (which may be gross) when the question asks about profit, or treating a count measure as if it were a sum. Cross-reference the field definitions and `#(doc)` descriptions in the model to confirm a field means what you think it means.\n\n### Semantic ambiguity\nField names or descriptions sometimes suggest one interpretation while the actual values tell a different story, for example, a field labeled \"annual ridership\" containing values that look like average weekday traffic, or a field named `revenue` that appears to represent net revenue in practice. When values don't match expectations, note whether the ambiguity affects the answer and call it out.\n\n### Fragile ad-hoc definitions\nWhen defining a new measure or dimension inline, common mistakes include assuming a field is numeric when it's actually a string, ignoring nulls in arithmetic (e.g., `a - b` yields null if either is null), and building logic around values that only cover a subset of the data.\n\nPay special attention to value coverage when defining a dimension that categorizes or subsets data. It's easy to capture too few values (missing categories that should be included) or too many (grouping in values that don't belong). For example, a `pick` expression that maps tier names to \"Premium\" and \"Standard\" might miss a tier that should be Premium, or a filter meant to isolate one product line might inadvertently include related but distinct products. Before relying on such a definition, run a distinct-values query on the underlying field to see the full set of values and confirm your logic handles them all correctly. This applies equally whether you define the logic as a new dimension or apply it directly as a `where` clause: the risk of incomplete value coverage is the same either way.\n\n### Hidden filters in views\nViews can have built-in `where` clauses that pre-filter the data. A view named `recent_orders` might only include the last 90 days, or `active_customers` might exclude certain statuses. If the view's definition is available, read it to understand what filters are baked in; they may conflict with what the user is asking for. When in doubt, query the base source directly and apply filters explicitly.\n\n## Result Interpretation\n\n### Implausible magnitudes\nIf a \"total\" is suspiciously small or large, question it. Common causes: missing filters (too large), over-filtering (too small), wrong unit (dollars vs. cents), or fan-out from joins (inflated).\n\n### Nulls distorting aggregations\nNull values are silently excluded from `avg()` and can make `sum()` results lower than expected. If a significant portion of a field's data is null, aggregations over that field may be misleading. When results seem off, compare total row count to a count of non-null values for the key field to gauge how much data is missing.\n\n### Confusing count vs. count distinct\n`count()` counts rows while measures defined with `count(field)` count distinct values of that field. Using a row count when you need distinct values (or vice versa) is a frequent source of inflated or deflated numbers, especially when the query touches joined sources.\n\n### Percentage of what?\nWhen computing percentages or shares, be explicit about the denominator. \"30% of revenue\" means nothing if you don't confirm what the total revenue is and whether it's filtered the same way.\n\n### Time period mismatches\nComparing metrics across different time periods without normalizing (e.g., comparing a full year to a partial quarter) produces misleading conclusions.\n\n### Extremes of a rate on a tiny denominator\n\"Which one is worst\" ranked on a ratio hands the top spot to whichever group has the fewest rows: one failure out of three is a 33% failure rate, and it will outrank a group with thousands. Before reporting an extreme of any rate, look at what is underneath it. Apply the model's documented minimum volume if it has one; if it has none, pick a floor, say which floor you picked, and print the denominator next to the rate rather than the rate alone.\n\n## Verification Signals\n\n### Parts don't sum to the whole\nIf you break down a total by category, the categories should sum to the total (or close to it, accounting for nulls). If they don't, something is wrong with the grain or filters.\n\n### A total the model tells you not to take\nThe check above assumes the parts are additive. Sometimes they are not, and the model says so: a doc noting that one event can be logged on two layers, or that a measure is non-additive, is telling you the sum across that dimension double-counts. Do not print a total row across such a dimension, and least of all one the user did not ask for. If a total is genuinely wanted, say why the model does not support it and give the per-category figures.\n\n### Row count surprises\nBefore interpreting results, check whether the row count makes sense. An unexpectedly high row count often indicates fan-out from a join. An unexpectedly low count may mean an overly restrictive filter.\n\n### Zero or empty results\nIf a query returns no rows, don't report \"there is no data.\" First verify that your filters are correct and the field names are right. The absence of results is usually a query problem, not a data problem."
322134
+ body: "# Data Analysis Pitfalls\n\nWatch for these common mistakes throughout the analysis workflow. When you encounter one, fix it before presenting results.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n## Query Construction\n\n### Wrong grain / fan-out\nUsing dimensions or measures from a joined source that has a finer grain than the base source can silently multiply rows, inflating aggregates. For example, aggregating revenue while grouping by a line-item field may double- or triple-count totals. If your query touches fields from a joined source, compare `count(key_field)` to `count()`: if the row count is significantly higher than the distinct key count, you likely have fan-out.\n\nThat check reads in ONE direction only. `count(joined.field)` is a distinct count evaluated at the joined source's grain, so for an ordinary many-to-one join it is much SMALLER than `count()`, and that is the expected, correct result -- not missing data. Ten thousand flights joined to four hundred aircraft give `count(aircraft.tail_num)` of 400, and nothing is wrong. Do not read a low value as a coverage problem and do not abandon the answer over it. **Coverage is measured at the base grain**, by counting base rows whose joined value is null:\n\n```malloy\nrun: flights -> {\n aggregate:\n all_rows is count()\n missing is count() { where: aircraft.aircraft_models.seats = null }\n}\n```\n\nAn agent once refused a correct answer it had already computed because `count(aircraft.tail_num)` returned 36 against 17,875 flights; measured at the base grain, zero flights were missing a seat value.\n\n### Invented entity names\nNever guess field names. Use only the exact field paths defined in the model (find them with `get_context`). A plausible-sounding name that does not exist in the model will produce an error, or worse, silently reference the wrong field.\n\n### Mismatched filter values\nDimensional values are case-sensitive and format-specific. Common mismatches include case differences (\"Nike\" vs \"NIKE\" vs \"nike, inc.\"), partial matches (\"New York\" when the data has \"New York City\"), and aliased values (\"USA\" vs \"United States\"). A filter on a value that doesn't exist in the data silently returns zero rows without erroring. Always use the exact dimensional values from retrieval results, and if in doubt, run a distinct-values query on the dimension to confirm.\n\n### Filtering on the wrong field\nIf the user asks to filter by \"brand\", confirm which dimension corresponds to \"brand\" in the model. There may be multiple fields with similar names at different levels of the hierarchy.\n\n### Missing filters\nIf the user asks about \"last quarter\" but you don't apply a time filter, you are returning all-time data. Always check whether the question implies filters you have not yet applied.\n\n### Misinterpreted entities\nA field can exist in the model and still be the wrong choice. For example, using `revenue` (which may be gross) when the question asks about profit, or treating a count measure as if it were a sum. Cross-reference the field definitions and `#(doc)` descriptions in the model to confirm a field means what you think it means.\n\n### Semantic ambiguity\nField names or descriptions sometimes suggest one interpretation while the actual values tell a different story, for example, a field labeled \"annual ridership\" containing values that look like average weekday traffic, or a field named `revenue` that appears to represent net revenue in practice. When values don't match expectations, note whether the ambiguity affects the answer and call it out.\n\n### Fragile ad-hoc definitions\nWhen defining a new measure or dimension inline, common mistakes include assuming a field is numeric when it's actually a string, ignoring nulls in arithmetic (e.g., `a - b` yields null if either is null), and building logic around values that only cover a subset of the data.\n\nPay special attention to value coverage when defining a dimension that categorizes or subsets data. It's easy to capture too few values (missing categories that should be included) or too many (grouping in values that don't belong). For example, a `pick` expression that maps tier names to \"Premium\" and \"Standard\" might miss a tier that should be Premium, or a filter meant to isolate one product line might inadvertently include related but distinct products. Before relying on such a definition, run a distinct-values query on the underlying field to see the full set of values and confirm your logic handles them all correctly. This applies equally whether you define the logic as a new dimension or apply it directly as a `where` clause: the risk of incomplete value coverage is the same either way.\n\n### Hidden filters in views\nViews can have built-in `where` clauses that pre-filter the data. A view named `recent_orders` might only include the last 90 days, or `active_customers` might exclude certain statuses. If the view's definition is available, read it to understand what filters are baked in; they may conflict with what the user is asking for. When in doubt, query the base source directly and apply filters explicitly.\n\n## Result Interpretation\n\n### Implausible magnitudes\nIf a \"total\" is suspiciously small or large, question it. Common causes: missing filters (too large), over-filtering (too small), wrong unit (dollars vs. cents), or fan-out from joins (inflated).\n\n### Nulls distorting aggregations\nNull values are silently excluded from `avg()` and can make `sum()` results lower than expected. If a significant portion of a field's data is null, aggregations over that field may be misleading. When results seem off, compare total row count to a count of non-null values for the key field to gauge how much data is missing.\n\n### Confusing count vs. count distinct\n`count()` counts rows while measures defined with `count(field)` count distinct values of that field. Using a row count when you need distinct values (or vice versa) is a frequent source of inflated or deflated numbers, especially when the query touches joined sources.\n\n### Percentage of what?\nWhen computing percentages or shares, be explicit about the denominator. \"30% of revenue\" means nothing if you don't confirm what the total revenue is and whether it's filtered the same way.\n\n### Time period mismatches\nComparing metrics across different time periods without normalizing (e.g., comparing a full year to a partial quarter) produces misleading conclusions.\n\n### Extremes of a rate on a tiny denominator\n\"Which one is worst\" ranked on a ratio hands the top spot to whichever group has the fewest rows: one failure out of three is a 33% failure rate, and it will outrank a group with thousands. Before reporting an extreme of any rate, look at what is underneath it. Apply the model's documented minimum volume if it has one; if it has none, pick a floor, say which floor you picked, and print the denominator next to the rate rather than the rate alone.\n\n## Verification Signals\n\n### Parts don't sum to the whole\nIf you break down a total by category, the categories should sum to the total (or close to it, accounting for nulls). If they don't, something is wrong with the grain or filters.\n\n### A total the model tells you not to take\nThe check above assumes the parts are additive. Sometimes they are not, and the model says so: a doc noting that one event can be logged on two layers, or that a measure is non-additive, is telling you the sum across that dimension double-counts. Do not print a total row across such a dimension, and least of all one the user did not ask for. If a total is genuinely wanted, say why the model does not support it and give the per-category figures.\n\n### Row count surprises\nBefore interpreting results, check whether the row count makes sense. An unexpectedly high row count often indicates fan-out from a join. An unexpectedly low count may mean an overly restrictive filter.\n\n### Zero or empty results\nIf a query returns no rows, don't report \"there is no data.\" First verify that your filters are correct and the field names are right. The absence of results is usually a query problem, not a data problem."
320658
322135
  },
320659
322136
  {
320660
322137
  name: "malloy-analysis-report",
@@ -323749,7 +325226,7 @@ Steps to follow when the user asks a question:
323749
325226
  {
323750
325227
  name: "malloy-phrase-detection",
323751
325228
  description: "How to phrase search_text on a get_context call so retrieval returns the fields you need instead of a truncated catalog. Covers target-type classification and decomposition patterns.",
323752
- body: "# Search Target Construction for `get_context`\n\nThe `get_context` tool description defines each field and what a call returns. This skill focuses on the parts you won't get right by default: classifying concepts into target types and splitting ambiguous phrases.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n**Scope of this skill:** the patterns below build `dimension` / `measure` / `view` targets. Phrasing for `source` targets is covered at the end.\n\n**A note on matching:** `get_context` searches over the model (sources, fields, views, and their descriptions), not the distinct categorical *values* stored in the data. To find which literal values a categorical dimension holds, target the dimension, then query its distinct values with `execute_query` (see the patterns below).\n\n## Always send `search_text`\n\n**Do not enumerate.** Omitting `search_text` lists a catalog rather than searching it. Knowing the package narrows *where* to look; it does not substitute for saying *what* you need: if you know the package, that is a reason to scope, not a reason to skip `search_text`. Enumerated listings are capped per source and per entity type, and with no relevance signal the cap drops the fields your question is about while keeping join-path noise.\n\nA bare listing has two legitimate uses. The first is answering \"what data is here?\" when the user has named no subject at all. The second is reading a specific entity you already have the exact name of: scope to its source, set `entity_name`, and pass `search_text: null`, which returns that entity's docstring and Malloy code without spending a search. Every other call carries `search_text` on every target.\n\n## Authoring `search_text` for entity targets\n\nWrite `search_text` as a brief semantic **description** of what you're looking for, not an echo of the user's word. This applies even when you already know the entity name from a prior result: still describe it, don't just repeat the name. That is a rule about how to *phrase* a search, and it does not conflict with the exact-entity lookup above: if you want that one entity's code and docstring rather than a ranked set, pin it with `entity_name` and skip the search entirely. Sending its name back as `search_text` is the move this rule forbids, because it searches for a name instead of either describing a concept or asking for the entity.\n\nOne target per concept is enough: the tool handles phrasing variants internally. Don't pile up dimension targets that point at the same field. Use multiple targets only when they describe genuinely distinct concepts (see \"Non-obvious decomposition patterns\" below).\n\n## Target-type decision guide\n\n- **`dimension`**: categorical attribute to group, filter, or join on. Also used for time and numeric fields.\n - \"region\" becomes `\"the geographic region\"`\n- **`measure`**: aggregation metric (count, sum, average, rate).\n - \"total revenue\" becomes `\"the total revenue or sales amount\"`\n- **`view`**: pre-built analysis. Include one whenever the question sounds like a canned report (summary, breakdown, top-N, trend).\n - \"sales summary\" becomes `\"a summary of sales metrics\"`\n- **`source`**: data domain, for a question that names a subject area rather than fields (phrasing below).\n\n**Resolving categorical values (no value-search target in v1).** When the user names a literal value like \"premium\" or \"New York City\", target the *dimension* it lives on (`\"the subscription tier\"`, `\"the city where the subscriber lives\"`). Then confirm the exact stored string by querying that dimension's distinct values with `execute_query` before you filter on it. The data may store `\"Premium\"`, `\"PREMIUM\"`, `\"NYC\"`, or `\"New York\"`, and only the data tells you which.\n\n## Non-obvious decomposition patterns\n\nThese are the rules you won't apply correctly by default:\n\n1. **Adjective + noun, split.** \"active users\" becomes two dimension targets: one for the attribute (`\"the status of the user account\"`) and one for the noun (`\"the user or account holder\"`). Resolve the modifier (\"active\") to the exact stored value by querying the status dimension's distinct values with `execute_query`.\n2. **Ambiguous concept, cover both types.** \"rating\", \"duration\", and the like could be either a dimension or a measure: create one target of each type.\n3. **Time references are dimensions.** \"last year\" becomes a dimension target for the relevant date field (`\"the date the event occurred\"`).\n4. **Numeric ranges are dimensions.** \"aged 50\", \"revenue over $1M\" become dimension targets; the comparison is applied in the query, not matched as text.\n5. **Categorical strings that look numeric are still dimensions.** \"18-30\", \"<5 days\", \"tier 2\" are stored as literal strings on a dimension. Target that dimension, then confirm the exact string with `execute_query`.\n6. **\"Top N\" without a named measure, add a ranking measure.** \"top 6 products\" becomes a measure for the ranking concept (`\"the performance metric for a product\"`) plus a dimension for the entity. If the measure is explicit (\"top products by total sales\"), use it directly and skip the generic ranking measure.\n7. **Multiple values for one concept, one dimension target.** Several values (\"premium and basic\") still map to a single dimension target for the parent field; enumerate the exact stored values with `execute_query`.\n8. **A population qualifier is a target, and so is the one the question omits.** Words like \"real\", \"actual\", \"genuine\", \"live\" or \"production\" are not filler: they name rows the model marks for exclusion. Target the flag itself (`\"the flag marking synthetic, test or monitoring traffic\"`), not just the noun they modify. Add one such target even when the question carries no qualifier at all, because a table of events, requests, sessions or logs usually holds test, internal or cancelled rows and nothing in the wording will say so. Resolve it to the model's own flag rather than inventing a filter on an id or a name; a hand-rolled exclusion and the documented one rarely select the same rows.\n\n## Worked example\n\n**User:** \"Customer churn in NYC over the last year for premium and basic subscribers\"\n\nThe targets for this question:\n\n| target_type | search_text |\n|---|---|\n| `measure` | `\"the rate at which customers leave the service\"` |\n| `dimension` | `\"the city where the subscriber lives\"` |\n| `dimension` | `\"the date the subscription was canceled\"` |\n| `dimension` | `\"the tier of the subscription\"` |\n| `view` | `\"subscriber churn or retention analysis\"` |\n\nKey moves: time (\"last year\") becomes a dimension on the cancellation date; \"NYC\" and \"premium/basic subscribers\" do not get their own value targets, they resolve to the city and tier dimensions. One `view` target is included to surface any canned churn analysis.\n\nThe response returns the source these fields live on (here `subscriptions`) with the matched fields on its card. Then run `execute_query` to read the city and tier dimensions' distinct values and confirm the exact strings to filter on (\"New York City\" vs \"NYC\", \"premium\" vs \"Premium\").\n\n## Authoring `search_text` for `source` targets\n\nAim for **3-8 words that name the entity and its business process**. Don't include filter values, time ranges, or aggregations: those belong in entity targets.\n\n| Too vague | Over-specific (entity-shaped) | Good |\n|---|---|---|\n| `\"orders\"` | `\"total order revenue by customer last year\"` | `\"customer order history and line items\"` |\n| `\"customer data\"` | `\"premium subscribers who churned in NYC\"` | `\"subscriber accounts and churn\"` |\n| `\"metrics\"` | `\"monthly revenue variance by account\"` | `\"sales pipeline and revenue forecasts\"` |\n\nHeuristics:\n\n1. **Translate, don't echo.** \"How did sales go last month?\" becomes `\"sales order revenue\"`, not `\"sales last month\"`.\n2. **Differentiate by data shape or business process, not by the user's industry, brand, or product category.** Prefer `\"order fulfillment and shipping\"` over `\"ecommerce data\"` when multiple commerce-ish packages exist. Do NOT add words like `\"eyewear\"`, `\"subscription box\"`, `\"Acme Corp\"`, or the user's specific vertical/brand. Source summaries describe data structure, not the customer's vertical, so those words add noise and can hurt matching.\n3. **Retry with alternative phrasings** if the right source isn't in the results before concluding it's missing."
325229
+ body: "# Search Target Construction for `get_context`\n\nThe `get_context` tool description defines each field and what a call returns. This skill focuses on the parts you won't get right by default: classifying concepts into target types and splitting ambiguous phrases.\n\n> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.\n\n**Scope of this skill:** the patterns below build `dimension` / `measure` / `view` targets. Phrasing for `source` targets is covered at the end.\n\n**A note on matching:** `get_context` searches over the model (sources, fields, views, and their descriptions), not the distinct categorical *values* stored in the data. To find which literal values a categorical dimension holds, target the dimension, then query its distinct values with `execute_query` (see the patterns below).\n\n## Always send `search_text`\n\n**Do not enumerate.** Omitting `search_text` lists a catalog rather than searching it. Knowing the package narrows *where* to look; it does not substitute for saying *what* you need: if you know the package, that is a reason to scope, not a reason to skip `search_text`. Enumerated listings are capped per source and per entity type, and with no relevance signal the cap drops the fields your question is about while keeping join-path noise.\n\nA bare listing has two legitimate uses. The first is answering \"what data is here?\" when the user has named no subject at all. The second is reading a specific entity you already have the exact name of: scope to its source, set `entity_name`, and pass `search_text: null`, which returns that entity's docstring and Malloy code without spending a search. Every other call carries `search_text` on every target.\n\n## Authoring `search_text` for entity targets\n\nWrite `search_text` as a brief semantic **description** of what you're looking for, not an echo of the user's word. This applies even when you already know the entity name from a prior result: still describe it, don't just repeat the name. That is a rule about how to *phrase* a search, and it does not conflict with the exact-entity lookup above: if you want that one entity's code and docstring rather than a ranked set, pin it with `entity_name` and skip the search entirely. Sending its name back as `search_text` is the move this rule forbids, because it searches for a name instead of either describing a concept or asking for the entity.\n\nOne target per concept is enough: the tool handles phrasing variants internally. Don't pile up dimension targets that point at the same field. Use multiple targets only when they describe genuinely distinct concepts (see \"Non-obvious decomposition patterns\" below).\n\n## Target-type decision guide\n\n- **`dimension`**: categorical attribute to group, filter, or join on. Also used for time and numeric fields.\n - \"region\" becomes `\"the geographic region\"`\n- **`measure`**: aggregation metric (count, sum, average, rate).\n - \"total revenue\" becomes `\"the total revenue or sales amount\"`\n- **`view`**: pre-built analysis. Include one whenever the question sounds like a canned report (summary, breakdown, top-N, trend).\n - \"sales summary\" becomes `\"a summary of sales metrics\"`\n- **`source`**: data domain, for a question that names a subject area rather than fields (phrasing below).\n\n**Resolving categorical values (no value-search target in v1).** When the user names a literal value like \"premium\" or \"New York City\", target the *dimension* it lives on (`\"the subscription tier\"`, `\"the city where the subscriber lives\"`). Then confirm the exact stored string by querying that dimension's distinct values with `execute_query` before you filter on it. The data may store `\"Premium\"`, `\"PREMIUM\"`, `\"NYC\"`, or `\"New York\"`, and only the data tells you which.\n\n## Non-obvious decomposition patterns\n\nThese are the rules you won't apply correctly by default:\n\n1. **Adjective + noun, split.** \"active users\" becomes two dimension targets: one for the attribute (`\"the status of the user account\"`) and one for the noun (`\"the user or account holder\"`). Resolve the modifier (\"active\") to the exact stored value by querying the status dimension's distinct values with `execute_query`.\n2. **Ambiguous concept, cover both types.** \"rating\", \"duration\", and the like could be either a dimension or a measure: create one target of each type.\n3. **Time references are dimensions.** \"last year\" becomes a dimension target for the relevant date field (`\"the date the event occurred\"`).\n4. **Numeric ranges are dimensions.** \"aged 50\", \"revenue over $1M\" become dimension targets; the comparison is applied in the query, not matched as text.\n5. **Categorical strings that look numeric are still dimensions.** \"18-30\", \"<5 days\", \"tier 2\" are stored as literal strings on a dimension. Target that dimension, then confirm the exact string with `execute_query`.\n6. **\"Top N\" without a named measure, add a ranking measure.** \"top 6 products\" becomes a measure for the ranking concept (`\"the performance metric for a product\"`) plus a dimension for the entity. If the measure is explicit (\"top products by total sales\"), use it directly and skip the generic ranking measure.\n7. **Multiple values for one concept, one dimension target.** Several values (\"premium and basic\") still map to a single dimension target for the parent field; enumerate the exact stored values with `execute_query`.\n8. **The quantity asked for is a measure, even when nothing names it.** \"How many titles were released in 2019?\" is asking for a count, but no noun in it is the count: the visible phrases are the subject (\"titles\") and the filter (\"2019\"), and mapping only those yields a `source` and a `dimension` target with no way to answer. Always add a measure target for the quantity itself (`\"the number of titles\"`). This holds for every interrogative that IS the aggregation -- \"how many\", \"how much\", \"how often\" -- and it is the pattern most easily lost when a filter or grouping is the loud part of the sentence. Measured: an answerer got this right on \"How many titles are in the dataset?\" and dropped it on \"How many titles were released in 2019?\", where the year took the attention.\n9. **A population qualifier is a target, and so is the one the question omits.** Words like \"real\", \"actual\", \"genuine\", \"live\" or \"production\" are not filler: they name rows the model marks for exclusion. Target the flag itself (`\"the flag marking synthetic, test or monitoring traffic\"`), not just the noun they modify. Add one such target even when the question carries no qualifier at all, because a table of events, requests, sessions or logs usually holds test, internal or cancelled rows and nothing in the wording will say so. Resolve it to the model's own flag rather than inventing a filter on an id or a name; a hand-rolled exclusion and the documented one rarely select the same rows.\n\n## Worked example\n\n**User:** \"Customer churn in NYC over the last year for premium and basic subscribers\"\n\nThe targets for this question:\n\n| target_type | search_text |\n|---|---|\n| `measure` | `\"the rate at which customers leave the service\"` |\n| `dimension` | `\"the city where the subscriber lives\"` |\n| `dimension` | `\"the date the subscription was canceled\"` |\n| `dimension` | `\"the tier of the subscription\"` |\n| `view` | `\"subscriber churn or retention analysis\"` |\n\nKey moves: time (\"last year\") becomes a dimension on the cancellation date; \"NYC\" and \"premium/basic subscribers\" do not get their own value targets, they resolve to the city and tier dimensions. One `view` target is included to surface any canned churn analysis.\n\nThe response returns the source these fields live on (here `subscriptions`) with the matched fields on its card. Then run `execute_query` to read the city and tier dimensions' distinct values and confirm the exact strings to filter on (\"New York City\" vs \"NYC\", \"premium\" vs \"Premium\").\n\n## Authoring `search_text` for `source` targets\n\nAim for **3-8 words that name the entity and its business process**. Don't include filter values, time ranges, or aggregations: those belong in entity targets.\n\n| Too vague | Over-specific (entity-shaped) | Good |\n|---|---|---|\n| `\"orders\"` | `\"total order revenue by customer last year\"` | `\"customer order history and line items\"` |\n| `\"customer data\"` | `\"premium subscribers who churned in NYC\"` | `\"subscriber accounts and churn\"` |\n| `\"metrics\"` | `\"monthly revenue variance by account\"` | `\"sales pipeline and revenue forecasts\"` |\n\nHeuristics:\n\n1. **Translate, don't echo.** \"How did sales go last month?\" becomes `\"sales order revenue\"`, not `\"sales last month\"`.\n2. **Differentiate by data shape or business process, not by the user's industry, brand, or product category.** Prefer `\"order fulfillment and shipping\"` over `\"ecommerce data\"` when multiple commerce-ish packages exist. Do NOT add words like `\"eyewear\"`, `\"subscription box\"`, `\"Acme Corp\"`, or the user's specific vertical/brand. Source summaries describe data structure, not the customer's vertical, so those words add noise and can hurt matching.\n3. **Retry with alternative phrasings** if the right source isn't in the results before concluding it's missing."
323753
325230
  },
323754
325231
  {
323755
325232
  name: "malloy-publish",
@@ -325827,6 +327304,8 @@ function incrementalLineage(params) {
325827
327304
  const mergeKeys = d.mergeKeys.filter((k) => k.kind === "dimension").map((k) => k.name);
325828
327305
  if (mergeKeys.length !== d.mergeKeys.length)
325829
327306
  return;
327307
+ if (mergeKeys.length > 0 && d.scopeIncomplete)
327308
+ return;
325830
327309
  return {
325831
327310
  physicalTableName: params.physicalTableName,
325832
327311
  connectionName: params.connectionName,
@@ -325835,6 +327314,7 @@ function incrementalLineage(params) {
325835
327314
  watermarkName: watermark.name,
325836
327315
  watermarkType: watermark.malloyType,
325837
327316
  mergeKeys,
327317
+ scopeColumns: d.scopeColumns,
325838
327318
  strategy: d.strategy
325839
327319
  };
325840
327320
  }
@@ -326416,7 +327896,7 @@ async function buildSourceIntoStorage(params) {
326416
327896
  }
326417
327897
  }
326418
327898
  const passthrough = await read(session, sourceType, federated.handle, buildSQL, queryMetadata);
326419
- const schema = await createTableAndDescribe(session, target, passthrough.selectSQL);
327899
+ const schema = await createTableAndDescribe(session, target, passthrough.selectSQL, params.partitionColumns ?? []);
326420
327900
  const seededThrough = await params.incremental?.afterSeed({
326421
327901
  session,
326422
327902
  quotedTablePath: target
@@ -326483,7 +327963,7 @@ async function buildDownstreamIntoStorage(params) {
326483
327963
  }
326484
327964
  const sql = projectToPublicColumns(downstream, downstream.getSQL({ virtualMap }));
326485
327965
  const target = quoteManifestTablePath(`${destinationName}.${physicalTableName}`, STORAGE_TARGET_DIALECT);
326486
- const schema = await createTableAndDescribe(session, target, sql);
327966
+ const schema = await createTableAndDescribe(session, target, sql, params.partitionColumns ?? []);
326487
327967
  return {
326488
327968
  storageDestinationName: destinationName,
326489
327969
  schema,
@@ -326555,22 +328035,53 @@ function sourceFederationConfig(sourceConnection) {
326555
328035
  proxy: sourceConnection.proxy
326556
328036
  };
326557
328037
  }
326558
- async function createTableAndDescribe(session, quotedTablePath, selectSQL) {
326559
- await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
328038
+ async function createTableAndDescribe(session, quotedTablePath, selectSQL, partitionColumns = []) {
328039
+ if (partitionColumns.length === 0) {
328040
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`);
328041
+ return await describeOrDrop(session, quotedTablePath);
328042
+ }
328043
+ const columns = partitionColumns.map((name) => quoteIdentifier(name, STORAGE_TARGET_DIALECT)).join(", ");
328044
+ let schema;
328045
+ await session.runSQL("BEGIN TRANSACTION");
326560
328046
  try {
326561
- return await describeTable(session, quotedTablePath);
326562
- } catch (describeErr) {
328047
+ await session.runSQL(`CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL}) WITH NO DATA`);
328048
+ await session.runSQL(`ALTER TABLE ${quotedTablePath} SET PARTITIONED BY (${columns})`);
328049
+ await session.runSQL(`INSERT INTO ${quotedTablePath} (${selectSQL})`);
328050
+ schema = await describeTable(session, quotedTablePath);
328051
+ await session.runSQL("COMMIT");
328052
+ } catch (buildErr) {
326563
328053
  try {
326564
- await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
326565
- } catch (dropErr) {
326566
- logger.warn("Failed to drop a storage table stranded by a schema read-back " + "failure (physical leak)", {
328054
+ await session.runSQL("ROLLBACK");
328055
+ } catch (rollbackErr) {
328056
+ logger.warn("Failed to roll back a partitioned storage build; the served " + "generation may have been replaced", {
326567
328057
  table: quotedTablePath,
326568
- error: errMessage(dropErr)
328058
+ error: errMessage(rollbackErr),
328059
+ cause: errMessage(buildErr)
326569
328060
  });
326570
328061
  }
328062
+ throw buildErr;
328063
+ }
328064
+ return schema;
328065
+ }
328066
+ async function describeOrDrop(session, quotedTablePath) {
328067
+ try {
328068
+ return await describeTable(session, quotedTablePath);
328069
+ } catch (describeErr) {
328070
+ await dropStranded(session, quotedTablePath, describeErr);
326571
328071
  throw describeErr;
326572
328072
  }
326573
328073
  }
328074
+ async function dropStranded(session, quotedTablePath, cause) {
328075
+ try {
328076
+ await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
328077
+ } catch (dropErr) {
328078
+ logger.warn("Failed to drop a storage table stranded by a failed build " + "(physical leak)", {
328079
+ table: quotedTablePath,
328080
+ error: errMessage(dropErr),
328081
+ cause: errMessage(cause)
328082
+ });
328083
+ }
328084
+ }
326574
328085
  async function describeTable(session, quotedTablePath) {
326575
328086
  const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
326576
328087
  const rows = Array.isArray(result) ? result : result.rows ?? [];
@@ -326719,6 +328230,16 @@ function manifestExcludingStorage(manifest, builtEntries) {
326719
328230
  function declaredStorage(persistSource) {
326720
328231
  return deriveAnnotationFields(persistSource).storage?.trim() || undefined;
326721
328232
  }
328233
+ function partitionColumnsForBuild(persistSource) {
328234
+ const resolved = resolvePartitionColumns(persistSource, deriveAnnotationFields(persistSource));
328235
+ if (!resolved.ok) {
328236
+ throw new MaterializationEligibilityError({
328237
+ reason: resolved.reason,
328238
+ message: `Source '${persistSource.name}' reached a build with an ` + `unusable 'partition=': ${resolved.detail}. The eligibility gate ` + `should have refused this before any warehouse work ran.`
328239
+ });
328240
+ }
328241
+ return resolved.columns;
328242
+ }
326722
328243
  function resolveStorageDestination(persistSource) {
326723
328244
  if (getPersistStorageMode() === "off")
326724
328245
  return;
@@ -327005,7 +328526,7 @@ class MaterializationService {
327005
328526
  if (destination) {
327006
328527
  if (compiled.preaggregatePlans?.[persistSource.sourceID]) {
327007
328528
  try {
327008
- assertMaterializationEligible(persistSource);
328529
+ assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
327009
328530
  } catch (err) {
327010
328531
  if (!(err instanceof MaterializationEligibilityError))
327011
328532
  throw err;
@@ -327016,10 +328537,10 @@ class MaterializationService {
327016
328537
  continue;
327017
328538
  }
327018
328539
  } else {
327019
- assertMaterializationEligible(persistSource);
328540
+ assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
327020
328541
  }
327021
328542
  } else {
327022
- assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID]);
328543
+ assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
327023
328544
  }
327024
328545
  const sourceEntityId = computeSourceEntityId(persistSource, compiled.connectionDigests);
327025
328546
  if (seen.has(sourceEntityId))
@@ -327357,13 +328878,13 @@ class MaterializationService {
327357
328878
  if (!instruction)
327358
328879
  continue;
327359
328880
  if (orchestratedInstruction?.destination && getPersistStorageMode() !== "off") {
327360
- assertMaterializationEligible(persistSource);
328881
+ assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
327361
328882
  } else {
327362
- assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID]);
328883
+ assertColocatedPersistNotAuthorizeGated(persistSource, persistSource.name, compiled.preaggregatePlans?.[persistSource.sourceID] ? "preaggregate" : "persist", compiled.sourceGateOutcomes?.[persistSource.sourceID], deriveAnnotationFields(persistSource));
327363
328884
  }
327364
328885
  sourceEntityId ??= computeSourceEntityId(persistSource, connectionDigests);
327365
328886
  if (!orchestratedInstruction && instruction.destination && getPersistStorageMode() !== "off") {
327366
- assertMaterializationEligible(persistSource);
328887
+ assertMaterializationEligible(persistSource, deriveAnnotationFields(persistSource));
327367
328888
  }
327368
328889
  const target = physicalTargetKey(instruction, graph.connectionName);
327369
328890
  const written = writtenTargets.get(target);
@@ -327782,6 +329303,7 @@ class MaterializationService {
327782
329303
  sourceConnection,
327783
329304
  buildSQL: params.publicBuildSQL,
327784
329305
  physicalTableName,
329306
+ partitionColumns: partitionColumnsForBuild(persistSource),
327785
329307
  environmentPath: environment.getEnvironmentPath(),
327786
329308
  queryMetadata,
327787
329309
  incremental: refresh
@@ -327935,6 +329457,7 @@ class MaterializationService {
327935
329457
  downstreamName: persistSource.name,
327936
329458
  virtualMap: buildVirtualMap(upstreams),
327937
329459
  physicalTableName,
329460
+ partitionColumns: partitionColumnsForBuild(persistSource),
327938
329461
  environmentPath: environment.getEnvironmentPath()
327939
329462
  });
327940
329463
  }