@duckcodeailabs/dql-agent 1.14.2 → 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 (196) hide show
  1. package/dist/agent-run-engine.d.ts +239 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2924 -85
  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-planner.d.ts +6 -0
  9. package/dist/agent-run-planner.d.ts.map +1 -1
  10. package/dist/agent-run-planner.js +2 -0
  11. package/dist/agent-run-planner.js.map +1 -1
  12. package/dist/agent-run-store.d.ts.map +1 -1
  13. package/dist/agent-run-store.js +591 -11
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +9 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +204 -4
  18. package/dist/agentic/analyst-loop.js.map +1 -1
  19. package/dist/agentic/assumptions.d.ts +1 -1
  20. package/dist/agentic/assumptions.d.ts.map +1 -1
  21. package/dist/agentic/assumptions.js.map +1 -1
  22. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  23. package/dist/agentic/ledger-tools.js +8 -1
  24. package/dist/agentic/ledger-tools.js.map +1 -1
  25. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  26. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  27. package/dist/agentic/orchestrator-policy.js +1 -1
  28. package/dist/agentic/orchestrator-policy.js.map +1 -1
  29. package/dist/agentic/research-agent.d.ts.map +1 -1
  30. package/dist/agentic/research-agent.js +10 -1
  31. package/dist/agentic/research-agent.js.map +1 -1
  32. package/dist/agentic/sql-authorization.d.ts +8 -0
  33. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  34. package/dist/agentic/sql-authorization.js +3 -0
  35. package/dist/agentic/sql-authorization.js.map +1 -1
  36. package/dist/agentic/tool-loop.d.ts +13 -1
  37. package/dist/agentic/tool-loop.d.ts.map +1 -1
  38. package/dist/agentic/tool-loop.js +441 -32
  39. package/dist/agentic/tool-loop.js.map +1 -1
  40. package/dist/analytical-compatibility.d.ts.map +1 -1
  41. package/dist/analytical-compatibility.js +8 -1
  42. package/dist/analytical-compatibility.js.map +1 -1
  43. package/dist/analytical-failure-repair.d.ts.map +1 -1
  44. package/dist/analytical-failure-repair.js +6 -2
  45. package/dist/analytical-failure-repair.js.map +1 -1
  46. package/dist/analytical-frame.d.ts +42 -1
  47. package/dist/analytical-frame.d.ts.map +1 -1
  48. package/dist/analytical-frame.js +490 -26
  49. package/dist/analytical-frame.js.map +1 -1
  50. package/dist/analytical-orchestration.d.ts +1259 -9
  51. package/dist/analytical-orchestration.d.ts.map +1 -1
  52. package/dist/analytical-orchestration.js +970 -84
  53. package/dist/analytical-orchestration.js.map +1 -1
  54. package/dist/answer-loop.d.ts +230 -10
  55. package/dist/answer-loop.d.ts.map +1 -1
  56. package/dist/answer-loop.js +1182 -85
  57. package/dist/answer-loop.js.map +1 -1
  58. package/dist/answer-shape.d.ts +27 -0
  59. package/dist/answer-shape.d.ts.map +1 -1
  60. package/dist/answer-shape.js +55 -0
  61. package/dist/answer-shape.js.map +1 -1
  62. package/dist/ask-observability/index.d.ts +11 -0
  63. package/dist/ask-observability/index.d.ts.map +1 -0
  64. package/dist/ask-observability/index.js +7 -0
  65. package/dist/ask-observability/index.js.map +1 -0
  66. package/dist/ask-observability/instrumentation.d.ts +35 -0
  67. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  68. package/dist/ask-observability/instrumentation.js +523 -0
  69. package/dist/ask-observability/instrumentation.js.map +1 -0
  70. package/dist/ask-observability/observer.d.ts +62 -0
  71. package/dist/ask-observability/observer.d.ts.map +1 -0
  72. package/dist/ask-observability/observer.js +537 -0
  73. package/dist/ask-observability/observer.js.map +1 -0
  74. package/dist/ask-observability/portable.d.ts +98 -0
  75. package/dist/ask-observability/portable.d.ts.map +1 -0
  76. package/dist/ask-observability/portable.js +1089 -0
  77. package/dist/ask-observability/portable.js.map +1 -0
  78. package/dist/ask-observability/store.d.ts +130 -0
  79. package/dist/ask-observability/store.d.ts.map +1 -0
  80. package/dist/ask-observability/store.js +1114 -0
  81. package/dist/ask-observability/store.js.map +1 -0
  82. package/dist/ask-observability/types.d.ts +380 -0
  83. package/dist/ask-observability/types.d.ts.map +1 -0
  84. package/dist/ask-observability/types.js +2 -0
  85. package/dist/ask-observability/types.js.map +1 -0
  86. package/dist/ask-observability/utils.d.ts +21 -0
  87. package/dist/ask-observability/utils.d.ts.map +1 -0
  88. package/dist/ask-observability/utils.js +102 -0
  89. package/dist/ask-observability/utils.js.map +1 -0
  90. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  91. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  92. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  93. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  94. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  95. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  96. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  97. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  98. package/dist/conversation/session-store.d.ts +31 -0
  99. package/dist/conversation/session-store.d.ts.map +1 -1
  100. package/dist/conversation/session-store.js +38 -0
  101. package/dist/conversation/session-store.js.map +1 -1
  102. package/dist/conversation/snapshot.d.ts +4 -1
  103. package/dist/conversation/snapshot.d.ts.map +1 -1
  104. package/dist/conversation/snapshot.js +85 -3
  105. package/dist/conversation/snapshot.js.map +1 -1
  106. package/dist/conversation/turn-trust.d.ts.map +1 -1
  107. package/dist/conversation/turn-trust.js +2 -0
  108. package/dist/conversation/turn-trust.js.map +1 -1
  109. package/dist/embeddings/provider.d.ts +1 -0
  110. package/dist/embeddings/provider.d.ts.map +1 -1
  111. package/dist/embeddings/provider.js +18 -0
  112. package/dist/embeddings/provider.js.map +1 -1
  113. package/dist/generated-analytical-proposal.d.ts +37 -1
  114. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  115. package/dist/generated-analytical-proposal.js +246 -3
  116. package/dist/generated-analytical-proposal.js.map +1 -1
  117. package/dist/governed-relational-compiler.d.ts +15 -0
  118. package/dist/governed-relational-compiler.d.ts.map +1 -1
  119. package/dist/governed-relational-compiler.js +99 -1
  120. package/dist/governed-relational-compiler.js.map +1 -1
  121. package/dist/index.d.ts +21 -12
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +14 -7
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +87 -1
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +2 -2
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/kg/build.js +32 -2
  130. package/dist/kg/build.js.map +1 -1
  131. package/dist/meaning-resolution.d.ts +207 -6
  132. package/dist/meaning-resolution.d.ts.map +1 -1
  133. package/dist/meaning-resolution.js +439 -53
  134. package/dist/meaning-resolution.js.map +1 -1
  135. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  136. package/dist/metadata/analysis-planner.js +78 -7
  137. package/dist/metadata/analysis-planner.js.map +1 -1
  138. package/dist/metadata/catalog.d.ts +14 -0
  139. package/dist/metadata/catalog.d.ts.map +1 -1
  140. package/dist/metadata/catalog.js +29 -2
  141. package/dist/metadata/catalog.js.map +1 -1
  142. package/dist/metadata/meaning-evidence.d.ts +28 -2
  143. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  144. package/dist/metadata/meaning-evidence.js +373 -16
  145. package/dist/metadata/meaning-evidence.js.map +1 -1
  146. package/dist/plan-execution-adapter.d.ts +9 -1
  147. package/dist/plan-execution-adapter.d.ts.map +1 -1
  148. package/dist/plan-execution-adapter.js +93 -13
  149. package/dist/plan-execution-adapter.js.map +1 -1
  150. package/dist/provider-egress.d.ts +28 -11
  151. package/dist/provider-egress.d.ts.map +1 -1
  152. package/dist/provider-egress.js +72 -22
  153. package/dist/provider-egress.js.map +1 -1
  154. package/dist/providers/claude.d.ts +2 -2
  155. package/dist/providers/claude.d.ts.map +1 -1
  156. package/dist/providers/claude.js +201 -37
  157. package/dist/providers/claude.js.map +1 -1
  158. package/dist/providers/dispatch.d.ts +27 -1
  159. package/dist/providers/dispatch.d.ts.map +1 -1
  160. package/dist/providers/dispatch.js +63 -1
  161. package/dist/providers/dispatch.js.map +1 -1
  162. package/dist/providers/gemini.js +10 -10
  163. package/dist/providers/gemini.js.map +1 -1
  164. package/dist/providers/index.d.ts +2 -2
  165. package/dist/providers/index.d.ts.map +1 -1
  166. package/dist/providers/index.js +1 -1
  167. package/dist/providers/index.js.map +1 -1
  168. package/dist/providers/ollama.js +15 -15
  169. package/dist/providers/ollama.js.map +1 -1
  170. package/dist/providers/openai.d.ts +2 -2
  171. package/dist/providers/openai.d.ts.map +1 -1
  172. package/dist/providers/openai.js +213 -43
  173. package/dist/providers/openai.js.map +1 -1
  174. package/dist/providers/types.d.ts +131 -1
  175. package/dist/providers/types.d.ts.map +1 -1
  176. package/dist/providers/types.js.map +1 -1
  177. package/dist/relationship-proof.d.ts +81 -0
  178. package/dist/relationship-proof.d.ts.map +1 -1
  179. package/dist/relationship-proof.js +228 -4
  180. package/dist/relationship-proof.js.map +1 -1
  181. package/dist/requirement-clauses.d.ts +23 -0
  182. package/dist/requirement-clauses.d.ts.map +1 -0
  183. package/dist/requirement-clauses.js +70 -0
  184. package/dist/requirement-clauses.js.map +1 -0
  185. package/dist/resolved-analytical-plan.d.ts +18 -4
  186. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  187. package/dist/resolved-analytical-plan.js +953 -50
  188. package/dist/resolved-analytical-plan.js.map +1 -1
  189. package/dist/router.d.ts +118 -2
  190. package/dist/router.d.ts.map +1 -1
  191. package/dist/router.js +2481 -232
  192. package/dist/router.js.map +1 -1
  193. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  194. package/dist/semantic-bridge/compose.js +23 -4
  195. package/dist/semantic-bridge/compose.js.map +1 -1
  196. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -16,12 +16,13 @@ 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, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
19
+ import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, mergeMeaningResolutionWithRequirementSeed, 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
+ import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, categoricalDimensionRequirementTerms, candidateConflictsWithExplicitRankingMeasure, candidateMatchesCategoricalDimensionRequirement, evidenceCandidateRoles, isEntityAttributeCandidate, } from './analytical-orchestration.js';
23
+ import { askTraceObserverForV1 } from './ask-observability/index.js';
23
24
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
24
- import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
25
+ import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, proveSameSnapshotMetricflowRoleExtensionV1, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
25
26
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
26
27
  const DEFAULT_THRESHOLD = 0.7;
27
28
  const DEFAULT_CACHE_SIZE = 200;
@@ -194,20 +195,22 @@ function parseClassification(raw) {
194
195
  rationale,
195
196
  };
196
197
  }
