@duckcodeailabs/dql-agent 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/agent-run-engine.d.ts +2 -0
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +67 -19
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +14 -50
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts.map +1 -1
  9. package/dist/agent-run-planner.js +14 -4
  10. package/dist/agent-run-planner.js.map +1 -1
  11. package/dist/agentic/toolset.js +32 -2
  12. package/dist/agentic/toolset.js.map +1 -1
  13. package/dist/answer-loop.d.ts +24 -0
  14. package/dist/answer-loop.d.ts.map +1 -1
  15. package/dist/answer-loop.js +387 -55
  16. package/dist/answer-loop.js.map +1 -1
  17. package/dist/answer-shape.js +14 -1
  18. package/dist/answer-shape.js.map +1 -1
  19. package/dist/cascade/budgets.d.ts.map +1 -1
  20. package/dist/cascade/budgets.js +5 -2
  21. package/dist/cascade/budgets.js.map +1 -1
  22. package/dist/conversation/session-store.d.ts +3 -0
  23. package/dist/conversation/session-store.d.ts.map +1 -1
  24. package/dist/conversation/session-store.js +8 -3
  25. package/dist/conversation/session-store.js.map +1 -1
  26. package/dist/conversation/snapshot.d.ts +2 -0
  27. package/dist/conversation/snapshot.d.ts.map +1 -1
  28. package/dist/conversation/snapshot.js +1 -0
  29. package/dist/conversation/snapshot.js.map +1 -1
  30. package/dist/conversation/working-state.d.ts +3 -0
  31. package/dist/conversation/working-state.d.ts.map +1 -1
  32. package/dist/conversation/working-state.js +26 -0
  33. package/dist/conversation/working-state.js.map +1 -1
  34. package/dist/domain-context.d.ts +14 -0
  35. package/dist/domain-context.d.ts.map +1 -1
  36. package/dist/domain-context.js +10 -0
  37. package/dist/domain-context.js.map +1 -1
  38. package/dist/embeddings/provider.d.ts +3 -1
  39. package/dist/embeddings/provider.d.ts.map +1 -1
  40. package/dist/embeddings/provider.js +19 -8
  41. package/dist/embeddings/provider.js.map +1 -1
  42. package/dist/index.d.ts +12 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +61 -6
  45. package/dist/index.js.map +1 -1
  46. package/dist/intent-controller.d.ts +15 -0
  47. package/dist/intent-controller.d.ts.map +1 -1
  48. package/dist/intent-controller.js +4 -1
  49. package/dist/intent-controller.js.map +1 -1
  50. package/dist/meaning-resolution.d.ts +110 -0
  51. package/dist/meaning-resolution.d.ts.map +1 -0
  52. package/dist/meaning-resolution.js +155 -0
  53. package/dist/meaning-resolution.js.map +1 -0
  54. package/dist/metadata/analysis-planner.d.ts +15 -1
  55. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  56. package/dist/metadata/analysis-planner.js +169 -50
  57. package/dist/metadata/analysis-planner.js.map +1 -1
  58. package/dist/metadata/block-fit.d.ts.map +1 -1
  59. package/dist/metadata/block-fit.js +51 -5
  60. package/dist/metadata/block-fit.js.map +1 -1
  61. package/dist/metadata/catalog.d.ts +62 -3
  62. package/dist/metadata/catalog.d.ts.map +1 -1
  63. package/dist/metadata/catalog.js +911 -98
  64. package/dist/metadata/catalog.js.map +1 -1
  65. package/dist/metadata/grain-gate.js +27 -9
  66. package/dist/metadata/grain-gate.js.map +1 -1
  67. package/dist/metadata/meaning-evidence.d.ts +70 -0
  68. package/dist/metadata/meaning-evidence.d.ts.map +1 -0
  69. package/dist/metadata/meaning-evidence.js +333 -0
  70. package/dist/metadata/meaning-evidence.js.map +1 -0
  71. package/dist/metadata/metric-match.d.ts.map +1 -1
  72. package/dist/metadata/metric-match.js +5 -2
  73. package/dist/metadata/metric-match.js.map +1 -1
  74. package/dist/router.d.ts +16 -4
  75. package/dist/router.d.ts.map +1 -1
  76. package/dist/router.js +409 -21
  77. package/dist/router.js.map +1 -1
  78. package/dist/semantic-bridge/compose.d.ts +12 -0
  79. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  80. package/dist/semantic-bridge/compose.js +177 -14
  81. package/dist/semantic-bridge/compose.js.map +1 -1
  82. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  83. package/dist/semantic-bridge/member-select.js +59 -10
  84. package/dist/semantic-bridge/member-select.js.map +1 -1
  85. package/dist/skills/loader.d.ts.map +1 -1
  86. package/dist/skills/loader.js +35 -33
  87. package/dist/skills/loader.js.map +1 -1
  88. package/dist/synthesize.d.ts +2 -0
  89. package/dist/synthesize.d.ts.map +1 -1
  90. package/dist/synthesize.js +84 -13
  91. package/dist/synthesize.js.map +1 -1
  92. package/package.json +4 -4
@@ -29,12 +29,13 @@ import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
29
29
  import { planAnalyticalPath } from './metadata/analytical-policy.js';
