@duckcodeailabs/dql-agent 1.13.5 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/agent-run-engine.d.ts +21 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +135 -29
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-store.d.ts.map +1 -1
  6. package/dist/agent-run-store.js +42 -25
  7. package/dist/agent-run-store.js.map +1 -1
  8. package/dist/analytical-orchestration.d.ts +256 -0
  9. package/dist/analytical-orchestration.d.ts.map +1 -0
  10. package/dist/analytical-orchestration.js +419 -0
  11. package/dist/analytical-orchestration.js.map +1 -0
  12. package/dist/analytical-result-facts.d.ts +1 -1
  13. package/dist/analytical-result-facts.d.ts.map +1 -1
  14. package/dist/analytical-result-facts.js +39 -0
  15. package/dist/analytical-result-facts.js.map +1 -1
  16. package/dist/answer-loop.d.ts +4 -0
  17. package/dist/answer-loop.d.ts.map +1 -1
  18. package/dist/answer-loop.js +95 -6
  19. package/dist/answer-loop.js.map +1 -1
  20. package/dist/conversation/snapshot.d.ts.map +1 -1
  21. package/dist/conversation/snapshot.js +3 -0
  22. package/dist/conversation/snapshot.js.map +1 -1
  23. package/dist/conversation/turn-trust.d.ts.map +1 -1
  24. package/dist/conversation/turn-trust.js +6 -0
  25. package/dist/conversation/turn-trust.js.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/meaning-resolution.d.ts +7 -0
  31. package/dist/meaning-resolution.d.ts.map +1 -1
  32. package/dist/meaning-resolution.js.map +1 -1
  33. package/dist/metadata/catalog.d.ts +24 -0
  34. package/dist/metadata/catalog.d.ts.map +1 -1
  35. package/dist/metadata/catalog.js +227 -42
  36. package/dist/metadata/catalog.js.map +1 -1
  37. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  38. package/dist/metadata/meaning-evidence.js +86 -1
  39. package/dist/metadata/meaning-evidence.js.map +1 -1
  40. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  41. package/dist/resolved-analytical-plan.js +92 -5
  42. package/dist/resolved-analytical-plan.js.map +1 -1
  43. package/dist/router.d.ts +8 -0
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +368 -22
  46. package/dist/router.js.map +1 -1
  47. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -793,6 +793,113 @@ function buildEvidenceClarification(candidates, missing = []) {
793
793
  return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
794
794
  return "Which governed business meaning should I use for this question?";
795
795
  }