197
- function buildMeaningSystemPrompt() {
198
+ export function buildMeaningSystemPrompt() {
198
199
  return [
199
200
  "You resolve business meaning for DQL, a governed analytics system.",
200
- "The host already performed broad retrieval. Compare ONLY the supplied candidate cards.",
201
- "Select the concept that best matches the full question, entity, dimensions, filters, time grain, formula, aggregation, domain, and conversation context.",
202
- "Trust is an execution preference only after relevance and compatibility. Never select an unrelated certified block over a relevant semantic metric.",
201
+ "The host already performed broad retrieval and supplied a host-owned requirement seed. Compare ONLY the supplied candidate cards against that seed.",
202
+ "Select the supplied candidate IDs that best bind the host-owned metric, entity, dimensions, filters, time grain, ranking, and output requirements.",
203
+ "Trust, route selection, SQL, and the final analytical frame are host-owned. Never select an unrelated certified block over a relevant semantic metric.",
203
204
  "You may reference ONLY candidate IDs supplied below. Never invent an ID, table, column, metric, relationship, or filter value.",
205
+ "Do not rewrite, add, remove, or replace any host-owned requirement. Do not use prior conversation unless the host seed says it is a continuation.",
206
+ "Do not emit SQL, trust labels, an execution route, query intent, or an analytical frame. Those fields are ignored if present for legacy compatibility.",
204
207
  "Use low confidence and recommend clarify when material business meanings remain unresolved.",
205
- "Respond with ONLY one JSON object matching this shape:",
206
- '{"interpretedQuestion":string,"questionType":"definition"|"value"|"ranking"|"trend"|"comparison"|"diagnosis"|"research","selectedConceptIds":string[],"recommendedExecutionId"?:string,"queryIntent":{"measures":string[],"dimensions":string[],"filters":[{"field":string,"value":string}],"timeRange"?:string,"timeGrain"?:string,"order"?:"asc"|"desc","limit"?:number},"analyticalFrame"?:AnalyticalQuestionFrameV2,"rejectedCandidates":[{"id":string,"reason":string}],"confidence":"high"|"medium"|"low","missingInformation":string[],"recommendedRoute":"certified"|"semantic"|"governed_sql"|"exploratory"|"clarify","clarifyingQuestion"?:string}',
207
- "AnalyticalQuestionFrameV2 uses version 2 and exact supplied IDs for metricConceptIds, entityGrainIds, dimensions with roles group_by/filter/display/rank_entity/time_axis, memberBindings, timeContext with bounded periods, comparison, ranking, requestedOutputs, and ambiguity. Include it for value/ranking/trend/comparison requests when the supplied capability facts are sufficient; otherwise report missingInformation.",
208
+ "Respond with ONLY one JSON object matching this minimal candidate-ID shape:",
209
+ '{"selectedCandidateIds":string[],"recommendedExecutionId"?:string,"confidence"?:"high"|"medium"|"low","missingInformation"?:string[]}',
210
+ "`selectedConceptIds` is accepted only for legacy compatibility. All omitted optional fields receive host-owned safe defaults.",
208
211
  ].join("\n");
209
212
  }
210
- function buildMeaningUserPrompt(request, evidence, candidates) {
213
+ export function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
211
214
  const cards = candidates.map((candidate) => ({
212
215
  id: candidate.id,
213
216
  kind: candidate.kind,
@@ -219,6 +222,7 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
219
222
  aggregation: compactText(candidate.aggregation, 120),
220
223
  domain: compactText(candidate.domain, 120),
221
224
  semanticModel: compactText(candidate.semanticModel, 160),
225
+ dataType: compactText(candidate.dataType, 80),
222
226
  primaryEntity: compactText(candidate.primaryEntity, 160),
223
227
  dimensions: compactArray(candidate.dimensions, 16, 120),
224
228
  timeGrains: compactArray(candidate.timeGrains, 8, 80),
@@ -232,21 +236,23 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
232
236
  analyticalCapability: candidate.analyticalCapability,
233
237
  }));
234
238
  const lines = [
235
- `Question: ${compactText(request.question, 2_000)}`,
236
- `Parsed request hints: ${JSON.stringify(compactQueryIntent(defaultQueryIntent(evidence)))}`,
239
+ `Host-owned requirement seed: ${JSON.stringify(requirementSeed)}`,
237
240
  `Candidate cards: ${JSON.stringify(cards)}`,
238
241
  ];
239
242
  const history = effectiveConversationHistory(request);
240
- if (history.length) {
243
+ const continuation = request.conversationBinding ?? 'none';
244
+ if (history.length && continuation !== 'none') {
241
245
  lines.push(`Recent conversation: ${JSON.stringify(history.slice(-4).map((turn) => ({
242
246
  role: turn.role,
243
247
  text: compactText(turn.text, 1_200),
244
248
  })))}`);
245
249
  }
246
- const envelope = renderConversationEnvelopeForPrompt(request.conversationContext);
250
+ const envelope = continuation === 'none'
251
+ ? undefined
252
+ : renderConversationEnvelopeForPrompt(request.conversationContext);
247
253
  if (envelope)
248
254
  lines.push(`Structured conversation state: ${JSON.stringify(envelope)}`);
249
- lines.push("Resolve the intended meaning and return JSON only.");
255
+ lines.push("Bind only supplied candidate IDs to the host-owned seed and return JSON only.");
250
256
  return lines.join("\n");
251
257
  }
252
258
  function compactText(value, maxLength) {
@@ -275,42 +281,62 @@ function compactQueryIntent(intent) {
275
281
  const QUESTION_TYPES = new Set(["definition", "value", "ranking", "trend", "comparison", "diagnosis", "research"]);
276
282
  const MEANING_CONFIDENCES = new Set(["high", "medium", "low"]);
277
283
  const MEANING_ROUTES = new Set(["certified", "semantic", "governed_sql", "exploratory", "clarify"]);
278
- function parseMeaningResolution(raw) {
284
+ function parseMeaningResolution(raw, requirementSeed) {
279
285
  const parsed = extractJsonObject(raw);
280
286
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
281
287
  return undefined;
282
288
  const record = parsed;
283
- if (typeof record.interpretedQuestion !== "string" || !record.interpretedQuestion.trim())
289
+ const interpretedQuestion = typeof record.interpretedQuestion === "string" && record.interpretedQuestion.trim()
290
+ ? record.interpretedQuestion.trim()
291
+ : requirementSeed?.sourceQuestion;
292
+ if (!interpretedQuestion)
284
293
  return undefined;
285
- if (typeof record.questionType !== "string" || !QUESTION_TYPES.has(record.questionType))
294
+ const questionType = typeof record.questionType === "string" && QUESTION_TYPES.has(record.questionType)
295
+ ? record.questionType
296
+ : requirementSeed ? questionTypeFromText(requirementSeed.sourceQuestion) : undefined;
297
+ if (!questionType)
286
298
  return undefined;
287
- if (!Array.isArray(record.selectedConceptIds) || record.selectedConceptIds.some((id) => typeof id !== "string"))
299
+ const selectedConceptIds = Array.isArray(record.selectedCandidateIds)
300
+ ? record.selectedCandidateIds
301
+ : record.selectedConceptIds;
302
+ if (!Array.isArray(selectedConceptIds) || selectedConceptIds.some((id) => typeof id !== "string"))
288
303
  return undefined;
289
- if (typeof record.confidence !== "string" || !MEANING_CONFIDENCES.has(record.confidence))
290
- return undefined;
291
- if (typeof record.recommendedRoute !== "string" || !MEANING_ROUTES.has(record.recommendedRoute))
304
+ const confidence = typeof record.confidence === "string" && MEANING_CONFIDENCES.has(record.confidence)
305
+ ? record.confidence
306
+ : requirementSeed
307
+ ? selectedConceptIds.length > 0 ? 'medium' : 'low'
308
+ : undefined;
309
+ if (!confidence)
292
310
  return undefined;
293
- if (!record.queryIntent || typeof record.queryIntent !== "object" || Array.isArray(record.queryIntent))
311
+ const recommendedRoute = typeof record.recommendedRoute === "string" && MEANING_ROUTES.has(record.recommendedRoute)
312
+ ? record.recommendedRoute
313
+ : requirementSeed ? "clarify" : undefined;
314
+ if (!recommendedRoute)
294
315
  return undefined;
295
- const query = record.queryIntent;
296
- const measures = stringArray(query.measures);
297
- const dimensions = stringArray(query.dimensions);
298
- const missingInformation = stringArray(record.missingInformation);
299
- if (!measures || !dimensions || !missingInformation || !Array.isArray(query.filters))
316
+ const missingInformation = stringArray(record.missingInformation) ?? (requirementSeed ? [] : undefined);
317
+ if (!missingInformation)
300
318
  return undefined;
301
- const filters = query.filters.flatMap((filter) => {
302
- if (!filter || typeof filter !== "object" || Array.isArray(filter))
303
- return [];
304
- const item = filter;
305
- return typeof item.field === "string" && typeof item.value === "string"
306
- ? [{ field: item.field, value: item.value }]
307
- : [];
308
- });
309
- if (filters.length !== query.filters.length)
319
+ const query = record.queryIntent && typeof record.queryIntent === "object" && !Array.isArray(record.queryIntent)
320
+ ? record.queryIntent
321
+ : undefined;
322
+ const measures = query ? stringArray(query.measures) : undefined;
323
+ const dimensions = query ? stringArray(query.dimensions) : undefined;
324
+ const filters = Array.isArray(query?.filters)
325
+ ? query.filters.flatMap((filter) => {
326
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
327
+ return [];
328
+ const item = filter;
329
+ return typeof item.field === "string" && typeof item.value === "string"
330
+ ? [{ field: item.field, value: item.value }]
331
+ : [];
332
+ })
333
+ : undefined;
334
+ if (!requirementSeed && (!measures || !dimensions || !filters || !Array.isArray(query?.filters) || filters.length !== query.filters.length))
310
335
  return undefined;
311
- if (!Array.isArray(record.rejectedCandidates))
336
+ const rejectedCandidatesRaw = record.rejectedCandidates;
337
+ if (rejectedCandidatesRaw !== undefined && !Array.isArray(rejectedCandidatesRaw))
312
338
  return undefined;
313
- const rejectedCandidates = record.rejectedCandidates.flatMap((candidate) => {
339
+ const rejectedCandidates = (rejectedCandidatesRaw ?? []).flatMap((candidate) => {
314
340
  if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
315
341
  return [];
316
342
  const item = candidate;
@@ -318,42 +344,66 @@ function parseMeaningResolution(raw) {
318
344
  ? [{ id: item.id, reason: item.reason }]
319
345
  : [];
320
346
  });
321
- if (rejectedCandidates.length !== record.rejectedCandidates.length)
347
+ if (rejectedCandidates.length !== (rejectedCandidatesRaw?.length ?? 0))
322
348
  return undefined;
323
349
  const recommendedExecutionId = typeof record.recommendedExecutionId === "string"
324
350
  ? record.recommendedExecutionId
325
351
  : undefined;
326
- const clarifyingQuestion = typeof record.clarifyingQuestion === "string" &&
327
- record.clarifyingQuestion.trim()
352
+ const clarifyingQuestion = typeof record.clarifyingQuestion === "string" && record.clarifyingQuestion.trim()
328
353
  ? record.clarifyingQuestion.trim()
329
354
  : undefined;
330
- const analyticalFrame = record.analyticalFrame === undefined
355
+ const providerExplicitlyClarified = (recommendedRoute === 'clarify'
356
+ && typeof record.recommendedRoute === 'string')
357
+ || Boolean(clarifyingQuestion)
358
+ || missingInformation.length > 0
359
+ || rejectedCandidates.length > 0;
360
+ // Legacy callers may still send a V2 frame. It remains readable without a
361
+ // seed, but a seeded meaning call must never grant a model frame authority.
362
+ const analyticalFrame = requirementSeed || record.analyticalFrame === undefined
331
363
  ? undefined
332
364
  : normalizeAnalyticalQuestionFrameV2(record.analyticalFrame);
333
- if (record.analyticalFrame !== undefined && !analyticalFrame)
365
+ if (!requirementSeed && record.analyticalFrame !== undefined && !analyticalFrame)
334
366
  return undefined;
335
367
  return {
336
- interpretedQuestion: record.interpretedQuestion.trim(),
337
- questionType: record.questionType,
338
- selectedConceptIds: record.selectedConceptIds,
368
+ interpretedQuestion,
369
+ questionType,
370
+ selectedConceptIds: selectedConceptIds,
339
371
  ...(recommendedExecutionId ? { recommendedExecutionId } : {}),
340
- queryIntent: {
341
- measures,
342
- dimensions,
343
- filters,
344
- ...(typeof query.timeRange === "string" ? { timeRange: query.timeRange } : {}),
345
- ...(typeof query.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
346
- ...(query.order === "asc" || query.order === "desc" ? { order: query.order } : {}),
347
- ...(typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
348
- ? { limit: Math.floor(query.limit) }
349
- : {}),
350
- },
372
+ queryIntent: requirementSeed
373
+ ? queryIntentFromRequirementSeed(requirementSeed)
374
+ : {
375
+ measures: measures,
376
+ dimensions: dimensions,
377
+ filters: filters,
378
+ ...(typeof query?.timeRange === "string" ? { timeRange: query.timeRange } : {}),
379
+ ...(typeof query?.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
380
+ ...(query?.order === "asc" || query?.order === "desc" ? { order: query.order } : {}),
381
+ ...(typeof query?.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
382
+ ? { limit: Math.floor(query.limit) }
383
+ : {}),
384
+ },
351
385
  rejectedCandidates,
352
- confidence: record.confidence,
386
+ confidence,
353
387
  missingInformation,
354
- recommendedRoute: record.recommendedRoute,
388
+ recommendedRoute,
355
389
  ...(clarifyingQuestion ? { clarifyingQuestion } : {}),
356
390
  ...(analyticalFrame ? { analyticalFrame } : {}),
391
+ ...(selectedConceptIds.length === 0 && !recommendedExecutionId && !providerExplicitlyClarified
392
+ ? { emptyCandidateBinding: true }
393
+ : {}),
394
+ };
395
+ }
396
+ function queryIntentFromRequirementSeed(seed) {
397
+ return {
398
+ measures: [...seed.queryIntent.measures],
399
+ dimensions: [...seed.queryIntent.dimensions],
400
+ filters: seed.queryIntent.filters.map((filter) => ({ field: filter.field, value: filter.value })),
401
+ ...(seed.queryIntent.timeRange ? { timeRange: seed.queryIntent.timeRange } : {}),
402
+ ...(seed.queryIntent.timeGrain ? { timeGrain: seed.queryIntent.timeGrain } : {}),
403
+ ...(seed.queryIntent.order ? { order: seed.queryIntent.order } : {}),
404
+ ...(seed.queryIntent.limit !== undefined ? { limit: seed.queryIntent.limit } : {}),
405
+ ...(seed.queryIntent.fiscalCalendarId ? { fiscalCalendarId: seed.queryIntent.fiscalCalendarId } : {}),
406
+ ...(seed.queryIntent.fiscalDateRoleId ? { fiscalDateRoleId: seed.queryIntent.fiscalDateRoleId } : {}),
357
407
  };
358
408
  }
359
409
  function stringArray(value) {
@@ -408,13 +458,228 @@ function conversationPositionToken(context) {
408
458
  return `${latest}:${turns.length}:${recent}:${tailId}`;
409
459
  }
410
460
  function retrievalTrace(evidence, candidates) {
461
+ const candidateTraceMetadata = candidates.flatMap((candidate) => evidenceCandidateRoles(candidate).map((role) => ({
462
+ // `id` is the identity accepted by the meaning resolver. It is already
463
+ // source-qualified by the retrieval contract; keep it so later selection
464
+ // IDs can be joined without guessing a legacy-to-canonical mapping.
465
+ candidateId: candidate.id,
466
+ role,
467
+ source: traceSourceForCandidate(candidate),
468
+ ...(candidate.retrievalLanes?.length ? { lanes: candidate.retrievalLanes } : {}),
469
+ })));
411
470
  return {
412
471
  ...(evidence.snapshotId ? { snapshotId: evidence.snapshotId } : {}),
413
472
  ...(evidence.sourceFingerprint ? { sourceFingerprint: evidence.sourceFingerprint } : {}),
473
+ ...(evidence.continuityFingerprint ? { continuityFingerprint: evidence.continuityFingerprint } : {}),
414
474
  candidateCount: candidates.length,
415
475
  candidateIds: candidates.map((candidate) => candidate.id),
476
+ ...(candidateTraceMetadata.length ? { candidateTraceMetadata } : {}),
416
477
  };
417
478
  }
479
+ /**
480
+ * Observability is a projection of snapshot-bound evidence, never a second
481
+ * retriever or a route authority. Keep source classification conservative so
482
+ * an absent lane is not manufactured from an arbitrary ID prefix.
483
+ */
484
+ function traceSourceForCandidate(candidate) {
485
+ // Qualified candidate identities preserve their originating index. Prefer
486
+ // that declared provenance for collapsed `sql_column`/`sql_table` kinds: a
487
+ // dbt manifest column must not be relabelled as runtime-schema evidence.
488
+ const identity = candidate.qualifiedId ?? candidate.id;
489
+ if (identity.startsWith('dbt:'))
490
+ return 'dbt_manifest';
491
+ if (identity.startsWith('runtime:'))
492
+ return 'runtime_schema';
493
+ if (identity.startsWith('semantic:'))
494
+ return 'semantic';
495
+ if (identity.startsWith('dql:'))
496
+ return candidate.kind === 'certified_block' ? 'certified' : 'governed_relational';
497
+ if (candidate.kind === 'certified_block')
498
+ return 'certified';
499
+ if (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
500
+ return 'semantic';
501
+ if (candidate.kind === 'dql_modeling')
502
+ return 'governed_relational';
503
+ if (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source')
504
+ return 'dbt_manifest';
505
+ if (candidate.kind === 'sql_table' || candidate.kind === 'sql_column')
506
+ return 'runtime_schema';
507
+ return 'exploratory';
508
+ }
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) {
515
+ const observer = askTraceObserverForV1(request);
516
+ if (!observer.enabled)
517
+ return;
518
+ const packageIds = new Set(packageCandidates.map((candidate) => candidate.id));
519
+ const fuse = observer.startSpan({
520
+ name: 'retrieval.fuse',
521
+ stage: 'retrieval',
522
+ payload: { kind: 'retrieval', candidateCount: candidates.length },
523
+ });
524
+ const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
525
+ const requestedRoles = new Set([
526
+ ...(requirements.measures.length || requirements.ranking?.metricTerms.length ? ['metric'] : []),
527
+ ...(requirements.entityTerms.length || requirements.entityDisplayTerms.length ? ['entity_label'] : []),
528
+ ...(requirements.time ? ['time_dimension'] : []),
529
+ ...(requirements.dimensions.length ? ['categorical_dimension'] : []),
530
+ ...(requirements.dimensions.length > 1 || requirements.entityTerms.length ? ['relationship'] : []),
531
+ ]);
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;
539
+ const hasRequestedEntityLabel = requirements.entityTerms.length > 0 || requirements.entityDisplayTerms.length > 0;
540
+ for (const [index, candidate] of candidates.slice(0, rawCandidateTraceLimit).entries()) {
541
+ const roles = evidenceCandidateRoles(candidate);
542
+ const source = traceSourceForCandidate(candidate);
543
+ // This receipt must retain the actual retrieval memberships captured by
544
+ // the snapshot. Do not infer a lane from aliases, IDs, or a relationship
545
+ // attribute after fusion has already lost that physical provenance.
546
+ const lanes = candidate.retrievalLanes
547
+ ?.map((entry) => ({ ...entry }))
548
+ .sort((left, right) => (left.rank ?? Number.MAX_SAFE_INTEGER) - (right.rank ?? Number.MAX_SAFE_INTEGER)
549
+ || left.lane.localeCompare(right.lane));
550
+ const lane = lanes?.[0]?.lane;
551
+ const laneRank = lanes?.[0]?.rank;
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';
559
+ const compatibilityCode = candidate.compatibility === 'compatible' ? 'compatible'
560
+ : candidate.compatibility === 'incompatible' ? 'operation_unsupported'
561
+ : 'unknown';
562
+ for (const role of roles) {
563
+ const common = {
564
+ // The resolver and cascade use `id`; keeping that exact stable identity
565
+ // makes retrieval, admission, and model-selection receipts joinable.
566
+ candidateId: candidate.id,
567
+ role,
568
+ source,
569
+ ...(lane ? { lane } : {}),
570
+ ...(laneRank !== undefined ? { laneRank } : {}),
571
+ ...(lanes?.length ? { lanes } : {}),
572
+ fusedRank: index + 1,
573
+ compatibilityCode,
574
+ };
575
+ observer.recordCandidateDecision({
576
+ ...common,
577
+ decision: 'retrieved',
578
+ reasonCode: candidate.eligible === false ? 'role_mismatch' : initialReason,
579
+ });
580
+ // An explicit ranking measure is a typed request, not a relevance hint.
581
+ // Keep correlated metrics in the receipt, but record their exclusion at
582
+ // the metric admission boundary instead of allowing them to become
583
+ // clarification options or to fill an unrelated analytical role.
584
+ if (role === 'metric' && candidateConflictsWithExplicitRankingMeasure(candidate, requirements)) {
585
+ observer.recordCandidateDecision({
586
+ ...common,
587
+ decision: 'excluded',
588
+ reasonCode: 'explicit_measure_conflict',
589
+ });
590
+ continue;
591
+ }
592
+ if (candidate.eligible === false || candidate.compatibility === 'incompatible') {
593
+ observer.recordCandidateDecision({
594
+ ...common,
595
+ decision: 'excluded',
596
+ reasonCode: candidate.compatibility === 'incompatible' ? 'capability_incompatible' : 'role_mismatch',
597
+ });
598
+ continue;
599
+ }
600
+ if (packageIds.has(candidate.id)) {
601
+ const reservedForRole = requestedRoles.has(role);
602
+ const unresolvedRoleAdmission = candidate.matchReasons.some((reason) => reason.startsWith(`candidate_for_unresolved_role:${role}:`));
603
+ observer.recordCandidateDecision({
604
+ ...common,
605
+ decision: 'reserved',
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',
610
+ });
611
+ observer.recordCandidateDecision({
612
+ ...common,
613
+ decision: 'admitted',
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',
618
+ });
619
+ }
620
+ else {
621
+ const isNoisyEntityAttribute = hasRequestedEntityLabel
622
+ && isEntityAttributeCandidate(candidate)
623
+ && role === 'categorical_dimension';
624
+ observer.recordCandidateDecision({
625
+ ...common,
626
+ decision: 'excluded',
627
+ reasonCode: isNoisyEntityAttribute ? 'entity_label_mismatch' : 'below_fused_limit',
628
+ });
629
+ }
630
+ }
631
+ }
632
+ observer.finishSpan(fuse, { outcome: 'ok', reasonCode: 'completed' });
633
+ }
634
+ /**
635
+ * Emit source-bound lane summaries before the meaning-package cap applies.
636
+ * These spans are evidence about the existing retrieval result only; they do
637
+ * not invoke, retry, or reinterpret any retrieval source.
638
+ */
639
+ function traceRetrievalLanesBeforePruning(request, evidence, candidates) {
640
+ const observer = askTraceObserverForV1(request);
641
+ if (!observer.enabled)
642
+ return;
643
+ const sourceSpan = {
644
+ certified: 'retrieval.certified',
645
+ semantic: 'retrieval.semantic',
646
+ governed_relational: 'retrieval.governed_relational',
647
+ dbt_manifest: 'retrieval.dbt_manifest',
648
+ runtime_schema: 'retrieval.runtime_schema',
649
+ vector: 'retrieval.vector',
650
+ conversation: 'retrieval.conversation',
651
+ };
652
+ const record = (name, source, count, coverage, lane) => {
653
+ const span = observer.startSpan({
654
+ name,
655
+ stage: 'retrieval',
656
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
657
+ payload: { kind: 'retrieval', ...(source ? { source } : {}), ...(lane ? { lane } : {}), candidateCount: count, ...(coverage ? { coverage } : {}) },
658
+ });
659
+ observer.finishSpan(span, {
660
+ outcome: coverage === 'unavailable' ? 'unavailable' : 'ok',
661
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
662
+ });
663
+ };
664
+ for (const coverage of sourceCoverageFromEvidence(evidence, candidates)) {
665
+ const name = sourceSpan[coverage.source];
666
+ if (!name)
667
+ continue;
668
+ record(name, coverage.source, coverage.candidateIds.length, coverage.status, coverage.source === 'vector' ? 'vector' : coverage.source === 'conversation' ? 'conversation' : undefined);
669
+ }
670
+ const laneSpan = {
671
+ exact: 'retrieval.exact',
672
+ lexical: 'retrieval.lexical',
673
+ vector: 'retrieval.vector',
674
+ graph: 'retrieval.graph',
675
+ conversation: 'retrieval.conversation',
676
+ };
677
+ for (const lane of Object.keys(laneSpan)) {
678
+ const count = candidates.filter((candidate) => candidate.retrievalLanes?.some((membership) => membership.lane === lane)).length;
679
+ if (count > 0)
680
+ record(laneSpan[lane], undefined, count, 'available', lane);
681
+ }
682
+ }
418
683
  /** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
419
684
  function sourceCoverageFromEvidence(evidence, candidates) {
420
685
  const supplied = new Map((evidence.diagnostics?.sourceCoverage ?? []).map((coverage) => [coverage.source, coverage]));
@@ -535,6 +800,9 @@ function withNormalizedAnalyticalRequirements(evidence, question) {
535
800
  ...evidence.parsedIntent,
536
801
  measures: requirements.measures,
537
802
  dimensions: requirements.dimensions,
803
+ ...(requirements.time?.grain && !evidence.parsedIntent.timeGrain
804
+ ? { timeGrain: requirements.time.grain }
805
+ : {}),
538
806
  },
539
807
  };
540
808
  }
@@ -577,11 +845,160 @@ function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
577
845
  return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
578
846
  || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
579
847
  }
580
- function safeRelationshipProofsForCandidate(candidate, requiredJoinKey) {
848
+ /**
849
+ * Relationship evidence has three deliberately different authorities:
850
+ *
851
+ * - governed: a certified, fresh proof may compile a governed relational plan;
852
+ * - exploratory: an explicitly allowed, validated relation may close a raw
853
+ * review-required SQL plan after normal runtime validation; and
854
+ * - hint: anything weaker may improve retrieval, but can never add a join.
855
+ *
856
+ * The first authority remains intentionally stricter. Reusing it for raw
857
+ * fallback is what made a missing semantic capability look like a terminal
858
+ * absence even when the dbt/runtime snapshot had the exact relations, keys,
859
+ * and fields required to inspect it safely. This helper never upgrades a
860
+ * draft relationship to governed trust: callers that use it must select the
861
+ * exploratory tier and retain review_required provenance.
862
+ */
863
+ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
864
+ const validation = safety.validation;
865
+ const checkedAt = Date.parse(validation?.checkedAt ?? '');
866
+ const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
867
+ const expirationInvalid = safety.evidenceExpiresAt !== undefined
868
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
869
+ const lifecycleAllowsExploration = safety.status === 'certified'
870
+ || safety.status === 'validated'
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
+ }
900
+ if (!lifecycleAllowsExploration
901
+ || safety.staleCertification === true
902
+ || safety.automaticJoinAllowed !== true
903
+ || safety.fanout !== 'safe'
904
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
905
+ || !safety.from?.trim()
906
+ || !safety.to?.trim()
907
+ || !validation
908
+ || validation.status !== 'passed'
909
+ || !validation.queryFingerprint?.trim()
910
+ || !validation.proofFingerprint?.trim()
911
+ || !Number.isFinite(checkedAt)
912
+ || expirationInvalid
913
+ || !keysAreUsable)
914
+ return false;
915
+ if (!requiredJoinKey)
916
+ return true;
917
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
918
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
919
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
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
+ }
992
+ function relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey) {
993
+ return authority === 'governed'
994
+ ? relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey)
995
+ : relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey);
996
+ }
997
+ function safeRelationshipProofsForCandidate(candidate, requiredJoinKey, authority = 'governed') {
581
998
  const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
582
999
  const proofs = new Map();
583
1000
  for (const safety of candidate.relationshipSafety ?? []) {
584
- if (!relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey))
1001
+ if (!relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey))
585
1002
  continue;
586
1003
  if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
587
1004
  continue;
@@ -596,6 +1013,210 @@ function candidateRelationshipEndpoints(candidate) {
596
1013
  candidate.analyticalCapability?.primaryEntityId ?? '',
597
1014
  ].map(normalizedRelationshipIdentity).filter(Boolean));
598
1015
  }
1016
+ /**
1017
+ * An attribution-required relationship is intentionally not a generic join
1018
+ * failure. It becomes terminal only when the user explicitly asks to rank an
1019
+ * un-attributed/attribution-scoped signal and the same immutable snapshot
1020
+ * proves that getting from that signal to the requested entity would require
1021
+ * the declared attribution edge. This keeps neutral unsafe relationship
1022
+ * evidence available for the normal clarification path (AGT-029), while
1023
+ * preventing a bare-ranking metric picker from masking a known governance
1024
+ * boundary with unrelated options.
1025
+ */
1026
+ const EXPLICIT_ATTRIBUTION_REQUEST_RE = /\b(?:un[-\s]?attributed|attribution|allocation|allocate)\b/i;
1027
+ function relationshipEndpointMatchesQuestionConcept(endpoint, question) {
1028
+ const leaf = normalizedRelationshipIdentity(endpoint).split('::').at(-1) ?? endpoint;
1029
+ const concept = normalizeMetricPhrase(leaf);
1030
+ const conceptTokens = substantiveLexicalTokens(concept);
1031
+ const questionTokens = new Set(substantiveLexicalTokens(question));
1032
+ return conceptTokens.length >= 2 && conceptTokens.every((token) => questionTokens.has(token));
1033
+ }
1034
+ function relationshipEndpointMatchesEntityTerms(endpoint, entityTerms) {
1035
+ const normalizedEndpoint = normalizeMetricPhrase(endpoint);
1036
+ return entityTerms.some((term) => metricTermsMatch(normalizedEndpoint, normalizeMetricPhrase(term)));
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
+ }
1055
+ function relationshipGraphReaches(starts, targets, relationships) {
1056
+ if (starts.size === 0 || targets.size === 0)
1057
+ return false;
1058
+ const graph = new Map();
1059
+ for (const relationship of relationships) {
1060
+ const from = scopedRelationshipEndpointIdentity(relationship.from, relationship);
1061
+ const to = scopedRelationshipEndpointIdentity(relationship.to, relationship);
1062
+ if (!from || !to)
1063
+ continue;
1064
+ const fromNeighbors = graph.get(from) ?? new Set();
1065
+ fromNeighbors.add(to);
1066
+ graph.set(from, fromNeighbors);
1067
+ const toNeighbors = graph.get(to) ?? new Set();
1068
+ toNeighbors.add(from);
1069
+ graph.set(to, toNeighbors);
1070
+ }
1071
+ const pending = [...starts];
1072
+ const visited = new Set(pending);
1073
+ while (pending.length > 0) {
1074
+ const current = pending.shift();
1075
+ if (targets.has(current))
1076
+ return true;
1077
+ for (const next of graph.get(current) ?? []) {
1078
+ if (!visited.has(next)) {
1079
+ visited.add(next);
1080
+ pending.push(next);
1081
+ }
1082
+ }
1083
+ }
1084
+ return false;
1085
+ }
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) {
1094
+ if (!EXPLICIT_ATTRIBUTION_REQUEST_RE.test(input.request.question)
1095
+ || input.requirements.entityTerms.length === 0)
1096
+ return undefined;
1097
+ // Use the complete same-snapshot retrieval result. The compact provider
1098
+ // package is intentionally allowed to omit a relationship card; omission
1099
+ // cannot erase an authored governance restriction before it is evaluated.
1100
+ const snapshotCandidates = [
1101
+ ...input.evidence.candidates,
1102
+ ...(input.evidence.clarificationCandidates ?? []),
1103
+ ].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
1104
+ const relationships = [...new Map(snapshotCandidates
1105
+ .flatMap((candidate) => candidate.relationshipSafety ?? [])
1106
+ .map((relationship) => [relationship.id, relationship])).values()]
1107
+ .filter((relationship) => Boolean(relationship.from?.trim() && relationship.to?.trim()));
1108
+ const attributionRequired = relationships.filter((relationship) => relationship.fanout === 'attribution_required');
1109
+ if (attributionRequired.length === 0)
1110
+ return undefined;
1111
+ const targetEndpoints = new Set(snapshotCandidates
1112
+ .flatMap((candidate) => [...candidateRelationshipEndpoints(candidate)])
1113
+ .filter((endpoint) => relationshipEndpointMatchesEntityTerms(endpoint, input.requirements.entityTerms)));
1114
+ if (targetEndpoints.size === 0)
1115
+ return undefined;
1116
+ const attributed = attributionRequired.find((relationship) => {
1117
+ const endpoints = [
1118
+ scopedRelationshipEndpointIdentity(relationship.from, relationship),
1119
+ scopedRelationshipEndpointIdentity(relationship.to, relationship),
1120
+ ].filter(Boolean);
1121
+ const requestedSignalEndpoints = new Set(endpoints.filter((endpoint) => relationshipEndpointMatchesQuestionConcept(endpoint, input.request.question)));
1122
+ if (requestedSignalEndpoints.size === 0)
1123
+ return false;
1124
+ // The declared graph can demonstrate why the requested entity would need
1125
+ // this relation, but only a fully certified/fanout-safe graph may
1126
+ // authorize automatic composition. A path that requires this edge is a
1127
+ // typed modeling gap, never a reason to invent an attribution join.
1128
+ const allReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, relationships);
1129
+ const safeRelationships = relationships.filter((candidate) => relationshipSafetyAllowsAutomaticJoin(candidate));
1130
+ const safelyReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, safeRelationships);
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;
1142
+ });
1143
+ if (!attributed)
1144
+ return undefined;
1145
+ const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
1146
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1147
+ const skipped = (tier) => {
1148
+ const item = coverageFor(tier);
1149
+ return {
1150
+ version: 1,
1151
+ tier,
1152
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1153
+ candidateIds: item?.candidateIds ?? [],
1154
+ reason: item?.status === 'available'
1155
+ ? `The ${tier} tier did not prove the complete attribution-safe requested tuple before plan freeze.`
1156
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1157
+ planFrozen: false,
1158
+ };
1159
+ };
1160
+ const message = 'The requested un-attributed signal requires a certified attribution relationship or approved allocation policy before it can be ranked by the requested entity. DQL did not infer a relationship or execute a query.';
1161
+ const witnessCandidateIds = [attributed.id];
1162
+ const terminalGap = {
1163
+ code: 'MISSING_RELATIONSHIP',
1164
+ missing: ['a certified attribution relationship or approved allocation policy for the requested un-attributed signal'],
1165
+ witnessCandidateIds,
1166
+ };
1167
+ const observer = askTraceObserverForV1(input.request);
1168
+ for (const candidate of snapshotCandidates.filter((candidate) => (candidate.relationshipSafety ?? []).some((relationship) => relationship.id === attributed.id))) {
1169
+ observer.recordCandidateDecision({
1170
+ candidateId: candidate.id,
1171
+ role: 'relationship',
1172
+ source: traceSourceForCandidate(candidate),
1173
+ decision: 'excluded',
1174
+ reasonCode: 'policy_denied',
1175
+ compatibilityCode: 'unknown',
1176
+ });
1177
+ }
1178
+ return {
1179
+ ...input.base,
1180
+ action: 'block',
1181
+ confidence: 1,
1182
+ reason: message,
1183
+ source: 'heuristic',
1184
+ category: 'data_lookup',
1185
+ depth: 'quick',
1186
+ requiresClarification: false,
1187
+ clarifyingQuestion: undefined,
1188
+ clarificationOptions: undefined,
1189
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
1190
+ terminalOutcome: {
1191
+ kind: 'modeling_gap',
1192
+ code: 'ANALYTICAL_MODELING_GAP',
1193
+ message,
1194
+ candidateIds: witnessCandidateIds,
1195
+ gap: terminalGap,
1196
+ },
1197
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
1198
+ requirements: input.requirements,
1199
+ sourceCoverage: coverage,
1200
+ attempts: [
1201
+ skipped('certified'),
1202
+ skipped('semantic'),
1203
+ {
1204
+ version: 1,
1205
+ tier: 'governed_relational',
1206
+ outcome: 'denied',
1207
+ candidateIds: witnessCandidateIds,
1208
+ reason: 'The declared relationship requires attribution or allocation approval and cannot authorize the requested ranking.',
1209
+ planFrozen: false,
1210
+ },
1211
+ ],
1212
+ planFrozen: false,
1213
+ terminalGap: cascadeTerminalRelationshipGap(terminalGap),
1214
+ stopReason: 'denied',
1215
+ }),
1216
+ resolvedAnalyticalPlan: undefined,
1217
+ meaningResolution: undefined,
1218
+ };
1219
+ }
599
1220
  /**
600
1221
  * A proof may be structurally safe yet belong to a different domain's relation
601
1222
  * with the same leaf name. The selected pair must match the proof's canonical
@@ -609,9 +1230,9 @@ function relationshipProofMatchesSelectedPair(safety, left, right) {
609
1230
  return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
610
1231
  || (leftEndpoints.has(to) && rightEndpoints.has(from))));
611
1232
  }
612
- function safeRelationshipProofsForPair(left, right, requiredJoinKey) {
613
- const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey);
614
- const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey);
1233
+ function safeRelationshipProofsForPair(left, right, requiredJoinKey, authority = 'governed') {
1234
+ const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey, authority);
1235
+ const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey, authority);
615
1236
  const shared = new Map();
616
1237
  for (const [id, proof] of leftProofs) {
617
1238
  const pairedProof = rightProofs.get(id);
@@ -637,7 +1258,13 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
637
1258
  // their safe-value validation happens independently from proving the raw
638
1259
  // physical closure. Treating them as fields made a valid relation appear
639
1260
  // unmodeled and produced a false absence diagnostic.
640
- const terms = [...new Set([...missingDimensions, ...requirements.measures, ...requiredPhysicalFieldTerms]
1261
+ const terms = [...new Set([
1262
+ ...missingDimensions,
1263
+ ...requirements.measures,
1264
+ ...requirements.entityDisplayTerms,
1265
+ ...(requirements.outputTerms ?? []),
1266
+ ...requiredPhysicalFieldTerms,
1267
+ ]
641
1268
  .map(normalizeMetricPhrase)
642
1269
  .filter(Boolean))];
643
1270
  if (terms.length === 0) {
@@ -668,6 +1295,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
668
1295
  const physicalRoleAliases = {
669
1296
  'product category': ['product type'],
670
1297
  category: ['product type'],
1298
+ customer: ['customer name'],
1299
+ account: ['account name'],
671
1300
  // `product_price` is a local revenue witness only when the same request
672
1301
  // also requires product-category grain. It must not let a bare selected
673
1302
  // revenue metric bypass its semantic contract through an unrelated raw
@@ -762,7 +1391,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
762
1391
  // routinely contains unrelated raw tables; requiring every one to join made
763
1392
  // a complete local path appear unavailable. Conversely, never infer a join
764
1393
  // from names or shared column strings: the proof must retain the structured
765
- // certified, validated, fanout-safe disposition above.
1394
+ // exploratory-safe disposition above. A selected path remains
1395
+ // review-required; it is not a governed relational plan.
766
1396
  const adjacent = relations.map(() => new Set());
767
1397
  const joinColumnsForProof = (proof, left, right) => {
768
1398
  const leftEndpoints = candidateRelationshipEndpoints(left);
@@ -793,7 +1423,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
793
1423
  const witnessesForEdge = new Map();
794
1424
  for (let left = 0; left < relations.length; left += 1) {
795
1425
  for (let right = left + 1; right < relations.length; right += 1) {
796
- const shared = [...safeRelationshipProofsForPair(relations[left], relations[right]).values()]
1426
+ const shared = [...safeRelationshipProofsForPair(relations[left], relations[right], undefined, 'exploratory').values()]
797
1427
  .sort((first, second) => first.id.localeCompare(second.id))
798
1428
  .flatMap((proof) => {
799
1429
  const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
@@ -853,7 +1483,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
853
1483
  return {
854
1484
  ok: false,
855
1485
  candidateIds: [],
856
- reason: 'Multiple physical relations lacked one connected, certified, validated, fanout-safe automatic-join path.',
1486
+ reason: 'Multiple physical relations lacked one connected, structured, fanout-safe exploratory join path.',
857
1487
  // This is not a lexical conclusion. Every requested physical field was
858
1488
  // found above, no one relation covered the tuple, and the structured
859
1489
  // relationship/fanout proof graph could not connect the required
@@ -861,7 +1491,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
861
1491
  // receipts and repair guidance.
862
1492
  gap: {
863
1493
  code: 'MISSING_RELATIONSHIP',
864
- missing: ['a connected certified, validated, fanout-safe relationship proof'],
1494
+ missing: ['a connected, explicitly allowed, validated, fanout-safe relationship proof'],
865
1495
  witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
866
1496
  },
867
1497
  };
@@ -875,7 +1505,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
875
1505
  if (selectedWitnesses.length !== selected.edges.length) {
876
1506
  return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
877
1507
  }
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.');
1508
+ return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and explicitly allowed relationship proofs support a bounded exploratory plan.');
879
1509
  }
880
1510
  /**
881
1511
  * The pre-freeze cascade is router authority, including when the bounded
@@ -888,27 +1518,84 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
888
1518
  function preFreezePhysicalCascadeDecision(input) {
889
1519
  // `candidates` can be the capped meaning package. Physical eligibility is
890
1520
  // 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;
1521
+ const snapshotCandidates = targetScopedSameSnapshotCandidates(input.evidence, input.candidates, input.targetScope, input.executionCandidateIds);
894
1522
  const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
895
1523
  const missingRankingMetric = Boolean(physicalPath.ok
896
1524
  && input.requireRankingMetric
897
1525
  && input.requirements.ranking
898
1526
  && input.requirements.ranking.metricTerms.length === 0);
899
1527
  const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
1528
+ const selectedPhysicalLegacyIds = snapshotCandidates
1529
+ .filter((candidate) => physicalPath.candidateIds.includes(candidate.qualifiedId ?? candidate.id))
1530
+ .map((candidate) => candidate.id);
1531
+ const selectedPhysicalIdsForPlan = selectedPhysicalLegacyIds.length > 0
1532
+ ? selectedPhysicalLegacyIds
1533
+ : physicalPath.candidateIds;
1534
+ // The physical cascade is still an authoritative Ask route, not a legacy
1535
+ // escape hatch. Recreate the same host-owned seed used before the meaning
1536
+ // call so a semantic miss cannot erase an explicit entity display key,
1537
+ // member/filter, ranking limit, time role, or output tuple on the way to
1538
+ // review-required exploration.
1539
+ const hostRequirementSeed = buildAnalyticalRequirementSeedV1({
1540
+ question: input.question,
1541
+ parsedIntent: input.evidence.parsedIntent,
1542
+ requirements: input.requirements,
1543
+ fiscalCalendar: declaredFiscalCalendar(input.evidence, snapshotCandidates),
1544
+ });
1545
+ // The router, not the SQL generator, owns analytical meaning and the
1546
+ // physical closure. Freeze that selected exploratory plan before a model
1547
+ // sees SQL work. The host later attaches an authorization receipt for the
1548
+ // exact read-only SQL/target; it must never be the event that changes the
1549
+ // selected tier or reinterprets this request.
1550
+ const exploratoryMeaning = exploratoryExecutable
1551
+ ? {
1552
+ interpretedQuestion: input.question,
1553
+ questionType: questionTypeFromText(input.question),
1554
+ selectedConceptIds: selectedPhysicalIdsForPlan,
1555
+ recommendedExecutionId: selectedPhysicalIdsForPlan[0],
1556
+ queryIntent: queryIntentFromRequirementSeed(hostRequirementSeed),
1557
+ rejectedCandidates: [],
1558
+ // Low confidence is intentionally non-executable in a resolved plan.
1559
+ // This is a router-proven physical closure, not a speculative model
1560
+ // answer, so the confidence describes the selected route only.
1561
+ confidence: 'high',
1562
+ missingInformation: [],
1563
+ recommendedRoute: 'exploratory',
1564
+ hostRequirementSeed,
1565
+ compatibilityOutcome: 'modeling_gap',
1566
+ compatibilityFailures: input.missingTerms.map((term) => ({
1567
+ code: 'MISSING_DIMENSION',
1568
+ field: term,
1569
+ message: `${term} was not complete in the earlier governed tiers.`,
1570
+ candidateIds: [],
1571
+ })),
1572
+ }
1573
+ : undefined;
1574
+ const resolvedAnalyticalPlan = exploratoryMeaning
1575
+ ? buildResolvedAnalyticalPlan({
1576
+ question: input.question,
1577
+ resolution: exploratoryMeaning,
1578
+ evidence: input.evidence,
1579
+ candidates: snapshotCandidates,
1580
+ mode: 'authoritative',
1581
+ })
1582
+ : undefined;
1583
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan?.capability === 'bounded_exploration';
900
1584
  const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
901
1585
  const coverageFor = (source) => coverage.find((item) => item.source === source);
902
1586
  const skippedAttempt = (tier, source) => {
903
1587
  const item = coverageFor(source);
1588
+ const semanticCompilerUnavailable = tier === 'semantic' && input.semanticTierUnavailable;
904
1589
  return {
905
1590
  version: 1,
906
1591
  tier,
907
- outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1592
+ outcome: semanticCompilerUnavailable || item?.status !== 'available' ? 'unavailable' : 'ineligible',
908
1593
  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.`,
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.`,
912
1599
  planFrozen: false,
913
1600
  };
914
1601
  };
@@ -934,7 +1621,7 @@ function preFreezePhysicalCascadeDecision(input) {
934
1621
  reason: missingRankingMetric
935
1622
  ? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
936
1623
  : physicalPath.reason,
937
- planFrozen: false,
1624
+ planFrozen: exploratoryPlanFrozen,
938
1625
  },
939
1626
  ];
940
1627
  const message = exploratoryExecutable
@@ -956,7 +1643,7 @@ function preFreezePhysicalCascadeDecision(input) {
956
1643
  planFrozen: false,
957
1644
  }],
958
1645
  ...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
959
- planFrozen: false,
1646
+ planFrozen: exploratoryPlanFrozen,
960
1647
  stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
961
1648
  });
962
1649
  if (!exploratoryExecutable) {
@@ -997,29 +1684,131 @@ function preFreezePhysicalCascadeDecision(input) {
997
1684
  clarificationOptions: undefined,
998
1685
  retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
999
1686
  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,
1687
+ meaningResolution: exploratoryMeaning,
1688
+ resolvedAnalyticalPlan,
1689
+ };
1690
+ }
1691
+ /**
1692
+ * The compact meaning package is deliberately not a proof-of-absence set.
1693
+ * Preserve the complete immutable retrieval snapshot for physical closure and
1694
+ * structured-continuation checks, while retaining a caller's server-issued
1695
+ * selection when it was supplied as a same-snapshot clarification card.
1696
+ */
1697
+ function immutableSnapshotCandidates(evidence, candidates, executionCandidateIds) {
1698
+ const byId = new Map();
1699
+ for (const candidate of [
1700
+ ...evidence.candidates,
1701
+ ...(evidence.clarificationCandidates ?? []),
1702
+ ...candidates,
1703
+ ]) {
1704
+ if (!byId.has(candidate.id))
1705
+ byId.set(candidate.id, candidate);
1706
+ }
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);
1742
+ }
1743
+ /**
1744
+ * A normal meaning call may nominate either a semantic metric or a governed
1745
+ * modeling card whose declared execution contract cannot satisfy the exact
1746
+ * tuple. Before presenting that *pre-freeze* modeling gap as terminal,
1747
+ * inspect the complete immutable snapshot for one bounded, relationship-safe
1748
+ * physical closure. This is not a post-freeze downgrade: policy, fiscal, and
1749
+ * unsafe failures remain blocked and a safe result is explicitly
1750
+ * review-required exploratory work.
1751
+ */
1752
+ function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
1753
+ const { decision, evidence } = input;
1754
+ const plan = decision.resolvedAnalyticalPlan;
1755
+ if (!plan
1756
+ || decision.terminalOutcome?.kind !== 'modeling_gap'
1757
+ || decision.analyticalCascadeDecision?.planFrozen === true
1758
+ || (plan.recommendedRoute !== 'semantic' && plan.recommendedRoute !== 'governed_sql'))
1759
+ return decision;
1760
+ const selectedPreFreezeTier = plan.recommendedRoute;
1761
+ const requirements = buildAnalyticalRequirementSet({
1762
+ question: input.question,
1763
+ parsedIntent: input.evidence.parsedIntent,
1764
+ });
1765
+ const unresolved = plan.resolutionFailure?.bindings
1766
+ .filter((binding) => binding.status !== 'resolved')
1767
+ .map((binding) => binding.requested) ?? [];
1768
+ const missingTerms = [...new Set([
1769
+ ...unresolved,
1770
+ ...requirements.dimensions,
1771
+ ...requirements.entityTerms,
1772
+ ...requirements.entityDisplayTerms,
1773
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1774
+ ])];
1775
+ const requiredPhysicalFieldTerms = [...new Set([
1776
+ ...requirements.dimensions,
1777
+ ...requirements.entityTerms,
1778
+ ...requirements.entityDisplayTerms,
1779
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1780
+ ...(plan.query.filters ?? []).map((filter) => filter.field),
1781
+ ...unresolved,
1782
+ ])];
1783
+ const physicalContinuation = preFreezePhysicalCascadeDecision({
1784
+ base: input.base,
1785
+ evidence,
1786
+ candidates: immutableSnapshotCandidates(evidence, input.candidates, input.executionCandidateIds),
1787
+ question: input.question,
1788
+ requirements,
1789
+ missingTerms,
1790
+ requiredPhysicalFieldTerms,
1791
+ messagePrefix: selectedPreFreezeTier === 'governed_sql'
1792
+ ? 'The selected governed relational interpretation did not prove a compiler-owned DQL projection before plan freeze.'
1793
+ : 'The selected semantic interpretation was pre-freeze-ineligible for the complete requested tuple.',
1794
+ terminalCandidateIds: plan.selectedConceptIds,
1795
+ ...(input.executionCandidateIds ? { executionCandidateIds: input.executionCandidateIds } : {}),
1796
+ });
1797
+ if (physicalContinuation.action === 'answer'
1798
+ && physicalContinuation.analyticalCascadeDecision?.selectedTier === 'exploratory_sql') {
1799
+ return physicalContinuation;
1800
+ }
1801
+ // The physical extension was considered and was not safe/executable. Keep
1802
+ // the original immutable semantic plan and its typed failure rather than
1803
+ // replacing it with a synthetic exploratory meaning; surface the full
1804
+ // snapshot cascade evidence so the terminal block explains why it could not
1805
+ // continue. This retains policy/unsafe/fiscal terminal semantics.
1806
+ return {
1807
+ ...decision,
1808
+ reason: physicalContinuation.reason,
1809
+ retrievalEvidence: physicalContinuation.retrievalEvidence,
1810
+ analyticalCascadeDecision: physicalContinuation.analyticalCascadeDecision,
1811
+ terminalOutcome: physicalContinuation.terminalOutcome ?? decision.terminalOutcome,
1023
1812
  };
