@duckcodeailabs/dql-agent 1.14.0 → 1.14.2

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 (173) hide show
  1. package/dist/agent-run-engine.d.ts +68 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +520 -26
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +25 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agentic/analyst-loop.d.ts +91 -0
  9. package/dist/agentic/analyst-loop.d.ts.map +1 -0
  10. package/dist/agentic/analyst-loop.js +331 -0
  11. package/dist/agentic/analyst-loop.js.map +1 -0
  12. package/dist/agentic/assumptions.d.ts +62 -0
  13. package/dist/agentic/assumptions.d.ts.map +1 -0
  14. package/dist/agentic/assumptions.js +73 -0
  15. package/dist/agentic/assumptions.js.map +1 -0
  16. package/dist/agentic/business-explanation.d.ts +56 -0
  17. package/dist/agentic/business-explanation.d.ts.map +1 -0
  18. package/dist/agentic/business-explanation.js +130 -0
  19. package/dist/agentic/business-explanation.js.map +1 -0
  20. package/dist/agentic/identifier-ledger.d.ts +74 -0
  21. package/dist/agentic/identifier-ledger.d.ts.map +1 -0
  22. package/dist/agentic/identifier-ledger.js +151 -0
  23. package/dist/agentic/identifier-ledger.js.map +1 -0
  24. package/dist/agentic/ledger-tools.d.ts +68 -0
  25. package/dist/agentic/ledger-tools.d.ts.map +1 -0
  26. package/dist/agentic/ledger-tools.js +198 -0
  27. package/dist/agentic/ledger-tools.js.map +1 -0
  28. package/dist/agentic/orchestrator-policy.d.ts +60 -0
  29. package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
  30. package/dist/agentic/orchestrator-policy.js +73 -0
  31. package/dist/agentic/orchestrator-policy.js.map +1 -0
  32. package/dist/agentic/orchestrator.d.ts +54 -0
  33. package/dist/agentic/orchestrator.d.ts.map +1 -0
  34. package/dist/agentic/orchestrator.js +67 -0
  35. package/dist/agentic/orchestrator.js.map +1 -0
  36. package/dist/agentic/preview-tool.d.ts +36 -0
  37. package/dist/agentic/preview-tool.d.ts.map +1 -0
  38. package/dist/agentic/preview-tool.js +71 -0
  39. package/dist/agentic/preview-tool.js.map +1 -0
  40. package/dist/agentic/rerank.d.ts +50 -0
  41. package/dist/agentic/rerank.d.ts.map +1 -0
  42. package/dist/agentic/rerank.js +140 -0
  43. package/dist/agentic/rerank.js.map +1 -0
  44. package/dist/agentic/research/hypothesis.d.ts +110 -0
  45. package/dist/agentic/research/hypothesis.d.ts.map +1 -0
  46. package/dist/agentic/research/hypothesis.js +125 -0
  47. package/dist/agentic/research/hypothesis.js.map +1 -0
  48. package/dist/agentic/research/synthesis.d.ts +36 -0
  49. package/dist/agentic/research/synthesis.d.ts.map +1 -0
  50. package/dist/agentic/research/synthesis.js +109 -0
  51. package/dist/agentic/research/synthesis.js.map +1 -0
  52. package/dist/agentic/research-agent.d.ts +54 -0
  53. package/dist/agentic/research-agent.d.ts.map +1 -0
  54. package/dist/agentic/research-agent.js +177 -0
  55. package/dist/agentic/research-agent.js.map +1 -0
  56. package/dist/agentic/sql-authorization.d.ts +131 -0
  57. package/dist/agentic/sql-authorization.d.ts.map +1 -0
  58. package/dist/agentic/sql-authorization.js +460 -0
  59. package/dist/agentic/sql-authorization.js.map +1 -0
  60. package/dist/agentic/tool-loop.d.ts +29 -0
  61. package/dist/agentic/tool-loop.d.ts.map +1 -1
  62. package/dist/agentic/tool-loop.js +129 -52
  63. package/dist/agentic/tool-loop.js.map +1 -1
  64. package/dist/agentic/toolset.d.ts.map +1 -1
  65. package/dist/agentic/toolset.js +155 -0
  66. package/dist/agentic/toolset.js.map +1 -1
  67. package/dist/agentic/turn-plan.d.ts +35 -0
  68. package/dist/agentic/turn-plan.d.ts.map +1 -0
  69. package/dist/agentic/turn-plan.js +108 -0
  70. package/dist/agentic/turn-plan.js.map +1 -0
  71. package/dist/agentic/value-probe.d.ts +89 -0
  72. package/dist/agentic/value-probe.d.ts.map +1 -0
  73. package/dist/agentic/value-probe.js +304 -0
  74. package/dist/agentic/value-probe.js.map +1 -0
  75. package/dist/analytical-frame.d.ts.map +1 -1
  76. package/dist/analytical-frame.js +10 -1
  77. package/dist/analytical-frame.js.map +1 -1
  78. package/dist/analytical-orchestration.d.ts +382 -0
  79. package/dist/analytical-orchestration.d.ts.map +1 -1
  80. package/dist/analytical-orchestration.js +782 -6
  81. package/dist/analytical-orchestration.js.map +1 -1
  82. package/dist/analytical-request-policy.d.ts +26 -0
  83. package/dist/analytical-request-policy.d.ts.map +1 -0
  84. package/dist/analytical-request-policy.js +82 -0
  85. package/dist/analytical-request-policy.js.map +1 -0
  86. package/dist/analytical-result-facts.d.ts +13 -0
  87. package/dist/analytical-result-facts.d.ts.map +1 -1
  88. package/dist/analytical-result-facts.js +37 -1
  89. package/dist/analytical-result-facts.js.map +1 -1
  90. package/dist/answer-loop.d.ts +105 -16
  91. package/dist/answer-loop.d.ts.map +1 -1
  92. package/dist/answer-loop.js +833 -108
  93. package/dist/answer-loop.js.map +1 -1
  94. package/dist/answer-shape.d.ts +32 -1
  95. package/dist/answer-shape.d.ts.map +1 -1
  96. package/dist/answer-shape.js +49 -3
  97. package/dist/answer-shape.js.map +1 -1
  98. package/dist/cascade/budgets.d.ts.map +1 -1
  99. package/dist/cascade/budgets.js +8 -1
  100. package/dist/cascade/budgets.js.map +1 -1
  101. package/dist/cascade/packer.d.ts +88 -0
  102. package/dist/cascade/packer.d.ts.map +1 -0
  103. package/dist/cascade/packer.js +106 -0
  104. package/dist/cascade/packer.js.map +1 -0
  105. package/dist/conversation/result-ops.d.ts +18 -3
  106. package/dist/conversation/result-ops.d.ts.map +1 -1
  107. package/dist/conversation/result-ops.js +77 -5
  108. package/dist/conversation/result-ops.js.map +1 -1
  109. package/dist/conversation/rolling-summary.d.ts.map +1 -1
  110. package/dist/conversation/rolling-summary.js +7 -2
  111. package/dist/conversation/rolling-summary.js.map +1 -1
  112. package/dist/conversation/session-store.d.ts +3 -0
  113. package/dist/conversation/session-store.d.ts.map +1 -1
  114. package/dist/conversation/session-store.js +8 -4
  115. package/dist/conversation/session-store.js.map +1 -1
  116. package/dist/conversation/snapshot.d.ts +20 -0
  117. package/dist/conversation/snapshot.d.ts.map +1 -1
  118. package/dist/conversation/snapshot.js +40 -2
  119. package/dist/conversation/snapshot.js.map +1 -1
  120. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  121. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  122. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  123. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  124. package/dist/hints/store.d.ts.map +1 -1
  125. package/dist/hints/store.js +15 -5
  126. package/dist/hints/store.js.map +1 -1
  127. package/dist/index.d.ts +29 -9
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +29 -6
  130. package/dist/index.js.map +1 -1
  131. package/dist/intent-controller.d.ts +36 -0
  132. package/dist/intent-controller.d.ts.map +1 -1
  133. package/dist/intent-controller.js +95 -0
  134. package/dist/intent-controller.js.map +1 -1
  135. package/dist/meaning-resolution.d.ts +69 -2
  136. package/dist/meaning-resolution.d.ts.map +1 -1
  137. package/dist/meaning-resolution.js +128 -7
  138. package/dist/meaning-resolution.js.map +1 -1
  139. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  140. package/dist/metadata/analysis-planner.js +124 -7
  141. package/dist/metadata/analysis-planner.js.map +1 -1
  142. package/dist/metadata/block-fit.d.ts +36 -0
  143. package/dist/metadata/block-fit.d.ts.map +1 -1
  144. package/dist/metadata/block-fit.js +290 -38
  145. package/dist/metadata/block-fit.js.map +1 -1
  146. package/dist/metadata/catalog.d.ts +20 -23
  147. package/dist/metadata/catalog.d.ts.map +1 -1
  148. package/dist/metadata/catalog.js +193 -22
  149. package/dist/metadata/catalog.js.map +1 -1
  150. package/dist/metadata/meaning-evidence.d.ts +10 -1
  151. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  152. package/dist/metadata/meaning-evidence.js +275 -53
  153. package/dist/metadata/meaning-evidence.js.map +1 -1
  154. package/dist/metadata/metric-match.d.ts +32 -0
  155. package/dist/metadata/metric-match.d.ts.map +1 -1
  156. package/dist/metadata/metric-match.js +120 -9
  157. package/dist/metadata/metric-match.js.map +1 -1
  158. package/dist/research-loop.d.ts +33 -2
  159. package/dist/research-loop.d.ts.map +1 -1
  160. package/dist/research-loop.js +66 -4
  161. package/dist/research-loop.js.map +1 -1
  162. package/dist/resolved-analytical-plan.d.ts +7 -0
  163. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  164. package/dist/resolved-analytical-plan.js +11 -4
  165. package/dist/resolved-analytical-plan.js.map +1 -1
  166. package/dist/router.d.ts +32 -19
  167. package/dist/router.d.ts.map +1 -1
  168. package/dist/router.js +1977 -73
  169. package/dist/router.js.map +1 -1
  170. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  171. package/dist/semantic-bridge/member-select.js +39 -21
  172. package/dist/semantic-bridge/member-select.js.map +1 -1
  173. package/package.json +5 -5
package/dist/router.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assumeDominantCandidate } from './agentic/assumptions.js';
1
2
  /**
2
3
  * Hybrid router — deterministic-first, LLM-assisted for the ambiguous middle.
3
4
  *
@@ -15,9 +16,10 @@
15
16
  * question never pays twice.
16
17
  */
17
18
  import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
18
- import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
19
+ import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
19
20
  import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
20
21
  import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
22
+ import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
21
23
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
22
24
  import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
23
25
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
@@ -413,13 +415,871 @@ function retrievalTrace(evidence, candidates) {
413
415
  candidateIds: candidates.map((candidate) => candidate.id),
414
416
  };
415
417
  }
