@duckcodeailabs/dql-agent 1.13.3 → 1.13.5

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 (131) hide show
  1. package/dist/agent-run-engine.d.ts +66 -3
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +441 -28
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agentic/tool-loop.d.ts.map +1 -1
  6. package/dist/agentic/tool-loop.js +100 -32
  7. package/dist/agentic/tool-loop.js.map +1 -1
  8. package/dist/aggregation-safety-proof.d.ts +41 -0
  9. package/dist/aggregation-safety-proof.d.ts.map +1 -0
  10. package/dist/aggregation-safety-proof.js +511 -0
  11. package/dist/aggregation-safety-proof.js.map +1 -0
  12. package/dist/analytical-compatibility.d.ts.map +1 -1
  13. package/dist/analytical-compatibility.js +42 -25
  14. package/dist/analytical-compatibility.js.map +1 -1
  15. package/dist/analytical-execution-graph.d.ts +4 -21
  16. package/dist/analytical-execution-graph.d.ts.map +1 -1
  17. package/dist/analytical-execution-graph.js +27 -0
  18. package/dist/analytical-execution-graph.js.map +1 -1
  19. package/dist/analytical-frame.d.ts +12 -1
  20. package/dist/analytical-frame.d.ts.map +1 -1
  21. package/dist/analytical-frame.js +102 -2
  22. package/dist/analytical-frame.js.map +1 -1
  23. package/dist/analytical-result-facts.d.ts +53 -0
  24. package/dist/analytical-result-facts.d.ts.map +1 -1
  25. package/dist/analytical-result-facts.js +144 -0
  26. package/dist/analytical-result-facts.js.map +1 -1
  27. package/dist/answer-loop.d.ts +40 -3
  28. package/dist/answer-loop.d.ts.map +1 -1
  29. package/dist/answer-loop.js +681 -37
  30. package/dist/answer-loop.js.map +1 -1
  31. package/dist/app-builder-orchestrator.d.ts +2 -0
  32. package/dist/app-builder-orchestrator.d.ts.map +1 -1
  33. package/dist/app-builder-orchestrator.js +142 -7
  34. package/dist/app-builder-orchestrator.js.map +1 -1
  35. package/dist/app-source-policy.d.ts +14 -0
  36. package/dist/app-source-policy.d.ts.map +1 -0
  37. package/dist/app-source-policy.js +38 -0
  38. package/dist/app-source-policy.js.map +1 -0
  39. package/dist/cascade/budgets.js +1 -1
  40. package/dist/cascade/route-policy.d.ts +1 -1
  41. package/dist/cascade/route-policy.d.ts.map +1 -1
  42. package/dist/cascade/route-policy.js +2 -0
  43. package/dist/cascade/route-policy.js.map +1 -1
  44. package/dist/generated-analytical-proposal.d.ts +37 -0
  45. package/dist/generated-analytical-proposal.d.ts.map +1 -0
  46. package/dist/generated-analytical-proposal.js +219 -0
  47. package/dist/generated-analytical-proposal.js.map +1 -0
  48. package/dist/index.d.ts +15 -9
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +28 -18
  51. package/dist/index.js.map +1 -1
  52. package/dist/intent-controller.d.ts +8 -1
  53. package/dist/intent-controller.d.ts.map +1 -1
  54. package/dist/intent-controller.js.map +1 -1
  55. package/dist/kg/build.js +102 -6
  56. package/dist/kg/build.js.map +1 -1
  57. package/dist/meaning-resolution.d.ts +23 -0
  58. package/dist/meaning-resolution.d.ts.map +1 -1
  59. package/dist/meaning-resolution.js +61 -8
  60. package/dist/meaning-resolution.js.map +1 -1
  61. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  62. package/dist/metadata/analysis-planner.js +46 -2
  63. package/dist/metadata/analysis-planner.js.map +1 -1
  64. package/dist/metadata/block-fit.d.ts.map +1 -1
  65. package/dist/metadata/block-fit.js +55 -2
  66. package/dist/metadata/block-fit.js.map +1 -1
  67. package/dist/metadata/catalog.d.ts.map +1 -1
  68. package/dist/metadata/catalog.js +9 -0
  69. package/dist/metadata/catalog.js.map +1 -1
  70. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  71. package/dist/metadata/meaning-evidence.js +323 -36
  72. package/dist/metadata/meaning-evidence.js.map +1 -1
  73. package/dist/metadata/sql-context-validation.d.ts +3 -0
  74. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  75. package/dist/metadata/sql-context-validation.js +16 -5
  76. package/dist/metadata/sql-context-validation.js.map +1 -1
  77. package/dist/plan-execution-adapter.d.ts +9 -1
  78. package/dist/plan-execution-adapter.d.ts.map +1 -1
  79. package/dist/plan-execution-adapter.js +81 -32
  80. package/dist/plan-execution-adapter.js.map +1 -1
  81. package/dist/provider-egress.d.ts +122 -0
  82. package/dist/provider-egress.d.ts.map +1 -0
  83. package/dist/provider-egress.js +504 -0
  84. package/dist/provider-egress.js.map +1 -0
  85. package/dist/providers/claude.d.ts.map +1 -1
  86. package/dist/providers/claude.js +76 -33
  87. package/dist/providers/claude.js.map +1 -1
  88. package/dist/providers/dispatch.d.ts +15 -0
  89. package/dist/providers/dispatch.d.ts.map +1 -0
  90. package/dist/providers/dispatch.js +25 -0
  91. package/dist/providers/dispatch.js.map +1 -0
  92. package/dist/providers/gemini.d.ts.map +1 -1
  93. package/dist/providers/gemini.js +16 -8
  94. package/dist/providers/gemini.js.map +1 -1
  95. package/dist/providers/index.d.ts +2 -1
  96. package/dist/providers/index.d.ts.map +1 -1
  97. package/dist/providers/index.js +1 -0
  98. package/dist/providers/index.js.map +1 -1
  99. package/dist/providers/ollama.d.ts.map +1 -1
  100. package/dist/providers/ollama.js +29 -10
  101. package/dist/providers/ollama.js.map +1 -1
  102. package/dist/providers/openai.d.ts.map +1 -1
  103. package/dist/providers/openai.js +92 -36
  104. package/dist/providers/openai.js.map +1 -1
  105. package/dist/providers/types.d.ts +43 -0
  106. package/dist/providers/types.d.ts.map +1 -1
  107. package/dist/providers/types.js.map +1 -1
  108. package/dist/relationship-proof.d.ts +19 -0
  109. package/dist/relationship-proof.d.ts.map +1 -0
  110. package/dist/relationship-proof.js +75 -0
  111. package/dist/relationship-proof.js.map +1 -0
  112. package/dist/research-loop.d.ts.map +1 -1
  113. package/dist/research-loop.js +17 -11
  114. package/dist/research-loop.js.map +1 -1
  115. package/dist/resolved-analytical-plan.d.ts +25 -3
  116. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  117. package/dist/resolved-analytical-plan.js +369 -27
  118. package/dist/resolved-analytical-plan.js.map +1 -1
  119. package/dist/router.d.ts +30 -1
  120. package/dist/router.d.ts.map +1 -1
  121. package/dist/router.js +705 -44
  122. package/dist/router.js.map +1 -1
  123. package/dist/semantic-identities.d.ts +18 -0
  124. package/dist/semantic-identities.d.ts.map +1 -0
  125. package/dist/semantic-identities.js +60 -0
  126. package/dist/semantic-identities.js.map +1 -0
  127. package/dist/semantic-proof-authority.d.ts +29 -0
  128. package/dist/semantic-proof-authority.d.ts.map +1 -0
  129. package/dist/semantic-proof-authority.js +34 -0
  130. package/dist/semantic-proof-authority.js.map +1 -0
  131. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * question never pays twice.
