@duckcodeailabs/dql-agent 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/agent-run-engine.d.ts +10 -0
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +13 -2
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/answer-loop.d.ts +22 -1
  6. package/dist/answer-loop.d.ts.map +1 -1
  7. package/dist/answer-loop.js +387 -37
  8. package/dist/answer-loop.js.map +1 -1
  9. package/dist/cascade/budgets.d.ts +3 -1
  10. package/dist/cascade/budgets.d.ts.map +1 -1
  11. package/dist/cascade/budgets.js +10 -1
  12. package/dist/cascade/budgets.js.map +1 -1
  13. package/dist/grounding/context-ledger.d.ts +4 -0
  14. package/dist/grounding/context-ledger.d.ts.map +1 -1
  15. package/dist/grounding/context-ledger.js.map +1 -1
  16. package/dist/index.d.ts +7 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +28 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/intent-controller.d.ts +7 -0
  21. package/dist/intent-controller.d.ts.map +1 -1
  22. package/dist/intent-controller.js.map +1 -1
  23. package/dist/metadata/analysis-planner.d.ts +8 -0
  24. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  25. package/dist/metadata/analysis-planner.js +50 -2
  26. package/dist/metadata/analysis-planner.js.map +1 -1
  27. package/dist/metadata/block-fit.js +24 -8
  28. package/dist/metadata/block-fit.js.map +1 -1
  29. package/dist/metadata/catalog.d.ts +7 -0
  30. package/dist/metadata/catalog.d.ts.map +1 -1
  31. package/dist/metadata/catalog.js +47 -0
  32. package/dist/metadata/catalog.js.map +1 -1
  33. package/dist/metadata/sql-context-validation.d.ts +5 -1
  34. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  35. package/dist/metadata/sql-context-validation.js +67 -0
  36. package/dist/metadata/sql-context-validation.js.map +1 -1
  37. package/dist/router.d.ts.map +1 -1
  38. package/dist/router.js +27 -3
  39. package/dist/router.js.map +1 -1
  40. package/package.json +4 -4
@@ -164,6 +164,8 @@ function refusalCodeForValidation(code) {
164
164
  }
165
165
  if (code === 'ambiguous_filter')
166
166
  return 'ambiguous';
167
+ if (code === 'misbound_filter')
168
+ return 'grounding_gap';
167
169
  return 'model_declined';
168
170
  }
169
171
  /**
@@ -179,14 +181,18 @@ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
179
181
  return 'policy_blocked';
180
182
  }
181
183
  /**
182
- * The governed guard is intentionally strict. Only the two outcomes that mean
184
+ * The governed guard is intentionally strict. Only outcomes that mean
183
185
  * "this repository has not modeled this join yet" may be handed to a host's
184
186
  * exploratory lane. All other decisions are explicit governance/safety
185
187
  * denials and must remain terminal in this loop.
186
188
  */
187
189
  function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
188
190
  const reason = decision.code;
