@duckcodeailabs/dql-agent 1.14.1 → 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 (84) hide show
  1. package/dist/agent-run-engine.d.ts +9 -1
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +199 -17
  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 +12 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  9. package/dist/agentic/analyst-loop.js +48 -28
  10. package/dist/agentic/analyst-loop.js.map +1 -1
  11. package/dist/agentic/research/synthesis.d.ts +4 -0
  12. package/dist/agentic/research/synthesis.d.ts.map +1 -1
  13. package/dist/agentic/research/synthesis.js +23 -8
  14. package/dist/agentic/research/synthesis.js.map +1 -1
  15. package/dist/agentic/research-agent.d.ts.map +1 -1
  16. package/dist/agentic/research-agent.js +3 -2
  17. package/dist/agentic/research-agent.js.map +1 -1
  18. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  19. package/dist/agentic/sql-authorization.js +230 -2
  20. package/dist/agentic/sql-authorization.js.map +1 -1
  21. package/dist/analytical-frame.d.ts.map +1 -1
  22. package/dist/analytical-frame.js +10 -1
  23. package/dist/analytical-frame.js.map +1 -1
  24. package/dist/analytical-orchestration.d.ts +302 -0
  25. package/dist/analytical-orchestration.d.ts.map +1 -1
  26. package/dist/analytical-orchestration.js +564 -0
  27. package/dist/analytical-orchestration.js.map +1 -1
  28. package/dist/answer-loop.d.ts +48 -0
  29. package/dist/answer-loop.d.ts.map +1 -1
  30. package/dist/answer-loop.js +679 -85
  31. package/dist/answer-loop.js.map +1 -1
  32. package/dist/answer-shape.d.ts +32 -1
  33. package/dist/answer-shape.d.ts.map +1 -1
  34. package/dist/answer-shape.js +49 -3
  35. package/dist/answer-shape.js.map +1 -1
  36. package/dist/conversation/snapshot.d.ts +20 -0
  37. package/dist/conversation/snapshot.d.ts.map +1 -1
  38. package/dist/conversation/snapshot.js +30 -1
  39. package/dist/conversation/snapshot.js.map +1 -1
  40. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  41. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  42. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  43. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  44. package/dist/index.d.ts +4 -4
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/intent-controller.d.ts +28 -0
  49. package/dist/intent-controller.d.ts.map +1 -1
  50. package/dist/intent-controller.js +30 -0
  51. package/dist/intent-controller.js.map +1 -1
  52. package/dist/meaning-resolution.d.ts +69 -2
  53. package/dist/meaning-resolution.d.ts.map +1 -1
  54. package/dist/meaning-resolution.js +128 -7
  55. package/dist/meaning-resolution.js.map +1 -1
  56. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  57. package/dist/metadata/analysis-planner.js +124 -7
  58. package/dist/metadata/analysis-planner.js.map +1 -1
  59. package/dist/metadata/block-fit.d.ts +18 -0
  60. package/dist/metadata/block-fit.d.ts.map +1 -1
  61. package/dist/metadata/block-fit.js +250 -38
  62. package/dist/metadata/block-fit.js.map +1 -1
  63. package/dist/metadata/catalog.d.ts.map +1 -1
  64. package/dist/metadata/catalog.js +57 -11
  65. package/dist/metadata/catalog.js.map +1 -1
  66. package/dist/metadata/meaning-evidence.d.ts +10 -1
  67. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  68. package/dist/metadata/meaning-evidence.js +261 -47
  69. package/dist/metadata/meaning-evidence.js.map +1 -1
  70. package/dist/research-loop.d.ts.map +1 -1
  71. package/dist/research-loop.js +23 -1
  72. package/dist/research-loop.js.map +1 -1
  73. package/dist/resolved-analytical-plan.d.ts +7 -0
  74. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  75. package/dist/resolved-analytical-plan.js +11 -4
  76. package/dist/resolved-analytical-plan.js.map +1 -1
  77. package/dist/router.d.ts +4 -3
  78. package/dist/router.d.ts.map +1 -1
  79. package/dist/router.js +1796 -125
  80. package/dist/router.js.map +1 -1
  81. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  82. package/dist/semantic-bridge/member-select.js +39 -21
  83. package/dist/semantic-bridge/member-select.js.map +1 -1
  84. package/package.json +5 -5