16
16
  */
17
17
  import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
18
- import { buildMeaningEvidencePackage, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
18
+ import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
19
19
  import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
20
20
  import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
21
21
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
22
- import { buildDeterministicAnalyticalFrame } from "./analytical-frame.js";
22
+ import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
23
23
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
24
24
  const DEFAULT_THRESHOLD = 0.7;
25
25
  const DEFAULT_CACHE_SIZE = 200;
@@ -414,14 +414,22 @@ function retrievalTrace(evidence, candidates) {
414
414
  };
415
415
  }
416
416
  function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
417
- const routedResolution = enforceAnalyticalCompatibility(resolution, evidence, candidates);
418
- const needsClarification = routedResolution.confidence === "low" ||
419
- routedResolution.recommendedRoute === "clarify";
420
- const analytical = routedResolution.questionType === "diagnosis" ||
421
- routedResolution.questionType === "research";
422
- const reason = needsClarification
423
- ? `The retrieved evidence does not prove one complete analytical tuple: ${routedResolution.missingInformation.join(" ") || routedResolution.interpretedQuestion}`
424
- : `Resolved the question against ${routedResolution.selectedConceptIds.join(", ")}: ${routedResolution.interpretedQuestion}`;
417
+ let planBoundResolution = resolution;
418
+ if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
419
+ const { analyticalFrame: sourceFrame, ...resolutionWithoutFrame } = resolution;
420
+ const bindingPlan = buildResolvedAnalyticalPlan({
421
+ question,
422
+ resolution: resolutionWithoutFrame,
423
+ evidence,
424
+ candidates,
425
+ mode,
426
+ });
427
+ planBoundResolution = {
428
+ ...resolution,
429
+ analyticalFrame: projectResolvedAnalyticalFrame({ plan: bindingPlan, sourceFrame }),
430
+ };
431
+ }
432
+ const routedResolution = enforceAnalyticalCompatibility(planBoundResolution, evidence, candidates);
425
433
  const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
426
434
  question,
427
435
  resolution: routedResolution,
@@ -429,13 +437,22 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
429
437
  candidates,
430
438
  mode,
431
439
  });
440
+ const reconciliation = reconcileResolvedPlanOutcome(routedResolution, resolvedAnalyticalPlan, candidates);
441
+ const needsClarification = reconciliation.outcome === 'clarify';
442
+ const terminallyBlocked = reconciliation.outcome === 'modeling_gap'
443
+ || reconciliation.outcome === 'policy_blocked';
444
+ const analytical = routedResolution.questionType === "diagnosis" ||
445
+ routedResolution.questionType === "research";
446
+ const reason = reconciliation.reason;
432
447
  return {
433
448
  ...base,
434
449
  action: needsClarification
435
450
  ? "clarify"
436
- : analytical
437
- ? "investigate"
438
- : "answer",
451
+ : terminallyBlocked
452
+ ? "block"
453
+ : analytical
454
+ ? "investigate"
455
+ : "answer",
439
456
  confidence: routedResolution.confidence === "high"
440
457
  ? 0.9
441
458
  : routedResolution.confidence === "medium"
@@ -449,17 +466,117 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
449
466
  resolvedAnalyticalPlan,
450
467
  retrievalEvidence: retrievalTrace(evidence, candidates),
451
468
  requiresClarification: needsClarification,
469
+ ...(terminallyBlocked
470
+ ? {
471
+ terminalOutcome: {
472
+ kind: reconciliation.outcome === 'policy_blocked'
473
+ ? 'policy_blocked'
474
+ : 'modeling_gap',
475
+ code: reconciliation.outcome === 'policy_blocked'
476
+ ? 'ANALYTICAL_POLICY_BLOCKED'
477
+ : 'ANALYTICAL_MODELING_GAP',
478
+ message: reconciliation.reason,
479
+ candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
480
+ },
481
+ }
482
+ : {}),
452
483
  ...(needsClarification
453
- ? { clarificationOptions: buildClarificationOptions(candidates) }
484
+ ? { clarificationOptions: reconciliation.options }
454
485
  : {}),
455
486
  ...(needsClarification
456
487
  ? {
457
- clarifyingQuestion: routedResolution.clarifyingQuestion ??
458
- buildEvidenceClarification(candidates, routedResolution.missingInformation),
488
+ clarifyingQuestion: reconciliation.question,
459
489
  }
460
490
  : {}),
461
491
  };
462
492
  }
