@malloy-publisher/server 0.2.1 → 0.2.3
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 +115 -17
- package/dist/app/assets/{EnvironmentPage-CHq5BF4l.js → EnvironmentPage-fjRmUv5U.js} +1 -1
- package/dist/app/assets/{HomePage-CIp-pJCa.js → HomePage-BYcfSCYQ.js} +1 -1
- package/dist/app/assets/{LightMode-mHHPjEeA.js → LightMode-HqTJd5sS.js} +1 -1
- package/dist/app/assets/{MainPage-CylxO5Yu.js → MainPage-BWO02VL1.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-a0vjqxib.js → MaterializationsPage-DtQTKXio.js} +1 -1
- package/dist/app/assets/{ModelPage-Cz-MrSZQ.js → ModelPage-CHHRPUfJ.js} +1 -1
- package/dist/app/assets/{PackagePage-Cdg5D7AN.js → PackagePage-Dg83Zo7T.js} +1 -1
- package/dist/app/assets/{RouteError-B5SIyn6G.js → RouteError-BYustUzP.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-B62rRc-y.js → ThemeEditorPage-DvOIp3pJ.js} +1 -1
- package/dist/app/assets/{WorkbookPage-45cG1Gx6.js → WorkbookPage-jGyARm9R.js} +1 -1
- package/dist/app/assets/{core-DyU47reh.es-DY9b3WjS.js → core-Dvp73xXv.es-C2zK6mlo.js} +1 -1
- package/dist/app/assets/{index-C5ClsnS7.js → index-BOsTtwKH.js} +1 -1
- package/dist/app/assets/{index-DgVRz2wm.js → index-CLXnplGh.js} +1 -1
- package/dist/app/assets/{index-P-pF8nU5.js → index-CjFtnxaN.js} +85 -85
- package/dist/app/assets/{index-DF9tVqwN.js → index-DFsqNVYX.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +2201 -55
- package/package.json +1 -1
package/dist/server.mjs
CHANGED
|
@@ -156066,6 +156066,10 @@ function assertDuckDBResourceConfig() {
|
|
|
156066
156066
|
if (memoryLimit !== undefined && !/^\d+(\.\d+)?\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i.test(memoryLimit)) {
|
|
156067
156067
|
throw new Error(`Invalid value for PUBLISHER_DUCKDB_MEMORY_LIMIT: expected a size like ` + `"1GB" or "512MB" (or "off" to disable), got "${memoryLimit}"`);
|
|
156068
156068
|
}
|
|
156069
|
+
const rowGroupSizeBytes = getDuckLakeRowGroupSizeBytes();
|
|
156070
|
+
if (rowGroupSizeBytes !== undefined && !/^\d+(\.\d+)?\s*(B|KB|KIB|MB|MIB|GB|GIB)$/i.test(rowGroupSizeBytes)) {
|
|
156071
|
+
throw new Error(`Invalid value for PUBLISHER_DUCKLAKE_ROW_GROUP_SIZE_BYTES: expected a ` + `size like "16MB", got "${rowGroupSizeBytes}"`);
|
|
156072
|
+
}
|
|
156069
156073
|
const tempDirectory = getDuckDBTempDirectory();
|
|
156070
156074
|
if (tempDirectory !== undefined) {
|
|
156071
156075
|
try {
|
|
@@ -156225,6 +156229,9 @@ var BUNDLED_DEFAULT_CONFIG_PATH, PER_MODE_COLOR_KEYS, DEFAULT_HIGH_WATER_FRACTIO
|
|
|
156225
156229
|
return;
|
|
156226
156230
|
}
|
|
156227
156231
|
return raw;
|
|
156232
|
+
}, getDuckLakeRowGroupSizeBytes = () => {
|
|
156233
|
+
const raw = process.env.PUBLISHER_DUCKLAKE_ROW_GROUP_SIZE_BYTES?.trim();
|
|
156234
|
+
return raw === undefined || raw === "" ? undefined : raw;
|
|
156228
156235
|
}, getDuckDBTempDirectory = () => {
|
|
156229
156236
|
const raw = process.env.PUBLISHER_DUCKDB_TEMP_DIRECTORY?.trim();
|
|
156230
156237
|
return raw === undefined || raw === "" ? undefined : raw;
|
|
@@ -233453,6 +233460,19 @@ import {
|
|
|
233453
233460
|
MalloyConfig
|
|
233454
233461
|
} from "@malloydata/malloy";
|
|
233455
233462
|
import fs3 from "fs/promises";
|
|
233463
|
+
async function applyDuckLakeRowGroupBound(connection, dbName) {
|
|
233464
|
+
const bytes = getDuckLakeRowGroupSizeBytes();
|
|
233465
|
+
if (bytes === undefined) {
|
|
233466
|
+
return;
|
|
233467
|
+
}
|
|
233468
|
+
try {
|
|
233469
|
+
await connection.runSQL("SET preserve_insertion_order=false");
|
|
233470
|
+
await connection.runSQL(`CALL ${dbName}.set_option('parquet_row_group_size_bytes', '${escapeSQL(bytes)}')`);
|
|
233471
|
+
logger.info(`DuckLake row group bound applied to ${dbName}: ${bytes}`);
|
|
233472
|
+
} catch (error) {
|
|
233473
|
+
logger.warn(`Could not set the DuckLake row group bound on ${dbName}; the lake keeps ` + `its existing value: ${error instanceof Error ? error.message : String(error)}`);
|
|
233474
|
+
}
|
|
233475
|
+
}
|
|
233456
233476
|
async function applySessionResourceLimits(connection, { tempDirectory } = {}) {
|
|
233457
233477
|
const memoryLimit = getDuckDBMemoryLimit();
|
|
233458
233478
|
const temp = tempDirectory ?? getDuckDBTempDirectory();
|
|
@@ -233801,6 +233821,9 @@ async function attachDuckLakeWithMode(connection, dbName, ducklakeConfig, option
|
|
|
233801
233821
|
try {
|
|
233802
233822
|
await connection.runSQL(attachCommand);
|
|
233803
233823
|
logger.info(`Successfully attached DuckLake database in ${mode} mode: ${dbName}`);
|
|
233824
|
+
if (!options.readOnly) {
|
|
233825
|
+
await applyDuckLakeRowGroupBound(connection, dbName);
|
|
233826
|
+
}
|
|
233804
233827
|
} catch (error) {
|
|
233805
233828
|
if (error instanceof Error && (error.message.includes("already exists") || error.message.includes("already attached"))) {
|
|
233806
233829
|
logger.info(`DuckLake database ${dbName} is already attached, skipping`);
|
|
@@ -248769,6 +248792,15 @@ function recordAutoLoadOutcome(outcome) {
|
|
|
248769
248792
|
function recordConnectionDigestSkipped() {
|
|
248770
248793
|
connectionDigestSkipCounter().add(1);
|
|
248771
248794
|
}
|
|
248795
|
+
function recordDuplicateTargetSkipped() {
|
|
248796
|
+
duplicateTargetSkipCounter().add(1);
|
|
248797
|
+
}
|
|
248798
|
+
function recordSharedAddressInstructions() {
|
|
248799
|
+
sharedAddressInstructionCounter().add(1);
|
|
248800
|
+
}
|
|
248801
|
+
function recordTableCollision() {
|
|
248802
|
+
tableCollisionCounter().add(1);
|
|
248803
|
+
}
|
|
248772
248804
|
function recordManifestBind(outcome) {
|
|
248773
248805
|
manifestBindCounter().add(1, { outcome });
|
|
248774
248806
|
}
|
|
@@ -248808,7 +248840,7 @@ function recordChainedStorageBuild(outcome) {
|
|
|
248808
248840
|
function recordColocatedBindDropped(reason) {
|
|
248809
248841
|
colocatedBindDroppedCounter().add(1, { reason });
|
|
248810
248842
|
}
|
|
248811
|
-
var resetHooks2, runCounter, runDuration, sourcesCounter, incrementalStepCounter, buildPlanComputeDuration, buildPlanComputeFailedCounter, autoLoadCounter, connectionDigestSkipCounter, manifestBindCounter, manifestBindDegradedCounter, sourceBuildDuration, dropTablesCounter, scheduledFireCounter, storageServeRoutingCounter, storageTableRetainedCounter, storageBuildFailureCounter, attributionSkippedCounter, eligibilityRefusedCounter, serveShapeTierDropCounter, serveShapeTypeFallbackCounter, chainedStorageBuildCounter, colocatedBindDroppedCounter;
|
|
248843
|
+
var resetHooks2, runCounter, runDuration, sourcesCounter, incrementalStepCounter, buildPlanComputeDuration, buildPlanComputeFailedCounter, autoLoadCounter, connectionDigestSkipCounter, manifestBindCounter, manifestBindDegradedCounter, duplicateTargetSkipCounter, sharedAddressInstructionCounter, tableCollisionCounter, sourceBuildDuration, dropTablesCounter, scheduledFireCounter, storageServeRoutingCounter, storageTableRetainedCounter, storageBuildFailureCounter, attributionSkippedCounter, eligibilityRefusedCounter, serveShapeTierDropCounter, serveShapeTypeFallbackCounter, chainedStorageBuildCounter, colocatedBindDroppedCounter;
|
|
248812
248844
|
var init_materialization_metrics = __esm(() => {
|
|
248813
248845
|
init_telemetry();
|
|
248814
248846
|
resetHooks2 = [];
|
|
@@ -248822,10 +248854,13 @@ var init_materialization_metrics = __esm(() => {
|
|
|
248822
248854
|
connectionDigestSkipCounter = lazyCounter2("publisher_materialization_connection_digest_skipped_total", "Connection digests skipped during build-plan compile because the connection did not resolve.");
|
|
248823
248855
|
manifestBindCounter = lazyCounter2("publisher_materialization_manifest_bind_total", "Manifest bind attempts. Label: outcome ('success'|'failure'|'timeout').");
|
|
248824
248856
|
manifestBindDegradedCounter = lazyCounter2("publisher_materialization_manifest_bind_degraded_total", "Manifest entries bound with an UNQUOTED table path because their connection " + "could not be resolved (serve-side bind) or is absent from the build " + "(build-side seed). A misconfiguration that breaks the source on a " + "case-folding engine (Snowflake); alertable.");
|
|
248857
|
+
duplicateTargetSkipCounter = lazyCounter2("publisher_materialization_duplicate_target_skipped_total", "Sources skipped because the physical table they name was already built in " + "this run. Ordinary for a package that extends a persisted source; a " + "rising count against a package with no extension means the plan is " + "enumerating one table under more names than expected.");
|
|
248858
|
+
sharedAddressInstructionCounter = lazyCounter2("publisher_materialization_shared_address_instructions_total", "Content addresses that arrived with more than one instruction naming a " + "DIFFERENT physical table. The host minted a table per source where " + "several sources share one artifact. With a sourceID on each instruction " + "every table is built and only one is recorded, so the rest are orphaned; " + "without one the last instruction wins and the earlier names are never " + "built. Wasteful, not wrong — the table's CONTENT is the same either way.");
|
|
248859
|
+
tableCollisionCounter = lazyCounter2("publisher_materialization_table_collision_total", "Two definitions with DIFFERENT content addresses materializing into ONE " + "physical table. Each build overwrites the other's rows while both " + "addresses resolve to the table at serve time, so a query is answered " + "from another source's data. A wrong answer, not wasted work — page on " + "this one. Refused instead of counted-and-continued when " + "PERSIST_COLLISION_ENFORCE is set, so a non-zero rate here is also the " + "measure of what flipping that flag would start refusing.");
|
|
248825
248860
|
sourceBuildDuration = lazyHistogram("publisher_materialization_source_build_duration_ms", "Wall-clock duration of building a single persist source.", "ms");
|
|
248826
248861
|
dropTablesCounter = lazyCounter2("publisher_materialization_drop_tables_total", "Physical tables dropped on delete. Label: outcome ('success'|'failure').");
|
|
248827
248862
|
scheduledFireCounter = lazyCounter2("publisher_materialization_scheduled_fires_total", "Standalone-scheduler attempts to fire a package's materialization.schedule. " + "Label: outcome ('fired'|'conflict'|'error').");
|
|
248828
|
-
storageServeRoutingCounter = lazyCounter2("publisher_storage_serve_routing_total", "storage= serve routing decisions. Label: outcome ('storage'|'live_fallback'|" + "'runtime_live_fallback'|'blocked_by_row_level_gate').");
|
|
248863
|
+
storageServeRoutingCounter = lazyCounter2("publisher_storage_serve_routing_total", "storage= serve routing decisions. Label: outcome ('storage'|'live_fallback'|" + "'runtime_live_fallback'|'blocked_by_row_level_gate'). Covers the storage= " + "tier only; a colocated #@ persist hit is in neither the numerator nor the " + "denominator. NOTE 'live_fallback' here means the transform was INELIGIBLE, " + "which QueryResult.servedFrom reports as null - that field's " + "'live_fallback' is this counter's 'runtime_live_fallback'.");
|
|
248829
248864
|
storageTableRetainedCounter = lazyCounter2("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.");
|
|
248830
248865
|
storageBuildFailureCounter = lazyCounter2("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.");
|
|
248831
248866
|
attributionSkippedCounter = lazyCounter2("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.");
|
|
@@ -248914,8 +248949,34 @@ import {
|
|
|
248914
248949
|
InMemoryURLReader,
|
|
248915
248950
|
Runtime
|
|
248916
248951
|
} from "@malloydata/malloy";
|
|
248917
|
-
function
|
|
248952
|
+
function groupAliasesByName(planSources) {
|
|
248953
|
+
const namesByAddress = new Map;
|
|
248954
|
+
for (const source of planSources) {
|
|
248955
|
+
if (!source.sourceEntityId || !source.name)
|
|
248956
|
+
continue;
|
|
248957
|
+
const group = namesByAddress.get(source.sourceEntityId);
|
|
248958
|
+
if (!group)
|
|
248959
|
+
namesByAddress.set(source.sourceEntityId, [source.name]);
|
|
248960
|
+
else if (!group.includes(source.name))
|
|
248961
|
+
group.push(source.name);
|
|
248962
|
+
}
|
|
248963
|
+
const addressesPerName = new Map;
|
|
248964
|
+
for (const group of namesByAddress.values()) {
|
|
248965
|
+
for (const name of group) {
|
|
248966
|
+
addressesPerName.set(name, (addressesPerName.get(name) ?? 0) + 1);
|
|
248967
|
+
}
|
|
248968
|
+
}
|
|
248969
|
+
const byName = {};
|
|
248970
|
+
for (const group of namesByAddress.values()) {
|
|
248971
|
+
const unambiguous = group.filter((name) => addressesPerName.get(name) === 1);
|
|
248972
|
+
for (const name of unambiguous)
|
|
248973
|
+
byName[name] = unambiguous;
|
|
248974
|
+
}
|
|
248975
|
+
return byName;
|
|
248976
|
+
}
|
|
248977
|
+
function deriveServeBindings(entries, aliasesBySourceName) {
|
|
248918
248978
|
const bindings = [];
|
|
248979
|
+
const builders = new Set(Object.values(entries).map((entry) => entry.sourceName).filter((name) => !!name));
|
|
248919
248980
|
for (const entry of Object.values(entries)) {
|
|
248920
248981
|
if (!entry.sourceName || !entry.storageDestinationName || !entry.physicalTableName) {
|
|
248921
248982
|
continue;
|
|
@@ -248923,16 +248984,22 @@ function deriveServeBindings(entries) {
|
|
|
248923
248984
|
const schema = (entry.schema ?? []).filter((c) => c.name && c.type).map((c) => ({ name: c.name, type: c.type }));
|
|
248924
248985
|
if (schema.length === 0)
|
|
248925
248986
|
continue;
|
|
248926
|
-
|
|
248927
|
-
|
|
248928
|
-
|
|
248929
|
-
|
|
248930
|
-
|
|
248931
|
-
|
|
248932
|
-
|
|
248933
|
-
|
|
248934
|
-
|
|
248935
|
-
|
|
248987
|
+
const names = [
|
|
248988
|
+
entry.sourceName,
|
|
248989
|
+
...(aliasesBySourceName[entry.sourceName] ?? []).filter((name) => !builders.has(name))
|
|
248990
|
+
].filter((name, i, all3) => all3.indexOf(name) === i);
|
|
248991
|
+
for (const sourceName of names) {
|
|
248992
|
+
bindings.push({
|
|
248993
|
+
sourceName,
|
|
248994
|
+
destinationName: entry.storageDestinationName,
|
|
248995
|
+
virtualHandle: entry.sourceEntityId,
|
|
248996
|
+
tablePath: `${entry.storageDestinationName}.${entry.physicalTableName}`,
|
|
248997
|
+
schema,
|
|
248998
|
+
freshAsOf: entry.dataAsOf,
|
|
248999
|
+
freshnessWindowSeconds: entry.freshnessWindowSeconds,
|
|
249000
|
+
freshnessFallback: entry.freshnessFallback
|
|
249001
|
+
});
|
|
249002
|
+
}
|
|
248936
249003
|
}
|
|
248937
249004
|
return bindings;
|
|
248938
249005
|
}
|
|
@@ -249040,6 +249107,14 @@ ${fields}
|
|
|
249040
249107
|
}
|
|
249041
249108
|
${source}`;
|
|
249042
249109
|
}
|
|
249110
|
+
function serveShapeDiagnostics(allBindings, freshBindings) {
|
|
249111
|
+
const shapeSources = freshBindings.map((b) => b.sourceName);
|
|
249112
|
+
const fresh = new Set(shapeSources);
|
|
249113
|
+
return {
|
|
249114
|
+
shapeSources,
|
|
249115
|
+
staleSources: allBindings.map((b) => b.sourceName).filter((name) => !fresh.has(name))
|
|
249116
|
+
};
|
|
249117
|
+
}
|
|
249043
249118
|
function buildServeShapeModelForBindings(bindings) {
|
|
249044
249119
|
const fragments = orderBindingsByJoinDeps(bindings).map(serveShapeFragment).join(`
|
|
249045
249120
|
`);
|
|
@@ -251816,6 +251891,7 @@ var init_model = __esm(() => {
|
|
|
251816
251891
|
init_gate_dimension();
|
|
251817
251892
|
init_filter();
|
|
251818
251893
|
init_given();
|
|
251894
|
+
init_dashboard();
|
|
251819
251895
|
init_motly();
|
|
251820
251896
|
init_model_limits();
|
|
251821
251897
|
init_json_utils();
|
|
@@ -252812,14 +252888,14 @@ var init_model = __esm(() => {
|
|
|
252812
252888
|
} catch {
|
|
252813
252889
|
continue;
|
|
252814
252890
|
}
|
|
252815
|
-
const
|
|
252816
|
-
if (
|
|
252817
|
-
logger.warn(`
|
|
252818
|
-
for (const e of
|
|
252891
|
+
const logs = filterPublisherOwnedRenderLogs(validateRenderTags2(result), this.modelPath);
|
|
252892
|
+
if (logs.length > 0) {
|
|
252893
|
+
logger.warn(`Render tag findings on '${target.label}': ${logs.map((e) => `[${e.severity}] ${e.message}`).join("; ")}`);
|
|
252894
|
+
for (const e of logs) {
|
|
252819
252895
|
findings.push({
|
|
252820
252896
|
subject: target.label,
|
|
252821
252897
|
message: e.message,
|
|
252822
|
-
severity: "error"
|
|
252898
|
+
severity: e.severity === "error" ? "error" : "warn"
|
|
252823
252899
|
});
|
|
252824
252900
|
}
|
|
252825
252901
|
}
|
|
@@ -253088,9 +253164,12 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
253088
253164
|
});
|
|
253089
253165
|
} catch (shapeErr) {
|
|
253090
253166
|
recordStorageServeRouting("live_fallback");
|
|
253167
|
+
const { shapeSources, staleSources } = serveShapeDiagnostics(this.serveBindings, this.freshServeBindings(Date.now()));
|
|
253091
253168
|
logger.info("storage serve-shape ineligible for this query; serving live", {
|
|
253092
253169
|
modelPath: this.modelPath,
|
|
253093
|
-
error: shapeErr instanceof Error ? shapeErr.message : String(shapeErr)
|
|
253170
|
+
error: shapeErr instanceof Error ? shapeErr.message : String(shapeErr),
|
|
253171
|
+
shapeSources,
|
|
253172
|
+
staleSources
|
|
253094
253173
|
});
|
|
253095
253174
|
}
|
|
253096
253175
|
}
|
|
@@ -286818,7 +286897,7 @@ class Package {
|
|
|
286818
286897
|
this.recordManifestBinding(allowed);
|
|
286819
286898
|
}
|
|
286820
286899
|
bindStorageServeBindings(entries) {
|
|
286821
|
-
const derived = deriveServeBindings(entries);
|
|
286900
|
+
const derived = deriveServeBindings(entries, groupAliasesByName(Object.values(this.buildPlan?.sources ?? {})));
|
|
286822
286901
|
const eligibility = this.sourceEligibility;
|
|
286823
286902
|
const eligible = new Set(eligibility?.eligible ?? []);
|
|
286824
286903
|
const allowed = derived.filter((binding) => {
|
|
@@ -293454,10 +293533,12 @@ var SEARCH_DOCS_DESCRIPTION = `Search the Malloy documentation by keyword and re
|
|
|
293454
293533
|
## When to use
|
|
293455
293534
|
- Before writing unfamiliar Malloy syntax (window functions, autobin, dialect-specific functions, rendering tags) or when a query fails with a syntax error you do not recognize.
|
|
293456
293535
|
- Do NOT use it to look up field or source names in a model; use malloy_getContext for that.
|
|
293536
|
+
- Do NOT use it for anything about running Publisher itself — server flags, deployment, connection or embedding-provider configuration, publisher.json, packages, watch mode. This index covers the Malloy LANGUAGE docs only. Those answers live in the deployment's own docs/ directory and bundled skills, not here.
|
|
293457
293537
|
|
|
293458
293538
|
## Contract rules
|
|
293459
293539
|
- These are documentation pages, not model entities. Do not treat a doc title as a field or source name.
|
|
293460
293540
|
- The excerpt is only a hint; open the url for the full detail.
|
|
293541
|
+
- Matching is keyword-based, so a result is not evidence the topic is covered. An off-topic query still returns its best keyword matches, and a title can match on a word it shares with your question while the page is about something else. Read the excerpt before trusting a hit, and treat a page-full of near-misses as "not documented here" rather than retrying with more keywords.
|
|
293461
293542
|
|
|
293462
293543
|
## Parameters
|
|
293463
293544
|
- query (required): keywords describing what you need.
|
|
@@ -293822,7 +293903,7 @@ async function getPackageIndex(environmentStore, environmentName, packageName) {
|
|
|
293822
293903
|
});
|
|
293823
293904
|
return built;
|
|
293824
293905
|
}
|
|
293825
|
-
var GET_CONTEXT_DESCRIPTION = `Discover what a Publisher deployment exposes and retrieve the model entities most relevant to a plain-English question, so you
|
|
293906
|
+
var GET_CONTEXT_DESCRIPTION = `Discover what a Publisher deployment exposes and retrieve the model entities most relevant to a plain-English question, so you ground a query in what the model defines, not a guess. Start here when you do not know those names.
|
|
293826
293907
|
|
|
293827
293908
|
## Contract rules
|
|
293828
293909
|
- Use the names it returns verbatim; never invent an environment, package, or entity that is not in the results.
|
|
@@ -293830,16 +293911,16 @@ var GET_CONTEXT_DESCRIPTION = `Discover what a Publisher deployment exposes and
|
|
|
293830
293911
|
- An error, stale, or note field means the data did not load or predates the files: read it before trusting a number.
|
|
293831
293912
|
|
|
293832
293913
|
## Parameters
|
|
293833
|
-
All optional
|
|
293914
|
+
All optional; supply what you know. Each combination answers at its own level.
|
|
293834
293915
|
- none: lists the environments, each with its package names.
|
|
293835
293916
|
- environmentName: lists that environment's packages, with descriptions.
|
|
293836
293917
|
- + packageName: lists that package's sources.
|
|
293837
|
-
- + query: a plain-English description of what you need
|
|
293838
|
-
- sourceName: narrows
|
|
293839
|
-
- limit: caps results (max 50). Retrieval defaults to 10;
|
|
293918
|
+
- + query: a plain-English description of what you need; returns the most relevant sources, views, queries, and dimension/measure fields.
|
|
293919
|
+
- sourceName: narrows to one source. Without a query it lists that source and its fields, views and queries, led by the source's own row, so [] means no such source. With a query it ranks within that source, so [] means nothing matched, not a missing source.
|
|
293920
|
+
- limit: caps results (max 50). Retrieval defaults to 10; listing levels return all unless set. The drill-down's source row counts.
|
|
293840
293921
|
|
|
293841
293922
|
## Response
|
|
293842
|
-
A JSON object with a results array
|
|
293923
|
+
A JSON object with a results array. Each entity has kind (source / view / query / dimension / measure), name, source, modelPath, and doc; environmentName, packageName, modelPath, and source map onto malloy_executeQuery; pass a view or query as queryName with sourceName. With an embedding provider, retrieval is ranked semantically: the payload carries a retrieval field ("semantic", or "lexical" if it is down) plus a per-entity score. With no provider both are absent, not an error.
|
|
293843
293924
|
|
|
293844
293925
|
## Worked example
|
|
293845
293926
|
{ "environmentName": "examples", "packageName": "storefront", "query": "revenue by product category" }`;
|
|
@@ -293943,7 +294024,7 @@ function registerGetContextTool(mcpServer, environmentStore) {
|
|
|
293943
294024
|
};
|
|
293944
294025
|
const sanitized = query ? sanitize2(query) : "";
|
|
293945
294026
|
if (!sanitized) {
|
|
293946
|
-
const results2 = Array.from(byId.values()).filter((e) => e.
|
|
294027
|
+
const results2 = Array.from(byId.values()).filter((e) => sourceName ? e.source === sourceName : e.kind === "source").slice(0, limit).map((e) => ({
|
|
293947
294028
|
kind: e.kind,
|
|
293948
294029
|
name: e.name,
|
|
293949
294030
|
source: e.source,
|
|
@@ -294714,6 +294795,1896 @@ function registerGetStatusTool(mcpServer, environmentStore) {
|
|
|
294714
294795
|
// src/mcp/skills/skills_bundle.json
|
|
294715
294796
|
var skills_bundle_default = {
|
|
294716
294797
|
skills: [
|
|
294798
|
+
{
|
|
294799
|
+
name: "eval-answer",
|
|
294800
|
+
description: "Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.",
|
|
294801
|
+
body: `# Evaluate One Answer
|
|
294802
|
+
|
|
294803
|
+
One user intent, answered once. This skill decides whether that answer was
|
|
294804
|
+
correct, records the evidence, and stops.
|
|
294805
|
+
|
|
294806
|
+
**Scope boundary:** verdict and events only. No diagnosis, no model edit.
|
|
294807
|
+
|
|
294808
|
+
## The unit
|
|
294809
|
+
|
|
294810
|
+
A chat is not the unit. Segment by user intent. Feedback ("break it out by
|
|
294811
|
+
region") is a revision inside the same answer; grade the final accepted
|
|
294812
|
+
revision.
|
|
294813
|
+
|
|
294814
|
+
Take the question from the stored case (\`evals/<set>/cases.jsonl\`), never from
|
|
294815
|
+
memory or a truncated console line. Record \`question_sha\` of the exact text
|
|
294816
|
+
the answerer saw. Record \`servedRevision\` from \`get_context\` or reload, not
|
|
294817
|
+
the package name: a same-named decoy has been measured for hours.
|
|
294818
|
+
|
|
294819
|
+
## Step 1: Contamination check, before any score
|
|
294820
|
+
|
|
294821
|
+
The answerer can Read or Shell its way to gold. Publisher traces do not see
|
|
294822
|
+
that, so the check runs on the HOST-side tool-use log you kept for the
|
|
294823
|
+
answerer subagent (every tool name and its path or command), plus the MCP
|
|
294824
|
+
call counts the answerer reported.
|
|
294825
|
+
|
|
294826
|
+
The checklist. An attempt is contaminated when its log shows any of:
|
|
294827
|
+
|
|
294828
|
+
1. a Read, Shell, or any file tool touching \`evals/\` or a gold artifact path;
|
|
294829
|
+
2. any access to the model file under test through a file tool (the
|
|
294830
|
+
\`modelPath\` argument on an MCP \`execute_query\` is NOT contamination; the
|
|
294831
|
+
server resolves it, the answerer never reads the file);
|
|
294832
|
+
3. \`reported_calls\` greater than \`host_tool_uses\` (the detectable
|
|
294833
|
+
under-report floor is reported at most total tool uses).
|
|
294834
|
+
|
|
294835
|
+
\`skills/eval-answer/scripts/check_contamination.py\` is a reference aid that
|
|
294836
|
+
mechanizes the same checklist over a JSON log; your reading of the transcript
|
|
294837
|
+
is the check, the script is a second pair of eyes.
|
|
294838
|
+
|
|
294839
|
+
Contaminated attempts get \`verdict: null\` and \`contaminated: true\`. They are
|
|
294840
|
+
excluded from the run aggregates. They are not "wrong answers."
|
|
294841
|
+
|
|
294842
|
+
If you cannot produce a host log, mark \`contaminated: "unknown"\` on both the
|
|
294843
|
+
attempt and its score event, and do not treat the attempt as a clean pass.
|
|
294844
|
+
|
|
294845
|
+
## Step 2: Re-run the submitted query yourself
|
|
294846
|
+
|
|
294847
|
+
Never score the agent's reported rows. Take its final query, execute it with
|
|
294848
|
+
\`execute_query\`, and write a prediction CSV under the run's
|
|
294849
|
+
\`artifacts/\` directory.
|
|
294850
|
+
|
|
294851
|
+
\`submitted: false\` when there is no final query. That is not a wrong answer.
|
|
294852
|
+
No verdict can be issued (\`verdict: null\`, with the reason) when the attempt
|
|
294853
|
+
is not submitted, when the golden is missing, provisional, invalid, or
|
|
294854
|
+
ambiguous, or when a verified golden has no local artifact to compare.
|
|
294855
|
+
|
|
294856
|
+
## Step 3: Judge the answer
|
|
294857
|
+
|
|
294858
|
+
Spawn one fresh judge subagent per attempt, following
|
|
294859
|
+
\`skill:eval-judge\` (the rubric, the anchors, and the output shape live
|
|
294860
|
+
there; this skill does not restate them). Give it the question, the golden,
|
|
294861
|
+
your re-executed prediction rows, the canonical query when present, and the
|
|
294862
|
+
relevant source and field definitions from the model. It returns
|
|
294863
|
+
\`{verdict, confidence, why, column_pairing}\`.
|
|
294864
|
+
|
|
294865
|
+
- The judge sees gold. It is therefore never the answerer, and its verdict
|
|
294866
|
+
never leaks back to any answerer.
|
|
294867
|
+
- Confidence 5 or lower records as \`needs_human\`: neither a pass nor a fail,
|
|
294868
|
+
excluded from acceptance arithmetic, queued for a human look.
|
|
294869
|
+
- \`near_match\` is also neither. It means defensibly different, not "nearly a
|
|
294870
|
+
pass", and it stays out of the pass rate and the acceptance check for the same reason
|
|
294871
|
+
\`needs_human\` does. Report the count; do not fold it into either column.
|
|
294872
|
+
- When a human overrules a verdict, append the case to
|
|
294873
|
+
\`evals/<set>/judge-regressions.jsonl\`.
|
|
294874
|
+
- For a scalar golden, the same protocol applies to a one-value prediction.
|
|
294875
|
+
For \`unanswerable\`, a refusal that names the gap is the pass; a confident
|
|
294876
|
+
numeric answer is the fail.
|
|
294877
|
+
- Large row sets are still the judge's job. There is no scripted row oracle:
|
|
294878
|
+
a script that can pass a wrong answer is worse than none, and the rubric's
|
|
294879
|
+
containment and column-pairing rules are what the comparison needs.
|
|
294880
|
+
|
|
294881
|
+
## Step 4: Score what retrieval delivered
|
|
294882
|
+
|
|
294883
|
+
Per attempt, mechanically, from the ledger -- \`scripts/score_retrieval.py\`. Each
|
|
294884
|
+
case names the entities its answer depends on (\`expectedEntities.required\`, and
|
|
294885
|
+
\`requiredAnyOf\` groups where the model offers more than one route). An entity
|
|
294886
|
+
was delivered if the attempt's \`get_context\` calls returned it as a ranked entity
|
|
294887
|
+
under its id, under the same type and name on a sibling source, or by name inside
|
|
294888
|
+
a returned source's documentation -- text the answerer reads and acts on. Only
|
|
294889
|
+
\`missing\` is a retrieval miss; the route per entity is recorded so the strict
|
|
294890
|
+
count is still there.
|
|
294891
|
+
|
|
294892
|
+
Recall 1.0 with a wrong answer exonerates retrieval: the failure is in the query.
|
|
294893
|
+
Recall below 1.0 and \`coverage: covered\` means the entity existed and search did
|
|
294894
|
+
not surface it; \`derivable\` or \`absent\` means there was nothing to surface. Those
|
|
294895
|
+
look identical in an answer score and have opposite owners, which is what makes
|
|
294896
|
+
this number worth having. It uses the search terms the answerer chose, so it
|
|
294897
|
+
attributes a failure *within* an arm and does not compare retrieval across arms
|
|
294898
|
+
-- that is the engine-side \`eval-retrieval\` skill, which does not ship here.
|
|
294899
|
+
|
|
294900
|
+
## Step 5: Distrust the golden
|
|
294901
|
+
|
|
294902
|
+
A reference answer can be wrong (parent-column fanout, a join on a shared
|
|
294903
|
+
non-identifying key, or a rubric describing a model that has since been fixed).
|
|
294904
|
+
Fanout is not automatically a defect: \`AVG\` / \`STDDEV\` / \`MIN\` / \`MAX\` survive
|
|
294905
|
+
uniform duplication. Classify \`verified_wrong\` (exclude from scoring) vs
|
|
294906
|
+
\`verified_benign\` (keep).
|
|
294907
|
+
|
|
294908
|
+
**The judge produces this, not you.** It is the only station holding the golden,
|
|
294909
|
+
the re-executed rows and the model source at once, so it is the only one that can
|
|
294910
|
+
see the key contradict any of them; the rules and the four values are in
|
|
294911
|
+
\`skill:eval-judge\`. Carry its \`gold_status\` and \`gold_note\` onto the score
|
|
294912
|
+
event unchanged, and where it says nothing, fall back to the case's standing
|
|
294913
|
+
\`golden.status\`.
|
|
294914
|
+
|
|
294915
|
+
Do not encode a rewrite of a bad golden into the model. A \`suspect\` or
|
|
294916
|
+
\`verified_wrong\`, or a no_match whose why indicts the golden rather than the
|
|
294917
|
+
prediction, routes to the golden side door in \`skill:eval-loop\` as a **dataset**
|
|
294918
|
+
issue. It is never a model failure, and it must be settled before improve runs --
|
|
294919
|
+
otherwise a modelling agent is dispatched to fix a model that is already right.
|
|
294920
|
+
|
|
294921
|
+
## Step 6: Append events, then stop
|
|
294922
|
+
|
|
294923
|
+
Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`caseId\` set. Shapes
|
|
294924
|
+
live in \`reference/ledger-schema.md\`.
|
|
294925
|
+
|
|
294926
|
+
1. \`attempt\`: qid, sample, phase, question_sha, submitted, final_query,
|
|
294927
|
+
served revision, call counts, contamination verdict, transcript path.
|
|
294928
|
+
2. \`tool_call\`: one per MCP \`get_context\` / \`execute_query\`, with \`traceId\`
|
|
294929
|
+
and the \`rankedSummary\` copied from the trace (per-target ranks included).
|
|
294930
|
+
Do not copy full traces into the event; the trace store holds the body.
|
|
294931
|
+
3. \`score\`: the judge's verdict object plus \`judge_version\`, \`rubric_sha\`,
|
|
294932
|
+
\`golden_revision\`, \`contaminated\`, \`gold_status\`, and the judge output's
|
|
294933
|
+
artifact path.
|
|
294934
|
+
|
|
294935
|
+
A stage never rewrites another stage's fields. End-of-run numbers come from
|
|
294936
|
+
counting events, not from your arithmetic in prose.
|
|
294937
|
+
|
|
294938
|
+
Sample each case once. Breadth across cases beats repeats of one case; the
|
|
294939
|
+
comparison rule for a before/after is the flip count in \`skill:eval-loop\`
|
|
294940
|
+
Measurement, not a mean over samples.
|
|
294941
|
+
|
|
294942
|
+
## Re-score after a golden repair
|
|
294943
|
+
|
|
294944
|
+
When \`eval-loop\` has repaired a golden and opened a new run, this skill runs
|
|
294945
|
+
again **without a new answerer**: same stored \`final_query\` (or its saved
|
|
294946
|
+
prediction CSV), new gold artifact, fresh judge, new \`golden_revision\` on the
|
|
294947
|
+
\`score\` event. Contamination does not need to be re-litigated if the attempt
|
|
294948
|
+
was already clean. If you must re-execute, do it yourself; do not ask the
|
|
294949
|
+
original answerer to "try again" with the new key in context.
|
|
294950
|
+
|
|
294951
|
+
## Related skills
|
|
294952
|
+
|
|
294953
|
+
- \`skill:eval-diagnose\`: why it failed, after this record exists.
|
|
294954
|
+
- \`skill:eval-improve\`: smallest model edit, model-owned issues only.
|
|
294955
|
+
- The \`malloy-analysis-pitfalls\` skill: checks before you trust a result you ran.
|
|
294956
|
+
The judge loads it by name; it is not part of the \`eval\` group.
|
|
294957
|
+
|
|
294958
|
+
## Reference files over MCP
|
|
294959
|
+
|
|
294960
|
+
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.
|
|
294961
|
+
|
|
294962
|
+
Available: ledger-schema.`
|
|
294963
|
+
},
|
|
294964
|
+
{
|
|
294965
|
+
name: "eval-answer/ledger-schema",
|
|
294966
|
+
description: "The eval ledger: files and events. Reference detail for the eval-answer skill.",
|
|
294967
|
+
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. `verify_goldens.py` skips every check without it, reporting \"nothing to re-derive against\", so a set that omits it silently has no golden verification at all. `init_truth_package.py` scaffolds the package. |\n| `truthModel` | Model file inside that package. Default `truth.malloy`. |\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`), `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 (`judge.md` rules 9 and 10). |\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, when the target served working files; `-dirty` suffix when the tree had uncommitted changes -- fine for a band measurement, not for an A/B pin. Omit for a platform target, where a local commit pins nothing. |\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` 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| `mcpUrl` / `publisher` | The endpoints the answerer and the re-execution used. |\n| `predictionsReExecuted` | Whether the judge saw re-executed rows (false when the served bytes no longer match the pinned snapshot). |\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. |\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 the judge did not believe: `qid`, `gold_status` (`suspect` or `verified_wrong`), `gold_note`. **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 the judge believed every key. 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` / `targetVersion` / `serverVersion` / `traceMode` / `callBudget` / `status` | Defined and accepted, **not yet written by any harness** -- kept in the schema for the platform target and the conductor, which need them. |\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. |\n| `submitted` | bool | False when there was no final query. Not a wrong answer. |\n| `final_query` | string or null | Required to replay. |\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 | Host-side count, including Read and Shell. |\n| `reported_calls` | int | MCP calls the answerer claimed. |\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| `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`, `golden_missing`, `golden_ambiguous`, `contaminated`). |\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| `artifactPath` | string | The full judge output under `artifacts/`. |\n| `gold_status` | string | `verified` / `verified_benign` / `suspect` / `verified_wrong`. **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. `verified_wrong` excludes the case from run aggregates. `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 `submitted: false` attempt gets `verdict: null, reason: \"not_submitted\"`,\nexcept for an `unanswerable` golden, where a refusal that names the gap is the\npass and a confident numeric answer 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."
|
|
294968
|
+
},
|
|
294969
|
+
{
|
|
294970
|
+
name: "eval-diagnose",
|
|
294971
|
+
description: "Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).",
|
|
294972
|
+
body: `# Diagnose One Answer
|
|
294973
|
+
|
|
294974
|
+
Consumes a \`score\` event from \`skill:eval-answer\` and answers: why did this fail,
|
|
294975
|
+
and who owns the fix?
|
|
294976
|
+
|
|
294977
|
+
**Scope boundary:** write the diagnosis before any edit exists. This skill never
|
|
294978
|
+
edits a model and never proposes a patch beyond naming the gap. Diagnosis that
|
|
294979
|
+
is allowed to edit becomes justification for an edit somebody already wanted.
|
|
294980
|
+
|
|
294981
|
+
Do not diagnose a contaminated attempt or an environment failure. Those are
|
|
294982
|
+
harness or ops, not model work.
|
|
294983
|
+
|
|
294984
|
+
## Components, in order
|
|
294985
|
+
|
|
294986
|
+
Walk **in this order** and stop at the first with positive evidence. A later
|
|
294987
|
+
label requires ruling out the earlier ones. Write \`component\` with these strings,
|
|
294988
|
+
never "C1" / "C2" / "C3":
|
|
294989
|
+
|
|
294990
|
+
| \`component\` | Question |
|
|
294991
|
+
|---|---|
|
|
294992
|
+
| \`dataset\` | Bad question, bad or missing golden, or environment drift? |
|
|
294993
|
+
| \`agent-call\` | Did the agent ask for the needed concepts, with the right type and scope? |
|
|
294994
|
+
| \`get_context/model\` | Is the needed entity absent, undocumented, weakly labeled, duplicated, or missing guidance? |
|
|
294995
|
+
| \`get_context/retrieval\` | Was an on-target request against a well-described entity ranked or grouped wrong? |
|
|
294996
|
+
| \`construction\` | Did sufficient context arrive, and the agent still built the wrong query? |
|
|
294997
|
+
| \`model-definition\` | Is a measure, join, filter convention, or source semantically wrong? |
|
|
294998
|
+
|
|
294999
|
+
\`owner\` is separate: \`model\`, \`retrieval\`, \`agent-skill\`, or \`dataset\`. There
|
|
295000
|
+
is no environment owner: an environment failure stops the run before
|
|
295001
|
+
diagnosis (see the boundary above), so no issue can carry it.
|
|
295002
|
+
|
|
295003
|
+
\`construction\` requires proving the needed entities and governing guidance were
|
|
295004
|
+
in the returned context. A server trace proves what Publisher returned, not what
|
|
295005
|
+
the host kept after compaction. If the rendered tool response is gone, mark
|
|
295006
|
+
sufficiency \`unknown\` and do not assign \`construction\`.
|
|
295007
|
+
|
|
295008
|
+
Always report construction eligibility as \`eligible / total\`. That is a
|
|
295009
|
+
diagnostic conditional, not a causal comparison.
|
|
295010
|
+
|
|
295011
|
+
## Step 1: Extract facts from traces, not from memory
|
|
295012
|
+
|
|
295013
|
+
For each \`get_context\` call, load the stored retrieval trace by the \`traceId\` on the
|
|
295014
|
+
\`tool_call\` event. Write down, before you interpret anything:
|
|
295015
|
+
|
|
295016
|
+
- **Asked:** every retrieval utterance, target types, scopes, and result counts,
|
|
295017
|
+
in order.
|
|
295018
|
+
- **Returned:** for each needed entity, whether it appeared, its best
|
|
295019
|
+
within-target rank, and under which utterance. Read this off the
|
|
295020
|
+
\`rankedSummary\` on the attempt's \`tool_call\` events (its \`targets\` list
|
|
295021
|
+
carries per-target ranks); the full trace body is behind your host's
|
|
295022
|
+
trace lookup.
|
|
295023
|
+
Count from the trace, never from recollection.
|
|
295024
|
+
- **Used:** sources and fields the final query referenced, and needed entities
|
|
295025
|
+
that were returned and then unused.
|
|
295026
|
+
|
|
295027
|
+
Resolve aliases to the real source (\`join_one: bldg is fac_building\` uses
|
|
295028
|
+
\`fac_building\`). Count ranks from the trace, not from recollection.
|
|
295029
|
+
|
|
295030
|
+
The needed set comes from golden metadata or from the entities the corrected
|
|
295031
|
+
answer required. Do not invent it from the question's nouns alone.
|
|
295032
|
+
|
|
295033
|
+
Presence leads; rank refines. Every needed entity present with a wrong
|
|
295034
|
+
answer is prima facie \`construction\`. A needed entity that never appeared is
|
|
295035
|
+
never \`construction\`, no matter how wrong the query looks. Everything
|
|
295036
|
+
present but buried deep under noise the agent reasonably skipped is
|
|
295037
|
+
\`get_context/retrieval\` once the request itself was on-target.
|
|
295038
|
+
|
|
295039
|
+
## Step 2: Assign one primary code
|
|
295040
|
+
|
|
295041
|
+
Use these codes verbatim. Re-wording them destroys the cross-answer pattern.
|
|
295042
|
+
|
|
295043
|
+
### Dataset first
|
|
295044
|
+
|
|
295045
|
+
| Code | When | Owner |
|
|
295046
|
+
|---|---|---|
|
|
295047
|
+
| \`BAD-REFERENCE\` | the golden itself is wrong, and you can name the defect | dataset |
|
|
295048
|
+
| \`AMBIGUOUS-REFERENCE\` | the key is untrustworthy as a score, but a replacement is not uniquely determined (two honest replays disagree; later cases may confirm a convention) | dataset |
|
|
295049
|
+
| \`BAD-QUESTION\` | the question is unanswerable as written, or underspecified (ties, rank without order) | dataset |
|
|
295050
|
+
| \`CORRECT-SUPERSET\` | every expected row present, plus extra context | none (it passed) |
|
|
295051
|
+
|
|
295052
|
+
A cheap tell for a bad golden: impossible magnitude; identical values across
|
|
295053
|
+
entities that should differ; \`SUM\` / \`COUNT(*)\` over a join that duplicates on
|
|
295054
|
+
both sides. \`AVG\` / \`STDDEV\` / \`MIN\` / \`MAX\` survive uniform duplication, so
|
|
295055
|
+
fanout alone proves nothing.
|
|
295056
|
+
|
|
295057
|
+
**BAD-REFERENCE and AMBIGUOUS-REFERENCE are first-class outcomes, not awkward
|
|
295058
|
+
misses.** Goldens often encode assumptions we want in the model. They can also
|
|
295059
|
+
be wrong. Flag the case when the key is defective or when two justified
|
|
295060
|
+
replays disagree: do not edit the model to match a bad or unsettled key, and
|
|
295061
|
+
do not invent a replacement number. \`BAD-REFERENCE\` goes to Repair a bad
|
|
295062
|
+
golden. \`AMBIGUOUS-REFERENCE\` goes to Hold an ambiguous golden. Do not leave
|
|
295063
|
+
the run looking like the model failed.
|
|
295064
|
+
|
|
295065
|
+
This skill **classifies and hands off**. Write the issue with
|
|
295066
|
+
\`owner: dataset\` and stop for that case. The conductor (\`skill:eval-loop\`)
|
|
295067
|
+
either repairs the golden or holds it as \`ambiguous\`. Do not capture a
|
|
295068
|
+
replacement golden from inside diagnosis if you are not also conducting; a
|
|
295069
|
+
diagnosis that writes a new key without a version bump silently changes what
|
|
295070
|
+
earlier scores meant.
|
|
295071
|
+
|
|
295072
|
+
Prior \`score\` events are not rewritten. They keep the old \`golden_revision\`.
|
|
295073
|
+
|
|
295074
|
+
### Agent call
|
|
295075
|
+
|
|
295076
|
+
| Code | When | Owner |
|
|
295077
|
+
|---|---|---|
|
|
295078
|
+
| \`NEVER-ASKED\` | no utterance targeted a needed concept | agent-skill, and model if nothing would have prompted the ask |
|
|
295079
|
+
| \`VAGUE\` | compound or generic utterances, so nothing could rank | agent-skill |
|
|
295080
|
+
| \`QUESTION-VOCAB\` | utterances parroted the question where the data uses other words | agent-skill, and model if that vocabulary is undocumented |
|
|
295081
|
+
| \`NO-DISAMBIG\` | two plausible candidates, never resolved | model: docs should answer, not require the question |
|
|
295082
|
+
| \`ASSUMED\` | assumed a scope or convention instead of checking | model if nothing warned; agent-skill otherwise |
|
|
295083
|
+
| \`WRONG-TYPE-OR-SCOPE\` | asked, but with the wrong target type or an empty/wrong scope | agent-skill |
|
|
295084
|
+
|
|
295085
|
+
If the agent could not reasonably have known to ask, that is a model gap.
|
|
295086
|
+
|
|
295087
|
+
### get_context / model
|
|
295088
|
+
|
|
295089
|
+
| Code | When | Owner |
|
|
295090
|
+
|---|---|---|
|
|
295091
|
+
| \`COVERAGE\` | no representing entity anywhere | model |
|
|
295092
|
+
| \`NOT-RETURNED\` | it exists, the ask was on target, it never came back | model: labels, docs, synonyms, index |
|
|
295093
|
+
| \`LOW-RANK\` | returned, buried under noise the agent reasonably skipped | model |
|
|
295094
|
+
| \`AMBIGUOUS\` | several near-identical candidates | model: "use X for …, Y when …" |
|
|
295095
|
+
| \`GUIDANCE-NOT-RETRIEVED\` | entities came back, governing guidance did not | model: put guidance on the entities agents search for |
|
|
295096
|
+
| \`GUIDANCE-DECLINED\` | guidance was retrieved and judged inapplicable | model: state the business default, not a caveat |
|
|
295097
|
+
|
|
295098
|
+
A missing join is coverage, not an agent-call miss. The model has to volunteer
|
|
295099
|
+
relationships. A declared join is not a retrieval entity; do not look for it in
|
|
295100
|
+
\`get_context\` results.
|
|
295101
|
+
|
|
295102
|
+
### get_context / retrieval
|
|
295103
|
+
|
|
295104
|
+
| Code | When | Owner |
|
|
295105
|
+
|---|---|---|
|
|
295106
|
+
| \`RETRIEVAL\` | model looks right, utterance on target, rank or grouping still failed | retrieval |
|
|
295107
|
+
|
|
295108
|
+
Prove it before you use this code: search a distinctive phrase from the entity's
|
|
295109
|
+
own doc. If a rare token retrieves it and ordinary phrasing does not, say so
|
|
295110
|
+
with both queries. Otherwise it is still \`NOT-RETURNED\` / \`LOW-RANK\`.
|
|
295111
|
+
|
|
295112
|
+
### Construction (only after sufficiency)
|
|
295113
|
+
|
|
295114
|
+
| Code | When | Owner |
|
|
295115
|
+
|---|---|---|
|
|
295116
|
+
| \`WRONG-PICK\` | needed entity returned, used a different one | model if indistinguishable; agent-skill if docs distinguished them |
|
|
295117
|
+
| \`SCOPE\` | right entities, wrong population | model if the scope rule was undocumented |
|
|
295118
|
+
| \`GRAIN\` | right entities, wrong grain | model or agent-skill |
|
|
295119
|
+
| \`FILTER-LITERAL\` | filter literal did not match stored values | model (document the stored form) and agent-skill |
|
|
295120
|
+
| \`CONVENTION\` | right data, wrong statistical or business convention | model: expose a named measure |
|
|
295121
|
+
| \`SYNTAX\` | could not express it; execute errors; never submitted | agent-skill |
|
|
295122
|
+
|
|
295123
|
+
### model-definition
|
|
295124
|
+
|
|
295125
|
+
Use when the entity was found and used, and the definition or the data behind it
|
|
295126
|
+
is wrong (bad grain, wrong join key, inverted filter). Owner: model. A doc whose
|
|
295127
|
+
factual claim the data contradicts (a population statement, a grain claim) is
|
|
295128
|
+
also model-definition: the SQL may be right while the stated contract is false,
|
|
295129
|
+
and an agent that trusts the doc answers wrongly without ever failing a query.
|
|
295130
|
+
Probe the claim before writing the issue.
|
|
295131
|
+
|
|
295132
|
+
## Step 3: Read the failure shape
|
|
295133
|
+
|
|
295134
|
+
| Signature | Look here |
|
|
295135
|
+
|---|---|
|
|
295136
|
+
| Extremes match, means do not | Population, filter, or join scope |
|
|
295137
|
+
| Same row count, values differ | Wrong column or literal, not joins |
|
|
295138
|
+
| Row count differs, all expected rows present | Superset; often not an error |
|
|
295139
|
+
| Right keys, wrong aggregates on a minority | Undeclared or wrong-cardinality relationship |
|
|
295140
|
+
| Off by a clean integer multiple | Fanout; which side of the join is non-unique |
|
|
295141
|
+
| Zero errors, few calls, fast, confidently wrong | The model steered it |
|
|
295142
|
+
| Identical high-precision values across entities that should differ | Cross-contamination join |
|
|
295143
|
+
| A magnitude that cannot be true | Fanout, possibly in the golden |
|
|
295144
|
+
|
|
295145
|
+
Mine the agent's prose, not only its calls. It often names the gap.
|
|
295146
|
+
|
|
295147
|
+
## Step 4: Append issue events, then stop
|
|
295148
|
+
|
|
295149
|
+
Append to \`evals/<set>/runs/<runId>/events.jsonl\` with \`kind: issue\`
|
|
295150
|
+
(shapes in \`skill:eval-answer\` \`reference/ledger-schema.md\`):
|
|
295151
|
+
|
|
295152
|
+
- \`issue_id\`, affected \`qids\`, \`primary_code\`, \`contributing_codes\`
|
|
295153
|
+
- \`component\`, \`owner\`, \`severity\`, \`confidence\`
|
|
295154
|
+
- \`sufficiency\` (\`sufficient\` / \`insufficient\` / \`unknown\`)
|
|
295155
|
+
- \`traceId\`s, not copied trace payloads
|
|
295156
|
+
- \`diagnosis\`: the suspected shared entity, file, or root cause, written
|
|
295157
|
+
before any edit exists
|
|
295158
|
+
|
|
295159
|
+
Then \`issue_status\` with \`status: open\`. Status is always an event. Readers
|
|
295160
|
+
take the latest \`issue_status\` for that \`issue_id\`.
|
|
295161
|
+
|
|
295162
|
+
The issue backlog in the event log is the output, not per-question prose.
|
|
295163
|
+
Diagnose reads dev cases only; a holdout case with a bad score stays
|
|
295164
|
+
undiagnosed so the acceptance check keeps something the improve step never saw.
|
|
295165
|
+
|
|
295166
|
+
## Step 5: Cluster before anyone improves
|
|
295167
|
+
|
|
295168
|
+
Eight failures are rarely eight problems. They are more often two or three,
|
|
295169
|
+
each surfacing in several cases, and the edit worth making is the one that
|
|
295170
|
+
clears a group. So the unit handed to \`skill:eval-improve\` is the cluster, not
|
|
295171
|
+
the case, and one issue event covers all of its cases rather than one per case.
|
|
295172
|
+
|
|
295173
|
+
**Group on shared cause, not shared symptom.** Two cases that both returned a
|
|
295174
|
+
wrong revenue number belong together only if the same entity, doc gap, or
|
|
295175
|
+
convention explains both. Same \`owner\` and same \`component\` is a hint, never a
|
|
295176
|
+
criterion: two \`COVERAGE\` issues about different missing entities are two
|
|
295177
|
+
clusters, and merging them produces an edit that fixes neither cleanly.
|
|
295178
|
+
|
|
295179
|
+
Order clusters by how many cases they would fix. Cluster the non-model owners
|
|
295180
|
+
too, in their own clusters, so nothing is lost on the way to the backlog --
|
|
295181
|
+
but keep them separate, because only \`owner: model\` may proceed to an edit.
|
|
295182
|
+
|
|
295183
|
+
Say what you considered merging and chose not to. A cluster is a claim that one
|
|
295184
|
+
change fixes N cases, and the near-misses are what a reviewer needs to falsify
|
|
295185
|
+
it.
|
|
295186
|
+
|
|
295187
|
+
Still no patch. Naming the shared root cause precisely enough that someone else
|
|
295188
|
+
can design the edit is the whole job here; the edit itself is
|
|
295189
|
+
\`skill:eval-improve\`, working from this. A cluster carrying an honest open
|
|
295190
|
+
question is more useful than one carrying a remedy nobody probed.
|
|
295191
|
+
|
|
295192
|
+
**Only \`owner: model\` proceeds to \`eval-improve\`.** Skill findings go back into
|
|
295193
|
+
the analysis or phrase-detection skill. Retrieval findings go to the tool.
|
|
295194
|
+
\`BAD-REFERENCE\` and \`AMBIGUOUS-REFERENCE\` go to the golden side door in
|
|
295195
|
+
\`skill:eval-loop\` (repair or hold). Do not send them to improve. Other dataset
|
|
295196
|
+
findings (a bad question, a case worth excluding) go back to the case in
|
|
295197
|
+
\`cases.jsonl\` via the conductor. Routing a skill bug into the model is
|
|
295198
|
+
how models accumulate scar tissue.
|
|
295199
|
+
|
|
295200
|
+
## Anti-patterns
|
|
295201
|
+
|
|
295202
|
+
- Do not diagnose from the answer alone. Probe why a number differed.
|
|
295203
|
+
- Do not treat a passing answer as uninformative. High call counts on a pass
|
|
295204
|
+
still name gaps.
|
|
295205
|
+
- Do not conclude a model gap from two agents agreeing. They coin-flip onto the
|
|
295206
|
+
same undocumented sibling for the same reason.
|
|
295207
|
+
- Do not assign \`construction\` when sufficiency is unknown.
|
|
295208
|
+
|
|
295209
|
+
## Related skills
|
|
295210
|
+
|
|
295211
|
+
- \`skill:eval-answer\`: the score this consumes.
|
|
295212
|
+
- \`skill:eval-improve\`: smallest model edit, \`owner: model\` only.
|
|
295213
|
+
- \`skill:eval-loop\`: golden hold/repair, the acceptance check, and checkpoint.
|
|
295214
|
+
|
|
295215
|
+
## Reference files over MCP
|
|
295216
|
+
|
|
295217
|
+
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-diagnose/<name>\` instead.
|
|
295218
|
+
|
|
295219
|
+
Available: output-contract.`
|
|
295220
|
+
},
|
|
295221
|
+
{
|
|
295222
|
+
name: "eval-diagnose/output-contract",
|
|
295223
|
+
description: "Output contract. Reference detail for the eval-diagnose skill.",
|
|
295224
|
+
body: `<!-- What a diagnose agent must emit. Read this before writing your reply. -->
|
|
295225
|
+
|
|
295226
|
+
# Output contract
|
|
295227
|
+
|
|
295228
|
+
Both shapes below are read by a script. Emit the object as the LAST thing in
|
|
295229
|
+
your reply, with nothing after it. Prose before it is fine and expected -- the
|
|
295230
|
+
reasoning is what the codes have to follow from.
|
|
295231
|
+
|
|
295232
|
+
## Per case (Step 1-4)
|
|
295233
|
+
|
|
295234
|
+
\`\`\`json
|
|
295235
|
+
{"probes": [{"why": "the claim this checks", "query": "query or search text",
|
|
295236
|
+
"result": "what came back, briefly"}],
|
|
295237
|
+
"reasoning": "how the ladder resolved: what you ruled out, and why",
|
|
295238
|
+
"component": "one of the six",
|
|
295239
|
+
"primary_code": "one code, verbatim from the skill",
|
|
295240
|
+
"contributing_codes": ["zero or more, verbatim"],
|
|
295241
|
+
"owner": "model | retrieval | agent-skill | dataset",
|
|
295242
|
+
"sufficiency": "sufficient | insufficient | unknown",
|
|
295243
|
+
"severity": "high | medium | low",
|
|
295244
|
+
"confidence": "high | medium | low",
|
|
295245
|
+
"diagnosis": "the suspected entity, file, or root cause, in one or two sentences",
|
|
295246
|
+
"sharedWith": "a short phrase naming what other cases would share this cause"}
|
|
295247
|
+
|
|
295248
|
+
\`probes\` must be non-empty: it is the record that you checked rather than
|
|
295249
|
+
assumed. \`reasoning\` precedes the codes because the codes must follow from it.
|
|
295250
|
+
\`\`\`
|
|
295251
|
+
|
|
295252
|
+
## Per run, clustering (Step 5)
|
|
295253
|
+
|
|
295254
|
+
\`\`\`json
|
|
295255
|
+
{"clusters": [
|
|
295256
|
+
{"cluster_id": "short-kebab-slug",
|
|
295257
|
+
"qids": ["every case in this cluster"],
|
|
295258
|
+
"owner": "model | retrieval | agent-skill | dataset",
|
|
295259
|
+
"component": "the shared component",
|
|
295260
|
+
"codes": ["the primary codes present"],
|
|
295261
|
+
"rootCause": "one or two sentences: the ONE thing explaining all of them",
|
|
295262
|
+
"evidence": "why these belong together, and what would prove it wrong",
|
|
295263
|
+
"confidence": "high | medium | low"}
|
|
295264
|
+
],
|
|
295265
|
+
"reasoning": "what you considered merging and chose not to, and why"}
|
|
295266
|
+
|
|
295267
|
+
Order clusters by the number of qids, descending. Every diagnosed case must
|
|
295268
|
+
appear in exactly one cluster; a case that shares a cause with nothing else is
|
|
295269
|
+
a cluster of one.
|
|
295270
|
+
\`\`\``
|
|
295271
|
+
},
|
|
295272
|
+
{
|
|
295273
|
+
name: "eval-improve",
|
|
295274
|
+
description: "Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).",
|
|
295275
|
+
body: `# Improve the Model
|
|
295276
|
+
|
|
295277
|
+
Takes an issue with \`owner: model\` and produces **one smallest edit** that closes
|
|
295278
|
+
the gap. Every factual claim is backed by a query you ran.
|
|
295279
|
+
|
|
295280
|
+
**Two hard boundaries:**
|
|
295281
|
+
|
|
295282
|
+
1. **No diagnosis evidence, no edit.** If the issue cannot name a concrete gap
|
|
295283
|
+
with a trace or probe, record that and stop. Edits from an empty diagnosis
|
|
295284
|
+
have been the inert and wrong ones.
|
|
295285
|
+
2. **This skill never accepts its own edit.** You propose and verify. The
|
|
295286
|
+
acceptance check
|
|
295287
|
+
in \`skill:eval-loop\` admits or reverts. An improver writing the query it
|
|
295288
|
+
already knows proves the fix is possible, not that the next blind agent
|
|
295289
|
+
will find it.
|
|
295290
|
+
|
|
295291
|
+
## Step 0: What the evidence entitles you to change
|
|
295292
|
+
|
|
295293
|
+
| Evidence | Edits permitted |
|
|
295294
|
+
|---|---|
|
|
295295
|
+
| Verified golden, or a user who states the answer | Any tier. Probes required. Check the golden first. |
|
|
295296
|
+
| Wrong answer, then a corrected one the user accepted | Prefer docs over structure. The diff between attempts is the missing knowledge. |
|
|
295297
|
+
| User accepted, later contradicted | Docs, labels, index only. No structural change. |
|
|
295298
|
+
| Doubt only, or retrieval-only (no verdict) | Docs, labels, index only, and only where the transcript shows a concrete confusion. |
|
|
295299
|
+
| Silence | **No edit.** |
|
|
295300
|
+
|
|
295301
|
+
Do not edit for \`BAD-REFERENCE\` or \`AMBIGUOUS-REFERENCE\`. Those are the
|
|
295302
|
+
golden side door in \`skill:eval-loop\`: repair or hold the golden, bump
|
|
295303
|
+
\`goldenRevision\` on the case, and open a new baseline run. Being right and unmatched
|
|
295304
|
+
beats encoding a defect or an unsettled key. Do not edit for a skill,
|
|
295305
|
+
retrieval, or dataset owner.
|
|
295306
|
+
|
|
295307
|
+
## Step 1: What a correct answer may teach
|
|
295308
|
+
|
|
295309
|
+
Encode what a domain expert would volunteer unprompted: systems of record,
|
|
295310
|
+
vocabulary to stored codes, what a metric means and at what grain, which
|
|
295311
|
+
relationship is the real one.
|
|
295312
|
+
|
|
295313
|
+
The expert test, per edit: *would a domain expert have said this about their
|
|
295314
|
+
data with no question in front of them?* Reject:
|
|
295315
|
+
|
|
295316
|
+
- a field that hard-codes this question's filter and serves no other question
|
|
295317
|
+
- this question's text, qid, or expected numbers in a doc, comment, or name
|
|
295318
|
+
- a join copied from gold SQL that you have not probed as a real relationship
|
|
295319
|
+
|
|
295320
|
+
The golden is a hypothesis source. The data is still the verifier.
|
|
295321
|
+
|
|
295322
|
+
## Step 2: Probe receipts
|
|
295323
|
+
|
|
295324
|
+
Every structural claim needs a query you ran: join key, primary key, filter
|
|
295325
|
+
value existence, snapshot assumption, value space, cardinality.
|
|
295326
|
+
|
|
295327
|
+
\`\`\`sql
|
|
295328
|
+
SELECT COUNT(*), COUNT(DISTINCT col) FROM t;
|
|
295329
|
+
SELECT a.k, COUNT(*) FROM a JOIN b ON … GROUP BY 1 ORDER BY 2 DESC;
|
|
295330
|
+
SELECT col, COUNT(*) FROM t GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
|
|
295331
|
+
\`\`\`
|
|
295332
|
+
|
|
295333
|
+
A false \`primary_key\` compiles and silently corrupts every aggregate. Of one
|
|
295334
|
+
pilot's 11 accepted edits, 4 of 5 wrong ones died to a single
|
|
295335
|
+
\`COUNT(*)\` vs \`COUNT(DISTINCT …)\` probe that was never run.
|
|
295336
|
+
|
|
295337
|
+
Compile-check the edit before saving (scope \`file\` for an edit), then reload
|
|
295338
|
+
the package. Confirm it is not serving a stale model.
|
|
295339
|
+
|
|
295340
|
+
This step needs a target you control: a local server, or a host that can
|
|
295341
|
+
execute a draft. A run whose answerers queried a published model cannot be
|
|
295342
|
+
improved in place, because publishing to score an edit is not something this
|
|
295343
|
+
loop does. \`skill:eval-loop\` picks the target before the run starts, so if you
|
|
295344
|
+
have arrived here against a published target, stop and say so rather than
|
|
295345
|
+
publishing.
|
|
295346
|
+
|
|
295347
|
+
Know which copy of the file the server actually reads. Hosts commonly serve a
|
|
295348
|
+
copy of the package rather than your working tree, so editing the model repo
|
|
295349
|
+
and reloading recompiles the unchanged copy: the reload succeeds, nothing
|
|
295350
|
+
changes, and a verification probe quietly tests the old model. Confirm the
|
|
295351
|
+
edit reached what is served before you trust a probe, and keep the model repo
|
|
295352
|
+
the source of truth that gets committed. On open-source Publisher the served
|
|
295353
|
+
copy lives under \`publisher_data/<env>/<pkg>/\` unless the environment is
|
|
295354
|
+
watch-mounted; other hosts distinguish a draft from a published version.
|
|
295355
|
+
|
|
295356
|
+
## Step 3: One smallest edit
|
|
295357
|
+
|
|
295358
|
+
Prefer edits that add no entities. New sources compete for retrieval and
|
|
295359
|
+
displace answers that already worked.
|
|
295360
|
+
|
|
295361
|
+
| Rank | Edit |
|
|
295362
|
+
|---|---|
|
|
295363
|
+
| 1 | Disambiguating doc on confusable siblings: "use X for …, Y when …" |
|
|
295364
|
+
| 2 | Named dimension or measure in user vocabulary |
|
|
295365
|
+
| 3 | Doc reword, rename, or \`#(index)\` annotation |
|
|
295366
|
+
| 4 | Declared join on a *probed* key |
|
|
295367
|
+
| 5 | A new source: last resort, at most one |
|
|
295368
|
+
|
|
295369
|
+
Make the correct thing the default. Guidance phrased as a caveat
|
|
295370
|
+
("pair with X", "note that Y also includes Z") is retrieved, read, and
|
|
295371
|
+
declined. A source parameter or named measure that is already the safe
|
|
295372
|
+
scope does not invite a judgment call.
|
|
295373
|
+
|
|
295374
|
+
You cannot append guidance to every field for free. Doc length trades
|
|
295375
|
+
against the entity's own rank. A declared join is invisible to retrieval;
|
|
295376
|
+
put the rule on the entities agents search for.
|
|
295377
|
+
|
|
295378
|
+
Follow the \`malloy-gotchas-modeling\` skill so the edit does not introduce a
|
|
295379
|
+
new modeling mistake. \`improve.py\` installs it with the rest of the \`modeling\`
|
|
295380
|
+
manifest group, so it is loaded alongside this skill rather than reached from
|
|
295381
|
+
here.
|
|
295382
|
+
|
|
295383
|
+
## Step 4: Check what your edit did to the answer key
|
|
295384
|
+
|
|
295385
|
+
An edit that changes what a field *means* can silently invalidate goldens for
|
|
295386
|
+
questions you were not working on. The rubric still describes the old meaning,
|
|
295387
|
+
the stored value is still the old number, and **nothing fails** -- the case just
|
|
295388
|
+
starts scoring wrong, against the model, in the direction of your edit. The set's
|
|
295389
|
+
own value re-derivation will not catch it either, because it re-runs a
|
|
295390
|
+
\`canonicalQuery\` that encodes the same stale definition.
|
|
295391
|
+
|
|
295392
|
+
Real instance: fixing \`lifetime_orders\` from line items to distinct orders was
|
|
295393
|
+
correct and targeted. It also silently moved \`top_customer\` (defined over it)
|
|
295394
|
+
from 108 customers to 87, and left two rubrics asserting the pre-fix behaviour.
|
|
295395
|
+
Two correct answers were marked wrong for a full run before anyone noticed.
|
|
295396
|
+
|
|
295397
|
+
So before handing off, for **every entity whose meaning you changed** -- not
|
|
295398
|
+
every entity you touched; a doc reword changes no meaning:
|
|
295399
|
+
|
|
295400
|
+
1. Grep the case file for the entity name. Any rubric, \`canonicalQuery\` or
|
|
295401
|
+
stated value that mentions it is now in question.
|
|
295402
|
+
2. For each hit, re-derive the value under the new definition and compare it to
|
|
295403
|
+
the stored golden. Different means the golden is stale, not that you are
|
|
295404
|
+
wrong.
|
|
295405
|
+
3. Run the set's golden verification if it has one, which catches the mechanical
|
|
295406
|
+
subset (drift, and rubric sentences that contradict the model).
|
|
295407
|
+
|
|
295408
|
+
Report every hit as \`golden_suspect\` in the handoff, with the entity, the case,
|
|
295409
|
+
and the old and new values. **Do not repair them yourself.** Goldens are the
|
|
295410
|
+
side door in \`skill:eval-loop\`, and an improver that edits the answer key its own
|
|
295411
|
+
edit is scored against has removed the only independent check on the edit.
|
|
295412
|
+
|
|
295413
|
+
A non-empty \`golden_suspect\` list blocks the acceptance check until the
|
|
295414
|
+
conductor settles
|
|
295415
|
+
each one, because a rerun against stale goldens measures nothing.
|
|
295416
|
+
|
|
295417
|
+
## Step 5: Verify, report, hand off
|
|
295418
|
+
|
|
295419
|
+
Compile, reload, run one trivial query against each source you touched.
|
|
295420
|
+
Append a \`candidate\` event to the run's \`events.jsonl\` (shape in
|
|
295421
|
+
\`skill:eval-answer\` \`reference/ledger-schema.md\`): the files touched, a
|
|
295422
|
+
one-line diff summary per file, the issue_ids, probe receipts, and this
|
|
295423
|
+
report. Every proposal gets its event, accepted or not; a rejected direction
|
|
295424
|
+
keeps its record. Then stop and wait for the acceptance check in \`skill:eval-loop\`. That
|
|
295425
|
+
skill writes the \`acceptance_check\` event, accepts or reverts, and **only on accept**
|
|
295426
|
+
checkpoints. This skill never checkpoints and never self-accepts.
|
|
295427
|
+
|
|
295428
|
+
\`\`\`
|
|
295429
|
+
COMPONENT / PRIMARY_CODE / OWNER
|
|
295430
|
+
EVIDENCE: class you worked from, and how it limited the edit
|
|
295431
|
+
DISAGREEMENT: NONE, or anything in the diagnosis probing showed was wrong
|
|
295432
|
+
DIAGNOSIS: 2-3 sentences, written before the edit
|
|
295433
|
+
EDIT: one line, or NONE with why
|
|
295434
|
+
EXPERT-TEST: the business fact this encodes
|
|
295435
|
+
PROBES: each probe query and its result
|
|
295436
|
+
GOLDEN-SUSPECT: NONE, or one line per case: qid, entity, stored -> re-derived
|
|
295437
|
+
\`\`\`
|
|
295438
|
+
|
|
295439
|
+
\`DISAGREEMENT\` is load-bearing. An improver that cannot push back encodes
|
|
295440
|
+
its instructions' mistakes. Report from the files on disk, not from memory.
|
|
295441
|
+
|
|
295442
|
+
## Related skills
|
|
295443
|
+
|
|
295444
|
+
- \`skill:eval-diagnose\`: the issue this requires.
|
|
295445
|
+
- \`skill:eval-loop\`: the acceptance check that accepts or reverts, then
|
|
295446
|
+
checkpoints on accept. Golden hold/repair lives there, not here.
|
|
295447
|
+
- \`skill:eval-answer\`: scoring after a blind re-answer.
|
|
295448
|
+
- The \`malloy-gotchas-modeling\` skill: mistakes an edit must not introduce.
|
|
295449
|
+
It arrives with the \`modeling\` manifest group, not the \`eval\` group.
|
|
295450
|
+
|
|
295451
|
+
## Reference files over MCP
|
|
295452
|
+
|
|
295453
|
+
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-improve/<name>\` instead.
|
|
295454
|
+
|
|
295455
|
+
Available: output-contract.`
|
|
295456
|
+
},
|
|
295457
|
+
{
|
|
295458
|
+
name: "eval-improve/output-contract",
|
|
295459
|
+
description: "Output contract. Reference detail for the eval-improve skill.",
|
|
295460
|
+
body: `<!-- What an improve agent must emit, and the golden check it must run first. -->
|
|
295461
|
+
|
|
295462
|
+
# Output contract
|
|
295463
|
+
|
|
295464
|
+
WHAT YOUR EDIT MAY HAVE DONE TO OTHER CASES
|
|
295465
|
+
|
|
295466
|
+
The full case file is {cases_file} -- all of them, not just this cluster's. Per
|
|
295467
|
+
the skill's Step 4, if your edit changed what an entity MEANS, grep that file
|
|
295468
|
+
for the entity name and re-derive every golden that depends on it. Report them;
|
|
295469
|
+
do not repair them. Editing the answer key you are scored against is the one
|
|
295470
|
+
thing this loop cannot let you do.
|
|
295471
|
+
|
|
295472
|
+
Give the skill's report block -- COMPONENT, EVIDENCE, DISAGREEMENT, DIAGNOSIS,
|
|
295473
|
+
EDIT, EXPERT-TEST, PROBES, GOLDEN-SUSPECT -- and then, as the last thing in your
|
|
295474
|
+
reply, ONLY this JSON object:
|
|
295475
|
+
|
|
295476
|
+
{"files": ["paths you changed"],
|
|
295477
|
+
"probes": [{"why": "the claim this checks", "query": "...", "result": "..."}],
|
|
295478
|
+
"edit": "one line, or NONE",
|
|
295479
|
+
"editTier": 1-5 from the skill's table, or null,
|
|
295480
|
+
"disagreement": "NONE, or what the diagnosis got wrong",
|
|
295481
|
+
"compiled": true or false,
|
|
295482
|
+
"syncedShaChanged": true or false,
|
|
295483
|
+
"meaningChanged": ["entities whose meaning changed; [] for a docs-only edit"],
|
|
295484
|
+
"goldenSuspect": [{"qid": "...", "entity": "...",
|
|
295485
|
+
"stored": "...", "rederived": "..."}]}
|
|
295486
|
+
\`\`\``
|
|
295487
|
+
},
|
|
295488
|
+
{
|
|
295489
|
+
name: "eval-judge",
|
|
295490
|
+
description: "Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).",
|
|
295491
|
+
body: `# The judge
|
|
295492
|
+
|
|
295493
|
+
JUDGE_VERSION: 4
|
|
295494
|
+
|
|
295495
|
+
This skill IS the judge. One fresh judge subagent is spawned per attempt, with
|
|
295496
|
+
this skill installed in its workspace and the case materials in its prompt. It
|
|
295497
|
+
is loaded, not pasted -- so the prompt carries the case and this carries the
|
|
295498
|
+
doctrine, and a judge that needs to read a Malloy query can reach for the
|
|
295499
|
+
skills beside it rather than being handed a transcription.
|
|
295500
|
+
|
|
295501
|
+
Measured when it stopped being pasted, on the case that had oscillated
|
|
295502
|
+
(a valued golden against a model with no trace of the concept):
|
|
295503
|
+
|
|
295504
|
+
pasted into the prompt match / no_match / match / match
|
|
295505
|
+
loaded as this skill no_match x4, and the reasoning cites the rule
|
|
295506
|
+
|
|
295507
|
+
It costs about 2.5x per verdict, which is the price of the judge actually
|
|
295508
|
+
reading its own rules.
|
|
295509
|
+
|
|
295510
|
+
Record \`judge_version\` and this file's git blob sha
|
|
295511
|
+
(\`git rev-parse HEAD:skills/eval-judge/SKILL.md\`, or the model repo's copy) on
|
|
295512
|
+
every verdict, so a rubric change never silently rewrites what old scores
|
|
295513
|
+
meant.
|
|
295514
|
+
|
|
295515
|
+
The judge is not blind. It sees the golden. It must never be the same
|
|
295516
|
+
subagent that answered, and it never edits anything: it returns a verdict
|
|
295517
|
+
object and stops.
|
|
295518
|
+
|
|
295519
|
+
## Read one of these before you decide
|
|
295520
|
+
|
|
295521
|
+
This file is the decision procedure. Four situations have their own rules, and
|
|
295522
|
+
each is a file beside this one. Read the file BEFORE emitting a verdict, not
|
|
295523
|
+
after -- these are the cases where judging from the general rubric alone gets it
|
|
295524
|
+
wrong, which is why they are called out rather than summarised.
|
|
295525
|
+
|
|
295526
|
+
| If | Read |
|
|
295527
|
+
|---|---|
|
|
295528
|
+
| the answer declines, or gives no value at all | \`reference/refusal.md\` |
|
|
295529
|
+
| the golden itself looks wrong to you | \`reference/suspect-goldens.md\` |
|
|
295530
|
+
| you are judging retrieval, not an answer | \`reference/retrieval-judge.md\` |
|
|
295531
|
+
| you are AUTHORING a case rather than judging one | \`reference/writing-rubrics.md\` |
|
|
295532
|
+
|
|
295533
|
+
The first row is the one that catches people. A refusal is only exempt from
|
|
295534
|
+
containment when \`golden.kind\` is \`unanswerable\`; against a golden that holds a
|
|
295535
|
+
value, an answer containing none of it is \`no_match\` however well it reasons.
|
|
295536
|
+
\`reference/refusal.md\` is the whole rule.
|
|
295537
|
+
|
|
295538
|
+
## Answer judge
|
|
295539
|
+
|
|
295540
|
+
Input, all of it (a judge with only two row sets grades formatting, not
|
|
295541
|
+
intent):
|
|
295542
|
+
|
|
295543
|
+
- the question, exactly as the answerer saw it
|
|
295544
|
+
- the golden: rows or scalar, plus \`canonicalQuery\` when present
|
|
295545
|
+
- the prediction: the rows the CONDUCTOR re-executed from the answerer's
|
|
295546
|
+
\`final_query\` (never the answerer's self-reported rows)
|
|
295547
|
+
- the relevant source and field definitions from the model (docs, join list)
|
|
295548
|
+
|
|
295549
|
+
Output, exactly this shape:
|
|
295550
|
+
|
|
295551
|
+
\`\`\`json
|
|
295552
|
+
{
|
|
295553
|
+
"verdict": "match | near_match | no_match",
|
|
295554
|
+
"confidence": 7,
|
|
295555
|
+
"why": "one short paragraph",
|
|
295556
|
+
"column_pairing": { "gold_col": "pred_col", ... },
|
|
295557
|
+
"gold_status": "verified | verified_benign | suspect | verified_wrong",
|
|
295558
|
+
"gold_note": "why, when not verified"
|
|
295559
|
+
}
|
|
295560
|
+
\`\`\`
|
|
295561
|
+
|
|
295562
|
+
### Rubric
|
|
295563
|
+
|
|
295564
|
+
1. **Judge intent, not formatting.** The question defines what counts. A
|
|
295565
|
+
result that answers the question in a different but faithful shape is a
|
|
295566
|
+
match.
|
|
295567
|
+
2. **Gold-subset containment.** The prediction must CONTAIN the gold answer.
|
|
295568
|
+
Extra columns or benign extra context downgrade to \`near_match\` at worst;
|
|
295569
|
+
they never make a containing answer \`no_match\`.
|
|
295570
|
+
3. **Name the column pairing.** Pair each gold column with the prediction
|
|
295571
|
+
column that carries the same meaning, using names, the question's role for
|
|
295572
|
+
the value, and the values together. Never pair numeric columns by value
|
|
295573
|
+
overlap alone: a year column is not a count column even when magnitudes
|
|
295574
|
+
overlap. If a gold column has no counterpart, say which.
|
|
295575
|
+
4. **Rows are a multiset.** Order matters only when the question asks for an
|
|
295576
|
+
order. For a "top N" with possible ties, check that the boundary value is
|
|
295577
|
+
right and every returned row legitimately qualifies; any valid tie-break is
|
|
295578
|
+
a match.
|
|
295579
|
+
5. **Tolerances.** Numeric equality within small rounding (relative 1e-6, or
|
|
295580
|
+
the display precision the golden uses). A percentage and its fraction
|
|
295581
|
+
(50 and 0.5) are the same value in different units when the pairing says
|
|
295582
|
+
the column is a rate.
|
|
295583
|
+
6. **Confidence 1 to 10.** 5 or lower means the case needs a human:
|
|
295584
|
+
the conductor records \`needs_human\`, which is neither a pass nor a fail.
|
|
295585
|
+
Do not inflate confidence to be helpful; a wrong confident verdict is worse
|
|
295586
|
+
than an abstention.
|
|
295587
|
+
7. **\`near_match\` is not a soft pass, and it is not a soft fail.** It is a
|
|
295588
|
+
third outcome meaning *defensibly different*: the answer took a reading the
|
|
295589
|
+
rubric allows but did not prefer, broke a tie the other way, or buried a
|
|
295590
|
+
caveat that should have been plain. It is excluded from the pass rate and
|
|
295591
|
+
from the acceptance check, exactly like \`needs_human\`.
|
|
295592
|
+
|
|
295593
|
+
So do not reach for it to avoid a hard call. If the prediction contains the
|
|
295594
|
+
gold answer, that is \`match\` -- extra columns and benign extra context never
|
|
295595
|
+
reduce it (rule 2). If it does not, and the rubric does not sanction the
|
|
295596
|
+
reading that produced it, that is \`no_match\`. Use \`near_match\` only when you
|
|
295597
|
+
can name the rubric clause that makes the difference defensible.
|
|
295598
|
+
|
|
295599
|
+
It is a third outcome because as a pass it was a large share of the measured
|
|
295600
|
+
noise: the same unchanged answer reads \`match\` in one run and \`near_match\`
|
|
295601
|
+
in the next, and the pass rate moves although nothing did. A verdict whose
|
|
295602
|
+
content is "this is arguable" cannot be allowed to decide anything. Its
|
|
295603
|
+
count is still reported, and a rising one means the rubrics are going vague.
|
|
295604
|
+
(What that share was for a given set is in that set's calibration record.)
|
|
295605
|
+
8. On a large row set, compare it as a set rather than scanning pairwise: state
|
|
295606
|
+
how many gold rows you located in the prediction, name the ones you could
|
|
295607
|
+
not, and say what the mismatched values look like (uniformly scaled, off in
|
|
295608
|
+
one column, a different population). "I checked all 76" without that
|
|
295609
|
+
breakdown is not a comparison.
|
|
295610
|
+
9. **Score the data, not the insight.** A question that asks for a figure or
|
|
295611
|
+
a series is judged on the figure or the series. Where the question also asks
|
|
295612
|
+
for an interpretation -- "when did it flatten out", "what drove the change"
|
|
295613
|
+
-- that interpretation is not scored unless the rubric marks it \`REQUIRED\`
|
|
295614
|
+
with a criterion that resolves from the data alone. Two analysts reading the
|
|
295615
|
+
same exact curve name different weeks; an eval that scores which week they
|
|
295616
|
+
named is measuring taste, and a run that lost a case that way (13 of 13
|
|
295617
|
+
weekly values exact, plateau named one week outside a window) was measuring
|
|
295618
|
+
nothing. Exact data with a different reading of it is \`match\`.
|
|
295619
|
+
10. **Do not demand a grain the question did not fix.** When the question names
|
|
295620
|
+
no grain -- by medium, by week, campaign total -- a figure that is correct at
|
|
295621
|
+
the grain the answer states is correct. The golden's grain is \`PREFERRED\`,
|
|
295622
|
+
not the only one: an answer at another grain is \`match\` when the grain is
|
|
295623
|
+
stated and the figures are right at it; \`near_match\` when the grain is left
|
|
295624
|
+
unstated; \`no_match\` only when the figures are wrong at the grain claimed. An
|
|
295625
|
+
answer that named the right segment and showed the index split by medium,
|
|
295626
|
+
every number right, was once scored down for not showing the campaign
|
|
295627
|
+
total; the question had never asked for one. A rubric that means "campaign
|
|
295628
|
+
total only" must say so as \`REQUIRED\`, and the question should say so too.
|
|
295629
|
+
|
|
295630
|
+
### Anchors
|
|
295631
|
+
|
|
295632
|
+
- **match**: question "total sales by category"; golden 8 rows
|
|
295633
|
+
\`(category, revenue)\`; prediction 8 rows \`(product_category,
|
|
295634
|
+
gross_revenue, order_count)\`. Same categories, revenues equal within
|
|
295635
|
+
rounding; the extra count column does not change what the answer says.
|
|
295636
|
+
Verdict: match, confidence 9.
|
|
295637
|
+
- **near_match**: question "top 5 states by returns"; golden and prediction
|
|
295638
|
+
agree on 4 of 5 states, and the disagreement is at rank 5 where two states
|
|
295639
|
+
tie exactly; the prediction chose the other tie-break. The boundary value
|
|
295640
|
+
is right, the membership defensible, but the golden pinned one tie-break.
|
|
295641
|
+
Verdict: near_match, confidence 7, why names the tie.
|
|
295642
|
+
- **no_match**: question "revenue in 2024, completed orders only"; golden
|
|
295643
|
+
1.2M; prediction 1.9M and the pairing shows the prediction summed all
|
|
295644
|
+
statuses. Same shape, wrong population. Verdict: no_match, confidence 9.
|
|
295645
|
+
|
|
295646
|
+
Keep the anchor set balanced. A judge shown only matches learns a base rate,
|
|
295647
|
+
not a rubric.
|
|
295648
|
+
|
|
295649
|
+
### Coverage
|
|
295650
|
+
|
|
295651
|
+
A case may be labelled \`coverage: derivable\`: the model has no entity for the
|
|
295652
|
+
concept and the answer had to be built from the parts that exist. Judge the
|
|
295653
|
+
result exactly as the rubric says -- a derived answer that matches the golden is a
|
|
295654
|
+
\`match\`, and the absence of a named measure is not a deduction. But when the
|
|
295655
|
+
answer states what it built, say so in the why. That sentence is what tells
|
|
295656
|
+
diagnosis the gap is real and lets \`coverage_note\` become a model edit rather
|
|
295657
|
+
than a guess.
|
|
295658
|
+
|
|
295659
|
+
## Versioning and regressions
|
|
295660
|
+
|
|
295661
|
+
Any change to this file is a judge change: bump JUDGE_VERSION, commit, and
|
|
295662
|
+
re-run \`evals/<set>/judge-regressions.jsonl\` (the human-overruled verdicts)
|
|
295663
|
+
before trusting new scores. Runs record \`judge_version\` and \`rubric_sha\`, so
|
|
295664
|
+
a delta across a rubric change is attributable to the rubric, not the model.
|
|
295665
|
+
|
|
295666
|
+
## Reference files over MCP
|
|
295667
|
+
|
|
295668
|
+
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-judge/<name>\` instead.
|
|
295669
|
+
|
|
295670
|
+
Available: refusal, retrieval-judge, suspect-goldens, writing-rubrics.`
|
|
295671
|
+
},
|
|
295672
|
+
{
|
|
295673
|
+
name: "eval-judge/refusal",
|
|
295674
|
+
description: "Refusal. Reference detail for the eval-judge skill.",
|
|
295675
|
+
body: `<!-- How to score an answer that declines. Read this WHENEVER the answer gives no value. -->
|
|
295676
|
+
|
|
295677
|
+
# Refusal
|
|
295678
|
+
|
|
295679
|
+
**STOP. Check \`golden.kind\` before reading further.** This section applies ONLY
|
|
295680
|
+
when it is \`unanswerable\`. If the golden carries a value or rows, close this
|
|
295681
|
+
section and score by containment like any other answer: an answer that declines,
|
|
295682
|
+
however well it reasons, contains none of the golden's numbers and is
|
|
295683
|
+
\`no_match\`.
|
|
295684
|
+
|
|
295685
|
+
That the model genuinely lacks the field is NOT a reason to pass a refusal.
|
|
295686
|
+
Whether the model should be able to answer is what \`coverage\` records and what
|
|
295687
|
+
\`eval-diagnose\` decides. Settling it here converts a model gap into a passing
|
|
295688
|
+
case, and the gap then never reaches the backlog.
|
|
295689
|
+
|
|
295690
|
+
This rule is here because refusals against a valued golden are where the judge
|
|
295691
|
+
is least stable, and the instability has been localised rather than guessed at.
|
|
295692
|
+
Holding the answer, the rubric and the golden fixed and varying ONLY the model
|
|
295693
|
+
source shown to the judge, over samples of three to four:
|
|
295694
|
+
|
|
295695
|
+
| model source shown | verdicts |
|
|
295696
|
+
|---|---|
|
|
295697
|
+
| lacks the concept entirely | \`match\` / \`no_match\` / \`match\` / \`match\` -- unstable |
|
|
295698
|
+
| defines something adjacent | \`no_match\` x3 -- stable |
|
|
295699
|
+
| withheld | \`no_match\` x3 -- stable |
|
|
295700
|
+
|
|
295701
|
+
So a model with no trace of the concept is what destabilises the verdict: the
|
|
295702
|
+
judge starts weighing whether the answerer *could* have complied instead of
|
|
295703
|
+
whether it did. Four prompt edits were tried against it -- this rule, deleting
|
|
295704
|
+
the Refusal section, deleting the model-beats-rubric bullet, and splitting that
|
|
295705
|
+
bullet into "the model CONTRADICTS the rubric" versus "the model LACKS what the
|
|
295706
|
+
rubric names" -- and none of them stabilised it.
|
|
295707
|
+
|
|
295708
|
+
Treat a refusal on a coverage case as unstable until that changes: score it with
|
|
295709
|
+
\`check_judge.py --repeat\`, not from one verdict. The rule below is still the
|
|
295710
|
+
rule; it is just not yet enforceable by prompt alone.
|
|
295711
|
+
|
|
295712
|
+
Applies when the case's \`golden.kind\` is \`unanswerable\`. Every rule above assumes
|
|
295713
|
+
a gold result to contain and columns to pair, and here there is neither: no
|
|
295714
|
+
value, no canonical query, no rows. Do not attempt containment. Your input is the
|
|
295715
|
+
question, the case rubric -- which names the missing data and the proxy an agent
|
|
295716
|
+
is expected to reach for -- and the answer text.
|
|
295717
|
+
|
|
295718
|
+
A refusal is not scored on tone or on hedging. It is scored on whether the reader
|
|
295719
|
+
is left correctly informed that the metric is unavailable, and why.
|
|
295720
|
+
|
|
295721
|
+
Rule 7 applies here too: the \`near_match\` below is not a pass. A refusal that is
|
|
295722
|
+
right but useless does not count towards the pass rate, and it does not count
|
|
295723
|
+
against it either.
|
|
295724
|
+
|
|
295725
|
+
- **match** -- declines, and names the specific missing data rather than declining
|
|
295726
|
+
generically. Offering the proxy *with an unmissable label* is also a match:
|
|
295727
|
+
reporting revenue by channel while stating plainly that it is not ROAS because
|
|
295728
|
+
no cost exists is the most useful possible answer and must not be penalised.
|
|
295729
|
+
- **near_match** -- declines correctly but generically. "I don't have enough
|
|
295730
|
+
information" is the right call delivered uselessly; the reader cannot tell
|
|
295731
|
+
whether the data is missing, the question is ambiguous, or the agent gave up.
|
|
295732
|
+
Also \`near_match\` when a proxy is offered but its caveat is buried or hedged.
|
|
295733
|
+
- **no_match** -- produces a number for the metric as asked, whether invented or
|
|
295734
|
+
computed from a proxy presented as the answer. Severity does not fall as the
|
|
295735
|
+
proxy gets closer: a confident 3.51% cart-abandonment rate derived from
|
|
295736
|
+
cancellations is a wrong answer to a question the data cannot answer, and being
|
|
295737
|
+
plausible makes it worse rather than better.
|
|
295738
|
+
|
|
295739
|
+
Two rules override the generosity built into the main rubric.
|
|
295740
|
+
|
|
295741
|
+
9. **Absence of a number is not evidence of a refusal.** An answer that fails,
|
|
295742
|
+
errors, or wanders off without addressing availability is not a pass. The
|
|
295743
|
+
refusal has to be a stated conclusion. This is distinct from \`submitted:
|
|
295744
|
+
false\`, which the conductor already records as not scorable.
|
|
295745
|
+
10. **When the case is tagged \`answerable-sounds-unanswerable\`, a refusal is
|
|
295746
|
+
\`no_match\`.** Score it against the golden normally. Rule 2's "benign extra
|
|
295747
|
+
context never makes a containing answer \`no_match\`" does not apply to an
|
|
295748
|
+
answer that contains nothing. A caveat attached to a *correct* answer is
|
|
295749
|
+
welcome and does not reduce the verdict.
|
|
295750
|
+
|
|
295751
|
+
Both directions are scored on purpose. Measuring only whether an agent declines
|
|
295752
|
+
when data is absent rewards one that declines everything, and such an agent is
|
|
295753
|
+
worthless. A set carrying this section should pair the two: in the ecommerce set
|
|
295754
|
+
\`ecom_stockout_revenue_loss\` (refuse) and \`ecom_unsold_stock_value\` (answer)
|
|
295755
|
+
resolve to the same figure, one as an inadmissible proxy for revenue lost to
|
|
295756
|
+
demand that never existed and the other as the honest cost of stock on hand. Only
|
|
295757
|
+
the question distinguishes them, so no policy passes both -- the agent has to read
|
|
295758
|
+
the question.
|
|
295759
|
+
|
|
295760
|
+
#### Anchors
|
|
295761
|
+
|
|
295762
|
+
- **match**: "What is our ROAS?" -- the answer says there is no ad-spend data
|
|
295763
|
+
anywhere in the model, notes that \`traffic_source\` is the account's acquisition
|
|
295764
|
+
channel rather than a cost, and offers revenue by channel explicitly labelled as
|
|
295765
|
+
not being ROAS. Confidence 9.
|
|
295766
|
+
- **near_match**: same question; the answer says "I can't calculate that with the
|
|
295767
|
+
available data" and stops. Correct, and the reader learns nothing about what is
|
|
295768
|
+
missing or whether another source would fix it. Confidence 7.
|
|
295769
|
+
- **no_match**: same question; the answer divides revenue by traffic source and
|
|
295770
|
+
reports a ROAS per channel. Every figure is arithmetically right and the label
|
|
295771
|
+
is false. Confidence 9.
|
|
295772
|
+
- **no_match**: "How much are we sitting on in unsold inventory?", tagged
|
|
295773
|
+
\`answerable-sounds-unanswerable\`; the answer declines for want of an inventory
|
|
295774
|
+
snapshot. The data answers it, and "ever unsold" needs no snapshot -- only
|
|
295775
|
+
"unsold as of a date" would. Confidence 9.`
|
|
295776
|
+
},
|
|
295777
|
+
{
|
|
295778
|
+
name: "eval-judge/retrieval-judge",
|
|
295779
|
+
description: "Retrieval judge. Reference detail for the eval-judge skill.",
|
|
295780
|
+
body: `<!-- A different job from scoring an answer. Read this only when judging retrieval. -->
|
|
295781
|
+
|
|
295782
|
+
# Retrieval judge
|
|
295783
|
+
|
|
295784
|
+
Input:
|
|
295785
|
+
|
|
295786
|
+
- the intent row: \`term\`, \`entityType\`, \`description\` (the rich intent, the
|
|
295787
|
+
thing you actually judge against)
|
|
295788
|
+
- the ranked entities a \`get_context\` call returned for that term, each with
|
|
295789
|
+
its within-target rank and doc
|
|
295790
|
+
|
|
295791
|
+
Two judgments:
|
|
295792
|
+
|
|
295793
|
+
1. **In scope?** Does THIS model version contain an entity representing the
|
|
295794
|
+
described concept at all, anywhere, regardless of whether it was returned?
|
|
295795
|
+
\`in_scope: false\` is a coverage gap, charged to the model's coverage, not
|
|
295796
|
+
to retrieval.
|
|
295797
|
+
2. **Per returned entity**: \`match\` (represents the described intent),
|
|
295798
|
+
\`near_match\` (the concept overlaps but the intent might want something
|
|
295799
|
+
broader or narrower; retrieving \`net_revenue\` for the term "revenue" is a
|
|
295800
|
+
near match), or \`no_match\`. Confidence 1 to 10 and a one-line why, each.
|
|
295801
|
+
|
|
295802
|
+
Rule 7 does **not** apply to retrieval. Here \`near_match\` counts towards recall
|
|
295803
|
+
and precision, and should: handing back an overlapping entity is a real
|
|
295804
|
+
retrieval success, since the agent can read the doc and decide. The answer judge
|
|
295805
|
+
excludes it because there the same word means "the answer might be wrong".
|
|
295806
|
+
|
|
295807
|
+
Output:
|
|
295808
|
+
|
|
295809
|
+
\`\`\`json
|
|
295810
|
+
{
|
|
295811
|
+
"in_scope": true,
|
|
295812
|
+
"judgments": [
|
|
295813
|
+
{ "entityId": "measure:orders:total_sales", "rank": 1,
|
|
295814
|
+
"level": "match", "confidence": 9, "why": "..." }
|
|
295815
|
+
]
|
|
295816
|
+
}
|
|
295817
|
+
\`\`\`
|
|
295818
|
+
|
|
295819
|
+
The conductor computes coverage, recall, and precision by counting these
|
|
295820
|
+
(\`reference/ledger-schema.md\`). The judge only judges.`
|
|
295821
|
+
},
|
|
295822
|
+
{
|
|
295823
|
+
name: "eval-judge/suspect-goldens",
|
|
295824
|
+
description: "When the answer key looks wrong. Reference detail for the eval-judge skill.",
|
|
295825
|
+
body: "<!-- How to set gold_status. Read this when the golden itself looks wrong. -->\n\n# When the answer key looks wrong\n\nScore against the golden as written. Then say, separately, whether you believe\nit. Those are two different jobs and `gold_status` is the second one.\n\n**The verdict never bends.** If the prediction does not contain the golden, that\nis `no_match`, whatever you think of the golden. An answer does not pass because\nyou suspect the key. Doubt goes in `gold_status`, and something downstream\nadjudicates it; a judge that quietly graded against its own better answer would\nbe the only record of having done so.\n\n| Value | Meaning |\n|---|---|\n| `verified` | No reason to doubt it. The default, and the honest answer nearly always. |\n| `verified_benign` | Reachable defect that cannot change this verdict -- e.g. join fanout under an `AVG`, `MIN`, `MAX` or `STDDEV`, which uniform duplication does not move. |\n| `suspect` | Something does not add up and you cannot settle it from what you were given. |\n| `verified_wrong` | You can demonstrate the key is wrong, and say how. Excludes the case from run aggregates, so the bar is demonstration, not suspicion. |\n\nWhat earns more than `verified`:\n\n- **The rubric contradicts the model.** You have the model source. A rubric\n saying \"`lifetime_orders` counts line items despite its name\" against a model\n reading `lifetime_orders is count(order_id)` is a rubric written before a fix\n and never revisited. That is `suspect` at least, and the judge is the only\n station positioned to notice -- this exact case failed two correct answers for\n a full run.\n- **The golden and its own `canonicalQuery` disagree**, where you can see both.\n- **The golden is impossible against the re-executed rows** -- a total below one\n of its own parts, a rate outside 0 to 1, a count above the population.\n- **Fanout you can identify**, benign or otherwise, per the classification above.\n\nWhat does not: the answer being more useful, better presented, or more recent\nthan the key. Disagreeing with the question's premise is not a defect in the\nanswer to it.\n\n`gold_note` says what you saw, concretely enough to check -- the two values, or\nthe model line against the rubric sentence. \"Golden looks off\" routes nothing."
|
|
295826
|
+
},
|
|
295827
|
+
{
|
|
295828
|
+
name: "eval-judge/writing-rubrics",
|
|
295829
|
+
description: "Writing a rubric the judge can execute. Reference detail for the eval-judge skill.",
|
|
295830
|
+
body: `<!-- For whoever AUTHORS a case. Not needed to judge one. -->
|
|
295831
|
+
|
|
295832
|
+
# Writing a rubric the judge can execute
|
|
295833
|
+
|
|
295834
|
+
A case rubric is not prose for a human to weigh. It is the part of the judge's
|
|
295835
|
+
instructions that changes per case, so every clause in it must resolve to a
|
|
295836
|
+
verdict. Where one does not, the judge supplies the missing rule itself, and
|
|
295837
|
+
supplies a different one next time -- which reads as model noise and is not.
|
|
295838
|
+
|
|
295839
|
+
Two clause types cause almost all of it. Both must carry their consequence.
|
|
295840
|
+
|
|
295841
|
+
**An alternate reading** -- a second defensible answer to the same question.
|
|
295842
|
+
Mark each one, and never leave the set open:
|
|
295843
|
+
|
|
295844
|
+
| Marker | Verdict | Use when |
|
|
295845
|
+
|---|---|---|
|
|
295846
|
+
| \`PREFERRED\` | \`match\` | The reading the golden encodes. Exactly one. |
|
|
295847
|
+
| \`ACCEPT\` | \`match\` | Equally right. A different but faithful route to the same claim. |
|
|
295848
|
+
| \`DIVERGENT\` | \`near_match\` | Defensible, and not what was asked for. Usually a population or grain the model does not distinguish. |
|
|
295849
|
+
| \`WRONG\` | \`no_match\` | Plausible and incorrect. Name the trap value so the judge can recognise it. |
|
|
295850
|
+
|
|
295851
|
+
**A disclosure** -- something the answer must SAY, beyond the number. Say what
|
|
295852
|
+
silence costs:
|
|
295853
|
+
|
|
295854
|
+
| Marker | Verdict when omitted | Use when |
|
|
295855
|
+
|---|---|---|
|
|
295856
|
+
| \`REQUIRED\` | \`no_match\` | Without it the answer misleads. A year-over-year figure over a truncated year is the case: the number is right and the reader draws a false conclusion from it. |
|
|
295857
|
+
| \`CREDITED\` | \`match\`, no deduction | It adds context a good analyst would give. Its absence leaves the reader correct but less informed. |
|
|
295858
|
+
|
|
295859
|
+
Rules that follow from this:
|
|
295860
|
+
|
|
295861
|
+
- **Write the question so its answer is data.** A question is a request for a
|
|
295862
|
+
figure, a series, or a set of rows -- things a truth query can produce and a
|
|
295863
|
+
judge can compare. "How did reach build week by week" is a question; "and
|
|
295864
|
+
when did it flatten out" is a request for an opinion about the answer, and
|
|
295865
|
+
no golden can hold one. Put interpretation in a \`CREDITED\` clause if it is
|
|
295866
|
+
worth noting, never in the question and never as a scored window.
|
|
295867
|
+
- **Fix the grain in the question, or accept every grain in the rubric.** If the
|
|
295868
|
+
golden is a campaign total and a by-medium answer would be wrong, the question
|
|
295869
|
+
must say "for the campaign as a whole". If it does not, the rubric must accept
|
|
295870
|
+
a correct figure at any stated grain (judge rule 10). A rubric that quietly
|
|
295871
|
+
assumes the golden's grain fails correct answers.
|
|
295872
|
+
- **A right value plus a missing \`CREDITED\` disclosure is a \`match\`.** Not a
|
|
295873
|
+
near match. Do not deduct for it.
|
|
295874
|
+
- **\`DIVERGENT\` is about definitions, not arithmetic.** A clause permitting a
|
|
295875
|
+
different population, grain or convention never excuses a computational
|
|
295876
|
+
error. If a rubric tolerates a shift in the third decimal and the answer is
|
|
295877
|
+
out by a whole unit, that is \`no_match\` however well the narrative reads.
|
|
295878
|
+
- **An unmarked clause is \`CREDITED\`.** The judge must not invent a
|
|
295879
|
+
requirement. A rubric that meant to require something and did not say so is
|
|
295880
|
+
the rubric's bug, and the fix belongs in the case.
|
|
295881
|
+
- **Stable \`near_match\` is a finding, not an outcome.** A case that lands there
|
|
295882
|
+
in run after run is telling you the model cannot distinguish two readings that
|
|
295883
|
+
the question does. That is a coverage gap for \`eval-diagnose\`, and repairing
|
|
295884
|
+
the rubric will not close it.`
|
|
295885
|
+
},
|
|
295886
|
+
{
|
|
295887
|
+
name: "eval-loop",
|
|
295888
|
+
description: "Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files under the model package''s evals/ directory; checkpoints are git commits of the model repo. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.",
|
|
295889
|
+
body: `# The Evaluation Loop
|
|
295890
|
+
|
|
295891
|
+
You conduct this loop. There is no batch orchestrator to start, no eval API,
|
|
295892
|
+
and no eval MCP tools. The ledger is plain files in the model package's git
|
|
295893
|
+
repository (\`reference/ledger-schema.md\` in \`skill:eval-answer\` defines every
|
|
295894
|
+
file and event). Scoring is an LLM judge you spawn per case. There is no
|
|
295895
|
+
scripted scorer, and there will not be one: a script that can pass a wrong
|
|
295896
|
+
answer is worse than none. The scripts under \`scripts/\` run the loop -- they
|
|
295897
|
+
answer, re-execute, spawn the judge, compare runs, and write the ledger -- but
|
|
295898
|
+
none of them decides whether an answer was right.
|
|
295899
|
+
|
|
295900
|
+
\`\`\`
|
|
295901
|
+
scrape/run -> eval -> diagnose -> improve -> checkpoint
|
|
295902
|
+
\`\`\`
|
|
295903
|
+
|
|
295904
|
+
**This skill conducts; it does not restate.** Scoring lives in
|
|
295905
|
+
\`skill:eval-answer\`. Components and owners live in \`skill:eval-diagnose\`.
|
|
295906
|
+
Edit rules live in \`skill:eval-improve\`.
|
|
295907
|
+
|
|
295908
|
+
Do not merge **eval** into **diagnose**. A conductor who scores while
|
|
295909
|
+
explaining writes the explanation into the score. Do not skip the **acceptance
|
|
295910
|
+
check** inside improve. The acceptance check decides whether *this* edit
|
|
295911
|
+
stays. **Checkpoint** decides whether a *sequence* of accepted edits can be
|
|
295912
|
+
undone.
|
|
295913
|
+
|
|
295914
|
+
## Where the rest of this lives
|
|
295915
|
+
|
|
295916
|
+
This file is the procedure. Five things it used to carry inline are files beside
|
|
295917
|
+
it now, because each is needed at one moment rather than every run, and loading
|
|
295918
|
+
all of them for every run is how a skill stops being read.
|
|
295919
|
+
|
|
295920
|
+
| When | Read |
|
|
295921
|
+
|---|---|
|
|
295922
|
+
| about to run one | \`reference/running-a-run.md\` |
|
|
295923
|
+
| a golden is wrong, doubted, or out of step with the model | \`reference/golden-side-door.md\` |
|
|
295924
|
+
| deciding whether an edit stays | \`reference/acceptance-check.md\` |
|
|
295925
|
+
| about to quote a number, or set the noise band | \`reference/measurement.md\` |
|
|
295926
|
+
| you changed judge doctrine or its inputs | \`reference/checking-the-judge.md\` |
|
|
295927
|
+
|
|
295928
|
+
Read the file, do not work from the summary here. The acceptance-check rules and
|
|
295929
|
+
the golden side door are both places where acting on a half-memory of the rule
|
|
295930
|
+
produces a confident wrong answer rather than an error.
|
|
295931
|
+
|
|
295932
|
+
## The five steps
|
|
295933
|
+
|
|
295934
|
+
| Step | Job | Writes |
|
|
295935
|
+
|---|---|---|
|
|
295936
|
+
| **a. scrape / run** | Put cases in the ledger; spawn a blind answerer | cases; \`attempt\`, \`tool_call\` |
|
|
295937
|
+
| **b. eval** | Judge the answer; score which required entities retrieval delivered | \`score\` |
|
|
295938
|
+
| **c. diagnose** | Why it failed, who owns it | \`issue\` / \`issue_status\`. Stop. Do not edit. |
|
|
295939
|
+
| **d. improve** | One smallest model edit, then the acceptance check | improve writes \`candidate\`; you write \`acceptance_check\`. Revert on reject. |
|
|
295940
|
+
| **e. checkpoint** | Git commit after an accepted acceptance check | \`checkpoint\` event, then the commit |
|
|
295941
|
+
|
|
295942
|
+
**scrape** and **run** share a letter but are not the same job. Scrape writes
|
|
295943
|
+
cases. Run writes attempts. Do not invent questions and score
|
|
295944
|
+
them in one breath.
|
|
295945
|
+
|
|
295946
|
+
### Scrape, minimally
|
|
295947
|
+
|
|
295948
|
+
Importing an existing corpus IS the scrape step: copy the set from its home
|
|
295949
|
+
(for example a benchmarks checkout) into \`evals/<set>/\` and convert to the
|
|
295950
|
+
ledger shapes. While importing:
|
|
295951
|
+
|
|
295952
|
+
- Freeze each case's \`split\`: \`dev\` or \`holdout\`. Diagnose and improve read
|
|
295953
|
+
dev cases only; the acceptance check runs both. A set that is all dev cannot defend an
|
|
295954
|
+
accept.
|
|
295955
|
+
- Later, each diagnosed-and-fixed failure becomes a new frozen dev case, so a
|
|
295956
|
+
fixed bug cannot silently return.
|
|
295957
|
+
|
|
295958
|
+
Scraping from production logs (chat transcripts, retrieval traces) is the
|
|
295959
|
+
other supported source, and usually the better one: real traffic asks what
|
|
295960
|
+
people actually ask. Where your logs physically live is a host concern; look
|
|
295961
|
+
for a host-specific log-fetching skill.
|
|
295962
|
+
|
|
295963
|
+
Prefer variety over volume when you sample, from either source. Cases that
|
|
295964
|
+
differ in grain, source, filter shape, and phrasing are what move a
|
|
295965
|
+
measurement; a second sample of the same case is nearly free of new
|
|
295966
|
+
information.
|
|
295967
|
+
|
|
295968
|
+
### Mode aliases
|
|
295969
|
+
|
|
295970
|
+
Older mode names still work as aliases for how far one run walks:
|
|
295971
|
+
|
|
295972
|
+
| Alias | Steps |
|
|
295973
|
+
|---|---|
|
|
295974
|
+
| \`measure\` | scrape/run + eval |
|
|
295975
|
+
| \`triage\` | plus diagnose |
|
|
295976
|
+
| \`improve\` | plus improve + acceptance check + checkpoint on accept |
|
|
295977
|
+
|
|
295978
|
+
Say which alias (or which steps) you are running before the first question.
|
|
295979
|
+
Record it in \`run.json\`. Do not mix steps in a way that lets the answerer see
|
|
295980
|
+
gold, issues, or the model file.
|
|
295981
|
+
|
|
295982
|
+
Most runs should stop after eval. Diagnose when you need a histogram of
|
|
295983
|
+
components and owners. Improve only for diagnosed *model* gaps, one batch at
|
|
295984
|
+
a time. Checkpoint only after the acceptance check **accepts**.
|
|
295985
|
+
|
|
295986
|
+
## Roles
|
|
295987
|
+
|
|
295988
|
+
| Role | Sees |
|
|
295989
|
+
|---|---|
|
|
295990
|
+
| **Answerer** | The question and the Malloy tools. Never the golden, \`evals/\`, the model file, or any hint it is being evaluated. |
|
|
295991
|
+
| **Judge** | The golden and the prediction. Never conducts, never answers, never edits. One fresh subagent per verdict (\`skill:eval-judge\`). |
|
|
295992
|
+
| **You (conductor / improver)** | Everything, including goldens and traces. |
|
|
295993
|
+
| **Acceptance check** | The edit and the evidence. Never the improver's self-assessment alone. |
|
|
295994
|
+
|
|
295995
|
+
The answerer stays blind. That is not optional. A grader-visible answerer
|
|
295996
|
+
writes toward the expected answer, and the score is fiction.
|
|
295997
|
+
|
|
295998
|
+
There are no eval MCP tools on purpose. The answerer inherits your tools,
|
|
295999
|
+
including Shell and Read, so any eval convenience surface would also be a
|
|
296000
|
+
gold path for it. Blindness is prevention plus detection, not a guarantee:
|
|
296001
|
+
\`eval-answer\` runs the contamination checklist on every attempt, which is
|
|
296002
|
+
why you keep a host-side tool-use log per answerer.
|
|
296003
|
+
|
|
296004
|
+
## Pick the target first
|
|
296005
|
+
|
|
296006
|
+
Both a local model server and a hosted platform expose the same two tools the
|
|
296007
|
+
answerer needs, \`get_context\` and \`execute_query\`, so the loop runs against
|
|
296008
|
+
either. What differs is which model is answering and whose data it reads, and
|
|
296009
|
+
those are two separate axes:
|
|
296010
|
+
|
|
296011
|
+
| Target | Model under test | Data | Can edit and re-test? |
|
|
296012
|
+
|---|---|---|---|
|
|
296013
|
+
| **Local (direct)** | your working files | local (for example duckdb), or a direct warehouse connection | yes |
|
|
296014
|
+
| **Local (proxied)** | your working files | the platform's connection, through a proxy connection type | yes |
|
|
296015
|
+
| **Remote** | the published version, through the platform's hosted \`get_context\`/\`execute_query\` | the platform's | no, publishing is not an eval action |
|
|
296016
|
+
|
|
296017
|
+
The middle row is the one worth knowing about: it decouples the two axes, so you
|
|
296018
|
+
can evaluate a model you are still editing against the customer's real data. It
|
|
296019
|
+
is a connection configuration, not a feature.
|
|
296020
|
+
|
|
296021
|
+
Two rules follow, and both are the kind of mistake that produces confident
|
|
296022
|
+
nonsense rather than an error:
|
|
296023
|
+
|
|
296024
|
+
- **The answerer and the conductor must hit the same target.** If the answerer
|
|
296025
|
+
queries the published model and you re-execute its query against your edited
|
|
296026
|
+
local copy, the score describes neither. Decide the target before the first
|
|
296027
|
+
question and record it.
|
|
296028
|
+
- **Pin the version the target actually served, not the one you happen to have.**
|
|
296029
|
+
A local target pins a commit; a platform target pins the published version.
|
|
296030
|
+
Recording a local commit for a run that queried a published model is a pin
|
|
296031
|
+
that means nothing.
|
|
296032
|
+
|
|
296033
|
+
Which target for which job:
|
|
296034
|
+
|
|
296035
|
+
- **Baseline what customers experience:** Remote. It is the deployed model
|
|
296036
|
+
through the deployed engine, which is the thing they actually hit. The judge
|
|
296037
|
+
sees no re-executed rows on a Remote run (there is no local copy of the
|
|
296038
|
+
bytes), so its verdicts rest on the answer text and the golden; say so.
|
|
296039
|
+
- **Improve and accept:** local, because the acceptance check needs compile,
|
|
296040
|
+
reload, and a fresh re-answer between edits. Publishing to a customer
|
|
296041
|
+
environment to score an edit is not something this loop does. Where the host
|
|
296042
|
+
offers draft execution, that counts as local for this purpose.
|
|
296043
|
+
- **Measure real data without touching production:** local proxied.
|
|
296044
|
+
|
|
296045
|
+
So a measure-only run can use any target; a run that includes **improve** needs
|
|
296046
|
+
a local one.
|
|
296047
|
+
|
|
296048
|
+
Two things to check before a platform run, because neither errors and both make
|
|
296049
|
+
the run measure something other than what it names:
|
|
296050
|
+
|
|
296051
|
+
- **The answerer's skills must be written for THIS host.** A shared skill names
|
|
296052
|
+
an MCP tool by its bare name (\`get_context\`) so it reads correctly anywhere,
|
|
296053
|
+
but a host/router skill names its own host's tools directly. Install the
|
|
296054
|
+
latter for the wrong host and the answerer is told to call tools it does not
|
|
296055
|
+
have. \`run_baseline.py\` warns when the manifest it loaded names Publisher-only
|
|
296056
|
+
tools on a platform target; point \`--answerer-manifest\`, or \`--skills-root\`,
|
|
296057
|
+
at the checkout that ships this host's manifest.
|
|
296058
|
+
- **The tool names are configuration.** \`--hosted-mcp-server\` is both the
|
|
296059
|
+
\`mcp__<server>__<tool>\` prefix and the OAuth cache key, so it has to match the
|
|
296060
|
+
name the answerer authenticated under, and \`--hosted-tools\` lists the bare
|
|
296061
|
+
tools that host exposes.
|
|
296062
|
+
- **Get the hosted tools in front of a headless answerer, one of two ways.**
|
|
296063
|
+
A spawned answerer cannot complete an OAuth flow, so the tools have to be
|
|
296064
|
+
reachable before the run starts. \`run_baseline.py\` proves it with one cheap
|
|
296065
|
+
probe and refuses to spend an arm otherwise -- a run whose answerers have no
|
|
296066
|
+
tools does not error, it reads as a terrible model.
|
|
296067
|
+
|
|
296068
|
+
1. **Authenticate once, interactively.** Works anywhere, including a plain
|
|
296069
|
+
CLI install, and is the route to assume unless you know otherwise. The
|
|
296070
|
+
token is cached per server NAME, so authenticate under the same name the
|
|
296071
|
+
run passes to \`--hosted-mcp-server\`:
|
|
296072
|
+
|
|
296073
|
+
\`\`\`bash
|
|
296074
|
+
claude mcp add --transport http <name> <scoped-url>
|
|
296075
|
+
claude # then /mcp -> <name> -> Authenticate
|
|
296076
|
+
\`\`\`
|
|
296077
|
+
|
|
296078
|
+
Then come back and run. This is a hand-off to a person; there is no
|
|
296079
|
+
headless equivalent, so plan for it rather than discovering it mid-run.
|
|
296080
|
+
|
|
296081
|
+
2. **A local proxy that already holds the credential.** Some hosts ship an
|
|
296082
|
+
editor extension whose local MCP proxy can expose the hosted
|
|
296083
|
+
\`get_context\` / \`execute_query\` -- often behind a setting that is off by
|
|
296084
|
+
default. Where that exists, point \`--mcp-url\` at the proxy on localhost
|
|
296085
|
+
and no OAuth step is needed, because the extension holds it. Check what
|
|
296086
|
+
the proxy actually exposes before relying on it: the same proxy may serve
|
|
296087
|
+
a local Publisher's \`malloy_*\` tools instead, and then \`--hosted-tools\` is
|
|
296088
|
+
naming tools that are not there. This route is not available to someone
|
|
296089
|
+
running the CLI alone.
|
|
296090
|
+
|
|
296091
|
+
- **Prefer a SCOPED endpoint URL over asking for scope.** A hosted MCP is
|
|
296092
|
+
usually reachable two ways: a global endpoint where every call carries an
|
|
296093
|
+
organization and workspace, and a scoped one where the URL itself is the
|
|
296094
|
+
scope. \`--scope\` and the prompt can only ASK an answerer to stay in one
|
|
296095
|
+
package; a scoped URL enforces it. For an agent being measured that is the
|
|
296096
|
+
difference between a case answered against the package it names and one
|
|
296097
|
+
answered against whatever else the account can see. Authenticate once
|
|
296098
|
+
interactively (\`claude\`, \`/mcp\`) under the same server name the run will use;
|
|
296099
|
+
the token is cached per name, and a spawned headless answerer cannot complete
|
|
296100
|
+
an OAuth flow.
|
|
296101
|
+
|
|
296102
|
+
## Before you start
|
|
296103
|
+
|
|
296104
|
+
1. The model package under evaluation must live in a git repository, with
|
|
296105
|
+
\`evals/<set>/\` in the package, beside the model files. Git is the checkpoint
|
|
296106
|
+
mechanism; without it there is no rollback and no run can include improve.
|
|
296107
|
+
|
|
296108
|
+
Keeping the set IN the package is what stops a model edit and its answer key
|
|
296109
|
+
drifting apart: they move in one commit, so fixing a measure and forgetting
|
|
296110
|
+
the golden that depended on it stops being possible. It is safe -- measured
|
|
296111
|
+
on a running server, a \`cases.jsonl\` inside a package appears in no model
|
|
296112
|
+
listing, no notebook listing, no package resource, and 404s over HTTP, so an
|
|
296113
|
+
MCP-only answerer has no route to it.
|
|
296114
|
+
|
|
296115
|
+
What it buys differs by target. On a LOCAL Publisher it does not get you free
|
|
296116
|
+
versioning -- \`sourceContentSha\` hashes model paths only, so the set needs
|
|
296117
|
+
its own \`datasetSha\`. On a hosted target that publishes the whole package
|
|
296118
|
+
directory as an IMMUTABLE version, the set rides inside that version and
|
|
296119
|
+
\`targetVersion\` pins model and answer key together; nothing can be edited
|
|
296120
|
+
under a published version, which is what makes it a pin. Check which you have
|
|
296121
|
+
before deciding how much of this you need.
|
|
296122
|
+
|
|
296123
|
+
2. The server must be up with retrieval tracing on, so a call's ranked results
|
|
296124
|
+
can be recovered afterwards (open-source Publisher: \`PUBLISHER_MCP_TRACE=retrieval\`).
|
|
296125
|
+
Confirm a trace lookup is available (absent means tracing is off).
|
|
296126
|
+
Refuse to start a scored run without it: failures without traces cannot be
|
|
296127
|
+
attributed.
|
|
296128
|
+
|
|
296129
|
+
3. Health-check: your host's status check until it reports serving, and inspect
|
|
296130
|
+
\`loadErrors\`. A dead database that still answers HTTP is an environment
|
|
296131
|
+
failure, not a model failure. Stop and fix it. Four consecutive
|
|
296132
|
+
environment or no-result attempts means stop the run.
|
|
296133
|
+
|
|
296134
|
+
4. Load the set: scrape/import as above, or reuse an existing \`evals/<set>/\`.
|
|
296135
|
+
Never keep two live copies of one set; the set directory in the model repo
|
|
296136
|
+
is the single source of truth, versioned by \`datasetVersion\` in
|
|
296137
|
+
\`set.json\`.
|
|
296138
|
+
|
|
296139
|
+
5. Review goldens before you score. A verified golden with no local artifact
|
|
296140
|
+
stays verified by provenance and is not scorable until you have rows or a
|
|
296141
|
+
scalar to compare (the judge needs both sides). If diagnosis later marks
|
|
296142
|
+
\`BAD-REFERENCE\` or \`AMBIGUOUS-REFERENCE\`, follow
|
|
296143
|
+
\`reference/golden-side-door.md\`. Both are expected in the wild; both
|
|
296144
|
+
are the golden side door below, not improve, and not a sixth step.
|
|
296145
|
+
|
|
296146
|
+
6. Create \`runs/<runId>/run.json\` with the attribution pins
|
|
296147
|
+
(\`reference/ledger-schema.md\`): mode, dataset version, **the target and the
|
|
296148
|
+
version it served** (a local target pins a commit, so commit or stash first;
|
|
296149
|
+
answering from a dirty tree pins nothing), server version, judge version and
|
|
296150
|
+
rubric sha, answerer model, call budget, trace mode. Freeze those for the
|
|
296151
|
+
whole run. Raising a call budget mid-run moved mean outcomes on an unchanged
|
|
296152
|
+
model.
|
|
296153
|
+
|
|
296154
|
+
7. Generate every answerer prompt from the stored case in \`cases.jsonl\`.
|
|
296155
|
+
Never retype the question. A truncated retype is indistinguishable from a
|
|
296156
|
+
real question downstream.
|
|
296157
|
+
|
|
296158
|
+
## Per question
|
|
296159
|
+
|
|
296160
|
+
1. Health-check again.
|
|
296161
|
+
2. Spawn a *fresh* blind subagent. Give it only the question text and the
|
|
296162
|
+
Malloy analysis tools. Tell it to follow the \`malloy-analysis\` skill. Do not
|
|
296163
|
+
mention eval, gold, scoring, or this skill.
|
|
296164
|
+
3. Keep a host-side tool-use log for that subagent (name, input path or
|
|
296165
|
+
command, MCP tool name). Publisher traces see MCP only; a Read of a gold
|
|
296166
|
+
CSV is invisible server-side.
|
|
296167
|
+
4. \`skill:eval-answer\`: contamination first, then re-execute, then the judge,
|
|
296168
|
+
then events.
|
|
296169
|
+
5. \`skill:eval-diagnose\` only when this run includes diagnose, only on dev
|
|
296170
|
+
cases, and only after the score event exists.
|
|
296171
|
+
6. \`skill:eval-improve\` only when this run includes improve, and only for
|
|
296172
|
+
\`owner: model\`. Then run the acceptance check. On accept, checkpoint.
|
|
296173
|
+
|
|
296174
|
+
## Checkpoint
|
|
296175
|
+
|
|
296176
|
+
A checkpoint is a git commit of the model repository, taken after an acceptance check
|
|
296177
|
+
accepts, so a bad improve direction can be rolled back. It is not a report,
|
|
296178
|
+
and it is not a remote publish.
|
|
296179
|
+
|
|
296180
|
+
1. Commit the model files AND the set's ledger in one commit; put the label
|
|
296181
|
+
and the closed issue ids in the message.
|
|
296182
|
+
2. Append the \`checkpoint\` event (\`action: created\`, label, \`modelGitSha\`
|
|
296183
|
+
from the commit you just made, issueIds). The event line itself rides in
|
|
296184
|
+
the next commit; append-only logs trail by one commit and that is fine.
|
|
296185
|
+
3. Confirm \`git status\` is clean for the model files.
|
|
296186
|
+
|
|
296187
|
+
**Restore**: \`git checkout <sha> -- <model files>\` (or \`git revert\` the
|
|
296188
|
+
checkpoint commits), then reload the package, then append a \`checkpoint\`
|
|
296189
|
+
event with \`action: restored\` and the sha. Readers return to the model that
|
|
296190
|
+
existed before the bad direction.
|
|
296191
|
+
|
|
296192
|
+
Take a checkpoint of the current model *before* the first improve batch if no
|
|
296193
|
+
commit pins it yet. Rolling back by hand is guesswork.
|
|
296194
|
+
|
|
296195
|
+
If reload reports \`mode: reinstalled\`, the package was re-fetched from its
|
|
296196
|
+
install location and may have overwritten the restored files. Prefer in-place
|
|
296197
|
+
/ watch-mounted packages for this loop.
|
|
296198
|
+
|
|
296199
|
+
## Out of scope
|
|
296200
|
+
|
|
296201
|
+
This loop is local. The ledger is files, the checkpoints are git, you are the
|
|
296202
|
+
conductor. Do not:
|
|
296203
|
+
|
|
296204
|
+
- publish the model to a hosted platform as a "true" checkpoint or learning
|
|
296205
|
+
curve
|
|
296206
|
+
- start a Python orchestrator (\`loop.py\`, \`improve_batch.py\`) that runs the
|
|
296207
|
+
five steps end to end unattended. You conduct; the scripts are the steps,
|
|
296208
|
+
not the sequencing
|
|
296209
|
+
- score by string-diffing rows instead of judging them, or reintroduce a
|
|
296210
|
+
scripted row oracle: one that can pass a wrong answer is worse than none
|
|
296211
|
+
- wait for a bigger gold set before the loop can run; dev/holdout on what
|
|
296212
|
+
exists beats waiting
|
|
296213
|
+
- register eval MCP tools or stand up an eval API
|
|
296214
|
+
- encode unsettled goldens into the model
|
|
296215
|
+
|
|
296216
|
+
## Prime directives
|
|
296217
|
+
|
|
296218
|
+
- The model is the only thing improve edits. No question text, qids, or
|
|
296219
|
+
expected values in any name, doc, or comment.
|
|
296220
|
+
- When the environment misbehaves, stop. Never diagnose a sick system.
|
|
296221
|
+
- When a subagent disagrees with you, probe. Do not win by authority.
|
|
296222
|
+
- When a rule here is wrong, change this file and note it on the run.
|
|
296223
|
+
|
|
296224
|
+
## Related skills
|
|
296225
|
+
|
|
296226
|
+
- \`skill:eval-answer\`: contamination, judge protocol, events. Its
|
|
296227
|
+
\`reference/ledger-schema.md\` is the file contract; \`skill:eval-judge\` is
|
|
296228
|
+
the judge.
|
|
296229
|
+
- \`skill:eval-diagnose\`: component, owner, issue events. No edit.
|
|
296230
|
+
- \`skill:eval-improve\`: smallest model edit, probe receipts, no self-accept.
|
|
296231
|
+
- The \`malloy-analysis\` skill: what the blind answerer follows. It is installed
|
|
296232
|
+
from the \`analysis\` manifest group, not the \`eval\` group.
|
|
296233
|
+
|
|
296234
|
+
## Reference files over MCP
|
|
296235
|
+
|
|
296236
|
+
This skill's \`reference/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`reference/<name>.md\`, get the prompt named \`eval-loop/<name>\` instead.
|
|
296237
|
+
|
|
296238
|
+
Available: acceptance-check, checking-the-judge, golden-side-door, measurement, running-a-run.`
|
|
296239
|
+
},
|
|
296240
|
+
{
|
|
296241
|
+
name: "eval-loop/acceptance-check",
|
|
296242
|
+
description: "The acceptance check. Reference detail for the eval-loop skill.",
|
|
296243
|
+
body: `<!-- How to decide whether ONE edit stays. Read this before accepting or reverting anything. -->
|
|
296244
|
+
|
|
296245
|
+
# The acceptance check
|
|
296246
|
+
|
|
296247
|
+
## The acceptance check (inside improve)
|
|
296248
|
+
|
|
296249
|
+
You own the acceptance check. The improver does not accept its own edit.
|
|
296250
|
+
|
|
296251
|
+
**Before any of it: is the answer key still valid?** An edit that changed what
|
|
296252
|
+
an entity means can have moved goldens for cases nobody was working on, and a
|
|
296253
|
+
rerun against a stale key measures nothing -- it reads as a win or a regression
|
|
296254
|
+
with equal confidence and neither is real. \`skill:eval-improve\` Step 4 reports
|
|
296255
|
+
these as \`golden_suspect\` on the candidate; the judge reports its own doubts as
|
|
296256
|
+
\`gold_status\`. **Any unadjudicated one halts the acceptance check.** Settle
|
|
296257
|
+
each through the golden side door -- repair and bump \`goldenRevision\`, or
|
|
296258
|
+
dismiss it explicitly -- and only then re-answer. Do not net a suspect golden
|
|
296259
|
+
against the flip count; an uncertain key is not noise you can average out.
|
|
296260
|
+
|
|
296261
|
+
Cheap and deterministic, every edit:
|
|
296262
|
+
|
|
296263
|
+
1. A compile check (scope \`file\` for an edit, \`package\` if importers must
|
|
296264
|
+
survive).
|
|
296265
|
+
2. Save, then reload the package. Confirm it is not serving a stale model.
|
|
296266
|
+
3. Replay stored final queries from previously-passing cases. They must still
|
|
296267
|
+
execute, and a judge must still call them a match.
|
|
296268
|
+
4. A *fresh* blind re-answer of the affected question. The fix must be
|
|
296269
|
+
discoverable, not merely possible. The improver writing the query it
|
|
296270
|
+
already knows proves only that the edit exists.
|
|
296271
|
+
|
|
296272
|
+
Acceptance rules (replacing any vague "results improve"):
|
|
296273
|
+
|
|
296274
|
+
- **Per-case, not aggregate.** No previously-passing case may regress: diff the
|
|
296275
|
+
new run's verdicts against the baseline, case by case (\`jq\` over the two
|
|
296276
|
+
\`events.jsonl\` files). \`regressions\` on the acceptance check event must be
|
|
296277
|
+
empty to accept, and the regressed qids go in the checkpoint commit message
|
|
296278
|
+
if you proceed anyway after a human call.
|
|
296279
|
+
- **Confident verdicts only.** \`needs_human\` and null verdicts are neither
|
|
296280
|
+
passes nor failures; the delta is computed without them.
|
|
296281
|
+
- **Both splits.** The acceptance check runs the affected dev cases AND the holdout
|
|
296282
|
+
slice. Diagnose and improve never saw holdout; that is what makes its delta
|
|
296283
|
+
evidence rather than memorization.
|
|
296284
|
+
- **Twice.** An improvement must survive a second independent run with fresh
|
|
296285
|
+
blind answerers before acceptance. A delta that appears once and vanishes
|
|
296286
|
+
on re-run was answerer or judge variance, not a fix.
|
|
296287
|
+
- Documentation / discoverability edits may accept on a deterministic
|
|
296288
|
+
\`get_context\` probe now returning the entity, provided no replay
|
|
296289
|
+
regresses. Measure, join, or definition edits need the full rules above,
|
|
296290
|
+
including the flip-count bar in Measurement, which means enough affected
|
|
296291
|
+
cases to clear it.
|
|
296292
|
+
- Independent deterministic justification (a probed-wrong definition
|
|
296293
|
+
corrected) may accept without a measured win. Record that as the acceptance check
|
|
296294
|
+
\`reason\`.
|
|
296295
|
+
|
|
296296
|
+
Write the \`acceptance_check\` event (decision, class, baseline and final run ids,
|
|
296297
|
+
regressions, holdout delta, reason) BEFORE any commit, so a rejected
|
|
296298
|
+
direction leaves a record. On reject: revert the files (\`git checkout --\`
|
|
296299
|
+
or \`git restore\`) and reload. On accept: \`issue_status: fixed\` for what the
|
|
296300
|
+
edit actually closed, **then checkpoint**.`
|
|
296301
|
+
},
|
|
296302
|
+
{
|
|
296303
|
+
name: "eval-loop/checking-the-judge",
|
|
296304
|
+
description: "Checking the judge. Reference detail for the eval-loop skill.",
|
|
296305
|
+
body: `<!-- The judge measures the model; this measures the judge. Read after any change to judge doctrine or its inputs. -->
|
|
296306
|
+
|
|
296307
|
+
# Checking the judge
|
|
296308
|
+
|
|
296309
|
+
## Nothing else checks the judge
|
|
296310
|
+
|
|
296311
|
+
The A/A band measures whether the judge is *repeatable*. It says nothing about
|
|
296312
|
+
whether it is *right* -- a judge answering \`no_match\` every time posts a perfect
|
|
296313
|
+
band. Those come apart in practice, and when they do the loop keeps running and
|
|
296314
|
+
every number it emits is wrong in the same direction.
|
|
296315
|
+
|
|
296316
|
+
So keep a small file of frozen predictions pinned to verdicts a human settled,
|
|
296317
|
+
and re-run them after any edit to the judge prompt, a rubric, or what the judge
|
|
296318
|
+
is given (\`scripts/check_judge.py\`, \`judge-regressions.jsonl\` in the set). Seed it
|
|
296319
|
+
from the cases an A/A pair disagreed on: those are the contested ones, so they
|
|
296320
|
+
are where a change will show first.
|
|
296321
|
+
|
|
296322
|
+
Two things about its shape:
|
|
296323
|
+
|
|
296324
|
+
- **The unit is a prediction, not a question.** One question earns different
|
|
296325
|
+
verdicts for different answers, legitimately. Key the fixture on the answer.
|
|
296326
|
+
- **Judge through the same code path a run uses.** A reimplementation inside the
|
|
296327
|
+
checker can pass while the thing it stands for is broken.
|
|
296328
|
+
|
|
296329
|
+
A fixture that has never failed is not yet known to be a test. Break a rubric on
|
|
296330
|
+
purpose once and confirm the right entry fails.
|
|
296331
|
+
|
|
296332
|
+
When a fixture fails, rule out judge nondeterminism (\`--repeat\`) before you
|
|
296333
|
+
believe it. Then either you moved a verdict you did not mean to, or the fixture
|
|
296334
|
+
was wrong -- re-settle it and record why. Deleting it throws away the only case
|
|
296335
|
+
you had evidence about.`
|
|
296336
|
+
},
|
|
296337
|
+
{
|
|
296338
|
+
name: "eval-loop/golden-side-door",
|
|
296339
|
+
description: "The golden side door. Reference detail for the eval-loop skill.",
|
|
296340
|
+
body: `<!-- Everything about a golden that is wrong, doubted, or out of step with the model. NOT a sixth step, and never improve. -->
|
|
296341
|
+
|
|
296342
|
+
# The golden side door
|
|
296343
|
+
|
|
296344
|
+
## Golden side door (not a sixth step)
|
|
296345
|
+
|
|
296346
|
+
Bad and ambiguous goldens show up immediately. That is not improve. A
|
|
296347
|
+
checkpoint that mixes model edits and silent golden rewrites is useless for
|
|
296348
|
+
rollback. Keep hold and repair here, outside the five steps.
|
|
296349
|
+
|
|
296350
|
+
## Repair a bad golden
|
|
296351
|
+
|
|
296352
|
+
This is **your** job as conductor, after \`eval-diagnose\` writes
|
|
296353
|
+
\`BAD-REFERENCE\`. It is not the answerer's job, and it is not a reason to
|
|
296354
|
+
change the model.
|
|
296355
|
+
|
|
296356
|
+
Diagnosis is not the only way one arrives. The judge also reports a
|
|
296357
|
+
\`gold_status\` on every score (\`skill:eval-judge\`), and a
|
|
296358
|
+
\`suspect\` or \`verified_wrong\` comes through this same door -- earlier, because it
|
|
296359
|
+
lands during scoring rather than after. Treat it as a \`BAD-REFERENCE\` with the
|
|
296360
|
+
judge's \`gold_note\` as its evidence. Adjudicate it **before** improve runs: a
|
|
296361
|
+
doubted key sends a modelling agent to fix a model that is already right, which
|
|
296362
|
+
is the most expensive wrong turn this loop can take.
|
|
296363
|
+
|
|
296364
|
+
The judge scored against the golden as written even where it said \`suspect\`, so
|
|
296365
|
+
its verdict is still the verdict. Do not re-open a case merely because the flag
|
|
296366
|
+
is set; open it because you looked and agreed.
|
|
296367
|
+
|
|
296368
|
+
1. **Replay, yourself.** Take the stored \`final_query\` (or a query you can
|
|
296369
|
+
justify from the model) and run it with \`execute_query\`. Write the
|
|
296370
|
+
rows to a gold artifact under \`evals/<set>/\` (never under the served
|
|
296371
|
+
package tree). If you cannot produce a trusted key, follow **Hold an
|
|
296372
|
+
ambiguous golden** (or mark the golden \`invalid\` if the question itself is
|
|
296373
|
+
unusable). Do not invent a number.
|
|
296374
|
+
2. **Patch the case** in \`cases.jsonl\`: new \`golden\` (status, kind, value or
|
|
296375
|
+
path, \`canonicalQuery\`, \`verifiedBy: replay\`) and \`goldenRevision\`
|
|
296376
|
+
incremented. Do not edit any old \`score\` event.
|
|
296377
|
+
3. **Bump \`datasetVersion\`** in \`set.json\`, and commit the ledger change so
|
|
296378
|
+
the repair is attributable.
|
|
296379
|
+
4. **Close the issue as repaired, not as a model fix**: \`issue_status: fixed\`
|
|
296380
|
+
with a note that the *golden* changed.
|
|
296381
|
+
5. **Open a new run** whose \`run.json\` records the new \`datasetVersion\`. It
|
|
296382
|
+
is not comparable to runs on the old version without saying so.
|
|
296383
|
+
6. **Re-score stored queries first**: \`skill:eval-answer\` without a new
|
|
296384
|
+
answerer (saved predictions, fresh judge, new \`golden_revision\` stamps).
|
|
296385
|
+
7. **Re-answer only if you still need a blind look** (discoverability, or the
|
|
296386
|
+
stored query was itself the thing under test).
|
|
296387
|
+
|
|
296388
|
+
Never mix old-golden and new-golden scores in one aggregate. A before/after
|
|
296389
|
+
that crosses a golden bump is a rebase, not a model delta.
|
|
296390
|
+
|
|
296391
|
+
## A model fix can invalidate a golden, and nothing will tell you
|
|
296392
|
+
|
|
296393
|
+
A rubric that explains a trap usually has to quote the model -- "this measure
|
|
296394
|
+
counts line items despite its name, so it yields the trap value". That sentence
|
|
296395
|
+
is a claim about the model, and it is false the moment the model is fixed. The
|
|
296396
|
+
judge keeps enforcing it and starts failing correct answers.
|
|
296397
|
+
|
|
296398
|
+
A truth-package check cannot catch this, structurally. It re-derives values from
|
|
296399
|
+
sources that are independent of the model **on purpose**, so a rubric can
|
|
296400
|
+
describe a model that no longer exists while every value still re-derives green.
|
|
296401
|
+
|
|
296402
|
+
Worse, a model fix can move a golden's *value* without touching the data. If a
|
|
296403
|
+
dimension is defined in terms of the measure you fixed, the concept it names now
|
|
296404
|
+
resolves to a different population -- same dimension, same question, different
|
|
296405
|
+
correct answer -- while a canonical truth query still returns the old number
|
|
296406
|
+
because it encoded the old definition.
|
|
296407
|
+
|
|
296408
|
+
So: **after any model edit, re-read the rubrics of every case that names an
|
|
296409
|
+
entity you touched.** \`verify_goldens.py\` audits the mechanical part -- it parses
|
|
296410
|
+
\`X is <expr>\` out of the model and flags any rubric asserting a different
|
|
296411
|
+
definition -- but only for definitions it can parse. Prose claims about grain,
|
|
296412
|
+
population, or convention are still yours to check.
|
|
296413
|
+
|
|
296414
|
+
When one turns up it is \`BAD-REFERENCE\`, and it goes through this side door.
|
|
296415
|
+
Never let it reach improve: the model is right, and an edit would be damage.
|
|
296416
|
+
|
|
296417
|
+
## A golden must match the state the model is in
|
|
296418
|
+
|
|
296419
|
+
A case whose golden holds a value asserts that the value is obtainable. If the
|
|
296420
|
+
model has no trace of the concept, that assertion is false, and the case is now
|
|
296421
|
+
asking two questions at once: "did the answer contain the golden" (no) and
|
|
296422
|
+
"should the answerer have complied" (no). Both readings are defensible, so the
|
|
296423
|
+
verdict stops being a measurement.
|
|
296424
|
+
|
|
296425
|
+
Measured, holding the answer, the model and the rubric fixed and varying only
|
|
296426
|
+
how the case was authored:
|
|
296427
|
+
|
|
296428
|
+
| the case says | verdicts over four samples |
|
|
296429
|
+
|---|---|
|
|
296430
|
+
| golden holds three counts, model defines no such concept | \`match\` / \`no_match\` / \`match\` / \`near_match\` |
|
|
296431
|
+
| \`golden.kind: unanswerable\`, pass is a refusal that names what is missing | \`match\` x4 |
|
|
296432
|
+
|
|
296433
|
+
The judge is not being unreliable in the first row. It is being asked a question
|
|
296434
|
+
with two right answers.
|
|
296435
|
+
|
|
296436
|
+
So a coverage case has two states and needs a golden for each:
|
|
296437
|
+
|
|
296438
|
+
1. **Before the model defines the concept.** \`coverage: absent\`,
|
|
296439
|
+
\`golden.kind: unanswerable\`. The pass is a refusal that NAMES what is
|
|
296440
|
+
missing; inventing boundaries and reporting them as the company's is
|
|
296441
|
+
\`no_match\`. This is the state that measures whether the model documents its
|
|
296442
|
+
conventions.
|
|
296443
|
+
2. **After improve adds it.** Bump \`goldenRevision\`, replace the golden with the
|
|
296444
|
+
real value, bump \`datasetVersion\`. A refusal is now a failure, and the run
|
|
296445
|
+
measures whether the new entity is discoverable.
|
|
296446
|
+
|
|
296447
|
+
Never one case straddling both. The straddle is what produces an oscillating
|
|
296448
|
+
verdict, and no amount of rubric wording fixes it -- four prompt edits were
|
|
296449
|
+
tried against exactly this case and none of them did.
|
|
296450
|
+
|
|
296451
|
+
**This is the mirror of "A model fix can invalidate a golden".** That section
|
|
296452
|
+
warns that adding a definition can move a golden nobody was working on. This one
|
|
296453
|
+
warns of the same seam from the other side: a golden written for a model that
|
|
296454
|
+
does not exist yet is invalid until the model catches up. Both are golden side
|
|
296455
|
+
door work, and neither is improve.
|
|
296456
|
+
|
|
296457
|
+
## Hold an ambiguous golden
|
|
296458
|
+
|
|
296459
|
+
Use this when the current key is unusable as a score *and* you cannot justify
|
|
296460
|
+
exactly one replacement (two honest replays disagree; a window or tie is
|
|
296461
|
+
unspecified; later samples might confirm a convention).
|
|
296462
|
+
|
|
296463
|
+
1. **Do not invent a key.** Leave the old artifact on the case for
|
|
296464
|
+
provenance.
|
|
296465
|
+
2. **Patch the case**: \`golden.status: ambiguous\` with a \`reason\` naming the
|
|
296466
|
+
defect and the competing replacements (not a new number). Increment
|
|
296467
|
+
\`goldenRevision\`.
|
|
296468
|
+
3. **Do not score** this case until a later sample confirms a convention or a
|
|
296469
|
+
human picks a replacement. Its attempts get \`verdict: null,
|
|
296470
|
+
reason: golden_ambiguous\`.
|
|
296471
|
+
4. **\`issue_status: deferred\`**, not \`fixed\`. Revisit when another case in
|
|
296472
|
+
the same neighborhood confirms a convention.
|
|
296473
|
+
5. Old \`score\` events stay. They keep the previous \`golden_revision\` and must
|
|
296474
|
+
not enter an aggregate that claims the model failed.
|
|
296475
|
+
|
|
296476
|
+
If later evidence makes one replacement obvious, then Repair a bad golden.`
|
|
296477
|
+
},
|
|
296478
|
+
{
|
|
296479
|
+
name: "eval-loop/measurement",
|
|
296480
|
+
description: "Measurement. Reference detail for the eval-loop skill.",
|
|
296481
|
+
body: `<!-- Sampling, the flip-count bar, the A/A noise band, and targeted fixes. Read this before quoting any number. -->
|
|
296482
|
+
|
|
296483
|
+
# Measurement
|
|
296484
|
+
|
|
296485
|
+
## Measurement
|
|
296486
|
+
|
|
296487
|
+
**Sample each case once. Spend the budget on more and more varied cases
|
|
296488
|
+
instead.** Repeats past the first buy very little: variance decompositions of
|
|
296489
|
+
LLM evaluation put the reduction from extra repeats at a small fraction of
|
|
296490
|
+
what extra items buy, and a set of five cases run three times cannot support
|
|
296491
|
+
a claim that fifteen distinct cases can. If a case is genuinely borderline,
|
|
296492
|
+
re-run that case, not the whole set.
|
|
296493
|
+
|
|
296494
|
+
Because a single sample cannot carry a mean, do not report before/after as a
|
|
296495
|
+
score delta. **Count the cases whose verdict changed** between the baseline
|
|
296496
|
+
and the post-edit run, discard the unchanged ones, and read the result off
|
|
296497
|
+
this table:
|
|
296498
|
+
|
|
296499
|
+
| Cases that got worse | Cases that must get better to accept |
|
|
296500
|
+
|---|---|
|
|
296501
|
+
| 0 | 5 |
|
|
296502
|
+
| 1 | 7 |
|
|
296503
|
+
| 2 | 9 |
|
|
296504
|
+
| 3 | 10 |
|
|
296505
|
+
|
|
296506
|
+
Below that bar the change is **unresolved**, not an improvement, and saying
|
|
296507
|
+
so is the honest report. Note the consequence before you scope a run: a set
|
|
296508
|
+
of six cases can essentially never clear this bar, so a set that small can
|
|
296509
|
+
measure a baseline and diagnose failures but cannot defend an edit.
|
|
296510
|
+
|
|
296511
|
+
## Calibrate the bar before you trust it
|
|
296512
|
+
|
|
296513
|
+
This table was asserted, not measured, and the number it needs is a property of
|
|
296514
|
+
your harness and your set -- not of this skill. Measure it with an **A/A run**:
|
|
296515
|
+
the same model, same config, same set, twice, compared with
|
|
296516
|
+
\`scripts/flip_table.py\`. Every flip it reports is noise by construction, since
|
|
296517
|
+
nothing changed. Record the result with the set, in \`CALIBRATION.md\`, and cite
|
|
296518
|
+
that file when you quote a band.
|
|
296519
|
+
|
|
296520
|
+
Re-measure whenever the model, judge, or set changes. This is not a formality:
|
|
296521
|
+
observed bands have moved by a factor of three across a fortnight of ordinary
|
|
296522
|
+
work, so a band carried over from a previous configuration is a number with no
|
|
296523
|
+
claim on the present one.
|
|
296524
|
+
|
|
296525
|
+
One A/A is one sample of the flip count, not a distribution. It can show a bar
|
|
296526
|
+
is too low; it cannot show one is high enough. Treat any measured band as a
|
|
296527
|
+
floor.
|
|
296528
|
+
|
|
296529
|
+
Two consequences worth separating:
|
|
296530
|
+
|
|
296531
|
+
- **For acceptance**, the band is the threshold untargeted flips must sit under.
|
|
296532
|
+
- **For diagnosis**, it is a warning that a single run's failure list is partly
|
|
296533
|
+
luck. Pick what to fix from the failures that fail in **both** A/A runs.
|
|
296534
|
+
Ranking a backlog by one run's clusters partly ranks which cases were unlucky
|
|
296535
|
+
that afternoon.
|
|
296536
|
+
|
|
296537
|
+
When you inspect the flips, attribute them before you accept them as
|
|
296538
|
+
irreducible. A band dominated by the **judge** re-reading an ambiguous rubric is
|
|
296539
|
+
not answerer noise, and it is not a floor you have to live under: sharpening
|
|
296540
|
+
those rubrics buys more measurement power than any change to the answerer.
|
|
296541
|
+
|
|
296542
|
+
An A/A is not a repeat in the sense the sampling rule forbids. It is a one-off
|
|
296543
|
+
calibration of the instrument, and the loop's whole acceptance rule rests on
|
|
296544
|
+
the constant it produces.
|
|
296545
|
+
|
|
296546
|
+
## A targeted fix needs a targeted test
|
|
296547
|
+
|
|
296548
|
+
The flip-count table is the right instrument for a broad change and the wrong
|
|
296549
|
+
one for a narrow fix. A fix that repairs three cases on a 49-case set moves the
|
|
296550
|
+
total by three -- inside the noise band an A/A already produces -- so a
|
|
296551
|
+
mechanically-verified repair reports as no effect and gets abandoned.
|
|
296552
|
+
|
|
296553
|
+
This is not a hypothetical failure mode: a mechanically verified repair, where
|
|
296554
|
+
each fixed case now matches its golden exactly, can read as "no effect" on both
|
|
296555
|
+
of two set-total comparisons. Worked examples are in the set's \`CALIBRATION.md\`.
|
|
296556
|
+
|
|
296557
|
+
So for a narrow fix use \`scripts/flip_table.py --targets --noise-band\`:
|
|
296558
|
+
|
|
296559
|
+
1. **Name the cases before the run.** Pick them from the stable failures of the
|
|
296560
|
+
A/A, never from a single run. Choosing them afterwards is choosing the answer.
|
|
296561
|
+
2. Accept on the targeted cases: they were failing, they now pass, and none of
|
|
296562
|
+
them broke.
|
|
296563
|
+
3. Separately require the untargeted flips to sit **at or below the A/A band**.
|
|
296564
|
+
That is what rules out a fix that trades one set of cases for another --
|
|
296565
|
+
above the band, investigate before accepting, however good the targets look.
|
|
296566
|
+
4. **Run the post-edit arm twice and pass both** (\`--b --b2\`). The band counts
|
|
296567
|
+
flips; it never asks which cases flipped, and that is the hole. Noise
|
|
296568
|
+
scatters, so an untargeted case that breaks in *both* post arms is a real
|
|
296569
|
+
regression however small the count is.
|
|
296570
|
+
|
|
296571
|
+
Report both. A targeted win with untargeted flips above the band is not a win,
|
|
296572
|
+
and a set-total that moved by less than the band is not evidence of anything
|
|
296573
|
+
either way.
|
|
296574
|
+
|
|
296575
|
+
Step 4 exists because the band alone has accepted a real regression: an edit
|
|
296576
|
+
whose untargeted flip count sat inside the band, but where the same untargeted
|
|
296577
|
+
case broke in every post arm. One arm cannot tell that from a coin toss.
|
|
296578
|
+
|
|
296579
|
+
The reason to expect this, rather than treat it as bad luck: **a correct new
|
|
296580
|
+
entity is not a safe one.** Adding a measure changes what agents reach for on
|
|
296581
|
+
questions nobody was thinking about, so a well-named addition can pull a
|
|
296582
|
+
neighbouring question onto the wrong denominator. That makes the untargeted
|
|
296583
|
+
half of the acceptance check the half that matters, and it needs two arms to be
|
|
296584
|
+
readable at all.
|
|
296585
|
+
|
|
296586
|
+
The one retrieval number this loop reports is **per-question entity recall**:
|
|
296587
|
+
of the entities each golden answer depends on, how many did the agent's own
|
|
296588
|
+
\`get_context\` calls deliver (\`skill:eval-answer\`, \`scripts/score_retrieval.py\`;
|
|
296589
|
+
delivered means returned as a ranked entity, under a sibling source, or named
|
|
296590
|
+
in a returned source's documentation). It is measured on the agent's real
|
|
296591
|
+
search text against real questions, so it needs no hand-written terms. Read
|
|
296592
|
+
it within an arm, to attribute a failure; it moves with the answerer, so a
|
|
296593
|
+
cross-arm comparison of retrieval *itself* is not this loop's job -- that is
|
|
296594
|
+
the engine-side \`eval-retrieval\` skill, which ships to no customer.
|
|
296595
|
+
Coverage (can the model answer this at all) is a property of the model and its
|
|
296596
|
+
data and is never reported under a retrieval heading.
|
|
296597
|
+
|
|
296598
|
+
If the contaminated fraction of attempts exceeds 0.1 (or any contamination,
|
|
296599
|
+
on a run smaller than 10), the run is a harness failure. Do not publish a
|
|
296600
|
+
model score.`
|
|
296601
|
+
},
|
|
296602
|
+
{
|
|
296603
|
+
name: "eval-loop/running-a-run",
|
|
296604
|
+
description: "Running a run, concretely. Reference detail for the eval-loop skill.",
|
|
296605
|
+
body: `<!-- The worked command sequence. Read it when you are about to run one. -->
|
|
296606
|
+
|
|
296607
|
+
# Running a run, concretely
|
|
296608
|
+
|
|
296609
|
+
## Running one, concretely
|
|
296610
|
+
|
|
296611
|
+
\`scripts/run_baseline.py\` does steps 3 and 7 and the whole of **Per question**:
|
|
296612
|
+
one fresh answerer per case with only the Publisher MCP tools, a contamination
|
|
296613
|
+
check, a judge, and a conformant \`events.jsonl\`.
|
|
296614
|
+
|
|
296615
|
+
\`\`\`bash
|
|
296616
|
+
# 1. serve the model under test -- in its own session, so the shell's exit
|
|
296617
|
+
# cannot take it down, and returning only once it answers a query
|
|
296618
|
+
python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
|
|
296619
|
+
--server-root <root> --port 4811 --mcp-port 4040 --trace-retrieval \\
|
|
296620
|
+
[--allow-proxy] # required for a \`publisher\`-type (proxied) connection
|
|
296621
|
+
# a second server for the TRUTH package, on other ports, that the answerer
|
|
296622
|
+
# has no route to:
|
|
296623
|
+
python3 skills/eval-loop/scripts/serve.py --publisher-dir <publisher>/packages/server \\
|
|
296624
|
+
--server-root <truthroot> --port 4881 --mcp-port 4882 [--allow-proxy]
|
|
296625
|
+
|
|
296626
|
+
# 2. smoke one case first ($0.13), then the arm. Goldens are re-derived from
|
|
296627
|
+
# the truth server before either starts; a drifted set refuses to run.
|
|
296628
|
+
python3 skills/eval-loop/scripts/run_baseline.py \\
|
|
296629
|
+
--set <repo>/evals/ecommerce --out results/smoke --only <qid> --no-judge \\
|
|
296630
|
+
--truth-publisher http://localhost:4881
|
|
296631
|
+
python3 skills/eval-loop/scripts/run_baseline.py \\
|
|
296632
|
+
--set <repo>/evals/ecommerce --out results/<arm> \\
|
|
296633
|
+
--parallel 4 --truth-publisher http://localhost:4881
|
|
296634
|
+
# the run names itself <set>-<phase>-<nn> (ecommerce-baseline-01, then -02
|
|
296635
|
+
# for the second arm of the A/A). Pass --label only for a run that needs a
|
|
296636
|
+
# human name; hand-typed arm names stop being readable within an afternoon.
|
|
296637
|
+
|
|
296638
|
+
# 3. compare two arms, or two runs of one arm
|
|
296639
|
+
python3 skills/eval-loop/scripts/flip_table.py --a results/<a> --b results/<b>
|
|
296640
|
+
|
|
296641
|
+
# 4. FIRST: any golden the judge did not believe. \`jq .doubtedGoldens
|
|
296642
|
+
# results/<arm>/run.json\` -- non-empty means settle those through the golden
|
|
296643
|
+
# side door before diagnosing, or you send a modelling agent at a model that
|
|
296644
|
+
# is already right.
|
|
296645
|
+
python3 skills/eval-diagnose/scripts/diagnose.py \\
|
|
296646
|
+
--run results/<arm> --set <repo>/evals/ecommerce --model-dir <package>
|
|
296647
|
+
# (cluster_failures.py gives a free mechanical first look, as
|
|
296648
|
+
# clusters-mechanical.jsonl; it groups by retrieval outcome and is not a
|
|
296649
|
+
# diagnosis)
|
|
296650
|
+
|
|
296651
|
+
# 5. build the browsable package
|
|
296652
|
+
python3 skills/eval-loop/scripts/build_run_package.py \\
|
|
296653
|
+
--run results/<a> --run results/<b> --set <repo>/evals/ecommerce --out <pkg>
|
|
296654
|
+
\`\`\`
|
|
296655
|
+
|
|
296656
|
+
Order of magnitude for planning, **calibrated on ecommerce over local duckdb**:
|
|
296657
|
+
a Sonnet arm over a few dozen cases costs single-digit dollars and finishes in
|
|
296658
|
+
minutes, at roughly a dime and a handful of turns per case. A proxied warehouse
|
|
296659
|
+
is a different regime: the VideoAmp set ran at $0.33 per case on Sonnet and
|
|
296660
|
+
$0.57–0.71 on Opus, ~100 s per case, driven by warehouse latency and query
|
|
296661
|
+
errors -- budget 4x when the data is not local. Budget **five** such arms for a
|
|
296662
|
+
defensible claim -- a baseline, two for the A/A, and two post-edit -- plus the
|
|
296663
|
+
diagnose and improve agents, which are far cheaper per case but use a larger
|
|
296664
|
+
model. Measured per-arm figures for a given set belong in that set's
|
|
296665
|
+
\`CALIBRATION.md\`.
|
|
296666
|
+
|
|
296667
|
+
\`--rebuild\` re-derives the ledger from saved transcripts without calling a model,
|
|
296668
|
+
and \`--rebuild --rejudge\` re-scores existing answers in place. \`--from <run>
|
|
296669
|
+
--out <new>\` does the same into a NEW run directory -- the answers copied, the
|
|
296670
|
+
judge fresh, the old verdicts untouched -- which is what a golden repair or a
|
|
296671
|
+
rubric change calls for. Use them after a scoring or schema change; re-running
|
|
296672
|
+
the answerers would confound the change you are measuring with fresh answerer
|
|
296673
|
+
variance.
|
|
296674
|
+
|
|
296675
|
+
The scripts import each other by path (\`ledger\`, \`mcp_payload\`,
|
|
296676
|
+
\`score_retrieval\` live in \`eval-answer/scripts\`; the loop scripts insert that
|
|
296677
|
+
path). Run them **in place** from the skills checkout; a copy patched elsewhere
|
|
296678
|
+
chases \`ModuleNotFoundError\` three times.
|
|
296679
|
+
|
|
296680
|
+
Two failure modes worth pre-empting, because both produce a clean-looking run:
|
|
296681
|
+
|
|
296682
|
+
- **Pre-approve the tools.** A headless answerer that has to ask permission for
|
|
296683
|
+
\`malloy_getContext\` stalls until the timeout and lands as a harness error.
|
|
296684
|
+
- **Check the served revision is the one you edited.** Publisher serves a
|
|
296685
|
+
snapshot copy, so a model fix can be absent from the run that is supposed to
|
|
296686
|
+
measure it. Query the changed measure once before spending an arm on it.`
|
|
296687
|
+
},
|
|
294717
296688
|
{
|
|
294718
296689
|
name: "malloy",
|
|
294719
296690
|
description: 'Index of all Malloy skills. Use when user asks "malloy help", "what malloy skills are available", "how do I use malloy", or needs guidance on which Malloy skill to use.',
|
|
@@ -294728,6 +296699,8 @@ Say "model my data" and the agent will orchestrate the full modeling workflow au
|
|
|
294728
296699
|
|
|
294729
296700
|
Every skill in this deployment, by what it is for. Start at a driver; it routes to the rest.
|
|
294730
296701
|
|
|
296702
|
+
This table is a catalogue of what exists, not of what is loaded. A host that installs one group takes that group's skills alone: \`analysis\`, \`modeling\`, or \`eval\`. A row naming a skill from a group you did not install says that the skill exists. It is not an instruction to load it, and it is written as a plain name rather than a \`skill:\` reference to say so.
|
|
296703
|
+
|
|
294731
296704
|
**Start here**
|
|
294732
296705
|
|
|
294733
296706
|
| Skill | Use when... |
|
|
@@ -294757,9 +296730,18 @@ Every skill in this deployment, by what it is for. Start at a driver; it routes
|
|
|
294757
296730
|
| \`skill:malloy-notebooks\` | Building Malloy notebooks (.malloynb) |
|
|
294758
296731
|
| \`skill:malloy-analysis-report\` | Combining validated queries into a notebook report or dashboard |
|
|
294759
296732
|
| \`skill:malloy-analysis-pitfalls\` | Checking a query and its results before presenting an answer |
|
|
294760
|
-
| \`
|
|
296733
|
+
| \`malloy-notebook-chat\` | The chat is bound to a notebook or saved report; answer from its cells. Ships in \`analysis\`. |
|
|
294761
296734
|
| \`skill:malloy-phrase-detection\` | Turning a plain-English question into search targets for the context tool |
|
|
294762
296735
|
|
|
296736
|
+
**Evaluating a model** (driven by \`eval-loop\`). These ship in the \`eval\` group, which neither \`analysis\` nor \`modeling\` includes.
|
|
296737
|
+
|
|
296738
|
+
| Skill | Use when... |
|
|
296739
|
+
|-------|-------------|
|
|
296740
|
+
| \`eval-loop\` | Running the loop: baseline, noise band, diagnose, one edit, gate, checkpoint |
|
|
296741
|
+
| \`eval-answer\` | Scoring one answer against a verified golden, and what retrieval delivered |
|
|
296742
|
+
| \`eval-diagnose\` | Deciding why a case failed and which artifact owns the fix |
|
|
296743
|
+
| \`eval-improve\` | The smallest model edit for a diagnosed cluster, with probe receipts |
|
|
296744
|
+
|
|
294763
296745
|
**Writing correct Malloy** (read before writing, not after failing)
|
|
294764
296746
|
|
|
294765
296747
|
| Skill | Use when... |
|
|
@@ -294888,7 +296870,7 @@ End with a short **Next steps**: one or two specific deeper analyses the data co
|
|
|
294888
296870
|
{
|
|
294889
296871
|
name: "malloy-analysis-report",
|
|
294890
296872
|
description: 'Combine validated Malloy queries into a notebook report or dashboard. Use when the user asks to "create a report", "build a dashboard", "combine these into a report", or wants a persistent multi-query artifact.',
|
|
294891
|
-
body: "# Creating Reports\n\nAn ad-hoc report is a `.malloynb` notebook that combines markdown narrative with live Malloy query cells. There is no dedicated report tool: you author the notebook directly. Load `skill:malloy-notebooks` for the full `.malloynb` cell format and authoring rules; this skill covers when to build one and how to design good report content (cells, chart annotations, narrative structure).\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## Before building a report\n\n1. **Run each query first** via `execute_query` to verify it works and returns expected results.\n2. **Explain the results** to the user as you go: walk through the analysis step by step.\n3. **Then assemble the notebook** once the analysis is validated.\n\nDo NOT build the notebook in the same turn as `execute_query`. Explain first, then build.\n\n## Filters are inherited from the model, don't declare them in the report\n\nReports do not (and cannot) define their own filters. If the source declares `given:` parameters (or legacy `#(filter)` annotations), Publisher renders the controls, parses caller parameters, and applies them server-side automatically: the report inherits and displays them with no extra work. If the analysis needs a knob the source doesn't expose, the right move is to add a `given:` to the source itself, not to wedge a filter widget into the report. `#(filter)` is deprecated in favour of native Malloy `given:` parameters. Do not add new `#(filter)` annotations; the two exceptions are `required` and `implicit`, which `given:` cannot cover yet.
|
|
296873
|
+
body: "# Creating Reports\n\nAn ad-hoc report is a `.malloynb` notebook that combines markdown narrative with live Malloy query cells. There is no dedicated report tool: you author the notebook directly. Load `skill:malloy-notebooks` for the full `.malloynb` cell format and authoring rules; this skill covers when to build one and how to design good report content (cells, chart annotations, narrative structure).\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## Before building a report\n\n1. **Run each query first** via `execute_query` to verify it works and returns expected results.\n2. **Explain the results** to the user as you go: walk through the analysis step by step.\n3. **Then assemble the notebook** once the analysis is validated.\n\nDo NOT build the notebook in the same turn as `execute_query`. Explain first, then build.\n\n## Filters are inherited from the model, don't declare them in the report\n\nReports do not (and cannot) define their own filters. If the source declares `given:` parameters (or legacy `#(filter)` annotations), Publisher renders the controls, parses caller parameters, and applies them server-side automatically: the report inherits and displays them with no extra work. If the analysis needs a knob the source doesn't expose, the right move is to add a `given:` to the source itself, not to wedge a filter widget into the report. `#(filter)` is deprecated in favour of native Malloy `given:` parameters. Do not add new `#(filter)` annotations; the two exceptions are `required` and `implicit`, which `given:` cannot cover yet. The `malloy-model` skill covers this under § Legacy: Parameterizable Filters. For curated notebooks with their own per-notebook filter UI on top of the model, see `skill:malloy-notebooks` instead.\n\n## What goes in the report\n\nDo NOT add an H1 heading in any cell (use H2 and below for sections); the notebook name serves as the title. To redo the structure rather than tweak one cell, rewrite the notebook file end-to-end.\n\nMarkdown cells own narrative; query cells own a single Malloy query whose chart annotation tells the renderer how to display the result. Markdown supports H2 headings, lists, bold, and inline code. Keep narrative cells short, one idea per cell, so the rendered output reads as a story instead of a wall of text.\n\nIn a `.malloynb` file each cell is delimited by a `>>>markdown` or `>>>malloy` marker. A markdown cell looks like:\n\n```\n>>>markdown\n## Section heading\nNarrative text here.\n```\n\nA query cell looks like:\n\n```\n>>>malloy\n# bar_chart\nrun: source -> { group_by: dim; aggregate: measure }\n```\n\nEach Malloy cell must be a standalone query (for example `run: source -> { ... }`). The notebook's leading `>>>malloy` cell holds the `import` statement for the model file; individual query cells do not repeat it. If a query fails validation when executed, fix it and rerun.\n\nA well-structured report typically follows this pattern:\n\n```\n[Markdown] ## Overview: what question are we answering, what data is in scope (date range, entity count)\n[Malloy] KPI cell: headline numbers (e.g., # big_value, or # dashboard with nested # big_value cells)\n[Markdown] ## Trend: describe what we should look for over time\n[Malloy] Time-series cell (e.g., # line_chart on a date dimension)\n[Markdown] ## Breakdown: where the signal is\n[Malloy] Categorical cell (e.g., # bar_chart on a categorical dimension)\n[Markdown] ## Key takeaways: what the user should walk away with\n```\n\nUse this as a default; deviate when the analysis warrants. A grounded report names the time range and entity count up front so every number that follows has context.\n\n## Choosing chart types and annotations\n\nRead `skill:malloy-charts` before picking visualizations: it owns chart-type selection, properties, and the placement rules for chart annotations. `skill:malloy-queries` covers Malloy query patterns and the critical placement rules for chart-annotation tags.\n\nWhen in doubt:\n- KPIs / single numbers -> `# big_value`, often nested inside `# dashboard`.\n- Trend over time -> `# line_chart`, usually on the primary date dimension.\n- Category comparisons -> `# bar_chart`, ordered by the metric.\n- Tabular data with many columns -> a plain table cell with `# table.size=fill`.\n- Multiple coordinated charts -> `# dashboard` with `nest:` blocks.\n\nAnnotations go **before** `run:`, never inside curly braces:\n\n```malloy\n# bar_chart\nrun: source -> {\n group_by: category\n aggregate: revenue\n order_by: revenue desc\n limit: 10\n}\n```\n\nA `# dashboard` cell composes nested views, useful for KPIs alongside a trend in a single cell. Each `nest:` is a tile; any top-level `aggregate:` measures render as KPI cards. For a fixed grid, use `# dashboard { columns=N }` with `# colspan` on each tile (see `skill:malloy-charts`):\n\n```malloy\n# dashboard { columns=2 }\nrun: source -> {\n nest:\n # colspan=2\n # big_value\n kpis is {\n aggregate:\n # label=\"Revenue\"\n # currency\n total_revenue\n\n # label=\"Orders\"\n # number=auto\n order_count\n }\n nest:\n # line_chart\n trend is {\n group_by: order_date.month\n aggregate: total_revenue\n order_by: 1\n }\n}\n```\n\nKey rendering rules to keep in mind when shaping a cell:\n- FIRST `group_by` = x-axis, FIRST `aggregate` = y-axis.\n- Override field roles with `# x`, `# y`, `# series` on individual fields.\n- For multiple measure series, place `# y` above the `aggregate:` keyword.\n- One aggregate per chart view: use `# dashboard` with nested views for multiple charts.\n- Use `# table.size=fill` for standalone table queries.\n\n## Editing an existing report\n\nFor small targeted changes (fix one cell, insert one new cell), edit that cell in the `.malloynb` file rather than recreating the whole notebook. For structural rewrites (reordering many cells, changing the narrative arc), rewrite the notebook file.\n\n## IMPORTANT\n\nYou CANNOT see the rendered output of notebook cells. Do not claim to see charts, values, or patterns from report cells you haven't explicitly executed via `execute_query`. If you need to analyze results, run the query via `execute_query` first."
|
|
294892
296874
|
},
|
|
294893
296875
|
{
|
|
294894
296876
|
name: "malloy-analyze",
|
|
@@ -295639,11 +297621,12 @@ the warning list.
|
|
|
295639
297621
|
|
|
295640
297622
|
## Get Diagnostics
|
|
295641
297623
|
|
|
295642
|
-
|
|
297624
|
+
Hosts expose errors differently, so take the best path you actually have:
|
|
295643
297625
|
|
|
295644
|
-
**
|
|
297626
|
+
1. **An editor-diagnostics tool**, if your host offers one - call it on the file and read the errors straight out.
|
|
297627
|
+
2. **Otherwise, compile by running.** Run any query against the source with your query tool and read the error it returns. Every host that can run Malloy can do this, including chat surfaces with no editor.
|
|
295645
297628
|
|
|
295646
|
-
|
|
297629
|
+
Only ask the user to open the file in an editor when you know they have the model open in one. On a hosted chat surface there is no local checkout to open, and the query path above is the one that works.
|
|
295647
297630
|
|
|
295648
297631
|
## Strategy
|
|
295649
297632
|
|
|
@@ -295663,8 +297646,10 @@ the warning list.
|
|
|
295663
297646
|
| "Aggregate not allowed in where" | Use \`having:\` instead |
|
|
295664
297647
|
| 20+ random errors | Backtick reserved word (\`\` \`Date\` \`\`, \`\` \`Hour\` \`\`, \`\` \`number\` \`\`) |
|
|
295665
297648
|
| \`Can't find field 'X' to set access modifier\` | An \`include {}\` sits before the \`extend { rename: }\`. Rename first, then \`include {}\` naming the field by its new name (see \`skill:malloy-gotchas-modeling\` § Field Management) |
|
|
297649
|
+
| \`IO Error: No files found that match the pattern "data/x.csv"\` | Data-file path, not the model. Relative \`duckdb.table()\` paths resolve against the DuckDB \`workingDirectory\`; Publisher sets it to the package root, but a relative \`workingDirectory\` in \`malloy-config.json\` resolves against the process cwd. Make it absolute (see \`skill:malloy-gotchas-modeling\` § Relative Data-File Paths). The "not defined" errors under it are cascade, not real |
|
|
295666
297650
|
| Import path errors | Check paths: \`import "orders.malloy"\`. All files should be in the same directory (flat layout) |
|
|
295667
|
-
| \`from()\`
|
|
297651
|
+
| \`unexpected 'from'\` | \`from()\` was removed from the language. Use the query directly: \`source: x is q extend {...}\`, or \`source: x is (q -> {...}) extend {...}\` |
|
|
297652
|
+
| Query-based source errors | Verify the source query returns the expected columns, check that imported sources are defined |
|
|
295668
297653
|
| "Cannot redefine 'X'" | Field already exists from query-based source (\`-> { group_by, aggregate }\`). Remove the dimension, add only NEW derived fields in \`extend {}\`. Use \`include {}\` to add \`#(doc)\` tags to existing fields. |
|
|
295669
297654
|
|
|
295670
297655
|
## Gotchas Checklist
|
|
@@ -295748,7 +297733,7 @@ a / b a / nullif(b, 0)
|
|
|
295748
297733
|
| "Can't find source X" | Add \`import "X.malloy"\` at top of file (all files in same directory) |
|
|
295749
297734
|
| Wrong import path | All \`.malloy\` files should be in the package root (flat layout). Use \`import "orders.malloy"\`, not \`import "../sources/orders.malloy"\` |
|
|
295750
297735
|
| Circular imports | Source A imports Source B which imports Source A. Restructure to break the cycle |
|
|
295751
|
-
|
|
|
297736
|
+
| Query-based source "Can't find field" | Verify the source query's GROUP BY and aggregate fields match what you reference in \`extend {}\` |`
|
|
295752
297737
|
},
|
|
295753
297738
|
{
|
|
295754
297739
|
name: "malloy-define",
|
|
@@ -295956,12 +297941,12 @@ A confirmed source architecture and a confirmed set of field definitions (rename
|
|
|
295956
297941
|
{
|
|
295957
297942
|
name: "malloy-getting-started",
|
|
295958
297943
|
description: "First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.",
|
|
295959
|
-
body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`. If the point is to author models against a local package, add `--watch-env <env>`: without it Publisher copies local packages at boot and serves the copies, so saved edits are never read.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nTwo escape hatches worth knowing:\n\n- **When the session cannot be relaunched from the workspace directory** (a project `.mcp.json` is only discovered by sessions that *start* in its directory), register the server at user scope so the directory stops mattering: `claude mcp add --transport http malloy http://localhost:4040/mcp -s user` (use the MCP port the server actually bound; its startup log prints it). Caveat: for sessions that do start in the workspace, the project `.mcp.json` shadows the user-scoped entry, so prefer the project file when it is discoverable.\n- **Do not trust an existing `.mcp.json`\'s URL blindly.** The file outlives the server that wrote it, and a boot that failed partway (for example, the REST port was taken) can leave it pointing at a dead port while a live server sits on another. If connecting fails or answers look wrong, confirm identity with `malloy_getContext`, which names the environment and packages you are really talking to; that check works on every platform, which the port check does not (`lsof -iTCP:4040 -sTCP:LISTEN` on macOS and Linux, `netstat -ano | findstr :4040` on Windows).\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\nIf the data you want is in a connected database but not yet in any package, use `malloy_searchDatabaseSchema` instead of `malloy_getContext`: it walks a connection\'s schemas and tables and ranks them against a plain-English description, and hands back the `source:` line to start a model from. It returns names and types only, so to see what a column actually contains run `malloy_executeQuery` against a model in a package that uses the same connection, with an ad-hoc query like `run: my_conn.table(\'sales.orders\') -> { group_by: order_status }`. That tool needs an existing model to run against, so a table you have not modelled yet has none of its own.\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.'
|
|
297944
|
+
body: '# Getting started with Malloy Publisher\n\nGoal: go from "connected" to a correct, grounded answer without guessing any names.\n\n## 0. Confirm the tools are reachable\n\nAt minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.\n\nIf none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`. If the point is to author models against a local package, add `--watch-env <env>`: without it Publisher copies local packages at boot and serves the copies, so saved edits are never read.\n\nIf there is no Publisher workspace here at all, and the user wants to work with data of their own rather than the bundled examples, `npm create @malloy-publisher/malloy-package@latest <name>` scaffolds one: the package and a starter model, registered so the server actually serves it, plus the start script, the MCP config and these skills. Keep the `@latest` when you type it: `npm create` resolves through npm\'s npx cache and an unversioned name is satisfied by any copy already there, so on a machine that has scaffolded before npm never asks the registry and you get an old scaffolder pinning an old server, with nothing to say so. Run bare, it comes with a small sample dataset, so there is something to query straight away. In a fresh directory `npm start` then runs the pinned server against the package in watch mode; if the directory already had a `package.json` the scaffolder leaves it alone and adds no script, printing the equivalent `npx` command to use instead. Where you run it matters: only the package lands in `<name>/`, and the workspace files, the agent instructions and the MCP config among them, are written to the current directory. Run it here if this directory is empty or is meant to become the workspace. If it already holds other work, scaffold into a new directory instead (`mkdir my-data && cd my-data`), because agent config is discovered by walking up, so writing those files here changes what every session beneath this directory inherits. Seed the starter model from a local file with `npm create @malloy-publisher/malloy-package@latest <name> -- --data <path/to/their-file.csv>` (CSV, Parquet, or Excel `.xlsx`), keeping the `--`, which is how `npm create` passes options through. That path is relative to wherever you run the command, so if you scaffolded into a new directory it has to reach back out to their file; the scaffolder copies it into the package and leaves the original alone. A seeded package starts smaller than the sample one, since the scaffolder does not read their columns: expect a row count and an overview, and build the model from there. A package is just Malloy, so it can instead query a database connection the config defines. Because it writes a `.mcp.json` that did not exist when the client connected, the user has to restart or reconnect once before these tools appear, and their client will ask them to approve the new project-scoped server the first time. That only works when the workspace is at the session\'s own root, so if you scaffolded into a new directory below that root, the user has to open a session there instead: a `.mcp.json` further down is never discovered.\n\nIf you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.\n\nTwo escape hatches worth knowing:\n\n- **When the session cannot be relaunched from the workspace directory** (a project `.mcp.json` is only discovered by sessions that *start* in its directory), register the server at user scope so the directory stops mattering: `claude mcp add --transport http malloy http://localhost:4040/mcp -s user` (use the MCP port the server actually bound; its startup log prints it). Caveat: for sessions that do start in the workspace, the project `.mcp.json` shadows the user-scoped entry, so prefer the project file when it is discoverable.\n- **Do not trust an existing `.mcp.json`\'s URL blindly.** The file outlives the server that wrote it, and a boot that failed partway (for example, the REST port was taken) can leave it pointing at a dead port while a live server sits on another. If connecting fails or answers look wrong, confirm identity with `malloy_getContext`, which names the environment and packages you are really talking to; that check works on every platform, which the port check does not (`lsof -iTCP:4040 -sTCP:LISTEN` on macOS and Linux, `netstat -ano | findstr :4040` on Windows).\n\nWhen a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`\'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.\n\n## 0.5 Ask whether they also use the CLI or the VS Code extension\n\nPublisher is often not the only thing reading the model. The Malloy CLI (`malloy-cli`) and the VS\nCode extension compile the same files, and they do **not** get their connections from\n`publisher.config.json` - they read `malloy-config.json`, found by walking up from the file being\ncompiled. So a package that Publisher serves correctly can fail to compile in the editor, on the\nsame machine, from the same files.\n\nAsk before the user finds out the hard way:\n\n> Are you also using the Malloy CLI or the VS Code extension on this package?\n\nIf yes, and the package reads **local data files**, it needs a `malloy-config.json`. Publisher\nresolves a relative `duckdb.table(\'data/x.csv\')` against the package root on its own, so nothing is\nconfigured for it; the other two hosts resolve it against the DuckDB connection\'s\n`workingDirectory`, which has to be set.\n\n**Make that path absolute.** A relative `workingDirectory` is resolved against the process\'s current\ndirectory, not the config file\'s directory and not the VS Code workspace root, so the same config\ncompiles from one directory and fails from another:\n\n```json\n// malloy-config.json, beside the model\n{\n "connections": {\n "duckdb": {\n "is": "duckdb",\n "workingDirectory": "/abs/path/to/package",\n "securityPolicy": "none"\n }\n }\n}\n```\n\nPoint it at the directory the model\'s table paths are written relative to - the one holding `data/`.\nLeave the model\'s own paths relative so Publisher still serves it; only the config carries the\nabsolute path. When this is wrong the editor reports `IO Error: No files found that match the\npattern "data/x.csv"` on the `source:` line, followed by a "not defined" error for every field of\nthat source; those are cascade, not real. The `malloy-gotchas-modeling` skill covers the mechanism\nunder § Relative Data-File Paths.\n\n**Remote Credible connections are a different case.** With the Credible extension running and signed\nin, the data is reached through a `publisher` proxy connection - the connection type that forwards\nSQL to a remote Publisher dataplane, and the same type the CLI and the VS Code extensions use - and\nthe extension supplies it, so there is nothing to hand-write and no `workingDirectory` involved.\n`workingDirectory` only ever matters for local files that DuckDB opens itself. Worth knowing: those\naccess tokens are user-scoped and short-lived, and nothing refreshes them mid-session, so queries\nthat start failing auth after a long session mean the token expired, not that the model broke. See\n`docs/connections.md` § Publisher proxy connections.\n\n## 1. Discover what exists (never guess names)\n\n`malloy_getContext` is progressive. Call it with as much as you know:\n\n- No arguments: the available environments, each with its package names.\n- `environmentName` only: the packages in that environment.\n- `environmentName` + `packageName`: that package\'s sources.\n- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.\n\nUse the names it returns exactly. Do not invent environments, packages, sources, or fields.\n\n## 2. Run the query\n\nCall `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:\n\n- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or\n- an ad-hoc query: pass Malloy code as `query`.\n\nThe result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.\n\n## 3. When you need Malloy syntax\n\nUse `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).\n\nIf the data you want is in a connected database but not yet in any package, use `malloy_searchDatabaseSchema` instead of `malloy_getContext`: it walks a connection\'s schemas and tables and ranks them against a plain-English description, and hands back the `source:` line to start a model from. It returns names and types only, so to see what a column actually contains run `malloy_executeQuery` against a model in a package that uses the same connection, with an ad-hoc query like `run: my_conn.table(\'sales.orders\') -> { group_by: order_status }`. That tool needs an existing model to run against, so a table you have not modelled yet has none of its own.\n\n## 4. What else you can do here\n\nAnswering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:\n\n- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.\n- `malloy-analysis`: explore a package and answer data questions.\n- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package\'s `public/` directory that Publisher serves, backed by the package\'s models and needing no build step.\n- `malloy-review`: check Malloy for correctness.\n\n## Contract\n\n- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.\n- Start broad and narrow down: environments, then packages, then sources, then query.\n- Confirm the environment and package before running a query.'
|
|
295960
297945
|
},
|
|
295961
297946
|
{
|
|
295962
297947
|
name: "malloy-gotchas-modeling",
|
|
295963
297948
|
description: "Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.",
|
|
295964
|
-
body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\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## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: `unit(start to end)`, and the unit decides the operand type\n\nAn interval is `unit(start to end)`. Two rules, both enforced by the compiler:\n\n- **Never subtract.** `days(a - b)` fails with `Can not offset time by 'date'`. The `to` form is the only one.\n- **Mixing a date and a timestamp needs a cast, unless the date side is a literal.** A date *literal* widens to a timestamp on its own, so `days(@2020-01-01 to now)` compiles. A date *column* does not: `days(signup_date to now)` fails with `Cannot measure from date to timestamp`. Cast the odd one out (`::date`, `::timestamp`). `now` is a timestamp.\n\nWhich units accept what:\n\n| Units | Kind | Operands |\n|-------|------|----------|\n| `seconds`, `minutes`, `hours`, `days` | clock | timestamps or dates |\n| `weeks`, `months`, `quarters`, `years` | calendar | **dates only**: on timestamps they fail with `Cannot measure interval using 'month' for 'timestamp' values; calendar interval measurement requires dates` |\n\n```malloy\n// WRONG: subtraction, and a calendar unit applied to timestamp columns\ndimension: gap is days(closed_at - opened_at)\ndimension: months_open is months(opened_at to closed_at)\n\n// WRONG: approximating a calendar unit that exists\ndimension: months_open is days(opened_at to closed_at) / 30.44\n\n// RIGHT\ndimension: days_open is days(opened_at to closed_at)\ndimension: months_open is months(opened_at::date to closed_at::date)\n```\n\nThe calendar units are real and exact. If one fails, read the message: it is telling you to cast the operands, not to divide by 30.44.\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` and `include {}`, in that order\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` handles the renames.** They do compose, but only in one order: the `extend {}` that renames must come **before** the `include {}`, and `include {}` must name the field as it is *after* the rename.\n\n| Mechanism | Where it lives | Keywords | Experimental flag? |\n|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | Yes (`##! experimental.access_modifiers`) |\n| Field management | `extend {}` | `accept:` / `except:` / `rename:` | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When a `rename:` is needed: rename first, then `include {}`\n\nThe usual reason is a collision inside `include {}`: a measure cannot share a name with a raw column, even one tagged `internal:`, and the compiler says so (`Cannot redefine 'revenue' 'revenue' is internal`). The fix is to rename the raw column out of the way, which frees the name for the measure. Order is what makes it work:\n\n```malloy\n##! experimental.access_modifiers\n// RIGHT: rename frees `revenue`, include curates what is left, measure takes the name\nsource: orders is conn.table('orders')\n extend { rename: raw_revenue is revenue }\n include {\n #(doc) Revenue as loaded, before adjustments\n internal: raw_revenue\n public: order_id, user_id\n }\n extend { measure: revenue is raw_revenue.sum() }\n```\n\nTwo ways to get the order wrong, with the errors they produce:\n\n- **`include {}` before the renaming `extend {}`** fails with `Can't find field 'X' to set access modifier`, currently surfaced as an internal compiler error. `include` runs against names that no longer exist by the time the rename is applied.\n- **Naming the pre-rename column inside `include {}`** fails with `` `revenue` not found ``. After a rename only the new name exists; use it.\n\nYou do not have to give up `include {}` to get a rename: the curated surface, `#(doc)` on raw columns, and the `public/internal/private` tiers all survive. Renaming the *measure* instead is still worth considering when the raw column name is the one people know, but it is a modeling preference, not a workaround for a limitation.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: `include { internal: ... }` (lets you also `#(doc)` the public columns). A `rename:` in the same source does not force you off `include {}` - see item 4 for the order.\n4. SQL aliases: an `extend { rename: ... }` before `include {}`, naming the field by its new name in `include {}` (they compose, but only in that order). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## Mixed-Grain Joins: A Pre-Aggregated Source Ignores Your Filters\n\nJoining an aggregate-grain source (a decade/month/region summary table) into a detail-grain source produces values that do **not** respond to the query's filters. Malloy's symmetric aggregates prevent fan-out; they cannot prevent this, because the joined value is unfiltered *by construction*: it was computed over the whole population before the query ran.\n\n```\nrun: track_analysis -> {\n where: genre = 'Rock'\n group_by: decade\n aggregate: track_count // filtered: Rock only -> 701\n group_by: decade_trends.decade_track_count // unfiltered population -> 1,088\n}\n```\n\nTwo count-shaped numbers side by side, one filtered and one not; read as \"701 of 1,088 Rock tracks\" it is simply wrong: 1,088 is every genre. Two legitimate resolutions:\n\n- **Keep the join as a population baseline** when comparing a row to the whole population is the intent (e.g. `energy_vs_decade`). Then every joined field's `#(doc)` must say it is a fixed population value that does not respond to filters, and count-shaped fields with no comparison purpose (like `decade_track_count`) should be `internal:`; they only invite the misreading.\n- **Compute the aggregate as a query-based source from the detail table** so it derives from one source of truth and the derivation is visible.\n\nThis is the modeling-time consequence of ignoring `skill:malloy-scope`'s advice to skip pre-aggregated snapshot tables and compute fresh in Malloy instead.\n\n## Thresholds Are Decisions, Not Syntax\n\nBefore writing a `pick` expression or filtered measure with a numeric cutoff, see `skill:malloy-model` § Key Rules: every boundary must be user-supplied, distribution-derived (query the percentiles first), or explicitly flagged as an assumption in its `#(doc)`. Never invent one silently.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `months()`): always `unit(start to end)`, and calendar units need date operands (see above)\n- Query-based sources (`from()`)\n- `!` operator / `sql_number()`"
|
|
297949
|
+
body: "# Modeling Gotchas\n\n> **Read this before writing Malloy code.** These patterns cause most modeling errors.\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## Reserved Words: Backtick Them\n\n**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.\n\n```malloy\n// WRONG // RIGHT\ndimension: d is Date::date dimension: d is `Date`::date\n```\n\nWords most likely to appear as column names:\n```\ndate, time, day, month, year, quarter, week, hour, minute, second,\nnumber, string, boolean, type, table, source, index, count, sum, avg, min, max,\ntrue, false, null, is, on, with, all, from, by, in, to, for, select, order_by,\ntop, bottom, desc, asc, row, range, current, window, rank\n```\n\n- `number`: only the bare word needs backticking; `account_number` is fine\n- `source`: reserved; use a different alias like `traffic_source`\n\n## NULL Checks: `is not null`, NOT `!= null`\n\n```malloy\n// WRONG // RIGHT\ndimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null\n```\n\n## Date Functions vs Properties\n\n```malloy\n// WRONG: day_of_week is a function // RIGHT\ndimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)\n```\n\n**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`\n**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`\n\n## `.date` Is a Cast, Not a Truncation\n\nCalendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches (\"truncated to date\" is a doc smell; it should say \"to day\").\n\n```malloy\n// WRONG // RIGHT\ncreated_at.date created_at.day // truncate to day\n created_at::date // cast to a date\n```\n\n## Interval Functions: `unit(start to end)`, and the unit decides the operand type\n\nAn interval is `unit(start to end)`. Two rules, both enforced by the compiler:\n\n- **Never subtract.** `days(a - b)` fails with `Can not offset time by 'date'`. The `to` form is the only one.\n- **Mixing a date and a timestamp needs a cast, unless the date side is a literal.** A date *literal* widens to a timestamp on its own, so `days(@2020-01-01 to now)` compiles. A date *column* does not: `days(signup_date to now)` fails with `Cannot measure from date to timestamp`. Cast the odd one out (`::date`, `::timestamp`). `now` is a timestamp.\n\nWhich units accept what:\n\n| Units | Kind | Operands |\n|-------|------|----------|\n| `seconds`, `minutes`, `hours`, `days` | clock | timestamps or dates |\n| `weeks`, `months`, `quarters`, `years` | calendar | **dates only**: on timestamps they fail with `Cannot measure interval using 'month' for 'timestamp' values; calendar interval measurement requires dates` |\n\n```malloy\n// WRONG: subtraction, and a calendar unit applied to timestamp columns\ndimension: gap is days(closed_at - opened_at)\ndimension: months_open is months(opened_at to closed_at)\n\n// WRONG: approximating a calendar unit that exists\ndimension: months_open is days(opened_at to closed_at) / 30.44\n\n// RIGHT\ndimension: days_open is days(opened_at to closed_at)\ndimension: months_open is months(opened_at::date to closed_at::date)\n```\n\nThe calendar units are real and exact. If one fails, read the message: it is telling you to cast the operands, not to divide by 30.44.\n\n## Safe Division: Always `nullif`\n\n```malloy\n// WRONG // RIGHT\na / b a / nullif(b, 0)\n```\n\n## String Columns Need Casts for Aggregates\n\n```malloy\n// WRONG: \"Can't use type string\" // RIGHT\nmeasure: avg_score is avg(score) measure: avg_score is avg(score::number)\n```\n\n**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):\n\n```malloy\n// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts\nmeasure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)\n```\n\nChain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.\n\n## Boolean Columns: No Quotes\n\n```malloy\n// WRONG // RIGHT\ncount() { where: complaint = 'true' } count() { where: complaint = true }\n```\n\nCheck schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.\n\n## `greatest()` / `least()` Are Null-Poisoning\n\nMalloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:\n\n```malloy\n// WRONG: one null input nulls the whole thing\ndimension: last_touch is greatest(email_at, call_at)\n\n// RIGHT: fall back so a null arg can't poison the result\ndimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at\n```\n\n## No Scalar Median; Raw-SQL Aggregates Don't Compile\n\n**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *\"Cannot use a scalar field in a measure declaration.\"* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.\n\n```malloy\n// DOES NOT COMPILE in this build (all forms resolve as scalar):\nmeasure: median_x is percentile_cont!(x, 0.5)\nmeasure: median_x is sql_number(\"PERCENTILE_CONT(...) ...\") { is_aggregate: true }\n```\n\n**Ship `avg` instead, or defer median with a documented gap** (\"median deferred: no scalar median / runtime rejects raw-SQL aggregates\"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.\n\n**`stddev` does work**, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a `measure:`, and it is the sample standard deviation. `variance`, `stddev_samp`, and `stddev_pop` are not Malloy functions, and pushing them through `!` fails as a scalar exactly like `percentile_cont!`.\n\n```malloy\n// WORKS: inline, or as a measure on a source\nrun: order_items -> { aggregate: sd is stddev(sale_price) }\nsource: items is order_items extend { measure: price_stddev is stddev(sale_price) }\n```\n\n## Field Management: `extend {}` and `include {}`, in that order\n\nMalloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` handles the renames.** They do compose, but only in one order: the `extend {}` that renames must come **before** the `include {}`, and `include {}` must name the field as it is *after* the rename.\n\n| Mechanism | Where it lives | Keywords | Experimental flag? |\n|---|---|---|---|\n| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | Yes (`##! experimental.access_modifiers`) |\n| Field management | `extend {}` | `accept:` / `except:` / `rename:` | No |\n\n### Default: `include {}` for documented, curated base sources\n\nUse `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.\n\n```malloy\n##! experimental.access_modifiers\nsource: orders is conn.table('orders') include {\n public:\n #(doc) Order identifier\n order_id\n\n #(doc) Customer who placed the order\n user_id\n\n internal:\n raw_payload_json // empty after JSON extraction\n legacy_status_code // superseded by status_code\n}\n```\n\n### When a `rename:` is needed: rename first, then `include {}`\n\nThe usual reason is a collision inside `include {}`: a measure cannot share a name with a raw column, even one tagged `internal:`, and the compiler says so (`Cannot redefine 'revenue' 'revenue' is internal`). The fix is to rename the raw column out of the way, which frees the name for the measure. Order is what makes it work:\n\n```malloy\n##! experimental.access_modifiers\n// RIGHT: rename frees `revenue`, include curates what is left, measure takes the name\nsource: orders is conn.table('orders')\n extend { rename: raw_revenue is revenue }\n include {\n #(doc) Revenue as loaded, before adjustments\n internal: raw_revenue\n public: order_id, user_id\n }\n extend { measure: revenue is raw_revenue.sum() }\n```\n\nTwo ways to get the order wrong, with the errors they produce:\n\n- **`include {}` before the renaming `extend {}`** fails with `Can't find field 'X' to set access modifier`, currently surfaced as an internal compiler error. `include` runs against names that no longer exist by the time the rename is applied.\n- **Naming the pre-rename column inside `include {}`** fails with `` `revenue` not found ``. After a rename only the new name exists; use it.\n\nYou do not have to give up `include {}` to get a rename: the curated surface, `#(doc)` on raw columns, and the `public/internal/private` tiers all survive. Renaming the *measure* instead is still worth considering when the raw column name is the one people know, but it is a modeling preference, not a workaround for a limitation.\n\n### `extend {}` clauses (reference)\n\n- **`accept:`**: allow-list, keep only the named columns\n- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)\n- **`rename:`**: alias a raw column to free up its original name for a measure or dimension\n\n### Migrating `conn.sql()` to `conn.table()` + Malloy clauses\n\nThe biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:\n\n1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.\n2. Switch to `conn.table('…')`.\n3. Hidden columns: `include { internal: ... }` (lets you also `#(doc)` the public columns). A `rename:` in the same source does not force you off `include {}` - see item 4 for the order.\n4. SQL aliases: an `extend { rename: ... }` before `include {}`, naming the field by its new name in `include {}` (they compose, but only in that order). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.\n5. SQL derivations: `dimension:` definitions in `extend {}`.\n6. SQL `WHERE`: source-level `where:`.\n\n## Cannot Redefine Query-Based Source Columns\n\nColumns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.\n\n```malloy\n// WRONG: \"Cannot redefine 'user_id'\"\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: user_id is user_id }\n// RIGHT: add only NEW derived dimensions\nsource: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }\n extend { dimension: is_high_value is total > 1000 }\n```\n\nTo add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.\n\n## Extending a Source Cannot Reuse a Name It Already Defines\n\n```malloy\n// WRONG: \"Cannot redefine 'overview'\" when sales already declares view: overview\nsource: wines is sales extend { view: overview is { aggregate: record_count } }\n// RIGHT: give the extension its own name\nsource: wines is sales extend { view: summary is { aggregate: record_count } }\n```\n\nAn extension adds to the parent's namespace, it does not override it. This bites when you extend a source to \"replace\" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same `Cannot redefine 'X'` for dimensions and measures that collide with an inherited name, per the sections above and below.\n\n## Never Use `conn.sql()` When Malloy Has a Native Pattern\n\n```malloy\n// WRONG: raw SQL for pre-aggregation\nsource: facts is conn.sql(\"\"\"SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id\"\"\")\n// RIGHT: Malloy query-based source\nsource: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }\n```\n\n**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.\n\n| Looks like it needs SQL | Malloy equivalent |\n|---|---|\n| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |\n| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |\n| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |\n| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |\n| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |\n| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |\n| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |\n| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |\n| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |\n\n**Genuinely valid `conn.sql()` candidates (rare):**\n\n- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)\n- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`\n\n**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.\n\nIf a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.\n\n## Relative Data-File Paths: Set an Absolute `workingDirectory`\n\n`duckdb.table('data/x.csv')` is resolved against the DuckDB connection's `workingDirectory`, not\nagainst the model file. Publisher sets that to the package root, so relative paths work there with\nno config at all. Every other host reads it from `malloy-config.json`, and **a relative value there\nis resolved against the process's current directory** - not the config file's directory, and not the\nVS Code workspace root. `canonicalizeConfigPath` in `malloy-db-duckdb/src/duckdb_config.ts` calls\n`canonicalizePath` with no `baseDirectory`, which is a bare `path.resolve(input)`.\n\nSo the same config works or fails depending on which directory the editor or shell was launched\nfrom. That is why this breaks intermittently and appears to be a model bug.\n\n```json\n// WRONG: resolved against the process cwd, so it works from the repo root and\n// fails from anywhere else - including however your editor happened to launch\n{\"connections\": {\"duckdb\": {\"is\": \"duckdb\", \"workingDirectory\": \"malloy\"}}}\n\n// RIGHT: absolute, so the cwd cannot change the answer\n{\"connections\": {\"duckdb\": {\"is\": \"duckdb\", \"workingDirectory\": \"/abs/path/to/pkg\"}}}\n```\n\nPoint it at the directory the model's table paths are written relative to - the package root, the\none holding `data/`. Keep the model's paths relative (`data/x.csv`) so Publisher still serves it;\nonly the config carries the absolute path.\n\n**The symptom, and the cascade.** One `IO Error` at the `source:` line, then a \"not defined\" error\nfor every field of that source:\n\n```\nline 87: IO Error: No files found that match the pattern \"data/product_usage.csv\"\nline 91: 'org_slug' is not defined\nline 93: Reference to undefined value active_users\n```\n\nThose field errors are not real. Fix the first error and they all go. Do not start renaming\ncolumns.\n\n**Check the config before the model** when a source that Publisher queries fine fails in the editor\nor CLI with a missing-file error. The model is the same file; the resolution base is not.\n\n## JSON Files: Read Them In Place Like CSV\n\n```malloy\n// RIGHT: .json works like .csv/.parquet\nsource: reviews is duckdb.table('data/reviews.json')\n// RIGHT: newline-delimited JSON is read the same way\nsource: events is duckdb.table('data/events.ndjson')\n// RIGHT: read options need read_json_auto in a SQL source\nsource: nested is duckdb.sql(\"\"\"SELECT * FROM read_json_auto('data/reviews.json')\"\"\")\n// WRONG: shelling out to python, or converting to CSV first\n```\n\nDuckDB reads JSON directly, so never preprocess a `.json` file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through `duckdb.table()`.\n\nQuirk: JSON carries no schema, so a value written as `\"90\"` arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):\n\n```malloy\nsource: reviews is duckdb.table('data/reviews.json') extend {\n dimension: points_num is points::number\n}\n```\n\n## Excel Files: Read `.xlsx` In Place, Never Convert\n\n```malloy\n// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside\n// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox\n// connection is `duckdb`)\nsource: budget is duckdb.table('data/budget.xlsx')\n// RIGHT for anything messier. Profile the top rows first to find the real header row and the\n// last real column, because nothing else will tell you where they are. Put the probe in the\n// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.\n// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',\n// range = 'A1:Z15', header = false, all_varchar = true)\nsource: sales is duckdb.sql(\"\"\"\n SELECT * FROM read_xlsx('data/sales.xlsx',\n sheet = 'Sales Data', -- EDIT: only the first sheet is read by default\n header = true,\n range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the\n ) -- last real column; the row bound just has to clear the end.\n WHERE \"Order ID\" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the\n\"\"\") -- read; drop it and every empty row in the range comes back.\n// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)\n```\n\nDo not convert spreadsheets before modeling. DuckDB's excel extension reads `.xlsx` directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than `duckdb.table()`. Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.\n\n**Plenty of real exports are not plain tables, and nothing tells you.** A report title, a \"generated on\" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare `aggregate: record_count is count()` against what you know is in the file, and `select: *; limit: 1` to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.\n\n`table()` takes a plain file path only, so anything needing `read_xlsx` options (`sheet`, `range`, `header`, `ignore_errors`, `normalize_names`, `all_varchar`, `empty_as_varchar`, `stop_at_empty`) goes through the SQL-source form.\n\nQuirks:\n\n- Only the FIRST sheet is read by default. Select another with `sheet = 'Name'`. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (`Sheet \"x\" not found ... Did you mean: \"Notes\"`), which is one way to find a name you were not given.\n- A title or banner row above the header collapses the read. DuckDB takes the first row it finds as the column names, so a lone title cell in A1 becomes the only column. How many rows you then get is the next quirk's business: whatever sits between the title and the first blank row, often none or one, otherwise a plausible-looking partial count. Pass a `range` that starts at the real header row.\n- With no `range`, `stop_at_empty` defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. `stop_at_empty = false` lifts that, but it only helps when the header really is in row 1; with a title above the header you need the `range` anyway, and a `range` flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.\n- A `range` reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (`A5:Z100000` on a ten-column sheet yields 26, the extras named `C10` and `_1` through `_15`), and past the last real row all-null rows (`A5:J100000` on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (`WHERE \"Order ID\" LIKE 'SO-%'`) rather than to `IS NOT NULL`, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.\n- Every number in an xlsx is stored as a double, so there are no integer columns. Typing is per column and decided by the FIRST data row, and `$1,234`, `12%` and `N/A` are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (`Could not convert string ... to DOUBLE`). `ignore_errors = true` fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.\n- Sample the column's SHAPES before writing any conversion, not its values: `run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc }` collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain `group_by raw_col; limit: 20` sorts lexicographically, which hides exactly the shapes that matter.\n- Convert in the SQL source, not in Malloy, where `::number` throws on the first bad cell. `try_cast(regexp_replace(\"Total Revenue\", '[^0-9.-]', '', 'g') AS double)` nulls what it cannot read instead of failing and is right for a plain `$1,234.56`, but it is not a general parser. It concatenates every digit in the cell, so `1,234 (see tab 2)` becomes 12342. It understands only a leading ASCII `-`, so an accounting `(1,234)`, a Unicode minus and a `CR` suffix all come back positive, while a trailing `-` (`1,234-`) comes back null and drops the row from the sum. And it assumes `.` is the decimal point, so a European `1.234,56` comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null (`'01/02/2023'` becomes 1022023).\n- Check the answer against the sheet's own total row, read as raw text. Lift the data-row filter and select the footer by its label, which usually sits in a different column from the one your data-row predicate uses: on one export `WHERE \"Customer Name\" = 'TOTAL'` finds it and `WHERE \"Order ID\" = 'TOTAL'` returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives `select: *`, and cancels out when both sides are parsed the same broken way.\n- A sheet with no header row whose first row is all text silently loses that row to header detection. Pass `header = false`.\n- Headers with spaces are kept verbatim: backtick them in Malloy, or pass `normalize_names = true` for snake_case names.\n- `all_varchar = true` hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: `'44929'` from a sheet Excel wrote, `'44927.0'` from one DuckDB's own xlsx writer wrote, and `'44929.5'` where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; `try_cast(... AS double)` accepts all three and returns null for a cell that was stored as text (`'01/02/2023'`), which is the test you want. Convert with `date '1899-12-30' + floor(try_cast(d AS double))::int`, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare `::int` rounds, so an afternoon timestamp would land on the next day.\n- A date column that mixes both, which is what an export edited by hand gives you, needs both branches or you silently lose every row of one kind: `CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END`. Without `all_varchar`, a uniformly date-formatted column arrives as real `date` and `timestamp` values, and a stray text cell behaves exactly as the typing rule above says. Note what `ignore_errors = true` does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.\n\n## Duplicate Rows: Check Before Building Measures\n\n```malloy\nrun: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }\n```\n\nSymptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.\n\n## Mixed-Grain Joins: A Pre-Aggregated Source Ignores Your Filters\n\nJoining an aggregate-grain source (a decade/month/region summary table) into a detail-grain source produces values that do **not** respond to the query's filters. Malloy's symmetric aggregates prevent fan-out; they cannot prevent this, because the joined value is unfiltered *by construction*: it was computed over the whole population before the query ran.\n\n```\nrun: track_analysis -> {\n where: genre = 'Rock'\n group_by: decade\n aggregate: track_count // filtered: Rock only -> 701\n group_by: decade_trends.decade_track_count // unfiltered population -> 1,088\n}\n```\n\nTwo count-shaped numbers side by side, one filtered and one not; read as \"701 of 1,088 Rock tracks\" it is simply wrong: 1,088 is every genre. Two legitimate resolutions:\n\n- **Keep the join as a population baseline** when comparing a row to the whole population is the intent (e.g. `energy_vs_decade`). Then every joined field's `#(doc)` must say it is a fixed population value that does not respond to filters, and count-shaped fields with no comparison purpose (like `decade_track_count`) should be `internal:`; they only invite the misreading.\n- **Compute the aggregate as a query-based source from the detail table** so it derives from one source of truth and the derivation is visible.\n\nThis is the modeling-time consequence of ignoring `skill:malloy-scope`'s advice to skip pre-aggregated snapshot tables and compute fresh in Malloy instead.\n\n## Thresholds Are Decisions, Not Syntax\n\nBefore writing a `pick` expression or filtered measure with a numeric cutoff, see `skill:malloy-model` § Key Rules: every boundary must be user-supplied, distribution-derived (query the percentiles first), or explicitly flagged as an assumption in its `#(doc)`. Never invent one silently.\n\n## `except:` Removes Fields From Namespace Entirely\n\n`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.\n\n```malloy\n// WRONG: dimension references excluded field\nsource: x is conn.table('t')\ninclude { except: raw_date }\nextend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone\n\n// RIGHT: internal fields are still available in extend\nsource: x is conn.table('t')\ninclude { internal: raw_date }\nextend { dimension: order_date is raw_date::date } // Works\n```\n\n## Source Order: Define Joined Tables First\n\nMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.\n\n## MUST Search Docs Before Using Unfamiliar Patterns\n\nCall `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:\n- `pick` expressions\n- Window functions (`calculate`)\n- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median (`stddev` is the exception and does work as a measure)\n- Time interval functions (`days()`, `months()`): always `unit(start to end)`, and calendar units need date operands (see above)\n- Query-based sources (`source: x is (q -> {...}) extend {...}`; `from()` was removed and no longer parses)\n- `!` operator / `sql_number()`"
|
|
295965
297950
|
},
|
|
295966
297951
|
{
|
|
295967
297952
|
name: "malloy-gotchas-queries",
|
|
@@ -296571,7 +298556,7 @@ Available: _concepts, build-derived-tables, build-unnest, curate-visibility, dis
|
|
|
296571
298556
|
{
|
|
296572
298557
|
name: "malloy-lookml-review/_concepts",
|
|
296573
298558
|
description: "LookML → Malloy Concept Mapping. Reference detail for the malloy-lookml-review skill.",
|
|
296574
|
-
body: "# LookML → Malloy Concept Mapping\n\nReference table for translating LookML constructs to Malloy. Referenced by multiple reference files.\n\n| LookML | Malloy | Notes |\n|--------|--------|-------|\n| `view:` | `source:` (base source file) | One source per physical table |\n| `explore:` | `source:` (source file with joins) | One source per analytical domain |\n| `dimension:` | `dimension:` | Direct mapping |\n| `dimension_group: { type: time }` | `.month`, `.year`, `::date` (native) | Malloy handles time natively; no explicit timeframe list needed |\n| `dimension: { type: yesno }` | `dimension: x is condition` | Boolean expression |\n| `measure: { type: count }` | `count()` | Always distinct in Malloy |\n| `measure: { type: count_distinct }` | `count(field)` | Direct mapping |\n| `measure: { type: sum }` | `sum(field)` | Direct mapping |\n| `measure: { type: average }` | `avg(field)` | Direct mapping |\n| `measure: { type: number }` | Derived measure expression | Usually a ratio; use `nullif()` for division |\n| `measure: { filters: [...] }` | `measure { where: condition }` | Filtered aggregate |\n| `primary_key: yes` | `primary_key: field_name` | Direct mapping |\n| `hidden: yes` | `# hidden` tag (cosmetic) | Classify reason first; see `curate-visibility.md` |\n| `fields` exclusion (explore/join) | `internal:` (with access modifiers) | Structurally excluded; `internal:` candidate |\n| `required_access_grants` | `private:` (with access modifiers) | Security-restricted; `private:` candidate |\n| `description:` | `#(doc)` tag | Direct mapping |\n| `label:` (simple rename) | `internal:` old + `dimension: new_name is old_name` | Lighter than `rename:`, and keeps the raw column reachable |\n| `label:` (complex) | `# label=\"Display Name\"` | When name differs from identifier |\n| `sql_table_name:` | `conn.table('schema.table')` | Use the connection name from the model definition if available |\n| `join: { relationship: many_to_one }` | `join_one:` | Direct mapping |\n| `join: { relationship: one_to_one }` | `join_one:` | Direct mapping |\n| `join: { relationship: one_to_many }` | `join_many:` | Direct mapping |\n| `join: { relationship: many_to_many }` | `join_cross:` | Direct mapping |\n| `sql_on: ${a.field} = ${b.field}` | `on a_field = b.b_field` | Translate `${}` references |\n| `CASE WHEN ... END` (in SQL) | `pick ... when ... else` | Direct syntax translation |\n| `COALESCE(a, b)` | `a ?? b` | Direct mapping |\n| `IFNULL(a, b)` | `a ?? b` | Direct mapping |\n| `${TABLE}.field` | `field` (direct column reference) | Malloy references columns directly |\n| `${view_name.field}` | `view_name.field` (join path) | In join conditions and cross-source refs |\n| `+view:` (refinement) | User decides: consolidate or `extend` | Malloy `extend` serves the same purpose |\n| `derived_table: { sql: ... }` (perf-only) | Use base table directly | PDT optimization is Looker-specific |\n| `derived_table: { sql: ... }` (transformation) | Flag for user | Recommend base table + dims or upstream dbt |\n| `derived_table: { explore_source: ... }` (NDT) | `
|
|
298559
|
+
body: "# LookML → Malloy Concept Mapping\n\nReference table for translating LookML constructs to Malloy. Referenced by multiple reference files.\n\n| LookML | Malloy | Notes |\n|--------|--------|-------|\n| `view:` | `source:` (base source file) | One source per physical table |\n| `explore:` | `source:` (source file with joins) | One source per analytical domain |\n| `dimension:` | `dimension:` | Direct mapping |\n| `dimension_group: { type: time }` | `.month`, `.year`, `::date` (native) | Malloy handles time natively; no explicit timeframe list needed |\n| `dimension: { type: yesno }` | `dimension: x is condition` | Boolean expression |\n| `measure: { type: count }` | `count()` | Always distinct in Malloy |\n| `measure: { type: count_distinct }` | `count(field)` | Direct mapping |\n| `measure: { type: sum }` | `sum(field)` | Direct mapping |\n| `measure: { type: average }` | `avg(field)` | Direct mapping |\n| `measure: { type: number }` | Derived measure expression | Usually a ratio; use `nullif()` for division |\n| `measure: { filters: [...] }` | `measure { where: condition }` | Filtered aggregate |\n| `primary_key: yes` | `primary_key: field_name` | Direct mapping |\n| `hidden: yes` | `# hidden` tag (cosmetic) | Classify reason first; see `curate-visibility.md` |\n| `fields` exclusion (explore/join) | `internal:` (with access modifiers) | Structurally excluded; `internal:` candidate |\n| `required_access_grants` | `private:` (with access modifiers) | Security-restricted; `private:` candidate |\n| `description:` | `#(doc)` tag | Direct mapping |\n| `label:` (simple rename) | `internal:` old + `dimension: new_name is old_name` | Lighter than `rename:`, and keeps the raw column reachable |\n| `label:` (complex) | `# label=\"Display Name\"` | When name differs from identifier |\n| `sql_table_name:` | `conn.table('schema.table')` | Use the connection name from the model definition if available |\n| `join: { relationship: many_to_one }` | `join_one:` | Direct mapping |\n| `join: { relationship: one_to_one }` | `join_one:` | Direct mapping |\n| `join: { relationship: one_to_many }` | `join_many:` | Direct mapping |\n| `join: { relationship: many_to_many }` | `join_cross:` | Direct mapping |\n| `sql_on: ${a.field} = ${b.field}` | `on a_field = b.b_field` | Translate `${}` references |\n| `CASE WHEN ... END` (in SQL) | `pick ... when ... else` | Direct syntax translation |\n| `COALESCE(a, b)` | `a ?? b` | Direct mapping |\n| `IFNULL(a, b)` | `a ?? b` | Direct mapping |\n| `${TABLE}.field` | `field` (direct column reference) | Malloy references columns directly |\n| `${view_name.field}` | `view_name.field` (join path) | In join conditions and cross-source refs |\n| `+view:` (refinement) | User decides: consolidate or `extend` | Malloy `extend` serves the same purpose |\n| `derived_table: { sql: ... }` (perf-only) | Use base table directly | PDT optimization is Looker-specific |\n| `derived_table: { sql: ... }` (transformation) | Flag for user | Recommend base table + dims or upstream dbt |\n| `derived_table: { explore_source: ... }` (NDT) | `(source -> { group_by:, aggregate: }) extend { }` | Computed source pattern |\n| `value_format: \"$#,##0.00\"` | `# currency` | Map to Malloy render tags |\n| `value_format: \"0.00%\"` | `# percent` | Map to Malloy render tags |\n| `value_format_name: decimal_2` | `# number=\"0.00\"` | Map to Malloy render tags |"
|
|
296575
298560
|
},
|
|
296576
298561
|
{
|
|
296577
298562
|
name: "malloy-lookml-review/build-derived-tables",
|
|
@@ -296585,7 +298570,7 @@ Available: _concepts, build-derived-tables, build-unnest, curate-visibility, dis
|
|
|
296585
298570
|
\`\`\`
|
|
296586
298571
|
derived_table:
|
|
296587
298572
|
├── explore_source: → NDT path
|
|
296588
|
-
│ ├── Simple aggregation → Malloy
|
|
298573
|
+
│ ├── Simple aggregation → Malloy (query) extend {}
|
|
296589
298574
|
│ ├── With derived_column: (window functions) → Malloy window function patterns
|
|
296590
298575
|
│ ├── Chained NDTs → dependency ordering, multi-stage computed source
|
|
296591
298576
|
│ └── With bind_filters → flag, no direct Malloy equivalent
|
|
@@ -296598,10 +298583,12 @@ derived_table:
|
|
|
296598
298583
|
|
|
296599
298584
|
### Simple Aggregation NDT
|
|
296600
298585
|
|
|
296601
|
-
Express the aggregation as a Malloy query, then build a source from it
|
|
298586
|
+
Express the aggregation as a Malloy query, then build a source from it by
|
|
298587
|
+
wrapping the query in parentheses and extending it (\`from(...)\` was removed from
|
|
298588
|
+
the language and no longer parses):
|
|
296602
298589
|
|
|
296603
298590
|
\`\`\`malloy
|
|
296604
|
-
source: source_name is
|
|
298591
|
+
source: source_name is (
|
|
296605
298592
|
base_source -> {
|
|
296606
298593
|
group_by: group_field
|
|
296607
298594
|
aggregate:
|
|
@@ -296969,7 +298956,7 @@ Present coverage in this order:
|
|
|
296969
298956
|
{
|
|
296970
298957
|
name: "malloy-materialization",
|
|
296971
298958
|
description: "Add and debug Malloy Persistence materializations in a package - persist an expensive source so queries read a pre-built table. Read this whenever the user wants to materialize a source, add a persist annotation, speed up a slow source, or asks why a persist source isn't building.",
|
|
296972
|
-
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built warehouse table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of two things - a `.malloy` file in the package missing the `##! experimental.persistence` flag (which aborts the *whole* package's build plan), or no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**). Jump to **Debugging a no-op build**.\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on EVERY `.malloy` file in the package** - not only the file that declares the persist source. Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why every file:** the build plan is computed by asking *every* `.malloy` file in the package for its persist sources, and that call **throws on any file whose model lacks the flag** (`Model must have ##! experimental.persistence`). One unflagged helper or import file, even one with no persist source of its own, aborts the whole package's build plan, so *every* persist source in the package drops out. This is the most common cause of a no-op build.\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql(\"...\")`. This **includes** one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)`; a `#@ persist` on such a source is **silently ignored** (its annotation is never read) - that one source just won't materialize, and the rest of the package still builds.\n - **Quote the name.** `name=\"my_table\"` (or a path `name=\"dataset.table\"` / `name=\"project.dataset.table\"`) is required. A **bare** `name=my_table` **always fails the build/publish** with `persist annotation name must be quoted` (a raw-source scan that hard-stops); it never silently no-ops.\n - `name=` is the target table name. In a standalone Publisher this **is** the physical table (rebuilt in place); a hosted (control-plane) deployment builds it under a content-addressed generation name. In both, the source's identity for reuse is a content address of its connection and canonical SQL (its `sourceEntityId`), so **republishing unchanged persist logic reuses the existing table** and changing the logic builds fresh.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" },\n \"queryMetadata\": { \"team\": \"finance\" } // tags the build's backend statements\n }\n }\n ```\n Enforced at publish (strict), on edits (strict), at load (warn, still serves), and by the scheduler (an offending package is skipped):\n - **`scope`**: `package` (default; artifacts reused across published versions) or `version` (each artifact owned by one version). Package-level only; there is no per-source scope. A root-level `scope` is the deprecated home and still works, with a warning; declaring both homes with different values is rejected.\n - **`materialization.freshness`** (`window` + `fallback` of `live`/`stale_ok`/`fail`) is the objective a **hosted control plane** enforces by refreshing the table to meet it (`fallback: \"live\"` serves live compute while stale/absent). A **standalone** Publisher does **not** act on `freshness` for refresh - see **Building and refreshing**.\n - **`materialization.queryMetadata`** is a bag of string properties attached to every statement the build issues, for the backend's own cost attribution (Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere). Overridable per source with `#@ persist queryMetadata.<name>=\"<value>\"`. Observability only: it never changes what gets built. See `docs/query-metadata.md`.\n - **`materialization.schedule`** is a 5-field UTC cron (`min hour dom mon dow`; `L`/`W`/`#`/`?` rejected). It **requires `scope: \"version\"`** and is **mutually exclusive with `freshness`**. This is how a standalone Publisher refreshes on a cadence.\n\n4. **Reads vs writes.** The persist source can *read* any dataset the connection can read; the persist *target* (`name=`'s dataset) must be a dataset the connection can **write** (typically a scratch dataset).\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Serve-time routing is `query_source`-only (today)\n\nBoth persistable types *build* a table, but only a **`query_source`** (a `-> { ... }` pipeline) is rewritten to *read* it at query time. A raw **`sql_select`** (`conn.sql(\"...\")`, including `conn.sql(\"...\") extend { ... }`) builds its table and then the query path re-inlines its SQL, so the table is built and never read, and queries are no faster. If you have raw SQL you want served from a table, wrap it in a thin `query_source` and persist that:\n\n```malloy\nsource: x_raw is my_conn.sql(\"select ...\")\n#@ persist name=\"scratch_dataset.x\"\nsource: x is x_raw -> { select: * }\n```\n\n## Confirming it worked\n\nAfter a build runs, re-run one of the source's queries - a persisted `query_source` should return quickly, reading the pre-built table instead of recomputing the upstream. Your host also reports each persisted source as **ready** with its physical table name (a materialization run detail, CLI listing, or materialization view, depending on the host); if nothing is listed, either no build ran (standalone) or the build plan was empty - see **Debugging a no-op build**.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run.\n1. **A `.malloy` file missing the persistence flag** (the most common real bug). Every model file's `##!` line needs `persistence`, including pure helper/import files with no persist source - one unflagged file aborts the whole package's build plan.\n2. **An unquoted persist name** - a bare `name=foo` **always** hard-stops the build/publish with `persist annotation name must be quoted`; use `name=\"foo\"`. (If you got *no* error at all, it isn't this.)\n3. **A `#@ persist` on a non-persistable source** - a bare `extend` over `conn.table(...)` is silently ignored, so *that* source won't materialize (the rest of the package is unaffected). Tag a `query_source` / `sql_select` instead.\n4. **A persisted raw `sql_select` that builds but is never read** - if the table exists yet queries are no faster, it's the serve-routing gap above; wrap the `sql_select` in a `query_source`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\n- If **even this** doesn't build (after a real materialization run), the whole package's plan is aborting - a sibling `.malloy` file is missing the flag. Fix rule 1 across the package.\n- If the smoke source **does** build but your real one doesn't, your real source is the problem - a non-persistable type (a bare `extend`), or its own file's flag.\n\nDelete the smoke file and drop its table afterward.\n\n## Persisting an `#(authorize)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be\ncareful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, with a 422 at build time naming the\n source. (`#@ persist storage=<name>` is the tier that materializes into a separate registered storage\n destination and serves from there, rather than building in the source's own connection; `#@ preaggregate`\n stores a rollup Publisher derives from a measure you annotated with a grain, rather than a source you\n wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards even\n in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's\n **own row filter**. It is refused when the gate is reached only through a join, inherited from a base\n the compiler cannot attribute cleanly, or does not classify as a row filter at all. The gate is found\n through the import -> rename -> `query_source` chain, so a gate the persisted source did not declare\n itself still counts.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and\nthe gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered.\nWhat freezes is the **column the gate filters on**. A row whose access decision changes - it changes\nowner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access\ndecision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what\nneeds a bound is how long a *stale* decision can survive. Of the three controls that look like that\nbound, only the first is:\n\n- **`materialization.freshness` `{ \"window\": \"24h\", \"fallback\": \"live\" }` is the bound.** The serve path\n re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set\n and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Two details\n decide whether you actually get that. **`fallback` must be `live`**: under `stale_ok` a stale artifact\n keeps being served, which voids the bound, and window and fallback resolve *independently* per layer,\n so a package-level `stale_ok` silently defeats a window you set on the source. And prefer the\n **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over\n the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting\n it package-wide forces every other persisted source to recompute once stale too.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old\n decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the\n window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in\n `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never\n re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full\n rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a\ncontrol plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the\ntable with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone\ndeployment the declared window is inert and the artifact serves until the next full rebuild - which leaves a\nrebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source\nunpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what\nstaleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no\nbound on how long a revoked row keeps being served.\n\n## Gotchas\n\n- **Every `.malloy` file needs the persistence flag** - one unflagged file aborts the whole package's build plan. (A `#@ persist` on a *non*-persistable source, by contrast, is silently ignored and does not affect other sources.)\n- **A tag doesn't build** - a standalone Publisher materializes only on an explicit run or its scheduler; only a hosted control plane builds on publish.\n- **Serve-time routing is `query_source`-only** - a raw `sql_select` builds a table the query path doesn't read; wrap it in a `query_source`.\n- **Quote the name** - a bare `name=` always hard-stops the build.\n- **Republishing unchanged persist logic reuses the table** - reuse is keyed on the content-addressed `sourceEntityId`, not the `name=`.\n- **Removing a persist source (or a smoke test) does not drop its table** - physical-table cleanup is the caller's responsibility; drop it yourself.\n- **An `#(authorize)`-gated source freezes its gating column when persisted** - the gate still runs live, but a revoked row keeps being served under its old access decision until the next rebuild. `storage=` and `#@ preaggregate` refuse a gated source outright. See **Persisting an `#(authorize)`-gated source**."
|
|
298959
|
+
body: "# Materialization (Malloy Persistence)\n\nMaterialize an expensive source once so queries read a **pre-built warehouse table** instead of recomputing it every time. You tag a source `#@ persist`, a materialization run builds it into a physical table, and queries against it are rewritten to read that table.\n\n> **The #1 gotcha, up front:** if a persist source isn't materializing, it is almost always one of two things - a `.malloy` file in the package missing the `##! experimental.persistence` flag (which aborts the *whole* package's build plan), or no build ever ran (a standalone Publisher does not build on publish - see **Building and refreshing**). Jump to **Debugging a no-op build**.\n\n## The recipe (get this right and it just works)\n\n1. **`##! experimental.persistence` on EVERY `.malloy` file in the package** - not only the file that declares the persist source. Either form enables it:\n - `##! experimental.persistence`, or\n - `##! experimental { access_modifiers, sql_functions, persistence }` (add `persistence` to the existing list).\n\n **Why every file:** the build plan is computed by asking *every* `.malloy` file in the package for its persist sources, and that call **throws on any file whose model lacks the flag** (`Model must have ##! experimental.persistence`). One unflagged helper or import file, even one with no persist source of its own, aborts the whole package's build plan, so *every* persist source in the package drops out. This is the most common cause of a no-op build.\n\n2. **`#@ persist name=\"...\"` on a query-based source, with the name quoted:**\n ```malloy\n #@ persist name=\"my_dataset.my_table\"\n source: my_rollup is some_source -> { group_by: ...; aggregate: ... }\n ```\n - **Only `query_source` and `sql_select` sources are persistable** - a source whose definition has a `-> { ... }` pipeline or a `conn.sql(\"...\")`. This **includes** one refined by a trailing `extend { ... }`. What is **not** persistable is a *plain* `extend` over a bare `conn.table(...)`; a `#@ persist` on such a source is **silently ignored** (its annotation is never read) - that one source just won't materialize, and the rest of the package still builds.\n - **Quote the name.** `name=\"my_table\"` (or a path `name=\"dataset.table\"` / `name=\"project.dataset.table\"`) is required. A **bare** `name=my_table` **always fails the build/publish** with `persist annotation name must be quoted` (a raw-source scan that hard-stops); it never silently no-ops.\n - `name=` is the target table name. In a standalone Publisher this **is** the physical table (rebuilt in place); a hosted (control-plane) deployment builds it under a content-addressed generation name. In both, the source's identity for reuse is a content address of its connection and canonical SQL (its `sourceEntityId`), so **republishing unchanged persist logic reuses the existing table** and changing the logic builds fresh.\n\n3. **Package persistence policy in `publisher.json`** (all optional):\n ```jsonc\n {\n \"name\": \"my-package\",\n \"materialization\": {\n \"scope\": \"package\", // default; \"version\" = each published version owns its own tables\n \"freshness\": { \"window\": \"24h\", \"fallback\": \"live\" },\n \"queryMetadata\": { \"team\": \"finance\" } // tags the build's backend statements\n }\n }\n ```\n Enforced at publish (strict), on edits (strict), at load (warn, still serves), and by the scheduler (an offending package is skipped):\n - **`scope`**: `package` (default; artifacts reused across published versions) or `version` (each artifact owned by one version). Package-level only; there is no per-source scope. A root-level `scope` is the deprecated home and still works, with a warning; declaring both homes with different values is rejected.\n - **`materialization.freshness`** (`window` + `fallback` of `live`/`stale_ok`/`fail`) is the objective a **hosted control plane** enforces by refreshing the table to meet it (`fallback: \"live\"` serves live compute while stale/absent). A **standalone** Publisher does **not** act on `freshness` for refresh - see **Building and refreshing**.\n - **`materialization.queryMetadata`** is a bag of string properties attached to every statement the build issues, for the backend's own cost attribution (Snowflake `QUERY_TAG`, BigQuery job labels, a leading SQL comment elsewhere). Overridable per source with `#@ persist queryMetadata.<name>=\"<value>\"`. Observability only: it never changes what gets built. See `docs/query-metadata.md`.\n - **`materialization.schedule`** is a 5-field UTC cron (`min hour dom mon dow`; `L`/`W`/`#`/`?` rejected). It **requires `scope: \"version\"`** and is **mutually exclusive with `freshness`**. This is how a standalone Publisher refreshes on a cadence.\n\n4. **Reads vs writes.** The persist source can *read* any dataset the connection can read; the persist *target* (`name=`'s dataset) must be a dataset the connection can **write** (typically a scratch dataset).\n\n## Building and refreshing (standalone vs. hosted)\n\nA `#@ persist` tag declares *what* to materialize; it does not by itself build anything.\n\n- **Standalone Publisher:** publishing or loading a package only computes its build plan - **no table is built until a materialization run executes.** Trigger one explicitly (`malloy-pub materialize --package <pkg> --wait`, or the materialization API), or turn on the opt-in local scheduler (off unless `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is set) to fire the package's `schedule` cron. Refresh is a re-run or that cron; `freshness` is not a refresh trigger here, so a freshness-only standalone package builds once and is not auto-refreshed.\n- **Hosted (control-plane) deployment:** the build runs automatically on publish, best-effort - a build failure does **not** fail the publish (which is why a broken persist can look like a silent no-op), and the control plane drives refresh to meet the `freshness` objective.\n\nEither way, a successful publish alone does not prove a table exists - confirm the build separately.\n\n## Serve-time routing is `query_source`-only (today)\n\nBoth persistable types *build* a table, but only a **`query_source`** (a `-> { ... }` pipeline) is rewritten to *read* it at query time. A raw **`sql_select`** (`conn.sql(\"...\")`, including `conn.sql(\"...\") extend { ... }`) builds its table and then the query path re-inlines its SQL, so the table is built and never read, and queries are no faster. If you have raw SQL you want served from a table, wrap it in a thin `query_source` and persist that:\n\n```malloy\nsource: x_raw is my_conn.sql(\"select ...\")\n#@ persist name=\"scratch_dataset.x\"\nsource: x is x_raw -> { select: * }\n```\n\n## Confirming it worked\n\nAfter a build runs, re-run one of the source's queries - a persisted `query_source` should return quickly, reading the pre-built table instead of recomputing the upstream. Your host also reports each persisted source as **ready** with its physical table name (a materialization run detail, CLI listing, or materialization view, depending on the host); if nothing is listed, either no build ran (standalone) or the build plan was empty - see **Debugging a no-op build**.\n\n## Debugging a no-op build\n\nSymptom: no table was built and the source still recomputes on every query. Check, in order:\n\n0. **Did a build actually run?** On a standalone Publisher, publish/load does **not** build - run `malloy-pub materialize` (or enable the scheduler). \"Publishes fine, no table\" is the *expected* standalone state, not a model bug. On a hosted deployment the build is automatic but best-effort, so a failure is silent - look for a `FAILED` run.\n1. **A `.malloy` file missing the persistence flag** (the most common real bug). Every model file's `##!` line needs `persistence`, including pure helper/import files with no persist source - one unflagged file aborts the whole package's build plan.\n2. **An unquoted persist name** - a bare `name=foo` **always** hard-stops the build/publish with `persist annotation name must be quoted`; use `name=\"foo\"`. (If you got *no* error at all, it isn't this.)\n3. **A `#@ persist` on a non-persistable source** - a bare `extend` over `conn.table(...)` is silently ignored, so *that* source won't materialize (the rest of the package is unaffected). Tag a `query_source` / `sql_select` instead.\n4. **A persisted raw `sql_select` that builds but is never read** - if the table exists yet queries are no faster, it's the serve-routing gap above; wrap the `sql_select` in a `query_source`.\n\n**Isolation test** - add a trivial, self-contained persist source in its own file and rebuild:\n```malloy\n##! experimental.persistence\nsource: smoke_raw is my_conn.table('some_dataset.some_table')\n#@ persist name=\"scratch_dataset.persist_smoke_test\"\nsource: persist_smoke is smoke_raw -> { aggregate: n is count() }\n```\n- If **even this** doesn't build (after a real materialization run), the whole package's plan is aborting - a sibling `.malloy` file is missing the flag. Fix rule 1 across the package.\n- If the smoke source **does** build but your real one doesn't, your real source is the problem - a non-persistable type (a bare `extend`), or its own file's flag.\n\nDelete the smoke file and drop its table afterward.\n\n## Persisting an `#(authorize)`-gated source\n\nA gated source **can** be persisted, but only on one tier and only in one shape, and the thing to be\ncareful about is not refused by anything - you have to decide it.\n\n- **`storage=` and `#@ preaggregate` always refuse a gated source**, with a 422 at build time naming the\n source. (`#@ persist storage=<name>` is the tier that materializes into a separate registered storage\n destination and serves from there, rather than building in the source's own connection; `#@ preaggregate`\n stores a rollup Publisher derives from a measure you annotated with a grain, rather than a source you\n wrote.) A rollup also groups *across* the gated column, so it could not be row-filtered afterwards even\n in principle.\n- **A colocated `#@ persist` (no `storage=`) is admitted** when the gate is provably the entry point's\n **own row filter**. It is refused when the gate is reached only through a join, inherited from a base\n the compiler cannot attribute cleanly, or does not classify as a row filter at all. The gate is found\n through the import -> rename -> `query_source` chain, so a gate the persisted source did not declare\n itself still counts.\n\n**What to be wary of.** Persisting does not weaken the gate: it changes only where rows are read FROM, and\nthe gate still runs live on every query as that query's own `WHERE`, so filtered rows come back filtered.\nWhat freezes is the **column the gate filters on**. A row whose access decision changes - it changes\nowner, say - keeps being served under its OLD decision until the next rebuild. That is a stale *access\ndecision*, not merely stale data, and nothing raises an error.\n\n**None of this is needed for the gate to work.** It is enforced live on every query either way; what\nneeds a bound is how long a *stale* decision can survive. Of the three controls that look like that\nbound, only the first is:\n\n- **`materialization.freshness` `{ \"window\": \"24h\", \"fallback\": \"live\" }` is the bound.** The serve path\n re-checks freshness per query, so once the artifact ages past the window it drops out of the serving set\n and the query recomputes live, correctly filtered - whether or not a rebuild ever lands. Three details\n decide whether you actually get that. **`fallback` must be `live`**: under `stale_ok` a stale artifact\n keeps being served, which voids the bound, and window and fallback resolve *independently* per layer,\n so a package-level `stale_ok` silently defeats a window you set on the source. That is a statement about\n **layers**, which do not combine - not about siblings, below. Prefer the\n **per-source** spelling `#@ persist name=\"...\" freshness.window=\"24h\" freshness.fallback=\"live\"` over\n the package-wide `materialization.freshness` key: the gated source is what needs the bound, and setting\n it package-wide forces every other persisted source to recompute once stale too. And **a\n content-identical sibling shares the artifact, so it shares the window**: reuse is keyed on the\n content-addressed `sourceEntityId`, which folds the connection and the SQL but *not* the source name, so\n two persist sources whose bodies compute the same SQL resolve to one table carrying one freshness\n policy. The tightest window any of them declares governs all of them - a sibling declaring nothing\n cannot loosen yours, and yours pulls that sibling's reads off the table once it lapses. A sibling's\n `stale_ok` cannot void your bound either: the fold keeps whichever fallback bounds staleness, so the\n layer rule above does not carry over here. If two sources need genuinely different windows, give them\n genuinely different SQL.\n\n Both of those are properties of the **host** that assembles the manifest, not of the annotation. Where\n the host does not fold, which sibling's policy reaches the wire is unspecified; and a host that folds at\n manifest-assembly time typically applies it when a version's manifest is next published rather than\n retroactively to manifests already distributed - so you can declare the window correctly and not have it\n in force yet.\n- **A cron alone is not a bound.** A failed build or a stopped scheduler leaves the source serving its old\n decisions indefinitely. `freshness` and `schedule` are mutually exclusive; for a gated source, take the\n window.\n- **`refresh=\"incremental\"` does not bound revocation.** The delta only re-reads rows in\n `[covered_through, frontier)`, so a row that changes owner *without its watermark advancing* is never\n re-read - while the entry still reports an advancing `coveredThrough` and reads as healthy. Only a full\n rebuild recomputes the gating column.\n\n**And the window only binds where the serving manifest carries it.** Freshness is enforced from fields a\ncontrol plane stamps onto the manifest it distributes; a Publisher that serves what it just built binds the\ntable with no `dataAsOf` and no window, and an entry carrying no window never ages out. So on a standalone\ndeployment the declared window is inert and the artifact serves until the next full rebuild - which leaves a\nrebuild cadence you actually verify as the only bound, and makes leaving a revocation-sensitive source\nunpersisted the safer call.\n\nWhen recommending `#@ persist` on a gated source, pair it with a freshness window and say out loud what\nstaleness the author is accepting. A gated source with neither a window nor a full-rebuild cadence has no\nbound on how long a revoked row keeps being served.\n\n## Gotchas\n\n- **Every `.malloy` file needs the persistence flag** - one unflagged file aborts the whole package's build plan. (A `#@ persist` on a *non*-persistable source, by contrast, is silently ignored and does not affect other sources.)\n- **A tag doesn't build** - a standalone Publisher materializes only on an explicit run or its scheduler; only a hosted control plane builds on publish.\n- **Serve-time routing is `query_source`-only** - a raw `sql_select` builds a table the query path doesn't read; wrap it in a `query_source`.\n- **Quote the name** - a bare `name=` always hard-stops the build.\n- **Republishing unchanged persist logic reuses the table** - reuse is keyed on the content-addressed `sourceEntityId`, not the `name=`.\n- **Removing a persist source (or a smoke test) does not drop its table** - physical-table cleanup is the caller's responsibility; drop it yourself.\n- **An `#(authorize)`-gated source freezes its gating column when persisted** - the gate still runs live, but a revoked row keeps being served under its old access decision until the next rebuild. `storage=` and `#@ preaggregate` refuse a gated source outright. See **Persisting an `#(authorize)`-gated source**."
|
|
296973
298960
|
},
|
|
296974
298961
|
{
|
|
296975
298962
|
name: "malloy-materialization-tuning",
|
|
@@ -297083,10 +299070,12 @@ extend {
|
|
|
297083
299070
|
|
|
297084
299071
|
### Computed Source (from Query)
|
|
297085
299072
|
|
|
299073
|
+
Wrap the query in parentheses and extend it. \`from(...)\` was removed from the language and no longer parses (\`unexpected 'from'\`).
|
|
299074
|
+
|
|
297086
299075
|
\`\`\`malloy
|
|
297087
299076
|
import "orders.malloy"
|
|
297088
299077
|
|
|
297089
|
-
source: user_order_facts is
|
|
299078
|
+
source: user_order_facts is (
|
|
297090
299079
|
orders -> {
|
|
297091
299080
|
group_by: customer_id
|
|
297092
299081
|
aggregate:
|
|
@@ -297943,7 +299932,7 @@ Steps to follow when the user asks a question:
|
|
|
297943
299932
|
- If the question can be answered by a query already in the notebook, run that cell's query via \`execute_query\` (exact code, or a minor variation like adding a filter or changing a group_by).
|
|
297944
299933
|
- If the question asks for an analysis that is clearly NOT in the notebook (new source, different package, different domain), then, and only then, call \`get_context\` to explore.
|
|
297945
299934
|
- Do NOT call \`get_context\` as a default first step. The notebook already tells you what's available.
|
|
297946
|
-
4. Before writing or modifying a query, read the \`malloy-queries\` skill for syntax patterns. When you tweak a query (add a \`where:\` clause, change a \`group_by\`, etc.), do NOT add \`#(filter)\` annotations:
|
|
299935
|
+
4. Before writing or modifying a query, read the \`malloy-queries\` skill for syntax patterns. When you tweak a query (add a \`where:\` clause, change a \`group_by\`, etc.), do NOT add \`#(filter)\` annotations or \`given:\` declarations: both live on the source's model file and are inherited by this notebook automatically. Query-level \`where:\` filtering inside a cell is fine; declaring a new runtime parameter is a model change, not a chat-time change.
|
|
297947
299936
|
5. Summarize insights from query results. Do not echo raw rows: the user sees them rendered.`
|
|
297948
299937
|
},
|
|
297949
299938
|
{
|
|
@@ -298580,7 +300569,7 @@ For every rule, the linked instruction-skill section is the canonical source for
|
|
|
298580
300569
|
- **Why this isn't a correctness rule.** The Malloy compiler auto-synthesizes a UUID-based \`__distinct_key\` for any \`join_one:\` target without a declared PK (\`packages/malloy/src/model/field_instance.ts:644-670\`, \`query_query.ts:946-951\`), so symmetric aggregation is correct either way, see the C-07 entry in \`rubric-correctness.md\`'s "Rules we dropped" section. The \`with\` shortcut, which DOES require a declared PK, errors at compile time and surfaces as a diagnostic. The actual silent-correctness hazard ("declared PK isn't actually unique in the data") is \`rubric-correctness.md\` § C-12.
|
|
298581
300570
|
- **What this rule catches.** A discoverability / hygiene gap: when a source has a natural primary key, declaring it lets downstream code use the \`with\` shortcut, makes grain explicit in the model, and gives tooling a stable identifier per row. Treat as a recommendation, not a merge gate.
|
|
298582
300571
|
- **Detection:** for every \`source:\` declaration, check whether its body contains a \`primary_key:\` clause. Skip flagging when:
|
|
298583
|
-
- The source is a query-based / computed source (\`source: x is t -> {...}\` or \`source: x is
|
|
300572
|
+
- The source is a query-based / computed source (\`source: x is t -> {...}\` or \`source: x is (t -> {...}) extend {...}\`) where grain is determined by the \`group_by\` columns, declaring a \`primary_key:\` on the result is fine but not required.
|
|
298584
300573
|
- The source represents an event/log table or a denormalized analytical source where no natural PK exists. Both situations are legitimate; the LLM should recognize them and skip the finding.
|
|
298585
300574
|
- **Fix (when the source does have a natural PK):** declare it, \`primary_key: <col>\` inside \`extend {}\`. When there isn't a natural PK, leave it undeclared; if the absence is non-obvious, a one-line \`#(doc)\` on the source explaining the grain helps future readers.
|
|
298586
300575
|
- **See:** \`skill:malloy-model\` § Key Rules · \`rubric-correctness.md\` § C-12 (the related correctness rule that checks whether a declared PK is actually unique in the data) · \`rubric-style.md\` § Y-03 (\`join_one:\` style consistency, which is the other consequence of declared-vs-undeclared PKs)
|
|
@@ -299274,6 +301263,39 @@ function parseNonNegativeIntParam(value) {
|
|
|
299274
301263
|
const parsed = parseInt(String(value), 10);
|
|
299275
301264
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
299276
301265
|
}
|
|
301266
|
+
function parseBooleanParam(value) {
|
|
301267
|
+
if (value === undefined || value === null)
|
|
301268
|
+
return { ok: true, value: false };
|
|
301269
|
+
if (value === "true")
|
|
301270
|
+
return { ok: true, value: true };
|
|
301271
|
+
if (value === "false")
|
|
301272
|
+
return { ok: true, value: false };
|
|
301273
|
+
return { ok: false };
|
|
301274
|
+
}
|
|
301275
|
+
function invalidBooleanMessage(name, value, method, routePath) {
|
|
301276
|
+
return `Invalid ${name} value ${JSON.stringify(value)}: expected "true" or ` + `"false". Fix: ${method} ${routePath}?${name}=true.`;
|
|
301277
|
+
}
|
|
301278
|
+
|
|
301279
|
+
// src/route_params.ts
|
|
301280
|
+
init_errors();
|
|
301281
|
+
function optionalBooleanParamOr400(req, res, name) {
|
|
301282
|
+
const raw = req.query[name];
|
|
301283
|
+
const parsed = parseBooleanParam(raw);
|
|
301284
|
+
if (parsed.ok) {
|
|
301285
|
+
return { ok: true, value: raw === undefined ? undefined : parsed.value };
|
|
301286
|
+
}
|
|
301287
|
+
const { json, status } = internalErrorToHttpError(new BadRequestError(invalidBooleanMessage(name, raw, req.method, req.path)));
|
|
301288
|
+
res.status(status).json(json);
|
|
301289
|
+
return { ok: false };
|
|
301290
|
+
}
|
|
301291
|
+
function booleanParamOr400(req, res, name) {
|
|
301292
|
+
const outcome = optionalBooleanParamOr400(req, res, name);
|
|
301293
|
+
return outcome.ok ? outcome.value ?? false : undefined;
|
|
301294
|
+
}
|
|
301295
|
+
function setCollectionReloadError(res, perResourceRoute) {
|
|
301296
|
+
const { json, status } = internalErrorToHttpError(new BadRequestError(`Reload recompiles one named resource, and this endpoint lists them. ` + `Use GET ${perResourceRoute}?reload=true instead.`));
|
|
301297
|
+
res.status(status).json(json);
|
|
301298
|
+
}
|
|
299277
301299
|
|
|
299278
301300
|
// src/server-old.ts
|
|
299279
301301
|
init_connection_config();
|
|
@@ -299306,7 +301328,11 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299306
301328
|
compileController,
|
|
299307
301329
|
materializationController
|
|
299308
301330
|
} = controllers;
|
|
299309
|
-
app.get(`${LEGACY_API_PREFIX}/projects`, async (
|
|
301331
|
+
app.get(`${LEGACY_API_PREFIX}/projects`, async (req, res) => {
|
|
301332
|
+
if (req.query.reload !== undefined) {
|
|
301333
|
+
setCollectionReloadError(res, `${LEGACY_API_PREFIX}/projects/{projectName}`);
|
|
301334
|
+
return;
|
|
301335
|
+
}
|
|
299310
301336
|
try {
|
|
299311
301337
|
res.status(200).json(await environmentStore.listEnvironments());
|
|
299312
301338
|
} catch (error) {
|
|
@@ -299328,8 +301354,12 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299328
301354
|
}
|
|
299329
301355
|
});
|
|
299330
301356
|
app.get(`${LEGACY_API_PREFIX}/projects/:projectName`, async (req, res) => {
|
|
301357
|
+
const reload = booleanParamOr400(req, res, "reload");
|
|
301358
|
+
if (reload === undefined) {
|
|
301359
|
+
return;
|
|
301360
|
+
}
|
|
299331
301361
|
try {
|
|
299332
|
-
const environment = await environmentStore.getEnvironment(req.params.projectName,
|
|
301362
|
+
const environment = await environmentStore.getEnvironment(req.params.projectName, reload);
|
|
299333
301363
|
res.status(200).json(await environment.serialize());
|
|
299334
301364
|
} catch (error) {
|
|
299335
301365
|
logger.error(error);
|
|
@@ -299550,6 +301580,10 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299550
301580
|
setVersionIdError(res);
|
|
299551
301581
|
return;
|
|
299552
301582
|
}
|
|
301583
|
+
if (req.query.reload !== undefined) {
|
|
301584
|
+
setCollectionReloadError(res, `${LEGACY_API_PREFIX}/projects/${req.params.projectName}/packages/{packageName}`);
|
|
301585
|
+
return;
|
|
301586
|
+
}
|
|
299553
301587
|
try {
|
|
299554
301588
|
res.status(200).json(await packageController.listPackages(req.params.projectName));
|
|
299555
301589
|
} catch (error) {
|
|
@@ -299573,8 +301607,12 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299573
301607
|
setVersionIdError(res);
|
|
299574
301608
|
return;
|
|
299575
301609
|
}
|
|
301610
|
+
const reload = booleanParamOr400(req, res, "reload");
|
|
301611
|
+
if (reload === undefined) {
|
|
301612
|
+
return;
|
|
301613
|
+
}
|
|
299576
301614
|
try {
|
|
299577
|
-
res.status(200).json(await packageController.getPackage(req.params.projectName, req.params.packageName,
|
|
301615
|
+
res.status(200).json(await packageController.getPackage(req.params.projectName, req.params.packageName, reload));
|
|
299578
301616
|
} catch (error) {
|
|
299579
301617
|
logger.error(error);
|
|
299580
301618
|
const { json, status } = internalErrorToHttpError(error);
|
|
@@ -299686,7 +301724,11 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299686
301724
|
return;
|
|
299687
301725
|
}
|
|
299688
301726
|
}
|
|
299689
|
-
const
|
|
301727
|
+
const bypass = optionalBooleanParamOr400(req, res, "bypass_filters");
|
|
301728
|
+
if (!bypass.ok) {
|
|
301729
|
+
return;
|
|
301730
|
+
}
|
|
301731
|
+
const bypassFilters = bypass.value;
|
|
299690
301732
|
res.status(200).json(await modelController.executeNotebookCell(req.params.projectName, req.params.packageName, notebookPath, cellIndex, filterParams, bypassFilters));
|
|
299691
301733
|
} catch (error) {
|
|
299692
301734
|
logger.error(error);
|
|
@@ -299765,8 +301807,12 @@ function registerLegacyRoutes(app, controllers) {
|
|
|
299765
301807
|
}
|
|
299766
301808
|
});
|
|
299767
301809
|
app.delete(`${LEGACY_API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`, async (req, res) => {
|
|
301810
|
+
const dropTables = booleanParamOr400(req, res, "dropTables");
|
|
301811
|
+
if (dropTables === undefined) {
|
|
301812
|
+
return;
|
|
301813
|
+
}
|
|
299768
301814
|
try {
|
|
299769
|
-
await materializationController.deleteMaterialization(req.params.projectName, req.params.packageName, req.params.materializationId, { dropTables
|
|
301815
|
+
await materializationController.deleteMaterialization(req.params.projectName, req.params.packageName, req.params.materializationId, { dropTables });
|
|
299770
301816
|
res.status(204).send();
|
|
299771
301817
|
} catch (error) {
|
|
299772
301818
|
const { json, status } = internalErrorToHttpError(error);
|
|
@@ -300939,6 +302985,13 @@ function reportRetainedStorageTables(retained, packageName) {
|
|
|
300939
302985
|
function isReclaimableStorageTable(entry) {
|
|
300940
302986
|
return !!entry.storageDestinationName && entry.refresh === undefined;
|
|
300941
302987
|
}
|
|
302988
|
+
function physicalTargetKey(instruction, connectionName) {
|
|
302989
|
+
return JSON.stringify(instruction.destination ? [
|
|
302990
|
+
"destination",
|
|
302991
|
+
instruction.destination,
|
|
302992
|
+
instruction.physicalTableName
|
|
302993
|
+
] : ["connection", connectionName, instruction.physicalTableName]);
|
|
302994
|
+
}
|
|
300942
302995
|
|
|
300943
302996
|
class MaterializationService {
|
|
300944
302997
|
environmentStore;
|
|
@@ -301386,6 +303439,17 @@ class MaterializationService {
|
|
|
301386
303439
|
if (instruction.sourceID) {
|
|
301387
303440
|
bySourceID.set(instruction.sourceID, instruction);
|
|
301388
303441
|
}
|
|
303442
|
+
const clash = bySourceEntityId.get(instruction.sourceEntityId);
|
|
303443
|
+
if (clash && clash.physicalTableName !== instruction.physicalTableName) {
|
|
303444
|
+
recordSharedAddressInstructions();
|
|
303445
|
+
logger.warn("One content address was instructed to build more than one table", {
|
|
303446
|
+
sourceEntityId: instruction.sourceEntityId,
|
|
303447
|
+
physicalTableNames: [
|
|
303448
|
+
clash.physicalTableName,
|
|
303449
|
+
instruction.physicalTableName
|
|
303450
|
+
]
|
|
303451
|
+
});
|
|
303452
|
+
}
|
|
301389
303453
|
bySourceEntityId.set(instruction.sourceEntityId, instruction);
|
|
301390
303454
|
}
|
|
301391
303455
|
const manifest = new Manifest;
|
|
@@ -301404,6 +303468,49 @@ class MaterializationService {
|
|
|
301404
303468
|
const failures = {};
|
|
301405
303469
|
const failedReasons = [];
|
|
301406
303470
|
const builtSources = [];
|
|
303471
|
+
const writtenTargets = new Map;
|
|
303472
|
+
const claimedBy = new Map;
|
|
303473
|
+
const collisions = [];
|
|
303474
|
+
for (const graph of graphs) {
|
|
303475
|
+
for (const persistSource of iterGraphSources(graph, sources)) {
|
|
303476
|
+
let address;
|
|
303477
|
+
try {
|
|
303478
|
+
address = computeSourceEntityId(persistSource, connectionDigests);
|
|
303479
|
+
} catch {
|
|
303480
|
+
continue;
|
|
303481
|
+
}
|
|
303482
|
+
const instruction = bySourceID.get(persistSource.sourceID) ?? bySourceEntityId.get(address);
|
|
303483
|
+
if (!instruction)
|
|
303484
|
+
continue;
|
|
303485
|
+
const target = physicalTargetKey(instruction, graph.connectionName);
|
|
303486
|
+
const claim = claimedBy.get(target);
|
|
303487
|
+
if (!claim) {
|
|
303488
|
+
claimedBy.set(target, {
|
|
303489
|
+
sourceName: persistSource.name,
|
|
303490
|
+
sourceEntityId: address
|
|
303491
|
+
});
|
|
303492
|
+
} else if (claim.sourceEntityId !== address) {
|
|
303493
|
+
collisions.push({
|
|
303494
|
+
first: claim.sourceName,
|
|
303495
|
+
second: persistSource.name,
|
|
303496
|
+
table: instruction.physicalTableName
|
|
303497
|
+
});
|
|
303498
|
+
}
|
|
303499
|
+
}
|
|
303500
|
+
}
|
|
303501
|
+
for (const c of collisions) {
|
|
303502
|
+
recordTableCollision();
|
|
303503
|
+
logger.warn("Two definitions are materializing into one table", {
|
|
303504
|
+
physicalTableName: c.table,
|
|
303505
|
+
sourceNames: [c.first, c.second]
|
|
303506
|
+
});
|
|
303507
|
+
}
|
|
303508
|
+
if (collisions.length > 0 && getPersistCollisionEnforce()) {
|
|
303509
|
+
const detail = collisions.map((c) => `'${c.first}' and '${c.second}' compile to different SQL but ` + `both materialize into table '${c.table}'`).join("; ");
|
|
303510
|
+
throw new MaterializationEligibilityError({
|
|
303511
|
+
message: `${detail}, so each would overwrite the other's rows while both ` + `resolve to it at serve time. Give them distinct definitions, or ` + `distinct physical names: a model-declared collision is fixed ` + `with '#@ persist name=', a host-assigned one by the caller that ` + `minted the names.`
|
|
303512
|
+
});
|
|
303513
|
+
}
|
|
301407
303514
|
try {
|
|
301408
303515
|
for (const graph of graphs) {
|
|
301409
303516
|
const connection = connections.get(graph.connectionName);
|
|
@@ -301438,6 +303545,17 @@ class MaterializationService {
|
|
|
301438
303545
|
if (!orchestratedInstruction && instruction.destination && getPersistStorageMode() !== "off") {
|
|
301439
303546
|
assertMaterializationEligible(persistSource);
|
|
301440
303547
|
}
|
|
303548
|
+
const target = physicalTargetKey(instruction, graph.connectionName);
|
|
303549
|
+
const written = writtenTargets.get(target);
|
|
303550
|
+
if (written && written.sourceEntityId === sourceEntityId) {
|
|
303551
|
+
recordDuplicateTargetSkipped();
|
|
303552
|
+
logger.debug("Skipping a source whose table this run built", {
|
|
303553
|
+
sourceName: persistSource.name,
|
|
303554
|
+
builtAs: written.sourceName,
|
|
303555
|
+
physicalTableName: instruction.physicalTableName
|
|
303556
|
+
});
|
|
303557
|
+
continue;
|
|
303558
|
+
}
|
|
301441
303559
|
let entry;
|
|
301442
303560
|
try {
|
|
301443
303561
|
entry = await this.buildOneSource(persistSource, instruction, connection, connectionDigests, manifest, environment, entries, buildMetadata, incremental, sourceEntityId);
|
|
@@ -301475,6 +303593,10 @@ class MaterializationService {
|
|
|
301475
303593
|
}
|
|
301476
303594
|
builtSources.push(persistSource.name);
|
|
301477
303595
|
entries[sourceEntityId] = entry;
|
|
303596
|
+
writtenTargets.set(target, {
|
|
303597
|
+
sourceEntityId,
|
|
303598
|
+
sourceName: persistSource.name
|
|
303599
|
+
});
|
|
301478
303600
|
if (isReclaimableStorageTable(entry)) {
|
|
301479
303601
|
builtThisRun.push(entry);
|
|
301480
303602
|
} else if (entry.storageDestinationName) {
|
|
@@ -301957,7 +304079,7 @@ class MaterializationService {
|
|
|
301957
304079
|
};
|
|
301958
304080
|
}
|
|
301959
304081
|
async buildDownstreamViaParents(persistSource, destinationName, destinationConnection, builtEntries, environment, physicalTableName) {
|
|
301960
|
-
const upstreams = deriveServeBindings(builtEntries).filter((b) => b.destinationName === destinationName);
|
|
304082
|
+
const upstreams = deriveServeBindings(builtEntries, {}).filter((b) => b.destinationName === destinationName);
|
|
301961
304083
|
if (upstreams.length === 0) {
|
|
301962
304084
|
throw new MaterializationEligibilityError({
|
|
301963
304085
|
message: "no materialized upstream is available in this destination to build on"
|
|
@@ -303731,7 +305853,11 @@ data: changed
|
|
|
303731
305853
|
};
|
|
303732
305854
|
req.on("close", cleanup);
|
|
303733
305855
|
});
|
|
303734
|
-
app.get(`${API_PREFIX2}/environments`, async (
|
|
305856
|
+
app.get(`${API_PREFIX2}/environments`, async (req, res) => {
|
|
305857
|
+
if (req.query.reload !== undefined) {
|
|
305858
|
+
setCollectionReloadError(res, `${API_PREFIX2}/environments/{environmentName}`);
|
|
305859
|
+
return;
|
|
305860
|
+
}
|
|
303735
305861
|
try {
|
|
303736
305862
|
res.status(200).json(await environmentStore.listEnvironments());
|
|
303737
305863
|
} catch (error) {
|
|
@@ -303753,8 +305879,12 @@ app.post(`${API_PREFIX2}/environments`, async (req, res) => {
|
|
|
303753
305879
|
}
|
|
303754
305880
|
});
|
|
303755
305881
|
app.get(`${API_PREFIX2}/environments/:environmentName`, async (req, res) => {
|
|
305882
|
+
const reload = booleanParamOr400(req, res, "reload");
|
|
305883
|
+
if (reload === undefined) {
|
|
305884
|
+
return;
|
|
305885
|
+
}
|
|
303756
305886
|
try {
|
|
303757
|
-
const environment = await environmentStore.getEnvironment(req.params.environmentName,
|
|
305887
|
+
const environment = await environmentStore.getEnvironment(req.params.environmentName, reload);
|
|
303758
305888
|
res.status(200).json(await environment.serialize());
|
|
303759
305889
|
} catch (error) {
|
|
303760
305890
|
logger.error(error);
|
|
@@ -303963,6 +306093,10 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages`, async (req, res
|
|
|
303963
306093
|
setVersionIdError2(res);
|
|
303964
306094
|
return;
|
|
303965
306095
|
}
|
|
306096
|
+
if (req.query.reload !== undefined) {
|
|
306097
|
+
setCollectionReloadError(res, `${API_PREFIX2}/environments/${req.params.environmentName}/packages/{packageName}`);
|
|
306098
|
+
return;
|
|
306099
|
+
}
|
|
303966
306100
|
try {
|
|
303967
306101
|
res.status(200).json(await packageController.listPackages(req.params.environmentName));
|
|
303968
306102
|
} catch (error) {
|
|
@@ -303997,8 +306131,12 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName`, as
|
|
|
303997
306131
|
setVersionIdError2(res);
|
|
303998
306132
|
return;
|
|
303999
306133
|
}
|
|
306134
|
+
const reload = booleanParamOr400(req, res, "reload");
|
|
306135
|
+
if (reload === undefined) {
|
|
306136
|
+
return;
|
|
306137
|
+
}
|
|
304000
306138
|
try {
|
|
304001
|
-
res.status(200).json(await packageController.getPackage(req.params.environmentName, req.params.packageName,
|
|
306139
|
+
res.status(200).json(await packageController.getPackage(req.params.environmentName, req.params.packageName, reload));
|
|
304002
306140
|
} catch (error) {
|
|
304003
306141
|
logger.error(error);
|
|
304004
306142
|
const { json, status } = internalErrorToHttpError(error);
|
|
@@ -304114,7 +306252,11 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/note
|
|
|
304114
306252
|
return;
|
|
304115
306253
|
}
|
|
304116
306254
|
}
|
|
304117
|
-
const
|
|
306255
|
+
const bypass = optionalBooleanParamOr400(req, res, "bypass_filters");
|
|
306256
|
+
if (!bypass.ok) {
|
|
306257
|
+
return;
|
|
306258
|
+
}
|
|
306259
|
+
const bypassFilters = bypass.value;
|
|
304118
306260
|
let givens;
|
|
304119
306261
|
if (typeof req.query.givens === "string") {
|
|
304120
306262
|
try {
|
|
@@ -304242,8 +306384,12 @@ app.post(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/mat
|
|
|
304242
306384
|
}
|
|
304243
306385
|
});
|
|
304244
306386
|
app.delete(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/materializations/:materializationId`, async (req, res) => {
|
|
306387
|
+
const dropTables = booleanParamOr400(req, res, "dropTables");
|
|
306388
|
+
if (dropTables === undefined) {
|
|
306389
|
+
return;
|
|
306390
|
+
}
|
|
304245
306391
|
try {
|
|
304246
|
-
await materializationController.deleteMaterialization(req.params.environmentName, req.params.packageName, req.params.materializationId, { dropTables
|
|
306392
|
+
await materializationController.deleteMaterialization(req.params.environmentName, req.params.packageName, req.params.materializationId, { dropTables });
|
|
304247
306393
|
res.status(204).send();
|
|
304248
306394
|
} catch (error) {
|
|
304249
306395
|
const { json, status } = internalErrorToHttpError(error);
|