30
30
  import { planCertifiedAdaptation } from './metadata/block-adapt.js';
31
31
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
32
- import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
32
+ import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
33
33
  import { runAgenticToolLoop } from './agentic/tool-loop.js';
34
34
  import { buildSemanticStageTools } from './agentic/toolset.js';
35
35
  import { deriveAgenticTrust } from './agentic/answer-contract.js';
36
36
  import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
37
37
  import { normalizeValueIndexText } from './grounding/value-index.js';
38
+ import { questionTypeFromText } from './meaning-resolution.js';
38
39
  import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
39
40
  import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
40
41
  import { stampTrustLabel } from './trust/stamp.js';
@@ -45,18 +46,105 @@ import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, crea
45
46
  * it — a generic, project-agnostic replacement for hard-coded value→dimension maps.
46
47
  */
47
48
  function resolveFilterValueColumns(value, schemaContext) {
49
+ return resolveAgentFilterValueBindings(value, schemaContext).map((binding) => binding.column);
50
+ }
51
+ /**
52
+ * Resolve a user-provided member phrase only against bounded values that were
53
+ * retrieved for eligible schema fields. This is deliberately separate from
54
+ * metadata/object search: the provider never chooses the canonical row value.
55
+ */
56
+ export function resolveAgentFilterValueBindings(value, schemaContext) {
48
57
  const needle = normalizeValueIndexText(value);
49
58
  if (!needle)
50
59
  return [];
51
- const columns = [];
60
+ const candidates = [];
52
61
  for (const table of schemaContext) {
53
62
  for (const column of table.columns) {
54
- if (column.sampleValues?.some((sample) => normalizeValueIndexText(sample) === needle)) {
55
- columns.push(column.name);
63
+ for (const sample of column.sampleValues ?? []) {
64
+ const normalizedSample = normalizeValueIndexText(sample);
65
+ if (!normalizedSample)
66
+ continue;
67
+ if (normalizedSample === needle) {
68
+ candidates.push({
69
+ column: column.name,
70
+ canonicalValue: sample,
71
+ match: sample === value ? 'exact' : 'normalized',
72
+ confidence: 1,
73
+ distance: 0,
74
+ });
75
+ continue;
76
+ }
77
+ const needleTokens = needle.split(' ').filter(Boolean);
78
+ const sampleTokens = normalizedSample.split(' ').filter(Boolean);
79
+ // A stakeholder often refers to a result member by its distinctive first
80
+ // token ("Melissa") while the warehouse stores the display value as
81
+ // "Melissa Lopez". Treat a strict token subset as a high-confidence fuzzy
82
+ // candidate, but let the runner-up ambiguity guard below reject it when
83
+ // more than one sampled value shares that token.
84
+ if (needleTokens.length < sampleTokens.length
85
+ && needleTokens.every((token) => token.length >= 3 && sampleTokens.includes(token))) {
86
+ candidates.push({
87
+ column: column.name,
88
+ canonicalValue: sample,
89
+ match: 'fuzzy',
90
+ confidence: 0.97,
91
+ distance: sampleTokens.length - needleTokens.length,
92
+ });
93
+ continue;
94
+ }
95
+ if (needleTokens.length !== sampleTokens.length)
96
+ continue;
97
+ const distance = damerauLevenshteinDistance(needle, normalizedSample);
98
+ const similarity = 1 - (distance / Math.max(needle.length, normalizedSample.length, 1));
99
+ const singleToken = needleTokens.length === 1;
100
+ const threshold = singleToken ? 0.94 : 0.92;
101
+ const maxDistance = singleToken ? 1 : 2;
102
+ const hasExactToken = needleTokens.some((token) => sampleTokens.includes(token));
103
+ if (similarity < threshold || distance > maxDistance || (!singleToken && !hasExactToken))
104
+ continue;
105
+ candidates.push({
106
+ column: column.name,
107
+ canonicalValue: sample,
108
+ match: 'fuzzy',
109
+ confidence: Number(similarity.toFixed(4)),
110
+ distance,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ candidates.sort((a, b) => b.confidence - a.confidence || a.distance - b.distance || a.column.localeCompare(b.column));
116
+ const best = candidates[0];
117
+ if (!best)
118
+ return [];
119
+ const runnerUp = candidates.find((candidate) => (candidate.column !== best.column
120
+ || normalizeValueIndexText(candidate.canonicalValue) !== normalizeValueIndexText(best.canonicalValue)));
121
+ if (best.match === 'fuzzy' && runnerUp && best.confidence - runnerUp.confidence < 0.08)
122
+ return [];
123
+ return candidates
124
+ .filter((candidate) => candidate.confidence === best.confidence
125
+ && normalizeValueIndexText(candidate.canonicalValue) === normalizeValueIndexText(best.canonicalValue))
126
+ .map(({ distance: _distance, ...binding }) => binding);
127
+ }
128
+ function damerauLevenshteinDistance(left, right) {
129
+ const rows = left.length + 1;
130
+ const columns = right.length + 1;
131
+ const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));
132
+ for (let row = 0; row < rows; row += 1)
133
+ matrix[row][0] = row;
134
+ for (let column = 0; column < columns; column += 1)
135
+ matrix[0][column] = column;
136
+ for (let row = 1; row < rows; row += 1) {
137
+ for (let column = 1; column < columns; column += 1) {
138
+ const cost = left[row - 1] === right[column - 1] ? 0 : 1;
139
+ matrix[row][column] = Math.min(matrix[row - 1][column] + 1, matrix[row][column - 1] + 1, matrix[row - 1][column - 1] + cost);
140
+ if (row > 1 && column > 1
141
+ && left[row - 1] === right[column - 2]
142
+ && left[row - 2] === right[column - 1]) {
143
+ matrix[row][column] = Math.min(matrix[row][column], matrix[row - 2][column - 2] + cost);
56
144
  }
57
145
  }
58
146
  }
59
- return columns;
147
+ return matrix[left.length][right.length];
60
148
  }
61
149
  const CERTIFIED_HIT_THRESHOLD = 0.18;
62
150
  const HARD_NEGATIVE_RATIO = 0.5;
@@ -315,7 +403,7 @@ async function runAnswerLoop(input) {
315
403
  : [];
316
404
  const hasExplicitDomainScope = Boolean(input.domain || input.domainContext?.activeDomain);
317
405
  const questionDomainScope = hasExplicitDomainScope ? [] : directQuestionDomains;
318
- const promptContextPack = questionDomainScope.length > 0
406
+ const scopedContextPack = questionDomainScope.length > 0
319
407
  ? scopeContextPackToQuestionDomains(input.contextPack, questionDomainScope, input.manifest)
320
408
  : input.contextPack;
321
409
  // Select the RELEVANT skills (not all) for this question; keep pinned project
@@ -356,11 +444,18 @@ async function runAnswerLoop(input) {
356
444
  const semanticHits = kg.search({ query: question, ...searchScope, kinds: SEMANTIC_KINDS, limit: 12 });
357
445
  const manifestHits = kg.search({ query: question, ...searchScope, kinds: MANIFEST_KINDS, limit: 12 });
358
446
  const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
359
- const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, promptContextPack), promptContextPack);
447
+ const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, scopedContextPack), scopedContextPack);
360
448
  const catalogRoute = input.contextPack?.routeDecision;
361
449
  const questionPlan = input.contextPack?.questionPlan?.requestedShape
362
450
  ? input.contextPack.questionPlan
363
451
  : buildAnalysisQuestionPlan(question, input.followUp);
452
+ // Retrieval may surface a high-trust block because its source tables and
453
+ // vocabulary overlap the question even when its output contract does not.
454
+ // Keep such candidates in the audit trail, but do not put their SQL or a stale
455
+ // "exact certified" route into the generation prompt. The model should decide
456
+ // from compatible certified evidence, semantic members, and dbt/runtime
457
+ // columns—not copy a customer-grain worked example into a product-grain ask.
458
+ const promptContextPack = contextPackForRequestedShape(scopedContextPack, question, questionPlan, kg);
364
459
  const repairBudgetState = createCascadeBudgetState(input.cascadeBudgetModel);
365
460
  const fallbackIntent = classifyAgentIntent({
366
461
  question,
@@ -381,9 +476,12 @@ async function runAnswerLoop(input) {
381
476
  if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
382
477
  semanticMetricNodes.push(node);
383
478
  }
384
- let semanticMetricMatch = await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
385
- measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
386
- }).catch(() => null);
479
+ const preferredSemanticMetric = resolvePreferredSemanticMetric([input.preferredExecutionId, ...(input.preferredEvidenceIds ?? [])], semanticMetricNodes, kg);
480
+ let semanticMetricMatch = preferredSemanticMetric
481
+ ? { metric: preferredSemanticMetric, score: 1, basis: 'name' }
482
+ : await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
483
+ measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
484
+ }).catch(() => null);
387
485
  // Stage 1: certified artifact match. Blocks can be executed; dashboards,
