@duckcodeailabs/dql-agent 1.13.4 → 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 (137) hide show
  1. package/dist/agent-run-engine.d.ts +87 -7
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +559 -40
  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/agentic/tool-loop.d.ts.map +1 -1
  9. package/dist/agentic/tool-loop.js +100 -32
  10. package/dist/agentic/tool-loop.js.map +1 -1
  11. package/dist/aggregation-safety-proof.d.ts +41 -0
  12. package/dist/aggregation-safety-proof.d.ts.map +1 -0
  13. package/dist/aggregation-safety-proof.js +511 -0
  14. package/dist/aggregation-safety-proof.js.map +1 -0
  15. package/dist/analytical-compatibility.d.ts.map +1 -1
  16. package/dist/analytical-compatibility.js +42 -25
  17. package/dist/analytical-compatibility.js.map +1 -1
  18. package/dist/analytical-execution-graph.d.ts +4 -21
  19. package/dist/analytical-execution-graph.d.ts.map +1 -1
  20. package/dist/analytical-execution-graph.js +27 -0
  21. package/dist/analytical-execution-graph.js.map +1 -1
  22. package/dist/analytical-frame.d.ts +12 -1
  23. package/dist/analytical-frame.d.ts.map +1 -1
  24. package/dist/analytical-frame.js +102 -2
  25. package/dist/analytical-frame.js.map +1 -1
  26. package/dist/analytical-orchestration.d.ts +256 -0
  27. package/dist/analytical-orchestration.d.ts.map +1 -0
  28. package/dist/analytical-orchestration.js +419 -0
  29. package/dist/analytical-orchestration.js.map +1 -0
  30. package/dist/analytical-result-facts.d.ts +54 -1
  31. package/dist/analytical-result-facts.d.ts.map +1 -1
  32. package/dist/analytical-result-facts.js +183 -0
  33. package/dist/analytical-result-facts.js.map +1 -1
  34. package/dist/answer-loop.d.ts +44 -3
  35. package/dist/answer-loop.d.ts.map +1 -1
  36. package/dist/answer-loop.js +771 -38
  37. package/dist/answer-loop.js.map +1 -1
  38. package/dist/cascade/budgets.js +1 -1
  39. package/dist/cascade/route-policy.d.ts +1 -1
  40. package/dist/cascade/route-policy.d.ts.map +1 -1
  41. package/dist/cascade/route-policy.js +2 -0
  42. package/dist/cascade/route-policy.js.map +1 -1
  43. package/dist/conversation/snapshot.d.ts.map +1 -1
  44. package/dist/conversation/snapshot.js +3 -0
  45. package/dist/conversation/snapshot.js.map +1 -1
  46. package/dist/conversation/turn-trust.d.ts.map +1 -1
  47. package/dist/conversation/turn-trust.js +6 -0
  48. package/dist/conversation/turn-trust.js.map +1 -1
  49. package/dist/generated-analytical-proposal.d.ts +37 -0
  50. package/dist/generated-analytical-proposal.d.ts.map +1 -0
  51. package/dist/generated-analytical-proposal.js +219 -0
  52. package/dist/generated-analytical-proposal.js.map +1 -0
  53. package/dist/index.d.ts +11 -5
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +7 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/intent-controller.d.ts +8 -1
  58. package/dist/intent-controller.d.ts.map +1 -1
  59. package/dist/intent-controller.js.map +1 -1
  60. package/dist/kg/build.js +102 -6
  61. package/dist/kg/build.js.map +1 -1
  62. package/dist/meaning-resolution.d.ts +30 -0
  63. package/dist/meaning-resolution.d.ts.map +1 -1
  64. package/dist/meaning-resolution.js +61 -8
  65. package/dist/meaning-resolution.js.map +1 -1
  66. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  67. package/dist/metadata/analysis-planner.js +46 -2
  68. package/dist/metadata/analysis-planner.js.map +1 -1
  69. package/dist/metadata/block-fit.d.ts.map +1 -1
  70. package/dist/metadata/block-fit.js +55 -2
  71. package/dist/metadata/block-fit.js.map +1 -1
  72. package/dist/metadata/catalog.d.ts +24 -0
  73. package/dist/metadata/catalog.d.ts.map +1 -1
  74. package/dist/metadata/catalog.js +227 -42
  75. package/dist/metadata/catalog.js.map +1 -1
  76. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  77. package/dist/metadata/meaning-evidence.js +408 -36
  78. package/dist/metadata/meaning-evidence.js.map +1 -1
  79. package/dist/metadata/sql-context-validation.d.ts +3 -0
  80. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  81. package/dist/metadata/sql-context-validation.js +16 -5
  82. package/dist/metadata/sql-context-validation.js.map +1 -1
  83. package/dist/plan-execution-adapter.d.ts +9 -1
  84. package/dist/plan-execution-adapter.d.ts.map +1 -1
  85. package/dist/plan-execution-adapter.js +81 -32
  86. package/dist/plan-execution-adapter.js.map +1 -1
  87. package/dist/provider-egress.d.ts +122 -0
  88. package/dist/provider-egress.d.ts.map +1 -0
  89. package/dist/provider-egress.js +504 -0
  90. package/dist/provider-egress.js.map +1 -0
  91. package/dist/providers/claude.d.ts.map +1 -1
  92. package/dist/providers/claude.js +76 -33
  93. package/dist/providers/claude.js.map +1 -1
  94. package/dist/providers/dispatch.d.ts +15 -0
  95. package/dist/providers/dispatch.d.ts.map +1 -0
  96. package/dist/providers/dispatch.js +25 -0
  97. package/dist/providers/dispatch.js.map +1 -0
  98. package/dist/providers/gemini.d.ts.map +1 -1
  99. package/dist/providers/gemini.js +16 -8
  100. package/dist/providers/gemini.js.map +1 -1
  101. package/dist/providers/index.d.ts +2 -1
  102. package/dist/providers/index.d.ts.map +1 -1
  103. package/dist/providers/index.js +1 -0
  104. package/dist/providers/index.js.map +1 -1
  105. package/dist/providers/ollama.d.ts.map +1 -1
  106. package/dist/providers/ollama.js +29 -10
  107. package/dist/providers/ollama.js.map +1 -1
  108. package/dist/providers/openai.d.ts.map +1 -1
  109. package/dist/providers/openai.js +92 -36
  110. package/dist/providers/openai.js.map +1 -1
  111. package/dist/providers/types.d.ts +43 -0
  112. package/dist/providers/types.d.ts.map +1 -1
  113. package/dist/providers/types.js.map +1 -1
  114. package/dist/relationship-proof.d.ts +19 -0
  115. package/dist/relationship-proof.d.ts.map +1 -0
  116. package/dist/relationship-proof.js +75 -0
  117. package/dist/relationship-proof.js.map +1 -0
  118. package/dist/research-loop.d.ts.map +1 -1
  119. package/dist/research-loop.js +17 -11
  120. package/dist/research-loop.js.map +1 -1
  121. package/dist/resolved-analytical-plan.d.ts +25 -3
  122. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  123. package/dist/resolved-analytical-plan.js +460 -31
  124. package/dist/resolved-analytical-plan.js.map +1 -1
  125. package/dist/router.d.ts +38 -1
  126. package/dist/router.d.ts.map +1 -1
  127. package/dist/router.js +1060 -53
  128. package/dist/router.js.map +1 -1
  129. package/dist/semantic-identities.d.ts +18 -0
  130. package/dist/semantic-identities.d.ts.map +1 -0
  131. package/dist/semantic-identities.js +60 -0
  132. package/dist/semantic-identities.js.map +1 -0
  133. package/dist/semantic-proof-authority.d.ts +29 -0
  134. package/dist/semantic-proof-authority.d.ts.map +1 -0
  135. package/dist/semantic-proof-authority.js +34 -0
  136. package/dist/semantic-proof-authority.js.map +1 -0
  137. 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"