package/dist/router.js CHANGED
@@ -16,9 +16,10 @@ import { assumeDominantCandidate } from './agentic/assumptions.js';
16
16
  * question never pays twice.
17
17
  */
18
18
  import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
19
- 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";
20
20
  import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
21
21
  import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
22
+ import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
22
23
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
23
24
  import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
24
25
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
@@ -414,13 +415,871 @@ function retrievalTrace(evidence, candidates) {
414
415
  candidateIds: candidates.map((candidate) => candidate.id),
415
416
  };
416
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
+ }
417
1268
  function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
418
- 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);
419
1274
  if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
420
- const { analyticalFrame: sourceFrame, ...resolutionWithoutFrame } = resolution;
1275
+ const sourceFrame = resolution.analyticalFrame;
421
1276
  const bindingPlan = buildResolvedAnalyticalPlan({
422
1277
  question,
423
- 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,
424
1283
  evidence,
425
1284
  candidates,
426
1285
  mode,
@@ -445,6 +1304,17 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
445
1304
  const analytical = routedResolution.questionType === "diagnosis" ||
446
1305
  routedResolution.questionType === "research";
447
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;
448
1318
  return {
449
1319
  ...base,
450
1320
  action: needsClarification
@@ -465,6 +1335,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
465
1335
  depth: analytical ? "deep" : "quick",
466
1336
  meaningResolution: routedResolution,
467
1337
  resolvedAnalyticalPlan,
1338
+ analyticalCascadeDecision,
468
1339
  retrievalEvidence: retrievalTrace(evidence, candidates),
469
1340
  requiresClarification: needsClarification,
470
1341
  ...(terminallyBlocked
@@ -478,6 +1349,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
478
1349
  : 'ANALYTICAL_MODELING_GAP',
479
1350
  message: reconciliation.reason,
480
1351
  candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
1352
+ ...(terminalGap ? { gap: terminalGap } : {}),
481
1353
  },
482
1354
  }
483
1355
  : {}),
@@ -491,6 +1363,151 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
491
1363
  : {}),
492
1364
  };