1024
1813
  }
1025
1814
  /**
@@ -1102,7 +1891,7 @@ function findSafeComplementaryPhysicalComposition(question, candidates) {
1102
1891
  const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
1103
1892
  if (!joinKey)
1104
1893
  continue;
1105
- const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey).keys()];
1894
+ const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey, 'exploratory').keys()];
1106
1895
  if (sharedRelationshipProofIds.length === 0)
1107
1896
  continue;
1108
1897
  const selectedPhysical = [
@@ -1148,10 +1937,40 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1148
1937
  // Use all already-retrieved cards from the same snapshot. The bounded meaning
1149
1938
  // package can omit a supporting raw join column, but no new retrieval/domain
1150
1939
  // scope is opened here.
1151
- const composition = findSafeComplementaryPhysicalComposition(question, evidence.candidates);
1940
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
1941
+ const composition = findSafeComplementaryPhysicalComposition(question, snapshotCandidates);
1152
1942
  if (!composition)
1153
1943
  return undefined;
1154
- const coverage = sourceCoverageFromEvidence(evidence, evidence.candidates);
1944
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
1945
+ const exploratoryMeaning = {
1946
+ interpretedQuestion: question,
1947
+ questionType: questionTypeFromText(question),
1948
+ selectedConceptIds: composition.candidateIds,
1949
+ recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
1950
+ queryIntent: {
1951
+ measures: [],
1952
+ dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
1953
+ filters: [{
1954
+ field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
1955
+ value: 'true',
1956
+ }],
1957
+ },
1958
+ rejectedCandidates: [],
1959
+ confidence: 'high',
1960
+ missingInformation: [],
1961
+ recommendedRoute: 'exploratory',
1962
+ compatibilityOutcome: 'modeling_gap',
1963
+ };
1964
+ const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
1965
+ question,
1966
+ resolution: exploratoryMeaning,
1967
+ evidence,
1968
+ candidates: snapshotCandidates,
1969
+ mode: 'authoritative',
1970
+ });
1971
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan.capability === 'bounded_exploration';
1972
+ if (!exploratoryPlanFrozen)
1973
+ return undefined;
1155
1974
  const coverageFor = (source) => coverage.find((item) => item.source === source);
1156
1975
  const skippedAttempt = (tier, source, reason) => {
1157
1976
  const item = coverageFor(source);
@@ -1184,7 +2003,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1184
2003
  outcome: 'executable',
1185
2004
  candidateIds: composition.candidateIds,
1186
2005
  reason: composition.reason,
1187
- planFrozen: false,
2006
+ planFrozen: exploratoryPlanFrozen,
1188
2007
  },
1189
2008
  ];
1190
2009
  const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
@@ -1192,7 +2011,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1192
2011
  sourceCoverage: coverage,
1193
2012
  attempts,
1194
2013
  selectedTier: 'exploratory_sql',
1195
- planFrozen: false,
2014
+ planFrozen: exploratoryPlanFrozen,
1196
2015
  stopReason: 'selected',
1197
2016
  });
1198
2017
  return {
@@ -1204,27 +2023,10 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1204
2023
  depth: 'quick',
1205
2024
  reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
1206
2025
  requiresClarification: false,
1207
- retrievalEvidence: retrievalTrace(evidence, candidates),
2026
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1208
2027
  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
- },
2028
+ resolvedAnalyticalPlan,
2029
+ meaningResolution: exploratoryMeaning,
1228
2030
  };
1229
2031
  }
1230
2032
  function cascadeForResolution(input) {
@@ -1262,6 +2064,7 @@ function cascadeForResolution(input) {
1262
2064
  attempts,
1263
2065
  ...(selectedTier ? { selectedTier } : {}),
1264
2066
  planFrozen: frozen,
2067
+ ...(input.terminalGap ? { terminalGap: input.terminalGap } : {}),
1265
2068
  stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
1266
2069
  });
1267
2070
  }
@@ -1304,6 +2107,9 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1304
2107
  const analytical = routedResolution.questionType === "diagnosis" ||
1305
2108
  routedResolution.questionType === "research";
1306
2109
  const reason = reconciliation.reason;
2110
+ const terminalGap = reconciliation.outcome === 'modeling_gap'
2111
+ ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
2112
+ : undefined;
1307
2113
  const analyticalCascadeDecision = cascadeForResolution({
1308
2114
  evidence,
1309
2115
  candidates,
@@ -1311,12 +2117,22 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1311
2117
  plan: resolvedAnalyticalPlan,
1312
2118
  reconciliation,
1313
2119
  question,
2120
+ ...(terminalGap ? { terminalGap: cascadeTerminalRelationshipGap(terminalGap) } : {}),
1314
2121
  });
1315
- const terminalGap = reconciliation.outcome === 'modeling_gap'
1316
- ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
1317
- : undefined;
2122
+ const declaredDimensionAssumptions = assumptionsForDeclaredDimensionAlternatives({
2123
+ question,
2124
+ evidence,
2125
+ candidates,
2126
+ plan: resolvedAnalyticalPlan,
2127
+ });
2128
+ // The deterministic pre-router may attach a generic soft clarification
2129
+ // while retrieval is still incomplete. Once this same turn has bound and
2130
+ // frozen a plan, that stale prompt must not leak into a completed answer or
2131
+ // its trace. Keep a clarification only when reconciliation itself says the
2132
+ // final immutable tuple is genuinely ambiguous.
2133
+ const { clarifyingQuestion: _staleClarifyingQuestion, clarificationOptions: _staleClarificationOptions, clarifySoft: _staleClarifySoft, ...baseWithoutStaleClarification } = base;
1318
2134
  return {
1319
- ...base,
2135
+ ...baseWithoutStaleClarification,
1320
2136
  action: needsClarification
1321
2137
  ? "clarify"
1322
2138
  : terminallyBlocked
@@ -1359,8 +2175,270 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1359
2175
  ...(needsClarification
1360
2176
  ? {
1361
2177
  clarifyingQuestion: reconciliation.question,
2178
+ ...(_staleClarifySoft ? { clarifySoft: true } : {}),
1362
2179
  }
1363
2180
  : {}),
2181
+ ...(declaredDimensionAssumptions.length > 0
2182
+ ? { assumptions: declaredDimensionAssumptions }
2183
+ : {}),
2184
+ };
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
+ },
1364
2442
  };
1365
2443
  }
1366
2444
  /**
@@ -1524,6 +2602,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
1524
2602
  ...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
1525
2603
  ...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
1526
2604
  ...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
2605
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => ({ kind: 'output', binding })),
1527
2606
  ].filter(({ binding }) => binding.status !== 'resolved');
1528
2607
  const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
1529
2608
  const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
@@ -1664,7 +2743,7 @@ function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
1664
2743
  .map(({ id, candidate }) => {
1665
2744
  return {
1666
2745
  id: candidate?.id ?? id,
1667
- label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
2746
+ label: clarificationOptionLabel(candidate?.name, candidate?.qualifiedId ?? id),
1668
2747
  ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
1669
2748
  kind: candidate?.kind ?? 'semantic_member',
1670
2749
  };
@@ -1674,6 +2753,19 @@ function qualifiedIdLabel(id) {
1674
2753
  const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
1675
2754
  return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
1676
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
+ }
1677
2769
  function routedClarificationQuestion(resolution, bindings, options) {
1678
2770
  if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
1679
2771
  return resolution.clarifyingQuestion;
@@ -1686,19 +2778,57 @@ function routedClarificationQuestion(resolution, bindings, options) {
1686
2778
  return resolution.clarifyingQuestion
1687
2779
  ?? `Which governed binding should I use before running this query?`;
1688
2780
  }
1689
- function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question) {
2781
+ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question, selectedConceptIds = [selected.id]) {
1690
2782
  // Preserve the exact stable ID emitted by the clarification option. A
1691
2783
  // candidate may also carry a source-qualified execution alias, but replacing
1692
2784
  // the clicked ID here makes the persisted ambiguity contract and the later
1693
2785
  // diagnostic receipt disagree even though the user chose a legitimate item.
1694
2786
  const selectedId = selected.id;
2787
+ // A server-issued display-key click has already passed the snapshot and
2788
+ // metric-capability checks. If its tuple is pre-freeze-ineligible, preserve
2789
+ // both the metric authority and the exact clicked ID through the typed gap
2790
+ // or same-snapshot exploratory continuation. Reconstructing only the
2791
+ // metric made restart receipts falsely look as though no choice was made.
2792
+ const preservedSelectionIds = [...new Set([
2793
+ ...selectedConceptIds.filter((id) => Boolean(id?.trim())),
2794
+ selectedId,
2795
+ ])];
2796
+ // A structured choice is validated against its exact stable identity, but an
2797
+ // incomplete selected capability must evaluate physical eligibility against
2798
+ // the entire immutable snapshot—not the compact model package that happened
2799
+ // to carry the clarification. This is one same-snapshot extension only.
2800
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
1695
2801
  const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
1696
2802
  const requiredPhysicalFieldTerms = [
1697
2803
  ...(evidence.parsedIntent?.dimensions ?? []),
1698
2804
  ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
1699
2805
  ];
1700
- const physicalPath = hasSafeExploratoryPhysicalPath(requirements, candidates, requirements.dimensions, requiredPhysicalFieldTerms);
1701
- const coverage = sourceCoverageFromEvidence(evidence, candidates);
2806
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, snapshotCandidates, requirements.dimensions, requiredPhysicalFieldTerms);
2807
+ // A structured clarification consumes the exact selected ID once, but an
2808
+ // incomplete governed capability is not a post-freeze terminal. Reuse the
2809
+ // router-owned physical cascade so the same-snapshot exploratory closure is
2810
+ // resolved and frozen *before* SQL is generated. The host may later attach
2811
+ // only an authorization receipt for that frozen plan; it must not select or
2812
+ // freeze another plan after SQL exists.
2813
+ if (physicalPath.ok) {
2814
+ return preFreezePhysicalCascadeDecision({
2815
+ base,
2816
+ evidence,
2817
+ candidates: snapshotCandidates,
2818
+ question,
2819
+ requirements,
2820
+ missingTerms: [...new Set([
2821
+ ...requirements.dimensions,
2822
+ ...requirements.entityTerms,
2823
+ ...requirements.entityDisplayTerms,
2824
+ ])],
2825
+ requiredPhysicalFieldTerms,
2826
+ messagePrefix: `The selected governed meaning ${selectedId} did not prove the complete requested tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact;`,
2827
+ terminalCandidateIds: preservedSelectionIds,
2828
+ requireRankingMetric: Boolean(requirements.ranking),
2829
+ });
2830
+ }
2831
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
1702
2832
  const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
1703
2833
  const attempts = [
1704
2834
  {
@@ -1713,7 +2843,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1713
2843
  version: 1,
1714
2844
  tier: 'semantic',
1715
2845
  outcome: 'ineligible',
1716
- candidateIds: [...new Set([selectedId, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
2846
+ candidateIds: [...new Set([...preservedSelectionIds, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
1717
2847
  reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
1718
2848
  planFrozen: false,
1719
2849
  },
@@ -1744,7 +2874,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1744
2874
  sourceCoverage: coverage,
1745
2875
  attempts: physicalPath.ok
1746
2876
  ? attempts
1747
- : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: [selectedId], reason: message, planFrozen: false }],
2877
+ : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: preservedSelectionIds, reason: message, planFrozen: false }],
1748
2878
  ...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
1749
2879
  planFrozen: false,
1750
2880
  stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
@@ -1752,7 +2882,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1752
2882
  const meaningResolution = {
1753
2883
  interpretedQuestion: question,
1754
2884
  questionType: 'value',
1755
- selectedConceptIds: [selectedId],
2885
+ selectedConceptIds: preservedSelectionIds,
1756
2886
  queryIntent: defaultQueryIntent(evidence),
1757
2887
  rejectedCandidates: [],
1758
2888
  confidence: 'low',
@@ -1763,7 +2893,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1763
2893
  code: 'INCOMPLETE_SELECTED_CAPABILITY',
1764
2894
  field: 'selected capability',
1765
2895
  message,
1766
- candidateIds: [selectedId],
2896
+ candidateIds: preservedSelectionIds,
1767
2897
  }],
1768
2898
  };
1769
2899
  if (!physicalPath.ok) {
@@ -1775,7 +2905,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1775
2905
  source: 'heuristic',
1776
2906
  category: 'data_lookup',
1777
2907
  depth: 'quick',
1778
- retrievalEvidence: retrievalTrace(evidence, candidates),
2908
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1779
2909
  requiresClarification: false,
1780
2910
  resolvedAnalyticalPlan: undefined,
1781
2911
  meaningResolution,
@@ -1784,7 +2914,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1784
2914
  kind: 'modeling_gap',
1785
2915
  code: 'ANALYTICAL_MODELING_GAP',
1786
2916
  message,
1787
- candidateIds: [selectedId],
2917
+ candidateIds: preservedSelectionIds,
1788
2918
  ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
1789
2919
  },
1790
2920
  };
@@ -1797,7 +2927,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1797
2927
  source: 'heuristic',
1798
2928
  category: 'data_lookup',
1799
2929
  depth: 'quick',
1800
- retrievalEvidence: retrievalTrace(evidence, candidates),
2930
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1801
2931
  requiresClarification: false,
1802
2932
  resolvedAnalyticalPlan: undefined,
1803
2933
  meaningResolution,
@@ -1857,12 +2987,38 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
1857
2987
  const metricEvidence = candidates.filter((candidate) => candidate.kind === "semantic_metric" &&
1858
2988
  candidate.analyticalCapability?.metricId &&
1859
2989
  metricIds.has(candidate.analyticalCapability.metricId));
2990
+ // Preserve an already validated semantic member when the resulting frame
2991
+ // proves that the selected metric owns that exact dimension. This is how a
2992
+ // server-issued “Account Name” clarification continues alongside the
2993
+ // explicit Revenue metric; stripping it here made trace meaning IDs and
2994
+ // durable continuation state look empty even though the frame was valid.
2995
+ const selectedFrameMembers = resolution.selectedConceptIds.flatMap((id) => {
2996
+ const member = candidates.find((candidate) => candidate.id === id);
2997
+ if (member?.kind !== 'semantic_member')
2998
+ return [];
2999
+ const resolvesIntoFrame = metricEvidence.some((metric) => {
3000
+ const identities = [
3001
+ member.qualifiedId,
3002
+ member.id,
3003
+ member.name,
3004
+ ...(member.aliases ?? []),
3005
+ ].filter((identity) => Boolean(identity));
3006
+ return identities.some((identity) => {
3007
+ const dimension = resolveMetricCapabilityDimension(metric, identity);
3008
+ return Boolean(dimension && result.frame.dimensions.some((binding) => binding.dimensionId === dimension.dimensionId));
3009
+ });
3010
+ });
3011
+ return resolvesIntoFrame ? [member.id] : [];
3012
+ });
1860
3013
  return {
1861
3014
  ...resolution,
1862
3015
  analyticalFrame: result.frame,
1863
3016
  analyticalPolicyIds: result.policyIds,
1864
3017
  ...(metricEvidence.length > 0
1865
- ? { selectedConceptIds: metricEvidence.map((candidate) => candidate.id) }
3018
+ ? { selectedConceptIds: [...new Set([
3019
+ ...metricEvidence.map((candidate) => candidate.id),
3020
+ ...selectedFrameMembers,
3021
+ ])] }
1866
3022
  : {}),
1867
3023
  recommendedExecutionId: result.candidateId,
1868
3024
  recommendedRoute: result.route,
@@ -1904,7 +3060,12 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
1904
3060
  analyticalFrame: result.frame,
1905
3061
  analyticalPolicyIds: result.policyIds,
1906
3062
  confidence: result.status === "clarify" ? "low" : resolution.confidence,
1907
- recommendedRoute: "clarify",
3063
+ // A compatibility block is a pre-freeze failure of the nominated tier,
3064
+ // not a new model decision to route somewhere else. Retain the semantic
3065
+ // nomination so the authoritative cascade can inspect the complete
3066
+ // same-snapshot qualified physical closure. Only genuine multi-option
3067
+ // ambiguity changes the route to clarification.
3068
+ recommendedRoute: result.status === 'clarify' ? 'clarify' : resolution.recommendedRoute,
1908
3069
  compatibilityOutcome,
1909
3070
  compatibilityFailures,
1910
3071
  missingInformation: [
@@ -1940,8 +3101,8 @@ function buildClarificationOptions(candidates, requirements) {
1940
3101
  return {
1941
3102
  id: candidate.id,
1942
3103
  label: ambiguousName
1943
- ? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
1944
- : candidate.name,
3104
+ ? `${clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id)} (${candidateKindLabel(candidate.kind)})`
3105
+ : clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id),
1945
3106
  ...(description ? { description } : {}),
1946
3107
  kind: candidate.kind,
1947
3108
  };
@@ -1981,6 +3142,9 @@ function persistedClarificationSelectionContext(request) {
1981
3142
  const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
1982
3143
  ? pendingRecord.sourceTurnId.trim()
1983
3144
  : undefined;
3145
+ const sourceQuestion = typeof pendingRecord.sourceQuestion === 'string' && pendingRecord.sourceQuestion.trim()
3146
+ ? pendingRecord.sourceQuestion.trim()
3147
+ : undefined;
1984
3148
  const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
1985
3149
  ? envelope.threadId.trim()
1986
3150
  : undefined;
@@ -2016,6 +3180,7 @@ function persistedClarificationSelectionContext(request) {
2016
3180
  ...(requirements ? { requirements } : {}),
2017
3181
  ...(snapshotId ? { snapshotId } : {}),
2018
3182
  ...(sourceTurnId ? { sourceTurnId } : {}),
3183
+ ...(sourceQuestion ? { sourceQuestion } : {}),
2019
3184
  ...(threadId ? { threadId } : {}),
2020
3185
  serverIssued: !invalidReason,
2021
3186
  ...(invalidReason ? { invalidReason } : {}),
@@ -2100,17 +3265,182 @@ function mergeClarificationRequirements(current, persisted) {
2100
3265
  entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
2101
3266
  entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
2102
3267
  memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
3268
+ ...(current.ranking || persisted.ranking
3269
+ ? {
3270
+ ranking: current.ranking && persisted.ranking
3271
+ ? {
3272
+ ...current.ranking,
3273
+ metricTerms: [...new Set([...current.ranking.metricTerms, ...persisted.ranking.metricTerms])],
3274
+ entityTerms: [...new Set([...current.ranking.entityTerms, ...persisted.ranking.entityTerms])],
3275
+ }
3276
+ : current.ranking ?? persisted.ranking,
3277
+ }
3278
+ : {}),
3279
+ ...(current.time || persisted.time
3280
+ ? {
3281
+ time: current.time && persisted.time
3282
+ ? {
3283
+ ...current.time,
3284
+ grain: current.time.grain ?? persisted.time.grain,
3285
+ fiscalPeriod: current.time.fiscalPeriod ?? persisted.time.fiscalPeriod,
3286
+ requiresDeclaredFiscalCalendar: current.time.requiresDeclaredFiscalCalendar
3287
+ || persisted.time.requiresDeclaredFiscalCalendar,
3288
+ }
3289
+ : current.time ?? persisted.time,
3290
+ }
3291
+ : {}),
3292
+ };
3293
+ }
3294
+ /**
3295
+ * A structured choice continues the server-rendered analytical frame. The
3296
+ * browser may echo `clarificationSourceQuestion`, but the persisted server
3297
+ * envelope wins whenever it is available so a choice label cannot be parsed as
3298
+ * a new question on reload or restart.
3299
+ */
3300
+ function structuredClarificationRequirements(request, persisted) {
3301
+ const sourceQuestion = persisted?.serverIssued && persisted.sourceQuestion
3302
+ ? persisted.sourceQuestion
3303
+ : request.clarificationSourceQuestion?.trim() || request.question;
3304
+ return mergeClarificationRequirements(buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.serverIssued ? persisted.requirements : undefined);
3305
+ }
3306
+ /**
3307
+ * A dimension clarification is not an alternate definition of the measure.
3308
+ * Find the exact semantic metric that owns the persisted explicit measure
3309
+ * before accepting a server-issued semantic member as a display/grouping
3310
+ * selection. Multiple metrics remain a real ambiguity; we never use a
3311
+ * dimension click to guess one of them.
3312
+ */
3313
+ function uniqueStructuredSelectionMetric(candidates, requirements) {
3314
+ const measures = explicitMeasureTermsForClarification(requirements);
3315
+ if (measures.length !== 1)
3316
+ return undefined;
3317
+ const byMetricId = new Map();
3318
+ for (const candidate of canonicalizeMetricMeasureCandidates(candidates)) {
3319
+ if (candidate.kind !== 'semantic_metric'
3320
+ || candidate.eligible === false
3321
+ // The first clarification deliberately exists because the metric's
3322
+ // display/entity role was not yet fully bound. A partial fit can still
3323
+ // be the unique, complete semantic capability for the explicit measure;
3324
+ // after the server-issued dimension choice binds it, the ordinary
3325
+ // immutable-plan compatibility checks remain authoritative. Only a
3326
+ // proven incompatible metric is disqualified at this identity boundary.
3327
+ || candidate.compatibility === 'incompatible'
3328
+ || candidateConflictsWithExplicitRankingMeasure(candidate, requirements)
3329
+ || !candidateProvesMetricTerm(candidate, measures[0]))
3330
+ continue;
3331
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
3332
+ if (normalized.status !== 'complete' || !normalized.capability)
3333
+ continue;
3334
+ const current = byMetricId.get(normalized.capability.metricId);
3335
+ if (!current
3336
+ || candidate.exactMatch && !current.exactMatch
3337
+ || candidate.relevanceScore > current.relevanceScore) {
3338
+ byMetricId.set(normalized.capability.metricId, candidate);
3339
+ }
3340
+ }
3341
+ return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
3342
+ }
3343
+ /**
3344
+ * A resolver can offer a qualified semantic capability dimension without a
3345
+ * standalone retrieval card. That is expected: the option is an authored
3346
+ * child of a metric capability, not an independently ranked candidate. On a
3347
+ * later click, restore that child only when the active server-issued
3348
+ * clarification contract names the exact option and the *same immutable
3349
+ * snapshot* still proves it through one exact metric capability.
3350
+ *
3351
+ * This is deliberately not a fuzzy retrieval fallback. A malformed,
3352
+ * foreign, stale, or client-invented ID never reaches this helper because the
3353
+ * host-only envelope/snapshot checks occur before the capability projection.
3354
+ */
3355
+ function rehydrateServerIssuedCapabilityDimensionSelection(input) {
3356
+ const selectedId = input.request.selectedEvidenceId?.trim();
3357
+ if (!selectedId)
3358
+ return undefined;
3359
+ const persisted = persistedClarificationSelectionContext(input.request);
3360
+ if (!persisted?.serverIssued || persisted.snapshotId !== input.evidence.snapshotId)
3361
+ return undefined;
3362
+ const offeredIds = new Set([...persisted.optionIds, ...persisted.ambiguityCandidateIds]);
3363
+ if (!offeredIds.has(selectedId))
3364
+ return undefined;
3365
+ const requirements = structuredClarificationRequirements(input.request, persisted);
3366
+ const metricCandidate = uniqueStructuredSelectionMetric(input.candidates, requirements);
3367
+ if (!metricCandidate)
3368
+ return undefined;
3369
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selectedId);
3370
+ if (!dimension)
3371
+ return undefined;
3372
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
3373
+ return undefined;
3374
+ if (!requirements.ranking
3375
+ && !dimension.supportedRoles.includes('group_by')
3376
+ && !dimension.supportedRoles.includes('filter'))
3377
+ return undefined;
3378
+ return {
3379
+ // Keep the exact server-issued ID as the router identity and persistable
3380
+ // choice binding. The metric capability's authored ID stays available as
3381
+ // provenance for semantic-frame construction.
3382
+ id: selectedId,
3383
+ qualifiedId: dimension.dimensionId,
3384
+ kind: 'semantic_member',
3385
+ semanticObjectType: 'dimension',
3386
+ trustTier: 'semantic',
3387
+ name: dimension.label || qualifiedIdLabel(dimension.dimensionId),
3388
+ ...(dimension.aliases?.length ? { aliases: dimension.aliases } : {}),
3389
+ ...(metricCandidate.domain ? { domain: metricCandidate.domain } : {}),
3390
+ ...(metricCandidate.semanticModel ? { semanticModel: metricCandidate.semanticModel } : {}),
3391
+ relevanceScore: metricCandidate.relevanceScore,
3392
+ matchReasons: ['server-issued snapshot capability binding'],
3393
+ compatibility: 'compatible',
3394
+ eligible: true,
3395
+ };
3396
+ }
3397
+ /**
3398
+ * Validate a selected display/grouping member against the selected metric's
3399
+ * physical semantic capability. The candidate must already have been offered
3400
+ * by the server; this only determines which typed role it can complete.
3401
+ */
3402
+ function structuredDimensionSelection(selected, candidates, requirements) {
3403
+ if (selected.kind !== 'semantic_member'
3404
+ || selected.eligible === false
3405
+ || selected.compatibility === 'incompatible')
3406
+ return undefined;
3407
+ const roles = evidenceCandidateRoles(selected);
3408
+ if (!roles.some((role) => role === 'entity_label'
3409
+ || role === 'categorical_dimension'
3410
+ || role === 'time_dimension'))
3411
+ return undefined;
3412
+ const metricCandidate = uniqueStructuredSelectionMetric(candidates, requirements);
3413
+ if (!metricCandidate)
3414
+ return undefined;
3415
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selected.qualifiedId ?? selected.id);
3416
+ if (!dimension)
3417
+ return undefined;
3418
+ // A top/bottom clarification must select an authored rankable entity, not a
3419
+ // filter-only field. For ordinary dimensional requests, grouping is enough.
3420
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
3421
+ return undefined;
3422
+ if (!requirements.ranking
3423
+ && !dimension.supportedRoles.includes('group_by')
3424
+ && !dimension.supportedRoles.includes('filter'))
3425
+ return undefined;
3426
+ return {
3427
+ kind: 'dimension',
3428
+ metricCandidate,
3429
+ selectedDimensionId: selected.id,
3430
+ dimensionId: dimension.dimensionId,
2103
3431
  };