418
+ /** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
419
+ function sourceCoverageFromEvidence(evidence, candidates) {
420
+ const supplied = new Map((evidence.diagnostics?.sourceCoverage ?? []).map((coverage) => [coverage.source, coverage]));
421
+ const sourceKinds = [
422
+ ['certified', (candidate) => candidate.kind === 'certified_block'],
423
+ ['semantic', (candidate) => candidate.trustTier === 'semantic' || candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member'],
424
+ ['governed_relational', (candidate) => candidate.kind === 'dql_modeling' || (candidate.relationshipEvidence?.length ?? 0) > 0],
425
+ ['exploratory', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
426
+ ['dbt_manifest', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source'],
427
+ ['runtime_schema', (candidate) => candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
428
+ ];
429
+ const coverage = [];
430
+ for (const [source, matches] of sourceKinds) {
431
+ const explicit = supplied.get(source);
432
+ if (explicit) {
433
+ coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
434
+ continue;
435
+ }
436
+ const ids = candidates.filter(matches).map((candidate) => candidate.qualifiedId ?? candidate.id).slice(0, 32);
437
+ const searched = evidence.diagnostics?.searchedKinds ?? [];
438
+ const relevantSearched = source === 'certified'
439
+ ? searched.includes('certified_block')
440
+ : source === 'semantic'
441
+ ? searched.includes('semantic_metric') || searched.includes('semantic_member')
442
+ : source === 'dbt_manifest'
443
+ ? searched.includes('dbt_model') || searched.includes('dbt_source')
444
+ : source === 'runtime_schema'
445
+ ? searched.includes('sql_table') || searched.includes('sql_column')
446
+ : source === 'exploratory'
447
+ ? searched.some((kind) => kind === 'dbt_model' || kind === 'dbt_source' || kind === 'sql_table' || kind === 'sql_column')
448
+ : searched.includes('dql_modeling');
449
+ coverage.push({ version: 1, source, status: ids.length > 0 ? 'available' : relevantSearched ? 'empty' : 'unavailable', candidateIds: ids });
450
+ }
451
+ // Vector/conversation status is only included when its retrieval lane told us
452
+ // its real status. It is not a synthetic “skipped” placeholder.
453
+ for (const source of ['vector', 'conversation']) {
454
+ const explicit = supplied.get(source);
455
+ if (explicit)
456
+ coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
457
+ }
458
+ return coverage;
459
+ }
460
+ function declaredFiscalCalendar(evidence, candidates) {
461
+ const calendar = evidence.fiscalCalendar;
462
+ // A fiscal period value is meaningless without the date role it applies to.
463
+ // Older snapshots may not contain this field, but they must clarify instead
464
+ // of silently applying FY26 to an arbitrary date column.
465
+ if (!calendar?.dateRoleId)
466
+ return undefined;
467
+ const identifiers = new Set(candidates.flatMap((candidate) => [candidate.id, candidate.qualifiedId].filter((id) => Boolean(id))));
468
+ return identifiers.has(calendar.id)
469
+ && identifiers.has(calendar.fiscalPeriodFieldId)
470
+ && identifiers.has(calendar.dateRoleId)
471
+ ? calendar
472
+ : undefined;
473
+ }
474
+ function withDeclaredFiscalBinding(resolution, evidence, candidates, question) {
475
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
476
+ const fiscalPeriod = requirements.time?.fiscalPeriod;
477
+ const calendar = fiscalPeriod ? declaredFiscalCalendar(evidence, candidates) : undefined;
478
+ if (!fiscalPeriod || !calendar)
479
+ return resolution;
480
+ const filters = resolution.queryIntent.filters.filter((filter) => filter.field !== calendar.fiscalPeriodFieldId);
481
+ return {
482
+ ...resolution,
483
+ queryIntent: {
484
+ ...resolution.queryIntent,
485
+ filters: [...filters, { field: calendar.fiscalPeriodFieldId, value: fiscalPeriod }],
486
+ ...(requirements.time?.grain && !resolution.queryIntent.timeGrain ? { timeGrain: requirements.time.grain } : {}),
487
+ fiscalCalendarId: calendar.id,
488
+ fiscalDateRoleId: calendar.dateRoleId,
489
+ },
490
+ };
491
+ }
492
+ function fiscalCalendarClarification(request, base, evidence, candidates) {
493
+ const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
494
+ if (!requirements.time?.requiresDeclaredFiscalCalendar || declaredFiscalCalendar(evidence, candidates))
495
+ return undefined;
496
+ const fiscalPeriod = requirements.time.fiscalPeriod ?? 'the requested fiscal period';
497
+ const coverage = sourceCoverageFromEvidence(evidence, candidates);
498
+ return {
499
+ ...base,
500
+ action: 'clarify',
501
+ confidence: 1,
502
+ source: 'heuristic',
503
+ category: 'unclear',
504
+ depth: 'quick',
505
+ reason: `${fiscalPeriod} requires one declared fiscal calendar and date-role mapping before a plan can freeze.`,
506
+ clarifyingQuestion: `Which declared fiscal calendar and date role should DQL use for ${fiscalPeriod}?`,
507
+ requiresClarification: true,
508
+ retrievalEvidence: retrievalTrace(evidence, candidates),
509
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
510
+ requirements,
511
+ sourceCoverage: coverage,
512
+ attempts: [{ version: 1, tier: 'clarify_or_gap', outcome: 'ambiguous', candidateIds: [], reason: 'No declared fiscal calendar/date-role binding was present in the snapshot.', planFrozen: false }],
513
+ planFrozen: false,
514
+ stopReason: 'ambiguous',
515
+ }),
516
+ };
517
+ }
518
+ function normalizedRelationshipIdentity(value) {
519
+ return value.trim().toLowerCase();
520
+ }
521
+ /**
522
+ * The retrieval parser is intentionally generous because its terms also seed
523
+ * search. Before the router turns those terms into a resolved plan, replace
524
+ * grammatical aggregation wrappers with the shared typed requirement set.
525
+ * This keeps all router paths (fast certified, no-meaning, structured
526
+ * continuation, and one-call meaning) on the same count/customer contract.
527
+ */
528
+ function withNormalizedAnalyticalRequirements(evidence, question) {
529
+ if (!evidence.parsedIntent)
530
+ return evidence;
531
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
532
+ return {
533
+ ...evidence,
534
+ parsedIntent: {
535
+ ...evidence.parsedIntent,
536
+ measures: requirements.measures,
537
+ dimensions: requirements.dimensions,
538
+ },
539
+ };
540
+ }
541
+ function relationshipSafetyIdentities(safety) {
542
+ return [...new Set([safety.id, ...(safety.aliases ?? [])]
543
+ .map(normalizedRelationshipIdentity)
544
+ .filter(Boolean))];
545
+ }
546
+ /**
547
+ * This mirrors the local manifest relationship admission gate with the compact
548
+ * facts preserved in retrieval evidence. IDs and names deliberately play no
549
+ * part in the decision: a neutral draft/many-to-many identity is still unsafe.
550
+ */
551
+ function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
552
+ const validation = safety.validation;
553
+ const checkedAt = Date.parse(validation?.checkedAt ?? '');
554
+ const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
555
+ const expirationInvalid = safety.evidenceExpiresAt !== undefined
556
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
557
+ if (safety.status !== 'certified'
558
+ || safety.staleCertification !== false
559
+ || safety.automaticJoinAllowed !== true
560
+ || safety.fanout !== 'safe'
561
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
562
+ || !safety.from?.trim()
563
+ || !safety.to?.trim()
564
+ || !safety.certificationFingerprint?.trim()
565
+ || !validation
566
+ || validation.status !== 'passed'
567
+ || !validation.queryFingerprint?.trim()
568
+ || !validation.proofFingerprint?.trim()
569
+ || !Number.isFinite(checkedAt)
570
+ || expirationInvalid
571
+ || safety.keys.length === 0
572
+ || safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
573
+ return false;
574
+ if (!requiredJoinKey)
575
+ return true;
576
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
577
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
578
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
579
+ }
580
+ function safeRelationshipProofsForCandidate(candidate, requiredJoinKey) {
581
+ const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
582
+ const proofs = new Map();
583
+ for (const safety of candidate.relationshipSafety ?? []) {
584
+ if (!relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey))
585
+ continue;
586
+ if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
587
+ continue;
588
+ proofs.set(safety.id, safety);
589
+ }
590
+ return proofs;
591
+ }
592
+ function candidateRelationshipEndpoints(candidate) {
593
+ return new Set([
594
+ ...(candidate.relationshipEndpointIds ?? []),
595
+ candidate.primaryEntity ?? '',
596
+ candidate.analyticalCapability?.primaryEntityId ?? '',
597
+ ].map(normalizedRelationshipIdentity).filter(Boolean));
598
+ }
599
+ /**
600
+ * A proof may be structurally safe yet belong to a different domain's relation
601
+ * with the same leaf name. The selected pair must match the proof's canonical
602
+ * endpoints in either direction before it can close an exploratory join.
603
+ */
604
+ function relationshipProofMatchesSelectedPair(safety, left, right) {
605
+ const leftEndpoints = candidateRelationshipEndpoints(left);
606
+ const rightEndpoints = candidateRelationshipEndpoints(right);
607
+ const from = normalizedRelationshipIdentity(safety.from ?? '');
608
+ const to = normalizedRelationshipIdentity(safety.to ?? '');
609
+ return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
610
+ || (leftEndpoints.has(to) && rightEndpoints.has(from))));
611
+ }
612
+ function safeRelationshipProofsForPair(left, right, requiredJoinKey) {
613
+ const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey);
614
+ const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey);
615
+ const shared = new Map();
616
+ for (const [id, proof] of leftProofs) {
617
+ const pairedProof = rightProofs.get(id);
618
+ if (!pairedProof
619
+ || !relationshipProofMatchesSelectedPair(proof, left, right)
620
+ || !relationshipProofMatchesSelectedPair(pairedProof, left, right))
621
+ continue;
622
+ shared.set(id, proof);
623
+ }
624
+ return shared;
625
+ }
626
+ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensions, requiredPhysicalFieldTerms = []) {
627
+ const physical = candidates.filter((candidate) => candidate.eligible !== false
628
+ && candidate.compatibility !== 'incompatible'
629
+ && (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
630
+ const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
631
+ const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
632
+ if (relations.length === 0 || columns.length === 0) {
633
+ return { ok: false, candidateIds: [], reason: 'No qualified raw relation plus column coverage was retrieved from this snapshot.' };
634
+ }
635
+ // This list is intentionally field/role-only. Filter values such as
636
+ // `Datadog`, `FY26`, or `true` are member constraints, not column names;
637
+ // their safe-value validation happens independently from proving the raw
638
+ // physical closure. Treating them as fields made a valid relation appear
639
+ // unmodeled and produced a false absence diagnostic.
640
+ const terms = [...new Set([...missingDimensions, ...requirements.measures, ...requiredPhysicalFieldTerms]
641
+ .map(normalizeMetricPhrase)
642
+ .filter(Boolean))];
643
+ if (terms.length === 0) {
644
+ return { ok: false, candidateIds: [], reason: 'No typed physical fields were available to prove an exploratory plan.' };
645
+ }
646
+ const relationSources = (relation) => relation.sourceObjects?.length ? relation.sourceObjects : [relation.qualifiedId ?? relation.id];
647
+ const columnTouchesRelation = (column, relation) => column.sourceObjects?.length
648
+ ? column.sourceObjects.some((source) => relationSources(relation).includes(source))
649
+ // A source that omitted its column edge cannot substantiate a multi-table
650
+ // plan. It remains usable only when a single relation is selected.
651
+ : relations.length === 1;
652
+ const columnFieldIdentityTerms = (column) => uniqueNormalizedTerms([
653
+ column.name,
654
+ ...(column.aliases ?? []),
655
+ ...(column.dimensions ?? []),
656
+ ]);
657
+ // These are intentionally a tiny, role-only bridge for raw physical
658
+ // evidence. They are evaluated against a column's own name/aliases in this
659
+ // immutable snapshot; they neither infer a relation nor turn a member value
660
+ // into a column. The aliases cover common authored field terminology in the
661
+ // supplied dbt/runtime schema (`product category` -> `product_type`, and a
662
+ // product-level revenue ask -> `product_price`).
663
+ const hasProductCategoryRequirement = [
664
+ ...requirements.dimensions,
665
+ ...missingDimensions,
666
+ ...requiredPhysicalFieldTerms,
667
+ ].some((term) => ['product category', 'category'].includes(normalizeMetricPhrase(term)));
668
+ const physicalRoleAliases = {
669
+ 'product category': ['product type'],
670
+ category: ['product type'],
671
+ // `product_price` is a local revenue witness only when the same request
672
+ // also requires product-category grain. It must not let a bare selected
673
+ // revenue metric bypass its semantic contract through an unrelated raw
674
+ // price column.
675
+ ...(hasProductCategoryRequirement ? { revenue: ['product price'] } : {}),
676
+ };
677
+ const physicalRoleTerms = (term) => uniqueNormalizedTerms([
678
+ term,
679
+ ...(physicalRoleAliases[normalizeMetricPhrase(term)] ?? []),
680
+ ]);
681
+ const columnMatchesTerm = (column, term) =>
682
+ // A relation-qualified ID can contain a metric word even when the column
683
+ // itself does not. Raw `fact_revenue.competitor` is not revenue evidence.
684
+ // Physical role proof therefore starts with field-local name/alias facts,
685
+ // never a parent relation token.
686
+ columnFieldIdentityTerms(column).some((identity) => physicalRoleTerms(term).some((role) => metricTermsMatch(identity, role)));
687
+ const matchingColumns = new Map(terms.map((term) => [
688
+ term,
689
+ columns.filter((column) => columnMatchesTerm(column, term)),
690
+ ]));
691
+ const uncovered = terms.filter((term) => (matchingColumns.get(term) ?? []).length === 0);
692
+ if (uncovered.length > 0) {
693
+ return { ok: false, candidateIds: [], reason: `Qualified physical columns did not cover ${uncovered.join(', ')}.` };
694
+ }
695
+ const stableCandidateId = (candidate) => candidate.qualifiedId ?? candidate.id;
696
+ const stableCandidates = (values) => [...values].sort((left, right) => Number(right.exactMatch === true) - Number(left.exactMatch === true)
697
+ || right.relevanceScore - left.relevanceScore
698
+ || stableCandidateId(left).localeCompare(stableCandidateId(right)));
699
+ const stableColumnsForTerm = (values, term) => [...values].sort((left, right) => {
700
+ const rank = (column) => {
701
+ const identities = columnFieldIdentityTerms(column);
702
+ if (identities.some((identity) => identity === term))
703
+ return 0;
704
+ if (identities.some((identity) => metricTermsMatch(identity, term)))
705
+ return 1;
706
+ return 2;
707
+ };
708
+ return rank(left) - rank(right)
709
+ || Number(right.exactMatch === true) - Number(left.exactMatch === true)
710
+ || right.relevanceScore - left.relevanceScore
711
+ || stableCandidateId(left).localeCompare(stableCandidateId(right));
712
+ });
713
+ const relationCoverage = relations.map((relation) => new Set(terms.filter((term) => (matchingColumns.get(term) ?? []).some((column) => columnTouchesRelation(column, relation)))));
714
+ const selectRequiredColumns = (selectedRelations) => {
715
+ const selected = new Map();
716
+ for (const term of [...terms].sort()) {
717
+ const alreadySelected = [...selected.values()].some((column) => columnMatchesTerm(column, term));
718
+ if (alreadySelected)
719
+ continue;
720
+ const matches = stableColumnsForTerm((matchingColumns.get(term) ?? []).filter((column) => selectedRelations.some((relation) => columnTouchesRelation(column, relation))), term);
721
+ const selectedColumn = matches[0];
722
+ if (!selectedColumn)
723
+ return undefined;
724
+ selected.set(stableCandidateId(selectedColumn), selectedColumn);
725
+ }
726
+ return [...selected.values()];
727
+ };
728
+ const physicalEvidence = (selectedRelations, requiredColumns, joinColumns, relationshipProofIds, reason) => {
729
+ // Keep one deterministic witness for every requested role, then the
730
+ // necessary join-key witnesses and proof identities. Do not add an
731
+ // entire table schema: that used to hide required columns behind a broad
732
+ // 32-card truncation. If the minimal evidence itself exceeds the cap,
733
+ // decline the path rather than silently dropping a required witness.
734
+ const ids = [...new Set([
735
+ ...stableCandidates(selectedRelations).map(stableCandidateId),
736
+ ...stableCandidates(requiredColumns).map(stableCandidateId),
737
+ ...stableCandidates(joinColumns).map(stableCandidateId),
738
+ ...[...relationshipProofIds].sort(),
739
+ ])];
740
+ if (ids.length > 32) {
741
+ return {
742
+ ok: false,
743
+ candidateIds: [],
744
+ reason: `The minimal qualified exploratory closure needs ${ids.length} required relation, field, join-key, or proof witnesses, exceeding the 32-card safety cap; no required evidence was dropped.`,
745
+ };
746
+ }
747
+ return { ok: true, candidateIds: ids, reason };
748
+ };
749
+ const completeRelation = relationCoverage
750
+ .map((coverage, index) => ({ coverage, index }))
751
+ .filter(({ coverage }) => coverage.size === terms.length)
752
+ .sort((left, right) => stableCandidateId(relations[left.index]).localeCompare(stableCandidateId(relations[right.index])))[0];
753
+ if (completeRelation) {
754
+ const relation = relations[completeRelation.index];
755
+ const requiredColumns = selectRequiredColumns([relation]);
756
+ if (!requiredColumns) {
757
+ return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relation.' };
758
+ }
759
+ return physicalEvidence([relation], requiredColumns, [], [], 'One qualified physical relation covers the requested fields without a join.');
760
+ }
761
+ // Build only from structured relationship proofs. A large retrieval snapshot
762
+ // routinely contains unrelated raw tables; requiring every one to join made
763
+ // a complete local path appear unavailable. Conversely, never infer a join
764
+ // from names or shared column strings: the proof must retain the structured
765
+ // certified, validated, fanout-safe disposition above.
766
+ const adjacent = relations.map(() => new Set());
767
+ const joinColumnsForProof = (proof, left, right) => {
768
+ const leftEndpoints = candidateRelationshipEndpoints(left);
769
+ const rightEndpoints = candidateRelationshipEndpoints(right);
770
+ const from = normalizedRelationshipIdentity(proof.from ?? '');
771
+ const to = normalizedRelationshipIdentity(proof.to ?? '');
772
+ const direct = leftEndpoints.has(from) && rightEndpoints.has(to);
773
+ const reverse = leftEndpoints.has(to) && rightEndpoints.has(from);
774
+ if (!direct && !reverse)
775
+ return undefined;
776
+ const witnesses = new Map();
777
+ for (const key of proof.keys) {
778
+ const leftKey = normalizeMetricPhrase(direct ? key.from : key.to);
779
+ const rightKey = normalizeMetricPhrase(direct ? key.to : key.from);
780
+ const leftColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, left)
781
+ && columnMatchesTerm(column, leftKey)), leftKey)[0];
782
+ const rightColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, right)
783
+ && columnMatchesTerm(column, rightKey)), rightKey)[0];
784
+ // A relationship record alone cannot prove a compilable raw join. Both
785
+ // canonical endpoint columns must be present in this same snapshot.
786
+ if (!leftColumn || !rightColumn)
787
+ return undefined;
788
+ witnesses.set(stableCandidateId(leftColumn), leftColumn);
789
+ witnesses.set(stableCandidateId(rightColumn), rightColumn);
790
+ }
791
+ return [...witnesses.values()];
792
+ };
793
+ const witnessesForEdge = new Map();
794
+ for (let left = 0; left < relations.length; left += 1) {
795
+ for (let right = left + 1; right < relations.length; right += 1) {
796
+ const shared = [...safeRelationshipProofsForPair(relations[left], relations[right]).values()]
797
+ .sort((first, second) => first.id.localeCompare(second.id))
798
+ .flatMap((proof) => {
799
+ const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
800
+ return joinColumns ? [{ proof, columns: joinColumns }] : [];
801
+ });
802
+ if (shared.length === 0)
803
+ continue;
804
+ adjacent[left].add(right);
805
+ adjacent[right].add(left);
806
+ witnessesForEdge.set(`${Math.min(left, right)}:${Math.max(left, right)}`, shared);
807
+ }
808
+ }
809
+ const closures = [];
810
+ for (let root = 0; root < relations.length; root += 1) {
811
+ const nodes = new Set([root]);
812
+ const edges = [];
813
+ const covered = new Set(relationCoverage[root]);
814
+ while (covered.size < terms.length) {
815
+ let best;
816
+ const pending = [{ node: root, path: [root] }];
817
+ const seen = new Set([root]);
818
+ while (pending.length > 0) {
819
+ const current = pending.shift();
820
+ const gain = [...relationCoverage[current.node]].filter((term) => !covered.has(term)).length;
821
+ if (gain > 0 && (!best || gain > best.gain || (gain === best.gain && current.path.length < best.distance))) {
822
+ best = { path: current.path, gain, distance: current.path.length };
823
+ }
824
+ for (const neighbor of adjacent[current.node] ?? []) {
825
+ if (seen.has(neighbor))
826
+ continue;
827
+ seen.add(neighbor);
828
+ pending.push({ node: neighbor, path: [...current.path, neighbor] });
829
+ }
830
+ }
831
+ if (!best)
832
+ break;
833
+ for (const node of best.path) {
834
+ nodes.add(node);
835
+ for (const term of relationCoverage[node])
836
+ covered.add(term);
837
+ }
838
+ for (let index = 1; index < best.path.length; index += 1) {
839
+ const left = best.path[index - 1];
840
+ const right = best.path[index];
841
+ if (!edges.some(([edgeLeft, edgeRight]) => edgeLeft === left && edgeRight === right || edgeLeft === right && edgeRight === left)) {
842
+ edges.push([left, right]);
843
+ }
844
+ }
845
+ }
846
+ if (covered.size === terms.length)
847
+ closures.push({ nodes, edges, covered });
848
+ }
849
+ const selected = closures.sort((left, right) => left.nodes.size - right.nodes.size
850
+ || left.edges.length - right.edges.length
851
+ || [...left.nodes].join(',').localeCompare([...right.nodes].join(',')))[0];
852
+ if (!selected) {
853
+ return {
854
+ ok: false,
855
+ candidateIds: [],
856
+ reason: 'Multiple physical relations lacked one connected, certified, validated, fanout-safe automatic-join path.',
857
+ // This is not a lexical conclusion. Every requested physical field was
858
+ // found above, no one relation covered the tuple, and the structured
859
+ // relationship/fanout proof graph could not connect the required
860
+ // relations. Preserve that proof-specific category for downstream
861
+ // receipts and repair guidance.
862
+ gap: {
863
+ code: 'MISSING_RELATIONSHIP',
864
+ missing: ['a connected certified, validated, fanout-safe relationship proof'],
865
+ witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
866
+ },
867
+ };
868
+ }
869
+ const selectedRelations = [...selected.nodes].map((index) => relations[index]);
870
+ const requiredColumns = selectRequiredColumns(selectedRelations);
871
+ if (!requiredColumns) {
872
+ return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relationship closure.' };
873
+ }
874
+ const selectedWitnesses = selected.edges.map(([left, right]) => witnessesForEdge.get(`${Math.min(left, right)}:${Math.max(left, right)}`)?.[0]).filter((witness) => Boolean(witness));
875
+ if (selectedWitnesses.length !== selected.edges.length) {
876
+ return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
877
+ }
878
+ return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and structured relationship safety proofs support a bounded exploratory plan.');
879
+ }
880
+ /**
881
+ * The pre-freeze cascade is router authority, including when the bounded
882
+ * meaning call could not run. A missing role in a compact meaning package is
883
+ * not terminal proof of absence: eligible certified/semantic tiers are marked
884
+ * ineligible, then the same immutable snapshot may prove a review-required
885
+ * physical path. Policy, fiscal, and relationship safety gates run before
886
+ * this helper and remain terminal where appropriate.
887
+ */
888
+ function preFreezePhysicalCascadeDecision(input) {
889
+ // `candidates` can be the capped meaning package. Physical eligibility is
890
+ // allowed one same-snapshot extension, never a new retrieval/domain scope.
891
+ const snapshotCandidates = input.evidence.candidates.length > 0
892
+ ? input.evidence.candidates
893
+ : input.candidates;
894
+ const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
895
+ const missingRankingMetric = Boolean(physicalPath.ok
896
+ && input.requireRankingMetric
897
+ && input.requirements.ranking
898
+ && input.requirements.ranking.metricTerms.length === 0);
899
+ const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
900
+ const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
901
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
902
+ const skippedAttempt = (tier, source) => {
903
+ const item = coverageFor(source);
904
+ return {
905
+ version: 1,
906
+ tier,
907
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
908
+ candidateIds: item?.candidateIds ?? [],
909
+ reason: item?.status === 'available'
910
+ ? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
911
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
912
+ planFrozen: false,
913
+ };
914
+ };
915
+ const governedCoverage = coverageFor('governed_relational');
916
+ const attempts = [
917
+ skippedAttempt('certified', 'certified'),
918
+ skippedAttempt('semantic', 'semantic'),
919
+ {
920
+ version: 1,
921
+ tier: 'governed_relational',
922
+ outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
923
+ candidateIds: governedCoverage?.candidateIds ?? [],
924
+ reason: governedCoverage?.status === 'available'
925
+ ? 'Retrieved governed relationship evidence did not prove a complete relational execution tuple.'
926
+ : `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
927
+ planFrozen: false,
928
+ },
929
+ {
930
+ version: 1,
931
+ tier: 'exploratory_sql',
932
+ outcome: exploratoryExecutable ? 'executable' : missingRankingMetric ? 'ambiguous' : 'unavailable',
933
+ candidateIds: physicalPath.candidateIds,
934
+ reason: missingRankingMetric
935
+ ? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
936
+ : physicalPath.reason,
937
+ planFrozen: false,
938
+ },
939
+ ];
940
+ const message = exploratoryExecutable
941
+ ? `${input.messagePrefix} A same-snapshot qualified physical path is available for review-required exploratory SQL.`
942
+ : missingRankingMetric
943
+ ? `${input.messagePrefix} ${physicalPath.reason} A ranking measure must be selected before DQL can freeze or explore this plan.`
944
+ : `${input.messagePrefix} ${physicalPath.reason}`;
945
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
946
+ requirements: input.requirements,
947
+ sourceCoverage: coverage,
948
+ attempts: exploratoryExecutable
949
+ ? attempts
950
+ : [...attempts, {
951
+ version: 1,
952
+ tier: 'clarify_or_gap',
953
+ outcome: 'unavailable',
954
+ candidateIds: [],
955
+ reason: message,
956
+ planFrozen: false,
957
+ }],
958
+ ...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
959
+ planFrozen: false,
960
+ stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
961
+ });
962
+ if (!exploratoryExecutable) {
963
+ return {
964
+ ...input.base,
965
+ action: 'block',
966
+ confidence: 1,
967
+ reason: message,
968
+ source: 'heuristic',
969
+ category: 'data_lookup',
970
+ depth: 'quick',
971
+ requiresClarification: false,
972
+ clarifyingQuestion: undefined,
973
+ clarificationOptions: undefined,
974
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
975
+ terminalOutcome: {
976
+ kind: 'modeling_gap',
977
+ code: 'ANALYTICAL_MODELING_GAP',
978
+ message,
979
+ candidateIds: input.terminalCandidateIds ?? [],
980
+ ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
981
+ },
982
+ analyticalCascadeDecision,
983
+ resolvedAnalyticalPlan: undefined,
984
+ meaningResolution: undefined,
985
+ };
986
+ }
987
+ return {
988
+ ...input.base,
989
+ action: 'answer',
990
+ confidence: 0.55,
991
+ reason: message,
992
+ source: 'heuristic',
993
+ category: 'data_lookup',
994
+ depth: 'quick',
995
+ requiresClarification: false,
996
+ clarifyingQuestion: undefined,
997
+ clarificationOptions: undefined,
998
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
999
+ analyticalCascadeDecision,
1000
+ meaningResolution: {
1001
+ interpretedQuestion: input.question,
1002
+ questionType: questionTypeFromText(input.question),
1003
+ selectedConceptIds: physicalPath.candidateIds,
1004
+ queryIntent: {
1005
+ ...defaultQueryIntent(input.evidence),
1006
+ measures: input.requirements.measures,
1007
+ dimensions: input.requirements.dimensions,
1008
+ filters: input.evidence.parsedIntent?.filters ?? [],
1009
+ },
1010
+ rejectedCandidates: [],
1011
+ confidence: 'low',
1012
+ missingInformation: [message],
1013
+ recommendedRoute: 'exploratory',
1014
+ compatibilityOutcome: 'modeling_gap',
1015
+ compatibilityFailures: input.missingTerms.map((term) => ({
1016
+ code: 'MISSING_DIMENSION',
1017
+ field: term,
1018
+ message: `${term} was not complete in the earlier governed tiers.`,
1019
+ candidateIds: [],
1020
+ })),
1021
+ },
1022
+ resolvedAnalyticalPlan: undefined,
1023
+ };
1024
+ }
1025
+ /**
1026
+ * A pair of raw relations is not normally a reason to bypass a clarification:
1027
+ * `orders`, `customers`, and `products` can still be three competing meanings.
1028
+ * It is different when the reader asked for a display value from one relation
1029
+ * and a predicate that physically exists only on another relation. In that
1030
+ * narrow case the evidence is complementary rather than competing.
1031
+ *
1032
+ * This helper is intentionally conservative. It requires all of the
1033
+ * following from ONE retrieval snapshot before it can select review-required
1034
+ * exploration:
1035
+ * - a display-shaped column (for example `product_name`),
1036
+ * - a boolean/predicate-shaped column on a different relation (for example
1037
+ * `is_perishable_supply`),
1038
+ * - the same qualified join key on both relations, and
1039
+ * - a shared, non-fanout relationship proof that touches those relations.
1040
+ *
1041
+ * That means a lexical collection of tables can never suppress a genuine
1042
+ * ambiguity. The generated executor still validates read-only SQL and its
1043
+ * join/aggregation safety before anything runs.
1044
+ */
1045
+ function findSafeComplementaryPhysicalComposition(question, candidates) {
1046
+ const physical = candidates.filter((candidate) => candidate.eligible !== false
1047
+ && candidate.compatibility !== 'incompatible'
1048
+ && (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
1049
+ const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
1050
+ const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
1051
+ if (relations.length < 2 || columns.length < 4)
1052
+ return undefined;
1053
+ const relationSources = (candidate) => candidate.sourceObjects?.length
1054
+ ? candidate.sourceObjects
1055
+ : [candidate.qualifiedId ?? candidate.id];
1056
+ const relationForColumn = (column) => {
1057
+ if (!column.sourceObjects?.length)
1058
+ return [];
1059
+ return relations.filter((relation) => relationSources(relation).some((source) => column.sourceObjects.includes(source)));
1060
+ };
1061
+ const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
1062
+ const candidateTokens = (candidate) => new Set(candidateIdentityTerms(candidate)
1063
+ .flatMap((identity) => identity.split(' '))
1064
+ .map(singularize)
1065
+ .filter(Boolean));
1066
+ const matchesQuestion = (candidate) => [...candidateTokens(candidate)].filter((token) => questionTokens.has(token));
1067
+ const displayColumns = columns.filter((column) => {
1068
+ const name = normalizeMetricPhrase(column.name);
1069
+ return /(?:^| )(?:name|label|title)(?:$| )/.test(name)
1070
+ && matchesQuestion(column).length > 0
1071
+ && relationForColumn(column).length > 0;
1072
+ });
1073
+ if (displayColumns.length === 0)
1074
+ return undefined;
1075
+ const displayTokens = new Set(displayColumns.flatMap(matchesQuestion));
1076
+ const predicateColumns = columns.filter((column) => {
1077
+ const name = normalizeMetricPhrase(column.name);
1078
+ // Predicate shape is required. A text field such as `supply_name` may be
1079
+ // lexically close to "supplies", but it is not proof of the requested
1080
+ // condition and must keep the ordinary clarification behavior.
1081
+ const predicateShape = /(?:^| )(?:is|has|flag|status|active|enabled)(?: |$)/.test(name);
1082
+ return predicateShape
1083
+ && matchesQuestion(column).some((token) => !displayTokens.has(token))
1084
+ && relationForColumn(column).length > 0;
1085
+ });
1086
+ if (predicateColumns.length === 0)
1087
+ return undefined;
1088
+ const columnJoinKeys = (column) => uniqueNormalizedTerms([
1089
+ column.name,
1090
+ ...(column.aliases ?? []),
1091
+ ]).filter((term) => /(?:^| )id$/.test(term));
1092
+ for (const displayColumn of displayColumns) {
1093
+ for (const displayRelation of relationForColumn(displayColumn)) {
1094
+ for (const predicateColumn of predicateColumns) {
1095
+ for (const predicateRelation of relationForColumn(predicateColumn)) {
1096
+ const displaySources = new Set(relationSources(displayRelation));
1097
+ if (relationSources(predicateRelation).some((source) => displaySources.has(source)))
1098
+ continue;
1099
+ const displayColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === displayRelation.id));
1100
+ const predicateColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === predicateRelation.id));
1101
+ const predicateJoinKeys = new Set(predicateColumnsOnRelation.flatMap(columnJoinKeys));
1102
+ const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
1103
+ if (!joinKey)
1104
+ continue;
1105
+ const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey).keys()];
1106
+ if (sharedRelationshipProofIds.length === 0)
1107
+ continue;
1108
+ const selectedPhysical = [
1109
+ displayRelation,
1110
+ predicateRelation,
1111
+ ...displayColumnsOnRelation,
1112
+ ...predicateColumnsOnRelation,
1113
+ ].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
1114
+ const baseRequirements = buildAnalyticalRequirementSet({ question });
1115
+ const requirements = {
1116
+ ...baseRequirements,
1117
+ dimensions: uniqueNormalizedTerms([...baseRequirements.dimensions, displayColumn.name]),
1118
+ entityTerms: uniqueNormalizedTerms([...baseRequirements.entityTerms, ...matchesQuestion(displayColumn)]),
1119
+ entityDisplayTerms: uniqueNormalizedTerms([...baseRequirements.entityDisplayTerms, ...matchesQuestion(displayColumn)]),
1120
+ memberTerms: uniqueNormalizedTerms([...baseRequirements.memberTerms, predicateColumn.name]),
1121
+ };
1122
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, selectedPhysical, [], [displayColumn.name, predicateColumn.name, joinKey]);
1123
+ if (!physicalPath.ok)
1124
+ continue;
1125
+ const relationshipCandidates = candidates.filter((candidate) => sharedRelationshipProofIds.includes(candidate.id)
1126
+ || Boolean(candidate.qualifiedId && sharedRelationshipProofIds.includes(candidate.qualifiedId)));
1127
+ return {
1128
+ requirements,
1129
+ candidateIds: [...new Set([
1130
+ ...physicalPath.candidateIds,
1131
+ ...sharedRelationshipProofIds,
1132
+ ...relationshipCandidates.map((candidate) => candidate.qualifiedId ?? candidate.id),
1133
+ ])].slice(0, 32),
1134
+ displayColumn,
1135
+ predicateColumn,
1136
+ joinKey,
1137
+ relationshipProofIds: sharedRelationshipProofIds,
1138
+ reason: `Qualified ${displayRelation.name}.${displayColumn.name} and ${predicateRelation.name}.${predicateColumn.name} are complementary requirements joined by ${joinKey}; the shared relationship proof permits review-required exploration.`,
1139
+ };
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ return undefined;
1145
+ }
1146
+ /** Build the router-owned cascade for a safe raw relational composition. */
1147
+ function complementaryExploratoryDecision(base, evidence, candidates, question) {
1148
+ // Use all already-retrieved cards from the same snapshot. The bounded meaning
1149
+ // package can omit a supporting raw join column, but no new retrieval/domain
1150
+ // scope is opened here.
1151
+ const composition = findSafeComplementaryPhysicalComposition(question, evidence.candidates);
1152
+ if (!composition)
1153
+ return undefined;
1154
+ const coverage = sourceCoverageFromEvidence(evidence, evidence.candidates);
1155
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1156
+ const skippedAttempt = (tier, source, reason) => {
1157
+ const item = coverageFor(source);
1158
+ return {
1159
+ version: 1,
1160
+ tier,
1161
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1162
+ candidateIds: item?.candidateIds ?? [],
1163
+ reason,
1164
+ planFrozen: false,
1165
+ };
1166
+ };
1167
+ const attempts = [
1168
+ skippedAttempt('certified', 'certified', 'No certified candidate proved the composed display and predicate tuple.'),
1169
+ skippedAttempt('semantic', 'semantic', 'No semantic candidate proved the composed display and predicate tuple.'),
1170
+ {
1171
+ version: 1,
1172
+ tier: 'governed_relational',
1173
+ outcome: 'ineligible',
1174
+ candidateIds: [...new Set([
1175
+ ...(coverageFor('governed_relational')?.candidateIds ?? []),
1176
+ ...composition.relationshipProofIds,
1177
+ ])].slice(0, 32),
1178
+ reason: 'The same-snapshot relationship proof closes the physical path, but no complete governed relational execution tuple was retrieved.',
1179
+ planFrozen: false,
1180
+ },
1181
+ {
1182
+ version: 1,
1183
+ tier: 'exploratory_sql',
1184
+ outcome: 'executable',
1185
+ candidateIds: composition.candidateIds,
1186
+ reason: composition.reason,
1187
+ planFrozen: false,
1188
+ },
1189
+ ];
1190
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
1191
+ requirements: composition.requirements,
1192
+ sourceCoverage: coverage,
1193
+ attempts,
1194
+ selectedTier: 'exploratory_sql',
1195
+ planFrozen: false,
1196
+ stopReason: 'selected',
1197
+ });
1198
+ return {
1199
+ ...base,
1200
+ action: 'answer',
1201
+ confidence: 0.55,
1202
+ source: 'heuristic',
1203
+ category: 'data_lookup',
1204
+ depth: 'quick',
1205
+ reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
1206
+ requiresClarification: false,
1207
+ retrievalEvidence: retrievalTrace(evidence, candidates),
1208
+ analyticalCascadeDecision,
1209
+ resolvedAnalyticalPlan: undefined,
1210
+ meaningResolution: {
1211
+ interpretedQuestion: question,
1212
+ questionType: questionTypeFromText(question),
1213
+ selectedConceptIds: composition.candidateIds,
1214
+ recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
1215
+ queryIntent: {
1216
+ measures: [],
1217
+ dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
1218
+ filters: [{
1219
+ field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
1220
+ value: 'true',
1221
+ }],
1222
+ },
1223
+ rejectedCandidates: [],
1224
+ confidence: 'low',
1225
+ missingInformation: ['No certified or semantic route proved the complete composed request.'],
1226
+ recommendedRoute: 'exploratory',
1227
+ },
1228
+ };
1229
+ }
1230
+ function cascadeForResolution(input) {
1231
+ const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
1232
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1233
+ const selectedTier = input.plan.capability === 'certified_execution' ? 'certified'
1234
+ : input.plan.capability === 'semantic_execution' ? 'semantic'
1235
+ : input.plan.capability === 'governed_relational' ? 'governed_relational'
1236
+ : input.plan.capability === 'bounded_exploration' ? 'exploratory_sql'
1237
+ : undefined;
1238
+ const frozen = input.reconciliation.outcome === 'ready' && input.plan.mode === 'authoritative';
1239
+ const tier = (name, source, selected) => {
1240
+ const item = coverageFor(source);
1241
+ return {
1242
+ version: 1,
1243
+ tier: name,
1244
+ outcome: selected ? 'executable' : item?.status === 'available' ? 'ineligible' : 'unavailable',
1245
+ candidateIds: item?.candidateIds ?? [],
1246
+ reason: selected ? `The ${name} tier proved the complete requested tuple and froze the plan.` : item?.status === 'available' ? `The ${name} tier was retrieved but did not prove the complete tuple.` : `The ${name} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1247
+ planFrozen: frozen && selected,
1248
+ };
1249
+ };
1250
+ const attempts = [
1251
+ tier('certified', 'certified', selectedTier === 'certified'),
1252
+ tier('semantic', 'semantic', selectedTier === 'semantic'),
1253
+ tier('governed_relational', 'governed_relational', selectedTier === 'governed_relational'),
1254
+ tier('exploratory_sql', 'exploratory', selectedTier === 'exploratory_sql'),
1255
+ ];
1256
+ if (!selectedTier) {
1257
+ attempts.push({ version: 1, tier: 'clarify_or_gap', outcome: input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'unavailable', candidateIds: [], reason: input.reconciliation.reason, planFrozen: false });
1258
+ }
1259
+ return buildAnalyticalCascadeDecision({
1260
+ requirements: buildAnalyticalRequirementSet({ question: input.question, parsedIntent: input.evidence.parsedIntent }),
1261
+ sourceCoverage: coverage,
1262
+ attempts,
1263
+ ...(selectedTier ? { selectedTier } : {}),
1264
+ planFrozen: frozen,
1265
+ stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
1266
+ });
1267
+ }
416
1268
  function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