493
+ /**
494
+ * The immutable RAP is the final routing authority. Meaning may nominate an
495
+ * execution route, but cannot leave the router claiming an answer after the
496
+ * host has retained an ambiguous or blocked qualified binding.
497
+ */
498
+ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
499
+ if (plan.capability !== 'blocked') {
500
+ return {
501
+ outcome: 'ready',
502
+ reason: `Resolved the question against ${plan.selectedConceptIds.join(', ')}: ${resolution.interpretedQuestion}`,
503
+ };
504
+ }
505
+ const bindings = [
506
+ ...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
507
+ ...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
508
+ ...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
509
+ ].filter(({ binding }) => binding.status !== 'resolved');
510
+ const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
511
+ const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
512
+ if (plan.resolutionFailure?.outcome === 'policy_blocked') {
513
+ return {
514
+ outcome: 'policy_blocked',
515
+ reason: `Policy blocked the selected analytical plan: ${plan.missingInformation.join(' ') || 'review the retained policy diagnostic.'}`,
516
+ };
517
+ }
518
+ if (plan.resolutionFailure?.outcome === 'modeling_gap') {
519
+ return {
520
+ outcome: 'modeling_gap',
521
+ reason: `The selected analytical plan has a governed modeling gap: ${plan.missingInformation.join(' ') || 'review the retained capability diagnostic.'}`,
522
+ };
523
+ }
524
+ if (userResolvableBinding || plan.resolutionFailure?.outcome === 'clarify') {
525
+ const optionIds = qualifiedChoiceIds.length > 0
526
+ ? qualifiedChoiceIds
527
+ : [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
528
+ const options = optionIds.length > 0
529
+ ? clarificationOptionsForQualifiedIds(optionIds, candidates)
530
+ : buildClarificationOptions(candidates);
531
+ const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
532
+ const question = routedClarificationQuestion(resolution, bindings, options);
533
+ return {
534
+ outcome: 'clarify',
535
+ reason: `The immutable analytical plan needs one identifier-bound choice: ${bindingSummary || plan.missingInformation.join(' ')}`,
536
+ question,
537
+ options,
538
+ };
539
+ }
540
+ if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
541
+ return {
542
+ outcome: 'clarify',
543
+ reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
544
+ question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
545
+ options: buildClarificationOptions(candidates),
546
+ };
547
+ }
548
+ return {
549
+ outcome: 'modeling_gap',
550
+ reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
551
+ };
552
+ }
553
+ function clarificationOptionsForQualifiedIds(ids, candidates) {
554
+ return ids.slice(0, 3).map((id) => {
555
+ const candidate = candidates.find((item) => item.id === id || item.qualifiedId === id);
556
+ return {
557
+ id,
558
+ label: candidate?.name ?? qualifiedIdLabel(id),
559
+ ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
560
+ kind: candidate?.kind ?? 'semantic_member',
561
+ };
562
+ });
563
+ }
564
+ function qualifiedIdLabel(id) {
565
+ const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
566
+ return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
567
+ }
568
+ function routedClarificationQuestion(resolution, bindings, options) {
569
+ if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
570
+ return resolution.clarifyingQuestion;
571
+ }
572
+ const first = bindings[0];
573
+ const labels = options.map((option) => option.label);
574
+ if (first && labels.length > 1) {
575
+ return `Which governed ${first.kind} should I use for “${first.binding.requested}”: ${labels.join(' or ')}?`;
576
+ }
577
+ return resolution.clarifyingQuestion
578
+ ?? `Which governed binding should I use before running this query?`;
579
+ }
463
580
  function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
464
581
  return {
465
582
  ...base,
@@ -478,7 +595,14 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
478
595
  const requestedMetricCount = new Set((evidence.parsedIntent?.measures ?? resolution.queryIntent.measures)
479
596
  .map(normalizeMetricPhrase)
480
597
  .filter(Boolean)).size;
598
+ // Parser hints may split one business metric name into overlapping measure
599
+ // tokens (for example "rollover balance amount" -> "balance", "amount").
600
+ // Treat several hints as a multi-metric contract only when the question
601
+ // actually coordinates separate measures; otherwise one exact qualified
602
+ // capability is allowed to bind all synonymous hints.
603
+ const explicitlyCoordinatesMetrics = /(?:,|\b(?:and|plus|versus|vs\.?|along with)\b)/i.test(resolution.interpretedQuestion);
481
604
  if (requestedMetricCount > 1
605
+ && explicitlyCoordinatesMetrics
482
606
  && (missingMetricTerms.length > 0
483
607
  || (resolution.analyticalFrame?.metricConceptIds.length ?? 0) < requestedMetricCount)) {
484
608
  const missing = missingMetricTerms.length > 0
@@ -529,6 +653,8 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
529
653
  recommendedExecutionId: result.candidateId,
530
654
  recommendedRoute: result.route,
531
655
  missingInformation: [],
656
+ compatibilityOutcome: undefined,
657
+ compatibilityFailures: undefined,
532
658
  };
533
659
  }
534
660
  // A question asking for SEVERAL metrics ("revenue and refunds by month") is a
@@ -547,12 +673,26 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
547
673
  };
548
674
  }
549
675
  const failures = result.failures.map((failure) => failure.message);