@@ -593,21 +793,151 @@ function buildEvidenceClarification(candidates, missing = []) {
593
793
  return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
594
794
  return "Which governed business meaning should I use for this question?";
595
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
+ }
596
903
  function directResolution(request, evidence, candidate, candidates) {
904
+ const inferredQuestionType = questionTypeFromText(request.question);
905
+ const questionType = inferredQuestionType === 'definition'
906
+ && candidate.kind === 'semantic_metric'
907
+ && candidate.exactMatch
908
+ && Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability)
909
+ && /^\s*what (?:is|was|were|are)\b/i.test(request.question)
910
+ && !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
911
+ ? 'value'
912
+ : inferredQuestionType;
597
913
  const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
598
914
  const analyticalFrame = buildDeterministicAnalyticalFrame({
599
915
  question: request.question,
916
+ questionType,
600
917
  evidence,
601
918
  metricCandidate: candidate,
602
919
  metricCandidates,
603
920
  candidates,
604
921
  });
922
+ const queryIntent = defaultQueryIntent(evidence);
923
+ const memberCandidates = candidates.filter((item) => {
924
+ if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
925
+ return false;
926
+ const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
927
+ return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
928
+ });
929
+ const canonicalFilters = queryIntent.filters.map((filter) => {
930
+ const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
931
+ .map(normalizeMetricPhrase)
932
+ .includes(normalizeMetricPhrase(filter.value)));
933
+ return member ? { ...filter, value: member.name } : filter;
934
+ });
605
935
  return {
606
936
  interpretedQuestion: request.question,
607
- questionType: questionTypeFromText(request.question),
608
- selectedConceptIds: metricCandidates.map((metric) => metric.id),
937
+ questionType,
938
+ selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
609
939
  recommendedExecutionId: candidate.id,
610
- queryIntent: defaultQueryIntent(evidence),
940
+ queryIntent: { ...queryIntent, filters: canonicalFilters },
611
941
  rejectedCandidates: [],
612
942
  confidence: "high",
613
943
  missingInformation: [],
@@ -623,6 +953,12 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
623
953
  // representations of the same request, not additional requested metrics.
624
954
  if (requestedTerms.length <= 1)
625
955
  return [primary];
956
+ const primaryNames = [primary.name, ...(primary.aliases ?? [])]
957
+ .map(normalizeMetricPhrase)
958
+ .filter(Boolean);
959
+ if (requestedTerms.every((term) => primaryNames.some((name) => metricTermsMatch(name, term)))) {
960
+ return [primary];
961
+ }
626
962
  const questionText = normalizeMetricPhrase(question);
627
963
  const metrics = candidates.filter((candidate) => {
628
964
  if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
@@ -646,6 +982,7 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
646
982
  function normalizeMetricPhrase(value) {
647
983
  return value
648
984
  .toLowerCase()
985
+ .replace(/%/g, ' percentage ')
649
986
  .replace(/[_./:-]+/g, ' ')
650
987
  .replace(/[^a-z0-9 ]+/g, ' ')
651
988
  .replace(/\s+/g, ' ')
@@ -686,36 +1023,528 @@ function exactMultiMetricPrimary(question, evidence, candidates) {
686
1023
  ? primary
687
1024
  : undefined;
688
1025
  }
689
- function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative') {
1026
+ function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative',
1027
+ /**
1028
+ * Whether DQL may commit to the best-ranked reading on the user's behalf.
1029
+ *
1030
+ * False when the meaning resolver never got to run (provider outage). Two
1031
+ * genuinely different metrics that merely share an alias — `booked_revenue`
1032
+ * and `billed_revenue` both aliased "revenue" — must not be settled by
1033
+ * lexical rank alone with the semantic judgment switched off. AGT-017.
1034
+ */
1035
+ mayAssumeInterpretation = true) {
690
1036
  const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
691
1037
  if (multiMetricPrimary) {
692
1038
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
693
1039
  }
694
- 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));
695
1050
  if (exactCompatible.length === 1 &&
696
1051
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
697
1052
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
698
1053
  }
699
- const semanticMetric = uniqueExecutableSemanticMetric(evidence, candidates);
1054
+ const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
700
1055
  if (semanticMetric) {
701
1056
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
702
1057
  }
703
- if (base.action === "investigate") {
1058
+ // Last resort before asking the user: commit to the best governed reading.
1059
+ // Clarifying whenever retrieval returned more than one candidate meant a
1060
+ // `type: simple` metric, the measure it wraps, and the model that holds them
1061
+ // were offered as three competing "meanings" of the same number.
1062
+ const best = mayAssumeInterpretation
1063
+ ? bestGovernedInterpretation(request.question, rankingCandidates)
1064
+ : undefined;
1065
+ if (best) {
1066
+ return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
1067
+ }
1068
+ return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
1069
+ }
1070
+ /** Leaf identity of a governed candidate, ignoring its source qualification. */
1071
+ function candidateLeafName(candidate) {
1072
+ const identity = candidate.qualifiedId ?? candidate.id;
1073
+ return (identity.split(/[.:]/).at(-1) ?? candidate.name).trim().toLowerCase();
1074
+ }
1075
+ /** Prefer the most authoritative representative of one underlying meaning. */
1076
+ function governedObjectAuthority(candidate) {
1077
+ if (candidate.semanticObjectType === 'metric')
1078
+ return 3;
1079
+ if (candidate.semanticObjectType === 'measure')
1080
+ return 2;
1081
+ if (candidate.semanticObjectType === 'model')
1082
+ return 1;
1083
+ return 0;
1084
+ }
1085
+ /**
1086
+ * Reduce retrieval output to genuinely DIFFERENT governed meanings.
1087
+ *
1088
+ * Two collapses, both provable from the semantic registry rather than guessed:
1089
+ * - A simple metric, the measure it wraps, and the model that holds them are
1090
+ * one meaning. Offering all three asks a person to choose between a thing
1091
+ * and its own wrapper.
1092
+ * - An entity is a join key, not an answer. When the question asks for an
1093
+ * attribute ("customer names") and a dimension matched, entities are not
1094
+ * candidate readings of it at all.
1095
+ */
1096
+ export function collapseRedundantGovernedCandidates(question, candidates) {
1097
+ const byScore = [...new Map(candidates
1098
+ .filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
1099
+ .map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
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);
1110
+ const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
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))
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;
1161
+ const representatives = new Map();
1162
+ const passthrough = [];
1163
+ for (const candidate of kindFiltered) {
1164
+ const type = candidate.semanticObjectType;
1165
+ if (type !== 'metric' && type !== 'measure' && type !== 'model') {
1166
+ passthrough.push(candidate);
1167
+ continue;
1168
+ }
1169
+ const key = `${(candidate.semanticModel ?? '').toLowerCase()}::${candidateLeafName(candidate)}`;
1170
+ const current = representatives.get(key);
1171
+ if (!current || governedObjectAuthority(candidate) > governedObjectAuthority(current)) {
1172
+ representatives.set(key, candidate);
1173
+ }
1174
+ }
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));
1213
+ }
1214
+ /**
1215
+ * The governed meaning to run when nothing proved a single exact reading.
1216
+ *
1217
+ * DQL commits to the best-ranked interpretation and discloses it through the
1218
+ * route label, instead of stopping to ask. Asking on every multi-candidate
1219
+ * question made ordinary lookups feel like an interrogation, and most of those
1220
+ * questions had no real ambiguity behind them.
1221
+ */
1222
+ export function bestGovernedInterpretation(question, candidates) {
1223
+ // Take the best candidate that is actually executable, rather than refusing
1224
+ // because the top-ranked hit happens to be a descriptive modeling entity.
1225
+ // `compatibility: 'unknown'` is common for governed objects that execute
1226
+ // perfectly well, so it does not disqualify — but 'partial' does. A partial
1227
+ // match has already been proven NOT to cover the request, and running one
1228
+ // silently answers a different question than the one that was asked.
1229
+ return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
1230
+ && (candidate.kind === 'certified_block'
1231
+ || candidate.kind === 'semantic_metric'
1232
+ || candidate.kind === 'semantic_member'));
1233
+ }
1234
+ /**
1235
+ * Retrieval may nominate qualified candidates, but only the resolved plan may
1236
+ * authorize analytical execution. If bounded meaning cannot freeze one exact
1237
+ * tuple, retain stable candidate identities for a focused continuation or emit
1238
+ * a typed modeling gap. Never hand those candidates to the legacy answer loop
1239
+ * as another meaning/planning authority.
1240
+ */
1241
+ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], question = '') {
1242
+ // Ask only about meanings that genuinely differ. A metric, the measure it
1243
+ // wraps, and their model are one reading, and a join key is not a reading of
1244
+ // an attribute question at all.
1245
+ const eligible = collapseRedundantGovernedCandidates(question, candidates);
1246
+ const trace = evidence ? retrievalTrace(evidence, candidates) : {
1247
+ candidateCount: 0,
1248
+ candidateIds: [],
1249
+ };
1250
+ if (eligible.length > 1) {
1251
+ const choices = eligible.slice(0, 3);
704
1252
  return {
705
1253
  ...base,
706
- category: "data_analysis",
707
- retrievalEvidence: retrievalTrace(evidence, candidates),
1254
+ action: 'clarify',
1255
+ confidence: 1,
1256
+ source: 'heuristic',
1257
+ category: 'unclear',
1258
+ depth: 'quick',
1259
+ reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
1260
+ requiresClarification: true,
1261
+ clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
1262
+ clarificationOptions: buildClarificationOptions(choices),
1263
+ retrievalEvidence: trace,
1264
+ resolvedAnalyticalPlan: undefined,
1265
+ meaningResolution: undefined,
708
1266
  };
709
1267
  }
1268
+ const candidateIds = eligible.map((candidate) => candidate.qualifiedId ?? candidate.id);
1269
+ const message = candidateIds.length === 1
1270
+ ? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
1271
+ : '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
1272
  return {
711
1273
  ...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),
1274
+ action: 'block',
1275
+ confidence: 1,
1276
+ source: 'heuristic',
1277
+ category: base.action === 'investigate' ? 'data_analysis' : 'data_lookup',
1278
+ depth: 'quick',
1279
+ reason: message,
1280
+ requiresClarification: false,
1281
+ retrievalEvidence: trace,
1282
+ terminalOutcome: {
1283
+ kind: 'modeling_gap',
1284
+ code: 'ANALYTICAL_MODELING_GAP',
1285
+ message,
1286
+ candidateIds,
1287
+ },
1288
+ resolvedAnalyticalPlan: undefined,
1289
+ meaningResolution: undefined,
1290
+ };
1291
+ }
1292
+ /**
1293
+ * Deterministic business questions that must be settled before meaning/model
1294
+ * orchestration. These are not proof failures: no immutable analytical plan
1295
+ * exists yet, so the only valid outcome is one concise clarification.
1296
+ */
1297
+ function deterministicPrePlanClarification(request, base, evidence, candidates) {
1298
+ const asksForRanking = questionTypeFromText(request.question) === 'ranking';
1299
+ const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
1300
+ .map(normalizeMetricPhrase)
1301
+ .filter(Boolean);
1302
+ const hasExplicitRankingMetric = requestedMeasures.length > 0
1303
+ && requestedMeasures.every((requested) => candidates.some((candidate) => candidateProvesMetricTerm(candidate, requested)))
1304
+ || hasStrongQualifiedMetricEvidence(candidates)
1305
+ || hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
1306
+ const retrievalEvidence = retrievalTrace(evidence, candidates);
1307
+ const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
1308
+ const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
1309
+ && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
1310
+ ? [normalizeMetricPhrase(filter.field)]
1311
+ : []));
1312
+ const missingDimensions = requestedDimensions.filter((requested) => !modeledFilterFields.has(requested)
1313
+ && !candidates.some((candidate) => candidateProvesDimensionTerm(candidate, requested)));
1314
+ if (missingDimensions.length > 0) {
1315
+ const filterValues = (evidence.parsedIntent?.filters ?? []).map((filter) => normalizeMetricPhrase(filter.value));
1316
+ const alternatives = candidates
1317
+ .filter(isCompatibleQualifiedMember)
1318
+ .filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, missingDimensions))
1319
+ .filter((candidate) => !candidateIdentityTerms(candidate).some((term) => requestedDimensions.some((requested) => metricTermsMatch(term, requested))
1320
+ || filterValues.some((value) => metricTermsMatch(term, value))))
1321
+ .sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
1322
+ .slice(0, 3);
1323
+ if (alternatives.length === 0) {
1324
+ if (!asksForRanking || hasExplicitRankingMetric)
1325
+ return undefined;
1326
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1327
+ }
1328
+ const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1329
+ const alternativeLabels = alternatives.map(renderCandidateChoice);
1330
+ return {
1331
+ ...base,
1332
+ action: 'clarify',
1333
+ confidence: 1,
1334
+ reason: `The requested dimension ${requestedLabel} is absent from the retrieved qualified evidence, so no analytical plan was frozen.`,
1335
+ source: 'heuristic',
1336
+ category: 'unclear',
1337
+ depth: 'quick',
1338
+ requiresClarification: true,
1339
+ clarifyingQuestion: alternativeLabels.length > 0
1340
+ ? `${requestedLabel} is not modeled. Should I use ${alternativeLabels.join(' or ')} instead?`
1341
+ : `${requestedLabel} is not modeled. Which governed dimension should I use instead?`,
1342
+ retrievalEvidence,
1343
+ ...(alternatives.length > 0 ? { clarificationOptions: buildClarificationOptions(alternatives) } : {}),
1344
+ resolvedAnalyticalPlan: undefined,
1345
+ meaningResolution: undefined,
1346
+ };
1347
+ }
1348
+ if (asksForRanking && !hasExplicitRankingMetric) {
1349
+ return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
1350
+ }
1351
+ return undefined;
1352
+ }
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
+ });
1400
+ return {
1401
+ ...base,
1402
+ action: 'clarify',
1403
+ confidence: 1,
1404
+ reason: 'A ranking needs a positively identified governed metric before an execution capability can be selected.',
1405
+ source: 'heuristic',
1406
+ category: 'unclear',
1407
+ depth: 'quick',
1408
+ requiresClarification: true,
1409
+ clarifyingQuestion: 'Top by which governed metric?',
1410
+ retrievalEvidence,
1411
+ ...(rankingChoices.length > 0
1412
+ ? { clarificationOptions: buildClarificationOptions(rankingChoices) }
1413
+ : {}),
1414
+ resolvedAnalyticalPlan: undefined,
1415
+ meaningResolution: undefined,
717
1416
  };