417
- let planBoundResolution = resolution;
1269
+ // Treat an LLM/direct selection as a nomination, never as permission to
1270
+ // let a certified block borrow a metric from neighboring semantic cards.
1271
+ // This check runs immediately before plan construction so every fast lane,
1272
+ // cached selection, explicit choice, and provider response shares it.
1273
+ let planBoundResolution = withDeclaredFiscalBinding(repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates), evidence, candidates, question);
418
1274
  if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
419
- const { analyticalFrame: sourceFrame, ...resolutionWithoutFrame } = resolution;
1275
+ const sourceFrame = resolution.analyticalFrame;
420
1276
  const bindingPlan = buildResolvedAnalyticalPlan({
421
1277
  question,
422
- resolution: resolutionWithoutFrame,
1278
+ // Keep the frame available while binding its time-axis roles. A
1279
+ // time dimension lives in `capability.timeDimensions`, not the ordinary
1280
+ // group-by list; stripping the frame first made a valid report_date
1281
+ // binding look unresolved after a certified→semantic recovery.
1282
+ resolution,
423
1283
  evidence,
424
1284
  candidates,
425
1285
  mode,
@@ -444,6 +1304,17 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
444
1304
  const analytical = routedResolution.questionType === "diagnosis" ||
445
1305
  routedResolution.questionType === "research";
446
1306
  const reason = reconciliation.reason;
1307
+ const analyticalCascadeDecision = cascadeForResolution({
1308
+ evidence,
1309
+ candidates,
1310
+ resolution: routedResolution,
1311
+ plan: resolvedAnalyticalPlan,
1312
+ reconciliation,
1313
+ question,
1314
+ });
1315
+ const terminalGap = reconciliation.outcome === 'modeling_gap'
1316
+ ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
1317
+ : undefined;
447
1318
  return {
448
1319
  ...base,
449
1320
  action: needsClarification
@@ -464,6 +1335,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
464
1335
  depth: analytical ? "deep" : "quick",
465
1336
  meaningResolution: routedResolution,
466
1337
  resolvedAnalyticalPlan,
1338
+ analyticalCascadeDecision,
467
1339
  retrievalEvidence: retrievalTrace(evidence, candidates),
468
1340
  requiresClarification: needsClarification,
469
1341
  ...(terminallyBlocked
@@ -477,6 +1349,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
477
1349
  : 'ANALYTICAL_MODELING_GAP',
478
1350
  message: reconciliation.reason,
479
1351
  candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
1352
+ ...(terminalGap ? { gap: terminalGap } : {}),
480
1353
  },
481
1354
  }
482
1355
  : {}),
@@ -490,6 +1363,151 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
490
1363
  : {}),