388
486
  // Apps, and notebooks are returned as governed citations/navigation targets.
389
487
  const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
@@ -400,7 +498,12 @@ async function runAnswerLoop(input) {
400
498
  const catalogCertifiedHit = shouldTryCertifiedRoute
401
499
  ? certifiedHitFromContextPack(input.contextPack, kg)
402
500
  : null;
403
- const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block' && semanticMetricMatch
501
+ // Catalog route scores are retrieval evidence, not permission to execute a
502
+ // block. Always enforce the output/grain/filter contract—even when no semantic
503
+ // metric happened to match. Previously this guard only ran when Lane 2 had a
504
+ // metric, so a high-scoring `top_beverage_customers` catalog hit could answer a
505
+ // product-type → product-name flow request with customer rows.
506
+ const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block'
404
507
  && !hasCertifiedNodeFit(question, questionPlan, catalogCertifiedHit.node)
405
508
  ? null
406
509
  : catalogCertifiedHit;
@@ -416,6 +519,7 @@ async function runAnswerLoop(input) {
416
519
  }) : null;
417
520
  let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
418
521
  ?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
522
+ let certifiedExecutionFallback;
419
523
  // Certified remains first when it actually covers the question. If the
420
524
  // retrieved block does not fit but a governed semantic metric does, never
421
525
  // let the broad catalog match pre-empt Lane 2.