2104
3432
  }
3433
+ function compatibleStructuredClarificationCandidates(candidates, requirements) {
3434
+ return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements)
3435
+ || Boolean(structuredDimensionSelection(candidate, candidates, requirements)));
3436
+ }
2105
3437
  function validateStructuredClarificationSelection(input) {
2106
3438
  const persisted = persistedClarificationSelectionContext(input.request);
2107
- const sourceQuestion = input.request.clarificationSourceQuestion?.trim() || input.request.question;
2108
- const requirements = mergeClarificationRequirements(
2109
3439
  // 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);
3440
+ // question and server-persisted typed contract, not arbitrary inherited
3441
+ // parser hints or the rendered option label.
3442
+ const requirements = structuredClarificationRequirements(input.request, persisted);
3443
+ const compatible = compatibleStructuredClarificationCandidates(input.candidates, requirements);
2114
3444
  const choiceIds = compatible.map((candidate) => candidate.id);
2115
3445
  if (!persisted?.serverIssued) {
2116
3446
  return {
@@ -2132,7 +3462,10 @@ function validateStructuredClarificationSelection(input) {
2132
3462
  if (!input.selected) {
2133
3463
  return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
2134
3464
  }
2135
- if (!candidateOwnsExplicitClarificationRoles(input.selected, requirements)) {
3465
+ const selection = candidateOwnsExplicitClarificationRoles(input.selected, requirements)
3466
+ ? { kind: 'metric' }
3467
+ : structuredDimensionSelection(input.selected, input.candidates, requirements);
3468
+ if (!selection) {
2136
3469
  return {
2137
3470
  ok: false,
2138
3471
  requirements,
@@ -2161,7 +3494,7 @@ function validateStructuredClarificationSelection(input) {
2161
3494
  reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
2162
3495
  };
2163
3496
  }
2164
- return { ok: true, requirements, choiceIds };
3497
+ return { ok: true, requirements, choiceIds, selection };
2165
3498
  }
2166
3499
  function invalidStructuredSelectionDecision(input) {
2167
3500
  const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
@@ -2383,6 +3716,7 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
2383
3716
  if (hasExplicitRankingMeasure(question, evidence))
2384
3717
  return resolution;
2385
3718
  const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
3719
+ || candidate.qualifiedId === resolution.recommendedExecutionId
2386
3720
  || resolution.selectedConceptIds.includes(candidate.id));
2387
3721
  if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
2388
3722
  return resolution;
@@ -2411,7 +3745,110 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
2411
3745
  : `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use for the ranking?`,
2412
3746
  };
2413
3747
  }
2414
- function directResolution(request, evidence, candidate, candidates) {
3748
+ function isSameSnapshotCategoricalExtensionForMetric(candidate, metricCandidate) {
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) {
3770
+ const capability = normalizeEvidenceAnalyticalCapability(metricCandidate).capability;
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
+ });
3807
+ }
3808
+ /**
3809
+ * A model chooses from the bounded package, but it cannot remove a unique
3810
+ * host-required categorical grouping whose exact qualified field is already
3811
+ * declared by the selected metric's immutable capability. This is a binding,
3812
+ * not a semantic guess: zero or multiple capability-backed cards leave the
3813
+ * requirement unresolved for the normal clarify/gap path.
3814
+ */
3815
+ function hostBoundCategoricalExtensions(input) {
3816
+ const bound = [];
3817
+ for (const requested of categoricalDimensionRequirementTerms(input.requirements)) {
3818
+ const matches = input.candidates.filter((candidate) => isSameSnapshotCategoricalExtensionForMetric(candidate, input.metricCandidate)
3819
+ && normalizeMetricPhrase(candidate.sameSnapshotRoleExtension.requestedTerm)
3820
+ === normalizeMetricPhrase(requested));
3821
+ if (matches.length === 1)
3822
+ bound.push(matches[0]);
3823
+ }
3824
+ return bound.filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
3825
+ }
3826
+ function directResolution(request, evidence, candidate, candidates, selectedDimensionIds = [], selectedDimensionConceptIds = selectedDimensionIds) {
3827
+ // Keep deterministic/direct routing on the same host-owned tuple as the
3828
+ // model path. Raw parser terms are retrieval hints only: they may not turn a
3829
+ // normalized business alias back into a phantom metric or dimension after
3830
+ // the meaning boundary has been intentionally skipped.
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
+ });
3839
+ const hostOwnedEvidence = {
3840
+ ...evidence,
3841
+ parsedIntent: {
3842
+ ...evidence.parsedIntent,
3843
+ measures: requirementSeed.queryIntent.measures,
3844
+ dimensions: requirementSeed.queryIntent.dimensions,
3845
+ filters: requirementSeed.queryIntent.filters,
3846
+ ...(requirementSeed.queryIntent.timeRange ? { timeRange: requirementSeed.queryIntent.timeRange } : {}),
3847
+ ...(requirementSeed.queryIntent.timeGrain ? { timeGrain: requirementSeed.queryIntent.timeGrain } : {}),
3848
+ ...(requirementSeed.queryIntent.order ? { order: requirementSeed.queryIntent.order } : {}),
3849
+ ...(requirementSeed.queryIntent.limit !== undefined ? { limit: requirementSeed.queryIntent.limit } : {}),
3850
+ },
3851
+ };
2415
3852
  const inferredQuestionType = questionTypeFromText(request.question);
2416
3853
  const questionType = inferredQuestionType === 'definition'
2417
3854
  && candidate.kind === 'semantic_metric'
@@ -2421,16 +3858,40 @@ function directResolution(request, evidence, candidate, candidates) {
2421
3858
  && !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
2422
3859
  ? 'value'
2423
3860
  : inferredQuestionType;
2424
- const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
3861
+ const metricCandidates = explicitlyRequestedMetricCandidates(request.question, hostOwnedEvidence, candidate, candidates);
3862
+ const requirements = requirementSeed.requirements;
3863
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
3864
+ metricCandidate: candidate,
3865
+ candidates,
3866
+ requirements,
3867
+ });
3868
+ const resolvedSelectedDimensionIds = [...new Set([
3869
+ ...selectedDimensionIds,
3870
+ ...hostBoundExtensions.map((item) => item.qualifiedId ?? item.id),
3871
+ ])];
2425
3872
  const analyticalFrame = buildDeterministicAnalyticalFrame({
2426
3873
  question: request.question,
2427
3874
  questionType,
2428
- evidence,
3875
+ evidence: hostOwnedEvidence,
2429
3876
  metricCandidate: candidate,
2430
3877
  metricCandidates,
3878
+ entityTerms: requirements.entityTerms,
3879
+ entityDisplayTerms: requirements.entityDisplayTerms,
3880
+ selectedDimensionIds: resolvedSelectedDimensionIds,
2431
3881
  candidates,
2432
3882
  });
2433
- const defaultIntent = defaultQueryIntent(evidence);
3883
+ const defaultIntent = defaultQueryIntent(hostOwnedEvidence);
3884
+ // `top` without a count is a deterministic product convention, not a
3885
+ // missing business meaning. Bind the default into the direct execution
3886
+ // nomination while the typed requirements retain `defaultedLimit: true` for
3887
+ // the answer/receipt to disclose the assumption.
3888
+ const rankedDefaultIntent = requirements.ranking
3889
+ ? {
3890
+ ...defaultIntent,
3891
+ order: defaultIntent.order ?? (requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'),
3892
+ limit: defaultIntent.limit ?? requirements.ranking.limit,
3893
+ }
3894
+ : defaultIntent;
2434
3895
  // An exact authored certified example has already proved this block's own
2435
3896
  // output contract. Parser wording such as "food and drink" describes the
2436
3897
  // values of the block's declared `category` output; it must not manufacture
@@ -2444,15 +3905,45 @@ function directResolution(request, evidence, candidate, candidates) {
2444
3905
  && certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
2445
3906
  && (candidate.dimensions?.length ?? 0) > 0;
2446
3907
  const queryIntent = exactCertifiedExample
2447
- ? { ...defaultIntent, dimensions: candidate.dimensions ?? [] }
2448
- : defaultIntent;
3908
+ ? { ...rankedDefaultIntent, dimensions: candidate.dimensions ?? [] }
3909
+ : rankedDefaultIntent;
3910
+ // Preserve a selected semantic field as a typed dimension binding rather
3911
+ // than folding its label into the question. The ID comes from the selected
3912
+ // metric's capability contract, so the immutable plan will revalidate it
3913
+ // against the same snapshot before it can freeze.
3914
+ const selectedQueryDimensions = analyticalFrame?.dimensions
3915
+ // Server-issued structured selections may add their qualified identity to
3916
+ // the intent carrier. A host-bound same-snapshot extension instead binds
3917
+ // through the V2 frame below; adding its ID as a second text dimension
3918
+ // would create a duplicate request (`region` plus its qualified field).
3919
+ .filter((binding) => selectedDimensionIds.includes(binding.dimensionId))
3920
+ .map((binding) => binding.dimensionId) ?? [];
3921
+ // A direct host resolution can bind a categorical capability dimension from
3922
+ // the current question without a model-selected member card. Carry the
3923
+ // *actual* same-snapshot card into the resolution as well, otherwise the
3924
+ // immutable solver sees a valid V2 frame but cannot prove the selected
3925
+ // MetricFlow grouping evidence and incorrectly marks the tuple partial.
3926
+ // This is not a fuzzy name lookup: the candidate must be the dimension's
3927
+ // own qualified identity or the already-recorded same-snapshot extension.
3928
+ const hostBoundDimensionConceptIds = analyticalFrame?.dimensions.flatMap((binding) => hostBoundExtensions
3929
+ .filter((item) => (item.qualifiedId ?? item.id) === binding.dimensionId)
3930
+ .map((item) => item.id)) ?? [];
3931
+ const queryIntentWithSelectedDimensions = selectedQueryDimensions.length > 0
3932
+ ? {
3933
+ ...queryIntent,
3934
+ dimensions: [...new Set([
3935
+ ...queryIntent.dimensions,
3936
+ ...selectedQueryDimensions,
3937
+ ])],
3938
+ }
3939
+ : queryIntent;
2449
3940
  const memberCandidates = candidates.filter((item) => {
2450
3941
  if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
2451
3942
  return false;
2452
3943
  const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
2453
- return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
3944
+ return queryIntentWithSelectedDimensions.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
2454
3945
  });
2455
- const canonicalFilters = queryIntent.filters.map((filter) => {
3946
+ const canonicalFilters = queryIntentWithSelectedDimensions.filters.map((filter) => {
2456
3947
  const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
2457
3948
  .map(normalizeMetricPhrase)
2458
3949
  .includes(normalizeMetricPhrase(filter.value)));
@@ -2461,18 +3952,225 @@ function directResolution(request, evidence, candidate, candidates) {
2461
3952
  return {
2462
3953
  interpretedQuestion: request.question,
2463
3954
  questionType,
2464
- selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
2465
- recommendedExecutionId: candidate.id,
2466
- queryIntent: { ...queryIntent, filters: canonicalFilters },
3955
+ selectedConceptIds: [...metricCandidates, ...memberCandidates]
3956
+ .map((item) => item.id)
3957
+ .concat(selectedDimensionConceptIds, hostBoundDimensionConceptIds)
3958
+ .filter((id, index, all) => all.indexOf(id) === index),
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,
3967
+ queryIntent: { ...queryIntentWithSelectedDimensions, filters: canonicalFilters },
2467
3968
  rejectedCandidates: [],
2468
3969
  confidence: "high",
2469
3970
  missingInformation: [],
2470
3971
  recommendedRoute: routeForEvidenceCandidate(candidate),
3972
+ // Direct/exact routing deliberately bypasses the model, not the host
3973
+ // meaning boundary. Carry the same immutable seed into plan binding so
3974
+ // display-key projection and downstream receipts cannot fall back to raw
3975
+ // parser wording.
3976
+ hostRequirementSeed: requirementSeed,
3977
+ ...(selectedDimensionIds.length > 0
3978
+ ? { structuredDimensionIds: [...new Set(selectedDimensionIds)] }
3979
+ : {}),
2471
3980
  ...(analyticalFrame ? { analyticalFrame } : {}),
2472
3981
  };
2473
3982
  }
3983
+ /**
3984
+ * Build the V2 execution frame after the one candidate-ID meaning call.
3985
+ *
3986
+ * The model's response has already passed package membership validation. It
3987
+ * may therefore identify a supplied semantic metric and supplied semantic
3988
+ * members, but it is never allowed to author the frame itself. This helper
3989
+ * rebuilds the frame from the immutable host seed plus those qualified
3990
+ * identities, exactly as {@link directResolution} does for a zero-call path.
3991
+ *
3992
+ * Keeping this boundary here is important: a candidate-only model response
3993
+ * used to bind a perfectly valid semantic tuple into a V1 plan. V1 plans
3994
+ * bypass the immutable analytical execution graph and fall into the legacy
3995
+ * semantic SQL path, where generic aggregation validation cannot prove a
3996
+ * MetricFlow capability. A host-built V2 frame keeps the selected semantic
3997
+ * route compiler-owned without letting the model change the requested tuple.
3998
+ */
3999
+ function attachHostOwnedAnalyticalFrame(input) {
4000
+ const { request, evidence, candidates, requirementSeed, resolution } = input;
4001
+ if (resolution.recommendedRoute !== 'semantic' || resolution.questionType === 'definition') {
4002
+ return resolution;
4003
+ }
4004
+ const matchesIdentity = (candidate, identity) => Boolean(identity && (candidate.id === identity || candidate.qualifiedId === identity));
4005
+ const metricCandidate = candidates.find((candidate) => candidate.kind === 'semantic_metric'
4006
+ && matchesIdentity(candidate, resolution.recommendedExecutionId))
4007
+ ?? candidates.find((candidate) => candidate.kind === 'semantic_metric'
4008
+ && resolution.selectedConceptIds.some((identity) => matchesIdentity(candidate, identity)));
4009
+ if (!metricCandidate || normalizeEvidenceAnalyticalCapability(metricCandidate).status !== 'complete') {
4010
+ return resolution;
4011
+ }
4012
+ // Parsed intent is retrieval evidence only. The deterministic frame reads
4013
+ // the same host-owned seed that the plan will later bind, so stale retrieval
4014
+ // terms cannot re-enter as a metric, dimension, filter, output, or time
4015
+ // constraint merely because the model selected an otherwise valid card.
4016
+ const hostOwnedEvidence = {
4017
+ ...evidence,
4018
+ parsedIntent: {
4019
+ ...evidence.parsedIntent,
4020
+ measures: [...requirementSeed.queryIntent.measures],
4021
+ dimensions: [...requirementSeed.queryIntent.dimensions],
4022
+ filters: requirementSeed.queryIntent.filters.map((filter) => ({ ...filter })),
4023
+ ...(requirementSeed.queryIntent.timeRange
4024
+ ? { timeRange: requirementSeed.queryIntent.timeRange }
4025
+ : {}),
4026
+ ...(requirementSeed.queryIntent.timeGrain
4027
+ ? { timeGrain: requirementSeed.queryIntent.timeGrain }
4028
+ : {}),
4029
+ ...(requirementSeed.queryIntent.order
4030
+ ? { order: requirementSeed.queryIntent.order }
4031
+ : {}),
4032
+ ...(requirementSeed.queryIntent.limit !== undefined
4033
+ ? { limit: requirementSeed.queryIntent.limit }
4034
+ : {}),
4035
+ },
4036
+ };
4037
+ const modelSelectedDimensionIds = resolution.selectedConceptIds.flatMap((identity) => {
4038
+ const candidate = candidates.find((item) => matchesIdentity(item, identity));
4039
+ return candidate?.kind === 'semantic_member'
4040
+ ? [candidate.qualifiedId ?? candidate.id]
4041
+ : [];
4042
+ });
4043
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
4044
+ metricCandidate,
4045
+ candidates,
4046
+ requirements: requirementSeed.requirements,
4047
+ });
4048
+ const selectedDimensionIds = [...new Set([
4049
+ ...modelSelectedDimensionIds,
4050
+ ...hostBoundExtensions.map((candidate) => candidate.qualifiedId ?? candidate.id),
4051
+ ])];
4052
+ const analyticalFrame = buildDeterministicAnalyticalFrame({
4053
+ question: requirementSeed.sourceQuestion,
4054
+ questionType: resolution.questionType,
4055
+ evidence: hostOwnedEvidence,
4056
+ metricCandidate,
4057
+ metricCandidates: explicitlyRequestedMetricCandidates(requirementSeed.sourceQuestion, hostOwnedEvidence, metricCandidate, candidates),
4058
+ entityTerms: requirementSeed.requirements.entityTerms,
4059
+ entityDisplayTerms: requirementSeed.requirements.entityDisplayTerms,
4060
+ selectedDimensionIds,
4061
+ candidates,
4062
+ });
4063
+ if (!analyticalFrame)
4064
+ return resolution;
4065
+ const hostBoundConceptIds = hostBoundExtensions.map((candidate) => candidate.id);
4066
+ const selectedConceptIds = [...new Set([
4067
+ ...resolution.selectedConceptIds,
4068
+ ...hostBoundConceptIds,
4069
+ ])];
4070
+ const overrideReceipts = hostBoundConceptIds.length > 0
4071
+ ? [
4072
+ ...(resolution.overrideReceipts ?? []),
4073
+ {
4074
+ version: 1,
4075
+ field: 'candidate_selection',
4076
+ action: 'host_preserved',
4077
+ reason: 'A unique, same-snapshot MetricFlow grouping field is required by the host-owned categorical dimension and cannot be removed by a model omission.',
4078
+ candidateIds: hostBoundConceptIds,
4079
+ },
4080
+ ]
4081
+ : resolution.overrideReceipts;
4082
+ return {
4083
+ ...resolution,
4084
+ selectedConceptIds,
4085
+ analyticalFrame,
4086
+ ...(overrideReceipts?.length ? { overrideReceipts } : {}),
4087
+ };
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
+ }
2474
4169
  function explicitlyRequestedMetricCandidates(question, evidence, primary, candidates) {
2475
- const requested = evidence.parsedIntent?.measures ?? [];
4170
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
4171
+ const requested = requirements.ranking?.metricTerms.length
4172
+ ? requirements.ranking.metricTerms
4173
+ : requirements.measures;
2476
4174
  const requestedTerms = [...new Set(requested.map(normalizeMetricPhrase).filter(Boolean))];
2477
4175
  // A single business metric can be retrieved alongside technical dbt measure
2478
4176
  // shims and registry aliases that share its words. Those are execution
@@ -2489,6 +4187,8 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
2489
4187
  const metrics = candidates.filter((candidate) => {
2490
4188
  if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
2491
4189
  return false;
4190
+ if (candidateConflictsWithExplicitRankingMeasure(candidate, requirements))
4191
+ return false;
2492
4192
  if (!normalizeEvidenceAnalyticalCapability(candidate).capability)
2493
4193
  return false;
2494
4194
  if (candidate.id === primary.id)
@@ -2607,6 +4307,29 @@ mayAssumeInterpretation = true) {
2607
4307
  }
2608
4308
  return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
2609
4309
  }
4310
+ /**
4311
+ * A bounded meaning call may return malformed JSON or a syntactically valid
4312
+ * low-confidence response with no candidate binding. It must not override a
4313
+ * plan the host can prove from one exact current-turn semantic identity. The
4314
+ * predicate deliberately requires a frozen executable plan: ordinary metric
4315
+ * ambiguity, a coverage gap, policy denial, and every unfrozen candidate still
4316
+ * take their existing clarification/block paths.
4317
+ */
4318
+ function isFrozenExecutableHostFallback(decision) {
4319
+ return decision.analyticalCascadeDecision?.planFrozen === true
4320
+ && Boolean(decision.resolvedAnalyticalPlan)
4321
+ && !decision.requiresClarification
4322
+ && !decision.terminalOutcome;
4323
+ }
4324
+ function meaningResolutionHasNoBinding(resolution) {
4325
+ return resolution.selectedConceptIds.length === 0
4326
+ && !resolution.recommendedExecutionId
4327
+ && (resolution.emptyCandidateBinding === true || (!resolution.clarifyingQuestion
4328
+ && resolution.recommendedRoute !== 'clarify'
4329
+ && resolution.compatibilityOutcome !== 'clarify'
4330
+ && resolution.missingInformation.length === 0
4331
+ && resolution.rejectedCandidates.length === 0));
4332
+ }
2610
4333
  /** Leaf identity of a governed candidate, ignoring its source qualification. */
2611
4334
  function candidateLeafName(candidate) {
2612
4335
  const identity = candidate.qualifiedId ?? candidate.id;
@@ -2919,7 +4642,11 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
2919
4642
  // requires a declared calendar at compilation time; this router simply must
2920
4643
  // not manufacture one or report an absence before relational/runtime context
2921
4644
  // has had a chance to bind the declared date role.
2922
- const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? [])
4645
+ // The host-owned requirements are the canonical reader tuple. Parsed intent
4646
+ // is only a retrieval seed: carrying its raw phrase here made "sales based
4647
+ // on the region" look like a literal physical dimension even after the
4648
+ // requirement seed had correctly normalized it to `region`.
4649
+ const requestedDimensions = requirements.dimensions
2923
4650
  .filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
2924
4651
  const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
2925
4652
  && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
@@ -2984,6 +4711,16 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
2984
4711
  messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
2985
4712
  });
2986
4713
  }
4714
+ // A single declared alternative can only be assumed when an executable
4715
+ // metric capability in this same snapshot explicitly contains it. A bare
4716
+ // standalone semantic member (for example `location_name` marked as an
4717
+ // alternative for `region`) is evidence of a possible wording, not proof
4718
+ // that it can safely answer this tuple. Without that closure, retain the
4719
+ // concise modeled-gap clarification rather than letting a later meaning
4720
+ // path substitute an unrelated field or metric.
4721
+ if (alternatives.length === 1
4722
+ && declaredDimensionAlternativeCompletesExecutableTuple(alternatives[0], candidates, requirements))
4723
+ return undefined;
2987
4724
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
2988
4725
  const alternativeLabels = alternatives.map(renderCandidateChoice);
2989
4726
  return {
@@ -3009,6 +4746,22 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
3009
4746
  }
3010
4747
  return undefined;
3011
4748
  }
4749
+ /**
4750
+ * The broad router terminal witness can carry reader-safe missing-role prose
4751
+ * for several gap classes. Observability persists only the relationship
4752
+ * variant, and only as an enumerated proof requirement, so an export cannot
4753
+ * accidentally turn an unresolved question term into trace content.
4754
+ */
4755
+ function cascadeTerminalRelationshipGap(gap) {
4756
+ if (!gap || gap.code !== 'MISSING_RELATIONSHIP')
4757
+ return undefined;
4758
+ return {
4759
+ version: 1,
4760
+ code: 'MISSING_RELATIONSHIP',
4761
+ requirement: 'certified_relationship_or_allocation_proof',
4762
+ witnessCandidateIds: [...new Set(gap.witnessCandidateIds)].sort().slice(0, 32),
4763
+ };
4764
+ }
3012
4765
  /**
3013
4766
  * A clarification with NO selectable options is unanswerable, and asking it is a
3014
4767
  * dead end rather than a safety measure.
@@ -3249,10 +5002,73 @@ function candidateProvesMetricTerm(candidate, requested) {
3249
5002
  .some((term) => metricTermsMatch(term, requested));
3250
5003
  }
3251
5004
  function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
5005
+ const extension = candidate.sameSnapshotRoleExtension;
5006
+ if (extension?.role === 'categorical_dimension'
5007
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
5008
+ || extension.basis === 'exact_metricflow_grouping_dimension')
5009
+ && missingDimensions.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
5010
+ return true;
3252
5011
  const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
3253
5012
  return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
3254
5013
  || facts.includes(`dimension alternative for ${requested}`));
3255
5014
  }
5015
+ function declaredDimensionAlternativeCompletesExecutableTuple(alternative, candidates, requirements) {
5016
+ // A substitution is executable only when a metric capability in the same
5017
+ // snapshot names this exact qualified dimension. Do not treat a lone
5018
+ // semantic-member card as an authorization to change the reader's request.
5019
+ if (requirements.measures.length === 0)
5020
+ return false;
5021
+ const alternativeId = alternative.qualifiedId ?? alternative.id;
5022
+ return candidates.some((candidate) => {
5023
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
5024
+ return false;
5025
+ if (!requirements.measures.every((measure) => candidateProvesMetricTerm(candidate, measure)))
5026
+ return false;
5027
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
5028
+ return normalized.status === 'complete'
5029
+ && Boolean(normalized.capability?.dimensions.some((dimension) => dimension.dimensionId === alternativeId));
5030
+ });
5031
+ }
5032
+ /**
5033
+ * A declared dimension alternative is safe only after the immutable plan has
5034
+ * bound that exact qualified field. This makes the one permitted vocabulary
5035
+ * substitution visible to the reader without letting a raw parser phrase or
5036
+ * model response authorize it.
5037
+ */
5038
+ function assumptionsForDeclaredDimensionAlternatives(input) {
5039
+ const requirements = buildAnalyticalRequirementSet({
5040
+ question: input.question,
5041
+ parsedIntent: input.evidence.parsedIntent,
5042
+ });
5043
+ const boundDimensionIds = new Set(input.plan.query.dimensions
5044
+ .filter((binding) => binding.status === 'resolved' && binding.qualifiedId)
5045
+ .map((binding) => binding.qualifiedId));
5046
+ const assumptions = [];
5047
+ for (const requested of requirements.dimensions) {
5048
+ const alternatives = input.candidates
5049
+ .filter(isCompatibleQualifiedMember)
5050
+ .filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, [requested]))
5051
+ .filter((candidate) => boundDimensionIds.has(candidate.qualifiedId ?? candidate.id));
5052
+ if (alternatives.length !== 1)
5053
+ continue;
5054
+ const alternative = alternatives[0];
5055
+ const extension = alternative.sameSnapshotRoleExtension;
5056
+ const assumption = assumeDominantCandidate({
5057
+ about: 'dimension',
5058
+ candidates: [{
5059
+ id: alternative.qualifiedId ?? alternative.id,
5060
+ label: alternative.name,
5061
+ score: alternative.relevanceScore,
5062
+ }],
5063
+ because: (chosen) => extension
5064
+ ? `“${requested}” is bound to ${chosen.label ?? chosen.id}, the sole same-snapshot MetricFlow grouping field shared by the selected metric.`
5065
+ : `“${requested}” is not modeled directly; ${chosen.label ?? chosen.id} is the sole compatible, declared alternative in this snapshot.`,
5066
+ });
5067
+ if (assumption)
5068
+ assumptions.push(assumption);
5069
+ }
5070
+ return assumptions;
5071
+ }
3256
5072
  /**
3257
5073
  * A missing optional parsed-intent projection must not erase positive metric
3258
5074
  * evidence already retrieved for the question. Only complete, compatible,
@@ -3350,12 +5166,50 @@ function renderCandidateChoice(candidate) {
3350
5166
  * metrics therefore remain ambiguous and continue to clarification/resolution.
3351
5167
  */