491
1364
  };
492
1365
  }
1366
+ /**
1367
+ * A block may be relevant to a metric without returning that metric. When a
1368
+ * stale catalog result or provider selection nominates such a block, continue
1369
+ * with one exact semantic definition when it exists; otherwise preserve the
1370
+ * genuine metric ambiguity as stable semantic choices. Never construct a
1371
+ * certified plan whose output contract merely borrows a neighbor's identity.
1372
+ */
1373
+ function repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates) {
1374
+ if (resolution.recommendedRoute !== 'certified')
1375
+ return resolution;
1376
+ const selected = resolution.recommendedExecutionId
1377
+ ? candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId)
1378
+ : candidates.find((candidate) => resolution.selectedConceptIds.includes(candidate.id));
1379
+ const requestedMeasures = evidence.parsedIntent?.measures?.length
1380
+ ? evidence.parsedIntent.measures
1381
+ : resolution.queryIntent.measures;
1382
+ if (!selected || selected.kind !== 'certified_block'
1383
+ || certifiedCandidateExplicitlyCoversMeasures(selected, requestedMeasures))
1384
+ return resolution;
1385
+ const compatibleMetrics = candidates.filter((candidate) => candidate.kind === 'semantic_metric'
1386
+ && candidate.compatibility === 'compatible'
1387
+ && requestedMeasures.length > 0
1388
+ && requestedMeasures.every((requested) => candidateProvesMetricTerm(candidate, requested))
1389
+ && Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability));
1390
+ const exactMetrics = compatibleMetrics.filter((candidate) => requestedMeasures.every((requested) => semanticMetricIdentityExactlyMatches(candidate, requested)));
1391
+ if (exactMetrics.length === 1) {
1392
+ const metric = exactMetrics[0];
1393
+ return {
1394
+ ...resolution,
1395
+ selectedConceptIds: [metric.id],
1396
+ recommendedExecutionId: metric.id,
1397
+ recommendedRoute: 'semantic',
1398
+ confidence: resolution.confidence === 'low' ? 'medium' : resolution.confidence,
1399
+ // The frame is a typed interpretation of the requested tuple, not a
1400
+ // claim that the rejected certified block can execute it. Preserve it
1401
+ // when the exact semantic metric owns the same request so the frozen
1402
+ // plan retains its V2 time/comparison/ranking contract. Clearing it
1403
+ // here downgraded a safe semantic recovery into a legacy V1 blocked
1404
+ // plan solely because the original certified nomination was incomplete.
1405
+ analyticalFrame: resolution.analyticalFrame,
1406
+ missingInformation: [...new Set([
1407
+ ...resolution.missingInformation,
1408
+ `${selected.name} does not declare ${requestedMeasures.join(', ')} as an output; continued with the exact semantic metric ${metric.name}.`,
1409
+ ])],
1410
+ };
1411
+ }
1412
+ const choices = compatibleMetrics.map((candidate) => candidate.id);
1413
+ return {
1414
+ ...resolution,
1415
+ selectedConceptIds: [],
1416
+ recommendedExecutionId: undefined,
1417
+ recommendedRoute: 'clarify',
1418
+ confidence: 'low',
1419
+ analyticalFrame: undefined,
1420
+ compatibilityOutcome: 'clarify',
1421
+ compatibilityFailures: [{
1422
+ code: 'CERTIFIED_MEASURE_OUTPUT_MISSING',
1423
+ field: 'measure',
1424
+ message: `${selected.name} does not declare ${requestedMeasures.join(', ')} as an output.`,
1425
+ candidateIds: choices,
1426
+ }],
1427
+ missingInformation: [...new Set([
1428
+ ...resolution.missingInformation,
1429
+ choices.length > 1
1430
+ ? `The selected certified block does not declare ${requestedMeasures.join(', ')}. Choose among the compatible semantic metric definitions.`
1431
+ : `The selected certified block does not declare ${requestedMeasures.join(', ')} as an output.`,
1432
+ ])],
1433
+ clarifyingQuestion: choices.length > 1
1434
+ ? 'Which compatible semantic metric should DQL use?'
1435
+ : resolution.clarifyingQuestion,
1436
+ };
1437
+ }
1438
+ function semanticMetricIdentityExactlyMatches(candidate, requested) {
1439
+ const requestedIdentity = normalizeMetricPhrase(requested);
1440
+ if (!requestedIdentity)
1441
+ return false;
1442
+ return [candidate.name, ...(candidate.aliases ?? []), candidate.qualifiedId ?? candidate.id]
1443
+ .map((identity) => normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity))
1444
+ .some((identity) => identity === requestedIdentity);
1445
+ }
1446
+ /**
1447
+ * Clarification is an execution-affecting choice, not a generic search result
1448
+ * picker. In particular, a block tagged with revenue may be useful context
1449
+ * but cannot be offered for an explicit revenue request unless that block's
1450
+ * own output contract declares revenue. Applying this at the option boundary
1451
+ * keeps a stale pooled candidate from becoming a later structured bypass.
1452
+ */
1453
+ function clarificationRequirementsForResolution(resolution) {
1454
+ const question = resolution.interpretedQuestion;
1455
+ const questionTerms = normalizeMetricPhrase(question);
1456
+ // A parser/result frame can carry inherited technical hints that do not
1457
+ // belong to the user's current wording. They are useful to planning, but
1458
+ // must not turn an unrelated structured option into an invalid selection.
1459
+ // Keep only parser measures the question actually says, then add the
1460
+ // resolver's own typed metric frame when it exists.
1461
+ const mentionedMeasures = resolution.queryIntent.measures.filter((measure) => {
1462
+ const normalized = normalizeMetricPhrase(measure);
1463
+ const tokens = normalized.split(' ').filter((token) => token.length >= 3);
1464
+ return tokens.length > 0 && tokens.every((token) => questionTerms.includes(token));
1465
+ });
1466
+ const frameMeasures = resolution.analyticalFrame?.metricConceptIds.map((id) => id.split(/[.:/]/).filter(Boolean).at(-1) ?? id) ?? [];
1467
+ return buildAnalyticalRequirementSet({
1468
+ question,
1469
+ parsedIntent: {
1470
+ ...resolution.queryIntent,
1471
+ measures: [...new Set([...mentionedMeasures, ...frameMeasures])],
1472
+ },
1473
+ });
1474
+ }
1475
+ function explicitMeasureTermsForClarification(requirements) {
1476
+ return [...new Set([
1477
+ ...(requirements?.measures ?? []),
1478
+ ...(requirements?.ranking?.metricTerms ?? []),
1479
+ ].map((term) => normalizeMetricPhrase(term.split(/[.:/]/).at(-1) ?? term)).filter(Boolean))];
1480
+ }
1481
+ function candidateOwnsExplicitClarificationRoles(candidate, requirements) {
1482
+ if (candidate.eligible === false || candidate.compatibility === 'incompatible')
1483
+ return false;
1484
+ const measures = explicitMeasureTermsForClarification(requirements);
1485
+ if (measures.length === 0)
1486
+ return true;
1487
+ if (candidate.kind === 'certified_block') {
1488
+ return certifiedCandidateExplicitlyCoversMeasures(candidate, measures);
1489
+ }
1490
+ // A structured choice for a measure must be the metric itself. An entity,
1491
+ // member, model, or physical relation can help compile the chosen metric but
1492
+ // cannot become its meaning merely because it happened to rank highly.
1493
+ // A semantic metric is a valid *meaning* choice when it owns at least one
1494
+ // explicitly requested metric. Multi-metric plans still cannot freeze until
1495
+ // every requested measure is bound by the immutable plan; this narrower
1496
+ // option check lets a user choose between the revenue/refunds definitions
1497
+ // without pretending the single metric already answers the whole tuple.
1498
+ return candidate.kind === 'semantic_metric'
1499
+ && measures.some((measure) => candidateProvesMetricTerm(candidate, measure));
1500
+ }
1501
+ function compatibleClarificationCandidates(candidates, requirements) {
1502
+ return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements));
1503
+ }
1504
+ function withoutMeasureClarificationRequirements(requirements) {
1505
+ const { ranking: _ranking, ...withoutRanking } = requirements;
1506
+ return {
1507
+ ...withoutRanking,
1508
+ measures: [],
1509
+ };
1510
+ }
493
1511
  /**
494
1512
  * The immutable RAP is the final routing authority. Meaning may nominate an
495
1513
  * execution route, but cannot leave the router claiming an answer after the
@@ -509,6 +1527,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
509
1527
  ].filter(({ binding }) => binding.status !== 'resolved');
510
1528
  const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
511
1529
  const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
1530
+ const clarificationRequirements = clarificationRequirementsForResolution(resolution);
512
1531
  if (plan.resolutionFailure?.outcome === 'policy_blocked') {
513
1532
  return {
514
1533
  outcome: 'policy_blocked',
@@ -525,9 +1544,19 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
525
1544
  const optionIds = qualifiedChoiceIds.length > 0
526
1545
  ? qualifiedChoiceIds
527
1546
  : [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
1547
+ const unresolvedMeasure = bindings.some(({ kind }) => kind === 'measure');
1548
+ const optionRequirements = unresolvedMeasure
1549
+ ? clarificationRequirements
1550
+ : withoutMeasureClarificationRequirements(clarificationRequirements);
528
1551
  const options = optionIds.length > 0
529
- ? clarificationOptionsForQualifiedIds(optionIds, candidates)
530
- : buildClarificationOptions(candidates);
1552
+ ? clarificationOptionsForQualifiedIds(optionIds, candidates, optionRequirements)
1553
+ : buildClarificationOptions(candidates, optionRequirements);
1554
+ if (options.length === 0) {
1555
+ return {
1556
+ outcome: 'modeling_gap',
1557
+ reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
1558
+ };
1559
+ }
531
1560
  const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
532
1561
  const question = routedClarificationQuestion(resolution, bindings, options);
533
1562
  return {
@@ -538,11 +1567,18 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
538
1567
  };
539
1568
  }
540
1569
  if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
1570
+ const options = buildClarificationOptions(candidates, clarificationRequirements);
1571
+ if (options.length === 0) {
1572
+ return {
1573
+ outcome: 'modeling_gap',
1574
+ reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
1575
+ };
1576
+ }
541
1577
  return {
542
1578
  outcome: 'clarify',
543
1579
  reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
544
1580
  question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
545
- options: buildClarificationOptions(candidates),
1581
+ options,
546
1582
  };
547
1583
  }
548
1584
  return {
@@ -550,12 +1586,85 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
550
1586
  reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
551
1587
  };
552
1588
  }
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);
1589
+ /**
1590
+ * Keep a specific terminal gap only when its producer supplied a typed reason.
1591
+ * A generic modeling gap must never become a relationship-gap claim because a
1592
+ * later renderer happened to mention joins in its repair copy.
1593
+ */
1594
+ function terminalGapWitnessForResolutionFailure(plan) {
1595
+ const failure = plan.resolutionFailure;
1596
+ if (!failure || failure.outcome !== 'modeling_gap')
1597
+ return undefined;
1598
+ const codes = new Set(failure.codes);
1599
+ const unresolved = [...new Set(failure.bindings
1600
+ .filter((binding) => binding.status !== 'resolved')
1601
+ .map((binding) => binding.requested)
1602
+ .filter(Boolean))];
1603
+ const witnessCandidateIds = [...new Set([
1604
+ ...failure.candidateIds,
1605
+ ...(failure.selectedCapabilityId ? [failure.selectedCapabilityId] : []),
1606
+ ...(failure.selectedExecutionId ? [failure.selectedExecutionId] : []),
1607
+ ])].sort();
1608
+ if (codes.has('RELATIONSHIP_PROOF_MISSING')) {
1609
+ return {
1610
+ code: 'MISSING_RELATIONSHIP',
1611
+ missing: ['a certified, validated, fanout-safe relationship proof'],
1612
+ witnessCandidateIds,
1613
+ };
1614
+ }
1615
+ if (codes.has('METRIC_CAPABILITY_MISSING') || codes.has('MISSING_MEASURE')) {
1616
+ return {
1617
+ code: 'MISSING_MEASURE',
1618
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed measure'],
1619
+ witnessCandidateIds,
1620
+ };
1621
+ }
1622
+ if (codes.has('MEMBER_FILTER_UNSUPPORTED') || codes.has('MISSING_ATTRIBUTE')) {
1623
+ return {
1624
+ code: 'MISSING_ATTRIBUTE',
1625
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed attribute or member filter'],
1626
+ witnessCandidateIds,
1627
+ };
1628
+ }
1629
+ if ([
1630
+ 'DIMENSION_ROLE_UNSUPPORTED',
1631
+ 'TIME_DIMENSION_REQUIRED',
1632
+ 'TIME_DIMENSION_AMBIGUOUS',
1633
+ 'TIME_ROLE_UNSUPPORTED',
1634
+ 'TIME_GRAIN_UNSUPPORTED',
1635
+ 'MISSING_DIMENSION',
1636
+ ].some((code) => codes.has(code))) {
1637
+ return {
1638
+ code: 'MISSING_DIMENSION',
1639
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed dimension or time role'],
1640
+ witnessCandidateIds,
1641
+ };
1642
+ }
1643
+ return {
1644
+ code: 'MISSING_RUNTIME_CAPABILITY',
1645
+ missing: unresolved.length > 0 ? unresolved : ['the complete requested analytical tuple'],
1646
+ witnessCandidateIds,
1647
+ };
1648
+ }
1649
+ function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
1650
+ const requireArtifactLocalMeasureProof = explicitMeasureTermsForClarification(requirements).length > 0;
1651
+ return ids
1652
+ .map((id) => ({
1653
+ id,
1654
+ candidate: candidates.find((item) => item.id === id || item.qualifiedId === id),
1655
+ }))
1656
+ .filter(({ candidate }) => candidate
1657
+ ? candidateOwnsExplicitClarificationRoles(candidate, requirements)
1658
+ // Qualified capability dimensions are synthesized by the resolved
1659
+ // plan, not necessarily returned as standalone retrieval cards. They
1660
+ // remain valid choices for a dimension/filter clarification, but never
1661
+ // for an explicit metric role whose local artifact proof is missing.
1662
+ : !requireArtifactLocalMeasureProof)
1663
+ .slice(0, 3)
1664
+ .map(({ id, candidate }) => {
556
1665
  return {
557
- id,
558
- label: candidate?.name ?? qualifiedIdLabel(id),
1666
+ id: candidate?.id ?? id,
1667
+ label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
559
1668
  ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
560
1669
  kind: candidate?.kind ?? 'semantic_member',
561
1670
  };
@@ -577,17 +1686,122 @@ function routedClarificationQuestion(resolution, bindings, options) {
577
1686
  return resolution.clarifyingQuestion
578
1687
  ?? `Which governed binding should I use before running this query?`;
579
1688
  }
580
- function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
1689
+ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question) {
1690
+ // Preserve the exact stable ID emitted by the clarification option. A
1691
+ // candidate may also carry a source-qualified execution alias, but replacing
1692
+ // the clicked ID here makes the persisted ambiguity contract and the later
1693
+ // diagnostic receipt disagree even though the user chose a legitimate item.
1694
+ const selectedId = selected.id;
1695
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
1696
+ const requiredPhysicalFieldTerms = [
1697
+ ...(evidence.parsedIntent?.dimensions ?? []),
1698
+ ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
1699
+ ];
1700
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, candidates, requirements.dimensions, requiredPhysicalFieldTerms);
1701
+ const coverage = sourceCoverageFromEvidence(evidence, candidates);
1702
+ const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
1703
+ const attempts = [
1704
+ {
1705
+ version: 1,
1706
+ tier: 'certified',
1707
+ outcome: 'ineligible',
1708
+ candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [],
1709
+ reason: `The explicit selection ${selectedId} did not prove a complete certified tuple.`,
1710
+ planFrozen: false,
1711
+ },
1712
+ {
1713
+ version: 1,
1714
+ tier: 'semantic',
1715
+ outcome: 'ineligible',
1716
+ candidateIds: [...new Set([selectedId, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
1717
+ reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
1718
+ planFrozen: false,
1719
+ },
1720
+ {
1721
+ version: 1,
1722
+ tier: 'governed_relational',
1723
+ outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
1724
+ candidateIds: governedCoverage?.candidateIds ?? [],
1725
+ reason: governedCoverage?.status === 'available'
1726
+ ? 'Retrieved governed relationship evidence did not prove a complete relational plan.'
1727
+ : `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
1728
+ planFrozen: false,
1729
+ },
1730
+ {
1731
+ version: 1,
1732
+ tier: 'exploratory_sql',
1733
+ outcome: physicalPath.ok ? 'executable' : 'unavailable',
1734
+ candidateIds: physicalPath.candidateIds,
1735
+ reason: physicalPath.reason,
1736
+ planFrozen: false,
1737
+ },
1738
+ ];
1739
+ const message = physicalPath.ok
1740
+ ? `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact; a same-snapshot qualified physical path is available for review-required exploratory SQL.`
1741
+ : `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and will not substitute a correlated metric or execute generated SQL because ${physicalPath.reason}`;
1742
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
1743
+ requirements,
1744
+ sourceCoverage: coverage,
1745
+ attempts: physicalPath.ok
1746
+ ? attempts
1747
+ : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: [selectedId], reason: message, planFrozen: false }],
1748
+ ...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
1749
+ planFrozen: false,
1750
+ stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
1751
+ });
1752
+ const meaningResolution = {
1753
+ interpretedQuestion: question,
1754
+ questionType: 'value',
1755
+ selectedConceptIds: [selectedId],
1756
+ queryIntent: defaultQueryIntent(evidence),
1757
+ rejectedCandidates: [],
1758
+ confidence: 'low',
1759
+ missingInformation: [message],
1760
+ recommendedRoute: physicalPath.ok ? 'exploratory' : 'clarify',
1761
+ compatibilityOutcome: 'modeling_gap',
1762
+ compatibilityFailures: [{
1763
+ code: 'INCOMPLETE_SELECTED_CAPABILITY',
1764
+ field: 'selected capability',
1765
+ message,
1766
+ candidateIds: [selectedId],
1767
+ }],
1768
+ };
1769
+ if (!physicalPath.ok) {
1770
+ return {
1771
+ ...base,
1772
+ action: 'block',
1773
+ confidence: 1,
1774
+ reason: message,
1775
+ source: 'heuristic',
1776
+ category: 'data_lookup',
1777
+ depth: 'quick',
1778
+ retrievalEvidence: retrievalTrace(evidence, candidates),
1779
+ requiresClarification: false,
1780
+ resolvedAnalyticalPlan: undefined,
1781
+ meaningResolution,
1782
+ analyticalCascadeDecision,
1783
+ terminalOutcome: {
1784
+ kind: 'modeling_gap',
1785
+ code: 'ANALYTICAL_MODELING_GAP',
1786
+ message,
1787
+ candidateIds: [selectedId],
1788
+ ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
1789
+ },
1790
+ };
1791
+ }
581
1792
  return {
582
1793
  ...base,
583
1794
  action: 'answer',
584
- confidence: 0.78,
585
- reason: `The user selected ${selected.name}, but that evidence does not prove the complete requested metric, dimension, filter, and grain tuple. The selection is consumed once; continue through the governed semantic/SQL cascade without dropping any requested part.`,
1795
+ confidence: 0.55,
1796
+ reason: message,
586
1797
  source: 'heuristic',
587
1798
  category: 'data_lookup',
588
1799
  depth: 'quick',
589
1800
  retrievalEvidence: retrievalTrace(evidence, candidates),
590
1801
  requiresClarification: false,
1802
+ resolvedAnalyticalPlan: undefined,
1803
+ meaningResolution,
1804
+ analyticalCascadeDecision,
591
1805
  };
592
1806
  }
593
1807
  function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
@@ -702,14 +1916,15 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
702
1916
  "The requested analytical tuple is not executable from the current governed model."),
703
1917
  };
704
1918
  }
705
- function buildClarificationOptions(candidates) {
706
- const governed = candidates.filter((candidate) => candidate.compatibility !== "incompatible" &&
1919
+ function buildClarificationOptions(candidates, requirements) {
1920
+ const roleCompatible = compatibleClarificationCandidates(candidates, requirements);
1921
+ const governed = roleCompatible.filter((candidate) => candidate.compatibility !== "incompatible" &&
707
1922
  (candidate.kind === "certified_block" ||
708
1923
  candidate.kind === "semantic_metric" ||
709
1924
  candidate.kind === "semantic_member"));
710
1925
  const pool = governed.length > 1
711
1926
  ? governed
712
- : candidates.filter((candidate) => candidate.compatibility !== "incompatible");
1927
+ : roleCompatible;
713
1928
  const chosen = pool.slice(0, 3);
714
1929
  // Two candidates can legitimately share a display name (a dbt model and its
715
1930
  // MetricFlow measure are both "customers"). Rendering both as "customers"
@@ -732,6 +1947,301 @@ function buildClarificationOptions(candidates) {
732
1947
  };
733
1948
  });
734
1949
  }
1950
+ /**
1951
+ * The server envelope persists the option/requirement contract that rendered a
1952
+ * clarification. Browser context is never authority: the local runtime adds
1953
+ * a host-only binding only after reading the persisted thread. Current snapshot
1954
+ * compatibility remains mandatory, so a client cannot forge an old option list
1955
+ * to admit an unrelated artifact.
1956
+ */
1957
+ function persistedClarificationSelectionContext(request) {
1958
+ const context = request.conversationContext;
1959
+ const envelope = context?.conversationEnvelope && typeof context.conversationEnvelope === 'object'
1960
+ ? context.conversationEnvelope
1961
+ : context?.serverSnapshot && typeof context.serverSnapshot === 'object'
1962
+ ? context.serverSnapshot
1963
+ : undefined;
1964
+ const pending = envelope?.pendingClarification;
1965
+ if (!pending || typeof pending !== 'object')
1966
+ return undefined;
1967
+ const pendingRecord = pending;
1968
+ const selection = pendingRecord.selection;
1969
+ if (!selection || typeof selection !== 'object')
1970
+ return undefined;
1971
+ const record = selection;
1972
+ const ids = (value) => Array.isArray(value)
1973
+ ? [...new Set(value.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
1974
+ : [];
1975
+ const requirements = normalizePersistedClarificationRequirements(record.requirements);
1976
+ const optionIds = ids(record.optionIds);
1977
+ const ambiguityCandidateIds = ids(record.ambiguityCandidateIds);
1978
+ const snapshotId = typeof record.snapshotId === 'string' && record.snapshotId.trim()
1979
+ ? record.snapshotId.trim()
1980
+ : undefined;
1981
+ const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
1982
+ ? pendingRecord.sourceTurnId.trim()
1983
+ : undefined;
1984
+ const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
1985
+ ? envelope.threadId.trim()
1986
+ : undefined;
1987
+ const authority = context?.serverIssuedClarificationSelection;
1988
+ const authorityRecord = authority && typeof authority === 'object' && !Array.isArray(authority)
1989
+ ? authority
1990
+ : undefined;
1991
+ const authorityMatches = Boolean(authorityRecord?.version === 1
1992
+ && typeof authorityRecord.threadId === 'string'
1993
+ && authorityRecord.threadId === threadId
1994
+ && typeof authorityRecord.sourceTurnId === 'string'
1995
+ && authorityRecord.sourceTurnId === sourceTurnId
1996
+ && typeof authorityRecord.snapshotId === 'string'
1997
+ && authorityRecord.snapshotId === snapshotId);
1998
+ const invalidReason = record.version !== 1
1999
+ ? 'The structured selection envelope has an unsupported version.'
2000
+ : !request.threadId || !threadId || threadId !== request.threadId
2001
+ ? 'The structured selection is not bound to the active server conversation thread.'
2002
+ : !sourceTurnId
2003
+ ? 'The structured selection is missing its server turn binding.'
2004
+ : optionIds.length === 0
2005
+ ? 'The structured selection is missing the options rendered by the server.'
2006
+ : !requirements
2007
+ ? 'The structured selection is missing its typed analytical requirements.'
2008
+ : !snapshotId
2009
+ ? 'The structured selection is missing its retrieval snapshot binding.'
2010
+ : !authorityMatches
2011
+ ? 'The structured selection was not issued by the active server conversation.'
2012
+ : undefined;
2013
+ return {
2014
+ optionIds,
2015
+ ambiguityCandidateIds,
2016
+ ...(requirements ? { requirements } : {}),
2017
+ ...(snapshotId ? { snapshotId } : {}),
2018
+ ...(sourceTurnId ? { sourceTurnId } : {}),
2019
+ ...(threadId ? { threadId } : {}),
2020
+ serverIssued: !invalidReason,
2021
+ ...(invalidReason ? { invalidReason } : {}),
2022
+ };
2023
+ }
2024
+ /**
2025
+ * The persisted envelope crosses a client/server boundary. It is only a
2026
+ * reject-only continuity hint, but it must still be parsed as data rather than
2027
+ * cast as an executable typed requirement set. In particular, a malformed
2028
+ * string must never be spread into one-character "measures" and weaken the
2029
+ * selected-ID validation path.
2030
+ */
2031
+ function normalizePersistedClarificationRequirements(value) {
2032
+ if (!value || typeof value !== 'object' || Array.isArray(value))
2033
+ return undefined;
2034
+ const record = value;
2035
+ if (record.version !== 1)
2036
+ return undefined;
2037
+ const terms = (input) => Array.isArray(input)
2038
+ ? [...new Set(input.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
2039
+ : [];
2040
+ const rankingRecord = record.ranking && typeof record.ranking === 'object' && !Array.isArray(record.ranking)
2041
+ ? record.ranking
2042
+ : undefined;
2043
+ const timeRecord = record.time && typeof record.time === 'object' && !Array.isArray(record.time)
2044
+ ? record.time
2045
+ : undefined;
2046
+ const direction = rankingRecord?.direction === 'bottom' ? 'bottom' : rankingRecord?.direction === 'top' ? 'top' : undefined;
2047
+ const limit = typeof rankingRecord?.limit === 'number' && Number.isFinite(rankingRecord.limit) && rankingRecord.limit > 0
2048
+ ? Math.floor(rankingRecord.limit)
2049
+ : undefined;
2050
+ const grain = timeRecord?.grain;
2051
+ const safeGrain = grain === 'day' || grain === 'week' || grain === 'month' || grain === 'quarter' || grain === 'year'
2052
+ ? grain
2053
+ : undefined;
2054
+ const timeRole = timeRecord?.role === 'time_axis' || timeRecord?.role === 'time_filter'
2055
+ ? timeRecord.role
2056
+ : undefined;
2057
+ const fiscalPeriod = typeof timeRecord?.fiscalPeriod === 'string' && timeRecord.fiscalPeriod.trim()
2058
+ ? timeRecord.fiscalPeriod.trim()
2059
+ : undefined;
2060
+ return {
2061
+ version: 1,
2062
+ measures: terms(record.measures),
2063
+ dimensions: terms(record.dimensions),
2064
+ entityTerms: terms(record.entityTerms),
2065
+ entityDisplayTerms: terms(record.entityDisplayTerms),
2066
+ memberTerms: terms(record.memberTerms),
2067
+ ...(direction && limit !== undefined
2068
+ ? {
2069
+ ranking: {
2070
+ metricTerms: terms(rankingRecord?.metricTerms),
2071
+ entityTerms: terms(rankingRecord?.entityTerms),
2072
+ direction,
2073
+ limit,
2074
+ defaultedLimit: rankingRecord?.defaultedLimit === true,
2075
+ },
2076
+ }
2077
+ : {}),
2078
+ ...(timeRole
2079
+ ? {
2080
+ time: {
2081
+ role: timeRole,
2082
+ ...(safeGrain ? { grain: safeGrain } : {}),
2083
+ ...(fiscalPeriod ? { fiscalPeriod } : {}),
2084
+ requiresDeclaredFiscalCalendar: timeRecord?.requiresDeclaredFiscalCalendar === true,
2085
+ },
2086
+ }
2087
+ : {}),
2088
+ };
2089
+ }
2090
+ function mergeClarificationRequirements(current, persisted) {
2091
+ if (!persisted)
2092
+ return current;
2093
+ // Persisted requirements are only a reject-only continuity check. Unioning
2094
+ // explicitly requested roles means an old selection cannot weaken the new
2095
+ // typed interpretation even if a caller fabricated its envelope.
2096
+ return {
2097
+ ...current,
2098
+ measures: [...new Set([...current.measures, ...(persisted.measures ?? [])])],
2099
+ dimensions: [...new Set([...current.dimensions, ...(persisted.dimensions ?? [])])],
2100
+ entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
2101
+ entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
2102
+ memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
2103
+ };
2104
+ }
2105
+ function validateStructuredClarificationSelection(input) {
2106
+ const persisted = persistedClarificationSelectionContext(input.request);
2107
+ const sourceQuestion = input.request.clarificationSourceQuestion?.trim() || input.request.question;
2108
+ const requirements = mergeClarificationRequirements(
2109
+ // A click/reload continuation is an identity action. Use the original user
2110
+ // question (and, when available, the server-persisted typed contract), not
2111
+ // arbitrary inherited parser hints from an unrelated previous turn.
2112
+ buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.requirements);
2113
+ const compatible = compatibleClarificationCandidates(input.candidates, requirements);
2114
+ const choiceIds = compatible.map((candidate) => candidate.id);
2115
+ if (!persisted?.serverIssued) {
2116
+ return {
2117
+ ok: false,
2118
+ requirements,
2119
+ choiceIds,
2120
+ reason: persisted?.invalidReason
2121
+ ?? 'A structured selection requires a server-issued clarification envelope.',
2122
+ };
2123
+ }
2124
+ if (persisted.snapshotId !== input.evidence.snapshotId) {
2125
+ return {
2126
+ ok: false,
2127
+ requirements,
2128
+ choiceIds,
2129
+ reason: 'The structured selection belongs to a stale retrieval snapshot and must be chosen again.',
2130
+ };
2131
+ }
2132
+ if (!input.selected) {
2133
+ return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
2134
+ }
2135
+ if (!candidateOwnsExplicitClarificationRoles(input.selected, requirements)) {
2136
+ return {
2137
+ ok: false,
2138
+ requirements,
2139
+ choiceIds,
2140
+ reason: `The selected artifact ${input.selected.id} does not own every explicitly requested analytical role.`,
2141
+ };
2142
+ }
2143
+ const persistedChoices = new Set([
2144
+ ...(persisted?.optionIds ?? []),
2145
+ ...(persisted?.ambiguityCandidateIds ?? []),
2146
+ ]);
2147
+ if (!persistedChoices.has(input.selected.id)
2148
+ && !persistedChoices.has(input.selected.qualifiedId ?? '')) {
2149
+ return {
2150
+ ok: false,
2151
+ requirements,
2152
+ choiceIds,
2153
+ reason: 'The selected governed identifier was not one of the persisted ambiguity choices for this question.',
2154
+ };
2155
+ }
2156
+ if (choiceIds.length > 0 && !choiceIds.includes(input.selected.id)) {
2157
+ return {
2158
+ ok: false,
2159
+ requirements,
2160
+ choiceIds,
2161
+ reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
2162
+ };
2163
+ }
2164
+ return { ok: true, requirements, choiceIds };
2165
+ }
2166
+ function invalidStructuredSelectionDecision(input) {
2167
+ const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
2168
+ const persisted = persistedClarificationSelectionContext(input.request);
2169
+ const persistedOptionIds = [...new Set([
2170
+ ...(persisted?.serverIssued ? persisted.optionIds : []),
2171
+ ...(persisted?.serverIssued ? persisted.ambiguityCandidateIds : []),
2172
+ ])];
2173
+ // Re-render only the original server-persisted ambiguity set when it exists.
2174
+ // A rejected click cannot silently widen into a fresh block/metric choice
2175
+ // that was never offered for this question; each remaining item is still
2176
+ // filtered against current snapshot eligibility and artifact-local roles.
2177
+ const options = persistedOptionIds.length > 0
2178
+ ? clarificationOptionsForQualifiedIds(persistedOptionIds, input.candidates, input.validation.requirements)
2179
+ : buildClarificationOptions(input.candidates, input.validation.requirements);
2180
+ const candidateIds = input.validation.choiceIds;
2181
+ const message = `${input.validation.reason} DQL did not freeze or execute a plan for that selection.`;
2182
+ const decision = buildAnalyticalCascadeDecision({
2183
+ requirements: input.validation.requirements,
2184
+ sourceCoverage: coverage,
2185
+ attempts: [
2186
+ { version: 1, tier: 'certified', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [], reason: 'No selected certified artifact proved the requested tuple.', planFrozen: false },
2187
+ { version: 1, tier: 'semantic', outcome: 'ambiguous', candidateIds, reason: 'The selected identifier was rejected before semantic ambiguity could be resolved.', planFrozen: false },
2188
+ { version: 1, tier: 'governed_relational', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'governed_relational')?.candidateIds ?? [], reason: 'A structured selection cannot authorize relational execution while its meaning is invalid.', planFrozen: false },
2189
+ { version: 1, tier: 'exploratory_sql', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'exploratory')?.candidateIds ?? [], reason: 'A structured selection cannot authorize exploratory execution while its meaning is invalid.', planFrozen: false },
2190
+ { version: 1, tier: 'clarify_or_gap', outcome: options.length > 0 ? 'ambiguous' : 'unavailable', candidateIds, reason: message, planFrozen: false },
2191
+ ],
2192
+ planFrozen: false,
2193
+ stopReason: options.length > 0 ? 'ambiguous' : 'coverage_gap',
2194
+ });
2195
+ if (options.length === 0) {
2196
+ return {
2197
+ ...input.base,
2198
+ action: 'block',
2199
+ confidence: 1,
2200
+ source: 'heuristic',
2201
+ category: 'data_lookup',
2202
+ depth: 'quick',
2203
+ reason: message,
2204
+ requiresClarification: false,
2205
+ retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
2206
+ terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds },
2207
+ analyticalCascadeDecision: decision,
2208
+ resolvedAnalyticalPlan: undefined,
2209
+ meaningResolution: undefined,
2210
+ };
2211
+ }
2212
+ return {
2213
+ ...input.base,
2214
+ action: 'clarify',
2215
+ confidence: 1,
2216
+ source: 'heuristic',
2217
+ category: 'unclear',
2218
+ depth: 'quick',
2219
+ reason: message,
2220
+ requiresClarification: true,
2221
+ clarifyingQuestion: 'That selection does not match the requested analytical meaning. Which compatible governed metric should DQL use?',
2222
+ clarificationOptions: options,
2223
+ retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
2224
+ analyticalCascadeDecision: decision,
2225
+ resolvedAnalyticalPlan: undefined,
2226
+ meaningResolution: {
2227
+ interpretedQuestion: input.request.question,
2228
+ questionType: questionTypeFromText(input.request.question),
2229
+ selectedConceptIds: [],
2230
+ queryIntent: defaultQueryIntent(input.evidence),
2231
+ rejectedCandidates: [{ id: input.selectedId, reason: input.validation.reason }],
2232
+ confidence: 'low',
2233
+ missingInformation: [message],
2234
+ recommendedRoute: 'clarify',
2235
+ compatibilityOutcome: 'clarify',
2236
+ compatibilityFailures: [{
2237
+ code: 'INVALID_STRUCTURED_SELECTION',
2238
+ field: 'selection',
2239
+ message: input.validation.reason,
2240
+ candidateIds,
2241
+ }],
2242
+ },
2243
+ };
2244
+ }
735
2245
  function candidateKindLabel(kind) {
736
2246
  if (kind === 'certified_block')
737
2247
  return 'certified block';
@@ -848,9 +2358,10 @@ function rankingMetricChoiceDecision(base, evidence, candidates, selected, quest
848
2358
  && candidate.kind === 'semantic_metric'
849
2359
  && !isDegenerateRankingMetric(question, evidence, candidate))
850
2360
  .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';
2361
+ if (options.length === 0) {
2362
+ return unanswerableClarificationFallback(base, retrievalTrace(evidence, candidates), `${selected.name} counts customers and cannot rank them, and no alternative governed measure was retrieved, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.`);
2363
+ }
2364
+ const labels = options.map((candidate) => renderCandidateChoice(candidate)).join(' or ');
854
2365
  return {
855
2366
  ...base,
856
2367
  action: 'clarify',
@@ -862,7 +2373,7 @@ function rankingMetricChoiceDecision(base, evidence, candidates, selected, quest
862
2373
  requiresClarification: true,
863
2374
  reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
864
2375
  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,
2376
+ clarificationOptions: buildClarificationOptions(options),
866
2377
  retrievalEvidence: retrievalTrace(evidence, candidates),
867
2378
  resolvedAnalyticalPlan: undefined,
868
2379
  meaningResolution: undefined,
@@ -919,7 +2430,22 @@ function directResolution(request, evidence, candidate, candidates) {
919
2430
  metricCandidates,
920
2431
  candidates,
921
2432
  });
922
- const queryIntent = defaultQueryIntent(evidence);
2433
+ const defaultIntent = defaultQueryIntent(evidence);
2434
+ // An exact authored certified example has already proved this block's own
2435
+ // output contract. Parser wording such as "food and drink" describes the
2436
+ // values of the block's declared `category` output; it must not manufacture
2437
+ // two literal dimensions and overrule that local contract. Only replace
2438
+ // dimensions when the selected block explicitly declares them, while the
2439
+ // strict requested-measure check remains unchanged.
2440
+ const exactCertifiedExample = candidate.kind === 'certified_block'
2441
+ && candidate.exactMatch
2442
+ && candidate.compatibility === 'compatible'
2443
+ && candidate.analyticalFitClass === 'exact'
2444
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
2445
+ && (candidate.dimensions?.length ?? 0) > 0;
2446
+ const queryIntent = exactCertifiedExample
2447
+ ? { ...defaultIntent, dimensions: candidate.dimensions ?? [] }
2448
+ : defaultIntent;
923
2449
  const memberCandidates = candidates.filter((item) => {
924
2450
  if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
925
2451
  return false;
@@ -1042,11 +2568,25 @@ mayAssumeInterpretation = true) {
1042
2568
  : candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
1043
2569
  if (questionTypeFromText(request.question) === 'ranking'
1044
2570
  && !hasExplicitRankingMeasure(request.question, evidence)) {
2571
+ // Assume the measure where one is clearly indicated, and BIND it through the
2572
+ // same resolution path an explicit selection takes — an assumption that
2573
+ // cannot freeze a plan is refused downstream and surfaces as `blocked` with
2574
+ // no options, which is worse than the question it replaced.
2575
+ const assumed = mayAssumeInterpretation
2576
+ ? assumableRankingMeasure(request.question, rankingCandidates)
2577
+ : undefined;
2578
+ if (assumed) {
2579
+ return {
2580
+ ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumed.candidate, candidates), 'heuristic', request.question, planMode),
2581
+ assumptions: [assumed.assumption],
2582
+ };
2583
+ }
1045
2584
  return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
1046
2585
  }
1047
2586
  const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
1048
2587
  && candidate.compatibility !== "incompatible"
1049
- && rankingCandidates.includes(candidate));
2588
+ && rankingCandidates.includes(candidate)
2589
+ && candidateMayTerminateCertifiedForMeasures(candidate, evidence.parsedIntent?.measures ?? []));
1050
2590
  if (exactCompatible.length === 1 &&
1051
2591
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1052
2592
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
@@ -1060,7 +2600,7 @@ mayAssumeInterpretation = true) {
1060
2600
  // `type: simple` metric, the measure it wraps, and the model that holds them
1061
2601
  // were offered as three competing "meanings" of the same number.
1062
2602
  const best = mayAssumeInterpretation
1063
- ? bestGovernedInterpretation(request.question, rankingCandidates)
2603
+ ? bestGovernedInterpretation(request.question, rankingCandidates, evidence.parsedIntent?.measures ?? [])
1064
2604
  : undefined;
1065
2605
  if (best) {
1066
2606
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
@@ -1219,7 +2759,7 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
1219
2759
  * question made ordinary lookups feel like an interrogation, and most of those
1220
2760
  * questions had no real ambiguity behind them.
1221
2761
  */
1222
- export function bestGovernedInterpretation(question, candidates) {
2762
+ export function bestGovernedInterpretation(question, candidates, requestedMeasures = []) {
1223
2763
  // Take the best candidate that is actually executable, rather than refusing
1224
2764
  // because the top-ranked hit happens to be a descriptive modeling entity.
1225
2765
  // `compatibility: 'unknown'` is common for governed objects that execute
@@ -1229,7 +2769,9 @@ export function bestGovernedInterpretation(question, candidates) {
1229
2769
  return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
1230
2770
  && (candidate.kind === 'certified_block'
1231
2771
  || candidate.kind === 'semantic_metric'
1232
- || candidate.kind === 'semantic_member'));
2772
+ || candidate.kind === 'semantic_member')
2773
+ && (candidate.kind !== 'certified_block'
2774
+ || certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures)));
1233
2775
  }
1234
2776
  /**
1235
2777
  * Retrieval may nominate qualified candidates, but only the resolved plan may
@@ -1247,8 +2789,69 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1247
2789
  candidateCount: 0,
1248
2790
  candidateIds: [],
1249
2791
  };
2792
+ // This generic fallback has no provider/plan-issued role target yet. Build
2793
+ // its strict option roles from the user's words only; inherited parsed-intent
2794
+ // hints can still guide later plan construction but must not hide every
2795
+ // valid compositional or dimension choice.
2796
+ const clarificationRequirements = buildAnalyticalRequirementSet({ question });
2797
+ const physicalRequirements = buildAnalyticalRequirementSet({
2798
+ question,
2799
+ parsedIntent: evidence?.parsedIntent,
2800
+ });
2801
+ const fallbackToPhysicalCascade = () => {
2802
+ if (!evidence)
2803
+ return undefined;
2804
+ const requiredPhysicalFieldTerms = [
2805
+ ...physicalRequirements.dimensions,
2806
+ ...physicalRequirements.entityTerms,
2807
+ ...physicalRequirements.entityDisplayTerms,
2808
+ ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
2809
+ ];
2810
+ return preFreezePhysicalCascadeDecision({
2811
+ base,
2812
+ evidence,
2813
+ candidates,
2814
+ question,
2815
+ requirements: physicalRequirements,
2816
+ missingTerms: physicalRequirements.dimensions,
2817
+ requiredPhysicalFieldTerms,
2818
+ messagePrefix: 'No certified, semantic, or governed relational candidate proved the complete requested tuple before plan freeze.',
2819
+ terminalCandidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id),
2820
+ });
2821
+ };
1250
2822
  if (eligible.length > 1) {
1251
- const choices = eligible.slice(0, 3);
2823
+ // Raw candidates can be complementary rather than competing. Do not ask a
2824
+ // person to pick between the table that supplies the display value and the
2825
+ // table that supplies the requested predicate when their qualified,
2826
+ // same-snapshot relationship proof already closes a safe exploration path.
2827
+ // The helper is deliberately stricter than generic retrieval: missing a
2828
+ // display/predicate role, a shared key, or relationship/fanout proof keeps
2829
+ // this exact clarification path intact.
2830
+ const composition = evidence
2831
+ ? complementaryExploratoryDecision(base, evidence, candidates, question)
2832
+ : undefined;
2833
+ if (composition)
2834
+ return composition;
2835
+ const choices = compatibleClarificationCandidates(eligible, clarificationRequirements).slice(0, 3);
2836
+ if (choices.length === 0) {
2837
+ return fallbackToPhysicalCascade() ?? (() => {
2838
+ const message = 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.';
2839
+ return {
2840
+ ...base,
2841
+ action: 'block',
2842
+ confidence: 1,
2843
+ source: 'heuristic',
2844
+ category: 'data_lookup',
2845
+ depth: 'quick',
2846
+ reason: message,
2847
+ requiresClarification: false,
2848
+ retrievalEvidence: trace,
2849
+ terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id) },
2850
+ resolvedAnalyticalPlan: undefined,
2851
+ meaningResolution: undefined,
2852
+ };
2853
+ })();
2854
+ }
1252
2855
  return {
1253
2856
  ...base,
1254
2857
  action: 'clarify',
@@ -1259,7 +2862,7 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1259
2862
  reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
1260
2863
  requiresClarification: true,
1261
2864
  clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
1262
- clarificationOptions: buildClarificationOptions(choices),
2865
+ clarificationOptions: buildClarificationOptions(choices, clarificationRequirements),
1263
2866
  retrievalEvidence: trace,
1264
2867
  resolvedAnalyticalPlan: undefined,
1265
2868
  meaningResolution: undefined,
@@ -1269,6 +2872,9 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1269
2872
  const message = candidateIds.length === 1
1270
2873
  ? `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
2874
  : 'No governed candidate proved the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability or choose a governed identifier before retrying.';
2875
+ const physicalFallback = fallbackToPhysicalCascade();
2876
+ if (physicalFallback)
2877
+ return physicalFallback;
1272
2878
  return {
1273
2879
  ...base,
1274
2880
  action: 'block',
@@ -1296,6 +2902,10 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1296
2902
  */
1297
2903
  function deterministicPrePlanClarification(request, base, evidence, candidates) {
1298
2904
  const asksForRanking = questionTypeFromText(request.question) === 'ranking';
2905
+ const requirements = buildAnalyticalRequirementSet({
2906
+ question: request.question,
2907
+ parsedIntent: evidence.parsedIntent,
2908
+ });
1299
2909
  const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
1300
2910
  .map(normalizeMetricPhrase)
1301
2911
  .filter(Boolean);
@@ -1304,7 +2914,13 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1304
2914
  || hasStrongQualifiedMetricEvidence(candidates)
1305
2915
  || hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
1306
2916
  const retrievalEvidence = retrievalTrace(evidence, candidates);
1307
- const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
2917
+ // “by month” is a time role/grain, and FY26 is a fiscal-period filter. They
2918
+ // are not requests for literal `month`/`year` columns. A fiscal token still
2919
+ // requires a declared calendar at compilation time; this router simply must
2920
+ // not manufacture one or report an absence before relational/runtime context
2921
+ // has had a chance to bind the declared date role.
2922
+ const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? [])
2923
+ .filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
1308
2924
  const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
1309
2925
  && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
1310
2926
  ? [normalizeMetricPhrase(filter.field)]
@@ -1321,9 +2937,52 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1321
2937
  .sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
1322
2938
  .slice(0, 3);
1323
2939
  if (alternatives.length === 0) {
1324
- if (!asksForRanking || hasExplicitRankingMetric)
2940
+ // Bare rankings need a measure choice, not a dimension gap. Retain the
2941
+ // no-options escape hatch only when the snapshot does not also expose a
2942
+ // typed structural request. For "top customers for perishable products",
2943
+ // a no-option ranking response previously hid the missing safe
2944
+ // customer→order→item→supply closure behind an answer-shaped dead end.
2945
+ // Record that pre-freeze gap instead; this still never invents a ranking
2946
+ // measure or a relationship.
2947
+ if (asksForRanking && !hasExplicitRankingMetric) {
2948
+ const rankingClarification = bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
2949
+ if (rankingClarification.action === 'clarify'
2950
+ && (rankingClarification.clarificationOptions?.length ?? 0) > 0) {
2951
+ return rankingClarification;
2952
+ }
2953
+ return preFreezePhysicalCascadeDecision({
2954
+ base,
2955
+ evidence,
2956
+ candidates,
2957
+ question: request.question,
2958
+ requirements,
2959
+ missingTerms: missingDimensions,
2960
+ requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
2961
+ messagePrefix: `No governed ranking measure and no complete governed tuple proved ${missingDimensions.map((term) => `“${term}”`).join(' and ')} before plan freeze.`,
2962
+ requireRankingMetric: true,
2963
+ });
2964
+ }
2965
+ // Parsed-intent hints can include inherited/default dimensions that the
2966
+ // user never asked for. Only turn a missing field into a product-facing
2967
+ // modeling gap when its wording is present in this turn; otherwise let
2968
+ // bounded meaning resolution preserve its own ambiguity contract.
2969
+ const normalizedQuestion = normalizeMetricPhrase(request.question);
2970
+ if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
1325
2971
  return undefined;
1326
- return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
2972
+ const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
2973
+ const temporalNote = requirements.time?.fiscalPeriod
2974
+ ? ` ${requirements.time.fiscalPeriod} remains an unbound fiscal-period token until a declared calendar is available; DQL will not guess one.`
2975
+ : '';
2976
+ return preFreezePhysicalCascadeDecision({
2977
+ base,
2978
+ evidence,
2979
+ candidates,
2980
+ question: request.question,
2981
+ requirements,
2982
+ missingTerms: missingDimensions,
2983
+ requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
2984
+ messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
2985
+ });
1327
2986
  }
1328
2987
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1329
2988
  const alternativeLabels = alternatives.map(renderCandidateChoice);
@@ -1350,6 +3009,39 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1350
3009
  }
1351
3010
  return undefined;
1352
3011
  }
3012
+ /**
3013
+ * A clarification with NO selectable options is unanswerable, and asking it is a
3014
+ * dead end rather than a safety measure.
3015
+ *
3016
+ * Reported from production: "who are the top customers for BCM" returned
3017
+ * "Top by which governed metric?" with zero choices. Answering it in prose
3018
+ * ("...who have top revenue") produced the IDENTICAL question again, because the
3019
+ * reply carries no `selectedEvidenceId` and re-enters the same path with the
3020
+ * same evidence. A question that can only be answered by clicking a button that
3021
+ * was never rendered loops forever.
3022
+ *
3023
+ * When the option list is empty the failure is in RETRIEVAL, not in the user's
3024
+ * phrasing, so continue into the review-required generated lane and let the
3025
+ * answer carry the caveat. `requiresClarification` is cleared deliberately:
3026
+ * leaving it set would make `answerAnywayRoute` treat this as material ambiguity
3027
+ * and re-block the turn.
3028
+ */
3029
+ function unanswerableClarificationFallback(base, retrievalEvidence, reason) {
3030
+ return {
3031
+ ...base,
3032
+ action: 'answer',
3033
+ confidence: Math.min(base.confidence, 0.5),
3034
+ source: 'heuristic',
3035
+ category: 'data_lookup',
3036
+ requiresClarification: false,
3037
+ clarifyingQuestion: undefined,
3038
+ clarificationOptions: undefined,
3039
+ reason,
3040
+ ...(retrievalEvidence ? { retrievalEvidence } : {}),
3041
+ resolvedAnalyticalPlan: undefined,
3042
+ meaningResolution: undefined,
3043
+ };
3044
+ }
1353
3045
  /**
1354
3046
  * "Top by which governed metric?" with NO choices is a dead end: the asker
1355
3047
  * cannot know which measures are both governed and valid at the ranked grain,
@@ -1365,7 +3057,101 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1365
3057
  *
1366
3058
  * Acceptance: AGT-030.
1367
3059
  */
1368
- function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates) {
3060
+ /**
3061
+ * Generic measure vocabulary: words that say HOW MUCH, never WHICH SUBSET.
3062
+ * A candidate built only from these adds no scope the asker did not state.
3063
+ */
3064
+ const GENERIC_MEASURE_TOKENS = new Set([
3065
+ 'revenue', 'spend', 'spending', 'sale', 'amount', 'value', 'total', 'sum',
3066
+ 'count', 'number', 'order', 'quantity', 'qty', 'price', 'cost', 'profit',
3067
+ 'margin', 'gross', 'net', 'lifetime', 'avg', 'average', 'mean', 'median', 'score',
3068
+ 'rate', 'ratio', 'percent', 'share', 'volume', 'unit', 'balance', 'pretax', 'ltv',
3069
+ 'top', 'rank', 'ranking', 'by', 'per', 'the', 'of', 'and',
3070
+ ]);
3071
+ /**
3072
+ * Crude, symmetric singularization. Applied to BOTH sides, so the only thing
3073
+ * that matters is that it agrees with itself — `address` becoming `addres` is
3074
+ * harmless when the question's `address` becomes `addres` too. Without it a
3075
+ * candidate named `customers.customer_value` is rejected against the question
3076
+ * "who are the top customers", because the singular `customer` is neither a
3077
+ * question word nor generic measure vocabulary. That is a morphology accident,
3078
+ * not an unrequested scope, and it silently refused good assumptions.
3079
+ */
3080
+ function singularize(token) {
3081
+ if (token.length > 3 && token.endsWith('ies'))
3082
+ return `${token.slice(0, -3)}y`;
3083
+ if (token.length > 3 && (token.endsWith('ses') || token.endsWith('xes') || token.endsWith('zes'))) {
3084
+ return token.slice(0, -2);
3085
+ }
3086
+ if (token.length > 2 && token.endsWith('s') && !token.endsWith('ss'))
3087
+ return token.slice(0, -1);
3088
+ return token;
3089
+ }
3090
+ /**
3091
+ * Pick the measure a bare ranking should assume, or nothing.
3092
+ *
3093
+ * Returns the CANDIDATE, not a decision, because the assumption is only useful
3094
+ * where it can be bound: an `action: 'answer'` with no frozen analytical plan is
3095
+ * refused by the plan boundary and reaches the user as `blocked` with no
3096
+ * options, which is strictly worse than the clarification it replaced. Measured
3097
+ * exactly that way before this was moved to a caller that can bind it.
3098
+ */
3099
+ export function assumableRankingMeasure(question, candidates) {
3100
+ // Only a MEASURE can be assumed. A certified block is a whole authored query,
3101
+ // not a ranking measure, so treating one as the answer to "by which metric?"
3102
+ // silently selects someone else's entire analysis.
3103
+ const assumable = candidates.filter((candidate) => (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
3104
+ && candidate.compatibility !== 'incompatible'
3105
+ && rankingCandidateFitsBareQuestion(question, candidate));
3106
+ if (assumable.length === 0)
3107
+ return undefined;
3108
+ const assumption = assumeDominantCandidate({
3109
+ about: 'metric',
3110
+ candidates: assumable.map((candidate) => ({
3111
+ id: candidate.qualifiedId ?? candidate.id,
3112
+ label: candidate.name,
3113
+ score: candidate.relevanceScore,
3114
+ })),
3115
+ because: (chosen) => `"${chosen.label ?? chosen.id}" was the highest-ranked governed measure that can rank this entity, and it adds no filter the question did not ask for.`,
3116
+ });
3117
+ if (!assumption)
3118
+ return undefined;
3119
+ const chosen = assumable.find((candidate) => (candidate.qualifiedId ?? candidate.id) === assumption.chose);
3120
+ return chosen ? { candidate: chosen, assumption } : undefined;
3121
+ }
3122
+ /**
3123
+ * Does this ranking measure fit a question that named no measure?
3124
+ *
3125
+ * "Top customers" must not be silently answered by `top_beverage_customers`:
3126
+ * that candidate carries a scope — beverages — the asker never asked for, and
3127
+ * answering with it returns a confidently wrong list under a different question
3128
+ * than the one posed. `assumeDominantCandidate` deliberately leaves this check
3129
+ * to the caller, because dominance is about ranking and this is about meaning.
3130
+ *
3131
+ * The rule: every substantive token in the candidate's name must be either
3132
+ * something the question already said, or generic measure vocabulary. Any
3133
+ * leftover token is an unrequested qualifier, and the turn keeps asking.
3134
+ */
3135
+ export function rankingCandidateFitsBareQuestion(question, candidate) {
3136
+ const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
3137
+ // Identities arrive source-qualified (`semantic:metric:orders.revenue`). The
3138
+ // `semantic`/`metric` prefix is plumbing, not vocabulary — tokenizing it would
3139
+ // make every candidate look scoped and refuse every assumption.
3140
+ const identity = (candidate.qualifiedId ?? candidate.id ?? '').split(':').at(-1) ?? '';
3141
+ const nameTokens = substantiveLexicalTokens([candidate.name, identity].join(' '));
3142
+ if (nameTokens.length === 0)
3143
+ return false;
3144
+ return nameTokens.map(singularize).every((token) => questionTokens.has(token) || GENERIC_MEASURE_TOKENS.has(token));
3145
+ }
3146
+ function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates,
3147
+ /**
3148
+ * May this turn settle the measure by assumption rather than by asking?
3149
+ * False wherever the step whose job is to judge ambiguity has positively
3150
+ * reported some — assuming past a finding overrides it rather than filling a
3151
+ * gap, and AGT-017 already establishes that lexical rank alone must not
3152
+ * settle meaning when semantic judgment is unavailable.
3153
+ */
3154
+ mayAssume = true) {
1369
3155
  const rankingChoices = (candidates ?? []).filter((candidate) => {
1370
3156
  if (candidate.compatibility === 'incompatible')
1371
3157
  return false;
@@ -1397,6 +3183,9 @@ function bareRankingClarification(base, retrievalEvidence, question, evidence, c
1397
3183
  return false;
1398
3184
  return true;
1399
3185
  });
3186
+ if (rankingChoices.length === 0) {
3187
+ return unanswerableClarificationFallback(base, retrievalEvidence, 'No retrieved governed measure can rank this entity, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.');
3188
+ }
1400
3189
  return {
1401
3190
  ...base,
1402
3191
  action: 'clarify',
@@ -1408,9 +3197,7 @@ function bareRankingClarification(base, retrievalEvidence, question, evidence, c
1408
3197
  requiresClarification: true,
1409
3198
  clarifyingQuestion: 'Top by which governed metric?',
1410
3199
  retrievalEvidence,
1411
- ...(rankingChoices.length > 0
1412
- ? { clarificationOptions: buildClarificationOptions(rankingChoices) }
1413
- : {}),
3200
+ clarificationOptions: buildClarificationOptions(rankingChoices),
1414
3201
  resolvedAnalyticalPlan: undefined,
1415
3202
  meaningResolution: undefined,
1416
3203
  };
@@ -1446,7 +3233,11 @@ function candidateProvesMetricTerm(candidate, requested) {
1446
3233
  if (candidate.compatibility === 'incompatible')
1447
3234
  return false;
1448
3235
  if (candidate.kind === 'certified_block') {
1449
- return candidate.compatibility === 'compatible';
3236
+ // A block's relevance/tag/example must never impersonate one of its
3237
+ // outputs. Only a declared block output may prove an explicit requested
3238
+ // metric (AGT-009/AGT-010).
3239
+ return candidate.compatibility === 'compatible'
3240
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, [requested]);
1450
3241
  }
1451
3242
  if (candidate.kind !== 'semantic_metric')
1452
3243
  return false;
@@ -1617,9 +3408,10 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
1617
3408
  * against a uniquely compatible certified block; a second compatible block or
1618
3409
  * metric still goes to the bounded AI resolver.
1619
3410
  */
1620
- function dominantCompatibleGovernedCandidate(candidates) {
3411
+ function dominantCompatibleGovernedCandidate(candidates, requestedMeasures = []) {
1621
3412
  const compatible = candidates.filter((candidate) => candidate.compatibility === "compatible"
1622
- && (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member"));
3413
+ && (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member")
3414
+ && candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures));
1623
3415
  if (compatible.length === 0)
1624
3416
  return undefined;
1625
3417
  const best = compatible[0];
@@ -1641,13 +3433,19 @@ function dominantCompatibleGovernedCandidate(candidates) {
1641
3433
  const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
1642
3434
  return hasExecutableCompetitor ? undefined : best;
1643
3435
  }
1644
- function authoritativeExactCertifiedExample(candidates) {
3436
+ function authoritativeExactCertifiedExample(candidates, requestedMeasures = []) {
1645
3437
  const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
1646
3438
  && candidate.exactMatch
1647
3439
  && candidate.compatibility === 'compatible'
1648
- && candidate.analyticalFitClass === 'exact');
3440
+ && candidate.analyticalFitClass === 'exact'
3441
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures));
1649
3442
  return exact.length === 1 ? exact[0] : undefined;
1650
3443
  }
3444
+ /** Certified output coverage is a Tier-1 invariant; other routes pass through. */
3445
+ function candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures) {
3446
+ return candidate.kind !== 'certified_block'
3447
+ || certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures);
3448
+ }
1651
3449
  function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
1652
3450
  if (!base.followsUp)
1653
3451
  return false;
@@ -1718,7 +3516,7 @@ export function createHybridRouter(options = {}) {
1718
3516
  const base = deterministic(request);
1719
3517
  // The only pre-retrieval exits are deliberately narrow and unambiguously
1720
3518
  // non-analytical. App composition has its own catalog-grounded workflow.
1721
- if (base.action === "converse" || base.action === "compose_app") {
3519
+ if (!request.selectedEvidenceId && (base.action === "converse" || base.action === "compose_app")) {
1722
3520
  return { ...base, source: base.source ?? "heuristic" };
1723
3521
  }
1724
3522
  const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
@@ -1737,6 +3535,12 @@ export function createHybridRouter(options = {}) {
1737
3535
  }
1738
3536
  }
1739
3537
  if (evidence) {
3538
+ // The retrieval parser may carry broad search phrases such as
3539
+ // `count_for_each_customer`. They are useful before retrieval, but
3540
+ // they are not executable measures. Normalize once at the router
3541
+ // boundary so every downstream plan/meaning path consumes the same
3542
+ // typed measure + entity/dimension requirements.
3543
+ evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
1740
3544
  evidence = {
1741
3545
  ...evidence,
1742
3546
  candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
@@ -1747,31 +3551,69 @@ export function createHybridRouter(options = {}) {
1747
3551
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
1748
3552
  : {}),
1749
3553
  };
1750
- let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
1751
- // A structured clarification selection is authoritative identity input,
1752
- // not a new fuzzy-search phrase. Keep it in the bounded package even if
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.
3554
+ // Retrieval can retain more context internally; only this compact,
3555
+ // role-balanced package reaches the one meaning call. The question is
3556
+ // supplied so explicit revenue, entity labels, and time roles cannot be
3557
+ // pruned by unrelated lexical matches.
3558
+ let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, request.question);
3559
+ // Clarification is local and never provider-bound, so it can inspect the
3560
+ // complete already-retrieved set. A raw selectedEvidenceId must be
3561
+ // validated against this set before any route can fall through to a
3562
+ // generic/generated answer; this is deliberately outside the bounded
3563
+ // package length guard so an empty retrieval cannot fail open.
3564
+ const clarificationCandidates = [
3565
+ ...evidence.candidates,
3566
+ ...(evidence.clarificationCandidates ?? []),
3567
+ ].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
3568
+ // A structured clarification selection is identity input, not a new
3569
+ // fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
3570
+ // supplemental clarification candidates, not execution candidates.
1759
3571
  const selectedEvidence = request.selectedEvidenceId
1760
- ? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
1761
- .find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
3572
+ ? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
3573
+ : undefined;
3574
+ const structuredSelection = request.selectedEvidenceId
3575
+ ? validateStructuredClarificationSelection({
3576
+ request,
3577
+ evidence,
3578
+ candidates: clarificationCandidates,
3579
+ selected: selectedEvidence,
3580
+ })
1762
3581
  : undefined;
3582
+ if (structuredSelection && !structuredSelection.ok) {
3583
+ return invalidStructuredSelectionDecision({
3584
+ base,
3585
+ request,
3586
+ evidence,
3587
+ candidates: clarificationCandidates,
3588
+ selectedId: request.selectedEvidenceId,
3589
+ validation: structuredSelection,
3590
+ });
3591
+ }
1763
3592
  if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
1764
3593
  candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
1765
- .slice(0, options.maxMeaningCandidates ?? 12);
3594
+ .slice(0, options.maxMeaningCandidates ?? 16);
3595
+ }
3596
+ // The compact meaning package can be empty when every ranked card was
3597
+ // reserved for a role that the parser marked missing. A unique authored
3598
+ // certified example is still an authoritative snapshot fact, so it
3599
+ // must be considered before the package-length guard and before any
3600
+ // deterministic missing-dimension cascade.
3601
+ if (!request.selectedEvidenceId) {
3602
+ const authoredExample = authoritativeExactCertifiedExample(clarificationCandidates, evidence?.parsedIntent?.measures ?? []);
3603
+ if (authoredExample) {
3604
+ const candidatesForResolution = candidates.some((candidate) => candidate.id === authoredExample.id)
3605
+ ? candidates
3606
+ : [authoredExample, ...candidates];
3607
+ return routeDecisionForResolution(base, evidence, candidatesForResolution, directResolution(request, evidence, authoredExample, candidatesForResolution), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
3608
+ }
1766
3609
  }
1767
3610
  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);
3611
+ // Fiscal tokens are execution requirements, not a semantic guess.
3612
+ // Ask exactly once before a meaning call or plan freeze when the
3613
+ // snapshot has no declared fiscal calendar/date-role binding.
3614
+ const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
3615
+ if (fiscalClarification)
3616
+ return fiscalClarification;
1775
3617
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
1776
3618
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
1777
3619
  || /@(metric|block|model|table|column)\(/i.test(request.question)));
@@ -1797,8 +3639,19 @@ export function createHybridRouter(options = {}) {
1797
3639
  return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
1798
3640
  }
1799
3641
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1800
- return selectedEvidence && decision.requiresClarification
1801
- ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
3642
+ // A valid stable semantic choice may still be pre-freeze-ineligible
3643
+ // in this local snapshot (for example, the semantic adapter cannot
3644
+ // execute the selected tuple). That is a normal cascade condition:
3645
+ // keep the chosen meaning, then consider the same-snapshot safe
3646
+ // exploratory path. An invalid/stale choice never reaches here, and
3647
+ // a frozen plan/policy denial is deliberately not downgraded.
3648
+ const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
3649
+ && decision.resolvedAnalyticalPlan?.capability === 'blocked'
3650
+ && decision.analyticalCascadeDecision?.planFrozen === false;
3651
+ return selectedEvidence
3652
+ && structuredSelection?.ok
3653
+ && (decision.requiresClarification || selectedPreFreezeModelingGap)
3654
+ ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence, request.question)
1802
3655
  : decision;
1803
3656
  }
1804
3657
  const multiMetricPrimary = !shouldUseMeaningCall
@@ -1807,18 +3660,14 @@ export function createHybridRouter(options = {}) {
1807
3660
  if (multiMetricPrimary) {
1808
3661
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1809
3662
  }
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") : [];
3663
+ const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch
3664
+ && candidate.compatibility !== "incompatible"
3665
+ && candidateMayTerminateCertifiedForMeasures(candidate, evidence?.parsedIntent?.measures ?? [])) : [];
1817
3666
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1818
3667
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
1819
3668
  }
1820
3669
  const dominant = !shouldUseMeaningCall
1821
- ? dominantCompatibleGovernedCandidate(candidates)
3670
+ ? dominantCompatibleGovernedCandidate(candidates, evidence.parsedIntent?.measures ?? [])
1822
3671
  : undefined;
1823
3672
  if (dominant) {
1824
3673
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
@@ -1865,7 +3714,12 @@ export function createHybridRouter(options = {}) {
1865
3714
  }))
1866
3715
  : undefined;
1867
3716
  if (resolution) {
1868
- const validated = validateMeaningResolution(resolution, candidates);
3717
+ // Repair an incomplete certified nomination before generic
3718
+ // evidence validation. This preserves the real semantic choices
3719
+ // instead of turning a false certified selection into an opaque
3720
+ // invalid-evidence gap.
3721
+ const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
3722
+ const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures);
1869
3723
  if (validated.ok) {
1870
3724
  const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
1871
3725
  // Meaning interpretation is still required for a fresh turn,
@@ -1905,11 +3759,29 @@ export function createHybridRouter(options = {}) {
1905
3759
  && !hasExplicitRankingMeasure(request.question, evidence)
1906
3760
  && !resolutionResolvedRanking
1907
3761
  && !explicitRankingSelection) {
3762
+ // Meaning resolution ran here. If it named the ranking
3763
+ // measure as the missing piece, that is its judgment and the
3764
+ // turn asks rather than guessing past it. Otherwise a clearly
3765
+ // indicated measure is assumed and BOUND through the same
3766
+ // resolution path an explicit selection takes — an assumption
3767
+ // that cannot freeze a plan is refused downstream and reaches
3768
+ // the reader as `blocked` with no options.
3769
+ const resolverFlagged = (safeResolution.missingInformation ?? [])
3770
+ .some((item) => /measure|metric/i.test(item));
3771
+ const assumedRanking = resolverFlagged
3772
+ ? undefined
3773
+ : assumableRankingMeasure(request.question, clarificationCandidates);
3774
+ if (assumedRanking) {
3775
+ return {
3776
+ ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
3777
+ assumptions: [assumedRanking.assumption],
3778
+ };
3779
+ }
1908
3780
  return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
1909
3781
  // Supplemental clarification cards carry the ranking
1910
3782
  // measures for the requested entity, which the execution
1911
3783
  // candidate set deliberately does not.
1912
- clarificationCandidates);
3784
+ clarificationCandidates, !resolverFlagged);
1913
3785
  }
1914
3786
  const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
1915
3787
  if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
@@ -1959,6 +3831,38 @@ export function createHybridRouter(options = {}) {
1959
3831
  ?? fallbackDecision;
1960
3832
  }
1961
3833
  }