493
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
+ }
494
1511
  /**
495
1512
  * The immutable RAP is the final routing authority. Meaning may nominate an
496
1513
  * execution route, but cannot leave the router claiming an answer after the
@@ -510,6 +1527,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
510
1527
  ].filter(({ binding }) => binding.status !== 'resolved');
511
1528
  const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
512
1529
  const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
1530
+ const clarificationRequirements = clarificationRequirementsForResolution(resolution);
513
1531
  if (plan.resolutionFailure?.outcome === 'policy_blocked') {
514
1532
  return {
515
1533
  outcome: 'policy_blocked',
@@ -526,9 +1544,19 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
526
1544
  const optionIds = qualifiedChoiceIds.length > 0
527
1545
  ? qualifiedChoiceIds
528
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);
529
1551
  const options = optionIds.length > 0
530
- ? clarificationOptionsForQualifiedIds(optionIds, candidates)
531
- : 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
+ }
532
1560
  const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
533
1561
  const question = routedClarificationQuestion(resolution, bindings, options);
534
1562
  return {
@@ -539,11 +1567,18 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
539
1567
  };
540
1568
  }
541
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
+ }
542
1577
  return {
543
1578
  outcome: 'clarify',
544
1579
  reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
545
1580
  question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
546
- options: buildClarificationOptions(candidates),
1581
+ options,
547
1582
  };
548
1583
  }
549
1584
  return {
@@ -551,12 +1586,85 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
551
1586
  reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
552
1587
  };
553
1588
  }
554
- function clarificationOptionsForQualifiedIds(ids, candidates) {
555
- return ids.slice(0, 3).map((id) => {
556
- 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 }) => {
557
1665
  return {
558
- id,
559
- label: candidate?.name ?? qualifiedIdLabel(id),
1666
+ id: candidate?.id ?? id,
1667
+ label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
560
1668
  ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
561
1669
  kind: candidate?.kind ?? 'semantic_member',
562
1670
  };
@@ -578,27 +1686,122 @@ function routedClarificationQuestion(resolution, bindings, options) {
578
1686
  return resolution.clarifyingQuestion
579
1687
  ?? `Which governed binding should I use before running this query?`;
580
1688
  }
581
- function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
582
- const selectedId = selected.qualifiedId ?? selected.id;
583
- const message = `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL did not substitute a correlated metric or execute a different artifact.`;
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
+ }
584
1792
  return {
585
1793
  ...base,
586
- action: 'block',
587
- confidence: 1,
1794
+ action: 'answer',
1795
+ confidence: 0.55,
588
1796
  reason: message,
589
1797
  source: 'heuristic',
590
1798
  category: 'data_lookup',
591
1799
  depth: 'quick',
592
1800
  retrievalEvidence: retrievalTrace(evidence, candidates),
593
1801
  requiresClarification: false,
594
- terminalOutcome: {
595
- kind: 'modeling_gap',
596
- code: 'ANALYTICAL_MODELING_GAP',
597
- message,
598
- candidateIds: [selectedId],
599
- },
600
1802
  resolvedAnalyticalPlan: undefined,
601
- meaningResolution: undefined,
1803
+ meaningResolution,
1804
+ analyticalCascadeDecision,
602
1805
  };
603
1806
  }
604
1807
  function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
@@ -713,14 +1916,15 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
713
1916
  "The requested analytical tuple is not executable from the current governed model."),
714
1917
  };
715
1918
  }
716
- function buildClarificationOptions(candidates) {
717
- 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" &&
718
1922
  (candidate.kind === "certified_block" ||
719
1923
  candidate.kind === "semantic_metric" ||
720
1924
  candidate.kind === "semantic_member"));
721
1925
  const pool = governed.length > 1
722
1926
  ? governed
723
- : candidates.filter((candidate) => candidate.compatibility !== "incompatible");
1927
+ : roleCompatible;
724
1928
  const chosen = pool.slice(0, 3);
725
1929
  // Two candidates can legitimately share a display name (a dbt model and its
726
1930
  // MetricFlow measure are both "customers"). Rendering both as "customers"
@@ -743,6 +1947,301 @@ function buildClarificationOptions(candidates) {
743
1947
  };
744
1948
  });
745
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
+ }
746
2245
  function candidateKindLabel(kind) {
747
2246
  if (kind === 'certified_block')
748
2247
  return 'certified block';
@@ -931,7 +2430,22 @@ function directResolution(request, evidence, candidate, candidates) {
931
2430
  metricCandidates,
932
2431
  candidates,
933
2432
  });
934
- 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;
935
2449
  const memberCandidates = candidates.filter((item) => {
936
2450
  if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
937
2451
  return false;
@@ -1071,7 +2585,8 @@ mayAssumeInterpretation = true) {
1071
2585
  }
1072
2586
  const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
1073
2587
  && candidate.compatibility !== "incompatible"
1074
- && rankingCandidates.includes(candidate));
2588
+ && rankingCandidates.includes(candidate)
2589
+ && candidateMayTerminateCertifiedForMeasures(candidate, evidence.parsedIntent?.measures ?? []));
1075
2590
  if (exactCompatible.length === 1 &&
1076
2591
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1077
2592
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
@@ -1085,7 +2600,7 @@ mayAssumeInterpretation = true) {
1085
2600
  // `type: simple` metric, the measure it wraps, and the model that holds them
1086
2601
  // were offered as three competing "meanings" of the same number.
1087
2602
  const best = mayAssumeInterpretation
1088
- ? bestGovernedInterpretation(request.question, rankingCandidates)
2603
+ ? bestGovernedInterpretation(request.question, rankingCandidates, evidence.parsedIntent?.measures ?? [])
1089
2604
  : undefined;
1090
2605
  if (best) {
1091
2606
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
@@ -1244,7 +2759,7 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
1244
2759
  * question made ordinary lookups feel like an interrogation, and most of those
1245
2760
  * questions had no real ambiguity behind them.
1246
2761
  */