@@ -449,7 +553,7 @@ async function runAnswerLoop(input) {
449
553
  // explicitly names the object in a definition request ("what is Revenue
450
554
  // Health?"). A broad lexical match must never turn an analytical question
451
555
  // into a no-data Certified answer merely because it starts with "what is".
452
- return isBusinessDefinitionQuestion(question)
556
+ return isPureBusinessDefinitionQuestion(question)
453
557
  && objectNameInQuestion(question, artifactHit.node);
454
558
  })();
455
559
  if (artifactHit && businessContextTerminal) {
@@ -554,46 +658,60 @@ async function runAnswerLoop(input) {
554
658
  suggestedViz: result?.chartConfig ? chartNameFromConfig(result.chartConfig) : undefined,
555
659
  });
556
660
  const dqlArtifact = buildCertifiedBlockDqlArtifact(artifactHit.node, result);
557
- return {
558
- kind: certifiedShapePassed ? 'certified' : 'uncertified',
559
- sourceTier,
560
- certification: certifiedShapePassed ? 'certified' : 'analyst_review_required',
561
- reviewStatus: certifiedShapePassed ? 'certified' : 'analyst_review_required',
562
- confidence: certifiedShapePassed ? 0.95 : 0.45,
563
- text,
564
- answer: text,
565
- block: artifactHit.node.kind === 'block' ? artifactHit.node : undefined,
566
- result,
567
- executionError,
568
- sql: result?.sql,
569
- dqlArtifact,
570
- trustLabel: certifiedShapePassed ? input.contextPack?.trustLabel ?? 'certified' : 'mixed',
571
- sourceCertifiedBlock: artifactHit.node.kind === 'block' ? artifactHit.node.name : undefined,
572
- contextPackId: input.contextPack?.id,
573
- validationWarnings: resultShapeWarnings,
574
- selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
575
- citations,
576
- memoryContext: input.memoryContext,
577
- analysisPlan,
578
- evidence: buildCertifiedEvidence({
579
- question,
580
- artifact: artifactHit.node,
581
- businessHits,
582
- semanticHits,
583
- manifestHits,
584
- considered,
661
+ const recoverableCertifiedFailure = artifactHit.node.kind === 'block'
662
+ && executionError !== undefined
663
+ && isRetryableCertifiedExecutionError(executionError);
664
+ if (recoverableCertifiedFailure) {
665
+ // A certified artifact is trusted evidence, not an obligation to return a
666
+ // failed query. Preserve the failure for the trace, remove executable
667
+ // trust for this turn, and continue through semantic/generated lanes.
668
+ // Any fallback result remains review-required; repaired SQL never inherits
669
+ // the source block's certification.
670
+ certifiedExecutionFallback = { node: artifactHit.node, error: executionError };
671
+ artifactHit = null;
672
+ }
673
+ else {
674
+ return {
675
+ kind: certifiedShapePassed ? 'certified' : 'uncertified',
676
+ sourceTier,
677
+ certification: certifiedShapePassed ? 'certified' : 'analyst_review_required',
678
+ reviewStatus: certifiedShapePassed ? 'certified' : 'analyst_review_required',
679
+ confidence: certifiedShapePassed ? 0.95 : 0.45,
680
+ text,
681
+ answer: text,
682
+ block: artifactHit.node.kind === 'block' ? artifactHit.node : undefined,
585
683
  result,
586
684
  executionError,
587
- resultShapeWarnings,
588
- executorWasAvailable: Boolean(input.executeCertifiedBlock),
685
+ sql: result?.sql,
686
+ dqlArtifact,
687
+ trustLabel: certifiedShapePassed ? input.contextPack?.trustLabel ?? 'certified' : 'mixed',
688
+ sourceCertifiedBlock: artifactHit.node.kind === 'block' ? artifactHit.node.name : undefined,
689
+ contextPackId: input.contextPack?.id,
690
+ validationWarnings: resultShapeWarnings,
691
+ selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
589
692
  citations,
590
- memoryContext: input.memoryContext ?? [],
693
+ memoryContext: input.memoryContext,
591
694
  analysisPlan,
592
- }),
593
- contextPack: input.contextPack,
594
- considered,
595
- providerUsed: provider.name,
596
- };
695
+ evidence: buildCertifiedEvidence({
696
+ question,
697
+ artifact: artifactHit.node,
698
+ businessHits,
699
+ semanticHits,
700
+ manifestHits,
701
+ considered,
702
+ result,
703
+ executionError,
704
+ resultShapeWarnings,
705
+ executorWasAvailable: Boolean(input.executeCertifiedBlock),
706
+ citations,
707
+ memoryContext: input.memoryContext ?? [],
708
+ analysisPlan,
709
+ }),
710
+ contextPack: input.contextPack,
711
+ considered,
712
+ providerUsed: provider.name,
713
+ };
714
+ }
597
715
  }
598
716
  // Spec 17, part C — SEMANTIC-METRIC MATCHING. Must run BEFORE the clarify
599
717
  // short-circuit: FTS alone misses clear metric questions ("total revenue" never
@@ -699,7 +817,14 @@ async function runAnswerLoop(input) {
699
817
  ], 14);
700
818
  const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node)).filter((node) => questionDomainScope.length === 0 || !node.domain || questionDomainScope.includes(node.domain));
701
819
  const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