3834
+ // A structured selection cannot enter the legacy/no-evidence classifier.
3835
+ // Without a fresh retrieval snapshot it has no proof that the server
3836
+ // option still exists, so fail closed before any provider dispatch or
3837
+ // generated-SQL fallback.
3838
+ if (request.selectedEvidenceId) {
3839
+ const unavailableEvidence = {
3840
+ snapshotId: 'unavailable:structured-selection',
3841
+ sourceFingerprint: 'unavailable:structured-selection',
3842
+ candidates: [],
3843
+ };
3844
+ const validation = validateStructuredClarificationSelection({
3845
+ request,
3846
+ evidence: unavailableEvidence,
3847
+ candidates: [],
3848
+ selected: undefined,
3849
+ });
3850
+ return invalidStructuredSelectionDecision({
3851
+ base,
3852
+ request,
3853
+ evidence: unavailableEvidence,
3854
+ candidates: [],
3855
+ selectedId: request.selectedEvidenceId,
3856
+ validation: validation.ok
3857
+ ? {
3858
+ ok: false,
3859
+ requirements: validation.requirements,
3860
+ choiceIds: validation.choiceIds,
3861
+ reason: 'The selected governed identifier could not be revalidated because retrieval is unavailable.',
3862
+ }
3863
+ : validation,
3864
+ });
3865
+ }
1962
3866
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;
1963
3867
  // only the ambiguous middle pays the old classification call. Importantly,
1964
3868
  // load the catalog context before the model may choose general knowledge.