189
- if (decision.safe || (reason !== 'unbound_relation' && reason !== 'unplanned_join' && reason !== 'relationship_not_certified')) {
191
+ const missingPathWithoutUnsafeProof = reason === 'unsafe_relationship' && decision.relationshipIds.length === 0;
192
+ if (decision.safe || (!missingPathWithoutUnsafeProof
193
+ && reason !== 'unbound_relation'
194
+ && reason !== 'unplanned_join'
195
+ && reason !== 'relationship_not_certified')) {
190
196
  return undefined;
191
197
  }
192
198
  // `unplanned_join` can also mean the model ignored an existing certified
@@ -914,9 +920,10 @@ async function runAnswerLoop(input) {
914
920
  // generation, which can express the breakdown.
915
921
  const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
916
922
  || questionPlan.dimensionTerms.length > 0;
923
+ const requiredGroupingCount = requestedGroupingDimensions(questionPlan).length;
917
924
  const dropsBreakdown = Boolean(composed)
918
925
  && wantedBreakdown
919
- && composed.dimensions.length === 0
926
+ && composed.dimensions.length < Math.max(1, requiredGroupingCount)
920
927
  && !composed.timeDimension;
921
928
  if (composed && !dropsBreakdown) {
922
929
  semanticBridgeAnswer = composed;
@@ -1047,6 +1054,14 @@ async function runAnswerLoop(input) {
1047
1054
  });
1048
1055
  }
1049
1056
  catch (err) {
1057
+ // A request-level cancellation/deadline is orchestration state, not an
1058
+ // upstream-provider outage. Preserve the AbortSignal reason so the run
1059
+ // engine can report a bounded deadline (or explicit user cancellation)
1060
+ // instead of the misleading generic "Provider error" label.
1061
+ if (input.signal?.aborted)
1062
+ throw input.signal.reason ?? err;
1063
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1064
+ throw err;
1050
1065
  const text = `Provider error: ${err.message}`;
1051
1066
  return {
1052
1067
  kind: 'no_answer',
@@ -1119,6 +1134,7 @@ async function runAnswerLoop(input) {
1119
1134
  intent,
1120
1135
  initial: { raw: proposed, parsed },
1121
1136
  contextLedger,
1137
+ followUp: input.followUp,
1122
1138
  executeGeneratedSql: input.executeGeneratedSql,
1123
1139
  signal: input.signal,
1124
1140
  reasoningEffort: input.reasoningEffort,
@@ -1177,7 +1193,8 @@ async function runAnswerLoop(input) {
1177
1193
  || (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
1178
1194
  || (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
1179
1195
  || contextBlocks.length > 0;
1180
- if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
1196
+ if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
1197
+ && (analyticalPlan?.safe !== false || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
1181
1198
  try {
1182
1199
  proposed = await generateProposalWithOptionalTools({
1183
1200
  provider,
@@ -1280,16 +1297,66 @@ async function runAnswerLoop(input) {
1280
1297
  intent,
1281
1298
  filterValues: input.followUp?.filters,
1282
1299
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1300
+ memberBindings: input.followUp?.memberBindings,
1283
1301
  });
1284
- contextValidation = initialValidation.ok
1285
- ? { ok: true, warnings: initialValidation.warnings }
1286
- : {
1302
+ const rankedGrainGap = missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions);
1303
+ contextValidation = initialValidation.ok && rankedGrainGap
1304
+ ? {
1287
1305
  ok: false,
1288
- code: initialValidation.code,
1289
- error: initialValidation.error,
1306
+ code: 'insufficient_context',
1307
+ error: 'Generated SQL omits the ranked grouping dimension ' + rankedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1290
1308
  warnings: initialValidation.warnings,
1291
- offending: initialValidation.offending,
1292
- };
1309
+ }
1310
+ : initialValidation.ok
1311
+ ? { ok: true, warnings: initialValidation.warnings }
1312
+ : {
1313
+ ok: false,
1314
+ code: initialValidation.code,
1315
+ error: initialValidation.error,
1316
+ warnings: initialValidation.warnings,
1317
+ offending: initialValidation.offending,
1318
+ };
1319
+ // A canonical member value that resolves to exactly one inspected column is
1320
+ // compiler-owned request state, not creative SQL. If the proposal omitted
1321
+ // that predicate entirely, inject it deterministically before spending the
1322
+ // bounded AI validation repair. Misbound or ambiguous predicates are never
1323
+ // rewritten here; they continue through the guarded correction/refusal path.
1324
+ if (!contextValidation.ok
1325
+ && contextValidation.code === 'misbound_filter'
1326
+ && !contextValidation.offending?.relation
1327
+ && !contextValidation.offending?.column
1328
+ && input.followUp?.memberBindings?.length) {
1329
+ const injected = injectUniqueResolvedMemberBindings(parsed.sql, input.followUp.memberBindings, schemaContext);
1330
+ if (injected) {
1331
+ parsed.sql = injected.sql;
1332
+ const rebound = contextLedger.validateSql(parsed.sql, {
1333
+ question,
1334
+ intent,
1335
+ filterValues: input.followUp?.filters,
1336
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1337
+ memberBindings: input.followUp.memberBindings,
1338
+ });
1339
+ const reboundGrainGap = rebound.ok
1340
+ ? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
1341
+ : undefined;
1342
+ contextValidation = rebound.ok && !reboundGrainGap
1343
+ ? { ok: true, warnings: [...rebound.warnings, ...injected.notes] }
1344
+ : rebound.ok
1345
+ ? {
1346
+ ok: false,
1347
+ code: 'insufficient_context',
1348
+ error: 'Generated SQL omits the ranked grouping dimension ' + reboundGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1349
+ warnings: [...rebound.warnings, ...injected.notes],
1350
+ }
1351
+ : {
1352
+ ok: false,
1353
+ code: rebound.code,
1354
+ error: rebound.error,
1355
+ warnings: [...rebound.warnings, ...injected.notes],
1356
+ offending: rebound.offending,
1357
+ };
1358
+ }
1359
+ }
1293
1360
  // Semantic compilation owns metric meaning, but a later dimensional join can
1294
1361
  // still make a compiled bare expression ambiguous at the warehouse binder.
1295
1362
  // Revoke governed trust for an invalid composition so the bounded repair lane
@@ -1305,9 +1372,12 @@ async function runAnswerLoop(input) {
1305
1372
  // stands instead of guessing a measure.
1306
1373
  // 2) Otherwise fall back to a CLEAN governed-metric definition (direct or exact
1307
1374
  // leaf-measure; no fuzzy family guess that could answer the wrong measure).
1308
- if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch) {
1375
+ if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch && !rankedGrainGap) {
1309
1376
  const grounded = contextLedger.validateRuntimeGrounding(parsed.sql);
1310
- if (grounded?.ok) {
1377
+ // Runtime grounding proves that relations and columns exist; it cannot prove
1378
+ // that a resolved business member was bound to the right dimension. Never
1379
+ // let this recovery lane bypass AGT-012's typed member invariant.
1380
+ if (grounded?.ok && !(input.followUp?.memberBindings?.length)) {
1311
1381
  contextValidation = { ok: true, warnings: grounded.warnings };
1312
1382
  }
1313
1383
  else if (scalarGovernedMetricRecoveryAllowed) {
@@ -1346,25 +1416,36 @@ async function runAnswerLoop(input) {
1346
1416
  intent,
1347
1417
  filterValues: input.followUp?.filters,
1348
1418
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1419
+ memberBindings: input.followUp?.memberBindings,
1349
1420
  });
1350
- contextValidation = revalidated.ok
1421
+ const regroundedGrainGap = revalidated.ok
1422
+ ? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
1423
+ : undefined;
1424
+ contextValidation = revalidated.ok && regroundedGrainGap
1351
1425
  ? {
1352
- ok: true,
1353
- warnings: [
1354
- ...revalidated.warnings,
1355
- ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1356
- ],
1357
- }
1358
- : {
1359
1426
  ok: false,
1360
- code: revalidated.code,
1361
- error: revalidated.error,
1362
- warnings: [
1363
- ...revalidated.warnings,
1364
- ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1365
- ],
1366
- offending: revalidated.offending,
1367
- };
1427
+ code: 'insufficient_context',
1428
+ error: 'Generated SQL omits the ranked grouping dimension ' + regroundedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1429
+ warnings: revalidated.warnings,
1430
+ }
1431
+ : revalidated.ok
1432
+ ? {
1433
+ ok: true,
1434
+ warnings: [
1435
+ ...revalidated.warnings,
1436
+ ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1437
+ ],
1438
+ }
1439
+ : {
1440
+ ok: false,
1441
+ code: revalidated.code,
1442
+ error: revalidated.error,
1443
+ warnings: [
1444
+ ...revalidated.warnings,
1445
+ ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1446
+ ],
1447
+ offending: revalidated.offending,
1448
+ };
1368
1449
  }
1369
1450
  }
1370
1451
  catch {
@@ -1378,14 +1459,18 @@ async function runAnswerLoop(input) {
1378
1459
  // provider get the same single chance; any provider failure keeps the honest
1379
1460
  // refusal below. This mirrors the engine-level repair loop, applied to the
1380
1461
  // context-validation gate that previously refused on first failure.
1381
- if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'reground')) {
1382
- recordLaneRepair(repairBudgetState, 'reground');
1462
+ if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'validation')) {
1463
+ recordLaneRepair(repairBudgetState, 'validation');
1383
1464
  try {
1384
1465
  const failedSql = parsed.sql ?? '';
1385
1466
  const repairPrompt = [
1386
1467
  `Your SQL was rejected before execution: ${contextValidation.error}`,
1387
1468
  formatOffendingValidationToken(contextValidation.offending),
1388
1469
  formatValidationWarningsForPrompt(contextValidation.warnings),
1470
+ renderRequestedShapeForRepair(questionPlan),
1471
+ 'For every required grouping alias, select the best matching inspected business column, project it with that exact alias, and include the same expression in GROUP BY.',
1472
+ 'When the warehouse uses a different physical name (for example a location field for a requested region), keep the inspected physical column and alias it to the requested business name. Do not silently drop the grouping.',
1473
+ 'Preserve every requested dimension and measure that was already correct; change only what the validation error requires.',
1389
1474
  'Correct it using ONLY the relations and columns from the inspected context above.',
1390
1475
  'If a needed column lives on a different relation, JOIN that relation using the suggested join paths.',
1391
1476
  'If the requested column does not exist anywhere in the inspected context, return the closest answerable SQL and say what is missing.',
@@ -1400,8 +1485,12 @@ async function runAnswerLoop(input) {
1400
1485
  intent,
1401
1486
  filterValues: input.followUp?.filters,
1402
1487
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1488
+ memberBindings: input.followUp?.memberBindings,
1403
1489
  });
1404
- if (revalidated.ok) {
1490
+ const repairedGrainGap = revalidated.ok
1491
+ ? missingRankedGrainOutput(questionPlan, reparsed.sql, semanticBridgeAnswer?.dimensions)
1492
+ : undefined;
1493
+ if (revalidated.ok && !repairedGrainGap) {
1405
1494
  const repairedSql = reparsed.sql;
1406
1495
  parsed.sql = repairedSql;
1407
1496
  parsed.text = reparsed.text || parsed.text;
@@ -1603,6 +1692,7 @@ async function runAnswerLoop(input) {
1603
1692
  intent,
1604
1693
  filterValues: input.followUp?.filters,
1605
1694
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1695
+ memberBindings: input.followUp?.memberBindings,
1606
1696
  });
1607
1697
  if (localRepairValidation.ok) {
1608
1698
  repairAttempts = 1;
@@ -1646,6 +1736,7 @@ async function runAnswerLoop(input) {
1646
1736
  intent,
1647
1737
  filterValues: input.followUp?.filters,
1648
1738
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1739
+ memberBindings: input.followUp?.memberBindings,
1649
1740
  });
1650
1741
  if (repairedValidation.ok) {
1651
1742
  repairAttempts += 1;
@@ -2180,6 +2271,16 @@ function renderAnalyticalPlanPrompt(plan) {
2180
2271
  if (!plan || plan.entities.length < 2)
2181
2272
  return undefined;
2182
2273
  if (!plan.safe) {
2274
+ if (analyticalPlanAllowsDbtExploration(plan)) {
2275
+ return [
2276
+ 'DQL GOVERNED RELATIONSHIP COVERAGE: MISSING.',
2277
+ plan.message ?? 'No certified analytical relationship path is available.',
2278
+ `Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
2279
+ 'This is not authorization for governed SQL. You MAY propose one bounded, read-only DBT-grounded exploratory SELECT/WITH using only the inspected relations and columns supplied below.',
2280
+ 'Use explicit aliases and equality join keys visible in the inspected schema. Preserve the requested member bindings exactly. Do not introduce an uninspected relation, a cross-domain path, an attribution/allocation rule, or more than four joins.',
2281
+ 'The host will independently parse, bound, probe, validate, and execute the candidate as analyst-review-required. If the inspected evidence cannot express the request, return no SQL.',
2282
+ ].join('\n');
2283
+ }
2183
2284
  return [
2184
2285
  'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
2185
2286
  plan.message ?? 'No certified analytical relationship path is available.',
@@ -2197,6 +2298,17 @@ function renderAnalyticalPlanPrompt(plan) {
2197
2298
  'Use only these relationships and exact key pairs. dbt DAG lineage and same-named columns are not join authorization. Any different join must be refused.',
2198
2299
  ].join('\n');
2199
2300
  }
2301
+ /**
2302
+ * Missing modeling may enter the narrow EXP-001 host-validated lane. Explicit
2303
+ * unsafe, cross-domain, attribution, expiry, purpose, and proof failures never do.
2304
+ */
2305
+ function analyticalPlanAllowsDbtExploration(plan) {
2306
+ if (plan.safe)
2307
+ return false;
2308
+ if (plan.code === 'unbound_relation' || plan.code === 'relationship_not_certified')
2309
+ return true;
2310
+ return plan.code === 'unsafe_relationship' && plan.relationshipIds.length === 0;
2311
+ }
2200
2312
  // Metric-anchored generation (Tier 2.5): a governed metric matched the question but
2201
2313
  // the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
2202
2314
  // definition as the measure rather than reinventing it, so the number stays consistent
@@ -2693,6 +2805,12 @@ function renderFollowUpContext(followUp) {
2693
2805
  followUp.dimensions?.length ? `requested dimensions: ${followUp.dimensions.join(', ')}` : '',
2694
2806
  followUp.priorResultColumns?.length ? `prior result columns: ${followUp.priorResultColumns.join(', ')}` : '',
2695
2807
  followUp.priorResultValues ? `prior result values: ${formatPriorResultValues(followUp.priorResultValues)}` : '',
2808
+ followUp.memberBindings?.length
2809
+ ? `required member bindings: ${followUp.memberBindings.map((binding) => `${binding.dimension} = ${binding.values.join(' | ')} (${binding.confidence})`).join('; ')}`
2810
+ : '',
2811
+ followUp.memberBindings?.length
2812
+ ? 'binding enforcement: apply every required member value only to a column representing its named dimension. Never substitute another dimension; if the named dimension is unavailable, report that gap.'
2813
+ : '',
2696
2814
  followUp.priorResultRef ? renderPriorResultReference(followUp.priorResultRef) : '',
2697
2815
  followUp.priorDqlArtifact ? renderPriorDqlArtifactReference(followUp.priorDqlArtifact) : '',
2698
2816
  followUp.priorLimit ? `prior result limit: ${followUp.priorLimit}` : '',
@@ -3033,14 +3151,243 @@ function escapeDqlArtifactTripleString(value) {
3033
3151
  function generatedResultShapeIsPartial(resultShape) {
3034
3152
  return resultShape.missingOutputs.length >= 2;
3035
3153
  }
3154
+ /**
3155
+ * A ranked query without its ranked entity is a different answer, even when the
3156
+ * aggregate executes. Catch this before execution so the bounded validation
3157
+ * correction can restore the requested grain instead of presenting a region-only
3158
+ * total for a top-product question.
3159
+ */
3160
+ export function missingRankedGrainOutput(questionPlan, sql, governedSemanticDimensions = []) {
3161
+ const grain = questionPlan.requestedShape.grain;
3162
+ if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
3163
+ return undefined;
3164
+ const shape = extractSimpleSelectShape(sql);
3165
+ if (!shape)
3166
+ return undefined;
3167
+ const columns = shape.selectExpressions
3168
+ .map(selectExpressionOutputName)
3169
+ .filter((column) => Boolean(column));
3170
+ const requiredGroupingDimensions = rankedGroupingDimensions(questionPlan);
3171
+ const literalGap = requiredGroupingDimensions.find((dimension) => !columns.some((output) => outputConceptMatches(output, dimension)));
3172
+ if (!literalGap)
3173
+ return undefined;
3174
+ // A bounded Lane-2 meaning selection may map a user's business word to a
3175
+ // differently named governed dimension (for example "region" to
3176
+ // `location_name`). Trust that mapping only when the semantic compiler
3177
+ // projected every selected governed dimension and the selection covers the
3178
+ // complete grouping cardinality. Arbitrary generated aliases never receive
3179
+ // this exception.
3180
+ const projectedGovernedDimensions = governedSemanticDimensions.filter((dimension) => columns.some((output) => outputConceptMatches(output, dimension)));
3181
+ if (projectedGovernedDimensions.length >= requiredGroupingDimensions.length)
3182
+ return undefined;
3183
+ return literalGap;
3184
+ }
3185
+ function requestedGroupingDimensions(questionPlan) {
3186
+ const grain = questionPlan.requestedShape.grain;
3187
+ const dimensions = [
3188
+ ...(grain ? [grain] : []),
3189
+ ...questionPlan.requestedShape.dimensions,
3190
+ ].filter((dimension) => {
3191
+ const isGrain = Boolean(grain) && conceptsEquivalent(dimension, grain);
3192
+ if (isGrain && !isCompoundMeasurePhrase(dimension, questionPlan.requestedShape.measures))
3193
+ return true;
3194
+ return !isMeasurePhrase(dimension, questionPlan.requestedShape.measures);
3195
+ });
3196
+ const seen = new Set();
3197
+ return dimensions.filter((dimension) => {
3198
+ const key = normalizedConceptTokens(dimension).join('_');
3199
+ if (!key || seen.has(key))
3200
+ return false;
3201
+ seen.add(key);
3202
+ return true;
3203
+ });
3204
+ }
3205
+ function rankedGroupingDimensions(questionPlan) {
3206
+ const grain = questionPlan.requestedShape.grain;
3207
+ if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
3208
+ return [];
3209
+ return requestedGroupingDimensions(questionPlan);
3210
+ }
3211
+ function renderRequestedShapeForRepair(questionPlan) {
3212
+ return 'Required answer contract: ' + JSON.stringify({
3213
+ grain: questionPlan.requestedShape.grain,
3214
+ dimensions: questionPlan.requestedShape.dimensions,
3215
+ measures: questionPlan.requestedShape.measures,
3216
+ requiredOutputs: questionPlan.requestedShape.requiredOutputs,
3217
+ requiredGroupingAliases: rankedGroupingDimensions(questionPlan),
3218
+ topN: questionPlan.requestedShape.topN,
3219
+ });
3220
+ }
3221
+ function outputConceptMatches(output, concept) {
3222
+ const outputTokens = normalizedConceptTokens(output);
3223
+ const conceptTokens = normalizedConceptTokens(concept);
3224
+ return conceptTokens.length > 0 && conceptTokens.every((token) => outputTokens.includes(token));
3225
+ }
3226
+ function isCompoundMeasurePhrase(concept, measures) {
3227
+ const conceptTokens = normalizedConceptTokens(concept);
3228
+ return measures.some((measure) => {
3229
+ const measureTokens = normalizedConceptTokens(measure);
3230
+ return measureTokens.length > 0
3231
+ && conceptTokens.length > measureTokens.length
3232
+ && measureTokens.every((token) => conceptTokens.includes(token));
3233
+ });
3234
+ }
3235
+ function isMeasurePhrase(concept, measures) {
3236
+ const conceptTokens = normalizedConceptTokens(concept);
3237
+ return measures.some((measure) => {
3238
+ const measureTokens = normalizedConceptTokens(measure);
3239
+ return measureTokens.length > 0
3240
+ && conceptTokens.length >= measureTokens.length
3241
+ && measureTokens.every((token) => conceptTokens.includes(token));
3242
+ });
3243
+ }
3244
+ function conceptsEquivalent(left, right) {
3245
+ const leftTokens = normalizedConceptTokens(left);
3246
+ const rightTokens = normalizedConceptTokens(right);
3247
+ return leftTokens.length === rightTokens.length
3248
+ && leftTokens.every((token, index) => token === rightTokens[index]);
3249
+ }
3250
+ function normalizedConceptTokens(value) {
3251
+ return value
3252
+ .toLowerCase()
3253
+ .replace(/[^a-z0-9]+/g, ' ')
3254
+ .trim()
3255
+ .split(/\s+/)
3256
+ .filter((token) => token && token !== 'name' && token !== 'label')
3257
+ .map((token) => token.endsWith('s') && token.length > 3 ? token.slice(0, -1) : token);
3258
+ }
3036
3259
  function trustedFollowUpFilterValues(followUp) {
3037
3260
  const filters = new Set((followUp?.filters ?? []).map((value) => value.trim()).filter(Boolean));
3038
- if (!filters.size || !followUp?.priorResultValues)
3261
+ const boundValues = (followUp?.memberBindings ?? [])
3262
+ .flatMap((binding) => binding.values)
3263
+ .map((value) => value.trim())
3264
+ .filter(Boolean);
3265
+ const priorValues = Object.values(followUp?.priorResultValues ?? {}).flat().map((value) => value.trim()).filter(Boolean);
3266
+ const trustedCandidates = uniqueDrilldownStrings([...boundValues, ...priorValues]);
3267
+ if (!trustedCandidates.length)
3039
3268
  return undefined;
3040
- const priorValues = Object.values(followUp.priorResultValues).flat().map((value) => value.trim()).filter(Boolean);
3041
- const trusted = priorValues.filter((value) => filters.has(value));
3269
+ // Typed bindings were resolved by the deterministic member resolver and are
3270
+ // trusted even if a legacy caller omitted the mirrored flat `filters` array.
3271
+ const trusted = trustedCandidates.filter((value) => boundValues.includes(value) || filters.has(value));
3042
3272
  return trusted.length > 0 ? uniqueDrilldownStrings(trusted) : undefined;
3043
3273
  }
3274
+ function injectUniqueResolvedMemberBindings(sql, bindings, schemaContext) {
3275
+ if (!/^\s*select\b/i.test(sql) || /;\s*\S/.test(sql))
3276
+ return undefined;
3277
+ const relationAliases = sqlRelationAliases(sql);
3278
+ const predicates = [];
3279
+ const notes = [];
3280
+ for (const binding of bindings) {
3281
+ const values = Array.from(new Set(binding.values.map((value) => value.trim()).filter(Boolean)));
3282
+ if (values.length === 0)
3283
+ continue;
3284
+ // Do not add a second interpretation when the proposal already contains a
3285
+ // canonical bound value. The validator will determine whether it is on the
3286
+ // correct dimension and reject a misbinding.
3287
+ if (values.some((value) => sql.toLowerCase().includes(sqlStringLiteral(value).toLowerCase())))
3288
+ continue;
3289
+ const candidates = schemaContext.flatMap((table) => table.columns.flatMap((column) => {
3290
+ if (!outputConceptMatches(column.name, binding.dimension))
3291
+ return [];
3292
+ if (values.some((value) => /[\p{L}]/u.test(value)) && isJoinKeyColumn(column.name))
3293
+ return [];
3294
+ const samples = new Set((column.sampleValues ?? []).map(normalizeValueIndexText));
3295
+ // The typed binding is already canonical request state. Samples may
3296
+ // strengthen the match when this turn has them, but snapshot scoping must
3297
+ // not erase a prior governed value. A conflicting non-empty sample set is
3298
+ // rejected; an absent sample set may proceed only through the unique
3299
+ // dimension-column + existing-query-path proof below.
3300
+ if (samples.size > 0 && !values.every((value) => samples.has(normalizeValueIndexText(value))))
3301
+ return [];
3302
+ const alias = findSqlAliasForRelation(table.relation, relationAliases);
3303
+ if (!alias || !safeSqlIdentifier(alias) || !safeSqlIdentifier(column.name))
3304
+ return [];
3305
+ return [{ alias, column: column.name }];
3306
+ }));
3307
+ const uniqueCandidates = Array.from(new Map(candidates.map((candidate) => [
3308
+ `${candidate.alias.toLowerCase()}.${candidate.column.toLowerCase()}`,
3309
+ candidate,
3310
+ ])).values());
3311
+ if (uniqueCandidates.length !== 1)
3312
+ return undefined;
3313
+ const candidate = uniqueCandidates[0];
3314
+ const qualifiedColumn = `${candidate.alias}.${candidate.column}`;
3315
+ const predicate = values.length === 1
3316
+ ? `${qualifiedColumn} = ${sqlStringLiteral(values[0])}`
3317
+ : `${qualifiedColumn} IN (${values.map(sqlStringLiteral).join(', ')})`;
3318
+ predicates.push(predicate);
3319
+ notes.push(`Applied the resolved ${binding.dimension} member binding deterministically on ${qualifiedColumn}.`);
3320
+ }
3321
+ if (predicates.length === 0)
3322
+ return undefined;
3323
+ const clauseIndex = firstTopLevelSqlClauseIndex(sql, ['group by', 'having', 'qualify', 'order by', 'limit']);
3324
+ const head = clauseIndex >= 0 ? sql.slice(0, clauseIndex).trimEnd() : sql.trimEnd();
3325
+ const tail = clauseIndex >= 0 ? ` ${sql.slice(clauseIndex).trimStart()}` : '';
3326
+ const joiner = /\bwhere\b/i.test(head) ? ' AND ' : ' WHERE ';
3327
+ return { sql: `${head}${joiner}${predicates.join(' AND ')}${tail}`, notes };
3328
+ }
3329
+ function sqlRelationAliases(sql) {
3330
+ const matches = [];
3331
+ const pattern = /\b(?:from|join)\s+((?:["`]?\w+["`]?\s*\.\s*)*["`]?\w+["`]?)\s*(?:as\s+)?([A-Za-z_][\w$]*)?/gi;
3332
+ for (const match of sql.matchAll(pattern)) {
3333
+ const relation = (match[1] ?? '').replace(/["`\s]/g, '');
3334
+ if (!relation)
3335
+ continue;
3336
+ const implicitAlias = relation.split('.').pop() ?? relation;
3337
+ const candidateAlias = match[2] && !SQL_CLAUSE_WORDS.has(match[2].toLowerCase()) ? match[2] : implicitAlias;
3338
+ matches.push({ relation, alias: candidateAlias });
3339
+ }
3340
+ return matches;
3341
+ }
3342
+ const SQL_CLAUSE_WORDS = new Set(['where', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'group', 'having', 'qualify', 'order', 'limit', 'on']);
3343
+ function findSqlAliasForRelation(relation, aliases) {
3344
+ const normalized = relation.replace(/["`\s]/g, '').toLowerCase();
3345
+ const short = normalized.split('.').pop();
3346
+ return aliases.find((candidate) => {
3347
+ const candidateNormalized = candidate.relation.toLowerCase();
3348
+ return candidateNormalized === normalized || candidateNormalized.split('.').pop() === short;
3349
+ })?.alias;
3350
+ }
3351
+ function firstTopLevelSqlClauseIndex(sql, clauses) {
3352
+ let depth = 0;
3353
+ let quote;
3354
+ for (let index = 0; index < sql.length; index += 1) {
3355
+ const char = sql[index];
3356
+ if (quote) {
3357
+ if (char === quote) {
3358
+ if (quote === "'" && sql[index + 1] === "'")
3359
+ index += 1;
3360
+ else
3361
+ quote = undefined;
3362
+ }
3363
+ continue;
3364
+ }
3365
+ if (char === "'" || char === '"' || char === '`') {
3366
+ quote = char;
3367
+ continue;
3368
+ }
3369
+ if (char === '(') {
3370
+ depth += 1;
3371
+ continue;
3372
+ }
3373
+ if (char === ')') {
3374
+ depth = Math.max(0, depth - 1);
3375
+ continue;
3376
+ }
3377
+ if (depth !== 0)
3378
+ continue;
3379
+ const rest = sql.slice(index).toLowerCase();
3380
+ if (clauses.some((clause) => rest.startsWith(clause) && (!rest[clause.length] || /\s/.test(rest[clause.length]))))
3381
+ return index;
3382
+ }
3383
+ return -1;
3384
+ }
3385
+ function safeSqlIdentifier(value) {
3386
+ return /^[A-Za-z_][\w$]*$/.test(value);
3387
+ }
3388
+ function sqlStringLiteral(value) {
3389
+ return `'${value.replace(/'/g, "''")}'`;
3390
+ }
3044
3391
  function buildCandidateJoinPaths(schemaContext) {
3045
3392
  const tables = [...schemaContext]
3046
3393
  .filter((table) => table.columns.some((column) => isJoinKeyColumn(column.name)))
@@ -3972,6 +4319,9 @@ async function scoreDeepGeneratedProposalCandidate(input, candidate) {
3972
4319
  const validation = input.contextLedger.validateSql(parsed.sql, {
3973
4320
  question: input.question,
3974
4321
  intent: input.intent,
4322
+ filterValues: input.followUp?.filters,
4323
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
4324
+ memberBindings: input.followUp?.memberBindings,
3975
4325
  });
3976
4326
  validationOk = validation.ok;
3977
4327
  if (validation.ok) {
@@ -4692,13 +5042,13 @@ function cascadeBudgetEvidenceRouteSteps(trace) {
4692
5042
  if (!trace)
4693
5043
  return [];
4694
5044
  const { usage, limits } = trace;
4695
- const used = usage.laneRegroundAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
5045
+ const used = usage.laneRegroundAttemptsUsed + usage.laneValidationAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
4696
5046
  if (used === 0)
4697
5047
  return [];
4698
5048
  return [{
4699
5049
  tool: 'cascade_budget',
4700
5050
  status: 'checked',
4701
- label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
5051
+ label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, validation ${usage.laneValidationAttemptsUsed}/${limits.lane.validation}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
4702
5052
  detail: `engine escalations ${usage.engineEscalationsUsed}/${limits.engineEscalations}`,
4703
5053
  }];
4704
5054
  }