702
- const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
820
+ const contextBlocks = contextNodes.filter((node) => {
821
+ if (node.kind !== 'block')
822
+ return false;
823
+ if (node.status !== 'certified')
824
+ return true;
825
+ const fit = evaluateCertifiedBlockFit({ question, plan: questionPlan, block: node });
826
+ return fit.kind === 'exact' || fit.kind === 'trim_safe';
827
+ });
703
828
  const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
704
829
  const contextOther = contextNodes.filter((n) => n.kind !== 'block' && !BUSINESS_CONTEXT_KINDS.includes(n.kind));
705
830
  const promptBudget = promptContextBudgetForQuestion({
@@ -758,6 +883,7 @@ async function runAnswerLoop(input) {
758
883
  questionPlan,
759
884
  matchedMetric: semanticMetricMatch.metric,
760
885
  filterValueColumns: (value) => resolveFilterValueColumns(value, schemaContext),
886
+ filterValueBindings: (value) => resolveAgentFilterValueBindings(value, schemaContext),
761
887
  ...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
762
888
  ...(input.semanticTableMapping ? { tableMapping: input.semanticTableMapping } : {}),
763
889
  });
@@ -1119,6 +1245,21 @@ async function runAnswerLoop(input) {
1119
1245
  providerUsed: provider.name,
1120
1246
  };
1121
1247
  }
1248
+ if (parsed.sql) {
1249
+ const tightenedFlow = tightenSourceTargetFlowProjection(parsed.sql, question, questionPlan);
1250
+ if (tightenedFlow && tightenedFlow.sql !== parsed.sql) {
1251
+ parsed.sql = tightenedFlow.sql;
1252
+ parsed.outputs = tightenedFlow.outputs;
1253
+ // Any edit to compiler-owned SQL is no longer an exact governed compile.
1254
+ // Keep the corrected result review-required instead of overstating trust.
1255
+ governedMetricAnswer = false;
1256
+ // A deep candidate may already have executed the wider proposal. The
1257
+ // narrowed SQL is now authoritative and must be executed/validated anew.
1258
+ deepCandidateResult = undefined;
1259
+ deepCandidateExecutionError = undefined;
1260
+ deepCandidateNotes.push('Removed an unrelated grouping from the source-to-target flow projection.');
1261
+ }
1262
+ }
1122
1263
  // Shared grounding (spec 15): deterministically qualify any bare relation the
1123
1264
  // model emitted to its real warehouse relation from the runtime schema BEFORE
1124
1265
  // governance validation. Same resolver the build path uses — one grounding,