1247
- export function bestGovernedInterpretation(question, candidates) {
2762
+ export function bestGovernedInterpretation(question, candidates, requestedMeasures = []) {
1248
2763
  // Take the best candidate that is actually executable, rather than refusing
1249
2764
  // because the top-ranked hit happens to be a descriptive modeling entity.
1250
2765
  // `compatibility: 'unknown'` is common for governed objects that execute
@@ -1254,7 +2769,9 @@ export function bestGovernedInterpretation(question, candidates) {
1254
2769
  return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
1255
2770
  && (candidate.kind === 'certified_block'
1256
2771
  || candidate.kind === 'semantic_metric'
1257
- || candidate.kind === 'semantic_member'));
2772
+ || candidate.kind === 'semantic_member')
2773
+ && (candidate.kind !== 'certified_block'
2774
+ || certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures)));
1258
2775
  }
1259
2776
  /**
1260
2777
  * Retrieval may nominate qualified candidates, but only the resolved plan may
@@ -1272,8 +2789,69 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1272
2789
  candidateCount: 0,
1273
2790
  candidateIds: [],
1274
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
+ };
1275
2822
  if (eligible.length > 1) {
1276
- 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
+ }
1277
2855
  return {
1278
2856
  ...base,
1279
2857
  action: 'clarify',
@@ -1284,7 +2862,7 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1284
2862
  reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
1285
2863
  requiresClarification: true,
1286
2864
  clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
1287
- clarificationOptions: buildClarificationOptions(choices),
2865
+ clarificationOptions: buildClarificationOptions(choices, clarificationRequirements),
1288
2866
  retrievalEvidence: trace,
1289
2867
  resolvedAnalyticalPlan: undefined,
1290
2868
  meaningResolution: undefined,
@@ -1294,6 +2872,9 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1294
2872
  const message = candidateIds.length === 1
1295
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.`
1296
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;
1297
2878
  return {
1298
2879
  ...base,
1299
2880
  action: 'block',
@@ -1321,6 +2902,10 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1321
2902
  */
1322
2903
  function deterministicPrePlanClarification(request, base, evidence, candidates) {
1323
2904
  const asksForRanking = questionTypeFromText(request.question) === 'ranking';
2905
+ const requirements = buildAnalyticalRequirementSet({
2906
+ question: request.question,
2907
+ parsedIntent: evidence.parsedIntent,
2908
+ });
1324
2909
  const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
1325
2910
  .map(normalizeMetricPhrase)
1326
2911
  .filter(Boolean);
@@ -1329,7 +2914,13 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1329
2914
  || hasStrongQualifiedMetricEvidence(candidates)
1330
2915
  || hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
1331
2916
  const retrievalEvidence = retrievalTrace(evidence, candidates);
1332
- 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));
1333
2924
  const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
1334
2925
  && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
1335
2926
  ? [normalizeMetricPhrase(filter.field)]
@@ -1347,9 +2938,29 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1347
2938
  .slice(0, 3);
1348
2939
  if (alternatives.length === 0) {
1349
2940
  // Bare rankings need a measure choice, not a dimension gap. Retain the
1350
- // no-options escape hatch for BCM-like retrieval failures.
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.
1351
2947
  if (asksForRanking && !hasExplicitRankingMetric) {
1352
- return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
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
+ });
1353
2964
  }
1354
2965
  // Parsed-intent hints can include inherited/default dimensions that the
1355
2966
  // user never asked for. Only turn a missing field into a product-facing
@@ -1359,47 +2970,19 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1359
2970
  if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
1360
2971
  return undefined;
