@duckcodeailabs/dql-agent 1.14.3-rc.1 → 1.14.4

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 (128) hide show
  1. package/dist/agent-run-engine.d.ts +177 -3
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2061 -57
  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 +51 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-store.d.ts.map +1 -1
  9. package/dist/agent-run-store.js +544 -9
  10. package/dist/agent-run-store.js.map +1 -1
  11. package/dist/agentic/analyst-loop.d.ts +2 -0
  12. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  13. package/dist/agentic/analyst-loop.js +41 -4
  14. package/dist/agentic/analyst-loop.js.map +1 -1
  15. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  16. package/dist/agentic/ledger-tools.js +8 -1
  17. package/dist/agentic/ledger-tools.js.map +1 -1
  18. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  19. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  20. package/dist/agentic/orchestrator-policy.js +1 -1
  21. package/dist/agentic/orchestrator-policy.js.map +1 -1
  22. package/dist/agentic/tool-loop.d.ts +13 -1
  23. package/dist/agentic/tool-loop.d.ts.map +1 -1
  24. package/dist/agentic/tool-loop.js +441 -32
  25. package/dist/agentic/tool-loop.js.map +1 -1
  26. package/dist/analytical-frame.d.ts +19 -0
  27. package/dist/analytical-frame.d.ts.map +1 -1
  28. package/dist/analytical-frame.js +170 -12
  29. package/dist/analytical-frame.js.map +1 -1
  30. package/dist/analytical-orchestration.d.ts +949 -10
  31. package/dist/analytical-orchestration.d.ts.map +1 -1
  32. package/dist/analytical-orchestration.js +499 -56
  33. package/dist/analytical-orchestration.js.map +1 -1
  34. package/dist/answer-loop.d.ts +166 -2
  35. package/dist/answer-loop.d.ts.map +1 -1
  36. package/dist/answer-loop.js +654 -18
  37. package/dist/answer-loop.js.map +1 -1
  38. package/dist/answer-shape.d.ts +27 -0
  39. package/dist/answer-shape.d.ts.map +1 -1
  40. package/dist/answer-shape.js +55 -0
  41. package/dist/answer-shape.js.map +1 -1
  42. package/dist/ask-observability/instrumentation.d.ts.map +1 -1
  43. package/dist/ask-observability/instrumentation.js +52 -1
  44. package/dist/ask-observability/instrumentation.js.map +1 -1
  45. package/dist/ask-observability/portable.js +562 -0
  46. package/dist/ask-observability/portable.js.map +1 -1
  47. package/dist/ask-observability/types.d.ts +26 -3
  48. package/dist/ask-observability/types.d.ts.map +1 -1
  49. package/dist/ask-observability/types.js.map +1 -1
  50. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  51. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  52. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  53. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  54. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  55. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  56. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  57. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  58. package/dist/conversation/session-store.d.ts +31 -0
  59. package/dist/conversation/session-store.d.ts.map +1 -1
  60. package/dist/conversation/session-store.js +38 -0
  61. package/dist/conversation/session-store.js.map +1 -1
  62. package/dist/conversation/snapshot.d.ts +4 -1
  63. package/dist/conversation/snapshot.d.ts.map +1 -1
  64. package/dist/conversation/snapshot.js +85 -3
  65. package/dist/conversation/snapshot.js.map +1 -1
  66. package/dist/conversation/turn-trust.d.ts.map +1 -1
  67. package/dist/conversation/turn-trust.js +2 -0
  68. package/dist/conversation/turn-trust.js.map +1 -1
  69. package/dist/embeddings/provider.d.ts +1 -0
  70. package/dist/embeddings/provider.d.ts.map +1 -1
  71. package/dist/embeddings/provider.js +18 -0
  72. package/dist/embeddings/provider.js.map +1 -1
  73. package/dist/generated-analytical-proposal.js +37 -2
  74. package/dist/generated-analytical-proposal.js.map +1 -1
  75. package/dist/index.d.ts +15 -8
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +9 -4
  78. package/dist/index.js.map +1 -1
  79. package/dist/intent-controller.d.ts +77 -1
  80. package/dist/intent-controller.d.ts.map +1 -1
  81. package/dist/intent-controller.js +1 -1
  82. package/dist/intent-controller.js.map +1 -1
  83. package/dist/kg/build.js +32 -2
  84. package/dist/kg/build.js.map +1 -1
  85. package/dist/meaning-resolution.d.ts +129 -0
  86. package/dist/meaning-resolution.d.ts.map +1 -1
  87. package/dist/meaning-resolution.js +108 -15
  88. package/dist/meaning-resolution.js.map +1 -1
  89. package/dist/metadata/analysis-planner.js +57 -6
  90. package/dist/metadata/analysis-planner.js.map +1 -1
  91. package/dist/metadata/catalog.d.ts +14 -0
  92. package/dist/metadata/catalog.d.ts.map +1 -1
  93. package/dist/metadata/catalog.js +28 -1
  94. package/dist/metadata/catalog.js.map +1 -1
  95. package/dist/metadata/meaning-evidence.d.ts +19 -0
  96. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  97. package/dist/metadata/meaning-evidence.js +113 -6
  98. package/dist/metadata/meaning-evidence.js.map +1 -1
  99. package/dist/provider-egress.d.ts +4 -1
  100. package/dist/provider-egress.d.ts.map +1 -1
  101. package/dist/provider-egress.js +50 -5
  102. package/dist/provider-egress.js.map +1 -1
  103. package/dist/providers/claude.d.ts +2 -2
  104. package/dist/providers/claude.d.ts.map +1 -1
  105. package/dist/providers/claude.js +191 -28
  106. package/dist/providers/claude.js.map +1 -1
  107. package/dist/providers/index.d.ts +1 -1
  108. package/dist/providers/index.d.ts.map +1 -1
  109. package/dist/providers/index.js.map +1 -1
  110. package/dist/providers/openai.d.ts +2 -2
  111. package/dist/providers/openai.d.ts.map +1 -1
  112. package/dist/providers/openai.js +191 -21
  113. package/dist/providers/openai.js.map +1 -1
  114. package/dist/providers/types.d.ts +79 -1
  115. package/dist/providers/types.d.ts.map +1 -1
  116. package/dist/providers/types.js.map +1 -1
  117. package/dist/requirement-clauses.d.ts +23 -0
  118. package/dist/requirement-clauses.d.ts.map +1 -0
  119. package/dist/requirement-clauses.js +70 -0
  120. package/dist/requirement-clauses.js.map +1 -0
  121. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  122. package/dist/resolved-analytical-plan.js +201 -15
  123. package/dist/resolved-analytical-plan.js.map +1 -1
  124. package/dist/router.d.ts +109 -2
  125. package/dist/router.d.ts.map +1 -1
  126. package/dist/router.js +753 -76
  127. package/dist/router.js.map +1 -1
  128. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -22,7 +22,7 @@ import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
22
22
  import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, categoricalDimensionRequirementTerms, candidateConflictsWithExplicitRankingMeasure, candidateMatchesCategoricalDimensionRequirement, evidenceCandidateRoles, isEntityAttributeCandidate, } from './analytical-orchestration.js';
23
23
  import { askTraceObserverForV1 } from './ask-observability/index.js';
24
24
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
25
- import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
25
+ import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, proveSameSnapshotMetricflowRoleExtensionV1, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
26
26
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
27
27
  const DEFAULT_THRESHOLD = 0.7;
28
28
  const DEFAULT_CACHE_SIZE = 200;
@@ -195,7 +195,7 @@ function parseClassification(raw) {
195
195
  rationale,
196
196
  };
197
197
  }