@@ -1548,6 +1689,9 @@ async function runAnswerLoop(input) {
1548
1689
  suggestedViz: parsed.viz ?? 'table',
1549
1690
  assumptions: [
1550
1691
  'The SQL preview is uncertified until an analyst reviews and promotes the DQL artifact.',
1692
+ ...(certifiedExecutionFallback
1693
+ ? [`Certified block ${certifiedExecutionFallback.node.name} failed execution and was bypassed: ${certifiedExecutionFallback.error}`]
1694
+ : []),
1551
1695
  ...(repairNarrative ? [`Auto-corrected the query after an execution error: ${repairNarrative}`] : []),
1552
1696
  ...contextValidation.warnings,
1553
1697
  ...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
@@ -1582,6 +1726,9 @@ async function runAnswerLoop(input) {
1582
1726
  : undefined;
1583
1727
  const validationWarnings = [
1584
1728
  ...(input.contextPack?.warnings ?? []),
1729
+ ...(certifiedExecutionFallback
1730
+ ? [`Certified block ${certifiedExecutionFallback.node.name} failed execution; this fallback result is review-required.`]
1731
+ : []),
1585
1732
  ...contextValidation.warnings,
1586
1733
  ...deepCandidateNotes,
1587
1734
  ...(resultShape?.warnings ?? []),
@@ -1769,6 +1916,10 @@ Rules:
1769
1916
  an additional output dimension. An entity ranking filtered to a category
1770
1917
  value must return one row per requested entity, not one row per
1771
1918
  entity-category-member pair.
1919
+ For a Sankey/source-to-target flow, return exactly two categorical columns
1920
+ (the requested source first, then the requested target) plus one numeric
1921
+ weight. Do not add an unrelated grouping such as customer type, segment, or
1922
+ region unless the question explicitly asks for it.
1772
1923
  4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
1773
1924
  measures and how they aggregate, the dimensions/filters, and the exact join
1774
1925
  path + join keys between the grounded tables. Then make the SQL match that
@@ -1779,10 +1930,18 @@ Rules:
1779
1930
  or use a grain-preserving value only when the owning entity remains in the
1780
1931
  output. Never SUM the repeated parent value at child grain. If the question
1781
1932
  requires allocating a parent value to children, ask for the allocation rule.
1933
+ For an entity-relative measure comparison such as "customers who paid less
1934
+ tax than Melissa", first aggregate the measure for every peer at the same
1935
+ entity grain, obtain the named reference entity's aggregate in a CTE or
1936
+ scalar subquery, then compare peers with the requested < or > predicate and
1937
+ exclude the reference entity. Do not filter the result down to the reference
1938
+ entity, compare unaggregated fact rows, or return one global aggregate.
1782
1939
  5. Choose a visualization deliberately in the "viz" JSON field: use single_value
1783
1940
  or kpi for one aggregate, line/area only for an ordered time series, bar for a
1784
1941
  categorical comparison, grouped-bar for multiple measures by one category,
1785
- scatter for two continuous measures, and table when the result is not chartable.
1942
+ donut for a small part-to-whole result, scatter for two continuous measures,
1943
+ histogram for a distribution, funnel for ordered stages, waterfall for signed
1944
+ contributions, sankey for source-to-target flows, and table for detailed rows.
1786
1945
  Do not default to bar. The runtime validates this preference against returned
1787
1946
  rows before displaying it.
1788
1947
  6. NEVER fabricate column names that are not present in the supplied schema,
@@ -1870,6 +2029,72 @@ function scopeContextPackToQuestionDomains(contextPack, domains, manifest) {
1870
2029
  },
1871
2030
  };
1872
2031
  }
2032
+ /**
2033
+ * Build the prompt-facing evidence pack after enforcing the certified output
2034
+ * contract. Incompatible blocks remain in the returned answer's `considered`
2035
+ * evidence for inspection, but their prose/SQL cannot steer generation.
2036
+ */
2037
+ function contextPackForRequestedShape(contextPack, question, questionPlan, kg) {
2038
+ if (!contextPack)
2039
+ return undefined;
2040
+ const incompatibleBlockKeys = new Set();
2041
+ for (const object of contextPack.objects) {
2042
+ if (object.objectType !== 'dql_block')
2043
+ continue;
2044
+ const blockName = object.name || object.objectKey.replace(/^dql:block:/, '');
2045
+ const node = kg.getNode(`block:${blockName}`);
2046
+ if (node?.kind === 'block') {
2047
+ const fit = evaluateCertifiedBlockFit({ question, plan: questionPlan, block: node });
2048
+ if (fit.kind === 'context_only' || fit.kind === 'not_applicable') {
2049
+ incompatibleBlockKeys.add(object.objectKey);
2050
+ }
2051
+ }
2052
+ }
2053
+ if (incompatibleBlockKeys.size === 0)
2054
+ return contextPack;
2055
+ // Some embedders/tests provide a lightweight pack without a route decision.
2056
+ // The prompt still benefits from removing mismatched worked-example SQL.
2057
+ const existingRoute = contextPack.routeDecision;
2058
+ if (!existingRoute) {
2059
+ return {
2060
+ ...contextPack,
2061
+ allowedSqlContext: {
2062
+ ...contextPack.allowedSqlContext,
2063
+ sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => !incompatibleBlockKeys.has(source.objectKey)),
2064
+ },
2065
+ };
2066
+ }
2067
+ const exactWasRemoved = Boolean(existingRoute.exactObjectKey
2068
+ && incompatibleBlockKeys.has(existingRoute.exactObjectKey));
2069
+ const routeDecision = exactWasRemoved
2070
+ ? (() => {
2071
+ const { exactObjectKey: _exactObjectKey, grainGate: _grainGate, ...route } = existingRoute;
2072
+ return {
2073
+ ...route,
2074
+ route: 'generated_sql',
2075
+ reason: 'No certified block satisfies the requested output shape; use compatible semantic and SQL evidence.',
2076
+ routeReason: 'The retrieved certified candidate has a different grain or output contract and is context-only.',
2077
+ trustLabel: route.trustLabel === 'certified' ? 'mixed' : route.trustLabel,
2078
+ reviewStatus: 'draft_ready',
2079
+ certifiedApplicability: route.certifiedApplicability
2080
+ ? { ...route.certifiedApplicability, kind: 'context_only' }
2081
+ : undefined,
2082
+ selectedEvidence: route.selectedEvidence,
2083
+ };
2084
+ })()
2085
+ : {
2086
+ ...existingRoute,
2087
+ };
2088
+ return {
2089
+ ...contextPack,
2090
+ trustLabel: exactWasRemoved && contextPack.trustLabel === 'certified' ? 'mixed' : contextPack.trustLabel,
2091
+ routeDecision,
2092
+ allowedSqlContext: {
2093
+ ...contextPack.allowedSqlContext,
2094
+ sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => !incompatibleBlockKeys.has(source.objectKey)),
2095
+ },
2096
+ };
2097
+ }
1873
2098
  function manifestRelationDomains(manifest) {
1874
2099
  const domains = new Map();
1875
2100
  if (!manifest?.modeling || !manifest.dbtProvenance)
@@ -2536,6 +2761,72 @@ function cleanGeneratedSummary(text) {
2536
2761
  .replace(/\s*(?:therefore,\s*)?i will generate[^.]*\.\s*/gi, ' ')
2537
2762
  .trim();
2538
2763
  }
2764
+ /**
2765
+ * A source-to-target flow has a strict three-field contract. Models sometimes
2766
+ * add a high-overlap but unrelated categorical grouping from retrieved context
2767
+ * (for example customer_type), which splits edge weights and corrupts both the
2768
+ * summary and Sankey. For a simple generated SELECT, narrow the projection and
2769
+ * GROUP BY to the two fields explicitly named in the question plus one
2770
+ * aggregate. This never invents SQL and leaves complex CTE queries untouched.
2771
+ */
2772
+ export function tightenSourceTargetFlowProjection(sql, question, plan) {
2773
+ if (!/\bsankey|flow|source.?to.?target|from .+ to\b/i.test(question))
2774
+ return undefined;
2775
+ if (!/^\s*select\b/i.test(sql))
2776
+ return undefined;
2777
+ const shape = extractSimpleSelectShape(sql);
2778
+ if (!shape || shape.selectExpressions.length < 3)
2779
+ return undefined;
2780
+ const entries = shape.selectExpressions.map((expression) => ({
2781
+ expression,
2782
+ output: selectExpressionOutputName(expression),
2783
+ })).filter((entry) => Boolean(entry.output));
2784
+ const measureTerms = new Set(plan.requestedShape.measures.map(normalizeFlowField));
2785
+ const aggregates = entries.filter((entry) => /\b(?:sum|count|avg|average|min|max)\s*\(/i.test(entry.expression)
2786
+ || [...measureTerms].some((measure) => flowFieldCovers(entry.output, measure)));
2787
+ const measure = aggregates[0];
2788
+ if (!measure)
2789
+ return undefined;
2790
+ const mentionedDimensions = entries
2791
+ .filter((entry) => entry !== measure)
2792
+ .map((entry) => ({ ...entry, position: flowFieldPosition(question, entry.output) }))
2793
+ .filter((entry) => entry.position >= 0)
2794
+ .sort((left, right) => left.position - right.position);
2795
+ const source = mentionedDimensions[0];
2796
+ const target = mentionedDimensions.find((entry) => entry.output !== source?.output);
2797
+ if (!source || !target)
2798
+ return undefined;
2799
+ const kept = [source, target, measure];
2800
+ const outputs = kept.map((entry) => entry.output);
2801
+ if (entries.length === kept.length && entries.every((entry, index) => entry.output === outputs[index])) {
2802
+ return { sql, outputs };
2803
+ }
2804
+ const selectMatch = /\bselect\b[\s\S]*?\bfrom\b/i.exec(sql);
2805
+ if (!selectMatch || selectMatch.index !== sql.search(/\bselect\b/i))
2806
+ return undefined;
2807
+ const replacement = `SELECT\n ${kept.map((entry) => entry.expression).join(',\n ')}\nFROM`;
2808
+ let narrowed = `${sql.slice(0, selectMatch.index)}${replacement}${sql.slice(selectMatch.index + selectMatch[0].length)}`;
2809
+ const groupExpressions = [source, target].map((entry) => selectSourceExpression(entry.expression));
2810
+ narrowed = narrowed.replace(/\bgroup\s+by\b[\s\S]*?(?=\bhaving\b|\border\s+by\b|\blimit\b|$)/i, `GROUP BY ${groupExpressions.join(', ')}`);
2811
+ return { sql: narrowed.trim(), outputs };
2812
+ }
2813
+ function selectSourceExpression(expression) {
2814
+ return expression
2815
+ .replace(/\s+as\s+["`]?\w+["`]?\s*$/i, '')
2816
+ .trim();
2817
+ }
2818
+ function flowFieldPosition(question, field) {
2819
+ const normalizedQuestion = normalizeFlowField(question);
2820
+ const normalizedField = normalizeFlowField(field);
2821
+ return normalizedField ? normalizedQuestion.indexOf(normalizedField) : -1;
2822
+ }
2823
+ function flowFieldCovers(field, term) {
2824
+ const normalizedField = normalizeFlowField(field);
2825
+ return normalizedField === term || normalizedField.split(' ').includes(term);
2826
+ }
2827
+ function normalizeFlowField(value) {
2828
+ return value.toLowerCase().replace(/[_-]+/g, ' ').replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
2829
+ }
2539
2830
  /**
2540
2831
  * Public for tests. Prefer the structured W2.7 JSON proposal contract, then
2541
2832
  * fall back to the legacy prose + ```sql block + Viz line format.
@@ -2967,7 +3258,7 @@ function pickCertifiedArtifact(input) {
2967
3258
  }
2968
3259
  }
2969
3260
  const executableHit = pickFirstCertifiedHit(input.executableArtifactHits, input.kg, input.excludedArtifactIds, input.question, input.questionPlan);
2970
- if (isBusinessDefinitionQuestion(input.question)) {
3261
+ if (isPureBusinessDefinitionQuestion(input.question)) {
2971
3262
  if (executableHit && hasExactExecutableArtifactSignal(input.question, executableHit.node)) {
2972
3263
  return executableHit;
2973
3264
  }
@@ -3074,6 +3365,10 @@ function isCertifiedHit(hit, kg) {
3074
3365
  function isBusinessDefinitionQuestion(question) {
3075
3366
  return /\b(what is|what are|define|definition|meaning of|what does .+ mean)\b/i.test(question);
3076
3367
  }
3368
+ /** Definition that may terminate with documentation instead of executing data. */
3369
+ function isPureBusinessDefinitionQuestion(question) {
3370
+ return questionTypeFromText(question) === 'definition';
3371
+ }
3077
3372
  function isBreakdownOrDrilldownQuestion(question) {
3078
3373
  return /\b(break\s*down|breakdown|drill\s*(?:down|into)|slice|segment|split|by\s+[a-z][\w\s-]{1,40})\b/i.test(question);
3079
3374
  }
@@ -3128,7 +3423,7 @@ function hasCompatibleCertifiedBlockMatch(question, node) {
3128
3423
  function hasCertifiedNodeFit(question, plan, node, options = {}) {
3129
3424
  if (!hasCompatibleRankingDirection(question, node))
3130
3425
  return false;
3131
- const definitionLookup = isBusinessDefinitionQuestion(question) && objectNameInQuestion(question, node);
3426
+ const definitionLookup = isPureBusinessDefinitionQuestion(question) && objectNameInQuestion(question, node);
3132
3427
  const exactExampleMatch = (node.examples ?? []).some((example) => normalizeQuestion(example.question) === normalizeQuestion(question));
3133
3428
  const exactObjectRequest = objectNameInQuestion(question, node)
3134
3429
  && /\b(run|use|open|show|execute|certified|saved|block)\b/i.test(question);
@@ -3818,6 +4113,18 @@ function summarizeEvidencePayload(value, maxLength = 700) {
3818
4113
  function isRetryableGeneratedSqlError(error) {
3819
4114
  return !/\b(read-only|readonly|select or with|unsafe|delete|insert|update|drop|alter|create|attach|copy|pragma)\b/i.test(error);
3820
4115
  }
4116
+ /**
4117
+ * Only SQL-shape/binding failures may leave the certified lane. Connectivity,
4118
+ * authorization, cancellation, and timeout failures would affect every query
4119
+ * route and must remain terminal rather than triggering wasteful provider work.
4120
+ */
4121
+ function isRetryableCertifiedExecutionError(error) {
4122
+ return /\b(?:binder|parser|catalog)\s+error\b/i.test(error)
4123
+ || /\bambiguous\s+reference\b/i.test(error)
4124
+ || /\breferenced\s+column\b.*\bnot\s+found\b/i.test(error)
4125
+ || /\bcolumn\b.*\b(?:not\s+found|does\s+not\s+exist|not\s+recognized|unknown)\b/i.test(error)
4126
+ || /\btable\b.*\b(?:not\s+found|does\s+not\s+exist|unknown)\b/i.test(error);
4127
+ }
3821
4128
  function repairGeneratedSqlLocally(sql, error, schemaContext) {
3822
4129
  const missing = error.match(/(?:Values list|Referenced table)\s+"([^"]+)"\s+does not have a column named\s+"([^"]+)"/i)
3823
4130
  ?? error.match(/Referenced column\s+"([^"]+)"\s+not found/i);
@@ -4005,9 +4312,10 @@ function interleaveContextHits(groups, budget) {
4005
4312
  }
4006
4313
  /**
4007
4314
  * Candidate metric KG nodes for semantic-metric matching (spec 17, part C).
4008
- * Starts with the FTS semantic + considered hits, then folds in EVERY metric
4315
+ * Starts with the FTS semantic + considered hits, then folds in every metric
4009
4316
  * node from the KG so a confident measure-family match is found even when FTS
4010
- * surfaced no metric at all (the "total revenue" miss). Bounded for safety.
4317
+ * surfaced no metric at all. Metric headers are compact; correctness must not
4318
+ * depend on an alphabetical first-200 slice in an enterprise catalog.
4011
4319
  */
4012
4320
  function collectMetricCandidates(semanticHits, considered, kg) {
4013
4321
  const byId = new Map();
@@ -4016,7 +4324,7 @@ function collectMetricCandidates(semanticHits, considered, kg) {
4016
4324
  byId.set(hit.node.nodeId, hit.node);
4017
4325
  }
4018
4326
  try {
4019
- for (const node of kg.getNodesByKind('metric', 200)) {
4327
+ for (const node of kg.getNodesByKind('metric', 100_000)) {
4020
4328
  if (!byId.has(node.nodeId))
4021
4329
  byId.set(node.nodeId, node);
4022
4330
  }
@@ -4026,6 +4334,30 @@ function collectMetricCandidates(semanticHits, considered, kg) {
4026
4334
  }
4027
4335
  return Array.from(byId.values());
4028
4336
  }
4337
+ /**
4338
+ * Bind a validated meaning-resolution ID to the exact KG metric node. This is
4339
+ * deliberately ID-only: labels/aliases are not reinterpreted here, and the
4340
+ * semantic compiler still has to prove the requested dimensions and filters.
4341
+ */
4342
+ function resolvePreferredSemanticMetric(ids, pool, kg) {
4343
+ const byId = new Map(pool.filter((node) => node.kind === 'metric').map((node) => [node.nodeId, node]));
4344
+ for (const rawId of ids) {
4345
+ const id = rawId?.trim();
4346
+ if (!id)
4347
+ continue;
4348
+ const nodeId = id.startsWith('semantic:metric:')
4349
+ ? id.slice('semantic:'.length)
4350
+ : id.startsWith('metric:')
4351
+ ? id
4352
+ : undefined;
4353
+ if (!nodeId)
4354
+ continue;
4355
+ const node = byId.get(nodeId) ?? kg.getNode(nodeId);
4356
+ if (node?.kind === 'metric')
4357
+ return node;
4358
+ }
4359
+ return undefined;
4360
+ }
4029
4361
  function buildCertifiedEvidence(input) {
4030
4362
  const businessContextAssets = uniqueAssets(input.businessHits
4031
4363
  .map((hit) => hit.node)