1361
2972
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1362
- const message = `The requested dimension ${requestedLabel} is not modeled in the certified blocks, semantic model, dbt manifest, or runtime schema searched for this question. Add or map ${requestedLabel} before retrying.`;
1363
- return {
1364
- ...base,
1365
- action: 'block',
1366
- confidence: 1,
1367
- reason: message,
1368
- source: 'heuristic',
1369
- category: 'data_lookup',
1370
- depth: 'quick',
1371
- requiresClarification: false,
1372
- retrievalEvidence,
1373
- terminalOutcome: {
1374
- kind: 'modeling_gap',
1375
- code: 'ANALYTICAL_MODELING_GAP',
1376
- message,
1377
- candidateIds: [],
1378
- },
1379
- meaningResolution: {
1380
- interpretedQuestion: request.question,
1381
- questionType: questionTypeFromText(request.question),
1382
- selectedConceptIds: [],
1383
- queryIntent: {
1384
- ...defaultQueryIntent(evidence),
1385
- measures: evidence.parsedIntent?.measures ?? [],
1386
- dimensions: evidence.parsedIntent?.dimensions ?? [],
1387
- filters: evidence.parsedIntent?.filters ?? [],
1388
- },
1389
- rejectedCandidates: [],
1390
- confidence: 'low',
1391
- missingInformation: [message],
1392
- recommendedRoute: 'clarify',
1393
- compatibilityOutcome: 'modeling_gap',
1394
- compatibilityFailures: missingDimensions.map((dimension) => ({
1395
- code: 'MISSING_DIMENSION',
1396
- field: dimension,
1397
- message: `${dimension} is not modeled.`,
1398
- candidateIds: [],
1399
- })),
1400
- },
1401
- resolvedAnalyticalPlan: undefined,
1402
- };
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
+ });
1403
2986
  }
1404
2987
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1405
2988
  const alternativeLabels = alternatives.map(renderCandidateChoice);
@@ -1650,7 +3233,11 @@ function candidateProvesMetricTerm(candidate, requested) {
1650
3233
  if (candidate.compatibility === 'incompatible')
1651
3234
  return false;
1652
3235
  if (candidate.kind === 'certified_block') {
1653
- 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]);
1654
3241
  }
1655
3242
  if (candidate.kind !== 'semantic_metric')
1656
3243
  return false;
@@ -1821,9 +3408,10 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
1821
3408
  * against a uniquely compatible certified block; a second compatible block or
1822
3409
  * metric still goes to the bounded AI resolver.
1823
3410
  */
1824
- function dominantCompatibleGovernedCandidate(candidates) {
3411
+ function dominantCompatibleGovernedCandidate(candidates, requestedMeasures = []) {
1825
3412
  const compatible = candidates.filter((candidate) => candidate.compatibility === "compatible"
1826
- && (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));
1827
3415
  if (compatible.length === 0)
1828
3416
  return undefined;
1829
3417
  const best = compatible[0];
@@ -1845,13 +3433,19 @@ function dominantCompatibleGovernedCandidate(candidates) {
1845
3433
  const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
1846
3434
  return hasExecutableCompetitor ? undefined : best;
1847
3435
  }
1848
- function authoritativeExactCertifiedExample(candidates) {
3436
+ function authoritativeExactCertifiedExample(candidates, requestedMeasures = []) {
1849
3437
  const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
1850
3438
  && candidate.exactMatch
1851
3439
  && candidate.compatibility === 'compatible'
1852
- && candidate.analyticalFitClass === 'exact');
3440
+ && candidate.analyticalFitClass === 'exact'
3441
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures));
1853
3442
  return exact.length === 1 ? exact[0] : undefined;
1854
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
+ }
1855
3449
  function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
1856
3450
  if (!base.followsUp)
1857
3451
  return false;
@@ -1922,7 +3516,7 @@ export function createHybridRouter(options = {}) {
1922
3516
  const base = deterministic(request);
1923
3517
  // The only pre-retrieval exits are deliberately narrow and unambiguously
1924
3518
  // non-analytical. App composition has its own catalog-grounded workflow.
1925
- if (base.action === "converse" || base.action === "compose_app") {
3519
+ if (!request.selectedEvidenceId && (base.action === "converse" || base.action === "compose_app")) {
1926
3520
  return { ...base, source: base.source ?? "heuristic" };
1927
3521
  }
1928
3522
  const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
@@ -1941,6 +3535,12 @@ export function createHybridRouter(options = {}) {
1941
3535
  }
1942
3536
  }
1943
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);
1944
3544
  evidence = {
1945
3545
  ...evidence,
1946
3546
  candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
@@ -1951,31 +3551,69 @@ export function createHybridRouter(options = {}) {
1951
3551
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
1952
3552
  : {}),
1953
3553
  };
