@duckcodeailabs/dql-agent 1.9.4 → 1.10.0

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 (69) hide show
  1. package/dist/agent-run-engine.d.ts +13 -0
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +59 -1
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/answer-loop.d.ts +34 -1
  6. package/dist/answer-loop.d.ts.map +1 -1
  7. package/dist/answer-loop.js +490 -57
  8. package/dist/answer-loop.js.map +1 -1
  9. package/dist/embeddings/provider.d.ts +1 -1
  10. package/dist/embeddings/provider.d.ts.map +1 -1
  11. package/dist/embeddings/provider.js.map +1 -1
  12. package/dist/governed-relational-compiler.d.ts +138 -0
  13. package/dist/governed-relational-compiler.d.ts.map +1 -0
  14. package/dist/governed-relational-compiler.js +315 -0
  15. package/dist/governed-relational-compiler.js.map +1 -0
  16. package/dist/index.d.ts +13 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/intent-controller.d.ts +5 -0
  21. package/dist/intent-controller.d.ts.map +1 -1
  22. package/dist/intent-controller.js.map +1 -1
  23. package/dist/kg/build.d.ts.map +1 -1
  24. package/dist/kg/build.js +81 -1
  25. package/dist/kg/build.js.map +1 -1
  26. package/dist/meaning-resolution.d.ts +4 -0
  27. package/dist/meaning-resolution.d.ts.map +1 -1
  28. package/dist/meaning-resolution.js +14 -4
  29. package/dist/meaning-resolution.js.map +1 -1
  30. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  31. package/dist/metadata/analysis-planner.js +34 -6
  32. package/dist/metadata/analysis-planner.js.map +1 -1
  33. package/dist/metadata/catalog.d.ts +83 -2
  34. package/dist/metadata/catalog.d.ts.map +1 -1
  35. package/dist/metadata/catalog.js +381 -28
  36. package/dist/metadata/catalog.js.map +1 -1
  37. package/dist/metadata/meaning-evidence.d.ts +10 -1
  38. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  39. package/dist/metadata/meaning-evidence.js +78 -1
  40. package/dist/metadata/meaning-evidence.js.map +1 -1
  41. package/dist/plan-execution-adapter.d.ts +60 -0
  42. package/dist/plan-execution-adapter.d.ts.map +1 -0
  43. package/dist/plan-execution-adapter.js +239 -0
  44. package/dist/plan-execution-adapter.js.map +1 -0
  45. package/dist/research-governance.d.ts +56 -0
  46. package/dist/research-governance.d.ts.map +1 -0
  47. package/dist/research-governance.js +68 -0
  48. package/dist/research-governance.js.map +1 -0
  49. package/dist/research-loop.d.ts +14 -0
  50. package/dist/research-loop.d.ts.map +1 -1
  51. package/dist/research-loop.js +77 -23
  52. package/dist/research-loop.js.map +1 -1
  53. package/dist/resolved-analytical-plan.d.ts +112 -0
  54. package/dist/resolved-analytical-plan.d.ts.map +1 -0
  55. package/dist/resolved-analytical-plan.js +346 -0
  56. package/dist/resolved-analytical-plan.js.map +1 -0
  57. package/dist/router.d.ts +3 -0
  58. package/dist/router.d.ts.map +1 -1
  59. package/dist/router.js +34 -10
  60. package/dist/router.js.map +1 -1
  61. package/dist/semantic-bridge/member-select.d.ts +6 -0
  62. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  63. package/dist/semantic-bridge/member-select.js +4 -0
  64. package/dist/semantic-bridge/member-select.js.map +1 -1
  65. package/dist/semantic-bridge/query-shape.d.ts +26 -0
  66. package/dist/semantic-bridge/query-shape.d.ts.map +1 -0
  67. package/dist/semantic-bridge/query-shape.js +48 -0
  68. package/dist/semantic-bridge/query-shape.js.map +1 -0
  69. package/package.json +4 -4
@@ -17,6 +17,7 @@
17
17
  import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
18
18
  import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
19
19
  import { detectResultSetOperation, computeResultSetOperation } from './conversation/result-ops.js';
20
+ import { classifyGovernedQueryShape } from './semantic-bridge/query-shape.js';
20
21
  import { domainContextSearchDomains } from './domain-context.js';
21
22
  import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
22
23
  import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
@@ -40,6 +41,9 @@ import { questionTypeFromText } from './meaning-resolution.js';
40
41
  import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
41
42
  import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
42
43
  import { stampTrustLabel } from './trust/stamp.js';
44
+ import { deriveResolvedAnalyticalPlan } from './resolved-analytical-plan.js';
45
+ import { adaptResolvedAnalyticalPlan, buildPlanExecutionRegistry, } from './plan-execution-adapter.js';
46
+ import { buildGovernedRelationalRegistry, compileGovernedRelationalPlan, finalizeGovernedCompilationReceipt, renderGovernedRelationalDqlArtifact, } from './governed-relational-compiler.js';
43
47
  import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, deepAlternativeCountForQuestion, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