718
1417
  }
1418
+ function uniqueNormalizedTerms(values) {
1419
+ return [...new Set(values.map(normalizeMetricPhrase).filter(Boolean))];
1420
+ }
1421
+ function isCompatibleQualifiedMember(candidate) {
1422
+ return candidate.kind === 'semantic_member'
1423
+ && candidate.compatibility !== 'incompatible'
1424
+ && Boolean(candidate.qualifiedId ?? candidate.id);
1425
+ }
1426
+ function candidateIdentityTerms(candidate) {
1427
+ return uniqueNormalizedTerms([
1428
+ candidate.id,
1429
+ candidate.qualifiedId ?? '',
1430
+ candidate.name,
1431
+ ...(candidate.aliases ?? []),
1432
+ ...(candidate.dimensions ?? []),
1433
+ ]);
1434
+ }
1435
+ function candidateProvesDimensionTerm(candidate, requested) {
1436
+ if (candidate.compatibility === 'incompatible')
1437
+ return false;
1438
+ if (candidate.kind === 'semantic_member'
1439
+ && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, requested)))
1440
+ return true;
1441
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
1442
+ return normalized.status === 'complete'
1443
+ && Boolean(normalized.capability?.dimensions.some((dimension) => metricTermsMatch(normalizeMetricPhrase(dimension.dimensionId), requested)));
1444
+ }
1445
+ function candidateProvesMetricTerm(candidate, requested) {
1446
+ if (candidate.compatibility === 'incompatible')
1447
+ return false;
1448
+ if (candidate.kind === 'certified_block') {
1449
+ return candidate.compatibility === 'compatible';
1450
+ }
1451
+ if (candidate.kind !== 'semantic_metric')
1452
+ return false;
1453
+ const stableIdentity = candidate.qualifiedId ?? candidate.id;
1454
+ if (!stableIdentity)
1455
+ return false;
1456
+ return [candidate.name, ...(candidate.aliases ?? [])]
1457
+ .map(normalizeMetricPhrase)
1458
+ .some((term) => metricTermsMatch(term, requested));
1459
+ }
1460
+ function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
1461
+ const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
1462
+ return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
1463
+ || facts.includes(`dimension alternative for ${requested}`));
1464
+ }
1465
+ /**
1466
+ * A missing optional parsed-intent projection must not erase positive metric
1467
+ * evidence already retrieved for the question. Only complete, compatible,
1468
+ * qualified semantic metrics with an exact/explicit match reason can bypass
1469
+ * the bare-ranking clarification; mere catalog presence is insufficient.
1470
+ */
1471
+ function hasStrongQualifiedMetricEvidence(candidates) {
1472
+ return candidates.some((candidate) => {
1473
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
1474
+ return false;
1475
+ if (!candidate.qualifiedId || !normalizeEvidenceAnalyticalCapability(candidate).capability)
1476
+ return false;
1477
+ if (candidate.exactMatch)
1478
+ return true;
1479
+ return candidate.matchReasons.some((reason) => {
1480
+ const normalized = normalizeMetricPhrase(reason);
1481
+ return /\b(?:exact|explicit)\b/.test(normalized)
1482
+ && /\b(?:metric|measure|meaning|name|alias)\b/.test(normalized);
1483
+ });
1484
+ });
1485
+ }
1486
+ function hasQuestionQualifiedMetricEvidence(question, evidence, candidates) {
1487
+ const questionTokens = new Set(substantiveLexicalTokens(question));
1488
+ const dimensionTokens = new Set(substantiveLexicalTokens((evidence.parsedIntent?.dimensions ?? []).join(' ')));
1489
+ return candidates.some((candidate) => {
1490
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
1491
+ return false;
1492
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
1493
+ if (normalized.status !== 'complete' || !candidate.qualifiedId)
1494
+ return false;
1495
+ return metricLexicalVariants(candidate).some((variant) => {
1496
+ const metricTokens = [...new Set(substantiveLexicalTokens(variant)
1497
+ .filter((token) => !dimensionTokens.has(token)))];
1498
+ if (metricTokens.length === 0)
1499
+ return false;
1500
+ const matched = metricTokens.filter((token) => questionTokens.has(token)).length;
1501
+ return metricTokens.length === 1
1502
+ ? matched === 1
1503
+ : matched >= 2 && matched / metricTokens.length >= 0.5;
1504
+ });
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Compare both authored identities and their canonical local names. Generated
1509
+ * semantic-model namespaces remain available as authored evidence, but cannot
1510
+ * dilute the leaf metric phrase that users naturally ask for.
1511
+ */
1512
+ function metricLexicalVariants(candidate) {
1513
+ const authored = [
1514
+ candidate.name,
1515
+ ...(candidate.aliases ?? []),
1516
+ ...(candidate.qualifiedId ? [candidate.qualifiedId] : []),
1517
+ ].filter(Boolean);
1518
+ return [...new Set(authored.flatMap((value) => {
1519
+ const local = value.split(/[.:/]/).filter(Boolean).at(-1);
1520
+ return local && local !== value ? [value, local] : [value];
1521
+ }))];
1522
+ }
1523
+ const NON_SUBSTANTIVE_RANKING_TOKENS = new Set([
1524
+ 'a', 'an', 'and', 'are', 'at', 'best', 'bottom', 'by', 'count', 'for',
1525
+ 'from', 'give', 'has', 'have', 'highest', 'in', 'is', 'least', 'list',
1526
+ 'lowest', 'me', 'measure', 'metric', 'most', 'number', 'of', 'on', 'or',
1527
+ 'per', 'rank', 'ranked', 'ranking', 'show', 'that', 'the', 'this', 'to',
1528
+ 'top', 'total', 'value', 'what', 'which', 'who', 'with', 'without', 'worst',
1529
+ 'amount',
1530
+ ]);
1531
+ function substantiveLexicalTokens(value) {
1532
+ return normalizeMetricPhrase(value)
1533
+ .split(' ')
1534
+ .filter((token) => token.length >= 2 && !NON_SUBSTANTIVE_RANKING_TOKENS.has(token));
1535
+ }
1536
+ function renderCandidateChoice(candidate) {
1537
+ const identity = candidate.qualifiedId ?? candidate.id;
1538
+ const stableName = identity.split(/[.:]/).at(-1) ?? candidate.name;
1539
+ // Never paste a raw semantic-layer record into the question a person reads.
1540
+ const description = humanizeCandidateDefinition(candidate.definition);
1541
+ const grain = candidate.primaryEntity?.trim();
1542
+ const kind = candidateKindLabel(candidate.kind);
1543
+ const detail = [description, grain ? `grain: ${grain}` : ''].filter(Boolean).join('; ');
1544
+ return detail
1545
+ ? `${candidate.name} — ${kind}, ${detail}`
1546
+ : `${candidate.name} — ${kind} (${stableName})`;
1547
+ }
719
1548
  /**
720
1549
  * AGT-017 / AGT-018 / PERF-002 — provider failure must not send an otherwise
721
1550
  * unambiguous semantic question back through the legacy answer cascade. A dbt
@@ -773,7 +1602,13 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
773
1602
  const floor = Math.max(0.55, exact.relevanceScore - 0.2);
774
1603
  return candidates.some((candidate) => candidate.id !== exact.id
775
1604
  && candidate.compatibility !== "incompatible"
776
- && candidate.relevanceScore >= floor);
1605
+ && candidate.relevanceScore >= floor
1606
+ // A dimension/member card can help bind the selected metric's tuple, but
1607
+ // it is not a competing business metric meaning. Treating it as one sends
1608
+ // an exact metric plus its own dimension candidates back to the resolver.
1609
+ && (exact.kind !== 'semantic_metric'
1610
+ || candidate.kind === 'semantic_metric'
1611
+ || candidate.kind === 'certified_block'));
777
1612
  }
778
1613
  /**
779
1614
  * Skip the meaning-model call only when host-owned fit checks have already
@@ -806,6 +1641,13 @@ function dominantCompatibleGovernedCandidate(candidates) {
806
1641
  const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
807
1642
  return hasExecutableCompetitor ? undefined : best;
808
1643
  }
1644
+ function authoritativeExactCertifiedExample(candidates) {
1645
+ const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
1646
+ && candidate.exactMatch
1647
+ && candidate.compatibility === 'compatible'
1648
+ && candidate.analyticalFitClass === 'exact');
1649
+ return exact.length === 1 ? exact[0] : undefined;
1650
+ }
809
1651
  function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
810
1652
  if (!base.followsUp)
811
1653
  return false;
@@ -848,6 +1690,7 @@ export function createHybridRouter(options = {}) {
848
1690
  const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
849
1691
  const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
850
1692
  const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
1693
+ const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
851
1694
  const cache = new Map();
852
1695
  let tick = 0;
853
1696
  const now = options.now ?? (() => { tick += 1; return tick; });
@@ -878,6 +1721,10 @@ export function createHybridRouter(options = {}) {
878
1721
  if (base.action === "converse" || base.action === "compose_app") {
879
1722
  return { ...base, source: base.source ?? "heuristic" };
880
1723
  }
1724
+ const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
1725
+ if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
1726
+ return softBoundaryDecision(request, base, initialDiscoveryRoute);
1727
+ }
881
1728
  let evidence;
882
1729
  if (options.getEvidence) {
883
1730
  try {
@@ -890,38 +1737,93 @@ export function createHybridRouter(options = {}) {
890
1737
  }
891
1738
  }
892
1739
  if (evidence) {
1740
+ evidence = {
1741
+ ...evidence,
1742
+ candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
1743
+ ...(evidence.clarificationCandidates
1744
+ ? { clarificationCandidates: canonicalizeMetricMeasureCandidates([
1745
+ ...evidence.candidates,
1746
+ ...evidence.clarificationCandidates,
1747
+ ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
1748
+ : {}),
1749
+ };
893
1750
  let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
894
1751
  // A structured clarification selection is authoritative identity input,
895
1752
  // not a new fuzzy-search phrase. Keep it in the bounded package even if
896
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.
897
1759
  const selectedEvidence = request.selectedEvidenceId
898
- ? 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)
899
1762
  : undefined;
900
1763
  if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
901
1764
  candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
902
1765
  .slice(0, options.maxMeaningCandidates ?? 12);
903
1766
  }
904
1767
  if (candidates.length > 0) {
1768
+ // Clarification is local and never provider-bound, so it can inspect the
1769
+ // complete already-retrieved set. Keep the smaller package below for
1770
+ // any later meaning call.
1771
+ const clarificationCandidates = [
1772
+ ...evidence.candidates,
1773
+ ...(evidence.clarificationCandidates ?? []),
1774
+ ].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
905
1775
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
906
- 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
+ }
907
1799
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
908
1800
  return selectedEvidence && decision.requiresClarification
909
1801
  ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
910
1802
  : decision;
911
1803
  }
912
- const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
1804
+ const multiMetricPrimary = !shouldUseMeaningCall
1805
+ ? exactMultiMetricPrimary(request.question, evidence, candidates)
1806
+ : undefined;
913
1807
  if (multiMetricPrimary) {
914
1808
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
915
1809
  }
916
- const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
1810
+ const authoredExample = !shouldUseMeaningCall
1811
+ ? authoritativeExactCertifiedExample(candidates)
1812
+ : undefined;
1813
+ if (authoredExample) {
1814
+ return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
1815
+ }
1816
+ const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
917
1817
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
918
1818
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
919
1819
  }
920
- const dominant = dominantCompatibleGovernedCandidate(candidates);
1820
+ const dominant = !shouldUseMeaningCall
1821
+ ? dominantCompatibleGovernedCandidate(candidates)
1822
+ : undefined;
921
1823
  if (dominant) {
922
1824
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
923
1825
  }
924
- if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
1826
+ if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
925
1827
  return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
926
1828
  }
927
1829
  const key = cacheKey(request, evidence);
@@ -929,12 +1831,29 @@ export function createHybridRouter(options = {}) {
929
1831
  if (cached && (options.cacheTtlMs === undefined || now() - cached.at < cacheTtlMs)) {
930
1832
  return { ...cached.decision, source: "cache" };
931
1833
  }
1834
+ // Distinguish "the resolver could not run" from "the resolver ran and
1835
+ // froze nothing". Only the first is a reason to refuse to interpret:
1836
+ // with the semantic judgment unavailable, lexical rank alone must not
1837
+ // settle two genuinely different metrics (AGT-017). When the resolver
1838
+ // did run, committing to the best governed reading is the whole point.
1839
+ let meaningResolverReachable = true;
932
1840
  try {
1841
+ if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
1842
+ return softBoundaryDecision(request, base, 'clarify');
1843
+ }
933
1844
  const resolution = options.resolveMeaning
934
1845
  ? await options.resolveMeaning({
935
1846
  question: request.question,
936
1847
  history: effectiveConversationHistory(request),
937
- evidence,
1848
+ // The resolver/provider receives the same bounded evidence
1849
+ // package as its candidate argument. Supplemental qualified
1850
+ // cards are a host-only clarification aid and must not leak
1851
+ // through this richer carrier.
1852
+ evidence: {
1853
+ ...evidence,
1854
+ candidates,
1855
+ clarificationCandidates: undefined,
1856
+ },
938
1857
  candidates,
939
1858
  signal: request.signal ?? options.signal,
940
1859
  })
@@ -948,26 +1867,77 @@ export function createHybridRouter(options = {}) {
948
1867
  if (resolution) {
949
1868
  const validated = validateMeaningResolution(resolution, candidates);
950
1869
  if (validated.ok) {
951
- 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'));
952
1919
  }
953
1920
  const invalidResolution = {
954
1921
  interpretedQuestion: request.question,
955
1922
  questionType: questionTypeFromText(request.question),
956
- selectedConceptIds: [],
957
- queryIntent: defaultQueryIntent(evidence),
1923
+ selectedConceptIds: resolution.selectedConceptIds,
1924
+ recommendedExecutionId: resolution.recommendedExecutionId,
1925
+ queryIntent: resolution.queryIntent,
958
1926
  rejectedCandidates: [],
959
1927
  confidence: "low",
960
1928
  missingInformation: [validated.reason],
961
1929
  recommendedRoute: "clarify",
962
- clarifyingQuestion: buildEvidenceClarification(candidates, [validated.reason]),
1930
+ compatibilityOutcome: 'modeling_gap',
1931
+ compatibilityFailures: [{
1932
+ code: 'INVALID_EVIDENCE_REFERENCE',
1933
+ field: 'meaningResolution',
1934
+ message: validated.reason,
1935
+ candidateIds: [],
1936
+ }],
963
1937
  };
964
1938
  const invalidDecision = routeDecisionForResolution(base, evidence, candidates, invalidResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
965
1939
  return remember(key, {
966
1940
  ...invalidDecision,
967
- action: 'answer',
968
- requiresClarification: false,
969
- clarificationOptions: undefined,
970
- clarifyingQuestion: undefined,
971
1941
  meaningResolutionErrorCode: 'invalid_evidence_reference',
972
1942
  });
973
1943
  }
@@ -976,8 +1946,17 @@ export function createHybridRouter(options = {}) {
976
1946
  rethrowCancellation(error, request.signal, options.signal);
977
1947
  // A resolver transport/parse failure falls back without losing the
978
1948
  // retrieval signal or permitting a general-knowledge misroute.
1949
+ meaningResolverReachable = false;
979
1950
  }
980
- return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
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;
981
1960
  }
982
1961
  }
983
1962
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;
@@ -987,6 +1966,9 @@ export function createHybridRouter(options = {}) {
987
1966
  return { ...base, source: base.source ?? "heuristic" };
988
1967
  }
989
1968
  let catalogContext;
1969
+ if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
1970
+ return softBoundaryDecision(request, base, 'clarify');
1971
+ }
990
1972
  try {
991
1973
  catalogContext = options.getCatalogContext ? await options.getCatalogContext(request) : undefined;
992
1974
  }
@@ -1017,5 +1999,30 @@ export function createHybridRouter(options = {}) {
1017
1999
  },
1018
2000
  };
1019
2001
  }
2002
+ function discoveryRouteBeforeRetrieval(request, base) {
2003
+ if (request.runBudget?.mode === 'research' || request.requestedMode === 'research')
2004
+ return 'research';
2005
+ if (base.action === 'clarify' || base.requiresClarification || request.signals?.missingContext?.length)
2006
+ return 'clarify';
2007
+ if (request.intent === 'exact_certified_lookup'
2008
+ || (request.signals?.certifiedScore ?? 0) >= 0.5
2009
+ || (request.signals?.metricScore ?? 0) >= 0.5)
2010
+ return 'semantic_answer';
2011
+ return 'generated_answer';
2012
+ }
2013
+ function softBoundaryDecision(request, base, route) {
2014
+ const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
2015
+ return {
2016
+ ...base,
2017
+ action: 'clarify',
2018
+ confidence: 1,
2019
+ source: 'heuristic',
2020
+ requiresClarification: true,
2021
+ reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
2022
+ clarifyingQuestion: request.runBudget?.mode === 'research'
2023
+ ? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
2024
+ : 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
2025
+ };
2026
+ }
1020
2027
  export { intentForCategory, parseMeaningResolution };
1021
2028
  //# sourceMappingURL=router.js.map