198
- function buildMeaningSystemPrompt() {
198
+ export function buildMeaningSystemPrompt() {
199
199
  return [
200
200
  "You resolve business meaning for DQL, a governed analytics system.",
201
201
  "The host already performed broad retrieval and supplied a host-owned requirement seed. Compare ONLY the supplied candidate cards against that seed.",
@@ -210,7 +210,7 @@ function buildMeaningSystemPrompt() {
210
210
  "`selectedConceptIds` is accepted only for legacy compatibility. All omitted optional fields receive host-owned safe defaults.",
211
211
  ].join("\n");
212
212
  }
213
- function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
213
+ export function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
214
214
  const cards = candidates.map((candidate) => ({
215
215
  id: candidate.id,
216
216
  kind: candidate.kind,
@@ -222,6 +222,7 @@ function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed)
222
222
  aggregation: compactText(candidate.aggregation, 120),
223
223
  domain: compactText(candidate.domain, 120),
224
224
  semanticModel: compactText(candidate.semanticModel, 160),
225
+ dataType: compactText(candidate.dataType, 80),
225
226
  primaryEntity: compactText(candidate.primaryEntity, 160),
226
227
  dimensions: compactArray(candidate.dimensions, 16, 120),
227
228
  timeGrains: compactArray(candidate.timeGrains, 8, 80),
@@ -469,6 +470,7 @@ function retrievalTrace(evidence, candidates) {
469
470
  return {
470
471
  ...(evidence.snapshotId ? { snapshotId: evidence.snapshotId } : {}),
471
472
  ...(evidence.sourceFingerprint ? { sourceFingerprint: evidence.sourceFingerprint } : {}),
473
+ ...(evidence.continuityFingerprint ? { continuityFingerprint: evidence.continuityFingerprint } : {}),
472
474
  candidateCount: candidates.length,
473
475
  candidateIds: candidates.map((candidate) => candidate.id),
474
476
  ...(candidateTraceMetadata.length ? { candidateTraceMetadata } : {}),
@@ -504,7 +506,12 @@ function traceSourceForCandidate(candidate) {
504
506
  return 'runtime_schema';
505
507
  return 'exploratory';
506
508
  }
507
- function traceCandidateLifecycleBeforePruning(request, evidence, candidates, packageCandidates) {
509
+ /**
510
+ * Persist the immutable snapshot's candidate lifecycle for either the legacy
511
+ * router or AskAnalystRuntimeV1. This is an observability projection only: it
512
+ * cannot retrieve, rank, admit, or alter a program.
513
+ */
514
+ export function recordAskCandidateLifecycleV1(request, evidence, candidates, packageCandidates) {
508
515
  const observer = askTraceObserverForV1(request);
509
516
  if (!observer.enabled)
510
517
  return;
@@ -522,9 +529,15 @@ function traceCandidateLifecycleBeforePruning(request, evidence, candidates, pac
522
529
  ...(requirements.dimensions.length ? ['categorical_dimension'] : []),
523
530
  ...(requirements.dimensions.length > 1 || requirements.entityTerms.length ? ['relationship'] : []),
524
531
  ]);
525
- const candidateLimit = 32;
532
+ // Candidate lifecycle is an Advanced trace projection over the immutable
533
+ // prequalification/search backing pool. It intentionally has a larger cap
534
+ // than EvidenceWorkspaceV2 (32) so users can see a relevant candidate was
535
+ // pruned as `not_admitted` rather than falsely told it was absent. This
536
+ // projection is not an admission path: planner cards stay <=16 and the
537
+ // frozen execution closure stays <=32 plus an explicit bounded extension.
538
+ const rawCandidateTraceLimit = 80;
526
539
  const hasRequestedEntityLabel = requirements.entityTerms.length > 0 || requirements.entityDisplayTerms.length > 0;
527
- for (const [index, candidate] of candidates.slice(0, candidateLimit).entries()) {
540
+ for (const [index, candidate] of candidates.slice(0, rawCandidateTraceLimit).entries()) {
528
541
  const roles = evidenceCandidateRoles(candidate);
529
542
  const source = traceSourceForCandidate(candidate);
530
543
  // This receipt must retain the actual retrieval memberships captured by
@@ -536,9 +549,13 @@ function traceCandidateLifecycleBeforePruning(request, evidence, candidates, pac
536
549
  || left.lane.localeCompare(right.lane));
537
550
  const lane = lanes?.[0]?.lane;
538
551
  const laneRank = lanes?.[0]?.rank;
539
- const initialReason = candidate.exactMatch
540
- ? 'exact_name_match'
541
- : 'unknown';
552
+ const configuredRuntimeValueGroundingPin = candidate.matchReasons
553
+ .includes('host configured runtime value grounding pin');
554
+ const initialReason = configuredRuntimeValueGroundingPin
555
+ ? 'configured_runtime_value_grounding_pin'
556
+ : candidate.exactMatch
557
+ ? 'exact_name_match'
558
+ : 'unknown';
542
559
  const compatibilityCode = candidate.compatibility === 'compatible' ? 'compatible'
543
560
  : candidate.compatibility === 'incompatible' ? 'operation_unsupported'
544
561
  : 'unknown';
@@ -582,15 +599,22 @@ function traceCandidateLifecycleBeforePruning(request, evidence, candidates, pac
582
599
  }
583
600
  if (packageIds.has(candidate.id)) {
584
601
  const reservedForRole = requestedRoles.has(role);
602
+ const unresolvedRoleAdmission = candidate.matchReasons.some((reason) => reason.startsWith(`candidate_for_unresolved_role:${role}:`));
585
603
  observer.recordCandidateDecision({
586
604
  ...common,
587
605
  decision: 'reserved',
588
- reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
606
+ reasonCode: configuredRuntimeValueGroundingPin ? 'configured_runtime_value_grounding_pin'
607
+ : candidate.exactMatch ? 'exact_name_match'
608
+ : unresolvedRoleAdmission ? 'candidate_for_unresolved_role'
609
+ : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
589
610
  });
590
611
  observer.recordCandidateDecision({
591
612
  ...common,
592
613
  decision: 'admitted',
593
- reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
614
+ reasonCode: configuredRuntimeValueGroundingPin ? 'configured_runtime_value_grounding_pin'
615
+ : candidate.exactMatch ? 'exact_name_match'
616
+ : unresolvedRoleAdmission ? 'candidate_for_unresolved_role'
617
+ : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
594
618
  });
595
619
  }
596
620
  else {
@@ -845,6 +869,34 @@ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
845
869
  const lifecycleAllowsExploration = safety.status === 'certified'
846
870
  || safety.status === 'validated'
847
871
  || safety.status === 'draft';
872
+ // A local runtime may mint this *after* it has evaluated the immutable DQL
873
+ // relationship graph. It is not a weaker spelling of automaticJoinAllowed:
874
+ // it can only admit a declared same-domain draft/review edge to the
875
+ // review-required exploratory compiler. Keep generic retrieved evidence on
876
+ // the stricter validated/automatic route below.
877
+ const hostAttestedExploratory = safety.exploratoryJoinAllowed === true
878
+ && Boolean(safety.exploratoryPathFingerprint?.trim());
879
+ const lifecycleAllowsHostAttestedExploration = safety.status === 'draft'
880
+ || safety.status === 'evaluated'
881
+ || safety.status === 'review'
882
+ || safety.status === 'reviewed';
883
+ const keysAreUsable = safety.keys.length > 0
884
+ && safety.keys.every((key) => Boolean(key.from.trim() && key.to.trim()));
885
+ if (hostAttestedExploratory) {
886
+ if (!lifecycleAllowsHostAttestedExploration
887
+ || safety.staleCertification === true
888
+ || safety.fanout !== 'safe'
889
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
890
+ || !safety.from?.trim()
891
+ || !safety.to?.trim()
892
+ || !keysAreUsable)
893
+ return false;
894
+ if (!requiredJoinKey)
895
+ return true;
896
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
897
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
898
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
899
+ }
848
900
  if (!lifecycleAllowsExploration
849
901
  || safety.staleCertification === true
850
902
  || safety.automaticJoinAllowed !== true
@@ -858,8 +910,7 @@ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
858
910
  || !validation.proofFingerprint?.trim()
859
911
  || !Number.isFinite(checkedAt)
860
912
  || expirationInvalid
861
- || safety.keys.length === 0
862
- || safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
913
+ || !keysAreUsable)
863
914
  return false;
864
915
  if (!requiredJoinKey)
865
916
  return true;
@@ -867,6 +918,77 @@ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
867
918
  return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
868
919
  || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
869
920
  }
921
+ /**
922
+ * Resolve a card's complete same-snapshot relationship proof set.
923
+ *
924
+ * Every evidence edge must map to exactly one structured proof (an alias may
925
+ * perform that mapping); every supplied proof must be used by an evidence
926
+ * edge. This rejects partial cards, unrelated safety records, and aliases
927
+ * that ambiguously name two proofs before a host-authored path card can carry
928
+ * them into planner context. The router/compiler still revalidates the
929
+ * selected raw edges at freeze time.
930
+ */
931
+ export function relationshipSafetyProofSelectionForCandidateV1(candidate) {
932
+ const evidenceByIdentity = new Map();
933
+ for (const rawEvidenceId of candidate.relationshipEvidence ?? []) {
934
+ const canonicalInput = rawEvidenceId.trim();
935
+ const identity = normalizedRelationshipIdentity(canonicalInput);
936
+ if (!identity)
937
+ return undefined;
938
+ // Duplicate spellings of the same input identity do not create another
939
+ // edge. They are canonicalized below to the matched safety ID.
940
+ if (!evidenceByIdentity.has(identity))
941
+ evidenceByIdentity.set(identity, canonicalInput);
942
+ }
943
+ if (evidenceByIdentity.size === 0)
944
+ return undefined;
945
+ const proofs = candidate.relationshipSafety ?? [];
946
+ if (proofs.length === 0)
947
+ return undefined;
948
+ const matchedProofIndexes = new Set();
949
+ const matchedProofs = [];
950
+ for (const evidenceIdentity of evidenceByIdentity.keys()) {
951
+ const matches = proofs
952
+ .map((safety, index) => ({ safety, index }))
953
+ .filter(({ safety }) => {
954
+ // A canonical record without an ID cannot be safely serialized even
955
+ // if a loose alias happens to match the retrieval edge.
956
+ if (!safety.id?.trim())
957
+ return false;
958
+ return relationshipSafetyIdentities(safety).includes(evidenceIdentity);
959
+ });
960
+ // One evidence edge must identify one and only one canonical proof. A
961
+ // duplicate ID, duplicate alias, or conflicting alias is unsafe rather
962
+ // than a tie the path-card builder may resolve heuristically.
963
+ if (matches.length !== 1)
964
+ return undefined;
965
+ const match = matches[0];
966
+ matchedProofIndexes.add(match.index);
967
+ if (!matchedProofs.some((proof) => proof === match.safety))
968
+ matchedProofs.push(match.safety);
969
+ }
970
+ // A card that carries an unrelated proof is not a compact proof set. Do
971
+ // not silently discard it: the raw card may represent a stale/mixed
972
+ // snapshot and cannot become a trusted atomic planner path.
973
+ if (matchedProofIndexes.size !== proofs.length)
974
+ return undefined;
975
+ const proofClass = matchedProofs.every((safety) => relationshipSafetyAllowsAutomaticJoin(safety))
976
+ ? 'governed'
977
+ : matchedProofs.every((safety) => relationshipSafetyAllowsExploratoryJoin(safety))
978
+ ? 'exploratory'
979
+ : undefined;
980
+ if (!proofClass)
981
+ return undefined;
982
+ return {
983
+ proofClass,
984
+ relationshipEvidence: [...new Set(matchedProofs.map((safety) => safety.id.trim()))].sort(),
985
+ relationshipSafety: [...matchedProofs]
986
+ .sort((left, right) => left.id.localeCompare(right.id)),
987
+ };
988
+ }
989
+ export function relationshipSafetyProofClassForCandidateV1(candidate) {
990
+ return relationshipSafetyProofSelectionForCandidateV1(candidate)?.proofClass;
991
+ }
870
992
  function relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey) {
871
993
  return authority === 'governed'
872
994
  ? relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey)
@@ -913,13 +1035,30 @@ function relationshipEndpointMatchesEntityTerms(endpoint, entityTerms) {
913
1035
  const normalizedEndpoint = normalizeMetricPhrase(endpoint);
914
1036
  return entityTerms.some((term) => metricTermsMatch(normalizedEndpoint, normalizeMetricPhrase(term)));
915
1037
  }
1038
+ /**
1039
+ * Modeling files commonly use local entity IDs for relationship endpoints while
1040
+ * candidate cards retain the domain-qualified entity identity. Relationship
1041
+ * safety is only meaningful inside the declared relationship's domain, so
1042
+ * bridge that representation here rather than falling back to an unscoped
1043
+ * leaf-name match. This helper is used exclusively by the attribution-policy
1044
+ * graph below; it never authorizes a join.
1045
+ */
1046
+ function scopedRelationshipEndpointIdentity(endpoint, relationship) {
1047
+ const normalizedEndpoint = normalizedRelationshipIdentity(endpoint ?? '');
1048
+ if (!normalizedEndpoint || normalizedEndpoint.includes('::entity::'))
1049
+ return normalizedEndpoint;
1050
+ const relationshipIdentity = relationshipSafetyIdentities(relationship)
1051
+ .find((identity) => identity.includes('::relationship::'));
1052
+ const domainPrefix = relationshipIdentity?.split('::relationship::')[0];
1053
+ return domainPrefix ? `${domainPrefix}::entity::${normalizedEndpoint}` : normalizedEndpoint;
1054
+ }
916
1055
  function relationshipGraphReaches(starts, targets, relationships) {
917
1056
  if (starts.size === 0 || targets.size === 0)
918
1057
  return false;
919
1058
  const graph = new Map();
920
1059
  for (const relationship of relationships) {
921
- const from = normalizedRelationshipIdentity(relationship.from ?? '');
922
- const to = normalizedRelationshipIdentity(relationship.to ?? '');
1060
+ const from = scopedRelationshipEndpointIdentity(relationship.from, relationship);
1061
+ const to = scopedRelationshipEndpointIdentity(relationship.to, relationship);
923
1062
  if (!from || !to)
924
1063
  continue;
925
1064
  const fromNeighbors = graph.get(from) ?? new Set();
@@ -944,7 +1083,14 @@ function relationshipGraphReaches(starts, targets, relationships) {
944
1083
  }
945
1084
  return false;
946
1085
  }
947
- function attributionRequiredRelationshipGapDecision(input) {
1086
+ /**
1087
+ * Evaluate an attribution/allocation policy boundary from one immutable
1088
+ * snapshot. AskAnalystRuntimeV1 invokes this before an optional provider
1089
+ * meaning call: a declared denial is neither an ambiguity nor a provider
1090
+ * failure. This helper only evaluates supplied evidence; it never retrieves
1091
+ * additional context or executes a query.
1092
+ */
1093
+ export function attributionRequiredRelationshipGapDecision(input) {
948
1094
  if (!EXPLICIT_ATTRIBUTION_REQUEST_RE.test(input.request.question)
949
1095
  || input.requirements.entityTerms.length === 0)
950
1096
  return undefined;
@@ -969,8 +1115,8 @@ function attributionRequiredRelationshipGapDecision(input) {
969
1115
  return undefined;
970
1116
  const attributed = attributionRequired.find((relationship) => {
971
1117
  const endpoints = [
972
- normalizedRelationshipIdentity(relationship.from ?? ''),
973
- normalizedRelationshipIdentity(relationship.to ?? ''),
1118
+ scopedRelationshipEndpointIdentity(relationship.from, relationship),
1119
+ scopedRelationshipEndpointIdentity(relationship.to, relationship),
974
1120
  ].filter(Boolean);
975
1121
  const requestedSignalEndpoints = new Set(endpoints.filter((endpoint) => relationshipEndpointMatchesQuestionConcept(endpoint, input.request.question)));
976
1122
  if (requestedSignalEndpoints.size === 0)
@@ -982,7 +1128,17 @@ function attributionRequiredRelationshipGapDecision(input) {
982
1128
  const allReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, relationships);
983
1129
  const safeRelationships = relationships.filter((candidate) => relationshipSafetyAllowsAutomaticJoin(candidate));
984
1130
  const safelyReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, safeRelationships);
985
- return allReachable && !safelyReachable;
1131
+ if (safelyReachable)
1132
+ return false;
1133
+ if (allReachable)
1134
+ return true;
1135
+ // The compact route workspace may omit an otherwise-safe continuation
1136
+ // edge, but it must not erase a declared attribution boundary for the
1137
+ // exact signal the user named. We have already established a direct,
1138
+ // snapshot-bound match for that signal and a requested entity. Returning
1139
+ // a typed modeling gap is safer than presenting unrelated metric choices
1140
+ // or attempting an unapproved attribution join.
1141
+ return true;
986
1142
  });
987
1143
  if (!attributed)
988
1144
  return undefined;
@@ -1362,7 +1518,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
1362
1518
  function preFreezePhysicalCascadeDecision(input) {
1363
1519
  // `candidates` can be the capped meaning package. Physical eligibility is
1364
1520
  // allowed one same-snapshot extension, never a new retrieval/domain scope.
1365
- const snapshotCandidates = immutableSnapshotCandidates(input.evidence, input.candidates);
1521
+ const snapshotCandidates = targetScopedSameSnapshotCandidates(input.evidence, input.candidates, input.targetScope, input.executionCandidateIds);
1366
1522
  const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
1367
1523
  const missingRankingMetric = Boolean(physicalPath.ok
1368
1524
  && input.requireRankingMetric
@@ -1429,14 +1585,17 @@ function preFreezePhysicalCascadeDecision(input) {
1429
1585
  const coverageFor = (source) => coverage.find((item) => item.source === source);
1430
1586
  const skippedAttempt = (tier, source) => {
1431
1587
  const item = coverageFor(source);
1588
+ const semanticCompilerUnavailable = tier === 'semantic' && input.semanticTierUnavailable;
1432
1589
  return {
1433
1590
  version: 1,
1434
1591
  tier,
1435
- outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1592
+ outcome: semanticCompilerUnavailable || item?.status !== 'available' ? 'unavailable' : 'ineligible',
1436
1593
  candidateIds: item?.candidateIds ?? [],
1437
- reason: item?.status === 'available'
1438
- ? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
1439
- : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1594
+ reason: semanticCompilerUnavailable
1595
+ ? 'The selected semantic compiler was unavailable before plan freeze.'
1596
+ : item?.status === 'available'
1597
+ ? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
1598
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1440
1599
  planFrozen: false,
1441
1600
  };
1442
1601
  };
@@ -1535,7 +1694,7 @@ function preFreezePhysicalCascadeDecision(input) {
1535
1694
  * structured-continuation checks, while retaining a caller's server-issued
1536
1695
  * selection when it was supplied as a same-snapshot clarification card.
1537
1696
  */
1538
- function immutableSnapshotCandidates(evidence, candidates) {
1697
+ function immutableSnapshotCandidates(evidence, candidates, executionCandidateIds) {
1539
1698
  const byId = new Map();
1540
1699
  for (const candidate of [
1541
1700
  ...evidence.candidates,
@@ -1545,7 +1704,41 @@ function immutableSnapshotCandidates(evidence, candidates) {
1545
1704
  if (!byId.has(candidate.id))
1546
1705
  byId.set(candidate.id, candidate);
1547
1706
  }
1548
- return [...byId.values()];
1707
+ const snapshot = [...byId.values()];
1708
+ if (!executionCandidateIds)
1709
+ return snapshot;
1710
+ const allowed = new Set(executionCandidateIds);
1711
+ return snapshot.filter((candidate) => allowed.has(candidate.id) || allowed.has(candidate.qualifiedId ?? candidate.id));
1712
+ }
1713
+ /**
1714
+ * The one permitted physical extension stays inside the already acquired
1715
+ * snapshot and, when the active target is represented in source provenance,
1716
+ * restricts physical cards to that target. This prevents a semantic target
1717
+ * mismatch from being misreported as global schema absence while retaining
1718
+ * relationship cards needed to prove a safe closure.
1719
+ */
1720
+ function targetScopedSameSnapshotCandidates(evidence, candidates, targetScope, executionCandidateIds) {
1721
+ const snapshot = immutableSnapshotCandidates(evidence, candidates, executionCandidateIds);
1722
+ if (!targetScope)
1723
+ return snapshot;
1724
+ const normalized = targetScope.toLowerCase();
1725
+ const physical = snapshot.filter((candidate) => candidate.kind === 'sql_table'
1726
+ || candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source');
1727
+ const scopedPhysical = physical.filter((candidate) => [
1728
+ ...(candidate.sourceObjects ?? []),
1729
+ candidate.provenance ?? '',
1730
+ candidate.semanticModel ?? '',
1731
+ candidate.domain ?? '',
1732
+ ].some((value) => value.toLowerCase().includes(normalized)));
1733
+ // Provenance often does not carry a connection name in older indexes. In
1734
+ // that case retain the immutable snapshot rather than inventing absence.
1735
+ if (scopedPhysical.length === 0)
1736
+ return snapshot;
1737
+ const scopedIds = new Set(scopedPhysical.map((candidate) => candidate.id));
1738
+ return snapshot.filter((candidate) => !physical.includes(candidate)
1739
+ || scopedIds.has(candidate.id)
1740
+ || candidate.kind === 'dql_modeling'
1741
+ || (candidate.relationshipEvidence?.length ?? 0) > 0);
1549
1742
  }
1550
1743
  /**
1551
1744
  * A normal meaning call may nominate either a semantic metric or a governed
@@ -1590,7 +1783,7 @@ function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
1590
1783
  const physicalContinuation = preFreezePhysicalCascadeDecision({
1591
1784
  base: input.base,
1592
1785
  evidence,
1593
- candidates: immutableSnapshotCandidates(evidence, input.candidates),
1786
+ candidates: immutableSnapshotCandidates(evidence, input.candidates, input.executionCandidateIds),
1594
1787
  question: input.question,
1595
1788
  requirements,
1596
1789
  missingTerms,
@@ -1599,6 +1792,7 @@ function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
1599
1792
  ? 'The selected governed relational interpretation did not prove a compiler-owned DQL projection before plan freeze.'
1600
1793
  : 'The selected semantic interpretation was pre-freeze-ineligible for the complete requested tuple.',
1601
1794
  terminalCandidateIds: plan.selectedConceptIds,
1795
+ ...(input.executionCandidateIds ? { executionCandidateIds: input.executionCandidateIds } : {}),
1602
1796
  });
1603
1797
  if (physicalContinuation.action === 'answer'
1604
1798
  && physicalContinuation.analyticalCascadeDecision?.selectedTier === 'exploratory_sql') {
@@ -1989,6 +2183,264 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1989
2183
  : {}),
1990
2184
  };
1991
2185
  }
2186
+ /**
2187
+ * Compiler-only bridge for AskAnalystRuntimeV1. The runtime owns question
2188
+ * framing, candidate admission, meaning selection, and the immutable program;
2189
+ * this adapter only validates that selected program against existing safe
2190
+ * certified/MetricFlow/relational/exploratory compilers. It must never call a
2191
+ * provider, retrieve a new snapshot, or nominate a different business meaning.
2192
+ */
2193
+ export function compileAskAnalyticalProgramV1(input) {
2194
+ const readiness = input.request.askAnalystTierReadiness;
2195
+ // A compiler must never widen the program that the runtime froze. A stale
2196
+ // adapter-selected identity is a compilation failure, not an invitation to
2197
+ // rank/retrieve/interpret the question again.
2198
+ const executionCandidates = input.executionCandidates ?? input.candidates;
2199
+ const executionIds = new Set(input.program.executionCandidateIds ?? input.program.candidateIds);
2200
+ const executionCandidateIds = [...executionIds];
2201
+ const isProgramExecutionCandidate = (candidate) => executionIds.has(candidate.id) || executionIds.has(candidate.qualifiedId ?? candidate.id);
2202
+ if (executionCandidates.some((candidate) => !isProgramExecutionCandidate(candidate))) {
2203
+ return immutableProgramBlocked(input.base, 'A compiler received a candidate outside the frozen Ask execution closure.');
2204
+ }
2205
+ const seedFilters = input.request.hostRequirementSeed?.queryIntent.filters ?? [];
2206
+ if (!validProgramFilterBindings(input.program, seedFilters)) {
2207
+ return immutableProgramBlocked(input.base, 'The immutable Ask program did not retain valid field, operator, and value bindings for every requested filter.');
2208
+ }
2209
+ const unboundCurrentQuestionLiterals = unboundProgramV3CurrentQuestionLiterals(input.program);
2210
+ if (unboundCurrentQuestionLiterals.length > 0) {
2211
+ // This is a normal pre-freeze analytical coverage gap, not an immutable
2212
+ // program corruption or policy breach. The current question named a
2213
+ // value, but no single selected qualified field/value binding survived
2214
+ // verification. Reporting it as a typed gap prevents an unknown or
2215
+ // ambiguous literal from broadening the SQL while preserving the ordinary
2216
+ // cascade vocabulary for repair and trace rendering.
2217
+ return currentQuestionLiteralCoverageGapDecision({
2218
+ base: input.base,
2219
+ evidence: input.evidence,
2220
+ executionCandidates,
2221
+ requirements: input.requirements,
2222
+ program: input.program,
2223
+ literals: unboundCurrentQuestionLiterals,
2224
+ });
2225
+ }
2226
+ const programIds = new Set(input.program.candidateIds);
2227
+ // The compiler receives the full frozen 32-card execution closure for
2228
+ // relationship/physical compilation, but only the program's verified
2229
+ // selected cards may authorize meaning. Do not treat closure membership as
2230
+ // selection authority: that would let a tail candidate change the question
2231
+ // after planner verification.
2232
+ const programIdentityIds = new Set(input.candidates
2233
+ .filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id))
2234
+ .flatMap((candidate) => [candidate.id, candidate.qualifiedId].filter((id) => Boolean(id))));
2235
+ const selectedIds = [
2236
+ ...input.resolution.selectedConceptIds,
2237
+ ...(input.resolution.recommendedExecutionId ? [input.resolution.recommendedExecutionId] : []),
2238
+ ];
2239
+ if (selectedIds.some((id) => !programIds.has(id) && !programIdentityIds.has(id))) {
2240
+ return {
2241
+ ...input.base,
2242
+ action: 'block',
2243
+ confidence: 1,
2244
+ source: 'heuristic',
2245
+ followsUp: false,
2246
+ reason: 'The immutable Ask program did not authorize every selected compiler identity.',
2247
+ terminalOutcome: {
2248
+ kind: 'policy_blocked',
2249
+ code: 'ANALYTICAL_POLICY_BLOCKED',
2250
+ message: 'A compiler attempted to consume a candidate outside the frozen Ask program.',
2251
+ candidateIds: selectedIds,
2252
+ },
2253
+ };
2254
+ }
2255
+ // `candidates` is the runtime-verified business tuple. The larger frozen
2256
+ // execution closure is deliberately supplied only to the physical
2257
+ // continuation below. Passing it into the semantic router lets a related
2258
+ // metric or numeric attribute re-open meaning selection after program
2259
+ // verification (for example Orders + Drink Orders + customer order number
2260
+ // for the singular request "order count for each customer").
2261
+ const selectedMeaningCandidates = input.candidates.filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id));
2262
+ const decision = routeDecisionForResolution(input.base, input.evidence, selectedMeaningCandidates, input.resolution, 'heuristic', input.request.question, input.mode ?? 'authoritative');
2263
+ // A material business choice is resolved before target readiness. A target
2264
+ // being unavailable does not make two independently valid semantic display
2265
+ // meanings become one physical meaning; collapsing it first caused offline
2266
+ // structured clarification/restart flows to report a false coverage gap.
2267
+ // Once an immutable meaning is selected, readiness still advances an
2268
+ // unavailable semantic target through the same-snapshot physical cascade.
2269
+ if (decision.requiresClarification === true || decision.action === 'clarify') {
2270
+ return decision;
2271
+ }
2272
+ // Connector/active-target readiness is recorded separately because it is an
2273
+ // execution boundary, not necessarily a semantic *compiler* boundary. A
2274
+ // native semantic plan can be proven and frozen from an authored local
2275
+ // snapshot without a configured connection; execution then reports the
2276
+ // normal post-freeze setup failure. Adapter/target binding for MetricFlow or
2277
+ // another external semantic compiler is folded into semanticCompiler by the
2278
+ // host readiness probe, so only that state authorizes pre-freeze fallback.
2279
+ const semanticUnavailable = readiness?.semanticCompiler === 'unavailable';
2280
+ // A semantic adapter/target mismatch is a pre-freeze availability result,
2281
+ // not a modeling absence. A safe physical closure from this exact snapshot
2282
+ // may still answer as review-required exploratory SQL.
2283
+ if (semanticUnavailable && input.resolution.recommendedRoute === 'semantic') {
2284
+ return preFreezePhysicalCascadeDecision({
2285
+ base: input.base,
2286
+ evidence: input.evidence,
2287
+ candidates: executionCandidates,
2288
+ question: input.request.question,
2289
+ requirements: input.requirements,
2290
+ missingTerms: [
2291
+ ...input.requirements.measures,
2292
+ ...input.requirements.dimensions,
2293
+ ...input.requirements.entityTerms,
2294
+ ...input.requirements.entityDisplayTerms,
2295
+ ],
2296
+ requiredPhysicalFieldTerms: [
2297
+ ...input.requirements.measures,
2298
+ ...input.requirements.dimensions,
2299
+ ...input.requirements.entityTerms,
2300
+ ...input.requirements.entityDisplayTerms,
2301
+ ],
2302
+ messagePrefix: 'The semantic compiler or active target was unavailable before plan freeze; DQL evaluated the same-snapshot safe physical path independently.',
2303
+ terminalCandidateIds: input.resolution.selectedConceptIds,
2304
+ ...(input.request.executionTarget?.target === 'connection' && input.request.executionTarget.connectionName
2305
+ ? { targetScope: input.request.executionTarget.connectionName }
2306
+ : {}),
2307
+ executionCandidateIds,
2308
+ semanticTierUnavailable: true,
2309
+ requireRankingMetric: Boolean(input.requirements.ranking && input.requirements.ranking.metricTerms.length === 0),
2310
+ });
2311
+ }
2312
+ // A selected semantic/governed plan may be invalid before it freezes. That
2313
+ // is explicitly allowed to advance, but a frozen, denied, or unsafe plan is
2314
+ // never silently downgraded.
2315
+ return continuePreFreezeModelingGapThroughPhysicalSnapshot({
2316
+ decision,
2317
+ base: input.base,
2318
+ evidence: input.evidence,
2319
+ candidates: executionCandidates,
2320
+ question: input.request.question,
2321
+ executionCandidateIds,
2322
+ });
2323
+ }
2324
+ function immutableProgramBlocked(base, message) {
2325
+ return {
2326
+ ...base,
2327
+ action: 'block',
2328
+ confidence: 1,
2329
+ source: 'heuristic',
2330
+ followsUp: false,
2331
+ reason: message,
2332
+ terminalOutcome: {
2333
+ kind: 'policy_blocked',
2334
+ code: 'ANALYTICAL_POLICY_BLOCKED',
2335
+ message,
2336
+ candidateIds: [],
2337
+ },
2338
+ };
2339
+ }
2340
+ function validProgramFilterBindings(program, seedFilters) {
2341
+ if (program.filters.some((filter) => filter.fieldTerms.length === 0 || !filter.fieldTerms.every(Boolean) || !filter.value.trim() || filter.operator === 'unknown'))
2342
+ return false;
2343
+ // Direct compiler consumers from before AskAnalystRuntimeV1 do not carry a
2344
+ // host requirement seed. They still must provide internally valid filters,
2345
+ // but cannot be compared to a seed that was never supplied. Authoritative
2346
+ // Ask always supplies one and therefore takes the exact-binding branch.
2347
+ if (seedFilters.length === 0)
2348
+ return true;
2349
+ // A program with no predicate is valid only when the host seed also has no
2350
+ // predicate. When a seed exists, exact normalized bindings make it
2351
+ // impossible for a compiler to drop, broaden, or replace a user filter.
2352
+ const normalize = (field, operator, value) => `${field.trim().toLowerCase()}|${operator.trim().toLowerCase()}|${value.trim().toLowerCase()}`;
2353
+ const expected = seedFilters.map((filter) => normalize(filter.field, 'equals', filter.value)).sort();
2354
+ const actual = program.filters
2355
+ .map((filter) => filter.fieldTerms.map((field) => normalize(field, filter.operator, filter.value)))
2356
+ .flat()
2357
+ .sort();
2358
+ return expected.length === actual.length && expected.every((value, index) => value === actual[index]);
2359
+ }
2360
+ /**
2361
+ * V3 carries raw current-turn atoms specifically so an incomplete parser or
2362
+ * compatibility adapter cannot erase a literal and allow a broad certified
2363
+ * block to look complete. A literal must be represented by a frozen predicate
2364
+ * before any compiler can select a tier. Prior-result anchors are distinct
2365
+ * host-bound continuity evidence and therefore have their own source.
2366
+ */
2367
+ function unboundProgramV3CurrentQuestionLiterals(program) {
2368
+ if (program.version !== 3)
2369
+ return [];
2370
+ const normalize = (value) => value.trim().toLowerCase();
2371
+ const frozenValues = new Set(program.filters.map((filter) => normalize(filter.value)));
2372
+ const trustedValues = new Set(program.trustedTaskAnchors
2373
+ .filter((anchor) => anchor.kind === 'member_binding' || anchor.kind === undefined)
2374
+ .flatMap((anchor) => anchor.values)
2375
+ .map(normalize));
2376
+ return program.inputAtoms
2377
+ .filter((atom) => atom.source === 'current_question' && atom.role === 'member')
2378
+ .map((atom) => atom.term)
2379
+ .filter((term) => !frozenValues.has(normalize(term)) && !trustedValues.has(normalize(term)));
2380
+ }
2381
+ /**
2382
+ * A missing/ambiguous literal binding is an answerability gap, not evidence
2383
+ * that the already-built program was tampered with. Keep it pre-freeze and
2384
+ * show the actual terminal cause, so Ask can safely explain why it did not
2385
+ * generate a broad exploratory query.
2386
+ */
2387
+ function currentQuestionLiteralCoverageGapDecision(input) {
2388
+ const normalize = (value) => value.trim().toLowerCase();
2389
+ const literalSet = new Set(input.literals.map(normalize));
2390
+ const matchingColumns = input.executionCandidates.filter((candidate) => candidate.kind === 'sql_column'
2391
+ && evidenceCandidateRoles(candidate).includes('categorical_dimension')
2392
+ && (candidate.safeValueEvidence ?? []).some((evidence) => literalSet.has(normalize(evidence.normalizedValue)) || literalSet.has(normalize(evidence.value))));
2393
+ const qualifiedColumnIds = [...new Set(matchingColumns.map((candidate) => candidate.qualifiedId ?? candidate.id))].sort();
2394
+ const ambiguity = qualifiedColumnIds.length > 1;
2395
+ const literalLabel = input.literals.map((literal) => `“${literal}”`).join(', ');
2396
+ const message = ambiguity
2397
+ ? `The current-question value ${literalLabel} matched multiple qualified categorical fields in this metadata snapshot, so DQL did not choose or broaden a generated SQL filter.`
2398
+ : `The current-question value ${literalLabel} was not verified on one selected qualified categorical field in this metadata snapshot, so DQL did not broaden a generated SQL filter.`;
2399
+ const candidateIds = qualifiedColumnIds.length > 0
2400
+ ? qualifiedColumnIds
2401
+ : [...new Set(input.program.candidateIds)].sort();
2402
+ const coverage = sourceCoverageFromEvidence(input.evidence, input.executionCandidates);
2403
+ const attempts = [
2404
+ { version: 1, tier: 'certified', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [], reason: 'A current-question literal is not bound in the frozen program.', planFrozen: false },
2405
+ { version: 1, tier: 'semantic', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [], reason: 'No selected semantic member binding proved the current literal.', planFrozen: false },
2406
+ { version: 1, tier: 'governed_relational', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'governed_relational')?.candidateIds ?? [], reason: 'A relationship cannot bind an unverified literal value by itself.', planFrozen: false },
2407
+ { version: 1, tier: 'exploratory_sql', outcome: 'unavailable', candidateIds: qualifiedColumnIds, reason: ambiguity ? 'More than one qualified physical field matched the literal.' : 'No selected qualified physical field had exact safe-value evidence for the literal.', planFrozen: false },
2408
+ { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds, reason: message, planFrozen: false },
2409
+ ];
2410
+ return {
2411
+ ...input.base,
2412
+ action: 'block',
2413
+ confidence: 1,
2414
+ followsUp: false,
2415
+ source: 'heuristic',
2416
+ category: 'data_lookup',
2417
+ depth: 'quick',
2418
+ reason: message,
2419
+ requiresClarification: false,
2420
+ retrievalEvidence: retrievalTrace(input.evidence, input.executionCandidates),
2421
+ resolvedAnalyticalPlan: undefined,
2422
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
2423
+ requirements: input.requirements,
2424
+ sourceCoverage: coverage,
2425
+ attempts,
2426
+ planFrozen: false,
2427
+ stopReason: 'coverage_gap',
2428
+ }),
2429
+ terminalOutcome: {
2430
+ kind: 'modeling_gap',
2431
+ code: 'ANALYTICAL_MODELING_GAP',
2432
+ message,
2433
+ candidateIds,
2434
+ gap: {
2435
+ code: 'MISSING_ATTRIBUTE',
2436
+ missing: ambiguity
2437
+ ? ['one unambiguous qualified categorical field for the current-question value']
2438
+ : ['one qualified categorical field with exact safe-value evidence for the current-question value'],
2439
+ witnessCandidateIds: candidateIds,
2440
+ },
2441
+ },
2442
+ };
2443
+ }
1992
2444
  /**
1993
2445
  * A block may be relevant to a metric without returning that metric. When a
1994
2446
  * stale catalog result or provider selection nominates such a block, continue
@@ -2291,7 +2743,7 @@ function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
2291
2743
  .map(({ id, candidate }) => {
2292
2744
  return {
2293
2745
  id: candidate?.id ?? id,
2294
- label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
2746
+ label: clarificationOptionLabel(candidate?.name, candidate?.qualifiedId ?? id),
2295
2747
  ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
2296
2748
  kind: candidate?.kind ?? 'semantic_member',
2297
2749
  };
@@ -2301,6 +2753,19 @@ function qualifiedIdLabel(id) {
2301
2753
  const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
2302
2754
  return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
2303
2755
  }
2756
+ /**
2757
+ * Candidate names are canonical metadata values, not necessarily presentation
2758
+ * labels. Keep a curated label intact, but humanize local snake/kebab names so
2759
+ * a stable clarification never asks a business user to choose `account_name`.
2760
+ */
2761
+ function clarificationOptionLabel(name, fallbackId) {
2762
+ const trimmed = name?.trim();
2763
+ if (!trimmed)
2764
+ return qualifiedIdLabel(fallbackId);
2765
+ return /[_-]/.test(trimmed)
2766
+ ? trimmed.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
2767
+ : trimmed;
2768
+ }
2304
2769
  function routedClarificationQuestion(resolution, bindings, options) {
2305
2770
  if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
2306
2771
  return resolution.clarifyingQuestion;
@@ -2636,8 +3101,8 @@ function buildClarificationOptions(candidates, requirements) {
2636
3101
  return {
2637
3102
  id: candidate.id,
2638
3103
  label: ambiguousName
2639
- ? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
2640
- : candidate.name,
3104
+ ? `${clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id)} (${candidateKindLabel(candidate.kind)})`
3105
+ : clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id),
2641
3106
  ...(description ? { description } : {}),
2642
3107
  kind: candidate.kind,
2643
3108
  };
@@ -3251,6 +3716,7 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
3251
3716
  if (hasExplicitRankingMeasure(question, evidence))
3252
3717
  return resolution;
3253
3718
  const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
3719
+ || candidate.qualifiedId === resolution.recommendedExecutionId
3254
3720
  || resolution.selectedConceptIds.includes(candidate.id));
3255
3721
  if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
3256
3722
  return resolution;
@@ -3280,25 +3746,64 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
3280
3746
  };
3281
3747
  }
3282
3748
  function isSameSnapshotCategoricalExtensionForMetric(candidate, metricCandidate) {
3283
- const extension = candidate.sameSnapshotRoleExtension;
3284
- if (!extension
3285
- || extension.version !== 1
3286
- || extension.role !== 'categorical_dimension'
3287
- || (extension.basis !== 'sole_metricflow_grouping_dimension'
3288
- && extension.basis !== 'exact_metricflow_grouping_dimension')
3289
- || candidate.kind !== 'semantic_member'
3290
- || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
3291
- return false;
3292
- const metricIds = new Set([
3293
- metricCandidate.id,
3294
- metricCandidate.qualifiedId,
3295
- normalizeEvidenceAnalyticalCapability(metricCandidate).capability?.metricId,
3296
- ].filter((id) => Boolean(id)));
3297
- if (!metricIds.has(extension.metricId))
3298
- return false;
3749
+ return Boolean(proveSameSnapshotMetricflowRoleExtensionV1({ candidate, metricCandidate }));
3750
+ }
3751
+ /**
3752
+ * A planner/runtime selection can name a semantic display card through its
3753
+ * registry identity while the metric capability names the same field through
3754
+ * its MetricFlow identity. That is common for dbt MetricFlow exports:
3755
+ * `semantic:dimension:customers.customer_name` is the selected registry card,
3756
+ * while the metric declares
3757
+ * `semantic:uncategorized:dimension:customers.customer_name` as its native
3758
+ * grouping field.
3759
+ *
3760
+ * Do not reopen retrieval to bridge those forms. When (and only when) the
3761
+ * already-selected semantic member maps uniquely to an exact group-by
3762
+ * dimension on the already-selected metric capability, record the
3763
+ * same-snapshot proof on that selected card. The existing semantic compiler
3764
+ * still checks the normalized capability, additivity, and native grouping
3765
+ * path before freezing. This lets a complete singular tuple execute without
3766
+ * permitting a correlated metric, a numeric order attribute, or any
3767
+ * unselected workspace card to become meaning authority.
3768
+ */
3769
+ function bindSelectedMetricFlowGroupingExtensions(metricCandidate, candidates) {
3299
3770
  const capability = normalizeEvidenceAnalyticalCapability(metricCandidate).capability;
3300
- return Boolean(capability?.dimensions.some((dimension) => dimension.dimensionId === extension.dimensionId
3301
- && dimension.supportedRoles.includes('group_by')));
3771
+ if (!capability)
3772
+ return candidates;
3773
+ const normalizedIdentitySet = (candidate) => new Set([
3774
+ candidate.id,
3775
+ candidate.qualifiedId,
3776
+ candidate.name,
3777
+ ...(candidate.aliases ?? []),
3778
+ ].filter((identity) => Boolean(identity)).map(normalizeMetricPhrase).filter(Boolean));
3779
+ const dimensionIdentities = (dimension) => new Set([
3780
+ dimension.dimensionId,
3781
+ dimension.label,
3782
+ ...(dimension.aliases ?? []),
3783
+ ].filter((identity) => Boolean(identity)).map(normalizeMetricPhrase).filter(Boolean));
3784
+ return candidates.map((candidate) => {
3785
+ if (candidate.kind !== 'semantic_member'
3786
+ || candidate.compatibility === 'incompatible'
3787
+ || candidate.sameSnapshotRoleExtension)
3788
+ return candidate;
3789
+ const identities = normalizedIdentitySet(candidate);
3790
+ const matchedDimensions = capability.dimensions.filter((dimension) => dimension.supportedRoles.includes('group_by')
3791
+ && [...dimensionIdentities(dimension)].some((identity) => identities.has(identity)));
3792
+ if (matchedDimensions.length !== 1)
3793
+ return candidate;
3794
+ const dimension = matchedDimensions[0];
3795
+ return {
3796
+ ...candidate,
3797
+ sameSnapshotRoleExtension: {
3798
+ version: 1,
3799
+ role: 'categorical_dimension',
3800
+ requestedTerm: candidate.name,
3801
+ metricId: capability.metricId,
3802
+ dimensionId: dimension.dimensionId,
3803
+ basis: 'exact_metricflow_grouping_dimension',
3804
+ },
3805
+ };
3806
+ });
3302
3807
  }
3303
3808
  /**
3304
3809
  * A model chooses from the bounded package, but it cannot remove a unique
@@ -3323,11 +3828,14 @@ function directResolution(request, evidence, candidate, candidates, selectedDime
3323
3828
  // model path. Raw parser terms are retrieval hints only: they may not turn a
3324
3829
  // normalized business alias back into a phantom metric or dimension after
3325
3830
  // the meaning boundary has been intentionally skipped.
3326
- const requirementSeed = buildAnalyticalRequirementSeedV1({
3327
- question: request.question,
3328
- parsedIntent: evidence.parsedIntent,
3329
- fiscalCalendar: declaredFiscalCalendar(evidence, candidates),
3330
- });
3831
+ const requirementSeed = request.hostRequirementSeed?.version === 1
3832
+ && request.hostRequirementSeed.sourceQuestion === request.question
3833
+ ? request.hostRequirementSeed
3834
+ : buildAnalyticalRequirementSeedV1({
3835
+ question: request.question,
3836
+ parsedIntent: evidence.parsedIntent,
3837
+ fiscalCalendar: declaredFiscalCalendar(evidence, candidates),
3838
+ });
3331
3839
  const hostOwnedEvidence = {
3332
3840
  ...evidence,
3333
3841
  parsedIntent: {
@@ -3448,7 +3956,14 @@ function directResolution(request, evidence, candidate, candidates, selectedDime
3448
3956
  .map((item) => item.id)
3449
3957
  .concat(selectedDimensionConceptIds, hostBoundDimensionConceptIds)
3450
3958
  .filter((id, index, all) => all.indexOf(id) === index),
3451
- recommendedExecutionId: candidate.id,
3959
+ // Meaning receipts retain the source-qualified identity selected from the
3960
+ // immutable workspace. The frozen semantic compiler plan may normalize
3961
+ // that handle to its canonical execution ID later, but rewriting it here
3962
+ // breaks the provenance link between the model/meaning choice and the
3963
+ // selected semantic evidence card.
3964
+ recommendedExecutionId: candidate.kind === 'semantic_metric'
3965
+ ? candidate.qualifiedId ?? candidate.id
3966
+ : candidate.id,
3452
3967
  queryIntent: { ...queryIntentWithSelectedDimensions, filters: canonicalFilters },
3453
3968
  rejectedCandidates: [],
3454
3969
  confidence: "high",
@@ -3571,6 +4086,86 @@ function attachHostOwnedAnalyticalFrame(input) {
3571
4086
  ...(overrideReceipts?.length ? { overrideReceipts } : {}),
3572
4087
  };
3573
4088
  }
4089
+ /**
4090
+ * Bind an already validated runtime meaning selection to the immutable
4091
+ * host-owned analytical frame. This is intentionally a narrow compiler
4092
+ * adapter: it does not retrieve, rank candidates, call a provider, or change
4093
+ * the runtime program. Keeping the V2 frame construction here lets the
4094
+ * existing MetricFlow compiler retain its capability/additivity validation
4095
+ * without returning ownership of interpretation to the legacy router.
4096
+ */
4097
+ export function bindAskAnalystProgramMeaningV1(input) {
4098
+ // The runtime has already verified which cards express the business tuple.
4099
+ // `input.candidates` can still contain the 16-card planner package so the
4100
+ // compiler has definition/relationship context, but it is not permission
4101
+ // to rebuild the frame with every correlated metric in that package. In the
4102
+ // retained Jaffle snapshot, that turned the singular Orders metric into
4103
+ // Orders + Large Orders + Food Orders + Drink Orders after verification.
4104
+ // Preserve only server-validated selection identities here; execution
4105
+ // closure remains a separate compiler input later in the cascade.
4106
+ const selectedIdentityIds = new Set([
4107
+ ...input.resolution.selectedConceptIds,
4108
+ ...(input.resolution.recommendedExecutionId ? [input.resolution.recommendedExecutionId] : []),
4109
+ ]);
4110
+ const selectedCandidates = input.candidates.filter((candidate) => [candidate.id, candidate.qualifiedId]
4111
+ .filter((identity) => Boolean(identity))
4112
+ .some((identity) => selectedIdentityIds.has(identity)));
4113
+ // Legacy/direct callers can lack a selected card only when they supplied an
4114
+ // incomplete resolution. Keep their prior behavior rather than fabricating
4115
+ // an empty frame; authoritative Ask always takes the narrow branch above.
4116
+ const framingCandidates = selectedCandidates.length > 0 ? selectedCandidates : input.candidates;
4117
+ const metricCandidate = framingCandidates.find((candidate) => candidate.kind === 'semantic_metric'
4118
+ && (candidate.id === input.resolution.recommendedExecutionId
4119
+ || candidate.qualifiedId === input.resolution.recommendedExecutionId
4120
+ || input.resolution.selectedConceptIds.some((identity) => candidate.id === identity || candidate.qualifiedId === identity)));
4121
+ const compilerFramingCandidates = metricCandidate
4122
+ ? bindSelectedMetricFlowGroupingExtensions(metricCandidate, framingCandidates)
4123
+ : framingCandidates;
4124
+ // The Ask runtime may rebuild the compiler-facing seed after verifying
4125
+ // canonical planner bindings. `attachHostOwnedAnalyticalFrame` deliberately
4126
+ // preserves most of the incoming resolution, so explicitly carry that
4127
+ // verified seed forward here. Otherwise a valid same-snapshot extension
4128
+ // can produce a frame with `locations.location_name`, only for RAP binding
4129
+ // to reopen the stale pre-planner phrase `region` and mark it unresolved.
4130
+ const frameBound = {
4131
+ ...attachHostOwnedAnalyticalFrame({ ...input, candidates: compilerFramingCandidates }),
4132
+ hostRequirementSeed: input.requirementSeed,
4133
+ };
4134
+ const protectedResolution = preventDegenerateRankingResolution(frameBound, input.evidence, compilerFramingCandidates, input.requirementSeed.sourceQuestion);
4135
+ // This adapter may enrich a selected MetricFlow tuple with host-owned
4136
+ // dimensions, but it may not silently subtract a verified runtime
4137
+ // selection. `attachHostOwnedAnalyticalFrame` rebuilds legacy resolution
4138
+ // fields for compiler compatibility; some native Measure -> MetricFlow
4139
+ // Metric pairs intentionally have different text identities (for example
4140
+ // `order_count` -> `Orders`). Keep every already-validated candidate ID
4141
+ // from the authoritative program, limited to this same candidate package.
4142
+ // The verifier/compiler still prove the metric's capability, grain, and
4143
+ // relationship closure before freeze.
4144
+ // The MetricFlow framing subset is allowed to shape legacy metric/grouping
4145
+ // metadata, but it is not the authority boundary for a V3 selection. A
4146
+ // verified planner can legitimately retain a qualified physical output or
4147
+ // context field beside its semantic metric. Keep those already-admitted
4148
+ // identities through the compatibility carrier; they remain subject to the
4149
+ // immutable compiler program's physical/semantic safety proof below.
4150
+ const allowed = new Set(input.candidates.flatMap((candidate) => [
4151
+ candidate.id,
4152
+ candidate.qualifiedId ?? candidate.id,
4153
+ ]));
4154
+ const preservedSelectedConceptIds = input.resolution.selectedConceptIds
4155
+ .filter((identity) => allowed.has(identity));
4156
+ const selectedConceptIds = [...new Set([
4157
+ ...protectedResolution.selectedConceptIds,
4158
+ ...preservedSelectedConceptIds,
4159
+ ])];
4160
+ const originalRecommendedExecutionId = input.resolution.recommendedExecutionId;
4161
+ return {
4162
+ ...protectedResolution,
4163
+ selectedConceptIds,
4164
+ ...(originalRecommendedExecutionId && allowed.has(originalRecommendedExecutionId)
4165
+ ? { recommendedExecutionId: originalRecommendedExecutionId }
4166
+ : {}),
4167
+ };
4168
+ }
3574
4169
  function explicitlyRequestedMetricCandidates(question, evidence, primary, candidates) {
3575
4170
  const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
3576
4171
  const requested = requirements.ranking?.metricTerms.length
@@ -4775,13 +5370,7 @@ function sameSnapshotRoleTargetedMeaningExtensions(input) {
4775
5370
  const requestedCategoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
4776
5371
  for (const candidate of input.clarificationCandidates) {
4777
5372
  const extension = candidate.sameSnapshotRoleExtension;
4778
- if (!extension
4779
- || extension.version !== 1
4780
- || extension.role !== 'categorical_dimension'
4781
- || (extension.basis !== 'sole_metricflow_grouping_dimension'
4782
- && extension.basis !== 'exact_metricflow_grouping_dimension')
4783
- || candidate.kind !== 'semantic_member'
4784
- || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
5373
+ if (!extension)
4785
5374
  continue;
4786
5375
  if (!requestedCategoricalTerms.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
4787
5376
  continue;
@@ -4795,9 +5384,18 @@ function sameSnapshotRoleTargetedMeaningExtensions(input) {
4795
5384
  if (input.candidates.some((admitted) => admitted.kind === 'semantic_member'
4796
5385
  && candidateMatchesCategoricalDimensionRequirement(admitted, [extension.requestedTerm])))
4797
5386
  continue;
4798
- const sourceMetricAdmitted = input.candidates.some((admitted) => admitted.kind === 'semantic_metric'
4799
- && (admitted.id === extension.metricId || admitted.qualifiedId === extension.metricId));
4800
- if (!sourceMetricAdmitted)
5387
+ // The compact package may retain a metric card across reloads, but only
5388
+ // its *current* normalized capability can authorize the companion
5389
+ // grouping card. Reuse the router/compiler/workspace proof rather than
5390
+ // accepting matching strings for metric/dimension identities.
5391
+ const sourceMetricProof = input.candidates.flatMap((admitted) => {
5392
+ const proof = proveSameSnapshotMetricflowRoleExtensionV1({
5393
+ candidate,
5394
+ metricCandidate: admitted,
5395
+ });
5396
+ return proof ? [proof] : [];
5397
+ });
5398
+ if (sourceMetricProof.length === 0)
4801
5399
  continue;
4802
5400
  const role = normalizeMetricPhrase(extension.requestedTerm);
4803
5401
  const values = extensionsByRole.get(role) ?? [];
@@ -4879,8 +5477,12 @@ export function createHybridRouter(options = {}) {
4879
5477
  if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
4880
5478
  return softBoundaryDecision(request, base, initialDiscoveryRoute);
4881
5479
  }
4882
- let evidence;
4883
- if (options.getEvidence) {
5480
+ // AskAnalystRuntimeV1 acquires one immutable snapshot before invoking
5481
+ // this compiler broker. Reusing that host-only handoff prevents a second
5482
+ // retrieval/ranking decision from silently becoming another source of
5483
+ // truth. Legacy callers retain the original retrieval behavior.
5484
+ let evidence = request.askAnalystEvidence;
5485
+ if (!evidence && options.getEvidence) {
4884
5486
  try {
4885
5487
  evidence = await options.getEvidence(request);
4886
5488
  }
@@ -4911,6 +5513,47 @@ export function createHybridRouter(options = {}) {
4911
5513
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
4912
5514
  : {}),
4913
5515
  };
5516
+ // Authoritative AskAnalystRuntimeV1 already completed the bounded
5517
+ // interpretation/tool phase. Do not re-run legacy candidate ranking,
5518
+ // clarification heuristics, or a provider call here: this layer is a
5519
+ // compiler broker only and can validate exactly the immutable program
5520
+ // selection against safe compiler primitives.
5521
+ if (request.askAnalystProgram && request.askAnalystMeaningResolution) {
5522
+ const programIds = new Set(request.askAnalystProgram.candidateIds);
5523
+ const executionIds = new Set(request.askAnalystProgram.executionCandidateIds
5524
+ ?? request.askAnalystProgram.candidateIds);
5525
+ const programCandidates = evidence.candidates.filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id));
5526
+ const executionCandidates = evidence.candidates.filter((candidate) => executionIds.has(candidate.id) || executionIds.has(candidate.qualifiedId ?? candidate.id));
5527
+ const requirements = request.askAnalystState?.frame.requirements
5528
+ ?? request.hostRequirementSeed?.requirements;
5529
+ if (!requirements || programCandidates.length === 0) {
5530
+ return {
5531
+ ...base,
5532
+ action: 'block',
5533
+ confidence: 1,
5534
+ reason: 'The Ask runtime program could not be compiled because its snapshot-bound candidate selection was unavailable.',
5535
+ source: 'heuristic',
5536
+ followsUp: false,
5537
+ terminalOutcome: {
5538
+ kind: 'modeling_gap',
5539
+ code: 'ANALYTICAL_MODELING_GAP',
5540
+ message: 'The immutable Ask runtime program did not retain a compiler-eligible candidate selection.',
5541
+ candidateIds: request.askAnalystProgram.candidateIds,
5542
+ },
5543
+ };
5544
+ }
5545
+ return compileAskAnalyticalProgramV1({
5546
+ base,
5547
+ request,
5548
+ evidence,
5549
+ program: request.askAnalystProgram,
5550
+ candidates: programCandidates,
5551
+ executionCandidates,
5552
+ resolution: request.askAnalystMeaningResolution,
5553
+ requirements,
5554
+ mode: options.resolvedPlanMode ?? 'authoritative',
5555
+ });
5556
+ }
4914
5557
  // Ranking measure identity is resolved from the same immutable
4915
5558
  // request/evidence frame that drives package reservation. A metric
4916
5559
  // may remain in broad retrieval context, but it cannot become a
@@ -4976,7 +5619,7 @@ export function createHybridRouter(options = {}) {
4976
5619
  // supplied so explicit revenue, entity labels, and time roles cannot be
4977
5620
  // pruned by unrelated lexical matches.
4978
5621
  let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, continuationQuestion);
4979
- traceCandidateLifecycleBeforePruning(request, evidence, retrievedCandidates, candidates);
5622
+ recordAskCandidateLifecycleV1(request, evidence, retrievedCandidates, candidates);
4980
5623
  // The complete already-retrieved set is retained only to validate a
4981
5624
  // server-issued stable selection before any route can fall through to
4982
5625
  // a generic/generated answer. New free-text choices are constrained to
@@ -5118,11 +5761,14 @@ export function createHybridRouter(options = {}) {
5118
5761
  // matched semantic metric remains the only execution/measure
5119
5762
  // authority. A selected dimension must therefore never flow through
5120
5763
  // the metric-only direct-resolution path as the primary candidate.
5764
+ const canonicalLiteral = canonicalLiteralEvidenceReference(request.question, candidates);
5121
5765
  const explicit = selectedDimensionBinding?.metricCandidate
5122
5766
  ?? selectedEvidence
5123
- ?? findExplicitEvidenceReference(request.question, candidates);
5767
+ ?? findExplicitEvidenceReference(request.question, candidates)
5768
+ ?? canonicalLiteral;
5124
5769
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
5125
- || /@(metric|block|model|table|column)\(/i.test(request.question)));
5770
+ || /@(metric|block|model|table|column)\(/i.test(request.question)
5771
+ || canonicalLiteral?.id === explicit.id));
5126
5772
  const shouldUseMeaningCall = requireMeaningCall
5127
5773
  && !explicitMeaningBinding
5128
5774
  && Boolean(options.resolveMeaning || options.complete);
@@ -5199,8 +5845,12 @@ export function createHybridRouter(options = {}) {
5199
5845
  // did run, committing to the best governed reading is the whole point.
5200
5846
  let meaningResolverReachable = true;
5201
5847
  try {
5202
- if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
5203
- return softBoundaryDecision(request, base, 'clarify');
5848
+ // Meaning is the one bounded planning continuation of an ordinary
5849
+ // generated Ask, not a user clarification. Charging it to the
5850
+ // short clarify target was the direct cause of the 1.14.x
5851
+ // "discovery window ended" loop before semantic/raw fallback.
5852
+ if (request.runBudget && !request.runBudget.mayStartDiscovery('generated_answer')) {
5853
+ return softBoundaryDecision(request, base, 'generated_answer');
5204
5854
  }
5205
5855
  let resolution;
5206
5856
  try {
@@ -5490,15 +6140,42 @@ function softBoundaryDecision(request, base, route) {
5490
6140
  const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
5491
6141
  return {
5492
6142
  ...base,
5493
- action: 'clarify',
6143
+ // A budget/admission boundary is an operational condition, never a
6144
+ // business ambiguity. Returning `clarify` here made the UI ask users to
6145
+ // choose a metric or grain even though no competing executable meanings
6146
+ // had been validated. Keep this a typed terminal block so diagnostics can
6147
+ // offer a retry/recovery action without teaching users to compensate for a
6148
+ // runtime deadline.
6149
+ action: 'block',
5494
6150
  confidence: 1,
5495
6151
  source: 'heuristic',
5496
- requiresClarification: true,
6152
+ requiresClarification: false,
6153
+ clarifyingQuestion: undefined,
5497
6154
  reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
5498
- clarifyingQuestion: request.runBudget?.mode === 'research'
5499
- ? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
5500
- : 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
5501
6155
  };
5502
6156
  }
6157
+ /**
6158
+ * A literal stable metric/block identifier is already a user-selected meaning.
6159
+ * It is intentionally narrower than a human label such as "revenue": only
6160
+ * an identifier-shaped value (`foo_bar`, `namespace.metric`, `metric:foo`) or
6161
+ * a supplied exact qualified ID can bypass the one meaning call. This restores
6162
+ * the useful 1.13 zero-call semantic path without turning lexical retrieval
6163
+ * into permission to guess between similarly named business metrics.
6164
+ */
6165
+ function canonicalLiteralEvidenceReference(question, candidates) {
6166
+ const normalizedQuestion = question.toLowerCase();
6167
+ const matches = candidates.filter((candidate) => {
6168
+ if (candidate.compatibility === 'incompatible' || candidate.eligible === false)
6169
+ return false;
6170
+ const identities = [candidate.qualifiedId, candidate.id, candidate.name, ...(candidate.aliases ?? [])]
6171
+ .filter((value) => Boolean(value))
6172
+ .filter((value) => /[_:.]/.test(value));
6173
+ return identities.some((identity) => {
6174
+ const escaped = identity.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6175
+ return new RegExp(`(^|[^a-z0-9_.:-])${escaped}($|[^a-z0-9_.:-])`, 'i').test(normalizedQuestion);
6176
+ });
6177
+ });
6178
+ return matches.length === 1 ? matches[0] : undefined;
6179
+ }
5503
6180
  export { intentForCategory, parseMeaningResolution };
5504
6181
  //# sourceMappingURL=router.js.map