@duckcodeailabs/dql-agent 1.13.5 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/agent-run-engine.d.ts +79 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +457 -39
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +13 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-store.d.ts.map +1 -1
  9. package/dist/agent-run-store.js +42 -25
  10. package/dist/agent-run-store.js.map +1 -1
  11. package/dist/agentic/analyst-loop.d.ts +91 -0
  12. package/dist/agentic/analyst-loop.d.ts.map +1 -0
  13. package/dist/agentic/analyst-loop.js +311 -0
  14. package/dist/agentic/analyst-loop.js.map +1 -0
  15. package/dist/agentic/assumptions.d.ts +62 -0
  16. package/dist/agentic/assumptions.d.ts.map +1 -0
  17. package/dist/agentic/assumptions.js +73 -0
  18. package/dist/agentic/assumptions.js.map +1 -0
  19. package/dist/agentic/business-explanation.d.ts +56 -0
  20. package/dist/agentic/business-explanation.d.ts.map +1 -0
  21. package/dist/agentic/business-explanation.js +130 -0
  22. package/dist/agentic/business-explanation.js.map +1 -0
  23. package/dist/agentic/identifier-ledger.d.ts +74 -0
  24. package/dist/agentic/identifier-ledger.d.ts.map +1 -0
  25. package/dist/agentic/identifier-ledger.js +151 -0
  26. package/dist/agentic/identifier-ledger.js.map +1 -0
  27. package/dist/agentic/ledger-tools.d.ts +68 -0
  28. package/dist/agentic/ledger-tools.d.ts.map +1 -0
  29. package/dist/agentic/ledger-tools.js +198 -0
  30. package/dist/agentic/ledger-tools.js.map +1 -0
  31. package/dist/agentic/orchestrator-policy.d.ts +60 -0
  32. package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
  33. package/dist/agentic/orchestrator-policy.js +73 -0
  34. package/dist/agentic/orchestrator-policy.js.map +1 -0
  35. package/dist/agentic/orchestrator.d.ts +54 -0
  36. package/dist/agentic/orchestrator.d.ts.map +1 -0
  37. package/dist/agentic/orchestrator.js +67 -0
  38. package/dist/agentic/orchestrator.js.map +1 -0
  39. package/dist/agentic/preview-tool.d.ts +36 -0
  40. package/dist/agentic/preview-tool.d.ts.map +1 -0
  41. package/dist/agentic/preview-tool.js +71 -0
  42. package/dist/agentic/preview-tool.js.map +1 -0
  43. package/dist/agentic/rerank.d.ts +50 -0
  44. package/dist/agentic/rerank.d.ts.map +1 -0
  45. package/dist/agentic/rerank.js +140 -0
  46. package/dist/agentic/rerank.js.map +1 -0
  47. package/dist/agentic/research/hypothesis.d.ts +110 -0
  48. package/dist/agentic/research/hypothesis.d.ts.map +1 -0
  49. package/dist/agentic/research/hypothesis.js +125 -0
  50. package/dist/agentic/research/hypothesis.js.map +1 -0
  51. package/dist/agentic/research/synthesis.d.ts +32 -0
  52. package/dist/agentic/research/synthesis.d.ts.map +1 -0
  53. package/dist/agentic/research/synthesis.js +94 -0
  54. package/dist/agentic/research/synthesis.js.map +1 -0
  55. package/dist/agentic/research-agent.d.ts +54 -0
  56. package/dist/agentic/research-agent.d.ts.map +1 -0
  57. package/dist/agentic/research-agent.js +176 -0
  58. package/dist/agentic/research-agent.js.map +1 -0
  59. package/dist/agentic/sql-authorization.d.ts +131 -0
  60. package/dist/agentic/sql-authorization.d.ts.map +1 -0
  61. package/dist/agentic/sql-authorization.js +232 -0
  62. package/dist/agentic/sql-authorization.js.map +1 -0
  63. package/dist/agentic/tool-loop.d.ts +29 -0
  64. package/dist/agentic/tool-loop.d.ts.map +1 -1
  65. package/dist/agentic/tool-loop.js +129 -52
  66. package/dist/agentic/tool-loop.js.map +1 -1
  67. package/dist/agentic/toolset.d.ts.map +1 -1
  68. package/dist/agentic/toolset.js +155 -0
  69. package/dist/agentic/toolset.js.map +1 -1
  70. package/dist/agentic/turn-plan.d.ts +35 -0
  71. package/dist/agentic/turn-plan.d.ts.map +1 -0
  72. package/dist/agentic/turn-plan.js +108 -0
  73. package/dist/agentic/turn-plan.js.map +1 -0
  74. package/dist/agentic/value-probe.d.ts +89 -0
  75. package/dist/agentic/value-probe.d.ts.map +1 -0
  76. package/dist/agentic/value-probe.js +304 -0
  77. package/dist/agentic/value-probe.js.map +1 -0
  78. package/dist/analytical-orchestration.d.ts +336 -0
  79. package/dist/analytical-orchestration.d.ts.map +1 -0
  80. package/dist/analytical-orchestration.js +631 -0
  81. package/dist/analytical-orchestration.js.map +1 -0
  82. package/dist/analytical-request-policy.d.ts +26 -0
  83. package/dist/analytical-request-policy.d.ts.map +1 -0
  84. package/dist/analytical-request-policy.js +82 -0
  85. package/dist/analytical-request-policy.js.map +1 -0
  86. package/dist/analytical-result-facts.d.ts +14 -1
  87. package/dist/analytical-result-facts.d.ts.map +1 -1
  88. package/dist/analytical-result-facts.js +76 -1
  89. package/dist/analytical-result-facts.js.map +1 -1
  90. package/dist/answer-loop.d.ts +61 -16
  91. package/dist/answer-loop.d.ts.map +1 -1
  92. package/dist/answer-loop.js +256 -36
  93. package/dist/answer-loop.js.map +1 -1
  94. package/dist/cascade/budgets.d.ts.map +1 -1
  95. package/dist/cascade/budgets.js +8 -1
  96. package/dist/cascade/budgets.js.map +1 -1
  97. package/dist/cascade/packer.d.ts +88 -0
  98. package/dist/cascade/packer.d.ts.map +1 -0
  99. package/dist/cascade/packer.js +106 -0
  100. package/dist/cascade/packer.js.map +1 -0
  101. package/dist/conversation/result-ops.d.ts +18 -3
  102. package/dist/conversation/result-ops.d.ts.map +1 -1
  103. package/dist/conversation/result-ops.js +77 -5
  104. package/dist/conversation/result-ops.js.map +1 -1
  105. package/dist/conversation/rolling-summary.d.ts.map +1 -1
  106. package/dist/conversation/rolling-summary.js +7 -2
  107. package/dist/conversation/rolling-summary.js.map +1 -1
  108. package/dist/conversation/session-store.d.ts +3 -0
  109. package/dist/conversation/session-store.d.ts.map +1 -1
  110. package/dist/conversation/session-store.js +8 -4
  111. package/dist/conversation/session-store.js.map +1 -1
  112. package/dist/conversation/snapshot.d.ts.map +1 -1
  113. package/dist/conversation/snapshot.js +13 -1
  114. package/dist/conversation/snapshot.js.map +1 -1
  115. package/dist/conversation/turn-trust.d.ts.map +1 -1
  116. package/dist/conversation/turn-trust.js +6 -0
  117. package/dist/conversation/turn-trust.js.map +1 -1
  118. package/dist/hints/store.d.ts.map +1 -1
  119. package/dist/hints/store.js +15 -5
  120. package/dist/hints/store.js.map +1 -1
  121. package/dist/index.d.ts +27 -5
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +28 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +8 -0
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +65 -0
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/meaning-resolution.d.ts +7 -0
  130. package/dist/meaning-resolution.d.ts.map +1 -1
  131. package/dist/meaning-resolution.js.map +1 -1
  132. package/dist/metadata/block-fit.d.ts +18 -0
  133. package/dist/metadata/block-fit.d.ts.map +1 -1
  134. package/dist/metadata/block-fit.js +41 -1
  135. package/dist/metadata/block-fit.js.map +1 -1
  136. package/dist/metadata/catalog.d.ts +44 -23
  137. package/dist/metadata/catalog.d.ts.map +1 -1
  138. package/dist/metadata/catalog.js +363 -53
  139. package/dist/metadata/catalog.js.map +1 -1
  140. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  141. package/dist/metadata/meaning-evidence.js +101 -8
  142. package/dist/metadata/meaning-evidence.js.map +1 -1
  143. package/dist/metadata/metric-match.d.ts +32 -0
  144. package/dist/metadata/metric-match.d.ts.map +1 -1
  145. package/dist/metadata/metric-match.js +120 -9
  146. package/dist/metadata/metric-match.js.map +1 -1
  147. package/dist/research-loop.d.ts +33 -2
  148. package/dist/research-loop.d.ts.map +1 -1
  149. package/dist/research-loop.js +44 -4
  150. package/dist/research-loop.js.map +1 -1
  151. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  152. package/dist/resolved-analytical-plan.js +92 -5
  153. package/dist/resolved-analytical-plan.js.map +1 -1
  154. package/dist/router.d.ts +36 -16
  155. package/dist/router.d.ts.map +1 -1
  156. package/dist/router.js +604 -25
  157. package/dist/router.js.map +1 -1
  158. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assumeDominantCandidate } from './agentic/assumptions.js';
