@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.
- package/dist/app/api-doc.yaml +103 -11
- package/dist/app/assets/{EnvironmentPage-cVoREk0T.js → EnvironmentPage-B02geyxh.js} +1 -1
- package/dist/app/assets/{HomePage-9sQJEb1e.js → HomePage-BdKzZEto.js} +1 -1
- package/dist/app/assets/{LightMode-C8uGSmfv.js → LightMode-BRAs1k98.js} +1 -1
- package/dist/app/assets/{MainPage-BIG2W9IX.js → MainPage-D9XZbGvk.js} +1 -1
- package/dist/app/assets/{ModelPage-DKkVe4Md.js → ModelPage-B-U47DzF.js} +2 -2
- package/dist/app/assets/{PackagePage-Cax6RcJM.js → PackagePage-CDp9A3_k.js} +1 -1
- package/dist/app/assets/{RenderedResult-BB7eokcf.es-D-hyXuFF.js → RenderedResult-CTD2Ej2S.es-BZsZAiwo.js} +2 -2
- package/dist/app/assets/{RouteError-BzJ2T1nX.js → RouteError-CSsLG99c.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-Ce7Cp5zL.js → ThemeEditorPage-C4u5l49i.js} +1 -1
- package/dist/app/assets/{ToggleButtonGroup-DOVU2psA.js → ToggleButtonGroup-IBhUYkv_.js} +1 -1
- package/dist/app/assets/{index-BJv4aICx.js → index--DAcpR5Y.js} +1 -1
- package/dist/app/assets/{index-BOX8mX0R.js → index-BKNRDF5y.js} +1 -1
- package/dist/app/assets/{index-DycyDIlK.js → index-Bd7WYNw_.js} +4 -4
- package/dist/app/assets/{index-CPy5UJ3W.js → index-CshLRozl.js} +1 -1
- package/dist/app/assets/{index-DfULFk5d.js → index-DOXSuS-a.js} +1 -1
- package/dist/app/assets/{index.es-Doid8lz7.js → index.es-Bj77sE9l.js} +4 -4
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +1704 -181
- package/package.json +20 -2
- 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 =
|
|
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
|
|
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(
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
271541
|
-
|
|
271542
|
-
|
|
271543
|
-
|
|
271544
|
-
|
|
271545
|
-
|
|
271546
|
-
|
|
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
|
-
|
|
272698
|
-
|
|
273051
|
+
const classified = classifyDynamicTerms(persistSource);
|
|
273052
|
+
if (!classified.ok) {
|
|
273053
|
+
recordEligibilityRefused(classified.reason);
|
|
272699
273054
|
throw new MaterializationEligibilityError({
|
|
272700
|
-
reason:
|
|
272701
|
-
message: `Source '${sourceName}' cannot be materialized into a storage ` + `destination:
|
|
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 =
|
|
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
|
-
|
|
275682
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
|
317439
|
-
|
|
317440
|
-
|
|
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,
|
|
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
|
|
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\`.
|
|
320160
|
-
|
|
320161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326562
|
-
|
|
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(
|
|
326565
|
-
} catch (
|
|
326566
|
-
logger.warn("Failed to
|
|
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(
|
|
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
|
}
|