676
+ const compatibilityFailures = result.failures.map((failure) => ({
677
+ code: failure.code,
678
+ field: failure.field,
679
+ message: failure.message,
680
+ candidateIds: [...(failure.candidateIds ?? [])],
681
+ }));
682
+ const policyFailure = result.failures.some((failure) => failure.code.startsWith('POLICY_'));
683
+ const compatibilityOutcome = policyFailure
684
+ ? 'policy_blocked'
685
+ : result.status === 'clarify'
686
+ ? 'clarify'
687
+ : 'modeling_gap';
550
688
  return {
551
689
  ...resolution,
552
690
  analyticalFrame: result.frame,
553
691
  analyticalPolicyIds: result.policyIds,
554
692
  confidence: result.status === "clarify" ? "low" : resolution.confidence,
555
693
  recommendedRoute: "clarify",
694
+ compatibilityOutcome,
695
+ compatibilityFailures,
556
696
  missingInformation: [
557
697
  ...new Set([...resolution.missingInformation, ...failures]),
558
698
  ],
@@ -570,12 +710,72 @@ function buildClarificationOptions(candidates) {
570
710
  const pool = governed.length > 1
571
711
  ? governed
572
712
  : candidates.filter((candidate) => candidate.compatibility !== "incompatible");
573
- return pool.slice(0, 3).map((candidate) => ({
574
- id: candidate.id,
575
- label: candidate.name,
576
- ...(candidate.definition?.trim() ? { description: candidate.definition.trim() } : {}),
577
- kind: candidate.kind,
578
- }));
713
+ const chosen = pool.slice(0, 3);
714
+ // Two candidates can legitimately share a display name (a dbt model and its
715
+ // MetricFlow measure are both "customers"). Rendering both as "customers"
716
+ // asks the user to choose between two identical-looking buttons, so the
717
+ // duplicates carry their distinguishing identity.
718
+ const nameCounts = new Map();
719
+ for (const candidate of chosen) {
720
+ nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
721
+ }
722
+ return chosen.map((candidate) => {
723
+ const ambiguousName = (nameCounts.get(candidate.name) ?? 0) > 1;
724
+ const description = humanizeCandidateDefinition(candidate.definition);
725
+ return {
726
+ id: candidate.id,
727
+ label: ambiguousName
728
+ ? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
729
+ : candidate.name,
730
+ ...(description ? { description } : {}),
731
+ kind: candidate.kind,
732
+ };
733
+ });
734
+ }
735
+ function candidateKindLabel(kind) {
736
+ if (kind === 'certified_block')
737
+ return 'certified block';
738
+ if (kind === 'semantic_metric')
739
+ return 'metric';
740
+ if (kind === 'semantic_member')
741
+ return 'model field';
742
+ return kind.replace(/[_-]+/g, ' ');
743
+ }
744
+ /**
745
+ * A candidate's `definition` is sometimes the raw semantic-layer record —
746
+ * `label: customers\naggregation: count_distinct\ntable: "..."\nexpr: customer_id`.
747
+ * Dumping that into a question asks a business user to disambiguate by reading
748
+ * YAML. Turn a recognisable key/value record into one plain sentence; leave
749
+ * genuine authored prose alone.
750
+ */
751
+ export function humanizeCandidateDefinition(definition) {
752
+ const text = definition?.trim();
753
+ if (!text)
754
+ return undefined;
755
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
756
+ const fields = new Map();
757
+ for (const line of lines) {
758
+ const match = /^([a-z_][a-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
759
+ if (match)
760
+ fields.set(match[1].toLowerCase(), match[2].trim().replace(/^["']|["']$/g, ''));
761
+ }
762
+ // Only treat it as a record when MOST of it is key/value pairs; a one-line
763
+ // sentence containing a colon is prose, not a dump.
764
+ if (fields.size < 2 || fields.size < lines.length - 1) {
765
+ return text.replace(/\s+/g, ' ').slice(0, 200);
766
+ }
767
+ const aggregation = fields.get('aggregation');
768
+ const expr = fields.get('expr');
769
+ const table = fields.get('table');
770
+ const parts = [];
771
+ if (aggregation)
772
+ parts.push(`${aggregation.replace(/_/g, ' ')}${expr ? ` of ${expr}` : ''}`);
773
+ else if (expr)
774
+ parts.push(expr);
775
+ if (table)
776
+ parts.push(`from ${table.split('.').pop()?.replace(/"/g, '') ?? table}`);
777
+ const summary = parts.join(' ');
778
+ return summary ? `${summary}.` : undefined;
579
779
  }
580
780
  function buildEvidenceClarification(candidates, missing = []) {
581
781
  const governedChoices = candidates.filter((candidate) => candidate.compatibility !== "incompatible"
@@ -594,20 +794,43 @@ function buildEvidenceClarification(candidates, missing = []) {
594
794
  return "Which governed business meaning should I use for this question?";
595
795
  }
596
796
  function directResolution(request, evidence, candidate, candidates) {
797
+ const inferredQuestionType = questionTypeFromText(request.question);
798
+ const questionType = inferredQuestionType === 'definition'
799
+ && candidate.kind === 'semantic_metric'
800
+ && candidate.exactMatch
801
+ && Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability)
802
+ && /^\s*what (?:is|was|were|are)\b/i.test(request.question)
803
+ && !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
804
+ ? 'value'
805
+ : inferredQuestionType;
597
806
  const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
598
807
  const analyticalFrame = buildDeterministicAnalyticalFrame({
599
808
  question: request.question,
809
+ questionType,
600
810
  evidence,
601
811
  metricCandidate: candidate,
602
812
  metricCandidates,
603
813
  candidates,
604
814
  });
815
+ const queryIntent = defaultQueryIntent(evidence);
816
+ const memberCandidates = candidates.filter((item) => {
817
+ if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
818
+ return false;
819
+ const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
820
+ return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
821
+ });
822
+ const canonicalFilters = queryIntent.filters.map((filter) => {
823
+ const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
824
+ .map(normalizeMetricPhrase)
825
+ .includes(normalizeMetricPhrase(filter.value)));
826
+ return member ? { ...filter, value: member.name } : filter;
827
+ });
605
828
  return {
606
829
  interpretedQuestion: request.question,
607
- questionType: questionTypeFromText(request.question),
608
- selectedConceptIds: metricCandidates.map((metric) => metric.id),
830
+ questionType,
831
+ selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
609
832
  recommendedExecutionId: candidate.id,
610
- queryIntent: defaultQueryIntent(evidence),
833
+ queryIntent: { ...queryIntent, filters: canonicalFilters },
611
834
  rejectedCandidates: [],
612
835
  confidence: "high",
613
836
  missingInformation: [],
@@ -623,6 +846,12 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
623
846
  // representations of the same request, not additional requested metrics.
624
847
  if (requestedTerms.length <= 1)
625
848
  return [primary];
849
+ const primaryNames = [primary.name, ...(primary.aliases ?? [])]
850
+ .map(normalizeMetricPhrase)
851
+ .filter(Boolean);
852
+ if (requestedTerms.every((term) => primaryNames.some((name) => metricTermsMatch(name, term)))) {
853
+ return [primary];
854
+ }
626
855
  const questionText = normalizeMetricPhrase(question);
627
856
  const metrics = candidates.filter((candidate) => {
628
857
  if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
@@ -646,6 +875,7 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
646
875
  function normalizeMetricPhrase(value) {
647
876
  return value
648
877
  .toLowerCase()
878
+ .replace(/%/g, ' percentage ')
649
879
  .replace(/[_./:-]+/g, ' ')
650
880
  .replace(/[^a-z0-9 ]+/g, ' ')
651
881
  .replace(/\s+/g, ' ')
@@ -686,7 +916,16 @@ function exactMultiMetricPrimary(question, evidence, candidates) {
686
916
  ? primary
687
917
  : undefined;
688
918
  }
689
- function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative') {
919
+ function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative',
920
+ /**
921
+ * Whether DQL may commit to the best-ranked reading on the user's behalf.
922
+ *
923
+ * False when the meaning resolver never got to run (provider outage). Two
924
+ * genuinely different metrics that merely share an alias — `booked_revenue`
925
+ * and `billed_revenue` both aliased "revenue" — must not be settled by
926
+ * lexical rank alone with the semantic judgment switched off. AGT-017.
927
+ */
928
+ mayAssumeInterpretation = true) {
690
929
  const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
691
930
  if (multiMetricPrimary) {
692
931
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
@@ -700,22 +939,354 @@ function routeWithoutMeaningModel(request, base, evidence, candidates, planMode
700
939
  if (semanticMetric) {
701
940
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
702
941
  }
703
- if (base.action === "investigate") {
942
+ // Last resort before asking the user: commit to the best governed reading.
943
+ // Clarifying whenever retrieval returned more than one candidate meant a
944
+ // `type: simple` metric, the measure it wraps, and the model that holds them
945
+ // were offered as three competing "meanings" of the same number.
946
+ const best = mayAssumeInterpretation
947
+ ? bestGovernedInterpretation(request.question, candidates)
948
+ : undefined;
949
+ if (best) {
950
+ return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
951
+ }
952
+ return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
953
+ }
954
+ /** Leaf identity of a governed candidate, ignoring its source qualification. */
955
+ function candidateLeafName(candidate) {
956
+ const identity = candidate.qualifiedId ?? candidate.id;
957
+ return (identity.split(/[.:]/).at(-1) ?? candidate.name).trim().toLowerCase();
958
+ }
959
+ /** Prefer the most authoritative representative of one underlying meaning. */
960
+ function governedObjectAuthority(candidate) {
961
+ if (candidate.semanticObjectType === 'metric')
962
+ return 3;
963
+ if (candidate.semanticObjectType === 'measure')
964
+ return 2;
965
+ if (candidate.semanticObjectType === 'model')
966
+ return 1;
967
+ return 0;
968
+ }
969
+ /**
970
+ * Reduce retrieval output to genuinely DIFFERENT governed meanings.
971
+ *
972
+ * Two collapses, both provable from the semantic registry rather than guessed:
973
+ * - A simple metric, the measure it wraps, and the model that holds them are
974
+ * one meaning. Offering all three asks a person to choose between a thing
975
+ * and its own wrapper.
976
+ * - An entity is a join key, not an answer. When the question asks for an
977
+ * attribute ("customer names") and a dimension matched, entities are not
978
+ * candidate readings of it at all.
979
+ */
980
+ export function collapseRedundantGovernedCandidates(question, candidates) {
981
+ const byScore = [...new Map(candidates
982
+ .filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
983
+ .map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
984
+ const wantsAttribute = /\b(names?|labels?|titles?|descriptions?)\b/i.test(question);
985
+ const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
986
+ const kindFiltered = wantsAttribute && hasDimension
987
+ ? byScore.filter((candidate) => candidate.semanticObjectType !== 'entity')
988
+ : byScore;
989
+ const representatives = new Map();
990
+ const passthrough = [];
991
+ for (const candidate of kindFiltered) {
992
+ const type = candidate.semanticObjectType;
993
+ if (type !== 'metric' && type !== 'measure' && type !== 'model') {
994
+ passthrough.push(candidate);
995
+ continue;
996
+ }
997
+ const key = `${(candidate.semanticModel ?? '').toLowerCase()}::${candidateLeafName(candidate)}`;
998
+ const current = representatives.get(key);
999
+ if (!current || governedObjectAuthority(candidate) > governedObjectAuthority(current)) {
1000
+ representatives.set(key, candidate);
1001
+ }
1002
+ }
1003
+ return [...passthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
1004
+ }
1005
+ /**
1006
+ * The governed meaning to run when nothing proved a single exact reading.
1007
+ *
1008
+ * DQL commits to the best-ranked interpretation and discloses it through the
1009
+ * route label, instead of stopping to ask. Asking on every multi-candidate
1010
+ * question made ordinary lookups feel like an interrogation, and most of those
1011
+ * questions had no real ambiguity behind them.
1012
+ */
1013
+ export function bestGovernedInterpretation(question, candidates) {
1014
+ // Take the best candidate that is actually executable, rather than refusing
1015
+ // because the top-ranked hit happens to be a descriptive modeling entity.
1016
+ // `compatibility: 'unknown'` is common for governed objects that execute
1017
+ // perfectly well, so it does not disqualify — but 'partial' does. A partial
1018
+ // match has already been proven NOT to cover the request, and running one
1019
+ // silently answers a different question than the one that was asked.
1020
+ return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
1021
+ && (candidate.kind === 'certified_block'
1022
+ || candidate.kind === 'semantic_metric'
1023
+ || candidate.kind === 'semantic_member'));
1024
+ }
1025
+ /**
1026
+ * Retrieval may nominate qualified candidates, but only the resolved plan may
1027
+ * authorize analytical execution. If bounded meaning cannot freeze one exact
1028
+ * tuple, retain stable candidate identities for a focused continuation or emit
1029
+ * a typed modeling gap. Never hand those candidates to the legacy answer loop
1030
+ * as another meaning/planning authority.
1031
+ */
1032
+ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], question = '') {
1033
+ // Ask only about meanings that genuinely differ. A metric, the measure it
1034
+ // wraps, and their model are one reading, and a join key is not a reading of
1035
+ // an attribute question at all.
1036
+ const eligible = collapseRedundantGovernedCandidates(question, candidates);
1037
+ const trace = evidence ? retrievalTrace(evidence, candidates) : {
1038
+ candidateCount: 0,
1039
+ candidateIds: [],
1040
+ };
1041
+ if (eligible.length > 1) {
1042
+ const choices = eligible.slice(0, 3);
704
1043
  return {
705
1044
  ...base,
706
- category: "data_analysis",
707
- retrievalEvidence: retrievalTrace(evidence, candidates),
1045
+ action: 'clarify',
1046
+ confidence: 1,
1047
+ source: 'heuristic',
1048
+ category: 'unclear',
1049
+ depth: 'quick',
1050
+ reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
1051
+ requiresClarification: true,
1052
+ clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
1053
+ clarificationOptions: buildClarificationOptions(choices),
1054
+ retrievalEvidence: trace,
1055
+ resolvedAnalyticalPlan: undefined,
1056
+ meaningResolution: undefined,
708
1057
  };
709
1058
  }
1059
+ const candidateIds = eligible.map((candidate) => candidate.qualifiedId ?? candidate.id);
1060
+ const message = candidateIds.length === 1
1061
+ ? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
1062
+ : 'No governed candidate proved the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability or choose a governed identifier before retrying.';
710
1063
  return {
711
1064
  ...base,
712
- action: "answer",
713
- confidence: Math.max(base.confidence, 0.7),
714
- reason: `Retrieved ${candidates.length} governed candidate${candidates.length === 1 ? "" : "s"}; the answer executor will resolve them without a router general-knowledge fallback.`,
715
- category: "data_lookup",
716
- retrievalEvidence: retrievalTrace(evidence, candidates),
1065
+ action: 'block',
1066
+ confidence: 1,
1067
+ source: 'heuristic',
1068
+ category: base.action === 'investigate' ? 'data_analysis' : 'data_lookup',
1069
+ depth: 'quick',
1070
+ reason: message,
1071
+ requiresClarification: false,
1072
+ retrievalEvidence: trace,
1073
+ terminalOutcome: {
1074
+ kind: 'modeling_gap',
1075
+ code: 'ANALYTICAL_MODELING_GAP',
1076
+ message,
1077
+ candidateIds,
1078
+ },
1079
+ resolvedAnalyticalPlan: undefined,
1080
+ meaningResolution: undefined,
717
1081
  };
718
1082
  }
1083
+ /**
1084
+ * Deterministic business questions that must be settled before meaning/model
1085
+ * orchestration. These are not proof failures: no immutable analytical plan
1086
+ * exists yet, so the only valid outcome is one concise clarification.
1087
+ */
1088
+ function deterministicPrePlanClarification(request, base, evidence, candidates) {
1089
+ const asksForRanking = questionTypeFromText(request.question) === 'ranking';
1090
+ const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
1091
+ .map(normalizeMetricPhrase)
1092
+ .filter(Boolean);
1093
+ const hasExplicitRankingMetric = requestedMeasures.length > 0
1094
+ && requestedMeasures.every((requested) => candidates.some((candidate) => candidateProvesMetricTerm(candidate, requested)))
1095
+ || hasStrongQualifiedMetricEvidence(candidates)
1096
+ || hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
1097
+ const retrievalEvidence = retrievalTrace(evidence, candidates);
1098
+ const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
1099
+ const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
1100
+ && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
1101
+ ? [normalizeMetricPhrase(filter.field)]
1102
+ : []));
1103
+ const missingDimensions = requestedDimensions.filter((requested) => !modeledFilterFields.has(requested)
1104
+ && !candidates.some((candidate) => candidateProvesDimensionTerm(candidate, requested)));
1105
+ if (missingDimensions.length > 0) {
1106
+ const filterValues = (evidence.parsedIntent?.filters ?? []).map((filter) => normalizeMetricPhrase(filter.value));
1107
+ const alternatives = candidates
1108
+ .filter(isCompatibleQualifiedMember)
1109
+ .filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, missingDimensions))
1110
+ .filter((candidate) => !candidateIdentityTerms(candidate).some((term) => requestedDimensions.some((requested) => metricTermsMatch(term, requested))
1111
+ || filterValues.some((value) => metricTermsMatch(term, value))))
1112
+ .sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
1113
+ .slice(0, 3);
1114
+ if (alternatives.length === 0) {
1115
+ if (!asksForRanking || hasExplicitRankingMetric)
1116
+ return undefined;
1117
+ return bareRankingClarification(base, retrievalEvidence);
1118
+ }
1119
+ const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1120
+ const alternativeLabels = alternatives.map(renderCandidateChoice);
1121
+ return {
1122
+ ...base,
1123
+ action: 'clarify',
1124
+ confidence: 1,
1125
+ reason: `The requested dimension ${requestedLabel} is absent from the retrieved qualified evidence, so no analytical plan was frozen.`,
1126
+ source: 'heuristic',
1127
+ category: 'unclear',
1128
+ depth: 'quick',
1129
+ requiresClarification: true,
1130
+ clarifyingQuestion: alternativeLabels.length > 0
1131
+ ? `${requestedLabel} is not modeled. Should I use ${alternativeLabels.join(' or ')} instead?`
1132
+ : `${requestedLabel} is not modeled. Which governed dimension should I use instead?`,
1133
+ retrievalEvidence,
1134
+ ...(alternatives.length > 0 ? { clarificationOptions: buildClarificationOptions(alternatives) } : {}),
1135
+ resolvedAnalyticalPlan: undefined,
1136
+ meaningResolution: undefined,
1137
+ };
1138
+ }
1139
+ if (asksForRanking && !hasExplicitRankingMetric) {
1140
+ return bareRankingClarification(base, retrievalEvidence);
1141
+ }
1142
+ return undefined;
1143
+ }
1144
+ function bareRankingClarification(base, retrievalEvidence) {
1145
+ return {
1146
+ ...base,
1147
+ action: 'clarify',
1148
+ confidence: 1,
1149
+ reason: 'A ranking needs a positively identified governed metric before an execution capability can be selected.',
1150
+ source: 'heuristic',
1151
+ category: 'unclear',
1152
+ depth: 'quick',
1153
+ requiresClarification: true,
1154
+ clarifyingQuestion: 'Top by which governed metric?',
1155
+ retrievalEvidence,
1156
+ resolvedAnalyticalPlan: undefined,
1157
+ meaningResolution: undefined,
1158
+ };
1159
+ }
1160
+ function uniqueNormalizedTerms(values) {
1161
+ return [...new Set(values.map(normalizeMetricPhrase).filter(Boolean))];
1162
+ }
1163
+ function isCompatibleQualifiedMember(candidate) {
1164
+ return candidate.kind === 'semantic_member'
1165
+ && candidate.compatibility !== 'incompatible'
1166
+ && Boolean(candidate.qualifiedId ?? candidate.id);
1167
+ }
1168
+ function candidateIdentityTerms(candidate) {
1169
+ return uniqueNormalizedTerms([
1170
+ candidate.id,
1171
+ candidate.qualifiedId ?? '',
1172
+ candidate.name,
1173
+ ...(candidate.aliases ?? []),
1174
+ ...(candidate.dimensions ?? []),
1175
+ ]);
1176
+ }
1177
+ function candidateProvesDimensionTerm(candidate, requested) {
1178
+ if (candidate.compatibility === 'incompatible')
1179
+ return false;
1180
+ if (candidate.kind === 'semantic_member'
1181
+ && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, requested)))
1182
+ return true;
1183
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
1184
+ return normalized.status === 'complete'
1185
+ && Boolean(normalized.capability?.dimensions.some((dimension) => metricTermsMatch(normalizeMetricPhrase(dimension.dimensionId), requested)));
1186
+ }
1187
+ function candidateProvesMetricTerm(candidate, requested) {
1188
+ if (candidate.compatibility === 'incompatible')
1189
+ return false;
1190
+ if (candidate.kind === 'certified_block') {
1191
+ return candidate.compatibility === 'compatible';
1192
+ }
1193
+ if (candidate.kind !== 'semantic_metric')
1194
+ return false;
1195
+ const stableIdentity = candidate.qualifiedId ?? candidate.id;
1196
+ if (!stableIdentity)
1197
+ return false;
1198
+ return [candidate.name, ...(candidate.aliases ?? [])]
1199
+ .map(normalizeMetricPhrase)
1200
+ .some((term) => metricTermsMatch(term, requested));
1201
+ }
1202
+ function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
1203
+ const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
1204
+ return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
1205
+ || facts.includes(`dimension alternative for ${requested}`));
1206
+ }
1207
+ /**
1208
+ * A missing optional parsed-intent projection must not erase positive metric
1209
+ * evidence already retrieved for the question. Only complete, compatible,
1210
+ * qualified semantic metrics with an exact/explicit match reason can bypass
1211
+ * the bare-ranking clarification; mere catalog presence is insufficient.
1212
+ */
1213
+ function hasStrongQualifiedMetricEvidence(candidates) {
1214
+ return candidates.some((candidate) => {
1215
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
1216
+ return false;
1217
+ if (!candidate.qualifiedId || !normalizeEvidenceAnalyticalCapability(candidate).capability)
1218
+ return false;
1219
+ if (candidate.exactMatch)
1220
+ return true;
1221
+ return candidate.matchReasons.some((reason) => {
1222
+ const normalized = normalizeMetricPhrase(reason);
1223
+ return /\b(?:exact|explicit)\b/.test(normalized)
1224
+ && /\b(?:metric|measure|meaning|name|alias)\b/.test(normalized);
1225
+ });
1226
+ });
1227
+ }
1228
+ function hasQuestionQualifiedMetricEvidence(question, evidence, candidates) {
1229
+ const questionTokens = new Set(substantiveLexicalTokens(question));
1230
+ const dimensionTokens = new Set(substantiveLexicalTokens((evidence.parsedIntent?.dimensions ?? []).join(' ')));
1231
+ return candidates.some((candidate) => {
1232
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
1233
+ return false;
1234
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
1235
+ if (normalized.status !== 'complete' || !candidate.qualifiedId)
1236
+ return false;
1237
+ return metricLexicalVariants(candidate).some((variant) => {
1238
+ const metricTokens = [...new Set(substantiveLexicalTokens(variant)
1239
+ .filter((token) => !dimensionTokens.has(token)))];
1240
+ if (metricTokens.length === 0)
1241
+ return false;
1242
+ const matched = metricTokens.filter((token) => questionTokens.has(token)).length;
1243
+ return metricTokens.length === 1
1244
+ ? matched === 1
1245
+ : matched >= 2 && matched / metricTokens.length >= 0.5;
1246
+ });
1247
+ });
1248
+ }
1249
+ /**
1250
+ * Compare both authored identities and their canonical local names. Generated
1251
+ * semantic-model namespaces remain available as authored evidence, but cannot
1252
+ * dilute the leaf metric phrase that users naturally ask for.
1253
+ */
1254
+ function metricLexicalVariants(candidate) {
1255
+ const authored = [
1256
+ candidate.name,
1257
+ ...(candidate.aliases ?? []),
1258
+ ...(candidate.qualifiedId ? [candidate.qualifiedId] : []),
1259
+ ].filter(Boolean);
1260
+ return [...new Set(authored.flatMap((value) => {
1261
+ const local = value.split(/[.:/]/).filter(Boolean).at(-1);
1262
+ return local && local !== value ? [value, local] : [value];
1263
+ }))];
1264
+ }
1265
+ const NON_SUBSTANTIVE_RANKING_TOKENS = new Set([
1266
+ 'a', 'an', 'and', 'are', 'at', 'best', 'bottom', 'by', 'count', 'for',
1267
+ 'from', 'give', 'has', 'have', 'highest', 'in', 'is', 'least', 'list',
1268
+ 'lowest', 'me', 'measure', 'metric', 'most', 'number', 'of', 'on', 'or',
1269
+ 'per', 'rank', 'ranked', 'ranking', 'show', 'that', 'the', 'this', 'to',
1270
+ 'top', 'total', 'value', 'what', 'which', 'who', 'with', 'without', 'worst',
1271
+ 'amount',
1272
+ ]);
1273
+ function substantiveLexicalTokens(value) {
1274
+ return normalizeMetricPhrase(value)
1275
+ .split(' ')
1276
+ .filter((token) => token.length >= 2 && !NON_SUBSTANTIVE_RANKING_TOKENS.has(token));
1277
+ }
1278
+ function renderCandidateChoice(candidate) {
1279
+ const identity = candidate.qualifiedId ?? candidate.id;
1280
+ const stableName = identity.split(/[.:]/).at(-1) ?? candidate.name;
1281
+ // Never paste a raw semantic-layer record into the question a person reads.
1282
+ const description = humanizeCandidateDefinition(candidate.definition);
1283
+ const grain = candidate.primaryEntity?.trim();
1284
+ const kind = candidateKindLabel(candidate.kind);
1285
+ const detail = [description, grain ? `grain: ${grain}` : ''].filter(Boolean).join('; ');
1286
+ return detail
1287
+ ? `${candidate.name} — ${kind}, ${detail}`
1288
+ : `${candidate.name} — ${kind} (${stableName})`;
1289
+ }
719
1290
  /**
720
1291
  * AGT-017 / AGT-018 / PERF-002 — provider failure must not send an otherwise
721
1292
  * unambiguous semantic question back through the legacy answer cascade. A dbt
@@ -773,7 +1344,13 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
773
1344
  const floor = Math.max(0.55, exact.relevanceScore - 0.2);
774
1345
  return candidates.some((candidate) => candidate.id !== exact.id
775
1346
  && candidate.compatibility !== "incompatible"
776
- && candidate.relevanceScore >= floor);
1347
+ && candidate.relevanceScore >= floor
1348
+ // A dimension/member card can help bind the selected metric's tuple, but
1349
+ // it is not a competing business metric meaning. Treating it as one sends
1350
+ // an exact metric plus its own dimension candidates back to the resolver.
1351
+ && (exact.kind !== 'semantic_metric'
1352
+ || candidate.kind === 'semantic_metric'
1353
+ || candidate.kind === 'certified_block'));
777
1354
  }
778
1355
  /**
779
1356
  * Skip the meaning-model call only when host-owned fit checks have already
@@ -806,6 +1383,13 @@ function dominantCompatibleGovernedCandidate(candidates) {
806
1383
  const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
807
1384
  return hasExecutableCompetitor ? undefined : best;
808
1385
  }
1386
+ function authoritativeExactCertifiedExample(candidates) {
1387
+ const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
1388
+ && candidate.exactMatch
1389
+ && candidate.compatibility === 'compatible'
1390
+ && candidate.analyticalFitClass === 'exact');
1391
+ return exact.length === 1 ? exact[0] : undefined;
1392
+ }
809
1393
  function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
810
1394
  if (!base.followsUp)
811
1395
  return false;
@@ -878,6 +1462,10 @@ export function createHybridRouter(options = {}) {
878
1462
  if (base.action === "converse" || base.action === "compose_app") {
879
1463
  return { ...base, source: base.source ?? "heuristic" };
880
1464
  }
1465
+ const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
1466
+ if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
1467
+ return softBoundaryDecision(request, base, initialDiscoveryRoute);
1468
+ }
881
1469
  let evidence;
882
1470
  if (options.getEvidence) {
883
1471
  try {
@@ -890,6 +1478,16 @@ export function createHybridRouter(options = {}) {
890
1478
  }
891
1479
  }
892
1480
  if (evidence) {
1481
+ evidence = {
1482
+ ...evidence,
1483
+ candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
1484
+ ...(evidence.clarificationCandidates
1485
+ ? { clarificationCandidates: canonicalizeMetricMeasureCandidates([
1486
+ ...evidence.candidates,
1487
+ ...evidence.clarificationCandidates,
1488
+ ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
1489
+ : {}),
1490
+ };
893
1491
  let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
894
1492
  // A structured clarification selection is authoritative identity input,
895
1493
  // not a new fuzzy-search phrase. Keep it in the bounded package even if
@@ -902,6 +1500,16 @@ export function createHybridRouter(options = {}) {
902
1500
  .slice(0, options.maxMeaningCandidates ?? 12);
903
1501
  }
904
1502
  if (candidates.length > 0) {
1503
+ // Clarification is local and never provider-bound, so it can inspect the
1504
+ // complete already-retrieved set. Keep the smaller package below for
1505
+ // any later meaning call.
1506
+ const clarificationCandidates = [
1507
+ ...evidence.candidates,
1508
+ ...(evidence.clarificationCandidates ?? []),
1509
+ ].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
1510
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
1511
+ if (deterministicClarification)
1512
+ return deterministicClarification;
905
1513
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
906
1514
  if (explicit && explicit.compatibility !== "incompatible") {
907
1515
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
@@ -913,6 +1521,10 @@ export function createHybridRouter(options = {}) {
913
1521
  if (multiMetricPrimary) {
914
1522
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
915
1523
  }
1524
+ const authoredExample = authoritativeExactCertifiedExample(candidates);
1525
+ if (authoredExample) {
1526
+ return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
1527
+ }
916
1528
  const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
917
1529
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
918
1530
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
@@ -929,12 +1541,29 @@ export function createHybridRouter(options = {}) {
929
1541
  if (cached && (options.cacheTtlMs === undefined || now() - cached.at < cacheTtlMs)) {
930
1542
  return { ...cached.decision, source: "cache" };
931
1543
  }
1544
+ // Distinguish "the resolver could not run" from "the resolver ran and
1545
+ // froze nothing". Only the first is a reason to refuse to interpret:
1546
+ // with the semantic judgment unavailable, lexical rank alone must not
1547
+ // settle two genuinely different metrics (AGT-017). When the resolver
1548
+ // did run, committing to the best governed reading is the whole point.
1549
+ let meaningResolverReachable = true;
932
1550
  try {
1551
+ if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
1552
+ return softBoundaryDecision(request, base, 'clarify');
1553
+ }
933
1554
  const resolution = options.resolveMeaning
934
1555
  ? await options.resolveMeaning({
935
1556
  question: request.question,
936
1557
  history: effectiveConversationHistory(request),
937
- evidence,
1558
+ // The resolver/provider receives the same bounded evidence
1559
+ // package as its candidate argument. Supplemental qualified
1560
+ // cards are a host-only clarification aid and must not leak
1561
+ // through this richer carrier.
1562
+ evidence: {
1563
+ ...evidence,
1564
+ candidates,
1565
+ clarificationCandidates: undefined,
1566
+ },
938
1567
  candidates,
939
1568
  signal: request.signal ?? options.signal,
940
1569
  })
@@ -953,21 +1582,24 @@ export function createHybridRouter(options = {}) {
953
1582
  const invalidResolution = {
954
1583
  interpretedQuestion: request.question,
955
1584
  questionType: questionTypeFromText(request.question),
956
- selectedConceptIds: [],
957
- queryIntent: defaultQueryIntent(evidence),
1585
+ selectedConceptIds: resolution.selectedConceptIds,
1586
+ recommendedExecutionId: resolution.recommendedExecutionId,
1587
+ queryIntent: resolution.queryIntent,
958
1588
  rejectedCandidates: [],
959
1589
  confidence: "low",
960
1590
  missingInformation: [validated.reason],
961
1591
  recommendedRoute: "clarify",
962
- clarifyingQuestion: buildEvidenceClarification(candidates, [validated.reason]),
1592
+ compatibilityOutcome: 'modeling_gap',
1593
+ compatibilityFailures: [{
1594
+ code: 'INVALID_EVIDENCE_REFERENCE',
1595
+ field: 'meaningResolution',
1596
+ message: validated.reason,
1597
+ candidateIds: [],
1598
+ }],
963
1599
  };
964
1600
  const invalidDecision = routeDecisionForResolution(base, evidence, candidates, invalidResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
965
1601
  return remember(key, {
966
1602
  ...invalidDecision,
967
- action: 'answer',
968
- requiresClarification: false,
969
- clarificationOptions: undefined,
970
- clarifyingQuestion: undefined,
971
1603
  meaningResolutionErrorCode: 'invalid_evidence_reference',
972
1604
  });
973
1605
  }
@@ -976,8 +1608,9 @@ export function createHybridRouter(options = {}) {
976
1608
  rethrowCancellation(error, request.signal, options.signal);
977
1609
  // A resolver transport/parse failure falls back without losing the
978
1610
  // retrieval signal or permitting a general-knowledge misroute.
1611
+ meaningResolverReachable = false;
979
1612
  }
980
- return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
1613
+ return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
981
1614
  }
982
1615
  }
983
1616
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;
@@ -987,6 +1620,9 @@ export function createHybridRouter(options = {}) {
987
1620
  return { ...base, source: base.source ?? "heuristic" };
988
1621
  }
989
1622
  let catalogContext;
1623
+ if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
1624
+ return softBoundaryDecision(request, base, 'clarify');
1625
+ }
990
1626
  try {
991
1627
  catalogContext = options.getCatalogContext ? await options.getCatalogContext(request) : undefined;
992
1628
  }
@@ -1017,5 +1653,30 @@ export function createHybridRouter(options = {}) {
1017
1653
  },
1018
1654
  };
1019
1655
  }
1656
+ function discoveryRouteBeforeRetrieval(request, base) {
1657
+ if (request.runBudget?.mode === 'research' || request.requestedMode === 'research')
1658
+ return 'research';
1659
+ if (base.action === 'clarify' || base.requiresClarification || request.signals?.missingContext?.length)
1660
+ return 'clarify';
1661
+ if (request.intent === 'exact_certified_lookup'
1662
+ || (request.signals?.certifiedScore ?? 0) >= 0.5
1663
+ || (request.signals?.metricScore ?? 0) >= 0.5)
1664
+ return 'semantic_answer';
1665
+ return 'generated_answer';
1666
+ }
1667
+ function softBoundaryDecision(request, base, route) {
1668
+ const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
1669
+ return {
1670
+ ...base,
1671
+ action: 'clarify',
1672
+ confidence: 1,
1673
+ source: 'heuristic',
1674
+ requiresClarification: true,
1675
+ reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
1676
+ clarifyingQuestion: request.runBudget?.mode === 'research'
1677
+ ? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
1678
+ : 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
1679
+ };
1680
+ }
1020
1681
  export { intentForCategory, parseMeaningResolution };
1021
1682
  //# sourceMappingURL=router.js.map