1
2
  /**
2
3
  * Hybrid router — deterministic-first, LLM-assisted for the ambiguous middle.
3
4
  *
@@ -578,16 +579,26 @@ function routedClarificationQuestion(resolution, bindings, options) {
578
579
  ?? `Which governed binding should I use before running this query?`;
579
580
  }
580
581
  function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
582
+ const selectedId = selected.qualifiedId ?? selected.id;
583
+ const message = `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL did not substitute a correlated metric or execute a different artifact.`;
581
584
  return {
582
585
  ...base,
583
- action: 'answer',
584
- confidence: 0.78,
585
- reason: `The user selected ${selected.name}, but that evidence does not prove the complete requested metric, dimension, filter, and grain tuple. The selection is consumed once; continue through the governed semantic/SQL cascade without dropping any requested part.`,
586
+ action: 'block',
587
+ confidence: 1,
588
+ reason: message,
586
589
  source: 'heuristic',
587
590
  category: 'data_lookup',
588
591
  depth: 'quick',
589
592
  retrievalEvidence: retrievalTrace(evidence, candidates),
590
593
  requiresClarification: false,
594
+ terminalOutcome: {
595
+ kind: 'modeling_gap',
596
+ code: 'ANALYTICAL_MODELING_GAP',
597
+ message,
598
+ candidateIds: [selectedId],
599
+ },
600
+ resolvedAnalyticalPlan: undefined,
601
+ meaningResolution: undefined,
591
602
  };
592
603
  }
593
604
  function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
@@ -793,6 +804,114 @@ function buildEvidenceClarification(candidates, missing = []) {
793
804
  return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
794
805
  return "Which governed business meaning should I use for this question?";
795
806
  }
807
+ /**
808
+ * A distinct-entity count is not a useful ranking measure at the same entity
809
+ * grain: every customer normally has a count of one. Keep the candidate in the
810
+ * evidence trace, but do not let lexical relevance freeze it as the answer to
811
+ * "top customers". This is deliberately a semantic suitability check, not a
812
+ * name-based ban; an explicit "top customers by customer count" request remains
813
+ * the user's choice and can proceed through normal compatibility checks.
814
+ */
815
+ function isDegenerateRankingMetric(question, evidence, candidate) {
816
+ if (questionTypeFromText(question) !== 'ranking')
817
+ return false;
818
+ if (candidate.kind !== 'semantic_metric' && candidate.kind !== 'semantic_member')
819
+ return false;
820
+ const capability = normalizeEvidenceAnalyticalCapability(candidate).capability;
821
+ const aggregation = normalizeMetricPhrase(candidate.aggregation ?? capability?.aggregation ?? '');
822
+ if (!aggregation || !/^(count|count distinct|count unique|count distinct values)$/.test(aggregation))
823
+ return false;
824
+ const questionTerms = new Set(substantiveLexicalTokens(question));
825
+ const entityTerms = [
826
+ candidate.primaryEntity ?? '',
827
+ ...(candidate.analyticalCapability?.resultGrainIds ?? []),
828
+ ...(candidate.dimensions ?? []),
829
+ ...(evidence.parsedIntent?.dimensions ?? []),
830
+ ].flatMap((value) => substantiveLexicalTokens(value));
831
+ const metricTerms = [candidate.name, candidate.qualifiedId ?? '', ...(candidate.aliases ?? [])]
832
+ .flatMap((value) => substantiveLexicalTokens(value));
833
+ return entityTerms.some((term) => questionTerms.has(term))
834
+ && metricTerms.some((term) => entityTerms.includes(term));
835
+ }
836
+ function hasExplicitRankingMeasure(question, evidence) {
837
+ const parsed = [
838
+ ...(evidence.parsedIntent?.measures ?? []),
839
+ ...extractRankingMeasurePhrases(question),
840
+ ].map(normalizeMetricPhrase).filter(Boolean);
841
+ return parsed.length > 0;
842
+ }
843
+ function extractRankingMeasurePhrases(question) {
844
+ const matches = [];
845
+ for (const pattern of [
846
+ /\b(?:by|based on|using|with|for)\s+(?:the\s+)?([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
847
+ /\b(?:highest|lowest|most|least)\s+([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
848
+ ]) {
849
+ for (const match of question.matchAll(pattern))
850
+ if (match[1])
851
+ matches.push(match[1]);
852
+ }
853
+ return matches;
854
+ }
855
+ function rankingMetricChoiceDecision(base, evidence, candidates, selected, question) {
856
+ const options = candidates
857
+ .filter((candidate) => candidate.id !== selected.id
858
+ && candidate.compatibility !== 'incompatible'
859
+ && candidate.kind === 'semantic_metric'
860
+ && !isDegenerateRankingMetric(question, evidence, candidate))
861
+ .slice(0, 3);
862
+ if (options.length === 0) {
863
+ return unanswerableClarificationFallback(base, retrievalTrace(evidence, candidates), `${selected.name} counts customers and cannot rank them, and no alternative governed measure was retrieved, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.`);
864
+ }
865
+ const labels = options.map((candidate) => renderCandidateChoice(candidate)).join(' or ');
866
+ return {
867
+ ...base,
868
+ action: 'clarify',
869
+ confidence: 1,
870
+ source: 'heuristic',
871
+ category: 'unclear',
872
+ depth: 'quick',
873
+ followsUp: true,
874
+ requiresClarification: true,
875
+ reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
876
+ clarifyingQuestion: `That metric counts unique customers and cannot rank individual customers. Which measure should rank them: ${labels}?`,
877
+ clarificationOptions: buildClarificationOptions(options),
878
+ retrievalEvidence: retrievalTrace(evidence, candidates),
879
+ resolvedAnalyticalPlan: undefined,
880
+ meaningResolution: undefined,
881
+ };
882
+ }
883
+ function preventDegenerateRankingResolution(resolution, evidence, candidates, question) {
884
+ if (hasExplicitRankingMeasure(question, evidence))
885
+ return resolution;
886
+ const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
887
+ || resolution.selectedConceptIds.includes(candidate.id));
888
+ if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
889
+ return resolution;
890
+ const alternatives = candidates
891
+ .filter((candidate) => candidate.id !== selected.id
892
+ && candidate.kind === 'semantic_metric'
893
+ && candidate.compatibility !== 'incompatible'
894
+ && !isDegenerateRankingMetric(question, evidence, candidate))
895
+ .slice(0, 3);
896
+ const alternativeLabels = alternatives.map(renderCandidateChoice).join(' or ');
897
+ return {
898
+ ...resolution,
899
+ confidence: 'low',
900
+ recommendedRoute: 'clarify',
901
+ recommendedExecutionId: undefined,
902
+ selectedConceptIds: [],
903
+ analyticalFrame: undefined,
904
+ missingInformation: [
905
+ ...new Set([
906
+ ...resolution.missingInformation,
907
+ `${selected.name} counts the ranked entity and is not a suitable ranking measure`,
908
+ ]),
909
+ ],
910
+ clarifyingQuestion: alternativeLabels
911
+ ? `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use: ${alternativeLabels}?`
912
+ : `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use for the ranking?`,
913
+ };
914
+ }
796
915
  function directResolution(request, evidence, candidate, candidates) {
797
916
  const inferredQuestionType = questionTypeFromText(request.question);
798
917
  const questionType = inferredQuestionType === 'definition'
@@ -930,12 +1049,34 @@ mayAssumeInterpretation = true) {
930
1049
  if (multiMetricPrimary) {
931
1050
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
932
1051
  }
933
- const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
1052
+ const rankingCandidates = hasExplicitRankingMeasure(request.question, evidence)
1053
+ ? candidates
1054
+ : candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
1055
+ if (questionTypeFromText(request.question) === 'ranking'
1056
+ && !hasExplicitRankingMeasure(request.question, evidence)) {
1057
+ // Assume the measure where one is clearly indicated, and BIND it through the
1058
+ // same resolution path an explicit selection takes — an assumption that
1059
+ // cannot freeze a plan is refused downstream and surfaces as `blocked` with
1060
+ // no options, which is worse than the question it replaced.
1061
+ const assumed = mayAssumeInterpretation
1062
+ ? assumableRankingMeasure(request.question, rankingCandidates)
1063
+ : undefined;
1064
+ if (assumed) {
1065
+ return {
1066
+ ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumed.candidate, candidates), 'heuristic', request.question, planMode),
1067
+ assumptions: [assumed.assumption],
1068
+ };
1069
+ }
1070
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
1071
+ }
1072
+ const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
1073
+ && candidate.compatibility !== "incompatible"
1074
+ && rankingCandidates.includes(candidate));
934
1075
  if (exactCompatible.length === 1 &&
935
1076
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
936
1077
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
937
1078
  }
938
- const semanticMetric = uniqueExecutableSemanticMetric(evidence, candidates);
1079
+ const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
939
1080
  if (semanticMetric) {
940
1081
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
941
1082
  }
@@ -944,7 +1085,7 @@ mayAssumeInterpretation = true) {
944
1085
  // `type: simple` metric, the measure it wraps, and the model that holds them
945
1086
  // were offered as three competing "meanings" of the same number.
946
1087
  const best = mayAssumeInterpretation
947
- ? bestGovernedInterpretation(request.question, candidates)
1088
+ ? bestGovernedInterpretation(request.question, rankingCandidates)
948
1089
  : undefined;
949
1090
  if (best) {
950
1091
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
@@ -981,11 +1122,67 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
981
1122
  const byScore = [...new Map(candidates
982
1123
  .filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
983
1124
  .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);
1125
+ // An entity is a JOIN KEY. It is a reading of "how many customers", but never
1126
+ // of "what customer type is <member>" — there the reader named a field and a
1127
+ // member, and the entity cannot answer either. The trigger used to be a
1128
+ // four-word list (name/label/title/description), so "customer type", "region",
1129
+ // and "when did X first order" all left the entity competing and turned an
1130
+ // ordinary attribute lookup into a bind-interrogation. Recognise the
1131
+ // interrogative FORM as well as the vocabulary; `hasDimension` below still
1132
+ // requires that a real attribute was actually retrieved.
1133
+ const wantsAttribute = /\b(names?|labels?|titles?|descriptions?|types?|status(?:es)?|categor(?:y|ies)|segments?|tiers?|regions?|emails?|addresses?)\b/i.test(question)
1134
+ || /\b(what|which|when|where)\b[^?]*\b(is|are|was|were|does|do|did|belongs?)\b/i.test(question);
985
1135
  const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
986
- const kindFiltered = wantsAttribute && hasDimension
987
- ? byScore.filter((candidate) => candidate.semanticObjectType !== 'entity')
1136
+ // An entity arrives as a semantic-layer entity OR as a DQL modeling entity
1137
+ // (`dql:entity:…`, kind `dql_modeling`). Testing only `semanticObjectType`
1138
+ // left the DQL one competing, so the interrogation survived the fix above.
1139
+ // Check BOTH identities: a DQL entity's `qualifiedId` is the bare
1140
+ // `commerce::entity::customer`, so testing the qualified id alone still let
1141
+ // it through.
1142
+ const isEntityCandidate = (candidate) => candidate.semanticObjectType === 'entity'
1143
+ || [candidate.id, candidate.qualifiedId ?? ''].some((identity) => /(^|:)entity(:|::)/i.test(identity));
1144
+ const entityFiltered = wantsAttribute && hasDimension
1145
+ ? byScore.filter((candidate) => !isEntityCandidate(candidate))
988
1146
  : byScore;
1147
+ // Within an attribute question, a candidate matching only a SUB-TOKEN of the
1148
+ // requested field is a lexical decoy, not a competing reading. "What customer
1149
+ // type is <member>?" dragged in `raw_products.type` and
1150
+ // `orders.new_customer_orders` purely because they contain "type" and
1151
+ // "customer", and two decoys are enough to trip the ambiguity gate and turn
1152
+ // the lookup into an interrogation. Score how much of the question each
1153
+ // candidate actually accounts for — including the fields a block declares,
1154
+ // which is how the block that OUTPUTS `customer_type` outranks a column
1155
+ // merely named `type` — and keep only the most specific matches.
1156
+ const normalizedQuestion = normalizeMetricPhrase(question);
1157
+ const phraseSpecificity = (candidate) => {
1158
+ const terms = [
1159
+ candidate.name,
1160
+ ...(candidate.aliases ?? []),
1161
+ ...(candidate.dimensions ?? []),
1162
+ ...(candidate.compatibilityFacts ?? [])
1163
+ .filter((fact) => fact.startsWith('output: '))
1164
+ .map((fact) => fact.slice('output: '.length)),
1165
+ ].map((term) => normalizeMetricPhrase(String(term ?? '').split(/[.:/]/).at(-1) ?? ''));
1166
+ let best = 0;
1167
+ for (const term of terms) {
1168
+ if (!term)
1169
+ continue;
1170
+ const matches = normalizedQuestion === term
1171
+ || normalizedQuestion.startsWith(`${term} `)
1172
+ || normalizedQuestion.endsWith(` ${term}`)
1173
+ || normalizedQuestion.includes(` ${term} `);
1174
+ if (matches)
1175
+ best = Math.max(best, term.split(' ').length);
1176
+ }
1177
+ return best;
1178
+ };
1179
+ const specificity = new Map(entityFiltered.map((candidate) => [candidate.id, phraseSpecificity(candidate)]));
1180
+ const bestSpecificity = Math.max(0, ...specificity.values());
1181
+ // Only prune when something matched a MULTI-word field name. A single shared
1182
+ // token is not enough evidence to call the others decoys.
1183
+ const kindFiltered = wantsAttribute && hasDimension && bestSpecificity >= 2
1184
+ ? entityFiltered.filter((candidate) => (specificity.get(candidate.id) ?? 0) === bestSpecificity)
1185
+ : entityFiltered;
989
1186
  const representatives = new Map();
990
1187
  const passthrough = [];
991
1188
  for (const candidate of kindFiltered) {
@@ -1000,7 +1197,44 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
1000
1197
  representatives.set(key, candidate);
1001
1198
  }
1002
1199
  }
1003
- return [...passthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
1200
+ // A certified block that already OUTPUTS an attribute is not a competing
1201
+ // MEANING of that attribute — it is the same reading at higher authority.
1202
+ // Keeping both turned an ordinary attribute lookup ("what customer type is
1203
+ // <member>?") into a "Which governed meaning should DQL bind: customer_profile
1204
+ // or customers.customer_type?" interrogation, even though the block declares
1205
+ // that exact output, sits at the requested grain, and permits the member
1206
+ // filter. The cascade already says certified outranks semantic for one
1207
+ // reading; this stops the tie from being mistaken for ambiguity.
1208
+ const certifiedCoverage = passthrough
1209
+ .filter((candidate) => candidate.kind === 'certified_block')
1210
+ .map((candidate) => new Set([
1211
+ ...(candidate.dimensions ?? []),
1212
+ ...(candidate.compatibilityFacts ?? [])
1213
+ .filter((fact) => fact.startsWith('output: '))
1214
+ .map((fact) => fact.slice('output: '.length)),
1215
+ ].map((value) => normalizeMetricPhrase(String(value).split(/[.:/]/).at(-1) ?? ''))
1216
+ .filter(Boolean)));
1217
+ const survivingPassthrough = certifiedCoverage.length === 0
1218
+ ? passthrough
1219
+ : passthrough.filter((candidate) => {
1220
+ // The same field can arrive three ways — the block's declared output, the
1221
+ // semantic dimension, and the raw warehouse/dbt column. Only the first is
1222
+ // a governed meaning; the other two are lower-trust representations of it.
1223
+ const supersedable = candidate.semanticObjectType === 'dimension'
1224
+ || candidate.kind === 'sql_column';
1225
+ if (!supersedable)
1226
+ return true;
1227
+ // A column's qualified identity points at its PARENT RELATION, so
1228
+ // `candidateLeafName` yields "customers" for `customers.customer_type`.
1229
+ // Match the candidate's own name as well, or a raw column is never
1230
+ // recognised as the field a block already publishes.
1231
+ const leaves = [
1232
+ normalizeMetricPhrase(String(candidate.name ?? '').split(/[.:/]/).at(-1) ?? ''),
1233
+ normalizeMetricPhrase(candidateLeafName(candidate)),
1234
+ ].filter(Boolean);
1235
+ return !leaves.some((leaf) => certifiedCoverage.some((outputs) => outputs.has(leaf)));
1236
+ });
1237
+ return [...survivingPassthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
1004
1238
  }
1005
1239
  /**
1006
1240
  * The governed meaning to run when nothing proved a single exact reading.
@@ -1112,9 +1346,60 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1112
1346
  .sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
1113
1347
  .slice(0, 3);
1114
1348
  if (alternatives.length === 0) {
1115
- if (!asksForRanking || hasExplicitRankingMetric)
1349
+ // Bare rankings need a measure choice, not a dimension gap. Retain the
1350
+ // no-options escape hatch for BCM-like retrieval failures.
1351
+ if (asksForRanking && !hasExplicitRankingMetric) {
1352
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1353
+ }
1354
+ // Parsed-intent hints can include inherited/default dimensions that the
1355
+ // user never asked for. Only turn a missing field into a product-facing
1356
+ // modeling gap when its wording is present in this turn; otherwise let
1357
+ // bounded meaning resolution preserve its own ambiguity contract.
1358
+ const normalizedQuestion = normalizeMetricPhrase(request.question);
1359
+ if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
1116
1360
  return undefined;
1117
- return bareRankingClarification(base, retrievalEvidence);
1361
+ const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1362
+ const message = `The requested dimension ${requestedLabel} is not modeled in the certified blocks, semantic model, dbt manifest, or runtime schema searched for this question. Add or map ${requestedLabel} before retrying.`;
1363
+ return {
1364
+ ...base,
1365
+ action: 'block',
1366
+ confidence: 1,
1367
+ reason: message,
1368
+ source: 'heuristic',
1369
+ category: 'data_lookup',
1370
+ depth: 'quick',
1371
+ requiresClarification: false,
1372
+ retrievalEvidence,
1373
+ terminalOutcome: {
1374
+ kind: 'modeling_gap',
1375
+ code: 'ANALYTICAL_MODELING_GAP',
1376
+ message,
1377
+ candidateIds: [],
1378
+ },
1379
+ meaningResolution: {
1380
+ interpretedQuestion: request.question,
1381
+ questionType: questionTypeFromText(request.question),
1382
+ selectedConceptIds: [],
1383
+ queryIntent: {
1384
+ ...defaultQueryIntent(evidence),
1385
+ measures: evidence.parsedIntent?.measures ?? [],
1386
+ dimensions: evidence.parsedIntent?.dimensions ?? [],
1387
+ filters: evidence.parsedIntent?.filters ?? [],
1388
+ },
1389
+ rejectedCandidates: [],
1390
+ confidence: 'low',
1391
+ missingInformation: [message],
1392
+ recommendedRoute: 'clarify',
1393
+ compatibilityOutcome: 'modeling_gap',
1394
+ compatibilityFailures: missingDimensions.map((dimension) => ({
1395
+ code: 'MISSING_DIMENSION',
1396
+ field: dimension,
1397
+ message: `${dimension} is not modeled.`,
1398
+ candidateIds: [],
1399
+ })),
1400
+ },
1401
+ resolvedAnalyticalPlan: undefined,
1402
+ };
1118
1403
  }
1119
1404
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1120
1405
  const alternativeLabels = alternatives.map(renderCandidateChoice);
@@ -1137,11 +1422,187 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1137
1422
  };
1138
1423
  }
1139
1424
  if (asksForRanking && !hasExplicitRankingMetric) {
1140
- return bareRankingClarification(base, retrievalEvidence);
1425
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1141
1426
  }
1142
1427
  return undefined;
1143
1428
  }
1144
- function bareRankingClarification(base, retrievalEvidence) {
1429
+ /**
1430
+ * A clarification with NO selectable options is unanswerable, and asking it is a
1431
+ * dead end rather than a safety measure.
1432
+ *
1433
+ * Reported from production: "who are the top customers for BCM" returned
1434
+ * "Top by which governed metric?" with zero choices. Answering it in prose
1435
+ * ("...who have top revenue") produced the IDENTICAL question again, because the
1436
+ * reply carries no `selectedEvidenceId` and re-enters the same path with the
1437
+ * same evidence. A question that can only be answered by clicking a button that
1438
+ * was never rendered loops forever.
1439
+ *
1440
+ * When the option list is empty the failure is in RETRIEVAL, not in the user's
1441
+ * phrasing, so continue into the review-required generated lane and let the
1442
+ * answer carry the caveat. `requiresClarification` is cleared deliberately:
1443
+ * leaving it set would make `answerAnywayRoute` treat this as material ambiguity
1444
+ * and re-block the turn.
1445
+ */
1446
+ function unanswerableClarificationFallback(base, retrievalEvidence, reason) {
1447
+ return {
1448
+ ...base,
1449
+ action: 'answer',
1450
+ confidence: Math.min(base.confidence, 0.5),
1451
+ source: 'heuristic',
1452
+ category: 'data_lookup',
1453
+ requiresClarification: false,
1454
+ clarifyingQuestion: undefined,
1455
+ clarificationOptions: undefined,
1456
+ reason,
1457
+ ...(retrievalEvidence ? { retrievalEvidence } : {}),
1458
+ resolvedAnalyticalPlan: undefined,
1459
+ meaningResolution: undefined,
1460
+ };
1461
+ }
1462
+ /**
1463
+ * "Top by which governed metric?" with NO choices is a dead end: the asker
1464
+ * cannot know which measures are both governed and valid at the ranked grain,
1465
+ * so the only move left is to guess. A built-CLI run on the commerce fixture
1466
+ * ended here with zero options while `revenue`, `lifetime_spend_pretax`, and
1467
+ * `orders` were all modeled.
1468
+ *
1469
+ * The question stays exactly as it was — this only attaches the compatible
1470
+ * ranking measures as selectable choices, minus any same-grain entity count,
1471
+ * which is degenerate for ranking individuals. Selecting one returns an
1472
+ * explicit qualified id, which takes the resolved-selection path instead of
1473
+ * asking again.
1474
+ *
1475
+ * Acceptance: AGT-030.
1476
+ */
1477
+ /**
1478
+ * Generic measure vocabulary: words that say HOW MUCH, never WHICH SUBSET.
1479
+ * A candidate built only from these adds no scope the asker did not state.
1480
+ */
1481
+ const GENERIC_MEASURE_TOKENS = new Set([
1482
+ 'revenue', 'spend', 'spending', 'sale', 'amount', 'value', 'total', 'sum',
1483
+ 'count', 'number', 'order', 'quantity', 'qty', 'price', 'cost', 'profit',
1484
+ 'margin', 'gross', 'net', 'lifetime', 'avg', 'average', 'mean', 'median', 'score',
1485
+ 'rate', 'ratio', 'percent', 'share', 'volume', 'unit', 'balance', 'pretax', 'ltv',
1486
+ 'top', 'rank', 'ranking', 'by', 'per', 'the', 'of', 'and',
1487
+ ]);
1488
+ /**
1489
+ * Crude, symmetric singularization. Applied to BOTH sides, so the only thing
1490
+ * that matters is that it agrees with itself — `address` becoming `addres` is
1491
+ * harmless when the question's `address` becomes `addres` too. Without it a
1492
+ * candidate named `customers.customer_value` is rejected against the question
1493
+ * "who are the top customers", because the singular `customer` is neither a
1494
+ * question word nor generic measure vocabulary. That is a morphology accident,
1495
+ * not an unrequested scope, and it silently refused good assumptions.
1496
+ */
1497
+ function singularize(token) {
1498
+ if (token.length > 3 && token.endsWith('ies'))
1499
+ return `${token.slice(0, -3)}y`;
1500
+ if (token.length > 3 && (token.endsWith('ses') || token.endsWith('xes') || token.endsWith('zes'))) {
1501
+ return token.slice(0, -2);
1502
+ }
1503
+ if (token.length > 2 && token.endsWith('s') && !token.endsWith('ss'))
1504
+ return token.slice(0, -1);
1505
+ return token;
1506
+ }
1507
+ /**
1508
+ * Pick the measure a bare ranking should assume, or nothing.
1509
+ *
1510
+ * Returns the CANDIDATE, not a decision, because the assumption is only useful
1511
+ * where it can be bound: an `action: 'answer'` with no frozen analytical plan is
1512
+ * refused by the plan boundary and reaches the user as `blocked` with no
1513
+ * options, which is strictly worse than the clarification it replaced. Measured
1514
+ * exactly that way before this was moved to a caller that can bind it.
1515
+ */
1516
+ export function assumableRankingMeasure(question, candidates) {
1517
+ // Only a MEASURE can be assumed. A certified block is a whole authored query,
1518
+ // not a ranking measure, so treating one as the answer to "by which metric?"
1519
+ // silently selects someone else's entire analysis.
1520
+ const assumable = candidates.filter((candidate) => (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
1521
+ && candidate.compatibility !== 'incompatible'
1522
+ && rankingCandidateFitsBareQuestion(question, candidate));
1523
+ if (assumable.length === 0)
1524
+ return undefined;
1525
+ const assumption = assumeDominantCandidate({
1526
+ about: 'metric',
1527
+ candidates: assumable.map((candidate) => ({
1528
+ id: candidate.qualifiedId ?? candidate.id,
1529
+ label: candidate.name,
1530
+ score: candidate.relevanceScore,
1531
+ })),
1532
+ because: (chosen) => `"${chosen.label ?? chosen.id}" was the highest-ranked governed measure that can rank this entity, and it adds no filter the question did not ask for.`,
1533
+ });
1534
+ if (!assumption)
1535
+ return undefined;
1536
+ const chosen = assumable.find((candidate) => (candidate.qualifiedId ?? candidate.id) === assumption.chose);
1537
+ return chosen ? { candidate: chosen, assumption } : undefined;
1538
+ }
1539
+ /**
1540
+ * Does this ranking measure fit a question that named no measure?
1541
+ *
1542
+ * "Top customers" must not be silently answered by `top_beverage_customers`:
1543
+ * that candidate carries a scope — beverages — the asker never asked for, and
1544
+ * answering with it returns a confidently wrong list under a different question
1545
+ * than the one posed. `assumeDominantCandidate` deliberately leaves this check
1546
+ * to the caller, because dominance is about ranking and this is about meaning.
1547
+ *
1548
+ * The rule: every substantive token in the candidate's name must be either
1549
+ * something the question already said, or generic measure vocabulary. Any
1550
+ * leftover token is an unrequested qualifier, and the turn keeps asking.
1551
+ */
1552
+ export function rankingCandidateFitsBareQuestion(question, candidate) {
1553
+ const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
1554
+ // Identities arrive source-qualified (`semantic:metric:orders.revenue`). The
1555
+ // `semantic`/`metric` prefix is plumbing, not vocabulary — tokenizing it would
1556
+ // make every candidate look scoped and refuse every assumption.
1557
+ const identity = (candidate.qualifiedId ?? candidate.id ?? '').split(':').at(-1) ?? '';
1558
+ const nameTokens = substantiveLexicalTokens([candidate.name, identity].join(' '));
1559
+ if (nameTokens.length === 0)
1560
+ return false;
1561
+ return nameTokens.map(singularize).every((token) => questionTokens.has(token) || GENERIC_MEASURE_TOKENS.has(token));
1562
+ }
1563
+ function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates,
1564
+ /**
1565
+ * May this turn settle the measure by assumption rather than by asking?
1566
+ * False wherever the step whose job is to judge ambiguity has positively
1567
+ * reported some — assuming past a finding overrides it rather than filling a
1568
+ * gap, and AGT-017 already establishes that lexical rank alone must not
1569
+ * settle meaning when semantic judgment is unavailable.
1570
+ */
1571
+ mayAssume = true) {
1572
+ const rankingChoices = (candidates ?? []).filter((candidate) => {
1573
+ if (candidate.compatibility === 'incompatible')
1574
+ return false;
1575
+ if (candidate.kind !== 'certified_block'
1576
+ && candidate.kind !== 'semantic_metric'
1577
+ && candidate.kind !== 'semantic_member')
1578
+ return false;
1579
+ // Check BOTH identities: `qualifiedId` is often the bare semantic-layer
1580
+ // name, so testing it alone let `semantic:model:customers` through.
1581
+ const identities = [candidate.id, candidate.qualifiedId ?? ''].filter(Boolean);
1582
+ // A model, entity, dimension, dbt node, or warehouse table cannot BE the
1583
+ // measure a ranking is ordered by; offering one as a "governed metric" is
1584
+ // how `semantic:model:customers` reached the choice list.
1585
+ if (identities.some((identity) => /^(semantic:(model|entity|dimension|time_dimension):|dbt:|warehouse:)/i.test(identity)))
1586
+ return false;
1587
+ // `semantic:measure:X.X` is a count of X reported at X's own grain — every
1588
+ // row scores 1, so it can never order X. The metadata-driven guard below
1589
+ // needs a declared aggregation, which retrieval does not always carry, so
1590
+ // this identity check catches the case that metadata misses.
1591
+ const degenerateIdentity = identities.some((identity) => {
1592
+ const measurePath = /^semantic:(?:measure|metric):(.+)$/i.exec(identity)?.[1] ?? '';
1593
+ const [owner, measureName] = measurePath.split('.');
1594
+ return Boolean(owner && measureName
1595
+ && normalizeMetricPhrase(owner) === normalizeMetricPhrase(measureName));
1596
+ });
1597
+ if (degenerateIdentity)
1598
+ return false;
1599
+ if (question && evidence && isDegenerateRankingMetric(question, evidence, candidate))
1600
+ return false;
1601
+ return true;
1602
+ });
1603
+ if (rankingChoices.length === 0) {
1604
+ return unanswerableClarificationFallback(base, retrievalEvidence, 'No retrieved governed measure can rank this entity, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.');
1605
+ }
1145
1606
  return {
1146
1607
  ...base,
1147
1608
  action: 'clarify',
@@ -1153,6 +1614,7 @@ function bareRankingClarification(base, retrievalEvidence) {
1153
1614
  requiresClarification: true,
1154
1615
  clarifyingQuestion: 'Top by which governed metric?',
1155
1616
  retrievalEvidence,
1617
+ clarificationOptions: buildClarificationOptions(rankingChoices),
1156
1618
  resolvedAnalyticalPlan: undefined,
1157
1619
  meaningResolution: undefined,
1158
1620
  };
@@ -1432,6 +1894,7 @@ export function createHybridRouter(options = {}) {
1432
1894
  const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
1433
1895
  const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
1434
1896
  const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
1897
+ const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
1435
1898
  const cache = new Map();
1436
1899
  let tick = 0;
1437
1900
  const now = options.now ?? (() => { tick += 1; return tick; });
@@ -1492,8 +1955,14 @@ export function createHybridRouter(options = {}) {
1492
1955
  // A structured clarification selection is authoritative identity input,
1493
1956
  // not a new fuzzy-search phrase. Keep it in the bounded package even if
1494
1957
  // per-tier limits would otherwise trim it from a large catalog.
1958
+ // Look in BOTH lists. The ranking-measure choices are supplemental
1959
+ // clarification candidates, not execution candidates, so resolving the
1960
+ // selection against `candidates` alone silently found nothing — the
1961
+ // click looked identical to no click, the ranking gate fired again, and
1962
+ // the same three options came back forever.
1495
1963
  const selectedEvidence = request.selectedEvidenceId
1496
- ? evidence.candidates.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
1964
+ ? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
1965
+ .find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
1497
1966
  : undefined;
1498
1967
  if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
1499
1968
  candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
@@ -1507,33 +1976,69 @@ export function createHybridRouter(options = {}) {
1507
1976
  ...evidence.candidates,
1508
1977
  ...(evidence.clarificationCandidates ?? []),
1509
1978
  ].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;
1513
1979
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
1514
- if (explicit && explicit.compatibility !== "incompatible") {
1980
+ const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
1981
+ || /@(metric|block|model|table|column)\(/i.test(request.question)));
1982
+ const shouldUseMeaningCall = requireMeaningCall
1983
+ && !explicitMeaningBinding
1984
+ && Boolean(options.resolveMeaning || options.complete);
1985
+ // A normal natural-language turn must be interpreted against the
1986
+ // candidate cards before a deterministic clarification is allowed.
1987
+ // Running this gate first was the source of the "Top by which
1988
+ // governed metric?" repeat loop: it treated a customer-count
1989
+ // execution shim as the answer and never let the meaning model see
1990
+ // the ranking entity/measure distinction.
1991
+ if (!shouldUseMeaningCall && !explicitMeaningBinding) {
1992
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
1993
+ if (deterministicClarification)
1994
+ return deterministicClarification;
1995
+ }
1996
+ if (explicit
1997
+ && explicit.compatibility !== "incompatible"
1998
+ && (!shouldUseMeaningCall || explicitMeaningBinding)) {
1999
+ if (isDegenerateRankingMetric(request.question, evidence, explicit)
2000
+ && !hasExplicitRankingMeasure(request.question, evidence)) {
2001
+ return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
2002
+ }
1515
2003
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1516
2004
  return selectedEvidence && decision.requiresClarification
1517
2005
  ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
1518
2006
  : decision;
1519
2007
  }
1520
- const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
2008
+ const multiMetricPrimary = !shouldUseMeaningCall
2009
+ ? exactMultiMetricPrimary(request.question, evidence, candidates)
2010
+ : undefined;
1521
2011
  if (multiMetricPrimary) {
1522
2012
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1523
2013
  }
2014
+ // THE FAST LANE. Evaluated regardless of `shouldUseMeaningCall`, so an
2015
+ // exact certified hit short-circuits BEFORE the ~10s meaning call
2016
+ // rather than paying for it. Previously this shortcut only applied
2017
+ // when the call was already being skipped, which meant a perfect
2018
+ // certified match — the cheapest, most certain answer DQL can give —
2019
+ // was also one of the slowest.
2020
+ //
2021
+ // The precondition is deliberately the strictest one available:
2022
+ // exactly one certified block, compatible, whose AUTHORED EXAMPLE the
2023
+ // question matches. `meaning-evidence.ts` sets that flag only for an
2024
+ // authored-example fit and explicitly notes it is the one signal that
2025
+ // may grant this shortcut — lexical equality to a block name does not
2026
+ // qualify.
1524
2027
  const authoredExample = authoritativeExactCertifiedExample(candidates);
1525
2028
  if (authoredExample) {
1526
2029
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
1527
2030
  }
1528
- const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
2031
+ const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
1529
2032
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1530
2033
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1531
2034
  }
1532
- const dominant = dominantCompatibleGovernedCandidate(candidates);
2035
+ const dominant = !shouldUseMeaningCall
2036
+ ? dominantCompatibleGovernedCandidate(candidates)
2037
+ : undefined;
1533
2038
  if (dominant) {
1534
2039
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1535
2040
  }
1536
- if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
2041
+ if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
1537
2042
  return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
1538
2043
  }
1539
2044
  const key = cacheKey(request, evidence);
@@ -1577,7 +2082,73 @@ export function createHybridRouter(options = {}) {
1577
2082
  if (resolution) {
1578
2083
  const validated = validateMeaningResolution(resolution, candidates);
1579
2084
  if (validated.ok) {
1580
- return remember(key, routeDecisionForResolution(base, evidence, candidates, validated.resolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
2085
+ const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
2086
+ // Meaning interpretation is still required for a fresh turn,
2087
+ // but it cannot invent a ranking measure when the user only
2088
+ // supplied an entity. Preserve the precise follow-up after the
2089
+ // bounded call so this does not regress into a generic block
2090
+ // or a repeated customer-count answer.
2091
+ //
2092
+ // Gate on the MEANING MODEL's classification, not on
2093
+ // `questionTypeFromText`. The text heuristic only looks for
2094
+ // words like "top", so it also claimed "what region does the
2095
+ // top customer belong to" (an attribute lookup) and "top
2096
+ // products in Philadelphia and the customers who bought them"
2097
+ // (a compound turn) — both were preempted here and never
2098
+ // routed, even though the bounded call had just resolved them.
2099
+ //
2100
+ // A resolution that named an execution target is honored:
2101
+ // `preventDegenerateRankingResolution` above has already
2102
+ // downgraded a same-grain entity count to `clarify`, so
2103
+ // anything still standing is a governed measure the model
2104
+ // selected from qualified candidate ids.
2105
+ const resolutionResolvedRanking = safeResolution.recommendedRoute !== 'clarify'
2106
+ && Boolean(safeResolution.recommendedExecutionId
2107
+ || safeResolution.selectedConceptIds.length > 0);
2108
+ // An explicit SELECTION answers this gate as well as words in
2109
+ // the question do. `hasExplicitRankingMeasure` reads the
2110
+ // question TEXT, and clicking a choice never changes the text —
2111
+ // so picking `customers.average_order_value` re-asked "Top by
2112
+ // which governed metric?" with the same three options, forever.
2113
+ // A degenerate pick is still refused above, so anything
2114
+ // arriving here is a measure the reader chose from governed
2115
+ // evidence.
2116
+ const explicitRankingSelection = Boolean(request.selectedEvidenceId)
2117
+ && Boolean(selectedEvidence)
2118
+ && !isDegenerateRankingMetric(request.question, evidence, selectedEvidence);
2119
+ if (safeResolution.questionType === 'ranking'
2120
+ && !hasExplicitRankingMeasure(request.question, evidence)
2121
+ && !resolutionResolvedRanking
2122
+ && !explicitRankingSelection) {
2123
+ // Meaning resolution ran here. If it named the ranking
2124
+ // measure as the missing piece, that is its judgment and the
2125
+ // turn asks rather than guessing past it. Otherwise a clearly
2126
+ // indicated measure is assumed and BOUND through the same
2127
+ // resolution path an explicit selection takes — an assumption
2128
+ // that cannot freeze a plan is refused downstream and reaches
2129
+ // the reader as `blocked` with no options.
2130
+ const resolverFlagged = (safeResolution.missingInformation ?? [])
2131
+ .some((item) => /measure|metric/i.test(item));
2132
+ const assumedRanking = resolverFlagged
2133
+ ? undefined
2134
+ : assumableRankingMeasure(request.question, clarificationCandidates);
2135
+ if (assumedRanking) {
2136
+ return {
2137
+ ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
2138
+ assumptions: [assumedRanking.assumption],
2139
+ };
2140
+ }
2141
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
2142
+ // Supplemental clarification cards carry the ranking
2143
+ // measures for the requested entity, which the execution
2144
+ // candidate set deliberately does not.
2145
+ clarificationCandidates, !resolverFlagged);
2146
+ }
2147
+ const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
2148
+ if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
2149
+ return deterministicGap;
2150
+ }
2151
+ return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
1581
2152
  }
1582
2153
  const invalidResolution = {
1583
2154
  interpretedQuestion: request.question,
@@ -1610,7 +2181,15 @@ export function createHybridRouter(options = {}) {
1610
2181
  // retrieval signal or permitting a general-knowledge misroute.
1611
2182
  meaningResolverReachable = false;
1612
2183
  }
1613
- return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
2184
+ const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
2185
+ if (!shouldUseMeaningCall)
2186
+ return fallbackDecision;
2187
+ // The provider was unavailable or returned malformed JSON. Apply the
2188
+ // deterministic clarification only after the bounded meaning attempt
2189
+ // has been exhausted; this preserves a precise recovery path without
2190
+ // allowing the generic governed error to terminate the question.
2191
+ return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
2192
+ ?? fallbackDecision;
1614
2193
  }
1615
2194
  }
1616
2195
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;