1954
- let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
1955
- // A structured clarification selection is authoritative identity input,
1956
- // not a new fuzzy-search phrase. Keep it in the bounded package even if
1957
- // per-tier limits would otherwise trim it from a large catalog.
1958
- // Look in BOTH lists. The ranking-measure choices are supplemental
1959
- // clarification candidates, not execution candidates, so resolving the
1960
- // selection against `candidates` alone silently found nothing — the
1961
- // click looked identical to no click, the ranking gate fired again, and
1962
- // the same three options came back forever.
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.
1963
3571
  const selectedEvidence = request.selectedEvidenceId
1964
- ? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
1965
- .find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
3572
+ ? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
1966
3573
  : undefined;
3574
+ const structuredSelection = request.selectedEvidenceId
3575
+ ? validateStructuredClarificationSelection({
3576
+ request,
3577
+ evidence,
3578
+ candidates: clarificationCandidates,
3579
+ selected: selectedEvidence,
3580
+ })
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
+ }
1967
3592
  if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
1968
3593
  candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
1969
- .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
+ }
1970
3609
  }
1971
3610
  if (candidates.length > 0) {
1972
- // Clarification is local and never provider-bound, so it can inspect the
1973
- // complete already-retrieved set. Keep the smaller package below for
1974
- // any later meaning call.
1975
- const clarificationCandidates = [
1976
- ...evidence.candidates,
1977
- ...(evidence.clarificationCandidates ?? []),
1978
- ].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;
1979
3617
  const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
1980
3618
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
1981
3619
  || /@(metric|block|model|table|column)\(/i.test(request.question)));
@@ -2001,8 +3639,19 @@ export function createHybridRouter(options = {}) {
2001
3639
  return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
2002
3640
  }
2003
3641
  const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2004
- return selectedEvidence && decision.requiresClarification
2005
- ? 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)
2006
3655
  : decision;
2007
3656
  }
2008
3657
  const multiMetricPrimary = !shouldUseMeaningCall
@@ -2011,29 +3660,14 @@ export function createHybridRouter(options = {}) {
2011
3660
  if (multiMetricPrimary) {
2012
3661
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2013
3662
  }
2014
- // THE FAST LANE. Evaluated regardless of `shouldUseMeaningCall`, so an
2015
- // exact certified hit short-circuits BEFORE the ~10s meaning call
2016
- // rather than paying for it. Previously this shortcut only applied
2017
- // when the call was already being skipped, which meant a perfect
2018
- // certified match — the cheapest, most certain answer DQL can give —
2019
- // was also one of the slowest.
2020
- //
2021
- // The precondition is deliberately the strictest one available:
2022
- // exactly one certified block, compatible, whose AUTHORED EXAMPLE the
2023
- // question matches. `meaning-evidence.ts` sets that flag only for an
2024
- // authored-example fit and explicitly notes it is the one signal that
2025
- // may grant this shortcut — lexical equality to a block name does not
2026
- // qualify.
2027
- const authoredExample = authoritativeExactCertifiedExample(candidates);
2028
- if (authoredExample) {
2029
- return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
2030
- }
2031
- 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 ?? [])) : [];
2032
3666
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
2033
3667
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2034
3668
  }
2035
3669
  const dominant = !shouldUseMeaningCall
2036
- ? dominantCompatibleGovernedCandidate(candidates)
3670
+ ? dominantCompatibleGovernedCandidate(candidates, evidence.parsedIntent?.measures ?? [])
2037
3671
  : undefined;
2038
3672
  if (dominant) {
2039
3673
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
@@ -2080,7 +3714,12 @@ export function createHybridRouter(options = {}) {
2080
3714
  }))
2081
3715
  : undefined;
2082
3716
  if (resolution) {
2083
- 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);
2084
3723
  if (validated.ok) {
2085
3724
  const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
2086
3725
  // Meaning interpretation is still required for a fresh turn,
@@ -2192,6 +3831,38 @@ export function createHybridRouter(options = {}) {
2192
3831
  ?? fallbackDecision;
2193
3832
  }
2194
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
+ }
2195
3866
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;
2196
3867
  // only the ambiguous middle pays the old classification call. Importantly,
2197
3868
  // load the catalog context before the model may choose general knowledge.