3352
5168
  function uniqueExecutableSemanticMetric(evidence, candidates) {
3353
- const requestedTokens = metricTokens(evidence.parsedIntent?.measures ?? []);
5169
+ const requestedMeasures = [...new Set((evidence.parsedIntent?.measures ?? [])
5170
+ .map(normalizeMetricPhrase)
5171
+ .filter(Boolean))];
5172
+ const requestedTokens = metricTokens(requestedMeasures);
3354
5173
  if (requestedTokens.size === 0)
3355
5174
  return undefined;
5175
+ // Do this before capability normalization. Local metadata can retain an
5176
+ // executable semantic metric card while its pre-plan compatibility hint is
5177
+ // still partial/unknown; a selected provider binding correctly reaches the
5178
+ // capability solver in that state. The exact leaf identity is nevertheless
5179
+ // a real, qualified current-snapshot meaning. Dedupe only representations
5180
+ // of the same qualified metric, never two same-named metrics from different
5181
+ // models, and let the immutable cascade prove execution afterwards.
5182
+ if (requestedMeasures.length === 1) {
5183
+ const requested = requestedMeasures[0];
5184
+ const exactByMetricIdentity = new Map();
5185
+ for (const candidate of candidates) {
5186
+ if (candidate.kind !== 'semantic_metric'
5187
+ || candidate.compatibility === 'incompatible'
5188
+ || candidate.eligible === false
5189
+ || !candidateMetricLeafIdentities(candidate).includes(requested))
5190
+ continue;
5191
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
5192
+ const metricIdentity = normalized.capability?.metricId
5193
+ ?? candidate.qualifiedId
5194
+ ?? candidate.id;
5195
+ const current = exactByMetricIdentity.get(metricIdentity);
5196
+ if (!current || candidate.relevanceScore > current.relevanceScore) {
5197
+ exactByMetricIdentity.set(metricIdentity, candidate);
5198
+ }
5199
+ }
5200
+ if (exactByMetricIdentity.size === 1) {
5201
+ return [...exactByMetricIdentity.values()][0];
5202
+ }
5203
+ }
3356
5204
  const byMetricId = new Map();
3357
5205
  for (const candidate of candidates) {
3358
- if (candidate.kind !== "semantic_metric" || candidate.compatibility !== "compatible")
5206
+ // Retrieval compatibility is a pre-plan hint. A current snapshot can mark
5207
+ // a MetricFlow metric `unknown`/`partial` until its selected grouping is
5208
+ // bound, even though the capability itself is complete. The direct route
5209
+ // below still solves the full tuple before freeze, so exclude only an
5210
+ // explicitly incompatible metric here rather than turning exact `revenue`
5211
+ // into an artificial choice against `product_revenue`.
5212
+ if (candidate.kind !== "semantic_metric" || candidate.compatibility === "incompatible")
3359
5213
  continue;
3360
5214
  if (/\bdbt\s+measure\b/i.test(candidate.provenance ?? ""))
3361
5215
  continue;
@@ -3374,8 +5228,33 @@ function uniqueExecutableSemanticMetric(evidence, candidates) {
3374
5228
  byMetricId.set(normalized.capability.metricId, candidate);
3375
5229
  }
3376
5230
  }
5231
+ // A malformed/empty candidate-ID meaning response may also safely fall back
5232
+ // when a complete capability proves one exact semantic metric identity from
5233
+ // the current-turn requirement seed. This is intentionally stricter than
5234
+ // the token fallback below: `order_items.revenue` has the leaf identity
5235
+ // `revenue`, whereas `order_items.product_revenue` is a different meaning
5236
+ // and must not force a clarification when the reader asked only for
5237
+ // revenue. Multi-metric questions retain their dedicated exact tuple path.
5238
+ if (requestedMeasures.length === 1) {
5239
+ const requested = requestedMeasures[0];
5240
+ const exactMatches = [...byMetricId.values()].filter((candidate) => candidateMetricLeafIdentities(candidate).includes(requested));
5241
+ if (exactMatches.length === 1)
5242
+ return exactMatches[0];
5243
+ }
3377
5244
  return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
3378
5245
  }