44
48
  /**
45
49
  * Physical column names whose sampled runtime values include `value`. Used by the
@@ -459,13 +463,126 @@ function tryCrossResultAnswer(input) {
459
463
  }
460
464
  return answer;
461
465
  }
466
+ /**
467
+ * Materialize exactly the Skill IDs/hashes selected in the immutable context
468
+ * pack. No downstream answer route may rerun trigger/domain selection or add a
469
+ * Skill that was not recorded in the KnowledgeLens.
470
+ *
471
+ * Acceptance: SKILL-003, AGT-013.
472
+ */
473
+ export function materializeKnowledgeLensSkills(contextPack, available) {
474
+ const byIdentity = new Map();
475
+ for (const skill of available) {
476
+ byIdentity.set(skill.qualifiedId ?? skill.id, skill);
477
+ if (!byIdentity.has(skill.id))
478
+ byIdentity.set(skill.id, skill);
479
+ }
480
+ return (contextPack.skills ?? []).map((selected) => {
481
+ const identity = selected.qualifiedId ?? selected.id;
482
+ const source = byIdentity.get(identity) ?? byIdentity.get(selected.id);
483
+ if (source) {
484
+ // Guidance is the immutable, bounded snapshot body. Retain structured
485
+ // source fields but never reread or inject a newer disk body mid-run.
486
+ return { ...source, body: selected.guidance };
487
+ }
488
+ return {
489
+ id: selected.id,
490
+ localId: selected.id,
491
+ qualifiedId: selected.qualifiedId,
492
+ scope: 'project',
493
+ domain: selected.domain,
494
+ domains: selected.domains,
495
+ modelAreaRefs: selected.modelAreaRefs,
496
+ kind: selected.kind,
497
+ status: selected.status,
498
+ owner: selected.owner,
499
+ triggers: selected.triggers,
500
+ exclusions: selected.exclusions,
501
+ description: selected.description,
502
+ preferredMetrics: selected.preferredMetrics,
503
+ preferredBlocks: selected.preferredBlocks,
504
+ preferredDimensions: selected.preferredDimensions,
505
+ requiredFilters: selected.requiredFilters,
506
+ clarifyWhen: selected.clarifyWhen,
507
+ examples: [],
508
+ sourceRefs: selected.sourceRefs,
509
+ vocabulary: selected.vocabulary,
510
+ body: selected.guidance,
511
+ sourcePath: selected.sourcePath ?? `snapshot:${selected.objectKey}`,
512
+ };
513
+ });
514
+ }
462
515
  export async function answer(input) {
516
+ const inheritedPlan = !input.resolvedAnalyticalPlan
517
+ && input.followUp?.priorResolvedAnalyticalPlan
518
+ && input.followUp.resolvedAnalyticalPlanDelta
519
+ ? deriveResolvedAnalyticalPlan(input.followUp.priorResolvedAnalyticalPlan, input.followUp.resolvedAnalyticalPlanDelta)
520
+ : undefined;
521
+ const normalizedInput = inheritedPlan
522
+ ? { ...input, resolvedAnalyticalPlan: inheritedPlan }
523
+ : input;
524
+ const resolvedPlanExecutionBinding = normalizedInput.resolvedAnalyticalPlan
525
+ ? adaptResolvedAnalyticalPlan({
526
+ plan: normalizedInput.resolvedAnalyticalPlan,
527
+ registry: buildPlanExecutionRegistry({
528
+ nodes: [
529
+ ...normalizedInput.kg.getNodesByKind('block', 100_000),
530
+ ...normalizedInput.kg.getNodesByKind('metric', 100_000),
531
+ ...normalizedInput.kg.getNodesByKind('dimension', 100_000),
532
+ ],
533
+ objects: normalizedInput.contextPack?.objects,
534
+ }),
535
+ semanticLayer: normalizedInput.semanticLayer,
536
+ expectedSnapshotId: normalizedInput.contextPack?.knowledgeLens.snapshotId,
537
+ })
538
+ : undefined;
539
+ const planBoundInput = resolvedPlanExecutionBinding
540
+ ? { ...normalizedInput, resolvedPlanExecutionBinding }
541
+ : normalizedInput;
542
+ const relationalSchemaContext = planBoundInput.schemaContext?.length
543
+ ? planBoundInput.schemaContext
544
+ : (planBoundInput.contextPack?.allowedSqlContext.relations ?? []).map((relation) => ({
545
+ relation: relation.relation,
546
+ name: relation.name,
547
+ columns: relation.columns.map((column) => ({
548
+ name: column.name,
549
+ type: column.type,
550
+ description: column.description,
551
+ })),
552
+ }));
553
+ const governedRelationalCompilation = planBoundInput.resolvedAnalyticalPlan?.mode === 'authoritative'
554
+ && planBoundInput.resolvedAnalyticalPlan.capability === 'governed_relational'
555
+ ? compileGovernedRelationalPlan({
556
+ plan: planBoundInput.resolvedAnalyticalPlan,
557
+ registry: buildGovernedRelationalRegistry({
558
+ snapshotId: planBoundInput.resolvedAnalyticalPlan.snapshotId,
559
+ schemaContext: relationalSchemaContext,
560
+ manifest: planBoundInput.manifest,
561
+ }),
562
+ driver: planBoundInput.semanticDriver,
563
+ })
564
+ : undefined;
565
+ const compiledInput = governedRelationalCompilation
566
+ ? { ...planBoundInput, governedRelationalCompilation }
567
+ : planBoundInput;
568
+ const executionInput = compiledInput.contextPack
569
+ ? {
570
+ ...compiledInput,
571
+ skills: materializeKnowledgeLensSkills(compiledInput.contextPack, compiledInput.skills ?? []),
572
+ skillsSelectionLocked: true,
573
+ }
574
+ : compiledInput;
463
575
  // Cross-result follow-up ("of these, the average") — computed from the prior
464
576
  // rows, before the cascade, so it never re-queries or times out.
465
- const crossResult = tryCrossResultAnswer(input);
466
- if (crossResult)
467
- return crossResult;
468
- const result = applyHollowAnswerGate(await runAnswerLoop(input));
577
+ const crossResult = tryCrossResultAnswer(executionInput);
578
+ if (crossResult) {
579
+ return {
580
+ ...crossResult,
581
+ resolvedAnalyticalPlan: executionInput.resolvedAnalyticalPlan,
582
+ executablePlan: executionInput.resolvedPlanExecutionBinding,
583
+ };
584
+ }
585
+ const result = applyHollowAnswerGate(await runAnswerLoop(executionInput));
469
586
  // Attach the canonical trust label once, at the single exit point, so every
470
587
  // return site inside runAnswerLoop stays untouched and backward compatible.
471
588
  // Freshness-aware trust: for a certified answer, fold the source block's data
@@ -494,6 +611,8 @@ export async function answer(input) {
494
611
  ...publicResult,
495
612
  domainContext: input.domainContext,
496
613
  intentDecision,
614
+ resolvedAnalyticalPlan: executionInput.resolvedAnalyticalPlan,
615
+ executablePlan: executionInput.resolvedPlanExecutionBinding,
497
616
  trustLabelInfo,
498
617
  provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
499
618
  cascade: publicResult.cascade ?? createCascadeAnswerResult({
@@ -513,15 +632,17 @@ export async function answer(input) {
513
632
  // Stamp the SELECTED skills that shaped the answer (transparency). Computed
514
633
  // here so every return site inside runAnswerLoop stays untouched.
515
634
  appliedSkills: result.appliedSkills ??
516
- selectRelevantSkills(input.skills ?? [], input.question, {
517
- userId: input.userId ?? null,
518
- modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
519
- domains: Array.from(new Set([
520
- ...domainContextSearchDomains(input.domainContext),
521
- ...(input.domain ? [input.domain] : []),
522
- ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
523
- ])),
524
- }).map((s) => ({
635
+ (executionInput.skillsSelectionLocked
636
+ ? executionInput.skills ?? []
637
+ : selectRelevantSkills(executionInput.skills ?? [], executionInput.question, {
638
+ userId: executionInput.userId ?? null,
639
+ modelAreaIds: executionInput.domainContext?.modelAreaId ? [executionInput.domainContext.modelAreaId] : [],
640
+ domains: Array.from(new Set([
641
+ ...domainContextSearchDomains(executionInput.domainContext),
642
+ ...(executionInput.domain ? [executionInput.domain] : []),
643
+ ...(executionInput.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
644
+ ])),
645
+ })).map((s) => ({
525
646
  id: s.id,
526
647
  description: s.description,
527
648
  })),
@@ -613,11 +734,13 @@ async function runAnswerLoop(input) {
613
734
  ? (input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : [])
614
735
  : []),
615
736
  ]));
616
- const selectedSkills = selectRelevantSkills(skills, question, {
617
- userId: userId ?? null,
618
- domains: inferredDomains,
619
- modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
620
- });
737
+ const selectedSkills = input.skillsSelectionLocked
738
+ ? skills
739
+ : selectRelevantSkills(skills, question, {
740
+ userId: userId ?? null,
741
+ domains: inferredDomains,
742
+ modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
743
+ });
621
744
  const effectiveBlockHints = Array.from(new Set([
622
745
  ...blockHints,
623
746
  // Only selected skills may influence block ranking. Previously a preferred
@@ -653,6 +776,85 @@ async function runAnswerLoop(input) {
653
776
  // columns—not copy a customer-grain worked example into a product-grain ask.
654
777
  const promptContextPack = contextPackForRequestedShape(scopedContextPack, question, questionPlan, kg);
655
778
  const repairBudgetState = createCascadeBudgetState(input.cascadeBudgetModel);
779
+ const authoritativePlanBinding = input.resolvedAnalyticalPlan?.mode === 'authoritative'
780
+ ? input.resolvedPlanExecutionBinding
781
+ : undefined;
782
+ const governedRelationalCompilation = input.governedRelationalCompilation;
783
+ if (governedRelationalCompilation?.status === 'blocked') {
784
+ const text = `The governed relational plan cannot compile safely: ${governedRelationalCompilation.reason}`;
785
+ return {
786
+ kind: 'no_answer',
787
+ sourceTier: 'no_answer',
788
+ certification: 'analyst_review_required',
789
+ reviewStatus: 'none',
790
+ confidence: 0,
791
+ text,
792
+ answer: text,
793
+ refusalCode: governedRelationalCompilation.code.startsWith('RELATIONSHIP_') ? 'modeling_gap' : 'grounding_gap',
794
+ refusalDetails: { code: 'grounding_gap', message: `${governedRelationalCompilation.code}: ${governedRelationalCompilation.reason}` },
795
+ citations: contextPackCitations(input.contextPack, 8),
796
+ considered,
797
+ contextPack: input.contextPack,
798
+ providerUsed: provider.name,
799
+ };
800
+ }
801
+ if (governedRelationalCompilation?.status === 'compiled') {
802
+ const dqlArtifact = renderGovernedRelationalDqlArtifact(governedRelationalCompilation);
803
+ let result;
804
+ let executionError;
805
+ let receipt = governedRelationalCompilation.receipt;
806
+ if (input.executeDqlArtifact) {
807
+ try {
808
+ result = await input.executeDqlArtifact(dqlArtifact);
809
+ receipt = finalizeGovernedCompilationReceipt(receipt, result);
810
+ }
811
+ catch (error) {
812
+ executionError = error instanceof Error ? error.message : String(error);
813
+ }
814
+ }
815
+ const text = executionError
816
+ ? `The governed relational query compiled, but execution failed: ${executionError}`
817
+ : result
818
+ ? `Compiled and executed the snapshot-bound governed relational plan. Returned ${result.rowCount} row${result.rowCount === 1 ? '' : 's'}.`
819
+ : 'Compiled the snapshot-bound governed relational plan. Execution was not requested.';
820
+ return {
821
+ kind: executionError ? 'no_answer' : 'uncertified',
822
+ sourceTier: executionError ? 'no_answer' : 'dbt_manifest',
823
+ certification: executionError ? 'analyst_review_required' : 'governed',
824
+ reviewStatus: executionError ? 'analyst_review_required' : 'governed',
825
+ confidence: executionError ? 0 : 0.9,
826
+ text,
827
+ answer: text,
828
+ ...(executionError ? { executionError, refusalCode: 'grounding_gap' } : {}),
829
+ proposedSql: governedRelationalCompilation.sql,
830
+ sql: governedRelationalCompilation.sql,
831
+ dqlArtifact,
832
+ result,
833
+ governedCompilationReceipt: receipt,
834
+ citations: schemaCitations(schemaContext, 8),
835
+ considered,
836
+ contextPack: input.contextPack,
837
+ providerUsed: provider.name,
838
+ };
839
+ }
840
+ if (authoritativePlanBinding?.status === 'blocked') {
841
+ const text = `The resolved analytical plan cannot execute safely: ${authoritativePlanBinding.reason}`;
842
+ return {
843
+ kind: 'no_answer',
844
+ sourceTier: 'no_answer',
845
+ certification: 'analyst_review_required',
846
+ reviewStatus: 'none',
847
+ confidence: 0,
848
+ text,
849
+ answer: text,
850
+ refusalCode: authoritativePlanBinding.code === 'PLAN_BLOCKED' ? 'ambiguous' : 'grounding_gap',
851
+ refusalDetails: { code: authoritativePlanBinding.code, message: authoritativePlanBinding.reason },
852
+ citations: contextPackCitations(input.contextPack, 8),
853
+ considered,
854
+ contextPack: input.contextPack,
855
+ providerUsed: provider.name,
856
+ };
857
+ }
656
858
  const fallbackIntent = classifyAgentIntent({
657
859
  question,
658
860
  followUp: input.followUp,
@@ -672,16 +874,25 @@ async function runAnswerLoop(input) {
672
874
  if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
673
875
  semanticMetricNodes.push(node);
674
876
  }
675
- const preferredSemanticMetric = resolvePreferredSemanticMetric([input.preferredExecutionId, ...(input.preferredEvidenceIds ?? [])], semanticMetricNodes, kg);
877
+ const authoritativeSemanticBinding = authoritativePlanBinding?.status === 'ready'
878
+ && authoritativePlanBinding.kind === 'semantic'
879
+ ? authoritativePlanBinding
880
+ : undefined;
881
+ const preferredSemanticMetric = authoritativeSemanticBinding?.metricNode
882
+ ?? (input.resolvedAnalyticalPlan?.mode === 'authoritative'
883
+ ? undefined
884
+ : resolvePreferredSemanticMetric([input.preferredExecutionId, ...(input.preferredEvidenceIds ?? [])], semanticMetricNodes, kg));
676
885
  const semanticLayerForExec = input.semanticLayer;
677
886
  const canExecuteSemanticMetricForMatch = input.canExecuteSemanticMetric
678
887
  ?? (semanticLayerForExec ? (name) => semanticLayerForExec.canComposeMetric(name) : undefined);
679
888
  let semanticMetricMatch = preferredSemanticMetric
680
889
  ? { metric: preferredSemanticMetric, score: 1, basis: 'name' }
681
- : await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
682
- measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
683
- ...(canExecuteSemanticMetricForMatch ? { canExecute: canExecuteSemanticMetricForMatch } : {}),
684
- }).catch(() => null);
890
+ : input.resolvedAnalyticalPlan?.mode === 'authoritative'
891
+ ? null
892
+ : await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
893
+ measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
894
+ ...(canExecuteSemanticMetricForMatch ? { canExecute: canExecuteSemanticMetricForMatch } : {}),
895
+ }).catch(() => null);
685
896
  // Stage 1: certified artifact match. Blocks can be executed; dashboards,
686
897
  // Apps, and notebooks are returned as governed citations/navigation targets.
687
898
  const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
@@ -725,8 +936,16 @@ async function runAnswerLoop(input) {
725
936
  excludedArtifactIds,
726
937
  kg,
727
938
  }) : null;
728
- let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
729
- ?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
939
+ const authoritativeCertifiedBinding = authoritativePlanBinding?.status === 'ready'
940
+ && authoritativePlanBinding.kind === 'certified'
941
+ ? authoritativePlanBinding
942
+ : undefined;
943
+ let artifactHit = authoritativeCertifiedBinding
944
+ ? { node: authoritativeCertifiedBinding.node, score: 1 }
945
+ : input.resolvedAnalyticalPlan?.mode === 'authoritative'
946
+ ? null
947
+ : drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
948
+ ?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
730
949
  let certifiedExecutionFallback;
731
950
  // Certified remains first when it actually covers the question. If the
732
951
  // retrieved block does not fit but a governed semantic metric does, never
@@ -874,8 +1093,10 @@ async function runAnswerLoop(input) {
874
1093
  const dqlArtifact = buildCertifiedBlockDqlArtifact(artifactHit.node, result, questionPlan.requestedShape.topN?.scope === 'per_group'
875
1094
  ? undefined
876
1095
  : questionPlan.requestedShape.topN?.n);
1096
+ const authoritativeCertifiedFailure = Boolean(authoritativeCertifiedBinding && executionError);
877
1097
  const recoverableCertifiedFailure = artifactHit.node.kind === 'block'
878
1098
  && executionError !== undefined
1099
+ && !authoritativeCertifiedFailure
879
1100
  && isRetryableCertifiedExecutionError(executionError);
880
1101
  if (recoverableCertifiedFailure) {
881
1102
  // A certified artifact is trusted evidence, not an obligation to return a
@@ -888,16 +1109,17 @@ async function runAnswerLoop(input) {
888
1109
  }
889
1110
  else {
890
1111
  return {
891
- kind: certifiedShapePassed ? 'certified' : 'uncertified',
892
- sourceTier,
1112
+ kind: authoritativeCertifiedFailure ? 'no_answer' : certifiedShapePassed ? 'certified' : 'uncertified',
1113
+ sourceTier: authoritativeCertifiedFailure ? 'no_answer' : sourceTier,
893
1114
  certification: certifiedShapePassed ? 'certified' : 'analyst_review_required',
894
- reviewStatus: certifiedShapePassed ? 'certified' : 'analyst_review_required',
1115
+ reviewStatus: authoritativeCertifiedFailure ? 'none' : certifiedShapePassed ? 'certified' : 'analyst_review_required',
895
1116
  confidence: certifiedShapePassed ? 0.95 : 0.45,
896
1117
  text,
897
1118
  answer: text,
898
1119
  block: artifactHit.node.kind === 'block' ? artifactHit.node : undefined,
899
1120
  result,
900
1121
  executionError,
1122
+ ...(authoritativeCertifiedFailure ? { refusalCode: 'grounding_gap' } : {}),
901
1123
  sql: result?.sql,
902
1124
  dqlArtifact,
903
1125
  trustLabel: certifiedShapePassed ? input.contextPack?.trustLabel ?? 'certified' : 'mixed',
@@ -1094,7 +1316,44 @@ async function runAnswerLoop(input) {
1094
1316
  let semanticBridgeAnswer;
1095
1317
  let semanticRuntimeFailure;
1096
1318
  let semanticRuntimeCompiledAnswer = false;
1097
- if (input.semanticLayer && semanticMetricMatch) {
1319
+ if (authoritativeSemanticBinding && input.semanticLayer) {
1320
+ const selection = authoritativeSemanticBinding.selection;
1321
+ semanticBridgeAnswer = composeSemanticQueryFromMembers({
1322
+ semanticLayer: input.semanticLayer,
1323
+ question,
1324
+ selection,
1325
+ ...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
1326
+ ...(input.semanticTableMapping ? { tableMapping: input.semanticTableMapping } : {}),
1327
+ });
1328
+ if (!semanticBridgeAnswer && input.semanticQueryCompiler) {
1329
+ try {
1330
+ const compiled = await input.semanticQueryCompiler(selection);
1331
+ semanticBridgeAnswer = composeSemanticQueryFromCompiledMembers({
1332
+ semanticLayer: input.semanticLayer,
1333
+ question,
1334
+ selection: compiled.selection ?? selection,
1335
+ sql: compiled.sql,
1336
+ });
1337
+ semanticRuntimeCompiledAnswer = Boolean(semanticBridgeAnswer && compiled.engine !== 'native');
1338
+ }
1339
+ catch (error) {
1340
+ semanticRuntimeFailure = error instanceof Error ? error.message : String(error);
1341
+ }
1342
+ }
1343
+ if (semanticBridgeAnswer) {
1344
+ semanticBridgeToolCalls.push({
1345
+ name: 'compile_resolved_analytical_plan',
1346
+ status: 'checked',
1347
+ inputSummary: `plan: ${authoritativeSemanticBinding.planId}; metric: ${selection.metrics.join(', ')}`,
1348
+ outputSummary: 'Compiled the exact snapshot-bound member selection without rematching.',
1349
+ order: 1,
1350
+ });
1351
+ }
1352
+ else if (!semanticRuntimeFailure) {
1353
+ semanticRuntimeFailure = `The exact plan ${authoritativeSemanticBinding.planId} is not composable by the pinned semantic runtime.`;
1354
+ }
1355
+ }
1356
+ if (!authoritativeSemanticBinding && input.semanticLayer && semanticMetricMatch) {
1098
1357
  semanticBridgeAnswer = composeSemanticQueryForQuestion({
1099
1358
  semanticLayer: input.semanticLayer,
1100
1359
  question,
@@ -1152,16 +1411,19 @@ async function runAnswerLoop(input) {
1152
1411
  // spend another model call re-selecting the same member or silently fall
1153
1412
  // into exploratory SQL; surface the compiler's actionable error instead.
1154
1413
  if (!semanticBridgeAnswer && !semanticRuntimeFailure) {
1155
- const selection = await selectSemanticMembersViaLlm({
1156
- provider,
1157
- semanticLayer: input.semanticLayer,
1158
- question,
1159
- signal: input.signal,
1160
- reasoningEffort: input.reasoningEffort,
1161
- });
1162
- if (selection) {
1414
+ const requestedGroupingDims = requestedGroupingDimensions(questionPlan);
1415
+ const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
1416
+ || questionPlan.dimensionTerms.length > 0;
1417
+ const requiredGroupingCount = requestedGroupingDims.length;
1418
+ // Capture the narrowed semantic layer so the async closure below keeps the
1419
+ // non-undefined type (TS resets narrowing across closure boundaries).
1420
+ const bridgeLayer = input.semanticLayer;
1421
+ // Compose a member selection: native first, then the runtime compiler for
1422
+ // metrics native can't express. Side effects (runtime flags, tool calls)
1423
+ // are recorded here so a retry reruns them cleanly.
1424
+ const composeSelection = async (selection) => {
1163
1425
  let composed = composeSemanticQueryFromMembers({
1164
- semanticLayer: input.semanticLayer,
1426
+ semanticLayer: bridgeLayer,
1165
1427
  question,
1166
1428
  selection,
1167
1429
  ...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
@@ -1171,7 +1433,7 @@ async function runAnswerLoop(input) {
1171
1433
  try {
1172
1434
  const compiled = await input.semanticQueryCompiler(selection);
1173
1435
  composed = composeSemanticQueryFromCompiledMembers({
1174
- semanticLayer: input.semanticLayer,
1436
+ semanticLayer: bridgeLayer,
1175
1437
  question,
1176
1438
  selection: compiled.selection ?? selection,
1177
1439
  sql: compiled.sql,
@@ -1191,18 +1453,44 @@ async function runAnswerLoop(input) {
1191
1453
  semanticRuntimeFailure = error instanceof Error ? error.message : String(error);
1192
1454
  }
1193
1455
  }
1194
- // Coverage guard: if the question asked for a breakdown but the LLM
1195
- // selection produced none, the governed answer would silently DROP the
1196
- // requested grouping (governed-but-wrong). Fall through to Lane-3
1197
- // generation, which can express the breakdown.
1198
- const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
1199
- || questionPlan.dimensionTerms.length > 0;
1200
- const requiredGroupingCount = requestedGroupingDimensions(questionPlan).length;
1201
- const dropsBreakdown = Boolean(composed)
1202
- && wantedBreakdown
1203
- && composed.dimensions.length < Math.max(1, requiredGroupingCount)
1204
- && !composed.timeDimension;
1205
- if (composed && !dropsBreakdown) {
1456
+ return composed;
1457
+ };
1458
+ // A governed answer that silently DROPS the requested grouping is governed-
1459
+ // but-wrong. Detect it so we retry (forcing the breakdown) rather than
1460
+ // adopting a scalar answer to a breakdown question.
1461
+ const dropsBreakdown = (composed) => Boolean(composed) && wantedBreakdown
1462
+ && composed.dimensions.length < Math.max(1, requiredGroupingCount)
1463
+ && !composed.timeDimension;
1464
+ let selection = await selectSemanticMembersViaLlm({
1465
+ provider,
1466
+ semanticLayer: input.semanticLayer,
1467
+ question,
1468
+ signal: input.signal,
1469
+ reasoningEffort: input.reasoningEffort,
1470
+ });
1471
+ if (selection) {
1472
+ let composed = await composeSelection(selection);
1473
+ // Retry ONCE forcing the requested breakdown dimensions before giving up
1474
+ // on the governed lane — the model sometimes omits a group-by it could
1475
+ // have kept. If it still can't, the Tier-2.5 gate decides refuse-vs-SQL.
1476
+ if (composed && dropsBreakdown(composed) && requestedGroupingDims.length > 0 && !semanticRuntimeFailure) {
1477
+ const retry = await selectSemanticMembersViaLlm({
1478
+ provider,
1479
+ semanticLayer: input.semanticLayer,
1480
+ question,
1481
+ signal: input.signal,
1482
+ reasoningEffort: input.reasoningEffort,
1483
+ requireDimensions: requestedGroupingDims,
1484
+ });
1485
+ if (retry) {
1486
+ const retryComposed = await composeSelection(retry);
1487
+ if (retryComposed && !dropsBreakdown(retryComposed)) {
1488
+ composed = retryComposed;
1489
+ selection = retry;
1490
+ }
1491
+ }
1492
+ }
1493
+ if (composed && !dropsBreakdown(composed)) {
1206
1494
  semanticBridgeAnswer = composed;
1207
1495
  semanticBridgeToolCalls.push({
1208
1496
  name: 'query_semantic_model',
@@ -1345,6 +1633,40 @@ async function runAnswerLoop(input) {
1345
1633
  // measure and only generates the join/grouping around it. Keeps the number
1346
1634
  // consistent with the governed metric. Degrades to plain generation when the metric
1347
1635
  // has no resolvable definition. The grain ledger + validation still gate the output.
1636
+ // Structured-selection gate: a governed metric matched but the compiler-owned
1637
+ // lanes above all failed. Refuse-with-reason ONLY when we are CONFIDENT the
1638
+ // failure is a MODELING gap — the requested breakdown resolves to a DECLARED
1639
+ // dimension the semantic models can't join to this metric (no_join_path). In
1640
+ // that one case free SQL would misjoin or reference an ambiguous column, so we
1641
+ // list the connected dimensions instead of guessing. Everything else — a
1642
+ // paraphrase the compile tool can still resolve, a raw column answerable by
1643
+ // safe single-table SQL, a genuine compiler gap (per-group topN/arithmetic/
1644
+ // window/HAVING), or a derived metric needing the runtime — keeps its existing
1645
+ // path (the compile_semantic_query tool + metric-anchored generation below).
1646
+ if (input.semanticLayer && semanticMetricMatch) {
1647
+ const shape = classifyGovernedQueryShape(questionPlan, semanticMetricMatch.metric.name, input.semanticLayer);
1648
+ const modelingGap = shape === 'genuine_gap'
1649
+ ? undefined
1650
+ : resolveModelingGap(questionPlan, semanticMetricMatch.metric.name, input.semanticLayer);
1651
+ if (modelingGap) {
1652
+ return buildModelingGapRefusal({
1653
+ question,
1654
+ metricName: modelingGap.metricName,
1655
+ incompatibleDimension: modelingGap.incompatibleDim,
1656
+ semanticLayer: input.semanticLayer,
1657
+ questionPlan,
1658
+ contextPack: input.contextPack,
1659
+ considered,
1660
+ memoryContext: input.memoryContext,
1661
+ artifactHits,
1662
+ businessHits,
1663
+ semanticHits,
1664
+ manifestHits,
1665
+ toolCalls: semanticBridgeToolCalls,
1666
+ providerName: provider.name,
1667
+ });
1668
+ }
1669
+ }
1348
1670
  const metricAnchor = semanticMetricMatch && input.semanticLayer
1349
1671
  ? resolveGovernedMetricDefinition(semanticMetricMatch.metric, semanticMetricNodes, input.semanticLayer)
1350
1672
  : undefined;
@@ -2082,7 +2404,7 @@ async function runAnswerLoop(input) {
2082
2404
  catch (err) {
2083
2405
  executionError = err instanceof Error ? err.message : String(err);
2084
2406
  }
2085
- if (executionError && !fanoutContradiction) {
2407
+ if (executionError && !fanoutContradiction && !authoritativeSemanticBinding) {
2086
2408
  if (isRetryableGeneratedSqlError(executionError)) {
2087
2409
  const localRepairSql = repairGeneratedSqlLocally(parsed.sql, executionError, schemaContext);
2088
2410
  if (localRepairSql && canUseLaneRepair(repairBudgetState, 'execution')) {
@@ -2304,19 +2626,21 @@ async function runAnswerLoop(input) {
2304
2626
  ? semanticMetricMatch?.metric.certification
2305
2627
  : undefined;
2306
2628
  const certifiedMetricAnswer = semanticMetricCertification === 'certified' || semanticMetricCertification === 'reviewed';
2629
+ const governedMetricExecutionFailure = governedMetricAnswer && Boolean(executionError);
2307
2630
  return {
2308
- kind: 'uncertified',
2309
- sourceTier: governedMetricAnswer ? 'semantic_layer' : activeTier,
2310
- certification: governedMetricAnswer ? 'governed' : 'ai_generated',
2311
- reviewStatus: governedMetricAnswer ? 'governed' : 'draft_ready',
2631
+ kind: governedMetricExecutionFailure ? 'no_answer' : 'uncertified',
2632
+ sourceTier: governedMetricExecutionFailure ? 'no_answer' : governedMetricAnswer ? 'semantic_layer' : activeTier,
2633
+ certification: governedMetricExecutionFailure ? 'analyst_review_required' : governedMetricAnswer ? 'governed' : 'ai_generated',
2634
+ reviewStatus: governedMetricExecutionFailure ? 'none' : governedMetricAnswer ? 'governed' : 'draft_ready',
2312
2635
  semanticMetricCertification,
2313
- confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
2636
+ confidence: governedMetricExecutionFailure ? 0 : certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
2314
2637
  text: generatedText,
2315
2638
  answer: generatedText,
2316
2639
  proposedSql: parsed.sql,
2317
2640
  sql: parsed.sql,
2318
2641
  result,
2319
2642
  executionError,
2643
+ ...(governedMetricExecutionFailure ? { refusalCode: 'grounding_gap' } : {}),
2320
2644
  suggestedViz: parsed.viz ?? 'table',
2321
2645
  dqlArtifact: answerDqlArtifact,
2322
2646
  draftBlock,
@@ -4972,6 +5296,115 @@ function isRetryableCertifiedExecutionError(error) {
4972
5296
  || /\bcolumn\b.*\b(?:not\s+found|does\s+not\s+exist|not\s+recognized|unknown)\b/i.test(error)
4973
5297
  || /\btable\b.*\b(?:not\s+found|does\s+not\s+exist|unknown)\b/i.test(error);
4974
5298
  }
5299
+ /**
5300
+ * A governed metric matched, the compiler-owned lanes couldn't build the exact
5301
+ * shape, and the shape is one the compiler SHOULD handle — so the gap is a
5302
+ * missing join/model relationship, not a reason to guess SQL. Refuse with the
5303
+ * connected dimensions so the user knows exactly what to add. Mirrors the
5304
+ * AGT-005 semantic-runtime-failure no_answer shape.
5305
+ */
5306
+ /**
5307
+ * Confident modeling-gap detector: does the requested breakdown resolve to a
5308
+ * DECLARED dimension the semantic models can't join to this metric? Only then is
5309
+ * a compose failure a real modeling gap (list the connected dims, refuse) rather
5310
+ * than a paraphrase the compile tool can resolve or a raw column safe SQL can
5311
+ * answer. Matching is intentionally CONSERVATIVE (name/label/qualified equality),
5312
+ * so an uncertain match falls through to generation — never a false refusal.
5313
+ */
5314
+ function resolveModelingGap(questionPlan, metricName, semanticLayer) {
5315
+ const canonical = semanticLayer.listMetrics().find((metric) => metric.name === metricName
5316
+ || metric.name.endsWith(`.${metricName}`)
5317
+ || metricName.endsWith(`.${metric.name}`))?.name ?? metricName;
5318
+ let explained;
5319
+ try {
5320
+ explained = semanticLayer.explainCompatibleDimensions([canonical]);
5321
+ }
5322
+ catch {
5323
+ return undefined;
5324
+ }
5325
+ const { compatible, incompatible } = explained;
5326
+ // A declared dimension the join graph can't reach from this metric. Ignore
5327
+ // metric_unresolved (a name-resolution miss, not a user-facing modeling gap).
5328
+ const gappy = incompatible.filter((dim) => dim.reason === 'no_join_path' || dim.reason === 'not_shared_across_metrics');
5329
+ if (gappy.length === 0)
5330
+ return undefined;
5331
+ const requested = [
5332
+ ...questionPlan.requestedShape.dimensions,
5333
+ ...questionPlan.dimensionTerms,
5334
+ ].map((value) => value.trim()).filter(Boolean);
5335
+ if (requested.length === 0)
5336
+ return undefined;
5337
+ const lastSegment = (name) => name?.split(/[._]/).pop();
5338
+ const matchesDim = (phrase, dim) => conceptsEquivalent(phrase, dim.name)
5339
+ || (dim.label ? conceptsEquivalent(phrase, dim.label) : false)
5340
+ || (dim.qualifiedName ? conceptsEquivalent(phrase, dim.qualifiedName) : false)
5341
+ || (lastSegment(dim.qualifiedName) ? conceptsEquivalent(phrase, lastSegment(dim.qualifiedName)) : false);
5342
+ for (const phrase of requested) {
5343
+ // A phrase that also matches a CONNECTED dimension is not a gap — the compose/
5344
+ // retry lane owns it. Only refuse when the phrase matches an unconnected dim
5345
+ // and nothing connected.
5346
+ if (compatible.some((dim) => matchesDim(phrase, dim)))
5347
+ continue;
5348
+ const hit = gappy.find((dim) => matchesDim(phrase, dim));
5349
+ if (hit) {
5350
+ const connected = compatible.map((dim) => dim.label || dim.name).slice(0, 12);
5351
+ return { metricName: canonical, incompatibleDim: hit.name, connected };
5352
+ }
5353
+ }
5354
+ return undefined;
5355
+ }
5356
+ function buildModelingGapRefusal(input) {
5357
+ const compatible = (() => {
5358
+ try {
5359
+ return input.semanticLayer.explainCompatibleDimensions([input.metricName]).compatible;
5360
+ }
5361
+ catch {
5362
+ return [];
5363
+ }
5364
+ })();
5365
+ const connected = compatible.map((dimension) => dimension.label || dimension.name).slice(0, 12);
5366
+ const requested = [
5367
+ ...input.questionPlan.requestedShape.dimensions,
5368
+ ...input.questionPlan.dimensionTerms,
5369
+ ].filter((value, index, all) => value && all.indexOf(value) === index);
5370
+ const requestedText = input.incompatibleDimension
5371
+ ? `by ${input.incompatibleDimension}`
5372
+ : requested.length > 0 ? `by ${requested.join(', ')}` : 'by the requested breakdown';
5373
+ const text = [
5374
+ `“${input.metricName}” can’t be grouped ${requestedText} — the semantic models don’t declare a join path between them.`,
5375
+ connected.length > 0
5376
+ ? `It CAN be grouped by: ${connected.join(', ')}.`
5377
+ : 'No compatible group-by dimension is modeled for this metric.',
5378
+ 'To enable this breakdown, add the relationship (a join/entity) in the dbt semantic models and recompile. Nothing was executed.',
5379
+ ].join(' ');
5380
+ return {
5381
+ kind: 'no_answer',
5382
+ sourceTier: 'no_answer',
5383
+ certification: 'analyst_review_required',
5384
+ reviewStatus: 'none',
5385
+ confidence: 0,
5386
+ text,
5387
+ answer: text,
5388
+ refusalCode: 'modeling_gap',
5389
+ refusalDetails: { code: 'modeling_gap', message: text },
5390
+ citations: [],
5391
+ memoryContext: input.memoryContext,
5392
+ evidence: buildNoAnswerEvidence({
5393
+ question: input.question,
5394
+ reason: text,
5395
+ artifactHits: input.artifactHits,
5396
+ businessHits: input.businessHits,
5397
+ semanticHits: input.semanticHits,
5398
+ manifestHits: input.manifestHits,
5399
+ considered: input.considered,
5400
+ memoryContext: input.memoryContext ?? [],
5401
+ toolCalls: input.toolCalls,
5402
+ }),
5403
+ contextPack: input.contextPack,
5404
+ considered: input.considered,
5405
+ providerUsed: input.providerName,
5406
+ };
5407
+ }
4975
5408
  function repairGeneratedSqlLocally(sql, error, schemaContext) {
4976
5409
  // Ambiguous column: a bare column exists in more than one joined table (e.g. a
4977
5410
  // conformed `report_as_of_dt` on both the fact and a joined dimension), and the