796
+ /**
797
+ * A distinct-entity count is not a useful ranking measure at the same entity
798
+ * grain: every customer normally has a count of one. Keep the candidate in the
799
+ * evidence trace, but do not let lexical relevance freeze it as the answer to
800
+ * "top customers". This is deliberately a semantic suitability check, not a
801
+ * name-based ban; an explicit "top customers by customer count" request remains
802
+ * the user's choice and can proceed through normal compatibility checks.
803
+ */
804
+ function isDegenerateRankingMetric(question, evidence, candidate) {
805
+ if (questionTypeFromText(question) !== 'ranking')
806
+ return false;
807
+ if (candidate.kind !== 'semantic_metric' && candidate.kind !== 'semantic_member')
808
+ return false;
809
+ const capability = normalizeEvidenceAnalyticalCapability(candidate).capability;
810
+ const aggregation = normalizeMetricPhrase(candidate.aggregation ?? capability?.aggregation ?? '');
811
+ if (!aggregation || !/^(count|count distinct|count unique|count distinct values)$/.test(aggregation))
812
+ return false;
813
+ const questionTerms = new Set(substantiveLexicalTokens(question));
814
+ const entityTerms = [
815
+ candidate.primaryEntity ?? '',
816
+ ...(candidate.analyticalCapability?.resultGrainIds ?? []),
817
+ ...(candidate.dimensions ?? []),
818
+ ...(evidence.parsedIntent?.dimensions ?? []),
819
+ ].flatMap((value) => substantiveLexicalTokens(value));
820
+ const metricTerms = [candidate.name, candidate.qualifiedId ?? '', ...(candidate.aliases ?? [])]
821
+ .flatMap((value) => substantiveLexicalTokens(value));
822
+ return entityTerms.some((term) => questionTerms.has(term))
823
+ && metricTerms.some((term) => entityTerms.includes(term));
824
+ }
825
+ function hasExplicitRankingMeasure(question, evidence) {
826
+ const parsed = [
827
+ ...(evidence.parsedIntent?.measures ?? []),
828
+ ...extractRankingMeasurePhrases(question),
829
+ ].map(normalizeMetricPhrase).filter(Boolean);
830
+ return parsed.length > 0;
831
+ }
832
+ function extractRankingMeasurePhrases(question) {
833
+ const matches = [];
834
+ for (const pattern of [
835
+ /\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,
836
+ /\b(?:highest|lowest|most|least)\s+([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
837
+ ]) {
838
+ for (const match of question.matchAll(pattern))
839
+ if (match[1])
840
+ matches.push(match[1]);
841
+ }
842
+ return matches;
843
+ }
844
+ function rankingMetricChoiceDecision(base, evidence, candidates, selected, question) {
845
+ const options = candidates
846
+ .filter((candidate) => candidate.id !== selected.id
847
+ && candidate.compatibility !== 'incompatible'
848
+ && candidate.kind === 'semantic_metric'
849
+ && !isDegenerateRankingMetric(question, evidence, candidate))
850
+ .slice(0, 3);
851
+ const labels = options.length > 0
852
+ ? options.map((candidate) => renderCandidateChoice(candidate)).join(' or ')
853
+ : 'revenue, order count, or another measure available in the model';
854
+ return {
855
+ ...base,
856
+ action: 'clarify',
857
+ confidence: 1,
858
+ source: 'heuristic',
859
+ category: 'unclear',
860
+ depth: 'quick',
861
+ followsUp: true,
862
+ requiresClarification: true,
863
+ reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
864
+ clarifyingQuestion: `That metric counts unique customers and cannot rank individual customers. Which measure should rank them: ${labels}?`,
865
+ clarificationOptions: options.length > 0 ? buildClarificationOptions(options) : undefined,
866
+ retrievalEvidence: retrievalTrace(evidence, candidates),
867
+ resolvedAnalyticalPlan: undefined,
868
+ meaningResolution: undefined,
869
+ };
870
+ }
871
+ function preventDegenerateRankingResolution(resolution, evidence, candidates, question) {
872
+ if (hasExplicitRankingMeasure(question, evidence))
873
+ return resolution;
874
+ const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
875
+ || resolution.selectedConceptIds.includes(candidate.id));
876
+ if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
877
+ return resolution;
878
+ const alternatives = candidates
879
+ .filter((candidate) => candidate.id !== selected.id
880
+ && candidate.kind === 'semantic_metric'
881
+ && candidate.compatibility !== 'incompatible'
882
+ && !isDegenerateRankingMetric(question, evidence, candidate))
883
+ .slice(0, 3);
884
+ const alternativeLabels = alternatives.map(renderCandidateChoice).join(' or ');
885
+ return {
886
+ ...resolution,
887
+ confidence: 'low',
888
+ recommendedRoute: 'clarify',
889
+ recommendedExecutionId: undefined,
890
+ selectedConceptIds: [],
891
+ analyticalFrame: undefined,
892
+ missingInformation: [
893
+ ...new Set([
894
+ ...resolution.missingInformation,
895
+ `${selected.name} counts the ranked entity and is not a suitable ranking measure`,
896
+ ]),
897
+ ],
898
+ clarifyingQuestion: alternativeLabels
899
+ ? `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use: ${alternativeLabels}?`
900
+ : `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?`,
901
+ };
902
+ }
796
903
  function directResolution(request, evidence, candidate, candidates) {
797
904
  const inferredQuestionType = questionTypeFromText(request.question);
798
905
  const questionType = inferredQuestionType === 'definition'
@@ -930,12 +1037,21 @@ mayAssumeInterpretation = true) {
930
1037
  if (multiMetricPrimary) {
931
1038
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
932
1039
  }
933
- const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
1040
+ const rankingCandidates = hasExplicitRankingMeasure(request.question, evidence)
1041
+ ? candidates
1042
+ : candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
1043
+ if (questionTypeFromText(request.question) === 'ranking'
1044
+ && !hasExplicitRankingMeasure(request.question, evidence)) {
1045
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
1046
+ }
1047
+ const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
1048
+ && candidate.compatibility !== "incompatible"
1049
+ && rankingCandidates.includes(candidate));
934
1050
  if (exactCompatible.length === 1 &&
935
1051
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
936
1052
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
937
1053
  }
938
- const semanticMetric = uniqueExecutableSemanticMetric(evidence, candidates);
1054
+ const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
939
1055
  if (semanticMetric) {
940
1056
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
941
1057
  }
@@ -944,7 +1060,7 @@ mayAssumeInterpretation = true) {
944
1060
  // `type: simple` metric, the measure it wraps, and the model that holds them
945
1061
  // were offered as three competing "meanings" of the same number.
946
1062
  const best = mayAssumeInterpretation
947
- ? bestGovernedInterpretation(request.question, candidates)
1063
+ ? bestGovernedInterpretation(request.question, rankingCandidates)
948
1064
  : undefined;
949
1065
  if (best) {
950
1066
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
@@ -981,11 +1097,67 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
981
1097
  const byScore = [...new Map(candidates
982
1098
  .filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
983
1099
  .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);
1100
+ // An entity is a JOIN KEY. It is a reading of "how many customers", but never
1101
+ // of "what customer type is <member>" — there the reader named a field and a
1102
+ // member, and the entity cannot answer either. The trigger used to be a
1103
+ // four-word list (name/label/title/description), so "customer type", "region",
1104
+ // and "when did X first order" all left the entity competing and turned an
1105
+ // ordinary attribute lookup into a bind-interrogation. Recognise the
1106
+ // interrogative FORM as well as the vocabulary; `hasDimension` below still
1107
+ // requires that a real attribute was actually retrieved.
1108
+ const wantsAttribute = /\b(names?|labels?|titles?|descriptions?|types?|status(?:es)?|categor(?:y|ies)|segments?|tiers?|regions?|emails?|addresses?)\b/i.test(question)
1109
+ || /\b(what|which|when|where)\b[^?]*\b(is|are|was|were|does|do|did|belongs?)\b/i.test(question);
985
1110
  const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
986
- const kindFiltered = wantsAttribute && hasDimension
987
- ? byScore.filter((candidate) => candidate.semanticObjectType !== 'entity')
1111
+ // An entity arrives as a semantic-layer entity OR as a DQL modeling entity
1112
+ // (`dql:entity:…`, kind `dql_modeling`). Testing only `semanticObjectType`
1113
+ // left the DQL one competing, so the interrogation survived the fix above.
1114
+ // Check BOTH identities: a DQL entity's `qualifiedId` is the bare
1115
+ // `commerce::entity::customer`, so testing the qualified id alone still let
1116
+ // it through.
1117
+ const isEntityCandidate = (candidate) => candidate.semanticObjectType === 'entity'
1118
+ || [candidate.id, candidate.qualifiedId ?? ''].some((identity) => /(^|:)entity(:|::)/i.test(identity));
1119
+ const entityFiltered = wantsAttribute && hasDimension
1120
+ ? byScore.filter((candidate) => !isEntityCandidate(candidate))
988
1121
  : byScore;
1122
+ // Within an attribute question, a candidate matching only a SUB-TOKEN of the
1123
+ // requested field is a lexical decoy, not a competing reading. "What customer
1124
+ // type is <member>?" dragged in `raw_products.type` and
1125
+ // `orders.new_customer_orders` purely because they contain "type" and
1126
+ // "customer", and two decoys are enough to trip the ambiguity gate and turn
1127
+ // the lookup into an interrogation. Score how much of the question each
1128
+ // candidate actually accounts for — including the fields a block declares,
1129
+ // which is how the block that OUTPUTS `customer_type` outranks a column
1130
+ // merely named `type` — and keep only the most specific matches.
1131
+ const normalizedQuestion = normalizeMetricPhrase(question);
1132
+ const phraseSpecificity = (candidate) => {
1133
+ const terms = [
1134
+ candidate.name,
1135
+ ...(candidate.aliases ?? []),
1136
+ ...(candidate.dimensions ?? []),
1137
+ ...(candidate.compatibilityFacts ?? [])
1138
+ .filter((fact) => fact.startsWith('output: '))
1139
+ .map((fact) => fact.slice('output: '.length)),
1140
+ ].map((term) => normalizeMetricPhrase(String(term ?? '').split(/[.:/]/).at(-1) ?? ''));
1141
+ let best = 0;
1142
+ for (const term of terms) {
1143
+ if (!term)
1144
+ continue;
1145
+ const matches = normalizedQuestion === term
1146
+ || normalizedQuestion.startsWith(`${term} `)
1147
+ || normalizedQuestion.endsWith(` ${term}`)
1148
+ || normalizedQuestion.includes(` ${term} `);
1149
+ if (matches)
1150
+ best = Math.max(best, term.split(' ').length);
1151
+ }
1152
+ return best;
1153
+ };
1154
+ const specificity = new Map(entityFiltered.map((candidate) => [candidate.id, phraseSpecificity(candidate)]));
1155
+ const bestSpecificity = Math.max(0, ...specificity.values());
1156
+ // Only prune when something matched a MULTI-word field name. A single shared
1157
+ // token is not enough evidence to call the others decoys.
1158
+ const kindFiltered = wantsAttribute && hasDimension && bestSpecificity >= 2
1159
+ ? entityFiltered.filter((candidate) => (specificity.get(candidate.id) ?? 0) === bestSpecificity)
1160
+ : entityFiltered;
989
1161
  const representatives = new Map();
990
1162
  const passthrough = [];
991
1163
  for (const candidate of kindFiltered) {
@@ -1000,7 +1172,44 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
1000
1172
  representatives.set(key, candidate);
1001
1173
  }
1002
1174
  }
1003
- return [...passthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
1175
+ // A certified block that already OUTPUTS an attribute is not a competing
1176
+ // MEANING of that attribute — it is the same reading at higher authority.
1177
+ // Keeping both turned an ordinary attribute lookup ("what customer type is
1178
+ // <member>?") into a "Which governed meaning should DQL bind: customer_profile
1179
+ // or customers.customer_type?" interrogation, even though the block declares
1180
+ // that exact output, sits at the requested grain, and permits the member
1181
+ // filter. The cascade already says certified outranks semantic for one
1182
+ // reading; this stops the tie from being mistaken for ambiguity.
1183
+ const certifiedCoverage = passthrough
1184
+ .filter((candidate) => candidate.kind === 'certified_block')
1185
+ .map((candidate) => new Set([
1186
+ ...(candidate.dimensions ?? []),
1187
+ ...(candidate.compatibilityFacts ?? [])
1188
+ .filter((fact) => fact.startsWith('output: '))
1189
+ .map((fact) => fact.slice('output: '.length)),
1190
+ ].map((value) => normalizeMetricPhrase(String(value).split(/[.:/]/).at(-1) ?? ''))
1191
+ .filter(Boolean)));
1192
+ const survivingPassthrough = certifiedCoverage.length === 0
1193
+ ? passthrough
1194
+ : passthrough.filter((candidate) => {
1195
+ // The same field can arrive three ways — the block's declared output, the
1196
+ // semantic dimension, and the raw warehouse/dbt column. Only the first is
1197
+ // a governed meaning; the other two are lower-trust representations of it.
1198
+ const supersedable = candidate.semanticObjectType === 'dimension'
1199
+ || candidate.kind === 'sql_column';
1200
+ if (!supersedable)
1201
+ return true;
1202
+ // A column's qualified identity points at its PARENT RELATION, so
1203
+ // `candidateLeafName` yields "customers" for `customers.customer_type`.
1204
+ // Match the candidate's own name as well, or a raw column is never
1205
+ // recognised as the field a block already publishes.
1206
+ const leaves = [
1207
+ normalizeMetricPhrase(String(candidate.name ?? '').split(/[.:/]/).at(-1) ?? ''),
1208
+ normalizeMetricPhrase(candidateLeafName(candidate)),
1209
+ ].filter(Boolean);
1210
+ return !leaves.some((leaf) => certifiedCoverage.some((outputs) => outputs.has(leaf)));
1211
+ });
1212
+ return [...survivingPassthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
1004
1213
  }
1005
1214
  /**
1006
1215
  * The governed meaning to run when nothing proved a single exact reading.
@@ -1114,7 +1323,7 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1114
1323
  if (alternatives.length === 0) {
1115
1324
  if (!asksForRanking || hasExplicitRankingMetric)
1116
1325
  return undefined;
1117
- return bareRankingClarification(base, retrievalEvidence);
1326
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1118
1327
  }
1119
1328
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1120
1329
  const alternativeLabels = alternatives.map(renderCandidateChoice);
@@ -1137,11 +1346,57 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1137
1346
  };
1138
1347
  }
1139
1348
  if (asksForRanking && !hasExplicitRankingMetric) {
1140
- return bareRankingClarification(base, retrievalEvidence);
1349
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1141
1350
  }
1142
1351
  return undefined;
1143
1352
  }
1144
- function bareRankingClarification(base, retrievalEvidence) {
1353
+ /**
1354
+ * "Top by which governed metric?" with NO choices is a dead end: the asker
1355
+ * cannot know which measures are both governed and valid at the ranked grain,
1356
+ * so the only move left is to guess. A built-CLI run on the commerce fixture
1357
+ * ended here with zero options while `revenue`, `lifetime_spend_pretax`, and
1358
+ * `orders` were all modeled.
1359
+ *
1360
+ * The question stays exactly as it was — this only attaches the compatible
1361
+ * ranking measures as selectable choices, minus any same-grain entity count,
1362
+ * which is degenerate for ranking individuals. Selecting one returns an
1363
+ * explicit qualified id, which takes the resolved-selection path instead of
1364
+ * asking again.
1365
+ *
1366
+ * Acceptance: AGT-030.
1367
+ */
1368
+ function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates) {
1369
+ const rankingChoices = (candidates ?? []).filter((candidate) => {
1370
+ if (candidate.compatibility === 'incompatible')
1371
+ return false;
1372
+ if (candidate.kind !== 'certified_block'
1373
+ && candidate.kind !== 'semantic_metric'
1374
+ && candidate.kind !== 'semantic_member')
1375
+ return false;
1376
+ // Check BOTH identities: `qualifiedId` is often the bare semantic-layer
1377
+ // name, so testing it alone let `semantic:model:customers` through.
1378
+ const identities = [candidate.id, candidate.qualifiedId ?? ''].filter(Boolean);
1379
+ // A model, entity, dimension, dbt node, or warehouse table cannot BE the
1380
+ // measure a ranking is ordered by; offering one as a "governed metric" is
1381
+ // how `semantic:model:customers` reached the choice list.
1382
+ if (identities.some((identity) => /^(semantic:(model|entity|dimension|time_dimension):|dbt:|warehouse:)/i.test(identity)))
1383
+ return false;
1384
+ // `semantic:measure:X.X` is a count of X reported at X's own grain — every
1385
+ // row scores 1, so it can never order X. The metadata-driven guard below
1386
+ // needs a declared aggregation, which retrieval does not always carry, so
1387
+ // this identity check catches the case that metadata misses.
1388
+ const degenerateIdentity = identities.some((identity) => {
1389
+ const measurePath = /^semantic:(?:measure|metric):(.+)$/i.exec(identity)?.[1] ?? '';
1390
+ const [owner, measureName] = measurePath.split('.');
1391
+ return Boolean(owner && measureName
1392
+ && normalizeMetricPhrase(owner) === normalizeMetricPhrase(measureName));
1393
+ });
1394
+ if (degenerateIdentity)
1395
+ return false;
1396
+ if (question && evidence && isDegenerateRankingMetric(question, evidence, candidate))
1397
+ return false;
1398
+ return true;
1399
+ });
1145
1400
  return {
1146
1401
  ...base,
1147
1402
  action: 'clarify',
@@ -1153,6 +1408,9 @@ function bareRankingClarification(base, retrievalEvidence) {
1153
1408
  requiresClarification: true,
1154
1409
  clarifyingQuestion: 'Top by which governed metric?',
1155
1410
  retrievalEvidence,
1411
+ ...(rankingChoices.length > 0
1412
+ ? { clarificationOptions: buildClarificationOptions(rankingChoices) }
1413
+ : {}),
1156
1414
  resolvedAnalyticalPlan: undefined,
1157
1415
  meaningResolution: undefined,
1158
1416
  };
@@ -1432,6 +1690,7 @@ export function createHybridRouter(options = {}) {
1432
1690
  const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
1433
1691
  const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
1434
1692
  const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
1693
+ const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
1435
1694
  const cache = new Map();
1436
1695
  let tick = 0;
1437
1696
  const now = options.now ?? (() => { tick += 1; return tick; });
@@ -1492,8 +1751,14 @@ export function createHybridRouter(options = {}) {
1492
1751
  // A structured clarification selection is authoritative identity input,
1493
1752
  // not a new fuzzy-search phrase. Keep it in the bounded package even if
1494
1753
  // per-tier limits would otherwise trim it from a large catalog.
1754
+ // Look in BOTH lists. The ranking-measure choices are supplemental
1755
+ // clarification candidates, not execution candidates, so resolving the
1756
+ // selection against `candidates` alone silently found nothing — the
1757
+ // click looked identical to no click, the ranking gate fired again, and
1758
+ // the same three options came back forever.
1495
1759
  const selectedEvidence = request.selectedEvidenceId
1496
- ? evidence.candidates.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
1760
+ ? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
1761
+ .find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
1497
1762
  : undefined;
1498
1763
  if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
1499
1764
  candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
@@ -1507,33 +1772,58 @@ export function createHybridRouter(options = {}) {
1507
1772
  ...evidence.candidates,
1508
1773
  ...(evidence.clarificationCandidates ?? []),
1509
1774
  ].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
1775
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
1514
- if (explicit && explicit.compatibility !== "incompatible") {
1776
+ const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
1777
+ || /@(metric|block|model|table|column)\(/i.test(request.question)));
1778
+ const shouldUseMeaningCall = requireMeaningCall
1779
+ && !explicitMeaningBinding
1780
+ && Boolean(options.resolveMeaning || options.complete);
1781
+ // A normal natural-language turn must be interpreted against the
1782
+ // candidate cards before a deterministic clarification is allowed.
1783
+ // Running this gate first was the source of the "Top by which
1784
+ // governed metric?" repeat loop: it treated a customer-count
1785
+ // execution shim as the answer and never let the meaning model see
1786
+ // the ranking entity/measure distinction.
1787
+ if (!shouldUseMeaningCall && !explicitMeaningBinding) {
1788
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
1789
+ if (deterministicClarification)
1790
+ return deterministicClarification;
1791
+ }
1792
+ if (explicit
1793
+ && explicit.compatibility !== "incompatible"
1794
+ && (!shouldUseMeaningCall || explicitMeaningBinding)) {
1795
+ if (isDegenerateRankingMetric(request.question, evidence, explicit)
1796
+ && !hasExplicitRankingMeasure(request.question, evidence)) {
1797
+ return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
1798
+ }
1515
1799
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1516
1800
  return selectedEvidence && decision.requiresClarification
1517
1801
  ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
1518
1802
  : decision;
1519
1803
  }
1520
- const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
1804
+ const multiMetricPrimary = !shouldUseMeaningCall
1805
+ ? exactMultiMetricPrimary(request.question, evidence, candidates)
1806
+ : undefined;
1521
1807
  if (multiMetricPrimary) {
1522
1808
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1523
1809
  }
1524
- const authoredExample = authoritativeExactCertifiedExample(candidates);
1810
+ const authoredExample = !shouldUseMeaningCall
1811
+ ? authoritativeExactCertifiedExample(candidates)
1812
+ : undefined;
1525
1813
  if (authoredExample) {
1526
1814
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
1527
1815
  }
1528
- const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
1816
+ const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
1529
1817
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1530
1818
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1531
1819
  }
1532
- const dominant = dominantCompatibleGovernedCandidate(candidates);
1820
+ const dominant = !shouldUseMeaningCall
1821
+ ? dominantCompatibleGovernedCandidate(candidates)
1822
+ : undefined;
1533
1823
  if (dominant) {
1534
1824
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1535
1825
  }
1536
- if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
1826
+ if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
1537
1827
  return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
1538
1828
  }
1539
1829
  const key = cacheKey(request, evidence);
@@ -1577,7 +1867,55 @@ export function createHybridRouter(options = {}) {
1577
1867
  if (resolution) {
1578
1868
  const validated = validateMeaningResolution(resolution, candidates);
1579
1869
  if (validated.ok) {
1580
- return remember(key, routeDecisionForResolution(base, evidence, candidates, validated.resolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
1870
+ const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
1871
+ // Meaning interpretation is still required for a fresh turn,
1872
+ // but it cannot invent a ranking measure when the user only
1873
+ // supplied an entity. Preserve the precise follow-up after the
1874
+ // bounded call so this does not regress into a generic block
1875
+ // or a repeated customer-count answer.
1876
+ //
1877
+ // Gate on the MEANING MODEL's classification, not on
1878
+ // `questionTypeFromText`. The text heuristic only looks for
1879
+ // words like "top", so it also claimed "what region does the
1880
+ // top customer belong to" (an attribute lookup) and "top
1881
+ // products in Philadelphia and the customers who bought them"
1882
+ // (a compound turn) — both were preempted here and never
1883
+ // routed, even though the bounded call had just resolved them.
1884
+ //
1885
+ // A resolution that named an execution target is honored:
1886
+ // `preventDegenerateRankingResolution` above has already
1887
+ // downgraded a same-grain entity count to `clarify`, so
1888
+ // anything still standing is a governed measure the model
1889
+ // selected from qualified candidate ids.
1890
+ const resolutionResolvedRanking = safeResolution.recommendedRoute !== 'clarify'
1891
+ && Boolean(safeResolution.recommendedExecutionId
1892
+ || safeResolution.selectedConceptIds.length > 0);
1893
+ // An explicit SELECTION answers this gate as well as words in
1894
+ // the question do. `hasExplicitRankingMeasure` reads the
1895
+ // question TEXT, and clicking a choice never changes the text —
1896
+ // so picking `customers.average_order_value` re-asked "Top by
1897
+ // which governed metric?" with the same three options, forever.
1898
+ // A degenerate pick is still refused above, so anything
1899
+ // arriving here is a measure the reader chose from governed
1900
+ // evidence.
1901
+ const explicitRankingSelection = Boolean(request.selectedEvidenceId)
1902
+ && Boolean(selectedEvidence)
1903
+ && !isDegenerateRankingMetric(request.question, evidence, selectedEvidence);
1904
+ if (safeResolution.questionType === 'ranking'
1905
+ && !hasExplicitRankingMeasure(request.question, evidence)
1906
+ && !resolutionResolvedRanking
1907
+ && !explicitRankingSelection) {
1908
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
1909
+ // Supplemental clarification cards carry the ranking
1910
+ // measures for the requested entity, which the execution
1911
+ // candidate set deliberately does not.
1912
+ clarificationCandidates);
1913
+ }
1914
+ const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
1915
+ if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
1916
+ return deterministicGap;
1917
+ }
1918
+ return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
1581
1919
  }
1582
1920
  const invalidResolution = {
1583
1921
  interpretedQuestion: request.question,
@@ -1610,7 +1948,15 @@ export function createHybridRouter(options = {}) {
1610
1948
  // retrieval signal or permitting a general-knowledge misroute.
1611
1949
  meaningResolverReachable = false;
1612
1950
  }
1613
- return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
1951
+ const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
1952
+ if (!shouldUseMeaningCall)
1953
+ return fallbackDecision;
1954
+ // The provider was unavailable or returned malformed JSON. Apply the
1955
+ // deterministic clarification only after the bounded meaning attempt
1956
+ // has been exhausted; this preserves a precise recovery path without
1957
+ // allowing the generic governed error to terminate the question.
1958
+ return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
1959
+ ?? fallbackDecision;
1614
1960
  }
1615
1961
  }
1616
1962
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;