5246
+ function candidateMetricLeafIdentities(candidate) {
5247
+ return [...new Set([
5248
+ candidate.id,
5249
+ candidate.qualifiedId ?? '',
5250
+ candidate.name,
5251
+ ...(candidate.aliases ?? []),
5252
+ ].flatMap((identity) => {
5253
+ const normalized = normalizeMetricPhrase(identity);
5254
+ const leaf = normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity);
5255
+ return [normalized, leaf].filter(Boolean);
5256
+ }))];
5257
+ }
3379
5258
  function metricTokens(values) {
3380
5259
  const ignored = new Set([
3381
5260
  "a", "an", "and", "as", "at", "by", "current", "for", "from", "is",
@@ -3478,6 +5357,81 @@ function rethrowCancellation(error, ...signals) {
3478
5357
  if (error instanceof Error && error.name === "AbortError")
3479
5358
  throw error;
3480
5359
  }
5360
+ /**
5361
+ * Admit the one narrowly typed extension produced by metadata only when it
5362
+ * closes an actually unmet requested categorical role and its source metric is
5363
+ * already in the compact meaning package. The extension was derived from the
5364
+ * same immutable metric capability snapshot; this function does not search,
5365
+ * infer a join, or create an alias. Multiple candidates for a role stay an
5366
+ * ambiguity instead of being silently ranked into a new business meaning.
5367
+ */
5368
+ function sameSnapshotRoleTargetedMeaningExtensions(input) {
5369
+ const extensionsByRole = new Map();
5370
+ const requestedCategoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
5371
+ for (const candidate of input.clarificationCandidates) {
5372
+ const extension = candidate.sameSnapshotRoleExtension;
5373
+ if (!extension)
5374
+ continue;
5375
+ if (!requestedCategoricalTerms.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
5376
+ continue;
5377
+ // A raw/dbt column can lexically satisfy "product category", but it is
5378
+ // not a MetricFlow binding for this semantic metric. Do not let that
5379
+ // broad retrieval hit suppress the exact same-snapshot semantic member:
5380
+ // otherwise a package contains only the raw column, the model cannot bind
5381
+ // the metric-native grouping, and the cascade falsely reports absence.
5382
+ // An already-admitted semantic member remains sufficient and keeps this
5383
+ // extension bounded to genuinely unmet semantic roles.
5384
+ if (input.candidates.some((admitted) => admitted.kind === 'semantic_member'
5385
+ && candidateMatchesCategoricalDimensionRequirement(admitted, [extension.requestedTerm])))
5386
+ continue;
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)
5399
+ continue;
5400
+ const role = normalizeMetricPhrase(extension.requestedTerm);
5401
+ const values = extensionsByRole.get(role) ?? [];
5402
+ if (!values.some((value) => value.id === candidate.id))
5403
+ values.push(candidate);
5404
+ extensionsByRole.set(role, values);
5405
+ }
5406
+ return [...extensionsByRole.values()]
5407
+ .flatMap((values) => values.length === 1 ? values : [])
5408
+ .sort((left, right) => left.id.localeCompare(right.id));
5409
+ }
5410
+ /**
5411
+ * Keep the role-targeted form of a qualified candidate when retrieval has
5412
+ * emitted both the ordinary catalog card and a same-snapshot extension with
5413
+ * the identical stable ID. The extension is not a second object or a new
5414
+ * join: it is the metadata proof explaining why that exact qualified object
5415
+ * closes a currently unmet role for a selected metric. Dropping it merely
5416
+ * because the less-specific catalog card appeared first makes the compact
5417
+ * meaning package lose a declared MetricFlow grouping and can manufacture a
5418
+ * false coverage gap.
5419
+ */
5420
+ function consolidateClarificationCandidates(input) {
5421
+ const byId = new Map();
5422
+ for (const candidate of input.candidates) {
5423
+ if (candidate.eligible === false
5424
+ || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
5425
+ continue;
5426
+ const existing = byId.get(candidate.id);
5427
+ if (!existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension)) {
5428
+ // Map replacement preserves the original stable ordering while retaining
5429
+ // the stricter same-snapshot proof for this exact candidate identity.
5430
+ byId.set(candidate.id, candidate);
5431
+ }
5432
+ }
5433
+ return [...byId.values()];
5434
+ }
3481
5435
  /**
3482
5436
  * Build a retrieval-first hybrid router. Narrow conversational/app preflight is
3483
5437
  * deterministic. Every other turn loads structured evidence before any
@@ -3523,8 +5477,12 @@ export function createHybridRouter(options = {}) {
3523
5477
  if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
3524
5478
  return softBoundaryDecision(request, base, initialDiscoveryRoute);
3525
5479
  }
3526
- let evidence;
3527
- 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) {
3528
5486
  try {
3529
5487
  evidence = await options.getEvidence(request);
3530
5488
  }
@@ -3541,6 +5499,10 @@ export function createHybridRouter(options = {}) {
3541
5499
  // boundary so every downstream plan/meaning path consumes the same
3542
5500
  // typed measure + entity/dimension requirements.
3543
5501
  evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
5502
+ // Keep the complete snapshot result for trace-only lifecycle evidence
5503
+ // before canonicalization and bounded meaning-package admission prune it.
5504
+ const retrievedCandidates = evidence.candidates;
5505
+ traceRetrievalLanesBeforePruning(request, evidence, retrievedCandidates);
3544
5506
  evidence = {
3545
5507
  ...evidence,
3546
5508
  candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
@@ -3551,26 +5513,140 @@ export function createHybridRouter(options = {}) {
3551
5513
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
3552
5514
  : {}),
3553
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
+ }
5557
+ // Ranking measure identity is resolved from the same immutable
5558
+ // request/evidence frame that drives package reservation. A metric
5559
+ // may remain in broad retrieval context, but it cannot become a
5560
+ // clarification or meaning-selection alternative after an explicit
5561
+ // comparator already bound a different ranking measure.
5562
+ const persistedSelection = request.selectedEvidenceId
5563
+ ? persistedClarificationSelectionContext(request)
5564
+ : undefined;
5565
+ // A structured click is a continuation of the original server turn,
5566
+ // not a new parse of its display label. Retain the typed partial frame
5567
+ // (ranking/time included) before candidate pruning can affect it.
5568
+ const continuationQuestion = persistedSelection?.serverIssued && persistedSelection.sourceQuestion
5569
+ ? persistedSelection.sourceQuestion
5570
+ // A free-text turn is always a new source question. A browser may
5571
+ // retain clarificationSourceQuestion for display, but it must never
5572
+ // make stale retrieval intent or a previous result authoritative.
5573
+ : request.question;
5574
+ // Retrieval parser output is never an independent source of request
5575
+ // authority, but it can refine a host-owned source question when every
5576
+ // business term is demonstrably present in that question. In
5577
+ // particular, "active seats" is a legitimate current-turn metric
5578
+ // refinement for "Which workspaces have the most active seats?";
5579
+ // omitting it altogether forced the seed to erase an explicit metric
5580
+ // before the model had a chance only to bind it. The shared
5581
+ // requirement builder rejects stale parser terms (for example a prior
5582
+ // rollover balance, member filter, or top-N), so this remains a
5583
+ // source-question-plus-server-selection tuple rather than retrieval
5584
+ // intent becoming plan authority.
5585
+ // A server-composed child Ask (currently a Research hypothesis) may
5586
+ // carry an exact host-owned seed. Planner prose is not user wording:
5587
+ // reparsing it can turn an asset namespace or explanatory phrase into
5588
+ // a second requested measure. Accept the seed only when it belongs
5589
+ // to this exact source question and there is no structured
5590
+ // continuation to merge; public request parsers never hydrate this
5591
+ // host-only field.
5592
+ const hostSeed = !request.selectedEvidenceId
5593
+ && request.hostRequirementSeed?.version === 1
5594
+ && request.hostRequirementSeed.sourceQuestion === continuationQuestion
5595
+ ? request.hostRequirementSeed
5596
+ : undefined;
5597
+ const sourceQuestionRequirements = hostSeed?.requirements
5598
+ ?? buildAnalyticalRequirementSet({
5599
+ question: continuationQuestion,
5600
+ parsedIntent: evidence.parsedIntent,
5601
+ });
5602
+ const analyticalRequirements = request.selectedEvidenceId
5603
+ ? mergeClarificationRequirements(sourceQuestionRequirements, persistedSelection?.serverIssued ? persistedSelection.requirements : undefined)
5604
+ : sourceQuestionRequirements;
5605
+ // The host freezes the reader's requested tuple before the bounded
5606
+ // meaning call. A model may bind only supplied candidate identities;
5607
+ // it cannot erase explicit outputs, ranking, time, or a new question's
5608
+ // independent scope by rephrasing the request.
5609
+ const requirementSeed = hostSeed
5610
+ ? hostSeed
5611
+ : buildAnalyticalRequirementSeedV1({
5612
+ question: continuationQuestion,
5613
+ parsedIntent: evidence.parsedIntent,
5614
+ requirements: analyticalRequirements,
5615
+ fiscalCalendar: declaredFiscalCalendar(evidence, evidence.candidates),
5616
+ });
3554
5617
  // Retrieval can retain more context internally; only this compact,
3555
5618
  // role-balanced package reaches the one meaning call. The question is
3556
5619
  // supplied so explicit revenue, entity labels, and time roles cannot be
3557
5620
  // 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);
5621
+ let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, continuationQuestion);
5622
+ recordAskCandidateLifecycleV1(request, evidence, retrievedCandidates, candidates);
5623
+ // The complete already-retrieved set is retained only to validate a
5624
+ // server-issued stable selection before any route can fall through to
5625
+ // a generic/generated answer. New free-text choices are constrained to
5626
+ // the admitted package below, so a pruned candidate cannot reappear as
5627
+ // an unrelated clarification option.
5628
+ const clarificationCandidates = consolidateClarificationCandidates({
5629
+ candidates: [
5630
+ ...evidence.candidates,
5631
+ ...(evidence.clarificationCandidates ?? []),
5632
+ ],
5633
+ requirements: analyticalRequirements,
5634
+ });
3568
5635
  // A structured clarification selection is identity input, not a new
3569
5636
  // fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
3570
5637
  // supplemental clarification candidates, not execution candidates.
3571
- const selectedEvidence = request.selectedEvidenceId
5638
+ let selectedEvidence = request.selectedEvidenceId
3572
5639
  ? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
3573
5640
  : undefined;
5641
+ if (!selectedEvidence && request.selectedEvidenceId) {
5642
+ selectedEvidence = rehydrateServerIssuedCapabilityDimensionSelection({
5643
+ request,
5644
+ evidence,
5645
+ candidates: clarificationCandidates,
5646
+ });
5647
+ if (selectedEvidence)
5648
+ clarificationCandidates.push(selectedEvidence);
5649
+ }
3574
5650
  const structuredSelection = request.selectedEvidenceId
3575
5651
  ? validateStructuredClarificationSelection({
3576
5652
  request,
@@ -3589,10 +5665,77 @@ export function createHybridRouter(options = {}) {
3589
5665
  validation: structuredSelection,
3590
5666
  });
3591
5667
  }
3592
- if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
3593
- candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
3594
- .slice(0, options.maxMeaningCandidates ?? 16);
5668
+ const selectedDimensionBinding = structuredSelection?.ok
5669
+ && structuredSelection.selection.kind === 'dimension'
5670
+ ? structuredSelection.selection
5671
+ : undefined;
5672
+ const stableSelectionCandidates = [
5673
+ ...(selectedDimensionBinding ? [selectedDimensionBinding.metricCandidate] : []),
5674
+ ...(selectedEvidence ? [selectedEvidence] : []),
5675
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
5676
+ // One host-authored, role-targeted extension may enter the same
5677
+ // snapshot package before absence is declared. It is eligible only
5678
+ // when a selected/retrieved semantic metric itself proves one unique
5679
+ // MetricFlow grouping dimension for the unmet business role. This is
5680
+ // not a lexical location→region rule and never creates a raw join.
5681
+ const roleTargetedExtensions = sameSnapshotRoleTargetedMeaningExtensions({
5682
+ candidates,
5683
+ clarificationCandidates,
5684
+ requirements: analyticalRequirements,
5685
+ });
5686
+ const extensions = [
5687
+ ...stableSelectionCandidates,
5688
+ ...roleTargetedExtensions,
5689
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index
5690
+ // A role-targeted card may intentionally share the stable ID of an
5691
+ // unscoped catalog card already admitted by generic relevance. In
5692
+ // that case replace the card in-place below so the provider sees the
5693
+ // same qualified object plus the immutable role proof; do not drop
5694
+ // the proof just because its base record was admitted first.
5695
+ && (() => {
5696
+ const existing = candidates.find((item) => item.id === candidate.id);
5697
+ return !existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension);
5698
+ })());
5699
+ if (extensions.length > 0) {
5700
+ // Keep the exact metric and the separately selected semantic member
5701
+ // together. The choice never becomes a synthetic metric merely to
5702
+ // fit the bounded meaning package.
5703
+ candidates = [...extensions, ...candidates.filter((candidate) => !extensions.some((extension) => extension.id === candidate.id))]
5704
+ .slice(0, Math.max(1, Math.min(16, options.maxMeaningCandidates ?? 16)));
5705
+ const observer = askTraceObserverForV1(request);
5706
+ for (const extension of extensions) {
5707
+ const roles = evidenceCandidateRoles(extension);
5708
+ for (const role of roles) {
5709
+ observer.recordCandidateDecision({
5710
+ // This must stay on the router's stable resolver identity. The
5711
+ // qualifiedId is display/provenance data and can differ from the
5712
+ // candidate id used by meaning validation and model selection.
5713
+ candidateId: extension.id,
5714
+ role,
5715
+ source: traceSourceForCandidate(extension),
5716
+ decision: 'extended',
5717
+ reasonCode: 'same_snapshot_extension',
5718
+ compatibilityCode: 'unknown',
5719
+ });
5720
+ }
5721
+ }
3595
5722
  }
5723
+ // Only package-admitted, role-compatible candidates may become a new
5724
+ // free-text clarification option. The complete snapshot is retained
5725
+ // above exclusively to validate a server-issued stable selection or
5726
+ // to prove a terminal safety boundary; it must never let a pruned,
5727
+ // unrelated metric re-enter the answer flow as a choice.
5728
+ const admittedClarificationCandidates = candidates.filter((candidate) => candidate.eligible !== false
5729
+ && candidate.compatibility !== 'incompatible'
5730
+ && !candidateConflictsWithExplicitRankingMeasure(candidate, analyticalRequirements));
5731
+ const attributionGap = attributionRequiredRelationshipGapDecision({
5732
+ request,
5733
+ base,
5734
+ evidence,
5735
+ requirements: analyticalRequirements,
5736
+ });
5737
+ if (attributionGap)
5738
+ return attributionGap;
3596
5739
  // The compact meaning package can be empty when every ranked card was
3597
5740
  // reserved for a role that the parser marked missing. A unique authored
3598
5741
  // certified example is still an authoritative snapshot fact, so it
@@ -3614,9 +5757,18 @@ export function createHybridRouter(options = {}) {
3614
5757
  const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
3615
5758
  if (fiscalClarification)
3616
5759
  return fiscalClarification;
3617
- const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
5760
+ // A selected display/grouping field completes a persisted frame; the
5761
+ // matched semantic metric remains the only execution/measure
5762
+ // authority. A selected dimension must therefore never flow through
5763
+ // the metric-only direct-resolution path as the primary candidate.
5764
+ const canonicalLiteral = canonicalLiteralEvidenceReference(request.question, candidates);
5765
+ const explicit = selectedDimensionBinding?.metricCandidate
5766
+ ?? selectedEvidence
5767
+ ?? findExplicitEvidenceReference(request.question, candidates)
5768
+ ?? canonicalLiteral;
3618
5769
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
3619
- || /@(metric|block|model|table|column)\(/i.test(request.question)));
5770
+ || /@(metric|block|model|table|column)\(/i.test(request.question)
5771
+ || canonicalLiteral?.id === explicit.id));
3620
5772
  const shouldUseMeaningCall = requireMeaningCall
3621
5773
  && !explicitMeaningBinding
3622
5774
  && Boolean(options.resolveMeaning || options.complete);
@@ -3627,7 +5779,7 @@ export function createHybridRouter(options = {}) {
3627
5779
  // execution shim as the answer and never let the meaning model see
3628
5780
  // the ranking entity/measure distinction.
3629
5781
  if (!shouldUseMeaningCall && !explicitMeaningBinding) {
3630
- const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5782
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
3631
5783
  if (deterministicClarification)
3632
5784
  return deterministicClarification;
3633
5785
  }
@@ -3638,7 +5790,7 @@ export function createHybridRouter(options = {}) {
3638
5790
  && !hasExplicitRankingMeasure(request.question, evidence)) {
3639
5791
  return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
3640
5792
  }
3641
- const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
5793
+ const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates, selectedDimensionBinding ? [selectedDimensionBinding.dimensionId] : [], selectedDimensionBinding ? [selectedDimensionBinding.selectedDimensionId] : []), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
3642
5794
  // A valid stable semantic choice may still be pre-freeze-ineligible
3643
5795
  // in this local snapshot (for example, the semantic adapter cannot
3644
5796
  // execute the selected tuple). That is a normal cascade condition:
@@ -3648,10 +5800,16 @@ export function createHybridRouter(options = {}) {
3648
5800
  const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
3649
5801
  && decision.resolvedAnalyticalPlan?.capability === 'blocked'
3650
5802
  && decision.analyticalCascadeDecision?.planFrozen === false;
3651
- return selectedEvidence
5803
+ return explicit
3652
5804
  && structuredSelection?.ok
3653
5805
  && (decision.requiresClarification || selectedPreFreezeModelingGap)
3654
- ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence, request.question)
5806
+ ? continueCascadeAfterIncompleteSelection(base, evidence, candidates,
5807
+ // Preserve the display-key identity the user clicked in
5808
+ // user-facing gap/continuation evidence; the paired metric
5809
+ // remains recorded below as execution provenance.
5810
+ selectedEvidence ?? explicit, request.question, selectedDimensionBinding
5811
+ ? [selectedDimensionBinding.metricCandidate.id, selectedDimensionBinding.selectedDimensionId]
5812
+ : [explicit.id])
3655
5813
  : decision;
3656
5814
  }
3657
5815
  const multiMetricPrimary = !shouldUseMeaningCall
@@ -3687,41 +5845,97 @@ export function createHybridRouter(options = {}) {
3687
5845
  // did run, committing to the best governed reading is the whole point.
3688
5846
  let meaningResolverReachable = true;
3689
5847
  try {
3690
- if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
3691
- 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');
3692
5854
  }
3693
- const resolution = options.resolveMeaning
3694
- ? await options.resolveMeaning({
3695
- question: request.question,
3696
- history: effectiveConversationHistory(request),
3697
- // The resolver/provider receives the same bounded evidence
3698
- // package as its candidate argument. Supplemental qualified
3699
- // cards are a host-only clarification aid and must not leak
3700
- // through this richer carrier.
3701
- evidence: {
3702
- ...evidence,
5855
+ let resolution;
5856
+ try {
5857
+ resolution = options.resolveMeaning
5858
+ ? await options.resolveMeaning({
5859
+ question: requirementSeed.sourceQuestion,
5860
+ history: request.conversationBinding && request.conversationBinding !== 'none'
5861
+ ? effectiveConversationHistory(request)
5862
+ : undefined,
5863
+ // The resolver/provider receives the same bounded evidence
5864
+ // package as its candidate argument. Supplemental qualified
5865
+ // cards are a host-only clarification aid and must not leak
5866
+ // through this richer carrier.
5867
+ evidence: {
5868
+ ...evidence,
5869
+ candidates,
5870
+ clarificationCandidates: undefined,
5871
+ },
3703
5872
  candidates,
3704
- clarificationCandidates: undefined,
3705
- },
3706
- candidates,
3707
- signal: request.signal ?? options.signal,
3708
- })
3709
- : options.complete
3710
- ? parseMeaningResolution(await options.complete({
3711
- system: buildMeaningSystemPrompt(),
3712
- user: buildMeaningUserPrompt(request, evidence, candidates),
5873
+ requirementSeed,
3713
5874
  signal: request.signal ?? options.signal,
3714
- }))
3715
- : undefined;
5875
+ })
5876
+ : options.complete
5877
+ ? parseMeaningResolution(await options.complete({
5878
+ system: buildMeaningSystemPrompt(),
5879
+ user: buildMeaningUserPrompt(request, evidence, candidates, requirementSeed),
5880
+ signal: request.signal ?? options.signal,
5881
+ request,
5882
+ phase: 'meaning_resolution',
5883
+ }), requirementSeed)
5884
+ : undefined;
5885
+ }
5886
+ catch (error) {
5887
+ throw error;
5888
+ }
3716
5889
  if (resolution) {
5890
+ // A model is allowed to say that it cannot bind any supplied
5891
+ // card, but that is not a new business ambiguity by itself. If
5892
+ // the host can prove one exact current-turn semantic identity,
5893
+ // take the ordinary direct/cascade path instead of preserving an
5894
+ // empty selection as a clarification. This is intentionally
5895
+ // evaluated before merge/validation so the host never treats an
5896
+ // empty provider response as an authoritative analytical frame.
5897
+ if (meaningResolutionHasNoBinding(resolution)) {
5898
+ const hostFallback = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', true);
5899
+ if (isFrozenExecutableHostFallback(hostFallback)) {
5900
+ return remember(key, hostFallback);
5901
+ }
5902
+ }
5903
+ // Merge exactly once at the model boundary. Candidate IDs remain
5904
+ // subject to the bounded-package validator below; model route,
5905
+ // SQL-adjacent frame, rephrased question, and query intent never
5906
+ // become downstream authority.
5907
+ resolution = mergeMeaningResolutionWithRequirementSeed({
5908
+ seed: requirementSeed,
5909
+ resolution,
5910
+ candidates,
5911
+ });
3717
5912
  // Repair an incomplete certified nomination before generic
3718
5913
  // evidence validation. This preserves the real semantic choices
3719
5914
  // instead of turning a false certified selection into an opaque
3720
5915
  // invalid-evidence gap.
3721
5916
  const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
3722
- const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures);
5917
+ const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures, { requirements: analyticalRequirements });
3723
5918
  if (validated.ok) {
3724
- const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
5919
+ // The meaning model may select only supplied candidate IDs. It
5920
+ // intentionally cannot return an analytical frame, because a
5921
+ // model-owned frame could add a metric, dimension, output, or
5922
+ // prior-turn scope after the host froze the requirement seed.
5923
+ //
5924
+ // Do not leave that candidate-only result on the legacy V1
5925
+ // path, though: a valid selected semantic metric plus its
5926
+ // selected supplied members must receive the same *host-built*
5927
+ // V2 frame as a zero-call/direct resolution. Otherwise the
5928
+ // router freezes a V1 semantic plan, the answer loop skips the
5929
+ // immutable semantic execution graph, and legacy SQL is
5930
+ // incorrectly checked against generic aggregation metadata.
5931
+ const frameBoundResolution = attachHostOwnedAnalyticalFrame({
5932
+ request,
5933
+ evidence,
5934
+ candidates,
5935
+ requirementSeed,
5936
+ resolution: validated.resolution,
5937
+ });
5938
+ const safeResolution = preventDegenerateRankingResolution(frameBoundResolution, evidence, candidates, request.question);
3725
5939
  // Meaning interpretation is still required for a fresh turn,
3726
5940
  // but it cannot invent a ranking measure when the user only
3727
5941
  // supplied an entity. Preserve the precise follow-up after the
@@ -3770,24 +5984,27 @@ export function createHybridRouter(options = {}) {
3770
5984
  .some((item) => /measure|metric/i.test(item));
3771
5985
  const assumedRanking = resolverFlagged
3772
5986
  ? undefined
3773
- : assumableRankingMeasure(request.question, clarificationCandidates);
5987
+ : assumableRankingMeasure(request.question, admittedClarificationCandidates);
3774
5988
  if (assumedRanking) {
3775
5989
  return {
3776
5990
  ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
3777
5991
  assumptions: [assumedRanking.assumption],
3778
5992
  };
3779
5993
  }
3780
- return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
3781
- // Supplemental clarification cards carry the ranking
3782
- // measures for the requested entity, which the execution
3783
- // candidate set deliberately does not.
3784
- clarificationCandidates, !resolverFlagged);
5994
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, admittedClarificationCandidates, !resolverFlagged);
3785
5995
  }
3786
- const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5996
+ const deterministicGap = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
3787
5997
  if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
3788
5998
  return deterministicGap;
3789
5999
  }
3790
- return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
6000
+ const meaningDecision = routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
6001
+ return remember(key, continuePreFreezeModelingGapThroughPhysicalSnapshot({
6002
+ decision: meaningDecision,
6003
+ base,
6004
+ evidence,
6005
+ candidates,
6006
+ question: request.question,
6007
+ }));
3791
6008
  }
3792
6009
  const invalidResolution = {
3793
6010
  interpretedQuestion: request.question,
@@ -3823,11 +6040,14 @@ export function createHybridRouter(options = {}) {
3823
6040
  const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
3824
6041
  if (!shouldUseMeaningCall)
3825
6042
  return fallbackDecision;
6043
+ if (isFrozenExecutableHostFallback(fallbackDecision)) {
6044
+ return fallbackDecision;
6045
+ }
3826
6046
  // The provider was unavailable or returned malformed JSON. Apply the
3827
6047
  // deterministic clarification only after the bounded meaning attempt
3828
6048
  // has been exhausted; this preserves a precise recovery path without
3829
6049
  // allowing the generic governed error to terminate the question.
3830
- return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
6050
+ return deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates)
3831
6051
  ?? fallbackDecision;
3832
6052
  }
3833
6053
  }
@@ -3890,6 +6110,8 @@ export function createHybridRouter(options = {}) {
3890
6110
  system: buildSystemPrompt(),
3891
6111
  user: buildUserPrompt(request, catalogContext),
3892
6112
  signal: request.signal ?? options.signal,
6113
+ request,
6114
+ phase: 'classification',
3893
6115
  });
3894
6116
  const classification = parseClassification(raw);
3895
6117
  if (classification)
@@ -3918,15 +6140,42 @@ function softBoundaryDecision(request, base, route) {
3918
6140
  const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
3919
6141
  return {
3920
6142
  ...base,
3921
- 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',
3922
6150
  confidence: 1,
3923
6151
  source: 'heuristic',
3924
- requiresClarification: true,
6152
+ requiresClarification: false,
6153
+ clarifyingQuestion: undefined,
3925
6154
  reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
3926
- clarifyingQuestion: request.runBudget?.mode === 'research'
3927
- ? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
3928
- : 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
3929
6155
  };
3930
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
+ }
3931
6180
  export { intentForCategory, parseMeaningResolution };
3932
6181
  //# sourceMappingURL=router.js.map