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

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 (174) hide show
  1. package/dist/agent-run-engine.d.ts +71 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +1048 -31
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +12 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/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 +48 -3
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +7 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +211 -28
  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/research/synthesis.d.ts +4 -0
  23. package/dist/agentic/research/synthesis.d.ts.map +1 -1
  24. package/dist/agentic/research/synthesis.js +23 -8
  25. package/dist/agentic/research/synthesis.js.map +1 -1
  26. package/dist/agentic/research-agent.d.ts.map +1 -1
  27. package/dist/agentic/research-agent.js +13 -3
  28. package/dist/agentic/research-agent.js.map +1 -1
  29. package/dist/agentic/sql-authorization.d.ts +8 -0
  30. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  31. package/dist/agentic/sql-authorization.js +233 -2
  32. package/dist/agentic/sql-authorization.js.map +1 -1
  33. package/dist/analytical-compatibility.d.ts.map +1 -1
  34. package/dist/analytical-compatibility.js +8 -1
  35. package/dist/analytical-compatibility.js.map +1 -1
  36. package/dist/analytical-failure-repair.d.ts.map +1 -1
  37. package/dist/analytical-failure-repair.js +6 -2
  38. package/dist/analytical-failure-repair.js.map +1 -1
  39. package/dist/analytical-frame.d.ts +23 -1
  40. package/dist/analytical-frame.d.ts.map +1 -1
  41. package/dist/analytical-frame.js +332 -17
  42. package/dist/analytical-frame.js.map +1 -1
  43. package/dist/analytical-orchestration.d.ts +613 -0
  44. package/dist/analytical-orchestration.d.ts.map +1 -1
  45. package/dist/analytical-orchestration.js +1007 -0
  46. package/dist/analytical-orchestration.js.map +1 -1
  47. package/dist/answer-loop.d.ts +110 -6
  48. package/dist/answer-loop.d.ts.map +1 -1
  49. package/dist/answer-loop.js +1180 -125
  50. package/dist/answer-loop.js.map +1 -1
  51. package/dist/answer-shape.d.ts +32 -1
  52. package/dist/answer-shape.d.ts.map +1 -1
  53. package/dist/answer-shape.js +49 -3
  54. package/dist/answer-shape.js.map +1 -1
  55. package/dist/ask-observability/index.d.ts +11 -0
  56. package/dist/ask-observability/index.d.ts.map +1 -0
  57. package/dist/ask-observability/index.js +7 -0
  58. package/dist/ask-observability/index.js.map +1 -0
  59. package/dist/ask-observability/instrumentation.d.ts +35 -0
  60. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  61. package/dist/ask-observability/instrumentation.js +472 -0
  62. package/dist/ask-observability/instrumentation.js.map +1 -0
  63. package/dist/ask-observability/observer.d.ts +62 -0
  64. package/dist/ask-observability/observer.d.ts.map +1 -0
  65. package/dist/ask-observability/observer.js +537 -0
  66. package/dist/ask-observability/observer.js.map +1 -0
  67. package/dist/ask-observability/portable.d.ts +98 -0
  68. package/dist/ask-observability/portable.d.ts.map +1 -0
  69. package/dist/ask-observability/portable.js +527 -0
  70. package/dist/ask-observability/portable.js.map +1 -0
  71. package/dist/ask-observability/store.d.ts +130 -0
  72. package/dist/ask-observability/store.d.ts.map +1 -0
  73. package/dist/ask-observability/store.js +1114 -0
  74. package/dist/ask-observability/store.js.map +1 -0
  75. package/dist/ask-observability/types.d.ts +357 -0
  76. package/dist/ask-observability/types.d.ts.map +1 -0
  77. package/dist/ask-observability/types.js +2 -0
  78. package/dist/ask-observability/types.js.map +1 -0
  79. package/dist/ask-observability/utils.d.ts +21 -0
  80. package/dist/ask-observability/utils.d.ts.map +1 -0
  81. package/dist/ask-observability/utils.js +102 -0
  82. package/dist/ask-observability/utils.js.map +1 -0
  83. package/dist/conversation/snapshot.d.ts +20 -0
  84. package/dist/conversation/snapshot.d.ts.map +1 -1
  85. package/dist/conversation/snapshot.js +30 -1
  86. package/dist/conversation/snapshot.js.map +1 -1
  87. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  88. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  89. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  90. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  91. package/dist/generated-analytical-proposal.d.ts +37 -1
  92. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  93. package/dist/generated-analytical-proposal.js +211 -3
  94. package/dist/generated-analytical-proposal.js.map +1 -1
  95. package/dist/governed-relational-compiler.d.ts +15 -0
  96. package/dist/governed-relational-compiler.d.ts.map +1 -1
  97. package/dist/governed-relational-compiler.js +99 -1
  98. package/dist/governed-relational-compiler.js.map +1 -1
  99. package/dist/index.d.ts +11 -9
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +7 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/intent-controller.d.ts +38 -0
  104. package/dist/intent-controller.d.ts.map +1 -1
  105. package/dist/intent-controller.js +31 -1
  106. package/dist/intent-controller.js.map +1 -1
  107. package/dist/meaning-resolution.d.ts +141 -2
  108. package/dist/meaning-resolution.d.ts.map +1 -1
  109. package/dist/meaning-resolution.js +444 -30
  110. package/dist/meaning-resolution.js.map +1 -1
  111. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  112. package/dist/metadata/analysis-planner.js +144 -7
  113. package/dist/metadata/analysis-planner.js.map +1 -1
  114. package/dist/metadata/block-fit.d.ts +18 -0
  115. package/dist/metadata/block-fit.d.ts.map +1 -1
  116. package/dist/metadata/block-fit.js +250 -38
  117. package/dist/metadata/block-fit.js.map +1 -1
  118. package/dist/metadata/catalog.d.ts.map +1 -1
  119. package/dist/metadata/catalog.js +58 -12
  120. package/dist/metadata/catalog.js.map +1 -1
  121. package/dist/metadata/meaning-evidence.d.ts +19 -3
  122. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  123. package/dist/metadata/meaning-evidence.js +518 -54
  124. package/dist/metadata/meaning-evidence.js.map +1 -1
  125. package/dist/plan-execution-adapter.d.ts +9 -1
  126. package/dist/plan-execution-adapter.d.ts.map +1 -1
  127. package/dist/plan-execution-adapter.js +93 -13
  128. package/dist/plan-execution-adapter.js.map +1 -1
  129. package/dist/provider-egress.d.ts +24 -10
  130. package/dist/provider-egress.d.ts.map +1 -1
  131. package/dist/provider-egress.js +22 -17
  132. package/dist/provider-egress.js.map +1 -1
  133. package/dist/providers/claude.d.ts.map +1 -1
  134. package/dist/providers/claude.js +10 -9
  135. package/dist/providers/claude.js.map +1 -1
  136. package/dist/providers/dispatch.d.ts +27 -1
  137. package/dist/providers/dispatch.d.ts.map +1 -1
  138. package/dist/providers/dispatch.js +63 -1
  139. package/dist/providers/dispatch.js.map +1 -1
  140. package/dist/providers/gemini.js +10 -10
  141. package/dist/providers/gemini.js.map +1 -1
  142. package/dist/providers/index.d.ts +2 -2
  143. package/dist/providers/index.d.ts.map +1 -1
  144. package/dist/providers/index.js +1 -1
  145. package/dist/providers/index.js.map +1 -1
  146. package/dist/providers/ollama.js +15 -15
  147. package/dist/providers/ollama.js.map +1 -1
  148. package/dist/providers/openai.js +22 -22
  149. package/dist/providers/openai.js.map +1 -1
  150. package/dist/providers/types.d.ts +52 -0
  151. package/dist/providers/types.d.ts.map +1 -1
  152. package/dist/providers/types.js.map +1 -1
  153. package/dist/relationship-proof.d.ts +81 -0
  154. package/dist/relationship-proof.d.ts.map +1 -1
  155. package/dist/relationship-proof.js +228 -4
  156. package/dist/relationship-proof.js.map +1 -1
  157. package/dist/research-loop.d.ts.map +1 -1
  158. package/dist/research-loop.js +23 -1
  159. package/dist/research-loop.js.map +1 -1
  160. package/dist/resolved-analytical-plan.d.ts +22 -1
  161. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  162. package/dist/resolved-analytical-plan.js +773 -49
  163. package/dist/resolved-analytical-plan.js.map +1 -1
  164. package/dist/router.d.ts +14 -4
  165. package/dist/router.d.ts.map +1 -1
  166. package/dist/router.js +3668 -425
  167. package/dist/router.js.map +1 -1
  168. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  169. package/dist/semantic-bridge/compose.js +23 -4
  170. package/dist/semantic-bridge/compose.js.map +1 -1
  171. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  172. package/dist/semantic-bridge/member-select.js +39 -21
  173. package/dist/semantic-bridge/member-select.js.map +1 -1
  174. package/package.json +5 -5
package/dist/router.js CHANGED
@@ -16,11 +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, 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, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, categoricalDimensionRequirementTerms, candidateConflictsWithExplicitRankingMeasure, candidateMatchesCategoricalDimensionRequirement, evidenceCandidateRoles, isEntityAttributeCandidate, } from './analytical-orchestration.js';
23
+ import { askTraceObserverForV1 } from './ask-observability/index.js';
22
24
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
23
- import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
25
+ import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
24
26
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
25
27
  const DEFAULT_THRESHOLD = 0.7;
26
28
  const DEFAULT_CACHE_SIZE = 200;
@@ -196,17 +198,19 @@ function parseClassification(raw) {
196
198
  function buildMeaningSystemPrompt() {
197
199
  return [
198
200
  "You resolve business meaning for DQL, a governed analytics system.",
199
- "The host already performed broad retrieval. Compare ONLY the supplied candidate cards.",
200
- "Select the concept that best matches the full question, entity, dimensions, filters, time grain, formula, aggregation, domain, and conversation context.",
201
- "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.",
202
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.",
203
207
  "Use low confidence and recommend clarify when material business meanings remain unresolved.",
204
- "Respond with ONLY one JSON object matching this shape:",
205
- '{"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}',
206
- "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.",
207
211
  ].join("\n");
208
212
  }
209
- function buildMeaningUserPrompt(request, evidence, candidates) {
213
+ function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
210
214
  const cards = candidates.map((candidate) => ({
211
215
  id: candidate.id,
212
216
  kind: candidate.kind,
@@ -231,21 +235,23 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
231
235
  analyticalCapability: candidate.analyticalCapability,
232
236
  }));
233
237
  const lines = [
234
- `Question: ${compactText(request.question, 2_000)}`,
235
- `Parsed request hints: ${JSON.stringify(compactQueryIntent(defaultQueryIntent(evidence)))}`,
238
+ `Host-owned requirement seed: ${JSON.stringify(requirementSeed)}`,
236
239
  `Candidate cards: ${JSON.stringify(cards)}`,
237
240
  ];
238
241
  const history = effectiveConversationHistory(request);
239
- if (history.length) {
242
+ const continuation = request.conversationBinding ?? 'none';
243
+ if (history.length && continuation !== 'none') {
240
244
  lines.push(`Recent conversation: ${JSON.stringify(history.slice(-4).map((turn) => ({
241
245
  role: turn.role,
242
246
  text: compactText(turn.text, 1_200),
243
247
  })))}`);
244
248
  }
245
- const envelope = renderConversationEnvelopeForPrompt(request.conversationContext);
249
+ const envelope = continuation === 'none'
250
+ ? undefined
251
+ : renderConversationEnvelopeForPrompt(request.conversationContext);
246
252
  if (envelope)
247
253
  lines.push(`Structured conversation state: ${JSON.stringify(envelope)}`);
248
- lines.push("Resolve the intended meaning and return JSON only.");
254
+ lines.push("Bind only supplied candidate IDs to the host-owned seed and return JSON only.");
249
255
  return lines.join("\n");
250
256
  }
251
257
  function compactText(value, maxLength) {
@@ -274,42 +280,62 @@ function compactQueryIntent(intent) {
274
280
  const QUESTION_TYPES = new Set(["definition", "value", "ranking", "trend", "comparison", "diagnosis", "research"]);
275
281
  const MEANING_CONFIDENCES = new Set(["high", "medium", "low"]);
276
282
  const MEANING_ROUTES = new Set(["certified", "semantic", "governed_sql", "exploratory", "clarify"]);
277
- function parseMeaningResolution(raw) {
283
+ function parseMeaningResolution(raw, requirementSeed) {
278
284
  const parsed = extractJsonObject(raw);
279
285
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
280
286
  return undefined;
281
287
  const record = parsed;
282
- if (typeof record.interpretedQuestion !== "string" || !record.interpretedQuestion.trim())
283
- return undefined;
284
- if (typeof record.questionType !== "string" || !QUESTION_TYPES.has(record.questionType))
288
+ const interpretedQuestion = typeof record.interpretedQuestion === "string" && record.interpretedQuestion.trim()
289
+ ? record.interpretedQuestion.trim()
290
+ : requirementSeed?.sourceQuestion;
291
+ if (!interpretedQuestion)
285
292
  return undefined;
286
- if (!Array.isArray(record.selectedConceptIds) || record.selectedConceptIds.some((id) => typeof id !== "string"))
293
+ const questionType = typeof record.questionType === "string" && QUESTION_TYPES.has(record.questionType)
294
+ ? record.questionType
295
+ : requirementSeed ? questionTypeFromText(requirementSeed.sourceQuestion) : undefined;
296
+ if (!questionType)
287
297
  return undefined;
288
- if (typeof record.confidence !== "string" || !MEANING_CONFIDENCES.has(record.confidence))
298
+ const selectedConceptIds = Array.isArray(record.selectedCandidateIds)
299
+ ? record.selectedCandidateIds
300
+ : record.selectedConceptIds;
301
+ if (!Array.isArray(selectedConceptIds) || selectedConceptIds.some((id) => typeof id !== "string"))
289
302
  return undefined;
290
- if (typeof record.recommendedRoute !== "string" || !MEANING_ROUTES.has(record.recommendedRoute))
303
+ const confidence = typeof record.confidence === "string" && MEANING_CONFIDENCES.has(record.confidence)
304
+ ? record.confidence
305
+ : requirementSeed
306
+ ? selectedConceptIds.length > 0 ? 'medium' : 'low'
307
+ : undefined;
308
+ if (!confidence)
291
309
  return undefined;
292
- if (!record.queryIntent || typeof record.queryIntent !== "object" || Array.isArray(record.queryIntent))
310
+ const recommendedRoute = typeof record.recommendedRoute === "string" && MEANING_ROUTES.has(record.recommendedRoute)
311
+ ? record.recommendedRoute
312
+ : requirementSeed ? "clarify" : undefined;
313
+ if (!recommendedRoute)
293
314
  return undefined;
294
- const query = record.queryIntent;
295
- const measures = stringArray(query.measures);
296
- const dimensions = stringArray(query.dimensions);
297
- const missingInformation = stringArray(record.missingInformation);
298
- if (!measures || !dimensions || !missingInformation || !Array.isArray(query.filters))
315
+ const missingInformation = stringArray(record.missingInformation) ?? (requirementSeed ? [] : undefined);
316
+ if (!missingInformation)
299
317
  return undefined;
300
- const filters = query.filters.flatMap((filter) => {
301
- if (!filter || typeof filter !== "object" || Array.isArray(filter))
302
- return [];
303
- const item = filter;
304
- return typeof item.field === "string" && typeof item.value === "string"
305
- ? [{ field: item.field, value: item.value }]
306
- : [];
307
- });
308
- if (filters.length !== query.filters.length)
318
+ const query = record.queryIntent && typeof record.queryIntent === "object" && !Array.isArray(record.queryIntent)
319
+ ? record.queryIntent
320
+ : undefined;
321
+ const measures = query ? stringArray(query.measures) : undefined;
322
+ const dimensions = query ? stringArray(query.dimensions) : undefined;
323
+ const filters = Array.isArray(query?.filters)
324
+ ? query.filters.flatMap((filter) => {
325
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
326
+ return [];
327
+ const item = filter;
328
+ return typeof item.field === "string" && typeof item.value === "string"
329
+ ? [{ field: item.field, value: item.value }]
330
+ : [];
331
+ })
332
+ : undefined;
333
+ if (!requirementSeed && (!measures || !dimensions || !filters || !Array.isArray(query?.filters) || filters.length !== query.filters.length))
309
334
  return undefined;
310
- if (!Array.isArray(record.rejectedCandidates))
335
+ const rejectedCandidatesRaw = record.rejectedCandidates;
336
+ if (rejectedCandidatesRaw !== undefined && !Array.isArray(rejectedCandidatesRaw))
311
337
  return undefined;
312
- const rejectedCandidates = record.rejectedCandidates.flatMap((candidate) => {
338
+ const rejectedCandidates = (rejectedCandidatesRaw ?? []).flatMap((candidate) => {
313
339
  if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
314
340
  return [];
315
341
  const item = candidate;
@@ -317,42 +343,66 @@ function parseMeaningResolution(raw) {
317
343
  ? [{ id: item.id, reason: item.reason }]
318
344
  : [];
319
345
  });
320
- if (rejectedCandidates.length !== record.rejectedCandidates.length)
346
+ if (rejectedCandidates.length !== (rejectedCandidatesRaw?.length ?? 0))
321
347
  return undefined;
322
348
  const recommendedExecutionId = typeof record.recommendedExecutionId === "string"
323
349
  ? record.recommendedExecutionId
324
350
  : undefined;
325
- const clarifyingQuestion = typeof record.clarifyingQuestion === "string" &&
326
- record.clarifyingQuestion.trim()
351
+ const clarifyingQuestion = typeof record.clarifyingQuestion === "string" && record.clarifyingQuestion.trim()
327
352
  ? record.clarifyingQuestion.trim()
328
353
  : undefined;
329
- const analyticalFrame = record.analyticalFrame === undefined
354
+ const providerExplicitlyClarified = (recommendedRoute === 'clarify'
355
+ && typeof record.recommendedRoute === 'string')
356
+ || Boolean(clarifyingQuestion)
357
+ || missingInformation.length > 0
358
+ || rejectedCandidates.length > 0;
359
+ // Legacy callers may still send a V2 frame. It remains readable without a
360
+ // seed, but a seeded meaning call must never grant a model frame authority.
361
+ const analyticalFrame = requirementSeed || record.analyticalFrame === undefined
330
362
  ? undefined
331
363
  : normalizeAnalyticalQuestionFrameV2(record.analyticalFrame);
332
- if (record.analyticalFrame !== undefined && !analyticalFrame)
364
+ if (!requirementSeed && record.analyticalFrame !== undefined && !analyticalFrame)
333
365
  return undefined;
334
366
  return {
335
- interpretedQuestion: record.interpretedQuestion.trim(),
336
- questionType: record.questionType,
337
- selectedConceptIds: record.selectedConceptIds,
367
+ interpretedQuestion,
368
+ questionType,
369
+ selectedConceptIds: selectedConceptIds,
338
370
  ...(recommendedExecutionId ? { recommendedExecutionId } : {}),
339
- queryIntent: {
340
- measures,
341
- dimensions,
342
- filters,
343
- ...(typeof query.timeRange === "string" ? { timeRange: query.timeRange } : {}),
344
- ...(typeof query.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
345
- ...(query.order === "asc" || query.order === "desc" ? { order: query.order } : {}),
346
- ...(typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
347
- ? { limit: Math.floor(query.limit) }
348
- : {}),
349
- },
371
+ queryIntent: requirementSeed
372
+ ? queryIntentFromRequirementSeed(requirementSeed)
373
+ : {
374
+ measures: measures,
375
+ dimensions: dimensions,
376
+ filters: filters,
377
+ ...(typeof query?.timeRange === "string" ? { timeRange: query.timeRange } : {}),
378
+ ...(typeof query?.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
379
+ ...(query?.order === "asc" || query?.order === "desc" ? { order: query.order } : {}),
380
+ ...(typeof query?.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
381
+ ? { limit: Math.floor(query.limit) }
382
+ : {}),
383
+ },
350
384
  rejectedCandidates,
351
- confidence: record.confidence,
385
+ confidence,
352
386
  missingInformation,
353
- recommendedRoute: record.recommendedRoute,
387
+ recommendedRoute,
354
388
  ...(clarifyingQuestion ? { clarifyingQuestion } : {}),
355
389
  ...(analyticalFrame ? { analyticalFrame } : {}),
390
+ ...(selectedConceptIds.length === 0 && !recommendedExecutionId && !providerExplicitlyClarified
391
+ ? { emptyCandidateBinding: true }
392
+ : {}),
393
+ };
394
+ }
395
+ function queryIntentFromRequirementSeed(seed) {
396
+ return {
397
+ measures: [...seed.queryIntent.measures],
398
+ dimensions: [...seed.queryIntent.dimensions],
399
+ filters: seed.queryIntent.filters.map((filter) => ({ field: filter.field, value: filter.value })),
400
+ ...(seed.queryIntent.timeRange ? { timeRange: seed.queryIntent.timeRange } : {}),
401
+ ...(seed.queryIntent.timeGrain ? { timeGrain: seed.queryIntent.timeGrain } : {}),
402
+ ...(seed.queryIntent.order ? { order: seed.queryIntent.order } : {}),
403
+ ...(seed.queryIntent.limit !== undefined ? { limit: seed.queryIntent.limit } : {}),
404
+ ...(seed.queryIntent.fiscalCalendarId ? { fiscalCalendarId: seed.queryIntent.fiscalCalendarId } : {}),
405
+ ...(seed.queryIntent.fiscalDateRoleId ? { fiscalDateRoleId: seed.queryIntent.fiscalDateRoleId } : {}),
356
406
  };
357
407
  }
358
408
  function stringArray(value) {
@@ -407,201 +457,2019 @@ function conversationPositionToken(context) {
407
457
  return `${latest}:${turns.length}:${recent}:${tailId}`;
408
458
  }
409
459
  function retrievalTrace(evidence, candidates) {
460
+ const candidateTraceMetadata = candidates.flatMap((candidate) => evidenceCandidateRoles(candidate).map((role) => ({
461
+ // `id` is the identity accepted by the meaning resolver. It is already
462
+ // source-qualified by the retrieval contract; keep it so later selection
463
+ // IDs can be joined without guessing a legacy-to-canonical mapping.
464
+ candidateId: candidate.id,
465
+ role,
466
+ source: traceSourceForCandidate(candidate),
467
+ ...(candidate.retrievalLanes?.length ? { lanes: candidate.retrievalLanes } : {}),
468
+ })));
410
469
  return {
411
470
  ...(evidence.snapshotId ? { snapshotId: evidence.snapshotId } : {}),
412
471
  ...(evidence.sourceFingerprint ? { sourceFingerprint: evidence.sourceFingerprint } : {}),
413
472
  candidateCount: candidates.length,
414
473
  candidateIds: candidates.map((candidate) => candidate.id),
474
+ ...(candidateTraceMetadata.length ? { candidateTraceMetadata } : {}),
415
475
  };
416
476
  }
417
- function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
418
- let planBoundResolution = resolution;
419
- if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
420
- const { analyticalFrame: sourceFrame, ...resolutionWithoutFrame } = resolution;
421
- const bindingPlan = buildResolvedAnalyticalPlan({
422
- question,
423
- resolution: resolutionWithoutFrame,
424
- evidence,
425
- candidates,
426
- mode,
427
- });
428
- planBoundResolution = {
429
- ...resolution,
430
- analyticalFrame: projectResolvedAnalyticalFrame({ plan: bindingPlan, sourceFrame }),
431
- };
432
- }
433
- const routedResolution = enforceAnalyticalCompatibility(planBoundResolution, evidence, candidates);
434
- const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
435
- question,
436
- resolution: routedResolution,
437
- evidence,
438
- candidates,
439
- mode,
477
+ /**
478
+ * Observability is a projection of snapshot-bound evidence, never a second
479
+ * retriever or a route authority. Keep source classification conservative so
480
+ * an absent lane is not manufactured from an arbitrary ID prefix.
481
+ */
482
+ function traceSourceForCandidate(candidate) {
483
+ // Qualified candidate identities preserve their originating index. Prefer
484
+ // that declared provenance for collapsed `sql_column`/`sql_table` kinds: a
485
+ // dbt manifest column must not be relabelled as runtime-schema evidence.
486
+ const identity = candidate.qualifiedId ?? candidate.id;
487
+ if (identity.startsWith('dbt:'))
488
+ return 'dbt_manifest';
489
+ if (identity.startsWith('runtime:'))
490
+ return 'runtime_schema';
491
+ if (identity.startsWith('semantic:'))
492
+ return 'semantic';
493
+ if (identity.startsWith('dql:'))
494
+ return candidate.kind === 'certified_block' ? 'certified' : 'governed_relational';
495
+ if (candidate.kind === 'certified_block')
496
+ return 'certified';
497
+ if (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
498
+ return 'semantic';
499
+ if (candidate.kind === 'dql_modeling')
500
+ return 'governed_relational';
501
+ if (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source')
502
+ return 'dbt_manifest';
503
+ if (candidate.kind === 'sql_table' || candidate.kind === 'sql_column')
504
+ return 'runtime_schema';
505
+ return 'exploratory';
506
+ }
507
+ function traceCandidateLifecycleBeforePruning(request, evidence, candidates, packageCandidates) {
508
+ const observer = askTraceObserverForV1(request);
509
+ if (!observer.enabled)
510
+ return;
511
+ const packageIds = new Set(packageCandidates.map((candidate) => candidate.id));
512
+ const fuse = observer.startSpan({
513
+ name: 'retrieval.fuse',
514
+ stage: 'retrieval',
515
+ payload: { kind: 'retrieval', candidateCount: candidates.length },
440
516
  });
441
- const reconciliation = reconcileResolvedPlanOutcome(routedResolution, resolvedAnalyticalPlan, candidates);
442
- const needsClarification = reconciliation.outcome === 'clarify';
443
- const terminallyBlocked = reconciliation.outcome === 'modeling_gap'
444
- || reconciliation.outcome === 'policy_blocked';
445
- const analytical = routedResolution.questionType === "diagnosis" ||
446
- routedResolution.questionType === "research";
447
- const reason = reconciliation.reason;
448
- return {
449
- ...base,
450
- action: needsClarification
451
- ? "clarify"
452
- : terminallyBlocked
453
- ? "block"
454
- : analytical
455
- ? "investigate"
456
- : "answer",
457
- confidence: routedResolution.confidence === "high"
458
- ? 0.9
459
- : routedResolution.confidence === "medium"
460
- ? 0.72
461
- : 0.45,
462
- reason,
463
- source,
464
- category: analytical ? "data_analysis" : needsClarification ? "unclear" : "data_lookup",
465
- depth: analytical ? "deep" : "quick",
466
- meaningResolution: routedResolution,
467
- resolvedAnalyticalPlan,
468
- retrievalEvidence: retrievalTrace(evidence, candidates),
469
- requiresClarification: needsClarification,
470
- ...(terminallyBlocked
471
- ? {
472
- terminalOutcome: {
473
- kind: reconciliation.outcome === 'policy_blocked'
474
- ? 'policy_blocked'
475
- : 'modeling_gap',
476
- code: reconciliation.outcome === 'policy_blocked'
477
- ? 'ANALYTICAL_POLICY_BLOCKED'
478
- : 'ANALYTICAL_MODELING_GAP',
479
- message: reconciliation.reason,
480
- candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
481
- },
517
+ const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
518
+ const requestedRoles = new Set([
519
+ ...(requirements.measures.length || requirements.ranking?.metricTerms.length ? ['metric'] : []),
520
+ ...(requirements.entityTerms.length || requirements.entityDisplayTerms.length ? ['entity_label'] : []),
521
+ ...(requirements.time ? ['time_dimension'] : []),
522
+ ...(requirements.dimensions.length ? ['categorical_dimension'] : []),
523
+ ...(requirements.dimensions.length > 1 || requirements.entityTerms.length ? ['relationship'] : []),
524
+ ]);
525
+ const candidateLimit = 32;
526
+ const hasRequestedEntityLabel = requirements.entityTerms.length > 0 || requirements.entityDisplayTerms.length > 0;
527
+ for (const [index, candidate] of candidates.slice(0, candidateLimit).entries()) {
528
+ const roles = evidenceCandidateRoles(candidate);
529
+ const source = traceSourceForCandidate(candidate);
530
+ // This receipt must retain the actual retrieval memberships captured by
531
+ // the snapshot. Do not infer a lane from aliases, IDs, or a relationship
532
+ // attribute after fusion has already lost that physical provenance.
533
+ const lanes = candidate.retrievalLanes
534
+ ?.map((entry) => ({ ...entry }))
535
+ .sort((left, right) => (left.rank ?? Number.MAX_SAFE_INTEGER) - (right.rank ?? Number.MAX_SAFE_INTEGER)
536
+ || left.lane.localeCompare(right.lane));
537
+ const lane = lanes?.[0]?.lane;
538
+ const laneRank = lanes?.[0]?.rank;
539
+ const initialReason = candidate.exactMatch
540
+ ? 'exact_name_match'
541
+ : 'unknown';
542
+ const compatibilityCode = candidate.compatibility === 'compatible' ? 'compatible'
543
+ : candidate.compatibility === 'incompatible' ? 'operation_unsupported'
544
+ : 'unknown';
545
+ for (const role of roles) {
546
+ const common = {
547
+ // The resolver and cascade use `id`; keeping that exact stable identity
548
+ // makes retrieval, admission, and model-selection receipts joinable.
549
+ candidateId: candidate.id,
550
+ role,
551
+ source,
552
+ ...(lane ? { lane } : {}),
553
+ ...(laneRank !== undefined ? { laneRank } : {}),
554
+ ...(lanes?.length ? { lanes } : {}),
555
+ fusedRank: index + 1,
556
+ compatibilityCode,
557
+ };
558
+ observer.recordCandidateDecision({
559
+ ...common,
560
+ decision: 'retrieved',
561
+ reasonCode: candidate.eligible === false ? 'role_mismatch' : initialReason,
562
+ });
563
+ // An explicit ranking measure is a typed request, not a relevance hint.
564
+ // Keep correlated metrics in the receipt, but record their exclusion at
565
+ // the metric admission boundary instead of allowing them to become
566
+ // clarification options or to fill an unrelated analytical role.
567
+ if (role === 'metric' && candidateConflictsWithExplicitRankingMeasure(candidate, requirements)) {
568
+ observer.recordCandidateDecision({
569
+ ...common,
570
+ decision: 'excluded',
571
+ reasonCode: 'explicit_measure_conflict',
572
+ });
573
+ continue;
482
574
  }
483
- : {}),
484
- ...(needsClarification
485
- ? { clarificationOptions: reconciliation.options }
486
- : {}),
487
- ...(needsClarification
488
- ? {
489
- clarifyingQuestion: reconciliation.question,
575
+ if (candidate.eligible === false || candidate.compatibility === 'incompatible') {
576
+ observer.recordCandidateDecision({
577
+ ...common,
578
+ decision: 'excluded',
579
+ reasonCode: candidate.compatibility === 'incompatible' ? 'capability_incompatible' : 'role_mismatch',
580
+ });
581
+ continue;
490
582
  }
491
- : {}),
492
- };
583
+ if (packageIds.has(candidate.id)) {
584
+ const reservedForRole = requestedRoles.has(role);
585
+ observer.recordCandidateDecision({
586
+ ...common,
587
+ decision: 'reserved',
588
+ reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
589
+ });
590
+ observer.recordCandidateDecision({
591
+ ...common,
592
+ decision: 'admitted',
593
+ reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
594
+ });
595
+ }
596
+ else {
597
+ const isNoisyEntityAttribute = hasRequestedEntityLabel
598
+ && isEntityAttributeCandidate(candidate)
599
+ && role === 'categorical_dimension';
600
+ observer.recordCandidateDecision({
601
+ ...common,
602
+ decision: 'excluded',
603
+ reasonCode: isNoisyEntityAttribute ? 'entity_label_mismatch' : 'below_fused_limit',
604
+ });
605
+ }
606
+ }
607
+ }
608
+ observer.finishSpan(fuse, { outcome: 'ok', reasonCode: 'completed' });
493
609
  }
494
610
  /**
495
- * The immutable RAP is the final routing authority. Meaning may nominate an
496
- * execution route, but cannot leave the router claiming an answer after the
497
- * host has retained an ambiguous or blocked qualified binding.
611
+ * Emit source-bound lane summaries before the meaning-package cap applies.
612
+ * These spans are evidence about the existing retrieval result only; they do
613
+ * not invoke, retry, or reinterpret any retrieval source.
498
614
  */
499
- function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
500
- if (plan.capability !== 'blocked') {
501
- return {
502
- outcome: 'ready',
503
- reason: `Resolved the question against ${plan.selectedConceptIds.join(', ')}: ${resolution.interpretedQuestion}`,
504
- };
505
- }
506
- const bindings = [
507
- ...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
508
- ...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
509
- ...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
510
- ].filter(({ binding }) => binding.status !== 'resolved');
511
- const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
512
- const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
513
- if (plan.resolutionFailure?.outcome === 'policy_blocked') {
514
- return {
515
- outcome: 'policy_blocked',
516
- reason: `Policy blocked the selected analytical plan: ${plan.missingInformation.join(' ') || 'review the retained policy diagnostic.'}`,
517
- };
615
+ function traceRetrievalLanesBeforePruning(request, evidence, candidates) {
616
+ const observer = askTraceObserverForV1(request);
617
+ if (!observer.enabled)
618
+ return;
619
+ const sourceSpan = {
620
+ certified: 'retrieval.certified',
621
+ semantic: 'retrieval.semantic',
622
+ governed_relational: 'retrieval.governed_relational',
623
+ dbt_manifest: 'retrieval.dbt_manifest',
624
+ runtime_schema: 'retrieval.runtime_schema',
625
+ vector: 'retrieval.vector',
626
+ conversation: 'retrieval.conversation',
627
+ };
628
+ const record = (name, source, count, coverage, lane) => {
629
+ const span = observer.startSpan({
630
+ name,
631
+ stage: 'retrieval',
632
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
633
+ payload: { kind: 'retrieval', ...(source ? { source } : {}), ...(lane ? { lane } : {}), candidateCount: count, ...(coverage ? { coverage } : {}) },
634
+ });
635
+ observer.finishSpan(span, {
636
+ outcome: coverage === 'unavailable' ? 'unavailable' : 'ok',
637
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
638
+ });
639
+ };
640
+ for (const coverage of sourceCoverageFromEvidence(evidence, candidates)) {
641
+ const name = sourceSpan[coverage.source];
642
+ if (!name)
643
+ continue;
644
+ record(name, coverage.source, coverage.candidateIds.length, coverage.status, coverage.source === 'vector' ? 'vector' : coverage.source === 'conversation' ? 'conversation' : undefined);
518
645
  }
519
- if (plan.resolutionFailure?.outcome === 'modeling_gap') {
520
- return {
521
- outcome: 'modeling_gap',
522
- reason: `The selected analytical plan has a governed modeling gap: ${plan.missingInformation.join(' ') || 'review the retained capability diagnostic.'}`,
523
- };
646
+ const laneSpan = {
647
+ exact: 'retrieval.exact',
648
+ lexical: 'retrieval.lexical',
649
+ vector: 'retrieval.vector',
650
+ graph: 'retrieval.graph',
651
+ conversation: 'retrieval.conversation',
652
+ };
653
+ for (const lane of Object.keys(laneSpan)) {
654
+ const count = candidates.filter((candidate) => candidate.retrievalLanes?.some((membership) => membership.lane === lane)).length;
655
+ if (count > 0)
656
+ record(laneSpan[lane], undefined, count, 'available', lane);
524
657
  }
525
- if (userResolvableBinding || plan.resolutionFailure?.outcome === 'clarify') {
526
- const optionIds = qualifiedChoiceIds.length > 0
527
- ? qualifiedChoiceIds
528
- : [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
529
- const options = optionIds.length > 0
530
- ? clarificationOptionsForQualifiedIds(optionIds, candidates)
531
- : buildClarificationOptions(candidates);
532
- const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
533
- const question = routedClarificationQuestion(resolution, bindings, options);
534
- return {
535
- outcome: 'clarify',
536
- reason: `The immutable analytical plan needs one identifier-bound choice: ${bindingSummary || plan.missingInformation.join(' ')}`,
537
- question,
538
- options,
539
- };
658
+ }
659
+ /** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
660
+ function sourceCoverageFromEvidence(evidence, candidates) {
661
+ const supplied = new Map((evidence.diagnostics?.sourceCoverage ?? []).map((coverage) => [coverage.source, coverage]));
662
+ const sourceKinds = [
663
+ ['certified', (candidate) => candidate.kind === 'certified_block'],
664
+ ['semantic', (candidate) => candidate.trustTier === 'semantic' || candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member'],
665
+ ['governed_relational', (candidate) => candidate.kind === 'dql_modeling' || (candidate.relationshipEvidence?.length ?? 0) > 0],
666
+ ['exploratory', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
667
+ ['dbt_manifest', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source'],
668
+ ['runtime_schema', (candidate) => candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
669
+ ];
670
+ const coverage = [];
671
+ for (const [source, matches] of sourceKinds) {
672
+ const explicit = supplied.get(source);
673
+ if (explicit) {
674
+ coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
675
+ continue;
676
+ }
677
+ const ids = candidates.filter(matches).map((candidate) => candidate.qualifiedId ?? candidate.id).slice(0, 32);
678
+ const searched = evidence.diagnostics?.searchedKinds ?? [];
679
+ const relevantSearched = source === 'certified'
680
+ ? searched.includes('certified_block')
681
+ : source === 'semantic'
682
+ ? searched.includes('semantic_metric') || searched.includes('semantic_member')
683
+ : source === 'dbt_manifest'
684
+ ? searched.includes('dbt_model') || searched.includes('dbt_source')
685
+ : source === 'runtime_schema'
686
+ ? searched.includes('sql_table') || searched.includes('sql_column')
687
+ : source === 'exploratory'
688
+ ? searched.some((kind) => kind === 'dbt_model' || kind === 'dbt_source' || kind === 'sql_table' || kind === 'sql_column')
689
+ : searched.includes('dql_modeling');
690
+ coverage.push({ version: 1, source, status: ids.length > 0 ? 'available' : relevantSearched ? 'empty' : 'unavailable', candidateIds: ids });
540
691
  }
541
- if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
542
- return {
543
- outcome: 'clarify',
544
- reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
545
- question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
546
- options: buildClarificationOptions(candidates),
547
- };
692
+ // Vector/conversation status is only included when its retrieval lane told us
693
+ // its real status. It is not a synthetic “skipped” placeholder.
694
+ for (const source of ['vector', 'conversation']) {
695
+ const explicit = supplied.get(source);
696
+ if (explicit)
697
+ coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
548
698
  }
699
+ return coverage;
700
+ }
701
+ function declaredFiscalCalendar(evidence, candidates) {
702
+ const calendar = evidence.fiscalCalendar;
703
+ // A fiscal period value is meaningless without the date role it applies to.
704
+ // Older snapshots may not contain this field, but they must clarify instead
705
+ // of silently applying FY26 to an arbitrary date column.
706
+ if (!calendar?.dateRoleId)
707
+ return undefined;
708
+ const identifiers = new Set(candidates.flatMap((candidate) => [candidate.id, candidate.qualifiedId].filter((id) => Boolean(id))));
709
+ return identifiers.has(calendar.id)
710
+ && identifiers.has(calendar.fiscalPeriodFieldId)
711
+ && identifiers.has(calendar.dateRoleId)
712
+ ? calendar
713
+ : undefined;
714
+ }
715
+ function withDeclaredFiscalBinding(resolution, evidence, candidates, question) {
716
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
717
+ const fiscalPeriod = requirements.time?.fiscalPeriod;
718
+ const calendar = fiscalPeriod ? declaredFiscalCalendar(evidence, candidates) : undefined;
719
+ if (!fiscalPeriod || !calendar)
720
+ return resolution;
721
+ const filters = resolution.queryIntent.filters.filter((filter) => filter.field !== calendar.fiscalPeriodFieldId);
549
722
  return {
550
- outcome: 'modeling_gap',
551
- reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
723
+ ...resolution,
724
+ queryIntent: {
725
+ ...resolution.queryIntent,
726
+ filters: [...filters, { field: calendar.fiscalPeriodFieldId, value: fiscalPeriod }],
727
+ ...(requirements.time?.grain && !resolution.queryIntent.timeGrain ? { timeGrain: requirements.time.grain } : {}),
728
+ fiscalCalendarId: calendar.id,
729
+ fiscalDateRoleId: calendar.dateRoleId,
730
+ },
552
731
  };
553
732
  }
554
- function clarificationOptionsForQualifiedIds(ids, candidates) {
555
- return ids.slice(0, 3).map((id) => {
556
- const candidate = candidates.find((item) => item.id === id || item.qualifiedId === id);
557
- return {
558
- id,
559
- label: candidate?.name ?? qualifiedIdLabel(id),
560
- ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
561
- kind: candidate?.kind ?? 'semantic_member',
562
- };
563
- });
733
+ function fiscalCalendarClarification(request, base, evidence, candidates) {
734
+ const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
735
+ if (!requirements.time?.requiresDeclaredFiscalCalendar || declaredFiscalCalendar(evidence, candidates))
736
+ return undefined;
737
+ const fiscalPeriod = requirements.time.fiscalPeriod ?? 'the requested fiscal period';
738
+ const coverage = sourceCoverageFromEvidence(evidence, candidates);
739
+ return {
740
+ ...base,
741
+ action: 'clarify',
742
+ confidence: 1,
743
+ source: 'heuristic',
744
+ category: 'unclear',
745
+ depth: 'quick',
746
+ reason: `${fiscalPeriod} requires one declared fiscal calendar and date-role mapping before a plan can freeze.`,
747
+ clarifyingQuestion: `Which declared fiscal calendar and date role should DQL use for ${fiscalPeriod}?`,
748
+ requiresClarification: true,
749
+ retrievalEvidence: retrievalTrace(evidence, candidates),
750
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
751
+ requirements,
752
+ sourceCoverage: coverage,
753
+ attempts: [{ version: 1, tier: 'clarify_or_gap', outcome: 'ambiguous', candidateIds: [], reason: 'No declared fiscal calendar/date-role binding was present in the snapshot.', planFrozen: false }],
754
+ planFrozen: false,
755
+ stopReason: 'ambiguous',
756
+ }),
757
+ };
564
758
  }
565
- function qualifiedIdLabel(id) {
566
- const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
567
- return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
759
+ function normalizedRelationshipIdentity(value) {
760
+ return value.trim().toLowerCase();
568
761
  }
569
- function routedClarificationQuestion(resolution, bindings, options) {
570
- if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
571
- return resolution.clarifyingQuestion;
762
+ /**
763
+ * The retrieval parser is intentionally generous because its terms also seed
764
+ * search. Before the router turns those terms into a resolved plan, replace
765
+ * grammatical aggregation wrappers with the shared typed requirement set.
766
+ * This keeps all router paths (fast certified, no-meaning, structured
767
+ * continuation, and one-call meaning) on the same count/customer contract.
768
+ */
769
+ function withNormalizedAnalyticalRequirements(evidence, question) {
770
+ if (!evidence.parsedIntent)
771
+ return evidence;
772
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
773
+ return {
774
+ ...evidence,
775
+ parsedIntent: {
776
+ ...evidence.parsedIntent,
777
+ measures: requirements.measures,
778
+ dimensions: requirements.dimensions,
779
+ ...(requirements.time?.grain && !evidence.parsedIntent.timeGrain
780
+ ? { timeGrain: requirements.time.grain }
781
+ : {}),
782
+ },
783
+ };
784
+ }
785
+ function relationshipSafetyIdentities(safety) {
786
+ return [...new Set([safety.id, ...(safety.aliases ?? [])]
787
+ .map(normalizedRelationshipIdentity)
788
+ .filter(Boolean))];
789
+ }
790
+ /**
791
+ * This mirrors the local manifest relationship admission gate with the compact
792
+ * facts preserved in retrieval evidence. IDs and names deliberately play no
793
+ * part in the decision: a neutral draft/many-to-many identity is still unsafe.
794
+ */
795
+ function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
796
+ const validation = safety.validation;
797
+ const checkedAt = Date.parse(validation?.checkedAt ?? '');
798
+ const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
799
+ const expirationInvalid = safety.evidenceExpiresAt !== undefined
800
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
801
+ if (safety.status !== 'certified'
802
+ || safety.staleCertification !== false
803
+ || safety.automaticJoinAllowed !== true
804
+ || safety.fanout !== 'safe'
805
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
806
+ || !safety.from?.trim()
807
+ || !safety.to?.trim()
808
+ || !safety.certificationFingerprint?.trim()
809
+ || !validation
810
+ || validation.status !== 'passed'
811
+ || !validation.queryFingerprint?.trim()
812
+ || !validation.proofFingerprint?.trim()
813
+ || !Number.isFinite(checkedAt)
814
+ || expirationInvalid
815
+ || safety.keys.length === 0
816
+ || safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
817
+ return false;
818
+ if (!requiredJoinKey)
819
+ return true;
820
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
821
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
822
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
823
+ }
824
+ /**
825
+ * Relationship evidence has three deliberately different authorities:
826
+ *
827
+ * - governed: a certified, fresh proof may compile a governed relational plan;
828
+ * - exploratory: an explicitly allowed, validated relation may close a raw
829
+ * review-required SQL plan after normal runtime validation; and
830
+ * - hint: anything weaker may improve retrieval, but can never add a join.
831
+ *
832
+ * The first authority remains intentionally stricter. Reusing it for raw
833
+ * fallback is what made a missing semantic capability look like a terminal
834
+ * absence even when the dbt/runtime snapshot had the exact relations, keys,
835
+ * and fields required to inspect it safely. This helper never upgrades a
836
+ * draft relationship to governed trust: callers that use it must select the
837
+ * exploratory tier and retain review_required provenance.
838
+ */
839
+ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
840
+ const validation = safety.validation;
841
+ const checkedAt = Date.parse(validation?.checkedAt ?? '');
842
+ const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
843
+ const expirationInvalid = safety.evidenceExpiresAt !== undefined
844
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
845
+ const lifecycleAllowsExploration = safety.status === 'certified'
846
+ || safety.status === 'validated'
847
+ || safety.status === 'draft';
848
+ if (!lifecycleAllowsExploration
849
+ || safety.staleCertification === true
850
+ || safety.automaticJoinAllowed !== true
851
+ || safety.fanout !== 'safe'
852
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
853
+ || !safety.from?.trim()
854
+ || !safety.to?.trim()
855
+ || !validation
856
+ || validation.status !== 'passed'
857
+ || !validation.queryFingerprint?.trim()
858
+ || !validation.proofFingerprint?.trim()
859
+ || !Number.isFinite(checkedAt)
860
+ || expirationInvalid
861
+ || safety.keys.length === 0
862
+ || safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
863
+ return false;
864
+ if (!requiredJoinKey)
865
+ return true;
866
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
867
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
868
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
869
+ }
870
+ function relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey) {
871
+ return authority === 'governed'
872
+ ? relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey)
873
+ : relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey);
874
+ }
875
+ function safeRelationshipProofsForCandidate(candidate, requiredJoinKey, authority = 'governed') {
876
+ const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
877
+ const proofs = new Map();
878
+ for (const safety of candidate.relationshipSafety ?? []) {
879
+ if (!relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey))
880
+ continue;
881
+ if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
882
+ continue;
883
+ proofs.set(safety.id, safety);
572
884
  }
573
- const first = bindings[0];
574
- const labels = options.map((option) => option.label);
575
- if (first && labels.length > 1) {
576
- return `Which governed ${first.kind} should I use for “${first.binding.requested}”: ${labels.join(' or ')}?`;
885
+ return proofs;
886
+ }
887
+ function candidateRelationshipEndpoints(candidate) {
888
+ return new Set([
889
+ ...(candidate.relationshipEndpointIds ?? []),
890
+ candidate.primaryEntity ?? '',
891
+ candidate.analyticalCapability?.primaryEntityId ?? '',
892
+ ].map(normalizedRelationshipIdentity).filter(Boolean));
893
+ }
894
+ /**
895
+ * An attribution-required relationship is intentionally not a generic join
896
+ * failure. It becomes terminal only when the user explicitly asks to rank an
897
+ * un-attributed/attribution-scoped signal and the same immutable snapshot
898
+ * proves that getting from that signal to the requested entity would require
899
+ * the declared attribution edge. This keeps neutral unsafe relationship
900
+ * evidence available for the normal clarification path (AGT-029), while
901
+ * preventing a bare-ranking metric picker from masking a known governance
902
+ * boundary with unrelated options.
903
+ */
904
+ const EXPLICIT_ATTRIBUTION_REQUEST_RE = /\b(?:un[-\s]?attributed|attribution|allocation|allocate)\b/i;
905
+ function relationshipEndpointMatchesQuestionConcept(endpoint, question) {
906
+ const leaf = normalizedRelationshipIdentity(endpoint).split('::').at(-1) ?? endpoint;
907
+ const concept = normalizeMetricPhrase(leaf);
908
+ const conceptTokens = substantiveLexicalTokens(concept);
909
+ const questionTokens = new Set(substantiveLexicalTokens(question));
910
+ return conceptTokens.length >= 2 && conceptTokens.every((token) => questionTokens.has(token));
911
+ }
912
+ function relationshipEndpointMatchesEntityTerms(endpoint, entityTerms) {
913
+ const normalizedEndpoint = normalizeMetricPhrase(endpoint);
914
+ return entityTerms.some((term) => metricTermsMatch(normalizedEndpoint, normalizeMetricPhrase(term)));
915
+ }
916
+ function relationshipGraphReaches(starts, targets, relationships) {
917
+ if (starts.size === 0 || targets.size === 0)
918
+ return false;
919
+ const graph = new Map();
920
+ for (const relationship of relationships) {
921
+ const from = normalizedRelationshipIdentity(relationship.from ?? '');
922
+ const to = normalizedRelationshipIdentity(relationship.to ?? '');
923
+ if (!from || !to)
924
+ continue;
925
+ const fromNeighbors = graph.get(from) ?? new Set();
926
+ fromNeighbors.add(to);
927
+ graph.set(from, fromNeighbors);
928
+ const toNeighbors = graph.get(to) ?? new Set();
929
+ toNeighbors.add(from);
930
+ graph.set(to, toNeighbors);
577
931
  }
578
- return resolution.clarifyingQuestion
579
- ?? `Which governed binding should I use before running this query?`;
932
+ const pending = [...starts];
933
+ const visited = new Set(pending);
934
+ while (pending.length > 0) {
935
+ const current = pending.shift();
936
+ if (targets.has(current))
937
+ return true;
938
+ for (const next of graph.get(current) ?? []) {
939
+ if (!visited.has(next)) {
940
+ visited.add(next);
941
+ pending.push(next);
942
+ }
943
+ }
944
+ }
945
+ return false;
580
946
  }
581
- function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
582
- const selectedId = selected.qualifiedId ?? selected.id;
583
- const message = `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL did not substitute a correlated metric or execute a different artifact.`;
947
+ function attributionRequiredRelationshipGapDecision(input) {
948
+ if (!EXPLICIT_ATTRIBUTION_REQUEST_RE.test(input.request.question)
949
+ || input.requirements.entityTerms.length === 0)
950
+ return undefined;
951
+ // Use the complete same-snapshot retrieval result. The compact provider
952
+ // package is intentionally allowed to omit a relationship card; omission
953
+ // cannot erase an authored governance restriction before it is evaluated.
954
+ const snapshotCandidates = [
955
+ ...input.evidence.candidates,
956
+ ...(input.evidence.clarificationCandidates ?? []),
957
+ ].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
958
+ const relationships = [...new Map(snapshotCandidates
959
+ .flatMap((candidate) => candidate.relationshipSafety ?? [])
960
+ .map((relationship) => [relationship.id, relationship])).values()]
961
+ .filter((relationship) => Boolean(relationship.from?.trim() && relationship.to?.trim()));
962
+ const attributionRequired = relationships.filter((relationship) => relationship.fanout === 'attribution_required');
963
+ if (attributionRequired.length === 0)
964
+ return undefined;
965
+ const targetEndpoints = new Set(snapshotCandidates
966
+ .flatMap((candidate) => [...candidateRelationshipEndpoints(candidate)])
967
+ .filter((endpoint) => relationshipEndpointMatchesEntityTerms(endpoint, input.requirements.entityTerms)));
968
+ if (targetEndpoints.size === 0)
969
+ return undefined;
970
+ const attributed = attributionRequired.find((relationship) => {
971
+ const endpoints = [
972
+ normalizedRelationshipIdentity(relationship.from ?? ''),
973
+ normalizedRelationshipIdentity(relationship.to ?? ''),
974
+ ].filter(Boolean);
975
+ const requestedSignalEndpoints = new Set(endpoints.filter((endpoint) => relationshipEndpointMatchesQuestionConcept(endpoint, input.request.question)));
976
+ if (requestedSignalEndpoints.size === 0)
977
+ return false;
978
+ // The declared graph can demonstrate why the requested entity would need
979
+ // this relation, but only a fully certified/fanout-safe graph may
980
+ // authorize automatic composition. A path that requires this edge is a
981
+ // typed modeling gap, never a reason to invent an attribution join.
982
+ const allReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, relationships);
983
+ const safeRelationships = relationships.filter((candidate) => relationshipSafetyAllowsAutomaticJoin(candidate));
984
+ const safelyReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, safeRelationships);
985
+ return allReachable && !safelyReachable;
986
+ });
987
+ if (!attributed)
988
+ return undefined;
989
+ const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
990
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
991
+ const skipped = (tier) => {
992
+ const item = coverageFor(tier);
993
+ return {
994
+ version: 1,
995
+ tier,
996
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
997
+ candidateIds: item?.candidateIds ?? [],
998
+ reason: item?.status === 'available'
999
+ ? `The ${tier} tier did not prove the complete attribution-safe requested tuple before plan freeze.`
1000
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1001
+ planFrozen: false,
1002
+ };
1003
+ };
1004
+ 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.';
1005
+ const witnessCandidateIds = [attributed.id];
1006
+ const terminalGap = {
1007
+ code: 'MISSING_RELATIONSHIP',
1008
+ missing: ['a certified attribution relationship or approved allocation policy for the requested un-attributed signal'],
1009
+ witnessCandidateIds,
1010
+ };
1011
+ const observer = askTraceObserverForV1(input.request);
1012
+ for (const candidate of snapshotCandidates.filter((candidate) => (candidate.relationshipSafety ?? []).some((relationship) => relationship.id === attributed.id))) {
1013
+ observer.recordCandidateDecision({
1014
+ candidateId: candidate.id,
1015
+ role: 'relationship',
1016
+ source: traceSourceForCandidate(candidate),
1017
+ decision: 'excluded',
1018
+ reasonCode: 'policy_denied',
1019
+ compatibilityCode: 'unknown',
1020
+ });
1021
+ }
584
1022
  return {
585
- ...base,
1023
+ ...input.base,
586
1024
  action: 'block',
587
1025
  confidence: 1,
588
1026
  reason: message,
589
1027
  source: 'heuristic',
590
1028
  category: 'data_lookup',
591
1029
  depth: 'quick',
592
- retrievalEvidence: retrievalTrace(evidence, candidates),
593
1030
  requiresClarification: false,
1031
+ clarifyingQuestion: undefined,
1032
+ clarificationOptions: undefined,
1033
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
594
1034
  terminalOutcome: {
595
1035
  kind: 'modeling_gap',
596
1036
  code: 'ANALYTICAL_MODELING_GAP',
597
1037
  message,
598
- candidateIds: [selectedId],
1038
+ candidateIds: witnessCandidateIds,
1039
+ gap: terminalGap,
599
1040
  },
1041
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
1042
+ requirements: input.requirements,
1043
+ sourceCoverage: coverage,
1044
+ attempts: [
1045
+ skipped('certified'),
1046
+ skipped('semantic'),
1047
+ {
1048
+ version: 1,
1049
+ tier: 'governed_relational',
1050
+ outcome: 'denied',
1051
+ candidateIds: witnessCandidateIds,
1052
+ reason: 'The declared relationship requires attribution or allocation approval and cannot authorize the requested ranking.',
1053
+ planFrozen: false,
1054
+ },
1055
+ ],
1056
+ planFrozen: false,
1057
+ terminalGap: cascadeTerminalRelationshipGap(terminalGap),
1058
+ stopReason: 'denied',
1059
+ }),
600
1060
  resolvedAnalyticalPlan: undefined,
601
1061
  meaningResolution: undefined,
602
1062
  };
603
1063
  }
604
- function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
1064
+ /**
1065
+ * A proof may be structurally safe yet belong to a different domain's relation
1066
+ * with the same leaf name. The selected pair must match the proof's canonical
1067
+ * endpoints in either direction before it can close an exploratory join.
1068
+ */
1069
+ function relationshipProofMatchesSelectedPair(safety, left, right) {
1070
+ const leftEndpoints = candidateRelationshipEndpoints(left);
1071
+ const rightEndpoints = candidateRelationshipEndpoints(right);
1072
+ const from = normalizedRelationshipIdentity(safety.from ?? '');
1073
+ const to = normalizedRelationshipIdentity(safety.to ?? '');
1074
+ return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
1075
+ || (leftEndpoints.has(to) && rightEndpoints.has(from))));
1076
+ }
1077
+ function safeRelationshipProofsForPair(left, right, requiredJoinKey, authority = 'governed') {
1078
+ const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey, authority);
1079
+ const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey, authority);
1080
+ const shared = new Map();
1081
+ for (const [id, proof] of leftProofs) {
1082
+ const pairedProof = rightProofs.get(id);
1083
+ if (!pairedProof
1084
+ || !relationshipProofMatchesSelectedPair(proof, left, right)
1085
+ || !relationshipProofMatchesSelectedPair(pairedProof, left, right))
1086
+ continue;
1087
+ shared.set(id, proof);
1088
+ }
1089
+ return shared;
1090
+ }
1091
+ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensions, requiredPhysicalFieldTerms = []) {
1092
+ const physical = candidates.filter((candidate) => candidate.eligible !== false
1093
+ && candidate.compatibility !== 'incompatible'
1094
+ && (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
1095
+ const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
1096
+ const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
1097
+ if (relations.length === 0 || columns.length === 0) {
1098
+ return { ok: false, candidateIds: [], reason: 'No qualified raw relation plus column coverage was retrieved from this snapshot.' };
1099
+ }
1100
+ // This list is intentionally field/role-only. Filter values such as
1101
+ // `Datadog`, `FY26`, or `true` are member constraints, not column names;
1102
+ // their safe-value validation happens independently from proving the raw
1103
+ // physical closure. Treating them as fields made a valid relation appear
1104
+ // unmodeled and produced a false absence diagnostic.
1105
+ const terms = [...new Set([
1106
+ ...missingDimensions,
1107
+ ...requirements.measures,
1108
+ ...requirements.entityDisplayTerms,
1109
+ ...(requirements.outputTerms ?? []),
1110
+ ...requiredPhysicalFieldTerms,
1111
+ ]
1112
+ .map(normalizeMetricPhrase)
1113
+ .filter(Boolean))];
1114
+ if (terms.length === 0) {
1115
+ return { ok: false, candidateIds: [], reason: 'No typed physical fields were available to prove an exploratory plan.' };
1116
+ }
1117
+ const relationSources = (relation) => relation.sourceObjects?.length ? relation.sourceObjects : [relation.qualifiedId ?? relation.id];
1118
+ const columnTouchesRelation = (column, relation) => column.sourceObjects?.length
1119
+ ? column.sourceObjects.some((source) => relationSources(relation).includes(source))
1120
+ // A source that omitted its column edge cannot substantiate a multi-table
1121
+ // plan. It remains usable only when a single relation is selected.
1122
+ : relations.length === 1;
1123
+ const columnFieldIdentityTerms = (column) => uniqueNormalizedTerms([
1124
+ column.name,
1125
+ ...(column.aliases ?? []),
1126
+ ...(column.dimensions ?? []),
1127
+ ]);
1128
+ // These are intentionally a tiny, role-only bridge for raw physical
1129
+ // evidence. They are evaluated against a column's own name/aliases in this
1130
+ // immutable snapshot; they neither infer a relation nor turn a member value
1131
+ // into a column. The aliases cover common authored field terminology in the
1132
+ // supplied dbt/runtime schema (`product category` -> `product_type`, and a
1133
+ // product-level revenue ask -> `product_price`).
1134
+ const hasProductCategoryRequirement = [
1135
+ ...requirements.dimensions,
1136
+ ...missingDimensions,
1137
+ ...requiredPhysicalFieldTerms,
1138
+ ].some((term) => ['product category', 'category'].includes(normalizeMetricPhrase(term)));
1139
+ const physicalRoleAliases = {
1140
+ 'product category': ['product type'],
1141
+ category: ['product type'],
1142
+ customer: ['customer name'],
1143
+ account: ['account name'],
1144
+ // `product_price` is a local revenue witness only when the same request
1145
+ // also requires product-category grain. It must not let a bare selected
1146
+ // revenue metric bypass its semantic contract through an unrelated raw
1147
+ // price column.
1148
+ ...(hasProductCategoryRequirement ? { revenue: ['product price'] } : {}),
1149
+ };
1150
+ const physicalRoleTerms = (term) => uniqueNormalizedTerms([
1151
+ term,
1152
+ ...(physicalRoleAliases[normalizeMetricPhrase(term)] ?? []),
1153
+ ]);
1154
+ const columnMatchesTerm = (column, term) =>
1155
+ // A relation-qualified ID can contain a metric word even when the column
1156
+ // itself does not. Raw `fact_revenue.competitor` is not revenue evidence.
1157
+ // Physical role proof therefore starts with field-local name/alias facts,
1158
+ // never a parent relation token.
1159
+ columnFieldIdentityTerms(column).some((identity) => physicalRoleTerms(term).some((role) => metricTermsMatch(identity, role)));
1160
+ const matchingColumns = new Map(terms.map((term) => [
1161
+ term,
1162
+ columns.filter((column) => columnMatchesTerm(column, term)),
1163
+ ]));
1164
+ const uncovered = terms.filter((term) => (matchingColumns.get(term) ?? []).length === 0);
1165
+ if (uncovered.length > 0) {
1166
+ return { ok: false, candidateIds: [], reason: `Qualified physical columns did not cover ${uncovered.join(', ')}.` };
1167
+ }
1168
+ const stableCandidateId = (candidate) => candidate.qualifiedId ?? candidate.id;
1169
+ const stableCandidates = (values) => [...values].sort((left, right) => Number(right.exactMatch === true) - Number(left.exactMatch === true)
1170
+ || right.relevanceScore - left.relevanceScore
1171
+ || stableCandidateId(left).localeCompare(stableCandidateId(right)));
1172
+ const stableColumnsForTerm = (values, term) => [...values].sort((left, right) => {
1173
+ const rank = (column) => {
1174
+ const identities = columnFieldIdentityTerms(column);
1175
+ if (identities.some((identity) => identity === term))
1176
+ return 0;
1177
+ if (identities.some((identity) => metricTermsMatch(identity, term)))
1178
+ return 1;
1179
+ return 2;
1180
+ };
1181
+ return rank(left) - rank(right)
1182
+ || Number(right.exactMatch === true) - Number(left.exactMatch === true)
1183
+ || right.relevanceScore - left.relevanceScore
1184
+ || stableCandidateId(left).localeCompare(stableCandidateId(right));
1185
+ });
1186
+ const relationCoverage = relations.map((relation) => new Set(terms.filter((term) => (matchingColumns.get(term) ?? []).some((column) => columnTouchesRelation(column, relation)))));
1187
+ const selectRequiredColumns = (selectedRelations) => {
1188
+ const selected = new Map();
1189
+ for (const term of [...terms].sort()) {
1190
+ const alreadySelected = [...selected.values()].some((column) => columnMatchesTerm(column, term));
1191
+ if (alreadySelected)
1192
+ continue;
1193
+ const matches = stableColumnsForTerm((matchingColumns.get(term) ?? []).filter((column) => selectedRelations.some((relation) => columnTouchesRelation(column, relation))), term);
1194
+ const selectedColumn = matches[0];
1195
+ if (!selectedColumn)
1196
+ return undefined;
1197
+ selected.set(stableCandidateId(selectedColumn), selectedColumn);
1198
+ }
1199
+ return [...selected.values()];
1200
+ };
1201
+ const physicalEvidence = (selectedRelations, requiredColumns, joinColumns, relationshipProofIds, reason) => {
1202
+ // Keep one deterministic witness for every requested role, then the
1203
+ // necessary join-key witnesses and proof identities. Do not add an
1204
+ // entire table schema: that used to hide required columns behind a broad
1205
+ // 32-card truncation. If the minimal evidence itself exceeds the cap,
1206
+ // decline the path rather than silently dropping a required witness.
1207
+ const ids = [...new Set([
1208
+ ...stableCandidates(selectedRelations).map(stableCandidateId),
1209
+ ...stableCandidates(requiredColumns).map(stableCandidateId),
1210
+ ...stableCandidates(joinColumns).map(stableCandidateId),
1211
+ ...[...relationshipProofIds].sort(),
1212
+ ])];
1213
+ if (ids.length > 32) {
1214
+ return {
1215
+ ok: false,
1216
+ candidateIds: [],
1217
+ reason: `The minimal qualified exploratory closure needs ${ids.length} required relation, field, join-key, or proof witnesses, exceeding the 32-card safety cap; no required evidence was dropped.`,
1218
+ };
1219
+ }
1220
+ return { ok: true, candidateIds: ids, reason };
1221
+ };
1222
+ const completeRelation = relationCoverage
1223
+ .map((coverage, index) => ({ coverage, index }))
1224
+ .filter(({ coverage }) => coverage.size === terms.length)
1225
+ .sort((left, right) => stableCandidateId(relations[left.index]).localeCompare(stableCandidateId(relations[right.index])))[0];
1226
+ if (completeRelation) {
1227
+ const relation = relations[completeRelation.index];
1228
+ const requiredColumns = selectRequiredColumns([relation]);
1229
+ if (!requiredColumns) {
1230
+ return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relation.' };
1231
+ }
1232
+ return physicalEvidence([relation], requiredColumns, [], [], 'One qualified physical relation covers the requested fields without a join.');
1233
+ }
1234
+ // Build only from structured relationship proofs. A large retrieval snapshot
1235
+ // routinely contains unrelated raw tables; requiring every one to join made
1236
+ // a complete local path appear unavailable. Conversely, never infer a join
1237
+ // from names or shared column strings: the proof must retain the structured
1238
+ // exploratory-safe disposition above. A selected path remains
1239
+ // review-required; it is not a governed relational plan.
1240
+ const adjacent = relations.map(() => new Set());
1241
+ const joinColumnsForProof = (proof, left, right) => {
1242
+ const leftEndpoints = candidateRelationshipEndpoints(left);
1243
+ const rightEndpoints = candidateRelationshipEndpoints(right);
1244
+ const from = normalizedRelationshipIdentity(proof.from ?? '');
1245
+ const to = normalizedRelationshipIdentity(proof.to ?? '');
1246
+ const direct = leftEndpoints.has(from) && rightEndpoints.has(to);
1247
+ const reverse = leftEndpoints.has(to) && rightEndpoints.has(from);
1248
+ if (!direct && !reverse)
1249
+ return undefined;
1250
+ const witnesses = new Map();
1251
+ for (const key of proof.keys) {
1252
+ const leftKey = normalizeMetricPhrase(direct ? key.from : key.to);
1253
+ const rightKey = normalizeMetricPhrase(direct ? key.to : key.from);
1254
+ const leftColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, left)
1255
+ && columnMatchesTerm(column, leftKey)), leftKey)[0];
1256
+ const rightColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, right)
1257
+ && columnMatchesTerm(column, rightKey)), rightKey)[0];
1258
+ // A relationship record alone cannot prove a compilable raw join. Both
1259
+ // canonical endpoint columns must be present in this same snapshot.
1260
+ if (!leftColumn || !rightColumn)
1261
+ return undefined;
1262
+ witnesses.set(stableCandidateId(leftColumn), leftColumn);
1263
+ witnesses.set(stableCandidateId(rightColumn), rightColumn);
1264
+ }
1265
+ return [...witnesses.values()];
1266
+ };
1267
+ const witnessesForEdge = new Map();
1268
+ for (let left = 0; left < relations.length; left += 1) {
1269
+ for (let right = left + 1; right < relations.length; right += 1) {
1270
+ const shared = [...safeRelationshipProofsForPair(relations[left], relations[right], undefined, 'exploratory').values()]
1271
+ .sort((first, second) => first.id.localeCompare(second.id))
1272
+ .flatMap((proof) => {
1273
+ const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
1274
+ return joinColumns ? [{ proof, columns: joinColumns }] : [];
1275
+ });
1276
+ if (shared.length === 0)
1277
+ continue;
1278
+ adjacent[left].add(right);
1279
+ adjacent[right].add(left);
1280
+ witnessesForEdge.set(`${Math.min(left, right)}:${Math.max(left, right)}`, shared);
1281
+ }
1282
+ }
1283
+ const closures = [];
1284
+ for (let root = 0; root < relations.length; root += 1) {
1285
+ const nodes = new Set([root]);
1286
+ const edges = [];
1287
+ const covered = new Set(relationCoverage[root]);
1288
+ while (covered.size < terms.length) {
1289
+ let best;
1290
+ const pending = [{ node: root, path: [root] }];
1291
+ const seen = new Set([root]);
1292
+ while (pending.length > 0) {
1293
+ const current = pending.shift();
1294
+ const gain = [...relationCoverage[current.node]].filter((term) => !covered.has(term)).length;
1295
+ if (gain > 0 && (!best || gain > best.gain || (gain === best.gain && current.path.length < best.distance))) {
1296
+ best = { path: current.path, gain, distance: current.path.length };
1297
+ }
1298
+ for (const neighbor of adjacent[current.node] ?? []) {
1299
+ if (seen.has(neighbor))
1300
+ continue;
1301
+ seen.add(neighbor);
1302
+ pending.push({ node: neighbor, path: [...current.path, neighbor] });
1303
+ }
1304
+ }
1305
+ if (!best)
1306
+ break;
1307
+ for (const node of best.path) {
1308
+ nodes.add(node);
1309
+ for (const term of relationCoverage[node])
1310
+ covered.add(term);
1311
+ }
1312
+ for (let index = 1; index < best.path.length; index += 1) {
1313
+ const left = best.path[index - 1];
1314
+ const right = best.path[index];
1315
+ if (!edges.some(([edgeLeft, edgeRight]) => edgeLeft === left && edgeRight === right || edgeLeft === right && edgeRight === left)) {
1316
+ edges.push([left, right]);
1317
+ }
1318
+ }
1319
+ }
1320
+ if (covered.size === terms.length)
1321
+ closures.push({ nodes, edges, covered });
1322
+ }
1323
+ const selected = closures.sort((left, right) => left.nodes.size - right.nodes.size
1324
+ || left.edges.length - right.edges.length
1325
+ || [...left.nodes].join(',').localeCompare([...right.nodes].join(',')))[0];
1326
+ if (!selected) {
1327
+ return {
1328
+ ok: false,
1329
+ candidateIds: [],
1330
+ reason: 'Multiple physical relations lacked one connected, structured, fanout-safe exploratory join path.',
1331
+ // This is not a lexical conclusion. Every requested physical field was
1332
+ // found above, no one relation covered the tuple, and the structured
1333
+ // relationship/fanout proof graph could not connect the required
1334
+ // relations. Preserve that proof-specific category for downstream
1335
+ // receipts and repair guidance.
1336
+ gap: {
1337
+ code: 'MISSING_RELATIONSHIP',
1338
+ missing: ['a connected, explicitly allowed, validated, fanout-safe relationship proof'],
1339
+ witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
1340
+ },
1341
+ };
1342
+ }
1343
+ const selectedRelations = [...selected.nodes].map((index) => relations[index]);
1344
+ const requiredColumns = selectRequiredColumns(selectedRelations);
1345
+ if (!requiredColumns) {
1346
+ return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relationship closure.' };
1347
+ }
1348
+ const selectedWitnesses = selected.edges.map(([left, right]) => witnessesForEdge.get(`${Math.min(left, right)}:${Math.max(left, right)}`)?.[0]).filter((witness) => Boolean(witness));
1349
+ if (selectedWitnesses.length !== selected.edges.length) {
1350
+ return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
1351
+ }
1352
+ 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.');
1353
+ }
1354
+ /**
1355
+ * The pre-freeze cascade is router authority, including when the bounded
1356
+ * meaning call could not run. A missing role in a compact meaning package is
1357
+ * not terminal proof of absence: eligible certified/semantic tiers are marked
1358
+ * ineligible, then the same immutable snapshot may prove a review-required
1359
+ * physical path. Policy, fiscal, and relationship safety gates run before
1360
+ * this helper and remain terminal where appropriate.
1361
+ */
1362
+ function preFreezePhysicalCascadeDecision(input) {
1363
+ // `candidates` can be the capped meaning package. Physical eligibility is
1364
+ // allowed one same-snapshot extension, never a new retrieval/domain scope.
1365
+ const snapshotCandidates = immutableSnapshotCandidates(input.evidence, input.candidates);
1366
+ const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
1367
+ const missingRankingMetric = Boolean(physicalPath.ok
1368
+ && input.requireRankingMetric
1369
+ && input.requirements.ranking
1370
+ && input.requirements.ranking.metricTerms.length === 0);
1371
+ const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
1372
+ const selectedPhysicalLegacyIds = snapshotCandidates
1373
+ .filter((candidate) => physicalPath.candidateIds.includes(candidate.qualifiedId ?? candidate.id))
1374
+ .map((candidate) => candidate.id);
1375
+ const selectedPhysicalIdsForPlan = selectedPhysicalLegacyIds.length > 0
1376
+ ? selectedPhysicalLegacyIds
1377
+ : physicalPath.candidateIds;
1378
+ // The physical cascade is still an authoritative Ask route, not a legacy
1379
+ // escape hatch. Recreate the same host-owned seed used before the meaning
1380
+ // call so a semantic miss cannot erase an explicit entity display key,
1381
+ // member/filter, ranking limit, time role, or output tuple on the way to
1382
+ // review-required exploration.
1383
+ const hostRequirementSeed = buildAnalyticalRequirementSeedV1({
1384
+ question: input.question,
1385
+ parsedIntent: input.evidence.parsedIntent,
1386
+ requirements: input.requirements,
1387
+ fiscalCalendar: declaredFiscalCalendar(input.evidence, snapshotCandidates),
1388
+ });
1389
+ // The router, not the SQL generator, owns analytical meaning and the
1390
+ // physical closure. Freeze that selected exploratory plan before a model
1391
+ // sees SQL work. The host later attaches an authorization receipt for the
1392
+ // exact read-only SQL/target; it must never be the event that changes the
1393
+ // selected tier or reinterprets this request.
1394
+ const exploratoryMeaning = exploratoryExecutable
1395
+ ? {
1396
+ interpretedQuestion: input.question,
1397
+ questionType: questionTypeFromText(input.question),
1398
+ selectedConceptIds: selectedPhysicalIdsForPlan,
1399
+ recommendedExecutionId: selectedPhysicalIdsForPlan[0],
1400
+ queryIntent: queryIntentFromRequirementSeed(hostRequirementSeed),
1401
+ rejectedCandidates: [],
1402
+ // Low confidence is intentionally non-executable in a resolved plan.
1403
+ // This is a router-proven physical closure, not a speculative model
1404
+ // answer, so the confidence describes the selected route only.
1405
+ confidence: 'high',
1406
+ missingInformation: [],
1407
+ recommendedRoute: 'exploratory',
1408
+ hostRequirementSeed,
1409
+ compatibilityOutcome: 'modeling_gap',
1410
+ compatibilityFailures: input.missingTerms.map((term) => ({
1411
+ code: 'MISSING_DIMENSION',
1412
+ field: term,
1413
+ message: `${term} was not complete in the earlier governed tiers.`,
1414
+ candidateIds: [],
1415
+ })),
1416
+ }
1417
+ : undefined;
1418
+ const resolvedAnalyticalPlan = exploratoryMeaning
1419
+ ? buildResolvedAnalyticalPlan({
1420
+ question: input.question,
1421
+ resolution: exploratoryMeaning,
1422
+ evidence: input.evidence,
1423
+ candidates: snapshotCandidates,
1424
+ mode: 'authoritative',
1425
+ })
1426
+ : undefined;
1427
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan?.capability === 'bounded_exploration';
1428
+ const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
1429
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1430
+ const skippedAttempt = (tier, source) => {
1431
+ const item = coverageFor(source);
1432
+ return {
1433
+ version: 1,
1434
+ tier,
1435
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1436
+ candidateIds: item?.candidateIds ?? [],
1437
+ reason: item?.status === 'available'
1438
+ ? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
1439
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1440
+ planFrozen: false,
1441
+ };
1442
+ };
1443
+ const governedCoverage = coverageFor('governed_relational');
1444
+ const attempts = [
1445
+ skippedAttempt('certified', 'certified'),
1446
+ skippedAttempt('semantic', 'semantic'),
1447
+ {
1448
+ version: 1,
1449
+ tier: 'governed_relational',
1450
+ outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
1451
+ candidateIds: governedCoverage?.candidateIds ?? [],
1452
+ reason: governedCoverage?.status === 'available'
1453
+ ? 'Retrieved governed relationship evidence did not prove a complete relational execution tuple.'
1454
+ : `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
1455
+ planFrozen: false,
1456
+ },
1457
+ {
1458
+ version: 1,
1459
+ tier: 'exploratory_sql',
1460
+ outcome: exploratoryExecutable ? 'executable' : missingRankingMetric ? 'ambiguous' : 'unavailable',
1461
+ candidateIds: physicalPath.candidateIds,
1462
+ reason: missingRankingMetric
1463
+ ? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
1464
+ : physicalPath.reason,
1465
+ planFrozen: exploratoryPlanFrozen,
1466
+ },
1467
+ ];
1468
+ const message = exploratoryExecutable
1469
+ ? `${input.messagePrefix} A same-snapshot qualified physical path is available for review-required exploratory SQL.`
1470
+ : missingRankingMetric
1471
+ ? `${input.messagePrefix} ${physicalPath.reason} A ranking measure must be selected before DQL can freeze or explore this plan.`
1472
+ : `${input.messagePrefix} ${physicalPath.reason}`;
1473
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
1474
+ requirements: input.requirements,
1475
+ sourceCoverage: coverage,
1476
+ attempts: exploratoryExecutable
1477
+ ? attempts
1478
+ : [...attempts, {
1479
+ version: 1,
1480
+ tier: 'clarify_or_gap',
1481
+ outcome: 'unavailable',
1482
+ candidateIds: [],
1483
+ reason: message,
1484
+ planFrozen: false,
1485
+ }],
1486
+ ...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
1487
+ planFrozen: exploratoryPlanFrozen,
1488
+ stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
1489
+ });
1490
+ if (!exploratoryExecutable) {
1491
+ return {
1492
+ ...input.base,
1493
+ action: 'block',
1494
+ confidence: 1,
1495
+ reason: message,
1496
+ source: 'heuristic',
1497
+ category: 'data_lookup',
1498
+ depth: 'quick',
1499
+ requiresClarification: false,
1500
+ clarifyingQuestion: undefined,
1501
+ clarificationOptions: undefined,
1502
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
1503
+ terminalOutcome: {
1504
+ kind: 'modeling_gap',
1505
+ code: 'ANALYTICAL_MODELING_GAP',
1506
+ message,
1507
+ candidateIds: input.terminalCandidateIds ?? [],
1508
+ ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
1509
+ },
1510
+ analyticalCascadeDecision,
1511
+ resolvedAnalyticalPlan: undefined,
1512
+ meaningResolution: undefined,
1513
+ };
1514
+ }
1515
+ return {
1516
+ ...input.base,
1517
+ action: 'answer',
1518
+ confidence: 0.55,
1519
+ reason: message,
1520
+ source: 'heuristic',
1521
+ category: 'data_lookup',
1522
+ depth: 'quick',
1523
+ requiresClarification: false,
1524
+ clarifyingQuestion: undefined,
1525
+ clarificationOptions: undefined,
1526
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
1527
+ analyticalCascadeDecision,
1528
+ meaningResolution: exploratoryMeaning,
1529
+ resolvedAnalyticalPlan,
1530
+ };
1531
+ }
1532
+ /**
1533
+ * The compact meaning package is deliberately not a proof-of-absence set.
1534
+ * Preserve the complete immutable retrieval snapshot for physical closure and
1535
+ * structured-continuation checks, while retaining a caller's server-issued
1536
+ * selection when it was supplied as a same-snapshot clarification card.
1537
+ */
1538
+ function immutableSnapshotCandidates(evidence, candidates) {
1539
+ const byId = new Map();
1540
+ for (const candidate of [
1541
+ ...evidence.candidates,
1542
+ ...(evidence.clarificationCandidates ?? []),
1543
+ ...candidates,
1544
+ ]) {
1545
+ if (!byId.has(candidate.id))
1546
+ byId.set(candidate.id, candidate);
1547
+ }
1548
+ return [...byId.values()];
1549
+ }
1550
+ /**
1551
+ * A normal meaning call may nominate either a semantic metric or a governed
1552
+ * modeling card whose declared execution contract cannot satisfy the exact
1553
+ * tuple. Before presenting that *pre-freeze* modeling gap as terminal,
1554
+ * inspect the complete immutable snapshot for one bounded, relationship-safe
1555
+ * physical closure. This is not a post-freeze downgrade: policy, fiscal, and
1556
+ * unsafe failures remain blocked and a safe result is explicitly
1557
+ * review-required exploratory work.
1558
+ */
1559
+ function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
1560
+ const { decision, evidence } = input;
1561
+ const plan = decision.resolvedAnalyticalPlan;
1562
+ if (!plan
1563
+ || decision.terminalOutcome?.kind !== 'modeling_gap'
1564
+ || decision.analyticalCascadeDecision?.planFrozen === true
1565
+ || (plan.recommendedRoute !== 'semantic' && plan.recommendedRoute !== 'governed_sql'))
1566
+ return decision;
1567
+ const selectedPreFreezeTier = plan.recommendedRoute;
1568
+ const requirements = buildAnalyticalRequirementSet({
1569
+ question: input.question,
1570
+ parsedIntent: input.evidence.parsedIntent,
1571
+ });
1572
+ const unresolved = plan.resolutionFailure?.bindings
1573
+ .filter((binding) => binding.status !== 'resolved')
1574
+ .map((binding) => binding.requested) ?? [];
1575
+ const missingTerms = [...new Set([
1576
+ ...unresolved,
1577
+ ...requirements.dimensions,
1578
+ ...requirements.entityTerms,
1579
+ ...requirements.entityDisplayTerms,
1580
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1581
+ ])];
1582
+ const requiredPhysicalFieldTerms = [...new Set([
1583
+ ...requirements.dimensions,
1584
+ ...requirements.entityTerms,
1585
+ ...requirements.entityDisplayTerms,
1586
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1587
+ ...(plan.query.filters ?? []).map((filter) => filter.field),
1588
+ ...unresolved,
1589
+ ])];
1590
+ const physicalContinuation = preFreezePhysicalCascadeDecision({
1591
+ base: input.base,
1592
+ evidence,
1593
+ candidates: immutableSnapshotCandidates(evidence, input.candidates),
1594
+ question: input.question,
1595
+ requirements,
1596
+ missingTerms,
1597
+ requiredPhysicalFieldTerms,
1598
+ messagePrefix: selectedPreFreezeTier === 'governed_sql'
1599
+ ? 'The selected governed relational interpretation did not prove a compiler-owned DQL projection before plan freeze.'
1600
+ : 'The selected semantic interpretation was pre-freeze-ineligible for the complete requested tuple.',
1601
+ terminalCandidateIds: plan.selectedConceptIds,
1602
+ });
1603
+ if (physicalContinuation.action === 'answer'
1604
+ && physicalContinuation.analyticalCascadeDecision?.selectedTier === 'exploratory_sql') {
1605
+ return physicalContinuation;
1606
+ }
1607
+ // The physical extension was considered and was not safe/executable. Keep
1608
+ // the original immutable semantic plan and its typed failure rather than
1609
+ // replacing it with a synthetic exploratory meaning; surface the full
1610
+ // snapshot cascade evidence so the terminal block explains why it could not
1611
+ // continue. This retains policy/unsafe/fiscal terminal semantics.
1612
+ return {
1613
+ ...decision,
1614
+ reason: physicalContinuation.reason,
1615
+ retrievalEvidence: physicalContinuation.retrievalEvidence,
1616
+ analyticalCascadeDecision: physicalContinuation.analyticalCascadeDecision,
1617
+ terminalOutcome: physicalContinuation.terminalOutcome ?? decision.terminalOutcome,
1618
+ };
1619
+ }
1620
+ /**
1621
+ * A pair of raw relations is not normally a reason to bypass a clarification:
1622
+ * `orders`, `customers`, and `products` can still be three competing meanings.
1623
+ * It is different when the reader asked for a display value from one relation
1624
+ * and a predicate that physically exists only on another relation. In that
1625
+ * narrow case the evidence is complementary rather than competing.
1626
+ *
1627
+ * This helper is intentionally conservative. It requires all of the
1628
+ * following from ONE retrieval snapshot before it can select review-required
1629
+ * exploration:
1630
+ * - a display-shaped column (for example `product_name`),
1631
+ * - a boolean/predicate-shaped column on a different relation (for example
1632
+ * `is_perishable_supply`),
1633
+ * - the same qualified join key on both relations, and
1634
+ * - a shared, non-fanout relationship proof that touches those relations.
1635
+ *
1636
+ * That means a lexical collection of tables can never suppress a genuine
1637
+ * ambiguity. The generated executor still validates read-only SQL and its
1638
+ * join/aggregation safety before anything runs.
1639
+ */
1640
+ function findSafeComplementaryPhysicalComposition(question, candidates) {
1641
+ const physical = candidates.filter((candidate) => candidate.eligible !== false
1642
+ && candidate.compatibility !== 'incompatible'
1643
+ && (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
1644
+ const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
1645
+ const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
1646
+ if (relations.length < 2 || columns.length < 4)
1647
+ return undefined;
1648
+ const relationSources = (candidate) => candidate.sourceObjects?.length
1649
+ ? candidate.sourceObjects
1650
+ : [candidate.qualifiedId ?? candidate.id];
1651
+ const relationForColumn = (column) => {
1652
+ if (!column.sourceObjects?.length)
1653
+ return [];
1654
+ return relations.filter((relation) => relationSources(relation).some((source) => column.sourceObjects.includes(source)));
1655
+ };
1656
+ const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
1657
+ const candidateTokens = (candidate) => new Set(candidateIdentityTerms(candidate)
1658
+ .flatMap((identity) => identity.split(' '))
1659
+ .map(singularize)
1660
+ .filter(Boolean));
1661
+ const matchesQuestion = (candidate) => [...candidateTokens(candidate)].filter((token) => questionTokens.has(token));
1662
+ const displayColumns = columns.filter((column) => {
1663
+ const name = normalizeMetricPhrase(column.name);
1664
+ return /(?:^| )(?:name|label|title)(?:$| )/.test(name)
1665
+ && matchesQuestion(column).length > 0
1666
+ && relationForColumn(column).length > 0;
1667
+ });
1668
+ if (displayColumns.length === 0)
1669
+ return undefined;
1670
+ const displayTokens = new Set(displayColumns.flatMap(matchesQuestion));
1671
+ const predicateColumns = columns.filter((column) => {
1672
+ const name = normalizeMetricPhrase(column.name);
1673
+ // Predicate shape is required. A text field such as `supply_name` may be
1674
+ // lexically close to "supplies", but it is not proof of the requested
1675
+ // condition and must keep the ordinary clarification behavior.
1676
+ const predicateShape = /(?:^| )(?:is|has|flag|status|active|enabled)(?: |$)/.test(name);
1677
+ return predicateShape
1678
+ && matchesQuestion(column).some((token) => !displayTokens.has(token))
1679
+ && relationForColumn(column).length > 0;
1680
+ });
1681
+ if (predicateColumns.length === 0)
1682
+ return undefined;
1683
+ const columnJoinKeys = (column) => uniqueNormalizedTerms([
1684
+ column.name,
1685
+ ...(column.aliases ?? []),
1686
+ ]).filter((term) => /(?:^| )id$/.test(term));
1687
+ for (const displayColumn of displayColumns) {
1688
+ for (const displayRelation of relationForColumn(displayColumn)) {
1689
+ for (const predicateColumn of predicateColumns) {
1690
+ for (const predicateRelation of relationForColumn(predicateColumn)) {
1691
+ const displaySources = new Set(relationSources(displayRelation));
1692
+ if (relationSources(predicateRelation).some((source) => displaySources.has(source)))
1693
+ continue;
1694
+ const displayColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === displayRelation.id));
1695
+ const predicateColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === predicateRelation.id));
1696
+ const predicateJoinKeys = new Set(predicateColumnsOnRelation.flatMap(columnJoinKeys));
1697
+ const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
1698
+ if (!joinKey)
1699
+ continue;
1700
+ const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey, 'exploratory').keys()];
1701
+ if (sharedRelationshipProofIds.length === 0)
1702
+ continue;
1703
+ const selectedPhysical = [
1704
+ displayRelation,
1705
+ predicateRelation,
1706
+ ...displayColumnsOnRelation,
1707
+ ...predicateColumnsOnRelation,
1708
+ ].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
1709
+ const baseRequirements = buildAnalyticalRequirementSet({ question });
1710
+ const requirements = {
1711
+ ...baseRequirements,
1712
+ dimensions: uniqueNormalizedTerms([...baseRequirements.dimensions, displayColumn.name]),
1713
+ entityTerms: uniqueNormalizedTerms([...baseRequirements.entityTerms, ...matchesQuestion(displayColumn)]),
1714
+ entityDisplayTerms: uniqueNormalizedTerms([...baseRequirements.entityDisplayTerms, ...matchesQuestion(displayColumn)]),
1715
+ memberTerms: uniqueNormalizedTerms([...baseRequirements.memberTerms, predicateColumn.name]),
1716
+ };
1717
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, selectedPhysical, [], [displayColumn.name, predicateColumn.name, joinKey]);
1718
+ if (!physicalPath.ok)
1719
+ continue;
1720
+ const relationshipCandidates = candidates.filter((candidate) => sharedRelationshipProofIds.includes(candidate.id)
1721
+ || Boolean(candidate.qualifiedId && sharedRelationshipProofIds.includes(candidate.qualifiedId)));
1722
+ return {
1723
+ requirements,
1724
+ candidateIds: [...new Set([
1725
+ ...physicalPath.candidateIds,
1726
+ ...sharedRelationshipProofIds,
1727
+ ...relationshipCandidates.map((candidate) => candidate.qualifiedId ?? candidate.id),
1728
+ ])].slice(0, 32),
1729
+ displayColumn,
1730
+ predicateColumn,
1731
+ joinKey,
1732
+ relationshipProofIds: sharedRelationshipProofIds,
1733
+ reason: `Qualified ${displayRelation.name}.${displayColumn.name} and ${predicateRelation.name}.${predicateColumn.name} are complementary requirements joined by ${joinKey}; the shared relationship proof permits review-required exploration.`,
1734
+ };
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ return undefined;
1740
+ }
1741
+ /** Build the router-owned cascade for a safe raw relational composition. */
1742
+ function complementaryExploratoryDecision(base, evidence, candidates, question) {
1743
+ // Use all already-retrieved cards from the same snapshot. The bounded meaning
1744
+ // package can omit a supporting raw join column, but no new retrieval/domain
1745
+ // scope is opened here.
1746
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
1747
+ const composition = findSafeComplementaryPhysicalComposition(question, snapshotCandidates);
1748
+ if (!composition)
1749
+ return undefined;
1750
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
1751
+ const exploratoryMeaning = {
1752
+ interpretedQuestion: question,
1753
+ questionType: questionTypeFromText(question),
1754
+ selectedConceptIds: composition.candidateIds,
1755
+ recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
1756
+ queryIntent: {
1757
+ measures: [],
1758
+ dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
1759
+ filters: [{
1760
+ field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
1761
+ value: 'true',
1762
+ }],
1763
+ },
1764
+ rejectedCandidates: [],
1765
+ confidence: 'high',
1766
+ missingInformation: [],
1767
+ recommendedRoute: 'exploratory',
1768
+ compatibilityOutcome: 'modeling_gap',
1769
+ };
1770
+ const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
1771
+ question,
1772
+ resolution: exploratoryMeaning,
1773
+ evidence,
1774
+ candidates: snapshotCandidates,
1775
+ mode: 'authoritative',
1776
+ });
1777
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan.capability === 'bounded_exploration';
1778
+ if (!exploratoryPlanFrozen)
1779
+ return undefined;
1780
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1781
+ const skippedAttempt = (tier, source, reason) => {
1782
+ const item = coverageFor(source);
1783
+ return {
1784
+ version: 1,
1785
+ tier,
1786
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
1787
+ candidateIds: item?.candidateIds ?? [],
1788
+ reason,
1789
+ planFrozen: false,
1790
+ };
1791
+ };
1792
+ const attempts = [
1793
+ skippedAttempt('certified', 'certified', 'No certified candidate proved the composed display and predicate tuple.'),
1794
+ skippedAttempt('semantic', 'semantic', 'No semantic candidate proved the composed display and predicate tuple.'),
1795
+ {
1796
+ version: 1,
1797
+ tier: 'governed_relational',
1798
+ outcome: 'ineligible',
1799
+ candidateIds: [...new Set([
1800
+ ...(coverageFor('governed_relational')?.candidateIds ?? []),
1801
+ ...composition.relationshipProofIds,
1802
+ ])].slice(0, 32),
1803
+ reason: 'The same-snapshot relationship proof closes the physical path, but no complete governed relational execution tuple was retrieved.',
1804
+ planFrozen: false,
1805
+ },
1806
+ {
1807
+ version: 1,
1808
+ tier: 'exploratory_sql',
1809
+ outcome: 'executable',
1810
+ candidateIds: composition.candidateIds,
1811
+ reason: composition.reason,
1812
+ planFrozen: exploratoryPlanFrozen,
1813
+ },
1814
+ ];
1815
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
1816
+ requirements: composition.requirements,
1817
+ sourceCoverage: coverage,
1818
+ attempts,
1819
+ selectedTier: 'exploratory_sql',
1820
+ planFrozen: exploratoryPlanFrozen,
1821
+ stopReason: 'selected',
1822
+ });
1823
+ return {
1824
+ ...base,
1825
+ action: 'answer',
1826
+ confidence: 0.55,
1827
+ source: 'heuristic',
1828
+ category: 'data_lookup',
1829
+ depth: 'quick',
1830
+ reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
1831
+ requiresClarification: false,
1832
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1833
+ analyticalCascadeDecision,
1834
+ resolvedAnalyticalPlan,
1835
+ meaningResolution: exploratoryMeaning,
1836
+ };
1837
+ }
1838
+ function cascadeForResolution(input) {
1839
+ const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
1840
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
1841
+ const selectedTier = input.plan.capability === 'certified_execution' ? 'certified'
1842
+ : input.plan.capability === 'semantic_execution' ? 'semantic'
1843
+ : input.plan.capability === 'governed_relational' ? 'governed_relational'
1844
+ : input.plan.capability === 'bounded_exploration' ? 'exploratory_sql'
1845
+ : undefined;
1846
+ const frozen = input.reconciliation.outcome === 'ready' && input.plan.mode === 'authoritative';
1847
+ const tier = (name, source, selected) => {
1848
+ const item = coverageFor(source);
1849
+ return {
1850
+ version: 1,
1851
+ tier: name,
1852
+ outcome: selected ? 'executable' : item?.status === 'available' ? 'ineligible' : 'unavailable',
1853
+ candidateIds: item?.candidateIds ?? [],
1854
+ reason: selected ? `The ${name} tier proved the complete requested tuple and froze the plan.` : item?.status === 'available' ? `The ${name} tier was retrieved but did not prove the complete tuple.` : `The ${name} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1855
+ planFrozen: frozen && selected,
1856
+ };
1857
+ };
1858
+ const attempts = [
1859
+ tier('certified', 'certified', selectedTier === 'certified'),
1860
+ tier('semantic', 'semantic', selectedTier === 'semantic'),
1861
+ tier('governed_relational', 'governed_relational', selectedTier === 'governed_relational'),
1862
+ tier('exploratory_sql', 'exploratory', selectedTier === 'exploratory_sql'),
1863
+ ];
1864
+ if (!selectedTier) {
1865
+ attempts.push({ version: 1, tier: 'clarify_or_gap', outcome: input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'unavailable', candidateIds: [], reason: input.reconciliation.reason, planFrozen: false });
1866
+ }
1867
+ return buildAnalyticalCascadeDecision({
1868
+ requirements: buildAnalyticalRequirementSet({ question: input.question, parsedIntent: input.evidence.parsedIntent }),
1869
+ sourceCoverage: coverage,
1870
+ attempts,
1871
+ ...(selectedTier ? { selectedTier } : {}),
1872
+ planFrozen: frozen,
1873
+ ...(input.terminalGap ? { terminalGap: input.terminalGap } : {}),
1874
+ stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
1875
+ });
1876
+ }
1877
+ function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
1878
+ // Treat an LLM/direct selection as a nomination, never as permission to
1879
+ // let a certified block borrow a metric from neighboring semantic cards.
1880
+ // This check runs immediately before plan construction so every fast lane,
1881
+ // cached selection, explicit choice, and provider response shares it.
1882
+ let planBoundResolution = withDeclaredFiscalBinding(repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates), evidence, candidates, question);
1883
+ if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
1884
+ const sourceFrame = resolution.analyticalFrame;
1885
+ const bindingPlan = buildResolvedAnalyticalPlan({
1886
+ question,
1887
+ // Keep the frame available while binding its time-axis roles. A
1888
+ // time dimension lives in `capability.timeDimensions`, not the ordinary
1889
+ // group-by list; stripping the frame first made a valid report_date
1890
+ // binding look unresolved after a certified→semantic recovery.
1891
+ resolution,
1892
+ evidence,
1893
+ candidates,
1894
+ mode,
1895
+ });
1896
+ planBoundResolution = {
1897
+ ...resolution,
1898
+ analyticalFrame: projectResolvedAnalyticalFrame({ plan: bindingPlan, sourceFrame }),
1899
+ };
1900
+ }
1901
+ const routedResolution = enforceAnalyticalCompatibility(planBoundResolution, evidence, candidates);
1902
+ const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
1903
+ question,
1904
+ resolution: routedResolution,
1905
+ evidence,
1906
+ candidates,
1907
+ mode,
1908
+ });
1909
+ const reconciliation = reconcileResolvedPlanOutcome(routedResolution, resolvedAnalyticalPlan, candidates);
1910
+ const needsClarification = reconciliation.outcome === 'clarify';
1911
+ const terminallyBlocked = reconciliation.outcome === 'modeling_gap'
1912
+ || reconciliation.outcome === 'policy_blocked';
1913
+ const analytical = routedResolution.questionType === "diagnosis" ||
1914
+ routedResolution.questionType === "research";
1915
+ const reason = reconciliation.reason;
1916
+ const terminalGap = reconciliation.outcome === 'modeling_gap'
1917
+ ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
1918
+ : undefined;
1919
+ const analyticalCascadeDecision = cascadeForResolution({
1920
+ evidence,
1921
+ candidates,
1922
+ resolution: routedResolution,
1923
+ plan: resolvedAnalyticalPlan,
1924
+ reconciliation,
1925
+ question,
1926
+ ...(terminalGap ? { terminalGap: cascadeTerminalRelationshipGap(terminalGap) } : {}),
1927
+ });
1928
+ const declaredDimensionAssumptions = assumptionsForDeclaredDimensionAlternatives({
1929
+ question,
1930
+ evidence,
1931
+ candidates,
1932
+ plan: resolvedAnalyticalPlan,
1933
+ });
1934
+ // The deterministic pre-router may attach a generic soft clarification
1935
+ // while retrieval is still incomplete. Once this same turn has bound and
1936
+ // frozen a plan, that stale prompt must not leak into a completed answer or
1937
+ // its trace. Keep a clarification only when reconciliation itself says the
1938
+ // final immutable tuple is genuinely ambiguous.
1939
+ const { clarifyingQuestion: _staleClarifyingQuestion, clarificationOptions: _staleClarificationOptions, clarifySoft: _staleClarifySoft, ...baseWithoutStaleClarification } = base;
1940
+ return {
1941
+ ...baseWithoutStaleClarification,
1942
+ action: needsClarification
1943
+ ? "clarify"
1944
+ : terminallyBlocked
1945
+ ? "block"
1946
+ : analytical
1947
+ ? "investigate"
1948
+ : "answer",
1949
+ confidence: routedResolution.confidence === "high"
1950
+ ? 0.9
1951
+ : routedResolution.confidence === "medium"
1952
+ ? 0.72
1953
+ : 0.45,
1954
+ reason,
1955
+ source,
1956
+ category: analytical ? "data_analysis" : needsClarification ? "unclear" : "data_lookup",
1957
+ depth: analytical ? "deep" : "quick",
1958
+ meaningResolution: routedResolution,
1959
+ resolvedAnalyticalPlan,
1960
+ analyticalCascadeDecision,
1961
+ retrievalEvidence: retrievalTrace(evidence, candidates),
1962
+ requiresClarification: needsClarification,
1963
+ ...(terminallyBlocked
1964
+ ? {
1965
+ terminalOutcome: {
1966
+ kind: reconciliation.outcome === 'policy_blocked'
1967
+ ? 'policy_blocked'
1968
+ : 'modeling_gap',
1969
+ code: reconciliation.outcome === 'policy_blocked'
1970
+ ? 'ANALYTICAL_POLICY_BLOCKED'
1971
+ : 'ANALYTICAL_MODELING_GAP',
1972
+ message: reconciliation.reason,
1973
+ candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
1974
+ ...(terminalGap ? { gap: terminalGap } : {}),
1975
+ },
1976
+ }
1977
+ : {}),
1978
+ ...(needsClarification
1979
+ ? { clarificationOptions: reconciliation.options }
1980
+ : {}),
1981
+ ...(needsClarification
1982
+ ? {
1983
+ clarifyingQuestion: reconciliation.question,
1984
+ ...(_staleClarifySoft ? { clarifySoft: true } : {}),
1985
+ }
1986
+ : {}),
1987
+ ...(declaredDimensionAssumptions.length > 0
1988
+ ? { assumptions: declaredDimensionAssumptions }
1989
+ : {}),
1990
+ };
1991
+ }
1992
+ /**
1993
+ * A block may be relevant to a metric without returning that metric. When a
1994
+ * stale catalog result or provider selection nominates such a block, continue
1995
+ * with one exact semantic definition when it exists; otherwise preserve the
1996
+ * genuine metric ambiguity as stable semantic choices. Never construct a
1997
+ * certified plan whose output contract merely borrows a neighbor's identity.
1998
+ */
1999
+ function repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates) {
2000
+ if (resolution.recommendedRoute !== 'certified')
2001
+ return resolution;
2002
+ const selected = resolution.recommendedExecutionId
2003
+ ? candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId)
2004
+ : candidates.find((candidate) => resolution.selectedConceptIds.includes(candidate.id));
2005
+ const requestedMeasures = evidence.parsedIntent?.measures?.length
2006
+ ? evidence.parsedIntent.measures
2007
+ : resolution.queryIntent.measures;
2008
+ if (!selected || selected.kind !== 'certified_block'
2009
+ || certifiedCandidateExplicitlyCoversMeasures(selected, requestedMeasures))
2010
+ return resolution;
2011
+ const compatibleMetrics = candidates.filter((candidate) => candidate.kind === 'semantic_metric'
2012
+ && candidate.compatibility === 'compatible'
2013
+ && requestedMeasures.length > 0
2014
+ && requestedMeasures.every((requested) => candidateProvesMetricTerm(candidate, requested))
2015
+ && Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability));
2016
+ const exactMetrics = compatibleMetrics.filter((candidate) => requestedMeasures.every((requested) => semanticMetricIdentityExactlyMatches(candidate, requested)));
2017
+ if (exactMetrics.length === 1) {
2018
+ const metric = exactMetrics[0];
2019
+ return {
2020
+ ...resolution,
2021
+ selectedConceptIds: [metric.id],
2022
+ recommendedExecutionId: metric.id,
2023
+ recommendedRoute: 'semantic',
2024
+ confidence: resolution.confidence === 'low' ? 'medium' : resolution.confidence,
2025
+ // The frame is a typed interpretation of the requested tuple, not a
2026
+ // claim that the rejected certified block can execute it. Preserve it
2027
+ // when the exact semantic metric owns the same request so the frozen
2028
+ // plan retains its V2 time/comparison/ranking contract. Clearing it
2029
+ // here downgraded a safe semantic recovery into a legacy V1 blocked
2030
+ // plan solely because the original certified nomination was incomplete.
2031
+ analyticalFrame: resolution.analyticalFrame,
2032
+ missingInformation: [...new Set([
2033
+ ...resolution.missingInformation,
2034
+ `${selected.name} does not declare ${requestedMeasures.join(', ')} as an output; continued with the exact semantic metric ${metric.name}.`,
2035
+ ])],
2036
+ };
2037
+ }
2038
+ const choices = compatibleMetrics.map((candidate) => candidate.id);
2039
+ return {
2040
+ ...resolution,
2041
+ selectedConceptIds: [],
2042
+ recommendedExecutionId: undefined,
2043
+ recommendedRoute: 'clarify',
2044
+ confidence: 'low',
2045
+ analyticalFrame: undefined,
2046
+ compatibilityOutcome: 'clarify',
2047
+ compatibilityFailures: [{
2048
+ code: 'CERTIFIED_MEASURE_OUTPUT_MISSING',
2049
+ field: 'measure',
2050
+ message: `${selected.name} does not declare ${requestedMeasures.join(', ')} as an output.`,
2051
+ candidateIds: choices,
2052
+ }],
2053
+ missingInformation: [...new Set([
2054
+ ...resolution.missingInformation,
2055
+ choices.length > 1
2056
+ ? `The selected certified block does not declare ${requestedMeasures.join(', ')}. Choose among the compatible semantic metric definitions.`
2057
+ : `The selected certified block does not declare ${requestedMeasures.join(', ')} as an output.`,
2058
+ ])],
2059
+ clarifyingQuestion: choices.length > 1
2060
+ ? 'Which compatible semantic metric should DQL use?'
2061
+ : resolution.clarifyingQuestion,
2062
+ };
2063
+ }
2064
+ function semanticMetricIdentityExactlyMatches(candidate, requested) {
2065
+ const requestedIdentity = normalizeMetricPhrase(requested);
2066
+ if (!requestedIdentity)
2067
+ return false;
2068
+ return [candidate.name, ...(candidate.aliases ?? []), candidate.qualifiedId ?? candidate.id]
2069
+ .map((identity) => normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity))
2070
+ .some((identity) => identity === requestedIdentity);
2071
+ }
2072
+ /**
2073
+ * Clarification is an execution-affecting choice, not a generic search result
2074
+ * picker. In particular, a block tagged with revenue may be useful context
2075
+ * but cannot be offered for an explicit revenue request unless that block's
2076
+ * own output contract declares revenue. Applying this at the option boundary
2077
+ * keeps a stale pooled candidate from becoming a later structured bypass.
2078
+ */
2079
+ function clarificationRequirementsForResolution(resolution) {
2080
+ const question = resolution.interpretedQuestion;
2081
+ const questionTerms = normalizeMetricPhrase(question);
2082
+ // A parser/result frame can carry inherited technical hints that do not
2083
+ // belong to the user's current wording. They are useful to planning, but
2084
+ // must not turn an unrelated structured option into an invalid selection.
2085
+ // Keep only parser measures the question actually says, then add the
2086
+ // resolver's own typed metric frame when it exists.
2087
+ const mentionedMeasures = resolution.queryIntent.measures.filter((measure) => {
2088
+ const normalized = normalizeMetricPhrase(measure);
2089
+ const tokens = normalized.split(' ').filter((token) => token.length >= 3);
2090
+ return tokens.length > 0 && tokens.every((token) => questionTerms.includes(token));
2091
+ });
2092
+ const frameMeasures = resolution.analyticalFrame?.metricConceptIds.map((id) => id.split(/[.:/]/).filter(Boolean).at(-1) ?? id) ?? [];
2093
+ return buildAnalyticalRequirementSet({
2094
+ question,
2095
+ parsedIntent: {
2096
+ ...resolution.queryIntent,
2097
+ measures: [...new Set([...mentionedMeasures, ...frameMeasures])],
2098
+ },
2099
+ });
2100
+ }
2101
+ function explicitMeasureTermsForClarification(requirements) {
2102
+ return [...new Set([
2103
+ ...(requirements?.measures ?? []),
2104
+ ...(requirements?.ranking?.metricTerms ?? []),
2105
+ ].map((term) => normalizeMetricPhrase(term.split(/[.:/]/).at(-1) ?? term)).filter(Boolean))];
2106
+ }
2107
+ function candidateOwnsExplicitClarificationRoles(candidate, requirements) {
2108
+ if (candidate.eligible === false || candidate.compatibility === 'incompatible')
2109
+ return false;
2110
+ const measures = explicitMeasureTermsForClarification(requirements);
2111
+ if (measures.length === 0)
2112
+ return true;
2113
+ if (candidate.kind === 'certified_block') {
2114
+ return certifiedCandidateExplicitlyCoversMeasures(candidate, measures);
2115
+ }
2116
+ // A structured choice for a measure must be the metric itself. An entity,
2117
+ // member, model, or physical relation can help compile the chosen metric but
2118
+ // cannot become its meaning merely because it happened to rank highly.
2119
+ // A semantic metric is a valid *meaning* choice when it owns at least one
2120
+ // explicitly requested metric. Multi-metric plans still cannot freeze until
2121
+ // every requested measure is bound by the immutable plan; this narrower
2122
+ // option check lets a user choose between the revenue/refunds definitions
2123
+ // without pretending the single metric already answers the whole tuple.
2124
+ return candidate.kind === 'semantic_metric'
2125
+ && measures.some((measure) => candidateProvesMetricTerm(candidate, measure));
2126
+ }
2127
+ function compatibleClarificationCandidates(candidates, requirements) {
2128
+ return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements));
2129
+ }
2130
+ function withoutMeasureClarificationRequirements(requirements) {
2131
+ const { ranking: _ranking, ...withoutRanking } = requirements;
2132
+ return {
2133
+ ...withoutRanking,
2134
+ measures: [],
2135
+ };
2136
+ }
2137
+ /**
2138
+ * The immutable RAP is the final routing authority. Meaning may nominate an
2139
+ * execution route, but cannot leave the router claiming an answer after the
2140
+ * host has retained an ambiguous or blocked qualified binding.
2141
+ */
2142
+ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
2143
+ if (plan.capability !== 'blocked') {
2144
+ return {
2145
+ outcome: 'ready',
2146
+ reason: `Resolved the question against ${plan.selectedConceptIds.join(', ')}: ${resolution.interpretedQuestion}`,
2147
+ };
2148
+ }
2149
+ const bindings = [
2150
+ ...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
2151
+ ...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
2152
+ ...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
2153
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => ({ kind: 'output', binding })),
2154
+ ].filter(({ binding }) => binding.status !== 'resolved');
2155
+ const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
2156
+ const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
2157
+ const clarificationRequirements = clarificationRequirementsForResolution(resolution);
2158
+ if (plan.resolutionFailure?.outcome === 'policy_blocked') {
2159
+ return {
2160
+ outcome: 'policy_blocked',
2161
+ reason: `Policy blocked the selected analytical plan: ${plan.missingInformation.join(' ') || 'review the retained policy diagnostic.'}`,
2162
+ };
2163
+ }
2164
+ if (plan.resolutionFailure?.outcome === 'modeling_gap') {
2165
+ return {
2166
+ outcome: 'modeling_gap',
2167
+ reason: `The selected analytical plan has a governed modeling gap: ${plan.missingInformation.join(' ') || 'review the retained capability diagnostic.'}`,
2168
+ };
2169
+ }
2170
+ if (userResolvableBinding || plan.resolutionFailure?.outcome === 'clarify') {
2171
+ const optionIds = qualifiedChoiceIds.length > 0
2172
+ ? qualifiedChoiceIds
2173
+ : [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
2174
+ const unresolvedMeasure = bindings.some(({ kind }) => kind === 'measure');
2175
+ const optionRequirements = unresolvedMeasure
2176
+ ? clarificationRequirements
2177
+ : withoutMeasureClarificationRequirements(clarificationRequirements);
2178
+ const options = optionIds.length > 0
2179
+ ? clarificationOptionsForQualifiedIds(optionIds, candidates, optionRequirements)
2180
+ : buildClarificationOptions(candidates, optionRequirements);
2181
+ if (options.length === 0) {
2182
+ return {
2183
+ outcome: 'modeling_gap',
2184
+ reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
2185
+ };
2186
+ }
2187
+ const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
2188
+ const question = routedClarificationQuestion(resolution, bindings, options);
2189
+ return {
2190
+ outcome: 'clarify',
2191
+ reason: `The immutable analytical plan needs one identifier-bound choice: ${bindingSummary || plan.missingInformation.join(' ')}`,
2192
+ question,
2193
+ options,
2194
+ };
2195
+ }
2196
+ if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
2197
+ const options = buildClarificationOptions(candidates, clarificationRequirements);
2198
+ if (options.length === 0) {
2199
+ return {
2200
+ outcome: 'modeling_gap',
2201
+ reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
2202
+ };
2203
+ }
2204
+ return {
2205
+ outcome: 'clarify',
2206
+ reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
2207
+ question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
2208
+ options,
2209
+ };
2210
+ }
2211
+ return {
2212
+ outcome: 'modeling_gap',
2213
+ reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
2214
+ };
2215
+ }
2216
+ /**
2217
+ * Keep a specific terminal gap only when its producer supplied a typed reason.
2218
+ * A generic modeling gap must never become a relationship-gap claim because a
2219
+ * later renderer happened to mention joins in its repair copy.
2220
+ */
2221
+ function terminalGapWitnessForResolutionFailure(plan) {
2222
+ const failure = plan.resolutionFailure;
2223
+ if (!failure || failure.outcome !== 'modeling_gap')
2224
+ return undefined;
2225
+ const codes = new Set(failure.codes);
2226
+ const unresolved = [...new Set(failure.bindings
2227
+ .filter((binding) => binding.status !== 'resolved')
2228
+ .map((binding) => binding.requested)
2229
+ .filter(Boolean))];
2230
+ const witnessCandidateIds = [...new Set([
2231
+ ...failure.candidateIds,
2232
+ ...(failure.selectedCapabilityId ? [failure.selectedCapabilityId] : []),
2233
+ ...(failure.selectedExecutionId ? [failure.selectedExecutionId] : []),
2234
+ ])].sort();
2235
+ if (codes.has('RELATIONSHIP_PROOF_MISSING')) {
2236
+ return {
2237
+ code: 'MISSING_RELATIONSHIP',
2238
+ missing: ['a certified, validated, fanout-safe relationship proof'],
2239
+ witnessCandidateIds,
2240
+ };
2241
+ }
2242
+ if (codes.has('METRIC_CAPABILITY_MISSING') || codes.has('MISSING_MEASURE')) {
2243
+ return {
2244
+ code: 'MISSING_MEASURE',
2245
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed measure'],
2246
+ witnessCandidateIds,
2247
+ };
2248
+ }
2249
+ if (codes.has('MEMBER_FILTER_UNSUPPORTED') || codes.has('MISSING_ATTRIBUTE')) {
2250
+ return {
2251
+ code: 'MISSING_ATTRIBUTE',
2252
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed attribute or member filter'],
2253
+ witnessCandidateIds,
2254
+ };
2255
+ }
2256
+ if ([
2257
+ 'DIMENSION_ROLE_UNSUPPORTED',
2258
+ 'TIME_DIMENSION_REQUIRED',
2259
+ 'TIME_DIMENSION_AMBIGUOUS',
2260
+ 'TIME_ROLE_UNSUPPORTED',
2261
+ 'TIME_GRAIN_UNSUPPORTED',
2262
+ 'MISSING_DIMENSION',
2263
+ ].some((code) => codes.has(code))) {
2264
+ return {
2265
+ code: 'MISSING_DIMENSION',
2266
+ missing: unresolved.length > 0 ? unresolved : ['the requested governed dimension or time role'],
2267
+ witnessCandidateIds,
2268
+ };
2269
+ }
2270
+ return {
2271
+ code: 'MISSING_RUNTIME_CAPABILITY',
2272
+ missing: unresolved.length > 0 ? unresolved : ['the complete requested analytical tuple'],
2273
+ witnessCandidateIds,
2274
+ };
2275
+ }
2276
+ function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
2277
+ const requireArtifactLocalMeasureProof = explicitMeasureTermsForClarification(requirements).length > 0;
2278
+ return ids
2279
+ .map((id) => ({
2280
+ id,
2281
+ candidate: candidates.find((item) => item.id === id || item.qualifiedId === id),
2282
+ }))
2283
+ .filter(({ candidate }) => candidate
2284
+ ? candidateOwnsExplicitClarificationRoles(candidate, requirements)
2285
+ // Qualified capability dimensions are synthesized by the resolved
2286
+ // plan, not necessarily returned as standalone retrieval cards. They
2287
+ // remain valid choices for a dimension/filter clarification, but never
2288
+ // for an explicit metric role whose local artifact proof is missing.
2289
+ : !requireArtifactLocalMeasureProof)
2290
+ .slice(0, 3)
2291
+ .map(({ id, candidate }) => {
2292
+ return {
2293
+ id: candidate?.id ?? id,
2294
+ label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
2295
+ ...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
2296
+ kind: candidate?.kind ?? 'semantic_member',
2297
+ };
2298
+ });
2299
+ }
2300
+ function qualifiedIdLabel(id) {
2301
+ const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
2302
+ return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
2303
+ }
2304
+ function routedClarificationQuestion(resolution, bindings, options) {
2305
+ if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
2306
+ return resolution.clarifyingQuestion;
2307
+ }
2308
+ const first = bindings[0];
2309
+ const labels = options.map((option) => option.label);
2310
+ if (first && labels.length > 1) {
2311
+ return `Which governed ${first.kind} should I use for “${first.binding.requested}”: ${labels.join(' or ')}?`;
2312
+ }
2313
+ return resolution.clarifyingQuestion
2314
+ ?? `Which governed binding should I use before running this query?`;
2315
+ }
2316
+ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question, selectedConceptIds = [selected.id]) {
2317
+ // Preserve the exact stable ID emitted by the clarification option. A
2318
+ // candidate may also carry a source-qualified execution alias, but replacing
2319
+ // the clicked ID here makes the persisted ambiguity contract and the later
2320
+ // diagnostic receipt disagree even though the user chose a legitimate item.
2321
+ const selectedId = selected.id;
2322
+ // A server-issued display-key click has already passed the snapshot and
2323
+ // metric-capability checks. If its tuple is pre-freeze-ineligible, preserve
2324
+ // both the metric authority and the exact clicked ID through the typed gap
2325
+ // or same-snapshot exploratory continuation. Reconstructing only the
2326
+ // metric made restart receipts falsely look as though no choice was made.
2327
+ const preservedSelectionIds = [...new Set([
2328
+ ...selectedConceptIds.filter((id) => Boolean(id?.trim())),
2329
+ selectedId,
2330
+ ])];
2331
+ // A structured choice is validated against its exact stable identity, but an
2332
+ // incomplete selected capability must evaluate physical eligibility against
2333
+ // the entire immutable snapshot—not the compact model package that happened
2334
+ // to carry the clarification. This is one same-snapshot extension only.
2335
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
2336
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
2337
+ const requiredPhysicalFieldTerms = [
2338
+ ...(evidence.parsedIntent?.dimensions ?? []),
2339
+ ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
2340
+ ];
2341
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, snapshotCandidates, requirements.dimensions, requiredPhysicalFieldTerms);
2342
+ // A structured clarification consumes the exact selected ID once, but an
2343
+ // incomplete governed capability is not a post-freeze terminal. Reuse the
2344
+ // router-owned physical cascade so the same-snapshot exploratory closure is
2345
+ // resolved and frozen *before* SQL is generated. The host may later attach
2346
+ // only an authorization receipt for that frozen plan; it must not select or
2347
+ // freeze another plan after SQL exists.
2348
+ if (physicalPath.ok) {
2349
+ return preFreezePhysicalCascadeDecision({
2350
+ base,
2351
+ evidence,
2352
+ candidates: snapshotCandidates,
2353
+ question,
2354
+ requirements,
2355
+ missingTerms: [...new Set([
2356
+ ...requirements.dimensions,
2357
+ ...requirements.entityTerms,
2358
+ ...requirements.entityDisplayTerms,
2359
+ ])],
2360
+ requiredPhysicalFieldTerms,
2361
+ 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;`,
2362
+ terminalCandidateIds: preservedSelectionIds,
2363
+ requireRankingMetric: Boolean(requirements.ranking),
2364
+ });
2365
+ }
2366
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
2367
+ const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
2368
+ const attempts = [
2369
+ {
2370
+ version: 1,
2371
+ tier: 'certified',
2372
+ outcome: 'ineligible',
2373
+ candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [],
2374
+ reason: `The explicit selection ${selectedId} did not prove a complete certified tuple.`,
2375
+ planFrozen: false,
2376
+ },
2377
+ {
2378
+ version: 1,
2379
+ tier: 'semantic',
2380
+ outcome: 'ineligible',
2381
+ candidateIds: [...new Set([...preservedSelectionIds, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
2382
+ reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
2383
+ planFrozen: false,
2384
+ },
2385
+ {
2386
+ version: 1,
2387
+ tier: 'governed_relational',
2388
+ outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
2389
+ candidateIds: governedCoverage?.candidateIds ?? [],
2390
+ reason: governedCoverage?.status === 'available'
2391
+ ? 'Retrieved governed relationship evidence did not prove a complete relational plan.'
2392
+ : `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
2393
+ planFrozen: false,
2394
+ },
2395
+ {
2396
+ version: 1,
2397
+ tier: 'exploratory_sql',
2398
+ outcome: physicalPath.ok ? 'executable' : 'unavailable',
2399
+ candidateIds: physicalPath.candidateIds,
2400
+ reason: physicalPath.reason,
2401
+ planFrozen: false,
2402
+ },
2403
+ ];
2404
+ const message = physicalPath.ok
2405
+ ? `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact; a same-snapshot qualified physical path is available for review-required exploratory SQL.`
2406
+ : `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and will not substitute a correlated metric or execute generated SQL because ${physicalPath.reason}`;
2407
+ const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
2408
+ requirements,
2409
+ sourceCoverage: coverage,
2410
+ attempts: physicalPath.ok
2411
+ ? attempts
2412
+ : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: preservedSelectionIds, reason: message, planFrozen: false }],
2413
+ ...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
2414
+ planFrozen: false,
2415
+ stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
2416
+ });
2417
+ const meaningResolution = {
2418
+ interpretedQuestion: question,
2419
+ questionType: 'value',
2420
+ selectedConceptIds: preservedSelectionIds,
2421
+ queryIntent: defaultQueryIntent(evidence),
2422
+ rejectedCandidates: [],
2423
+ confidence: 'low',
2424
+ missingInformation: [message],
2425
+ recommendedRoute: physicalPath.ok ? 'exploratory' : 'clarify',
2426
+ compatibilityOutcome: 'modeling_gap',
2427
+ compatibilityFailures: [{
2428
+ code: 'INCOMPLETE_SELECTED_CAPABILITY',
2429
+ field: 'selected capability',
2430
+ message,
2431
+ candidateIds: preservedSelectionIds,
2432
+ }],
2433
+ };
2434
+ if (!physicalPath.ok) {
2435
+ return {
2436
+ ...base,
2437
+ action: 'block',
2438
+ confidence: 1,
2439
+ reason: message,
2440
+ source: 'heuristic',
2441
+ category: 'data_lookup',
2442
+ depth: 'quick',
2443
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
2444
+ requiresClarification: false,
2445
+ resolvedAnalyticalPlan: undefined,
2446
+ meaningResolution,
2447
+ analyticalCascadeDecision,
2448
+ terminalOutcome: {
2449
+ kind: 'modeling_gap',
2450
+ code: 'ANALYTICAL_MODELING_GAP',
2451
+ message,
2452
+ candidateIds: preservedSelectionIds,
2453
+ ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
2454
+ },
2455
+ };
2456
+ }
2457
+ return {
2458
+ ...base,
2459
+ action: 'answer',
2460
+ confidence: 0.55,
2461
+ reason: message,
2462
+ source: 'heuristic',
2463
+ category: 'data_lookup',
2464
+ depth: 'quick',
2465
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
2466
+ requiresClarification: false,
2467
+ resolvedAnalyticalPlan: undefined,
2468
+ meaningResolution,
2469
+ analyticalCascadeDecision,
2470
+ };
2471
+ }
2472
+ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
605
2473
  const missingMetricTerms = missingExplicitMetricTerms(evidence.parsedIntent?.measures ?? resolution.queryIntent.measures, candidates);
606
2474
  const requestedMetricCount = new Set((evidence.parsedIntent?.measures ?? resolution.queryIntent.measures)
607
2475
  .map(normalizeMetricPhrase)
@@ -654,12 +2522,38 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
654
2522
  const metricEvidence = candidates.filter((candidate) => candidate.kind === "semantic_metric" &&
655
2523
  candidate.analyticalCapability?.metricId &&
656
2524
  metricIds.has(candidate.analyticalCapability.metricId));
2525
+ // Preserve an already validated semantic member when the resulting frame
2526
+ // proves that the selected metric owns that exact dimension. This is how a
2527
+ // server-issued “Account Name” clarification continues alongside the
2528
+ // explicit Revenue metric; stripping it here made trace meaning IDs and
2529
+ // durable continuation state look empty even though the frame was valid.
2530
+ const selectedFrameMembers = resolution.selectedConceptIds.flatMap((id) => {
2531
+ const member = candidates.find((candidate) => candidate.id === id);
2532
+ if (member?.kind !== 'semantic_member')
2533
+ return [];
2534
+ const resolvesIntoFrame = metricEvidence.some((metric) => {
2535
+ const identities = [
2536
+ member.qualifiedId,
2537
+ member.id,
2538
+ member.name,
2539
+ ...(member.aliases ?? []),
2540
+ ].filter((identity) => Boolean(identity));
2541
+ return identities.some((identity) => {
2542
+ const dimension = resolveMetricCapabilityDimension(metric, identity);
2543
+ return Boolean(dimension && result.frame.dimensions.some((binding) => binding.dimensionId === dimension.dimensionId));
2544
+ });
2545
+ });
2546
+ return resolvesIntoFrame ? [member.id] : [];
2547
+ });
657
2548
  return {
658
2549
  ...resolution,
659
2550
  analyticalFrame: result.frame,
660
2551
  analyticalPolicyIds: result.policyIds,
661
2552
  ...(metricEvidence.length > 0
662
- ? { selectedConceptIds: metricEvidence.map((candidate) => candidate.id) }
2553
+ ? { selectedConceptIds: [...new Set([
2554
+ ...metricEvidence.map((candidate) => candidate.id),
2555
+ ...selectedFrameMembers,
2556
+ ])] }
663
2557
  : {}),
664
2558
  recommendedExecutionId: result.candidateId,
665
2559
  recommendedRoute: result.route,
@@ -668,80 +2562,553 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
668
2562
  compatibilityFailures: undefined,
669
2563
  };
670
2564
  }
671
- // A question asking for SEVERAL metrics ("revenue and refunds by month") is a
672
- // legitimate question, not an ambiguous one. The v2 analytical lane is
673
- // contract-per-metric by construction, so it blocks — but blocking must not
674
- // turn into a clarify prompt or a silent collapse to one metric. Drop the
675
- // single-metric frame instead and let the cascade fall through to the semantic
676
- // bridge, which compiles multi-metric selections natively.
677
- if (result.status === "blocked"
678
- && result.failures.length > 0
679
- && result.failures.every((failure) => failure.code === "MULTI_METRIC_UNSUPPORTED")) {
680
- const { analyticalFrame: _droppedFrame, ...withoutFrame } = resolution;
2565
+ // A question asking for SEVERAL metrics ("revenue and refunds by month") is a
2566
+ // legitimate question, not an ambiguous one. The v2 analytical lane is
2567
+ // contract-per-metric by construction, so it blocks — but blocking must not
2568
+ // turn into a clarify prompt or a silent collapse to one metric. Drop the
2569
+ // single-metric frame instead and let the cascade fall through to the semantic
2570
+ // bridge, which compiles multi-metric selections natively.
2571
+ if (result.status === "blocked"
2572
+ && result.failures.length > 0
2573
+ && result.failures.every((failure) => failure.code === "MULTI_METRIC_UNSUPPORTED")) {
2574
+ const { analyticalFrame: _droppedFrame, ...withoutFrame } = resolution;
2575
+ return {
2576
+ ...withoutFrame,
2577
+ analyticalPolicyIds: result.policyIds,
2578
+ };
2579
+ }
2580
+ const failures = result.failures.map((failure) => failure.message);
2581
+ const compatibilityFailures = result.failures.map((failure) => ({
2582
+ code: failure.code,
2583
+ field: failure.field,
2584
+ message: failure.message,
2585
+ candidateIds: [...(failure.candidateIds ?? [])],
2586
+ }));
2587
+ const policyFailure = result.failures.some((failure) => failure.code.startsWith('POLICY_'));
2588
+ const compatibilityOutcome = policyFailure
2589
+ ? 'policy_blocked'
2590
+ : result.status === 'clarify'
2591
+ ? 'clarify'
2592
+ : 'modeling_gap';
2593
+ return {
2594
+ ...resolution,
2595
+ analyticalFrame: result.frame,
2596
+ analyticalPolicyIds: result.policyIds,
2597
+ confidence: result.status === "clarify" ? "low" : resolution.confidence,
2598
+ // A compatibility block is a pre-freeze failure of the nominated tier,
2599
+ // not a new model decision to route somewhere else. Retain the semantic
2600
+ // nomination so the authoritative cascade can inspect the complete
2601
+ // same-snapshot qualified physical closure. Only genuine multi-option
2602
+ // ambiguity changes the route to clarification.
2603
+ recommendedRoute: result.status === 'clarify' ? 'clarify' : resolution.recommendedRoute,
2604
+ compatibilityOutcome,
2605
+ compatibilityFailures,
2606
+ missingInformation: [
2607
+ ...new Set([...resolution.missingInformation, ...failures]),
2608
+ ],
2609
+ clarifyingQuestion: result.status === "clarify"
2610
+ ? result.failure.message
2611
+ : (failures[0] ??
2612
+ "The requested analytical tuple is not executable from the current governed model."),
2613
+ };
2614
+ }
2615
+ function buildClarificationOptions(candidates, requirements) {
2616
+ const roleCompatible = compatibleClarificationCandidates(candidates, requirements);
2617
+ const governed = roleCompatible.filter((candidate) => candidate.compatibility !== "incompatible" &&
2618
+ (candidate.kind === "certified_block" ||
2619
+ candidate.kind === "semantic_metric" ||
2620
+ candidate.kind === "semantic_member"));
2621
+ const pool = governed.length > 1
2622
+ ? governed
2623
+ : roleCompatible;
2624
+ const chosen = pool.slice(0, 3);
2625
+ // Two candidates can legitimately share a display name (a dbt model and its
2626
+ // MetricFlow measure are both "customers"). Rendering both as "customers"
2627
+ // asks the user to choose between two identical-looking buttons, so the
2628
+ // duplicates carry their distinguishing identity.
2629
+ const nameCounts = new Map();
2630
+ for (const candidate of chosen) {
2631
+ nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
2632
+ }
2633
+ return chosen.map((candidate) => {
2634
+ const ambiguousName = (nameCounts.get(candidate.name) ?? 0) > 1;
2635
+ const description = humanizeCandidateDefinition(candidate.definition);
2636
+ return {
2637
+ id: candidate.id,
2638
+ label: ambiguousName
2639
+ ? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
2640
+ : candidate.name,
2641
+ ...(description ? { description } : {}),
2642
+ kind: candidate.kind,
2643
+ };
2644
+ });
2645
+ }
2646
+ /**
2647
+ * The server envelope persists the option/requirement contract that rendered a
2648
+ * clarification. Browser context is never authority: the local runtime adds
2649
+ * a host-only binding only after reading the persisted thread. Current snapshot
2650
+ * compatibility remains mandatory, so a client cannot forge an old option list
2651
+ * to admit an unrelated artifact.
2652
+ */
2653
+ function persistedClarificationSelectionContext(request) {
2654
+ const context = request.conversationContext;
2655
+ const envelope = context?.conversationEnvelope && typeof context.conversationEnvelope === 'object'
2656
+ ? context.conversationEnvelope
2657
+ : context?.serverSnapshot && typeof context.serverSnapshot === 'object'
2658
+ ? context.serverSnapshot
2659
+ : undefined;
2660
+ const pending = envelope?.pendingClarification;
2661
+ if (!pending || typeof pending !== 'object')
2662
+ return undefined;
2663
+ const pendingRecord = pending;
2664
+ const selection = pendingRecord.selection;
2665
+ if (!selection || typeof selection !== 'object')
2666
+ return undefined;
2667
+ const record = selection;
2668
+ const ids = (value) => Array.isArray(value)
2669
+ ? [...new Set(value.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
2670
+ : [];
2671
+ const requirements = normalizePersistedClarificationRequirements(record.requirements);
2672
+ const optionIds = ids(record.optionIds);
2673
+ const ambiguityCandidateIds = ids(record.ambiguityCandidateIds);
2674
+ const snapshotId = typeof record.snapshotId === 'string' && record.snapshotId.trim()
2675
+ ? record.snapshotId.trim()
2676
+ : undefined;
2677
+ const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
2678
+ ? pendingRecord.sourceTurnId.trim()
2679
+ : undefined;
2680
+ const sourceQuestion = typeof pendingRecord.sourceQuestion === 'string' && pendingRecord.sourceQuestion.trim()
2681
+ ? pendingRecord.sourceQuestion.trim()
2682
+ : undefined;
2683
+ const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
2684
+ ? envelope.threadId.trim()
2685
+ : undefined;
2686
+ const authority = context?.serverIssuedClarificationSelection;
2687
+ const authorityRecord = authority && typeof authority === 'object' && !Array.isArray(authority)
2688
+ ? authority
2689
+ : undefined;
2690
+ const authorityMatches = Boolean(authorityRecord?.version === 1
2691
+ && typeof authorityRecord.threadId === 'string'
2692
+ && authorityRecord.threadId === threadId
2693
+ && typeof authorityRecord.sourceTurnId === 'string'
2694
+ && authorityRecord.sourceTurnId === sourceTurnId
2695
+ && typeof authorityRecord.snapshotId === 'string'
2696
+ && authorityRecord.snapshotId === snapshotId);
2697
+ const invalidReason = record.version !== 1
2698
+ ? 'The structured selection envelope has an unsupported version.'
2699
+ : !request.threadId || !threadId || threadId !== request.threadId
2700
+ ? 'The structured selection is not bound to the active server conversation thread.'
2701
+ : !sourceTurnId
2702
+ ? 'The structured selection is missing its server turn binding.'
2703
+ : optionIds.length === 0
2704
+ ? 'The structured selection is missing the options rendered by the server.'
2705
+ : !requirements
2706
+ ? 'The structured selection is missing its typed analytical requirements.'
2707
+ : !snapshotId
2708
+ ? 'The structured selection is missing its retrieval snapshot binding.'
2709
+ : !authorityMatches
2710
+ ? 'The structured selection was not issued by the active server conversation.'
2711
+ : undefined;
2712
+ return {
2713
+ optionIds,
2714
+ ambiguityCandidateIds,
2715
+ ...(requirements ? { requirements } : {}),
2716
+ ...(snapshotId ? { snapshotId } : {}),
2717
+ ...(sourceTurnId ? { sourceTurnId } : {}),
2718
+ ...(sourceQuestion ? { sourceQuestion } : {}),
2719
+ ...(threadId ? { threadId } : {}),
2720
+ serverIssued: !invalidReason,
2721
+ ...(invalidReason ? { invalidReason } : {}),
2722
+ };
2723
+ }
2724
+ /**
2725
+ * The persisted envelope crosses a client/server boundary. It is only a
2726
+ * reject-only continuity hint, but it must still be parsed as data rather than
2727
+ * cast as an executable typed requirement set. In particular, a malformed
2728
+ * string must never be spread into one-character "measures" and weaken the
2729
+ * selected-ID validation path.
2730
+ */
2731
+ function normalizePersistedClarificationRequirements(value) {
2732
+ if (!value || typeof value !== 'object' || Array.isArray(value))
2733
+ return undefined;
2734
+ const record = value;
2735
+ if (record.version !== 1)
2736
+ return undefined;
2737
+ const terms = (input) => Array.isArray(input)
2738
+ ? [...new Set(input.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
2739
+ : [];
2740
+ const rankingRecord = record.ranking && typeof record.ranking === 'object' && !Array.isArray(record.ranking)
2741
+ ? record.ranking
2742
+ : undefined;
2743
+ const timeRecord = record.time && typeof record.time === 'object' && !Array.isArray(record.time)
2744
+ ? record.time
2745
+ : undefined;
2746
+ const direction = rankingRecord?.direction === 'bottom' ? 'bottom' : rankingRecord?.direction === 'top' ? 'top' : undefined;
2747
+ const limit = typeof rankingRecord?.limit === 'number' && Number.isFinite(rankingRecord.limit) && rankingRecord.limit > 0
2748
+ ? Math.floor(rankingRecord.limit)
2749
+ : undefined;
2750
+ const grain = timeRecord?.grain;
2751
+ const safeGrain = grain === 'day' || grain === 'week' || grain === 'month' || grain === 'quarter' || grain === 'year'
2752
+ ? grain
2753
+ : undefined;
2754
+ const timeRole = timeRecord?.role === 'time_axis' || timeRecord?.role === 'time_filter'
2755
+ ? timeRecord.role
2756
+ : undefined;
2757
+ const fiscalPeriod = typeof timeRecord?.fiscalPeriod === 'string' && timeRecord.fiscalPeriod.trim()
2758
+ ? timeRecord.fiscalPeriod.trim()
2759
+ : undefined;
2760
+ return {
2761
+ version: 1,
2762
+ measures: terms(record.measures),
2763
+ dimensions: terms(record.dimensions),
2764
+ entityTerms: terms(record.entityTerms),
2765
+ entityDisplayTerms: terms(record.entityDisplayTerms),
2766
+ memberTerms: terms(record.memberTerms),
2767
+ ...(direction && limit !== undefined
2768
+ ? {
2769
+ ranking: {
2770
+ metricTerms: terms(rankingRecord?.metricTerms),
2771
+ entityTerms: terms(rankingRecord?.entityTerms),
2772
+ direction,
2773
+ limit,
2774
+ defaultedLimit: rankingRecord?.defaultedLimit === true,
2775
+ },
2776
+ }
2777
+ : {}),
2778
+ ...(timeRole
2779
+ ? {
2780
+ time: {
2781
+ role: timeRole,
2782
+ ...(safeGrain ? { grain: safeGrain } : {}),
2783
+ ...(fiscalPeriod ? { fiscalPeriod } : {}),
2784
+ requiresDeclaredFiscalCalendar: timeRecord?.requiresDeclaredFiscalCalendar === true,
2785
+ },
2786
+ }
2787
+ : {}),
2788
+ };
2789
+ }
2790
+ function mergeClarificationRequirements(current, persisted) {
2791
+ if (!persisted)
2792
+ return current;
2793
+ // Persisted requirements are only a reject-only continuity check. Unioning
2794
+ // explicitly requested roles means an old selection cannot weaken the new
2795
+ // typed interpretation even if a caller fabricated its envelope.
2796
+ return {
2797
+ ...current,
2798
+ measures: [...new Set([...current.measures, ...(persisted.measures ?? [])])],
2799
+ dimensions: [...new Set([...current.dimensions, ...(persisted.dimensions ?? [])])],
2800
+ entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
2801
+ entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
2802
+ memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
2803
+ ...(current.ranking || persisted.ranking
2804
+ ? {
2805
+ ranking: current.ranking && persisted.ranking
2806
+ ? {
2807
+ ...current.ranking,
2808
+ metricTerms: [...new Set([...current.ranking.metricTerms, ...persisted.ranking.metricTerms])],
2809
+ entityTerms: [...new Set([...current.ranking.entityTerms, ...persisted.ranking.entityTerms])],
2810
+ }
2811
+ : current.ranking ?? persisted.ranking,
2812
+ }
2813
+ : {}),
2814
+ ...(current.time || persisted.time
2815
+ ? {
2816
+ time: current.time && persisted.time
2817
+ ? {
2818
+ ...current.time,
2819
+ grain: current.time.grain ?? persisted.time.grain,
2820
+ fiscalPeriod: current.time.fiscalPeriod ?? persisted.time.fiscalPeriod,
2821
+ requiresDeclaredFiscalCalendar: current.time.requiresDeclaredFiscalCalendar
2822
+ || persisted.time.requiresDeclaredFiscalCalendar,
2823
+ }
2824
+ : current.time ?? persisted.time,
2825
+ }
2826
+ : {}),
2827
+ };
2828
+ }
2829
+ /**
2830
+ * A structured choice continues the server-rendered analytical frame. The
2831
+ * browser may echo `clarificationSourceQuestion`, but the persisted server
2832
+ * envelope wins whenever it is available so a choice label cannot be parsed as
2833
+ * a new question on reload or restart.
2834
+ */
2835
+ function structuredClarificationRequirements(request, persisted) {
2836
+ const sourceQuestion = persisted?.serverIssued && persisted.sourceQuestion
2837
+ ? persisted.sourceQuestion
2838
+ : request.clarificationSourceQuestion?.trim() || request.question;
2839
+ return mergeClarificationRequirements(buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.serverIssued ? persisted.requirements : undefined);
2840
+ }
2841
+ /**
2842
+ * A dimension clarification is not an alternate definition of the measure.
2843
+ * Find the exact semantic metric that owns the persisted explicit measure
2844
+ * before accepting a server-issued semantic member as a display/grouping
2845
+ * selection. Multiple metrics remain a real ambiguity; we never use a
2846
+ * dimension click to guess one of them.
2847
+ */
2848
+ function uniqueStructuredSelectionMetric(candidates, requirements) {
2849
+ const measures = explicitMeasureTermsForClarification(requirements);
2850
+ if (measures.length !== 1)
2851
+ return undefined;
2852
+ const byMetricId = new Map();
2853
+ for (const candidate of canonicalizeMetricMeasureCandidates(candidates)) {
2854
+ if (candidate.kind !== 'semantic_metric'
2855
+ || candidate.eligible === false
2856
+ // The first clarification deliberately exists because the metric's
2857
+ // display/entity role was not yet fully bound. A partial fit can still
2858
+ // be the unique, complete semantic capability for the explicit measure;
2859
+ // after the server-issued dimension choice binds it, the ordinary
2860
+ // immutable-plan compatibility checks remain authoritative. Only a
2861
+ // proven incompatible metric is disqualified at this identity boundary.
2862
+ || candidate.compatibility === 'incompatible'
2863
+ || candidateConflictsWithExplicitRankingMeasure(candidate, requirements)
2864
+ || !candidateProvesMetricTerm(candidate, measures[0]))
2865
+ continue;
2866
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
2867
+ if (normalized.status !== 'complete' || !normalized.capability)
2868
+ continue;
2869
+ const current = byMetricId.get(normalized.capability.metricId);
2870
+ if (!current
2871
+ || candidate.exactMatch && !current.exactMatch
2872
+ || candidate.relevanceScore > current.relevanceScore) {
2873
+ byMetricId.set(normalized.capability.metricId, candidate);
2874
+ }
2875
+ }
2876
+ return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
2877
+ }
2878
+ /**
2879
+ * A resolver can offer a qualified semantic capability dimension without a
2880
+ * standalone retrieval card. That is expected: the option is an authored
2881
+ * child of a metric capability, not an independently ranked candidate. On a
2882
+ * later click, restore that child only when the active server-issued
2883
+ * clarification contract names the exact option and the *same immutable
2884
+ * snapshot* still proves it through one exact metric capability.
2885
+ *
2886
+ * This is deliberately not a fuzzy retrieval fallback. A malformed,
2887
+ * foreign, stale, or client-invented ID never reaches this helper because the
2888
+ * host-only envelope/snapshot checks occur before the capability projection.
2889
+ */
2890
+ function rehydrateServerIssuedCapabilityDimensionSelection(input) {
2891
+ const selectedId = input.request.selectedEvidenceId?.trim();
2892
+ if (!selectedId)
2893
+ return undefined;
2894
+ const persisted = persistedClarificationSelectionContext(input.request);
2895
+ if (!persisted?.serverIssued || persisted.snapshotId !== input.evidence.snapshotId)
2896
+ return undefined;
2897
+ const offeredIds = new Set([...persisted.optionIds, ...persisted.ambiguityCandidateIds]);
2898
+ if (!offeredIds.has(selectedId))
2899
+ return undefined;
2900
+ const requirements = structuredClarificationRequirements(input.request, persisted);
2901
+ const metricCandidate = uniqueStructuredSelectionMetric(input.candidates, requirements);
2902
+ if (!metricCandidate)
2903
+ return undefined;
2904
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selectedId);
2905
+ if (!dimension)
2906
+ return undefined;
2907
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
2908
+ return undefined;
2909
+ if (!requirements.ranking
2910
+ && !dimension.supportedRoles.includes('group_by')
2911
+ && !dimension.supportedRoles.includes('filter'))
2912
+ return undefined;
2913
+ return {
2914
+ // Keep the exact server-issued ID as the router identity and persistable
2915
+ // choice binding. The metric capability's authored ID stays available as
2916
+ // provenance for semantic-frame construction.
2917
+ id: selectedId,
2918
+ qualifiedId: dimension.dimensionId,
2919
+ kind: 'semantic_member',
2920
+ semanticObjectType: 'dimension',
2921
+ trustTier: 'semantic',
2922
+ name: dimension.label || qualifiedIdLabel(dimension.dimensionId),
2923
+ ...(dimension.aliases?.length ? { aliases: dimension.aliases } : {}),
2924
+ ...(metricCandidate.domain ? { domain: metricCandidate.domain } : {}),
2925
+ ...(metricCandidate.semanticModel ? { semanticModel: metricCandidate.semanticModel } : {}),
2926
+ relevanceScore: metricCandidate.relevanceScore,
2927
+ matchReasons: ['server-issued snapshot capability binding'],
2928
+ compatibility: 'compatible',
2929
+ eligible: true,
2930
+ };
2931
+ }
2932
+ /**
2933
+ * Validate a selected display/grouping member against the selected metric's
2934
+ * physical semantic capability. The candidate must already have been offered
2935
+ * by the server; this only determines which typed role it can complete.
2936
+ */
2937
+ function structuredDimensionSelection(selected, candidates, requirements) {
2938
+ if (selected.kind !== 'semantic_member'
2939
+ || selected.eligible === false
2940
+ || selected.compatibility === 'incompatible')
2941
+ return undefined;
2942
+ const roles = evidenceCandidateRoles(selected);
2943
+ if (!roles.some((role) => role === 'entity_label'
2944
+ || role === 'categorical_dimension'
2945
+ || role === 'time_dimension'))
2946
+ return undefined;
2947
+ const metricCandidate = uniqueStructuredSelectionMetric(candidates, requirements);
2948
+ if (!metricCandidate)
2949
+ return undefined;
2950
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selected.qualifiedId ?? selected.id);
2951
+ if (!dimension)
2952
+ return undefined;
2953
+ // A top/bottom clarification must select an authored rankable entity, not a
2954
+ // filter-only field. For ordinary dimensional requests, grouping is enough.
2955
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
2956
+ return undefined;
2957
+ if (!requirements.ranking
2958
+ && !dimension.supportedRoles.includes('group_by')
2959
+ && !dimension.supportedRoles.includes('filter'))
2960
+ return undefined;
2961
+ return {
2962
+ kind: 'dimension',
2963
+ metricCandidate,
2964
+ selectedDimensionId: selected.id,
2965
+ dimensionId: dimension.dimensionId,
2966
+ };
2967
+ }
2968
+ function compatibleStructuredClarificationCandidates(candidates, requirements) {
2969
+ return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements)
2970
+ || Boolean(structuredDimensionSelection(candidate, candidates, requirements)));
2971
+ }
2972
+ function validateStructuredClarificationSelection(input) {
2973
+ const persisted = persistedClarificationSelectionContext(input.request);
2974
+ // A click/reload continuation is an identity action. Use the original user
2975
+ // question and server-persisted typed contract, not arbitrary inherited
2976
+ // parser hints or the rendered option label.
2977
+ const requirements = structuredClarificationRequirements(input.request, persisted);
2978
+ const compatible = compatibleStructuredClarificationCandidates(input.candidates, requirements);
2979
+ const choiceIds = compatible.map((candidate) => candidate.id);
2980
+ if (!persisted?.serverIssued) {
2981
+ return {
2982
+ ok: false,
2983
+ requirements,
2984
+ choiceIds,
2985
+ reason: persisted?.invalidReason
2986
+ ?? 'A structured selection requires a server-issued clarification envelope.',
2987
+ };
2988
+ }
2989
+ if (persisted.snapshotId !== input.evidence.snapshotId) {
2990
+ return {
2991
+ ok: false,
2992
+ requirements,
2993
+ choiceIds,
2994
+ reason: 'The structured selection belongs to a stale retrieval snapshot and must be chosen again.',
2995
+ };
2996
+ }
2997
+ if (!input.selected) {
2998
+ return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
2999
+ }
3000
+ const selection = candidateOwnsExplicitClarificationRoles(input.selected, requirements)
3001
+ ? { kind: 'metric' }
3002
+ : structuredDimensionSelection(input.selected, input.candidates, requirements);
3003
+ if (!selection) {
681
3004
  return {
682
- ...withoutFrame,
683
- analyticalPolicyIds: result.policyIds,
3005
+ ok: false,
3006
+ requirements,
3007
+ choiceIds,
3008
+ reason: `The selected artifact ${input.selected.id} does not own every explicitly requested analytical role.`,
684
3009
  };
685
3010
  }
686
- const failures = result.failures.map((failure) => failure.message);
687
- const compatibilityFailures = result.failures.map((failure) => ({
688
- code: failure.code,
689
- field: failure.field,
690
- message: failure.message,
691
- candidateIds: [...(failure.candidateIds ?? [])],
692
- }));
693
- const policyFailure = result.failures.some((failure) => failure.code.startsWith('POLICY_'));
694
- const compatibilityOutcome = policyFailure
695
- ? 'policy_blocked'
696
- : result.status === 'clarify'
697
- ? 'clarify'
698
- : 'modeling_gap';
699
- return {
700
- ...resolution,
701
- analyticalFrame: result.frame,
702
- analyticalPolicyIds: result.policyIds,
703
- confidence: result.status === "clarify" ? "low" : resolution.confidence,
704
- recommendedRoute: "clarify",
705
- compatibilityOutcome,
706
- compatibilityFailures,
707
- missingInformation: [
708
- ...new Set([...resolution.missingInformation, ...failures]),
709
- ],
710
- clarifyingQuestion: result.status === "clarify"
711
- ? result.failure.message
712
- : (failures[0] ??
713
- "The requested analytical tuple is not executable from the current governed model."),
714
- };
715
- }
716
- function buildClarificationOptions(candidates) {
717
- const governed = candidates.filter((candidate) => candidate.compatibility !== "incompatible" &&
718
- (candidate.kind === "certified_block" ||
719
- candidate.kind === "semantic_metric" ||
720
- candidate.kind === "semantic_member"));
721
- const pool = governed.length > 1
722
- ? governed
723
- : candidates.filter((candidate) => candidate.compatibility !== "incompatible");
724
- const chosen = pool.slice(0, 3);
725
- // Two candidates can legitimately share a display name (a dbt model and its
726
- // MetricFlow measure are both "customers"). Rendering both as "customers"
727
- // asks the user to choose between two identical-looking buttons, so the
728
- // duplicates carry their distinguishing identity.
729
- const nameCounts = new Map();
730
- for (const candidate of chosen) {
731
- nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
3011
+ const persistedChoices = new Set([
3012
+ ...(persisted?.optionIds ?? []),
3013
+ ...(persisted?.ambiguityCandidateIds ?? []),
3014
+ ]);
3015
+ if (!persistedChoices.has(input.selected.id)
3016
+ && !persistedChoices.has(input.selected.qualifiedId ?? '')) {
3017
+ return {
3018
+ ok: false,
3019
+ requirements,
3020
+ choiceIds,
3021
+ reason: 'The selected governed identifier was not one of the persisted ambiguity choices for this question.',
3022
+ };
732
3023
  }
733
- return chosen.map((candidate) => {
734
- const ambiguousName = (nameCounts.get(candidate.name) ?? 0) > 1;
735
- const description = humanizeCandidateDefinition(candidate.definition);
3024
+ if (choiceIds.length > 0 && !choiceIds.includes(input.selected.id)) {
736
3025
  return {
737
- id: candidate.id,
738
- label: ambiguousName
739
- ? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
740
- : candidate.name,
741
- ...(description ? { description } : {}),
742
- kind: candidate.kind,
3026
+ ok: false,
3027
+ requirements,
3028
+ choiceIds,
3029
+ reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
743
3030
  };
3031
+ }
3032
+ return { ok: true, requirements, choiceIds, selection };
3033
+ }
3034
+ function invalidStructuredSelectionDecision(input) {
3035
+ const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
3036
+ const persisted = persistedClarificationSelectionContext(input.request);
3037
+ const persistedOptionIds = [...new Set([
3038
+ ...(persisted?.serverIssued ? persisted.optionIds : []),
3039
+ ...(persisted?.serverIssued ? persisted.ambiguityCandidateIds : []),
3040
+ ])];
3041
+ // Re-render only the original server-persisted ambiguity set when it exists.
3042
+ // A rejected click cannot silently widen into a fresh block/metric choice
3043
+ // that was never offered for this question; each remaining item is still
3044
+ // filtered against current snapshot eligibility and artifact-local roles.
3045
+ const options = persistedOptionIds.length > 0
3046
+ ? clarificationOptionsForQualifiedIds(persistedOptionIds, input.candidates, input.validation.requirements)
3047
+ : buildClarificationOptions(input.candidates, input.validation.requirements);
3048
+ const candidateIds = input.validation.choiceIds;
3049
+ const message = `${input.validation.reason} DQL did not freeze or execute a plan for that selection.`;
3050
+ const decision = buildAnalyticalCascadeDecision({
3051
+ requirements: input.validation.requirements,
3052
+ sourceCoverage: coverage,
3053
+ attempts: [
3054
+ { version: 1, tier: 'certified', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [], reason: 'No selected certified artifact proved the requested tuple.', planFrozen: false },
3055
+ { version: 1, tier: 'semantic', outcome: 'ambiguous', candidateIds, reason: 'The selected identifier was rejected before semantic ambiguity could be resolved.', planFrozen: false },
3056
+ { version: 1, tier: 'governed_relational', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'governed_relational')?.candidateIds ?? [], reason: 'A structured selection cannot authorize relational execution while its meaning is invalid.', planFrozen: false },
3057
+ { version: 1, tier: 'exploratory_sql', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'exploratory')?.candidateIds ?? [], reason: 'A structured selection cannot authorize exploratory execution while its meaning is invalid.', planFrozen: false },
3058
+ { version: 1, tier: 'clarify_or_gap', outcome: options.length > 0 ? 'ambiguous' : 'unavailable', candidateIds, reason: message, planFrozen: false },
3059
+ ],
3060
+ planFrozen: false,
3061
+ stopReason: options.length > 0 ? 'ambiguous' : 'coverage_gap',
744
3062
  });
3063
+ if (options.length === 0) {
3064
+ return {
3065
+ ...input.base,
3066
+ action: 'block',
3067
+ confidence: 1,
3068
+ source: 'heuristic',
3069
+ category: 'data_lookup',
3070
+ depth: 'quick',
3071
+ reason: message,
3072
+ requiresClarification: false,
3073
+ retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
3074
+ terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds },
3075
+ analyticalCascadeDecision: decision,
3076
+ resolvedAnalyticalPlan: undefined,
3077
+ meaningResolution: undefined,
3078
+ };
3079
+ }
3080
+ return {
3081
+ ...input.base,
3082
+ action: 'clarify',
3083
+ confidence: 1,
3084
+ source: 'heuristic',
3085
+ category: 'unclear',
3086
+ depth: 'quick',
3087
+ reason: message,
3088
+ requiresClarification: true,
3089
+ clarifyingQuestion: 'That selection does not match the requested analytical meaning. Which compatible governed metric should DQL use?',
3090
+ clarificationOptions: options,
3091
+ retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
3092
+ analyticalCascadeDecision: decision,
3093
+ resolvedAnalyticalPlan: undefined,
3094
+ meaningResolution: {
3095
+ interpretedQuestion: input.request.question,
3096
+ questionType: questionTypeFromText(input.request.question),
3097
+ selectedConceptIds: [],
3098
+ queryIntent: defaultQueryIntent(input.evidence),
3099
+ rejectedCandidates: [{ id: input.selectedId, reason: input.validation.reason }],
3100
+ confidence: 'low',
3101
+ missingInformation: [message],
3102
+ recommendedRoute: 'clarify',
3103
+ compatibilityOutcome: 'clarify',
3104
+ compatibilityFailures: [{
3105
+ code: 'INVALID_STRUCTURED_SELECTION',
3106
+ field: 'selection',
3107
+ message: input.validation.reason,
3108
+ candidateIds,
3109
+ }],
3110
+ },
3111
+ };
745
3112
  }
746
3113
  function candidateKindLabel(kind) {
747
3114
  if (kind === 'certified_block')
@@ -912,7 +3279,68 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
912
3279
  : `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?`,
913
3280
  };
914
3281
  }
915
- function directResolution(request, evidence, candidate, candidates) {
3282
+ function isSameSnapshotCategoricalExtensionForMetric(candidate, metricCandidate) {
3283
+ const extension = candidate.sameSnapshotRoleExtension;
3284
+ if (!extension
3285
+ || extension.version !== 1
3286
+ || extension.role !== 'categorical_dimension'
3287
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
3288
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
3289
+ || candidate.kind !== 'semantic_member'
3290
+ || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
3291
+ return false;
3292
+ const metricIds = new Set([
3293
+ metricCandidate.id,
3294
+ metricCandidate.qualifiedId,
3295
+ normalizeEvidenceAnalyticalCapability(metricCandidate).capability?.metricId,
3296
+ ].filter((id) => Boolean(id)));
3297
+ if (!metricIds.has(extension.metricId))
3298
+ return false;
3299
+ const capability = normalizeEvidenceAnalyticalCapability(metricCandidate).capability;
3300
+ return Boolean(capability?.dimensions.some((dimension) => dimension.dimensionId === extension.dimensionId
3301
+ && dimension.supportedRoles.includes('group_by')));
3302
+ }
3303
+ /**
3304
+ * A model chooses from the bounded package, but it cannot remove a unique
3305
+ * host-required categorical grouping whose exact qualified field is already
3306
+ * declared by the selected metric's immutable capability. This is a binding,
3307
+ * not a semantic guess: zero or multiple capability-backed cards leave the
3308
+ * requirement unresolved for the normal clarify/gap path.
3309
+ */
3310
+ function hostBoundCategoricalExtensions(input) {
3311
+ const bound = [];
3312
+ for (const requested of categoricalDimensionRequirementTerms(input.requirements)) {
3313
+ const matches = input.candidates.filter((candidate) => isSameSnapshotCategoricalExtensionForMetric(candidate, input.metricCandidate)
3314
+ && normalizeMetricPhrase(candidate.sameSnapshotRoleExtension.requestedTerm)
3315
+ === normalizeMetricPhrase(requested));
3316
+ if (matches.length === 1)
3317
+ bound.push(matches[0]);
3318
+ }
3319
+ return bound.filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
3320
+ }
3321
+ function directResolution(request, evidence, candidate, candidates, selectedDimensionIds = [], selectedDimensionConceptIds = selectedDimensionIds) {
3322
+ // Keep deterministic/direct routing on the same host-owned tuple as the
3323
+ // model path. Raw parser terms are retrieval hints only: they may not turn a
3324
+ // normalized business alias back into a phantom metric or dimension after
3325
+ // the meaning boundary has been intentionally skipped.
3326
+ const requirementSeed = buildAnalyticalRequirementSeedV1({
3327
+ question: request.question,
3328
+ parsedIntent: evidence.parsedIntent,
3329
+ fiscalCalendar: declaredFiscalCalendar(evidence, candidates),
3330
+ });
3331
+ const hostOwnedEvidence = {
3332
+ ...evidence,
3333
+ parsedIntent: {
3334
+ ...evidence.parsedIntent,
3335
+ measures: requirementSeed.queryIntent.measures,
3336
+ dimensions: requirementSeed.queryIntent.dimensions,
3337
+ filters: requirementSeed.queryIntent.filters,
3338
+ ...(requirementSeed.queryIntent.timeRange ? { timeRange: requirementSeed.queryIntent.timeRange } : {}),
3339
+ ...(requirementSeed.queryIntent.timeGrain ? { timeGrain: requirementSeed.queryIntent.timeGrain } : {}),
3340
+ ...(requirementSeed.queryIntent.order ? { order: requirementSeed.queryIntent.order } : {}),
3341
+ ...(requirementSeed.queryIntent.limit !== undefined ? { limit: requirementSeed.queryIntent.limit } : {}),
3342
+ },
3343
+ };
916
3344
  const inferredQuestionType = questionTypeFromText(request.question);
917
3345
  const questionType = inferredQuestionType === 'definition'
918
3346
  && candidate.kind === 'semantic_metric'
@@ -922,23 +3350,92 @@ function directResolution(request, evidence, candidate, candidates) {
922
3350
  && !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
923
3351
  ? 'value'
924
3352
  : inferredQuestionType;
925
- const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
3353
+ const metricCandidates = explicitlyRequestedMetricCandidates(request.question, hostOwnedEvidence, candidate, candidates);
3354
+ const requirements = requirementSeed.requirements;
3355
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
3356
+ metricCandidate: candidate,
3357
+ candidates,
3358
+ requirements,
3359
+ });
3360
+ const resolvedSelectedDimensionIds = [...new Set([
3361
+ ...selectedDimensionIds,
3362
+ ...hostBoundExtensions.map((item) => item.qualifiedId ?? item.id),
3363
+ ])];
926
3364
  const analyticalFrame = buildDeterministicAnalyticalFrame({
927
3365
  question: request.question,
928
3366
  questionType,
929
- evidence,
3367
+ evidence: hostOwnedEvidence,
930
3368
  metricCandidate: candidate,
931
3369
  metricCandidates,
3370
+ entityTerms: requirements.entityTerms,
3371
+ entityDisplayTerms: requirements.entityDisplayTerms,
3372
+ selectedDimensionIds: resolvedSelectedDimensionIds,
932
3373
  candidates,
933
3374
  });
934
- const queryIntent = defaultQueryIntent(evidence);
3375
+ const defaultIntent = defaultQueryIntent(hostOwnedEvidence);
3376
+ // `top` without a count is a deterministic product convention, not a
3377
+ // missing business meaning. Bind the default into the direct execution
3378
+ // nomination while the typed requirements retain `defaultedLimit: true` for
3379
+ // the answer/receipt to disclose the assumption.
3380
+ const rankedDefaultIntent = requirements.ranking
3381
+ ? {
3382
+ ...defaultIntent,
3383
+ order: defaultIntent.order ?? (requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'),
3384
+ limit: defaultIntent.limit ?? requirements.ranking.limit,
3385
+ }
3386
+ : defaultIntent;
3387
+ // An exact authored certified example has already proved this block's own
3388
+ // output contract. Parser wording such as "food and drink" describes the
3389
+ // values of the block's declared `category` output; it must not manufacture
3390
+ // two literal dimensions and overrule that local contract. Only replace
3391
+ // dimensions when the selected block explicitly declares them, while the
3392
+ // strict requested-measure check remains unchanged.
3393
+ const exactCertifiedExample = candidate.kind === 'certified_block'
3394
+ && candidate.exactMatch
3395
+ && candidate.compatibility === 'compatible'
3396
+ && candidate.analyticalFitClass === 'exact'
3397
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
3398
+ && (candidate.dimensions?.length ?? 0) > 0;
3399
+ const queryIntent = exactCertifiedExample
3400
+ ? { ...rankedDefaultIntent, dimensions: candidate.dimensions ?? [] }
3401
+ : rankedDefaultIntent;
3402
+ // Preserve a selected semantic field as a typed dimension binding rather
3403
+ // than folding its label into the question. The ID comes from the selected
3404
+ // metric's capability contract, so the immutable plan will revalidate it
3405
+ // against the same snapshot before it can freeze.
3406
+ const selectedQueryDimensions = analyticalFrame?.dimensions
3407
+ // Server-issued structured selections may add their qualified identity to
3408
+ // the intent carrier. A host-bound same-snapshot extension instead binds
3409
+ // through the V2 frame below; adding its ID as a second text dimension
3410
+ // would create a duplicate request (`region` plus its qualified field).
3411
+ .filter((binding) => selectedDimensionIds.includes(binding.dimensionId))
3412
+ .map((binding) => binding.dimensionId) ?? [];
3413
+ // A direct host resolution can bind a categorical capability dimension from
3414
+ // the current question without a model-selected member card. Carry the
3415
+ // *actual* same-snapshot card into the resolution as well, otherwise the
3416
+ // immutable solver sees a valid V2 frame but cannot prove the selected
3417
+ // MetricFlow grouping evidence and incorrectly marks the tuple partial.
3418
+ // This is not a fuzzy name lookup: the candidate must be the dimension's
3419
+ // own qualified identity or the already-recorded same-snapshot extension.
3420
+ const hostBoundDimensionConceptIds = analyticalFrame?.dimensions.flatMap((binding) => hostBoundExtensions
3421
+ .filter((item) => (item.qualifiedId ?? item.id) === binding.dimensionId)
3422
+ .map((item) => item.id)) ?? [];
3423
+ const queryIntentWithSelectedDimensions = selectedQueryDimensions.length > 0
3424
+ ? {
3425
+ ...queryIntent,
3426
+ dimensions: [...new Set([
3427
+ ...queryIntent.dimensions,
3428
+ ...selectedQueryDimensions,
3429
+ ])],
3430
+ }
3431
+ : queryIntent;
935
3432
  const memberCandidates = candidates.filter((item) => {
936
3433
  if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
937
3434
  return false;
938
3435
  const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
939
- return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
3436
+ return queryIntentWithSelectedDimensions.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
940
3437
  });
941
- const canonicalFilters = queryIntent.filters.map((filter) => {
3438
+ const canonicalFilters = queryIntentWithSelectedDimensions.filters.map((filter) => {
942
3439
  const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
943
3440
  .map(normalizeMetricPhrase)
944
3441
  .includes(normalizeMetricPhrase(filter.value)));
@@ -947,18 +3444,138 @@ function directResolution(request, evidence, candidate, candidates) {
947
3444
  return {
948
3445
  interpretedQuestion: request.question,
949
3446
  questionType,
950
- selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
3447
+ selectedConceptIds: [...metricCandidates, ...memberCandidates]
3448
+ .map((item) => item.id)
3449
+ .concat(selectedDimensionConceptIds, hostBoundDimensionConceptIds)
3450
+ .filter((id, index, all) => all.indexOf(id) === index),
951
3451
  recommendedExecutionId: candidate.id,
952
- queryIntent: { ...queryIntent, filters: canonicalFilters },
3452
+ queryIntent: { ...queryIntentWithSelectedDimensions, filters: canonicalFilters },
953
3453
  rejectedCandidates: [],
954
3454
  confidence: "high",
955
3455
  missingInformation: [],
956
3456
  recommendedRoute: routeForEvidenceCandidate(candidate),
3457
+ // Direct/exact routing deliberately bypasses the model, not the host
3458
+ // meaning boundary. Carry the same immutable seed into plan binding so
3459
+ // display-key projection and downstream receipts cannot fall back to raw
3460
+ // parser wording.
3461
+ hostRequirementSeed: requirementSeed,
3462
+ ...(selectedDimensionIds.length > 0
3463
+ ? { structuredDimensionIds: [...new Set(selectedDimensionIds)] }
3464
+ : {}),
957
3465
  ...(analyticalFrame ? { analyticalFrame } : {}),
958
3466
  };
959
3467
  }
3468
+ /**
3469
+ * Build the V2 execution frame after the one candidate-ID meaning call.
3470
+ *
3471
+ * The model's response has already passed package membership validation. It
3472
+ * may therefore identify a supplied semantic metric and supplied semantic
3473
+ * members, but it is never allowed to author the frame itself. This helper
3474
+ * rebuilds the frame from the immutable host seed plus those qualified
3475
+ * identities, exactly as {@link directResolution} does for a zero-call path.
3476
+ *
3477
+ * Keeping this boundary here is important: a candidate-only model response
3478
+ * used to bind a perfectly valid semantic tuple into a V1 plan. V1 plans
3479
+ * bypass the immutable analytical execution graph and fall into the legacy
3480
+ * semantic SQL path, where generic aggregation validation cannot prove a
3481
+ * MetricFlow capability. A host-built V2 frame keeps the selected semantic
3482
+ * route compiler-owned without letting the model change the requested tuple.
3483
+ */
3484
+ function attachHostOwnedAnalyticalFrame(input) {
3485
+ const { request, evidence, candidates, requirementSeed, resolution } = input;
3486
+ if (resolution.recommendedRoute !== 'semantic' || resolution.questionType === 'definition') {
3487
+ return resolution;
3488
+ }
3489
+ const matchesIdentity = (candidate, identity) => Boolean(identity && (candidate.id === identity || candidate.qualifiedId === identity));
3490
+ const metricCandidate = candidates.find((candidate) => candidate.kind === 'semantic_metric'
3491
+ && matchesIdentity(candidate, resolution.recommendedExecutionId))
3492
+ ?? candidates.find((candidate) => candidate.kind === 'semantic_metric'
3493
+ && resolution.selectedConceptIds.some((identity) => matchesIdentity(candidate, identity)));
3494
+ if (!metricCandidate || normalizeEvidenceAnalyticalCapability(metricCandidate).status !== 'complete') {
3495
+ return resolution;
3496
+ }
3497
+ // Parsed intent is retrieval evidence only. The deterministic frame reads
3498
+ // the same host-owned seed that the plan will later bind, so stale retrieval
3499
+ // terms cannot re-enter as a metric, dimension, filter, output, or time
3500
+ // constraint merely because the model selected an otherwise valid card.
3501
+ const hostOwnedEvidence = {
3502
+ ...evidence,
3503
+ parsedIntent: {
3504
+ ...evidence.parsedIntent,
3505
+ measures: [...requirementSeed.queryIntent.measures],
3506
+ dimensions: [...requirementSeed.queryIntent.dimensions],
3507
+ filters: requirementSeed.queryIntent.filters.map((filter) => ({ ...filter })),
3508
+ ...(requirementSeed.queryIntent.timeRange
3509
+ ? { timeRange: requirementSeed.queryIntent.timeRange }
3510
+ : {}),
3511
+ ...(requirementSeed.queryIntent.timeGrain
3512
+ ? { timeGrain: requirementSeed.queryIntent.timeGrain }
3513
+ : {}),
3514
+ ...(requirementSeed.queryIntent.order
3515
+ ? { order: requirementSeed.queryIntent.order }
3516
+ : {}),
3517
+ ...(requirementSeed.queryIntent.limit !== undefined
3518
+ ? { limit: requirementSeed.queryIntent.limit }
3519
+ : {}),
3520
+ },
3521
+ };
3522
+ const modelSelectedDimensionIds = resolution.selectedConceptIds.flatMap((identity) => {
3523
+ const candidate = candidates.find((item) => matchesIdentity(item, identity));
3524
+ return candidate?.kind === 'semantic_member'
3525
+ ? [candidate.qualifiedId ?? candidate.id]
3526
+ : [];
3527
+ });
3528
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
3529
+ metricCandidate,
3530
+ candidates,
3531
+ requirements: requirementSeed.requirements,
3532
+ });
3533
+ const selectedDimensionIds = [...new Set([
3534
+ ...modelSelectedDimensionIds,
3535
+ ...hostBoundExtensions.map((candidate) => candidate.qualifiedId ?? candidate.id),
3536
+ ])];
3537
+ const analyticalFrame = buildDeterministicAnalyticalFrame({
3538
+ question: requirementSeed.sourceQuestion,
3539
+ questionType: resolution.questionType,
3540
+ evidence: hostOwnedEvidence,
3541
+ metricCandidate,
3542
+ metricCandidates: explicitlyRequestedMetricCandidates(requirementSeed.sourceQuestion, hostOwnedEvidence, metricCandidate, candidates),
3543
+ entityTerms: requirementSeed.requirements.entityTerms,
3544
+ entityDisplayTerms: requirementSeed.requirements.entityDisplayTerms,
3545
+ selectedDimensionIds,
3546
+ candidates,
3547
+ });
3548
+ if (!analyticalFrame)
3549
+ return resolution;
3550
+ const hostBoundConceptIds = hostBoundExtensions.map((candidate) => candidate.id);
3551
+ const selectedConceptIds = [...new Set([
3552
+ ...resolution.selectedConceptIds,
3553
+ ...hostBoundConceptIds,
3554
+ ])];
3555
+ const overrideReceipts = hostBoundConceptIds.length > 0
3556
+ ? [
3557
+ ...(resolution.overrideReceipts ?? []),
3558
+ {
3559
+ version: 1,
3560
+ field: 'candidate_selection',
3561
+ action: 'host_preserved',
3562
+ reason: 'A unique, same-snapshot MetricFlow grouping field is required by the host-owned categorical dimension and cannot be removed by a model omission.',
3563
+ candidateIds: hostBoundConceptIds,
3564
+ },
3565
+ ]
3566
+ : resolution.overrideReceipts;
3567
+ return {
3568
+ ...resolution,
3569
+ selectedConceptIds,
3570
+ analyticalFrame,
3571
+ ...(overrideReceipts?.length ? { overrideReceipts } : {}),
3572
+ };
3573
+ }
960
3574
  function explicitlyRequestedMetricCandidates(question, evidence, primary, candidates) {
961
- const requested = evidence.parsedIntent?.measures ?? [];
3575
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
3576
+ const requested = requirements.ranking?.metricTerms.length
3577
+ ? requirements.ranking.metricTerms
3578
+ : requirements.measures;
962
3579
  const requestedTerms = [...new Set(requested.map(normalizeMetricPhrase).filter(Boolean))];
963
3580
  // A single business metric can be retrieved alongside technical dbt measure
964
3581
  // shims and registry aliases that share its words. Those are execution
@@ -975,6 +3592,8 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
975
3592
  const metrics = candidates.filter((candidate) => {
976
3593
  if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
977
3594
  return false;
3595
+ if (candidateConflictsWithExplicitRankingMeasure(candidate, requirements))
3596
+ return false;
978
3597
  if (!normalizeEvidenceAnalyticalCapability(candidate).capability)
979
3598
  return false;
980
3599
  if (candidate.id === primary.id)
@@ -1071,7 +3690,8 @@ mayAssumeInterpretation = true) {
1071
3690
  }
1072
3691
  const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
1073
3692
  && candidate.compatibility !== "incompatible"
1074
- && rankingCandidates.includes(candidate));
3693
+ && rankingCandidates.includes(candidate)
3694
+ && candidateMayTerminateCertifiedForMeasures(candidate, evidence.parsedIntent?.measures ?? []));
1075
3695
  if (exactCompatible.length === 1 &&
1076
3696
  !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
1077
3697
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
@@ -1085,13 +3705,36 @@ mayAssumeInterpretation = true) {
1085
3705
  // `type: simple` metric, the measure it wraps, and the model that holds them
1086
3706
  // were offered as three competing "meanings" of the same number.
1087
3707
  const best = mayAssumeInterpretation
1088
- ? bestGovernedInterpretation(request.question, rankingCandidates)
3708
+ ? bestGovernedInterpretation(request.question, rankingCandidates, evidence.parsedIntent?.measures ?? [])
1089
3709
  : undefined;
1090
3710
  if (best) {
1091
3711
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
1092
3712
  }
1093
3713
  return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
1094
3714
  }
3715
+ /**
3716
+ * A bounded meaning call may return malformed JSON or a syntactically valid
3717
+ * low-confidence response with no candidate binding. It must not override a
3718
+ * plan the host can prove from one exact current-turn semantic identity. The
3719
+ * predicate deliberately requires a frozen executable plan: ordinary metric
3720
+ * ambiguity, a coverage gap, policy denial, and every unfrozen candidate still
3721
+ * take their existing clarification/block paths.
3722
+ */
3723
+ function isFrozenExecutableHostFallback(decision) {
3724
+ return decision.analyticalCascadeDecision?.planFrozen === true
3725
+ && Boolean(decision.resolvedAnalyticalPlan)
3726
+ && !decision.requiresClarification
3727
+ && !decision.terminalOutcome;
3728
+ }
3729
+ function meaningResolutionHasNoBinding(resolution) {
3730
+ return resolution.selectedConceptIds.length === 0
3731
+ && !resolution.recommendedExecutionId
3732
+ && (resolution.emptyCandidateBinding === true || (!resolution.clarifyingQuestion
3733
+ && resolution.recommendedRoute !== 'clarify'
3734
+ && resolution.compatibilityOutcome !== 'clarify'
3735
+ && resolution.missingInformation.length === 0
3736
+ && resolution.rejectedCandidates.length === 0));
3737
+ }
1095
3738
  /** Leaf identity of a governed candidate, ignoring its source qualification. */
1096
3739
  function candidateLeafName(candidate) {
1097
3740
  const identity = candidate.qualifiedId ?? candidate.id;
@@ -1244,7 +3887,7 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
1244
3887
  * question made ordinary lookups feel like an interrogation, and most of those
1245
3888
  * questions had no real ambiguity behind them.
1246
3889
  */
1247
- export function bestGovernedInterpretation(question, candidates) {
3890
+ export function bestGovernedInterpretation(question, candidates, requestedMeasures = []) {
1248
3891
  // Take the best candidate that is actually executable, rather than refusing
1249
3892
  // because the top-ranked hit happens to be a descriptive modeling entity.
1250
3893
  // `compatibility: 'unknown'` is common for governed objects that execute
@@ -1254,7 +3897,9 @@ export function bestGovernedInterpretation(question, candidates) {
1254
3897
  return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
1255
3898
  && (candidate.kind === 'certified_block'
1256
3899
  || candidate.kind === 'semantic_metric'
1257
- || candidate.kind === 'semantic_member'));
3900
+ || candidate.kind === 'semantic_member')
3901
+ && (candidate.kind !== 'certified_block'
3902
+ || certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures)));
1258
3903
  }
1259
3904
  /**
1260
3905
  * Retrieval may nominate qualified candidates, but only the resolved plan may
@@ -1272,8 +3917,69 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1272
3917
  candidateCount: 0,
1273
3918
  candidateIds: [],
1274
3919
  };
3920
+ // This generic fallback has no provider/plan-issued role target yet. Build
3921
+ // its strict option roles from the user's words only; inherited parsed-intent
3922
+ // hints can still guide later plan construction but must not hide every
3923
+ // valid compositional or dimension choice.
3924
+ const clarificationRequirements = buildAnalyticalRequirementSet({ question });
3925
+ const physicalRequirements = buildAnalyticalRequirementSet({
3926
+ question,
3927
+ parsedIntent: evidence?.parsedIntent,
3928
+ });
3929
+ const fallbackToPhysicalCascade = () => {
3930
+ if (!evidence)
3931
+ return undefined;
3932
+ const requiredPhysicalFieldTerms = [
3933
+ ...physicalRequirements.dimensions,
3934
+ ...physicalRequirements.entityTerms,
3935
+ ...physicalRequirements.entityDisplayTerms,
3936
+ ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
3937
+ ];
3938
+ return preFreezePhysicalCascadeDecision({
3939
+ base,
3940
+ evidence,
3941
+ candidates,
3942
+ question,
3943
+ requirements: physicalRequirements,
3944
+ missingTerms: physicalRequirements.dimensions,
3945
+ requiredPhysicalFieldTerms,
3946
+ messagePrefix: 'No certified, semantic, or governed relational candidate proved the complete requested tuple before plan freeze.',
3947
+ terminalCandidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id),
3948
+ });
3949
+ };
1275
3950
  if (eligible.length > 1) {
1276
- const choices = eligible.slice(0, 3);
3951
+ // Raw candidates can be complementary rather than competing. Do not ask a
3952
+ // person to pick between the table that supplies the display value and the
3953
+ // table that supplies the requested predicate when their qualified,
3954
+ // same-snapshot relationship proof already closes a safe exploration path.
3955
+ // The helper is deliberately stricter than generic retrieval: missing a
3956
+ // display/predicate role, a shared key, or relationship/fanout proof keeps
3957
+ // this exact clarification path intact.
3958
+ const composition = evidence
3959
+ ? complementaryExploratoryDecision(base, evidence, candidates, question)
3960
+ : undefined;
3961
+ if (composition)
3962
+ return composition;
3963
+ const choices = compatibleClarificationCandidates(eligible, clarificationRequirements).slice(0, 3);
3964
+ if (choices.length === 0) {
3965
+ return fallbackToPhysicalCascade() ?? (() => {
3966
+ const message = 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.';
3967
+ return {
3968
+ ...base,
3969
+ action: 'block',
3970
+ confidence: 1,
3971
+ source: 'heuristic',
3972
+ category: 'data_lookup',
3973
+ depth: 'quick',
3974
+ reason: message,
3975
+ requiresClarification: false,
3976
+ retrievalEvidence: trace,
3977
+ terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id) },
3978
+ resolvedAnalyticalPlan: undefined,
3979
+ meaningResolution: undefined,
3980
+ };
3981
+ })();
3982
+ }
1277
3983
  return {
1278
3984
  ...base,
1279
3985
  action: 'clarify',
@@ -1284,7 +3990,7 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1284
3990
  reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
1285
3991
  requiresClarification: true,
1286
3992
  clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
1287
- clarificationOptions: buildClarificationOptions(choices),
3993
+ clarificationOptions: buildClarificationOptions(choices, clarificationRequirements),
1288
3994
  retrievalEvidence: trace,
1289
3995
  resolvedAnalyticalPlan: undefined,
1290
3996
  meaningResolution: undefined,
@@ -1294,6 +4000,9 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1294
4000
  const message = candidateIds.length === 1
1295
4001
  ? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
1296
4002
  : 'No governed candidate proved the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability or choose a governed identifier before retrying.';
4003
+ const physicalFallback = fallbackToPhysicalCascade();
4004
+ if (physicalFallback)
4005
+ return physicalFallback;
1297
4006
  return {
1298
4007
  ...base,
1299
4008
  action: 'block',
@@ -1321,6 +4030,10 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
1321
4030
  */
1322
4031
  function deterministicPrePlanClarification(request, base, evidence, candidates) {
1323
4032
  const asksForRanking = questionTypeFromText(request.question) === 'ranking';
4033
+ const requirements = buildAnalyticalRequirementSet({
4034
+ question: request.question,
4035
+ parsedIntent: evidence.parsedIntent,
4036
+ });
1324
4037
  const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
1325
4038
  .map(normalizeMetricPhrase)
1326
4039
  .filter(Boolean);
@@ -1329,7 +4042,17 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1329
4042
  || hasStrongQualifiedMetricEvidence(candidates)
1330
4043
  || hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
1331
4044
  const retrievalEvidence = retrievalTrace(evidence, candidates);
1332
- const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
4045
+ // “by month” is a time role/grain, and FY26 is a fiscal-period filter. They
4046
+ // are not requests for literal `month`/`year` columns. A fiscal token still
4047
+ // requires a declared calendar at compilation time; this router simply must
4048
+ // not manufacture one or report an absence before relational/runtime context
4049
+ // has had a chance to bind the declared date role.
4050
+ // The host-owned requirements are the canonical reader tuple. Parsed intent
4051
+ // is only a retrieval seed: carrying its raw phrase here made "sales based
4052
+ // on the region" look like a literal physical dimension even after the
4053
+ // requirement seed had correctly normalized it to `region`.
4054
+ const requestedDimensions = requirements.dimensions
4055
+ .filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
1333
4056
  const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
1334
4057
  && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
1335
4058
  ? [normalizeMetricPhrase(filter.field)]
@@ -1347,9 +4070,29 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1347
4070
  .slice(0, 3);
1348
4071
  if (alternatives.length === 0) {
1349
4072
  // Bare rankings need a measure choice, not a dimension gap. Retain the
1350
- // no-options escape hatch for BCM-like retrieval failures.
4073
+ // no-options escape hatch only when the snapshot does not also expose a
4074
+ // typed structural request. For "top customers for perishable products",
4075
+ // a no-option ranking response previously hid the missing safe
4076
+ // customer→order→item→supply closure behind an answer-shaped dead end.
4077
+ // Record that pre-freeze gap instead; this still never invents a ranking
4078
+ // measure or a relationship.
1351
4079
  if (asksForRanking && !hasExplicitRankingMetric) {
1352
- return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
4080
+ const rankingClarification = bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
4081
+ if (rankingClarification.action === 'clarify'
4082
+ && (rankingClarification.clarificationOptions?.length ?? 0) > 0) {
4083
+ return rankingClarification;
4084
+ }
4085
+ return preFreezePhysicalCascadeDecision({
4086
+ base,
4087
+ evidence,
4088
+ candidates,
4089
+ question: request.question,
4090
+ requirements,
4091
+ missingTerms: missingDimensions,
4092
+ requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
4093
+ messagePrefix: `No governed ranking measure and no complete governed tuple proved ${missingDimensions.map((term) => `“${term}”`).join(' and ')} before plan freeze.`,
4094
+ requireRankingMetric: true,
4095
+ });
1353
4096
  }
1354
4097
  // Parsed-intent hints can include inherited/default dimensions that the
1355
4098
  // user never asked for. Only turn a missing field into a product-facing
@@ -1359,48 +4102,30 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1359
4102
  if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
1360
4103
  return undefined;
1361
4104
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1362
- const message = `The requested dimension ${requestedLabel} is not modeled in the certified blocks, semantic model, dbt manifest, or runtime schema searched for this question. Add or map ${requestedLabel} before retrying.`;
1363
- return {
1364
- ...base,
1365
- action: 'block',
1366
- confidence: 1,
1367
- reason: message,
1368
- source: 'heuristic',
1369
- category: 'data_lookup',
1370
- depth: 'quick',
1371
- requiresClarification: false,
1372
- retrievalEvidence,
1373
- terminalOutcome: {
1374
- kind: 'modeling_gap',
1375
- code: 'ANALYTICAL_MODELING_GAP',
1376
- message,
1377
- candidateIds: [],
1378
- },
1379
- meaningResolution: {
1380
- interpretedQuestion: request.question,
1381
- questionType: questionTypeFromText(request.question),
1382
- selectedConceptIds: [],
1383
- queryIntent: {
1384
- ...defaultQueryIntent(evidence),
1385
- measures: evidence.parsedIntent?.measures ?? [],
1386
- dimensions: evidence.parsedIntent?.dimensions ?? [],
1387
- filters: evidence.parsedIntent?.filters ?? [],
1388
- },
1389
- rejectedCandidates: [],
1390
- confidence: 'low',
1391
- missingInformation: [message],
1392
- recommendedRoute: 'clarify',
1393
- compatibilityOutcome: 'modeling_gap',
1394
- compatibilityFailures: missingDimensions.map((dimension) => ({
1395
- code: 'MISSING_DIMENSION',
1396
- field: dimension,
1397
- message: `${dimension} is not modeled.`,
1398
- candidateIds: [],
1399
- })),
1400
- },
1401
- resolvedAnalyticalPlan: undefined,
1402
- };
4105
+ const temporalNote = requirements.time?.fiscalPeriod
4106
+ ? ` ${requirements.time.fiscalPeriod} remains an unbound fiscal-period token until a declared calendar is available; DQL will not guess one.`
4107
+ : '';
4108
+ return preFreezePhysicalCascadeDecision({
4109
+ base,
4110
+ evidence,
4111
+ candidates,
4112
+ question: request.question,
4113
+ requirements,
4114
+ missingTerms: missingDimensions,
4115
+ requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
4116
+ messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
4117
+ });
1403
4118
  }
4119
+ // A single declared alternative can only be assumed when an executable
4120
+ // metric capability in this same snapshot explicitly contains it. A bare
4121
+ // standalone semantic member (for example `location_name` marked as an
4122
+ // alternative for `region`) is evidence of a possible wording, not proof
4123
+ // that it can safely answer this tuple. Without that closure, retain the
4124
+ // concise modeled-gap clarification rather than letting a later meaning
4125
+ // path substitute an unrelated field or metric.
4126
+ if (alternatives.length === 1
4127
+ && declaredDimensionAlternativeCompletesExecutableTuple(alternatives[0], candidates, requirements))
4128
+ return undefined;
1404
4129
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
1405
4130
  const alternativeLabels = alternatives.map(renderCandidateChoice);
1406
4131
  return {
@@ -1426,6 +4151,22 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
1426
4151
  }
1427
4152
  return undefined;
1428
4153
  }
4154
+ /**
4155
+ * The broad router terminal witness can carry reader-safe missing-role prose
4156
+ * for several gap classes. Observability persists only the relationship
4157
+ * variant, and only as an enumerated proof requirement, so an export cannot
4158
+ * accidentally turn an unresolved question term into trace content.
4159
+ */
4160
+ function cascadeTerminalRelationshipGap(gap) {
4161
+ if (!gap || gap.code !== 'MISSING_RELATIONSHIP')
4162
+ return undefined;
4163
+ return {
4164
+ version: 1,
4165
+ code: 'MISSING_RELATIONSHIP',
4166
+ requirement: 'certified_relationship_or_allocation_proof',
4167
+ witnessCandidateIds: [...new Set(gap.witnessCandidateIds)].sort().slice(0, 32),
4168
+ };
4169
+ }
1429
4170
  /**
1430
4171
  * A clarification with NO selectable options is unanswerable, and asking it is a
1431
4172
  * dead end rather than a safety measure.
@@ -1650,7 +4391,11 @@ function candidateProvesMetricTerm(candidate, requested) {
1650
4391
  if (candidate.compatibility === 'incompatible')
1651
4392
  return false;
1652
4393
  if (candidate.kind === 'certified_block') {
1653
- return candidate.compatibility === 'compatible';
4394
+ // A block's relevance/tag/example must never impersonate one of its
4395
+ // outputs. Only a declared block output may prove an explicit requested
4396
+ // metric (AGT-009/AGT-010).
4397
+ return candidate.compatibility === 'compatible'
4398
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, [requested]);
1654
4399
  }
1655
4400
  if (candidate.kind !== 'semantic_metric')
1656
4401
  return false;
@@ -1662,10 +4407,73 @@ function candidateProvesMetricTerm(candidate, requested) {
1662
4407
  .some((term) => metricTermsMatch(term, requested));
1663
4408
  }
1664
4409
  function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
4410
+ const extension = candidate.sameSnapshotRoleExtension;
4411
+ if (extension?.role === 'categorical_dimension'
4412
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
4413
+ || extension.basis === 'exact_metricflow_grouping_dimension')
4414
+ && missingDimensions.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
4415
+ return true;
1665
4416
  const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
1666
4417
  return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
1667
4418
  || facts.includes(`dimension alternative for ${requested}`));
1668
4419
  }
4420
+ function declaredDimensionAlternativeCompletesExecutableTuple(alternative, candidates, requirements) {
4421
+ // A substitution is executable only when a metric capability in the same
4422
+ // snapshot names this exact qualified dimension. Do not treat a lone
4423
+ // semantic-member card as an authorization to change the reader's request.
4424
+ if (requirements.measures.length === 0)
4425
+ return false;
4426
+ const alternativeId = alternative.qualifiedId ?? alternative.id;
4427
+ return candidates.some((candidate) => {
4428
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
4429
+ return false;
4430
+ if (!requirements.measures.every((measure) => candidateProvesMetricTerm(candidate, measure)))
4431
+ return false;
4432
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
4433
+ return normalized.status === 'complete'
4434
+ && Boolean(normalized.capability?.dimensions.some((dimension) => dimension.dimensionId === alternativeId));
4435
+ });
4436
+ }
4437
+ /**
4438
+ * A declared dimension alternative is safe only after the immutable plan has
4439
+ * bound that exact qualified field. This makes the one permitted vocabulary
4440
+ * substitution visible to the reader without letting a raw parser phrase or
4441
+ * model response authorize it.
4442
+ */
4443
+ function assumptionsForDeclaredDimensionAlternatives(input) {
4444
+ const requirements = buildAnalyticalRequirementSet({
4445
+ question: input.question,
4446
+ parsedIntent: input.evidence.parsedIntent,
4447
+ });
4448
+ const boundDimensionIds = new Set(input.plan.query.dimensions
4449
+ .filter((binding) => binding.status === 'resolved' && binding.qualifiedId)
4450
+ .map((binding) => binding.qualifiedId));
4451
+ const assumptions = [];
4452
+ for (const requested of requirements.dimensions) {
4453
+ const alternatives = input.candidates
4454
+ .filter(isCompatibleQualifiedMember)
4455
+ .filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, [requested]))
4456
+ .filter((candidate) => boundDimensionIds.has(candidate.qualifiedId ?? candidate.id));
4457
+ if (alternatives.length !== 1)
4458
+ continue;
4459
+ const alternative = alternatives[0];
4460
+ const extension = alternative.sameSnapshotRoleExtension;
4461
+ const assumption = assumeDominantCandidate({
4462
+ about: 'dimension',
4463
+ candidates: [{
4464
+ id: alternative.qualifiedId ?? alternative.id,
4465
+ label: alternative.name,
4466
+ score: alternative.relevanceScore,
4467
+ }],
4468
+ because: (chosen) => extension
4469
+ ? `“${requested}” is bound to ${chosen.label ?? chosen.id}, the sole same-snapshot MetricFlow grouping field shared by the selected metric.`
4470
+ : `“${requested}” is not modeled directly; ${chosen.label ?? chosen.id} is the sole compatible, declared alternative in this snapshot.`,
4471
+ });
4472
+ if (assumption)
4473
+ assumptions.push(assumption);
4474
+ }
4475
+ return assumptions;
4476
+ }
1669
4477
  /**
1670
4478
  * A missing optional parsed-intent projection must not erase positive metric
1671
4479
  * evidence already retrieved for the question. Only complete, compatible,
@@ -1763,12 +4571,50 @@ function renderCandidateChoice(candidate) {
1763
4571
  * metrics therefore remain ambiguous and continue to clarification/resolution.
1764
4572
  */
1765
4573
  function uniqueExecutableSemanticMetric(evidence, candidates) {
1766
- const requestedTokens = metricTokens(evidence.parsedIntent?.measures ?? []);
4574
+ const requestedMeasures = [...new Set((evidence.parsedIntent?.measures ?? [])
4575
+ .map(normalizeMetricPhrase)
4576
+ .filter(Boolean))];
4577
+ const requestedTokens = metricTokens(requestedMeasures);
1767
4578
  if (requestedTokens.size === 0)
1768
4579
  return undefined;
4580
+ // Do this before capability normalization. Local metadata can retain an
4581
+ // executable semantic metric card while its pre-plan compatibility hint is
4582
+ // still partial/unknown; a selected provider binding correctly reaches the
4583
+ // capability solver in that state. The exact leaf identity is nevertheless
4584
+ // a real, qualified current-snapshot meaning. Dedupe only representations
4585
+ // of the same qualified metric, never two same-named metrics from different
4586
+ // models, and let the immutable cascade prove execution afterwards.
4587
+ if (requestedMeasures.length === 1) {
4588
+ const requested = requestedMeasures[0];
4589
+ const exactByMetricIdentity = new Map();
4590
+ for (const candidate of candidates) {
4591
+ if (candidate.kind !== 'semantic_metric'
4592
+ || candidate.compatibility === 'incompatible'
4593
+ || candidate.eligible === false
4594
+ || !candidateMetricLeafIdentities(candidate).includes(requested))
4595
+ continue;
4596
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
4597
+ const metricIdentity = normalized.capability?.metricId
4598
+ ?? candidate.qualifiedId
4599
+ ?? candidate.id;
4600
+ const current = exactByMetricIdentity.get(metricIdentity);
4601
+ if (!current || candidate.relevanceScore > current.relevanceScore) {
4602
+ exactByMetricIdentity.set(metricIdentity, candidate);
4603
+ }
4604
+ }
4605
+ if (exactByMetricIdentity.size === 1) {
4606
+ return [...exactByMetricIdentity.values()][0];
4607
+ }
4608
+ }
1769
4609
  const byMetricId = new Map();
1770
4610
  for (const candidate of candidates) {
1771
- if (candidate.kind !== "semantic_metric" || candidate.compatibility !== "compatible")
4611
+ // Retrieval compatibility is a pre-plan hint. A current snapshot can mark
4612
+ // a MetricFlow metric `unknown`/`partial` until its selected grouping is
4613
+ // bound, even though the capability itself is complete. The direct route
4614
+ // below still solves the full tuple before freeze, so exclude only an
4615
+ // explicitly incompatible metric here rather than turning exact `revenue`
4616
+ // into an artificial choice against `product_revenue`.
4617
+ if (candidate.kind !== "semantic_metric" || candidate.compatibility === "incompatible")
1772
4618
  continue;
1773
4619
  if (/\bdbt\s+measure\b/i.test(candidate.provenance ?? ""))
1774
4620
  continue;
@@ -1787,8 +4633,33 @@ function uniqueExecutableSemanticMetric(evidence, candidates) {
1787
4633
  byMetricId.set(normalized.capability.metricId, candidate);
1788
4634
  }
1789
4635
  }
4636
+ // A malformed/empty candidate-ID meaning response may also safely fall back
4637
+ // when a complete capability proves one exact semantic metric identity from
4638
+ // the current-turn requirement seed. This is intentionally stricter than
4639
+ // the token fallback below: `order_items.revenue` has the leaf identity
4640
+ // `revenue`, whereas `order_items.product_revenue` is a different meaning
4641
+ // and must not force a clarification when the reader asked only for
4642
+ // revenue. Multi-metric questions retain their dedicated exact tuple path.
4643
+ if (requestedMeasures.length === 1) {
4644
+ const requested = requestedMeasures[0];
4645
+ const exactMatches = [...byMetricId.values()].filter((candidate) => candidateMetricLeafIdentities(candidate).includes(requested));
4646
+ if (exactMatches.length === 1)
4647
+ return exactMatches[0];
4648
+ }
1790
4649
  return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
1791
4650
  }
4651
+ function candidateMetricLeafIdentities(candidate) {
4652
+ return [...new Set([
4653
+ candidate.id,
4654
+ candidate.qualifiedId ?? '',
4655
+ candidate.name,
4656
+ ...(candidate.aliases ?? []),
4657
+ ].flatMap((identity) => {
4658
+ const normalized = normalizeMetricPhrase(identity);
4659
+ const leaf = normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity);
4660
+ return [normalized, leaf].filter(Boolean);
4661
+ }))];
4662
+ }
1792
4663
  function metricTokens(values) {
1793
4664
  const ignored = new Set([
1794
4665
  "a", "an", "and", "as", "at", "by", "current", "for", "from", "is",
@@ -1821,9 +4692,10 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
1821
4692
  * against a uniquely compatible certified block; a second compatible block or
1822
4693
  * metric still goes to the bounded AI resolver.
1823
4694
  */
1824
- function dominantCompatibleGovernedCandidate(candidates) {
4695
+ function dominantCompatibleGovernedCandidate(candidates, requestedMeasures = []) {
1825
4696
  const compatible = candidates.filter((candidate) => candidate.compatibility === "compatible"
1826
- && (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member"));
4697
+ && (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member")
4698
+ && candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures));
1827
4699
  if (compatible.length === 0)
1828
4700
  return undefined;
1829
4701
  const best = compatible[0];
@@ -1845,13 +4717,19 @@ function dominantCompatibleGovernedCandidate(candidates) {
1845
4717
  const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
1846
4718
  return hasExecutableCompetitor ? undefined : best;
1847
4719
  }
1848
- function authoritativeExactCertifiedExample(candidates) {
4720
+ function authoritativeExactCertifiedExample(candidates, requestedMeasures = []) {
1849
4721
  const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
1850
4722
  && candidate.exactMatch
1851
4723
  && candidate.compatibility === 'compatible'
1852
- && candidate.analyticalFitClass === 'exact');
4724
+ && candidate.analyticalFitClass === 'exact'
4725
+ && certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures));
1853
4726
  return exact.length === 1 ? exact[0] : undefined;
1854
4727
  }
4728
+ /** Certified output coverage is a Tier-1 invariant; other routes pass through. */
4729
+ function candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures) {
4730
+ return candidate.kind !== 'certified_block'
4731
+ || certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures);
4732
+ }
1855
4733
  function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
1856
4734
  if (!base.followsUp)
1857
4735
  return false;
@@ -1884,6 +4762,78 @@ function rethrowCancellation(error, ...signals) {
1884
4762
  if (error instanceof Error && error.name === "AbortError")
1885
4763
  throw error;
1886
4764
  }
4765
+ /**
4766
+ * Admit the one narrowly typed extension produced by metadata only when it
4767
+ * closes an actually unmet requested categorical role and its source metric is
4768
+ * already in the compact meaning package. The extension was derived from the
4769
+ * same immutable metric capability snapshot; this function does not search,
4770
+ * infer a join, or create an alias. Multiple candidates for a role stay an
4771
+ * ambiguity instead of being silently ranked into a new business meaning.
4772
+ */
4773
+ function sameSnapshotRoleTargetedMeaningExtensions(input) {
4774
+ const extensionsByRole = new Map();
4775
+ const requestedCategoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
4776
+ for (const candidate of input.clarificationCandidates) {
4777
+ const extension = candidate.sameSnapshotRoleExtension;
4778
+ if (!extension
4779
+ || extension.version !== 1
4780
+ || extension.role !== 'categorical_dimension'
4781
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
4782
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
4783
+ || candidate.kind !== 'semantic_member'
4784
+ || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
4785
+ continue;
4786
+ if (!requestedCategoricalTerms.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
4787
+ continue;
4788
+ // A raw/dbt column can lexically satisfy "product category", but it is
4789
+ // not a MetricFlow binding for this semantic metric. Do not let that
4790
+ // broad retrieval hit suppress the exact same-snapshot semantic member:
4791
+ // otherwise a package contains only the raw column, the model cannot bind
4792
+ // the metric-native grouping, and the cascade falsely reports absence.
4793
+ // An already-admitted semantic member remains sufficient and keeps this
4794
+ // extension bounded to genuinely unmet semantic roles.
4795
+ if (input.candidates.some((admitted) => admitted.kind === 'semantic_member'
4796
+ && candidateMatchesCategoricalDimensionRequirement(admitted, [extension.requestedTerm])))
4797
+ continue;
4798
+ const sourceMetricAdmitted = input.candidates.some((admitted) => admitted.kind === 'semantic_metric'
4799
+ && (admitted.id === extension.metricId || admitted.qualifiedId === extension.metricId));
4800
+ if (!sourceMetricAdmitted)
4801
+ continue;
4802
+ const role = normalizeMetricPhrase(extension.requestedTerm);
4803
+ const values = extensionsByRole.get(role) ?? [];
4804
+ if (!values.some((value) => value.id === candidate.id))
4805
+ values.push(candidate);
4806
+ extensionsByRole.set(role, values);
4807
+ }
4808
+ return [...extensionsByRole.values()]
4809
+ .flatMap((values) => values.length === 1 ? values : [])
4810
+ .sort((left, right) => left.id.localeCompare(right.id));
4811
+ }
4812
+ /**
4813
+ * Keep the role-targeted form of a qualified candidate when retrieval has
4814
+ * emitted both the ordinary catalog card and a same-snapshot extension with
4815
+ * the identical stable ID. The extension is not a second object or a new
4816
+ * join: it is the metadata proof explaining why that exact qualified object
4817
+ * closes a currently unmet role for a selected metric. Dropping it merely
4818
+ * because the less-specific catalog card appeared first makes the compact
4819
+ * meaning package lose a declared MetricFlow grouping and can manufacture a
4820
+ * false coverage gap.
4821
+ */
4822
+ function consolidateClarificationCandidates(input) {
4823
+ const byId = new Map();
4824
+ for (const candidate of input.candidates) {
4825
+ if (candidate.eligible === false
4826
+ || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
4827
+ continue;
4828
+ const existing = byId.get(candidate.id);
4829
+ if (!existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension)) {
4830
+ // Map replacement preserves the original stable ordering while retaining
4831
+ // the stricter same-snapshot proof for this exact candidate identity.
4832
+ byId.set(candidate.id, candidate);
4833
+ }
4834
+ }
4835
+ return [...byId.values()];
4836
+ }
1887
4837
  /**
1888
4838
  * Build a retrieval-first hybrid router. Narrow conversational/app preflight is
1889
4839
  * deterministic. Every other turn loads structured evidence before any
@@ -1922,7 +4872,7 @@ export function createHybridRouter(options = {}) {
1922
4872
  const base = deterministic(request);
1923
4873
  // The only pre-retrieval exits are deliberately narrow and unambiguously
1924
4874
  // non-analytical. App composition has its own catalog-grounded workflow.
1925
- if (base.action === "converse" || base.action === "compose_app") {
4875
+ if (!request.selectedEvidenceId && (base.action === "converse" || base.action === "compose_app")) {
1926
4876
  return { ...base, source: base.source ?? "heuristic" };
1927
4877
  }
1928
4878
  const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
@@ -1941,6 +4891,16 @@ export function createHybridRouter(options = {}) {
1941
4891
  }
1942
4892
  }
1943
4893
  if (evidence) {
4894
+ // The retrieval parser may carry broad search phrases such as
4895
+ // `count_for_each_customer`. They are useful before retrieval, but
4896
+ // they are not executable measures. Normalize once at the router
4897
+ // boundary so every downstream plan/meaning path consumes the same
4898
+ // typed measure + entity/dimension requirements.
4899
+ evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
4900
+ // Keep the complete snapshot result for trace-only lifecycle evidence
4901
+ // before canonicalization and bounded meaning-package admission prune it.
4902
+ const retrievedCandidates = evidence.candidates;
4903
+ traceRetrievalLanesBeforePruning(request, evidence, retrievedCandidates);
1944
4904
  evidence = {
1945
4905
  ...evidence,
1946
4906
  candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
@@ -1951,32 +4911,216 @@ export function createHybridRouter(options = {}) {
1951
4911
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
1952
4912
  : {}),
1953
4913
  };
1954
- let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
1955
- // A structured clarification selection is authoritative identity input,
1956
- // not a new fuzzy-search phrase. Keep it in the bounded package even if
1957
- // per-tier limits would otherwise trim it from a large catalog.
1958
- // Look in BOTH lists. The ranking-measure choices are supplemental
1959
- // clarification candidates, not execution candidates, so resolving the
1960
- // selection against `candidates` alone silently found nothing — the
1961
- // click looked identical to no click, the ranking gate fired again, and
1962
- // the same three options came back forever.
1963
- const selectedEvidence = request.selectedEvidenceId
1964
- ? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
1965
- .find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
4914
+ // Ranking measure identity is resolved from the same immutable
4915
+ // request/evidence frame that drives package reservation. A metric
4916
+ // may remain in broad retrieval context, but it cannot become a
4917
+ // clarification or meaning-selection alternative after an explicit
4918
+ // comparator already bound a different ranking measure.
4919
+ const persistedSelection = request.selectedEvidenceId
4920
+ ? persistedClarificationSelectionContext(request)
1966
4921
  : undefined;
1967
- if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
1968
- candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
1969
- .slice(0, options.maxMeaningCandidates ?? 12);
1970
- }
1971
- if (candidates.length > 0) {
1972
- // Clarification is local and never provider-bound, so it can inspect the
1973
- // complete already-retrieved set. Keep the smaller package below for
1974
- // any later meaning call.
1975
- const clarificationCandidates = [
4922
+ // A structured click is a continuation of the original server turn,
4923
+ // not a new parse of its display label. Retain the typed partial frame
4924
+ // (ranking/time included) before candidate pruning can affect it.
4925
+ const continuationQuestion = persistedSelection?.serverIssued && persistedSelection.sourceQuestion
4926
+ ? persistedSelection.sourceQuestion
4927
+ // A free-text turn is always a new source question. A browser may
4928
+ // retain clarificationSourceQuestion for display, but it must never
4929
+ // make stale retrieval intent or a previous result authoritative.
4930
+ : request.question;
4931
+ // Retrieval parser output is never an independent source of request
4932
+ // authority, but it can refine a host-owned source question when every
4933
+ // business term is demonstrably present in that question. In
4934
+ // particular, "active seats" is a legitimate current-turn metric
4935
+ // refinement for "Which workspaces have the most active seats?";
4936
+ // omitting it altogether forced the seed to erase an explicit metric
4937
+ // before the model had a chance only to bind it. The shared
4938
+ // requirement builder rejects stale parser terms (for example a prior
4939
+ // rollover balance, member filter, or top-N), so this remains a
4940
+ // source-question-plus-server-selection tuple rather than retrieval
4941
+ // intent becoming plan authority.
4942
+ // A server-composed child Ask (currently a Research hypothesis) may
4943
+ // carry an exact host-owned seed. Planner prose is not user wording:
4944
+ // reparsing it can turn an asset namespace or explanatory phrase into
4945
+ // a second requested measure. Accept the seed only when it belongs
4946
+ // to this exact source question and there is no structured
4947
+ // continuation to merge; public request parsers never hydrate this
4948
+ // host-only field.
4949
+ const hostSeed = !request.selectedEvidenceId
4950
+ && request.hostRequirementSeed?.version === 1
4951
+ && request.hostRequirementSeed.sourceQuestion === continuationQuestion
4952
+ ? request.hostRequirementSeed
4953
+ : undefined;
4954
+ const sourceQuestionRequirements = hostSeed?.requirements
4955
+ ?? buildAnalyticalRequirementSet({
4956
+ question: continuationQuestion,
4957
+ parsedIntent: evidence.parsedIntent,
4958
+ });
4959
+ const analyticalRequirements = request.selectedEvidenceId
4960
+ ? mergeClarificationRequirements(sourceQuestionRequirements, persistedSelection?.serverIssued ? persistedSelection.requirements : undefined)
4961
+ : sourceQuestionRequirements;
4962
+ // The host freezes the reader's requested tuple before the bounded
4963
+ // meaning call. A model may bind only supplied candidate identities;
4964
+ // it cannot erase explicit outputs, ranking, time, or a new question's
4965
+ // independent scope by rephrasing the request.
4966
+ const requirementSeed = hostSeed
4967
+ ? hostSeed
4968
+ : buildAnalyticalRequirementSeedV1({
4969
+ question: continuationQuestion,
4970
+ parsedIntent: evidence.parsedIntent,
4971
+ requirements: analyticalRequirements,
4972
+ fiscalCalendar: declaredFiscalCalendar(evidence, evidence.candidates),
4973
+ });
4974
+ // Retrieval can retain more context internally; only this compact,
4975
+ // role-balanced package reaches the one meaning call. The question is
4976
+ // supplied so explicit revenue, entity labels, and time roles cannot be
4977
+ // pruned by unrelated lexical matches.
4978
+ let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, continuationQuestion);
4979
+ traceCandidateLifecycleBeforePruning(request, evidence, retrievedCandidates, candidates);
4980
+ // The complete already-retrieved set is retained only to validate a
4981
+ // server-issued stable selection before any route can fall through to
4982
+ // a generic/generated answer. New free-text choices are constrained to
4983
+ // the admitted package below, so a pruned candidate cannot reappear as
4984
+ // an unrelated clarification option.
4985
+ const clarificationCandidates = consolidateClarificationCandidates({
4986
+ candidates: [
1976
4987
  ...evidence.candidates,
1977
4988
  ...(evidence.clarificationCandidates ?? []),
1978
- ].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
1979
- const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
4989
+ ],
4990
+ requirements: analyticalRequirements,
4991
+ });
4992
+ // A structured clarification selection is identity input, not a new
4993
+ // fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
4994
+ // supplemental clarification candidates, not execution candidates.
4995
+ let selectedEvidence = request.selectedEvidenceId
4996
+ ? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
4997
+ : undefined;
4998
+ if (!selectedEvidence && request.selectedEvidenceId) {
4999
+ selectedEvidence = rehydrateServerIssuedCapabilityDimensionSelection({
5000
+ request,
5001
+ evidence,
5002
+ candidates: clarificationCandidates,
5003
+ });
5004
+ if (selectedEvidence)
5005
+ clarificationCandidates.push(selectedEvidence);
5006
+ }
5007
+ const structuredSelection = request.selectedEvidenceId
5008
+ ? validateStructuredClarificationSelection({
5009
+ request,
5010
+ evidence,
5011
+ candidates: clarificationCandidates,
5012
+ selected: selectedEvidence,
5013
+ })
5014
+ : undefined;
5015
+ if (structuredSelection && !structuredSelection.ok) {
5016
+ return invalidStructuredSelectionDecision({
5017
+ base,
5018
+ request,
5019
+ evidence,
5020
+ candidates: clarificationCandidates,
5021
+ selectedId: request.selectedEvidenceId,
5022
+ validation: structuredSelection,
5023
+ });
5024
+ }
5025
+ const selectedDimensionBinding = structuredSelection?.ok
5026
+ && structuredSelection.selection.kind === 'dimension'
5027
+ ? structuredSelection.selection
5028
+ : undefined;
5029
+ const stableSelectionCandidates = [
5030
+ ...(selectedDimensionBinding ? [selectedDimensionBinding.metricCandidate] : []),
5031
+ ...(selectedEvidence ? [selectedEvidence] : []),
5032
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
5033
+ // One host-authored, role-targeted extension may enter the same
5034
+ // snapshot package before absence is declared. It is eligible only
5035
+ // when a selected/retrieved semantic metric itself proves one unique
5036
+ // MetricFlow grouping dimension for the unmet business role. This is
5037
+ // not a lexical location→region rule and never creates a raw join.
5038
+ const roleTargetedExtensions = sameSnapshotRoleTargetedMeaningExtensions({
5039
+ candidates,
5040
+ clarificationCandidates,
5041
+ requirements: analyticalRequirements,
5042
+ });
5043
+ const extensions = [
5044
+ ...stableSelectionCandidates,
5045
+ ...roleTargetedExtensions,
5046
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index
5047
+ // A role-targeted card may intentionally share the stable ID of an
5048
+ // unscoped catalog card already admitted by generic relevance. In
5049
+ // that case replace the card in-place below so the provider sees the
5050
+ // same qualified object plus the immutable role proof; do not drop
5051
+ // the proof just because its base record was admitted first.
5052
+ && (() => {
5053
+ const existing = candidates.find((item) => item.id === candidate.id);
5054
+ return !existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension);
5055
+ })());
5056
+ if (extensions.length > 0) {
5057
+ // Keep the exact metric and the separately selected semantic member
5058
+ // together. The choice never becomes a synthetic metric merely to
5059
+ // fit the bounded meaning package.
5060
+ candidates = [...extensions, ...candidates.filter((candidate) => !extensions.some((extension) => extension.id === candidate.id))]
5061
+ .slice(0, Math.max(1, Math.min(16, options.maxMeaningCandidates ?? 16)));
5062
+ const observer = askTraceObserverForV1(request);
5063
+ for (const extension of extensions) {
5064
+ const roles = evidenceCandidateRoles(extension);
5065
+ for (const role of roles) {
5066
+ observer.recordCandidateDecision({
5067
+ // This must stay on the router's stable resolver identity. The
5068
+ // qualifiedId is display/provenance data and can differ from the
5069
+ // candidate id used by meaning validation and model selection.
5070
+ candidateId: extension.id,
5071
+ role,
5072
+ source: traceSourceForCandidate(extension),
5073
+ decision: 'extended',
5074
+ reasonCode: 'same_snapshot_extension',
5075
+ compatibilityCode: 'unknown',
5076
+ });
5077
+ }
5078
+ }
5079
+ }
5080
+ // Only package-admitted, role-compatible candidates may become a new
5081
+ // free-text clarification option. The complete snapshot is retained
5082
+ // above exclusively to validate a server-issued stable selection or
5083
+ // to prove a terminal safety boundary; it must never let a pruned,
5084
+ // unrelated metric re-enter the answer flow as a choice.
5085
+ const admittedClarificationCandidates = candidates.filter((candidate) => candidate.eligible !== false
5086
+ && candidate.compatibility !== 'incompatible'
5087
+ && !candidateConflictsWithExplicitRankingMeasure(candidate, analyticalRequirements));
5088
+ const attributionGap = attributionRequiredRelationshipGapDecision({
5089
+ request,
5090
+ base,
5091
+ evidence,
5092
+ requirements: analyticalRequirements,
5093
+ });
5094
+ if (attributionGap)
5095
+ return attributionGap;
5096
+ // The compact meaning package can be empty when every ranked card was
5097
+ // reserved for a role that the parser marked missing. A unique authored
5098
+ // certified example is still an authoritative snapshot fact, so it
5099
+ // must be considered before the package-length guard and before any
5100
+ // deterministic missing-dimension cascade.
5101
+ if (!request.selectedEvidenceId) {
5102
+ const authoredExample = authoritativeExactCertifiedExample(clarificationCandidates, evidence?.parsedIntent?.measures ?? []);
5103
+ if (authoredExample) {
5104
+ const candidatesForResolution = candidates.some((candidate) => candidate.id === authoredExample.id)
5105
+ ? candidates
5106
+ : [authoredExample, ...candidates];
5107
+ return routeDecisionForResolution(base, evidence, candidatesForResolution, directResolution(request, evidence, authoredExample, candidatesForResolution), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
5108
+ }
5109
+ }
5110
+ if (candidates.length > 0) {
5111
+ // Fiscal tokens are execution requirements, not a semantic guess.
5112
+ // Ask exactly once before a meaning call or plan freeze when the
5113
+ // snapshot has no declared fiscal calendar/date-role binding.
5114
+ const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
5115
+ if (fiscalClarification)
5116
+ return fiscalClarification;
5117
+ // A selected display/grouping field completes a persisted frame; the
5118
+ // matched semantic metric remains the only execution/measure
5119
+ // authority. A selected dimension must therefore never flow through
5120
+ // the metric-only direct-resolution path as the primary candidate.
5121
+ const explicit = selectedDimensionBinding?.metricCandidate
5122
+ ?? selectedEvidence
5123
+ ?? findExplicitEvidenceReference(request.question, candidates);
1980
5124
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
1981
5125
  || /@(metric|block|model|table|column)\(/i.test(request.question)));
1982
5126
  const shouldUseMeaningCall = requireMeaningCall
@@ -1989,7 +5133,7 @@ export function createHybridRouter(options = {}) {
1989
5133
  // execution shim as the answer and never let the meaning model see
1990
5134
  // the ranking entity/measure distinction.
1991
5135
  if (!shouldUseMeaningCall && !explicitMeaningBinding) {
1992
- const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5136
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
1993
5137
  if (deterministicClarification)
1994
5138
  return deterministicClarification;
1995
5139
  }
@@ -2000,9 +5144,26 @@ export function createHybridRouter(options = {}) {
2000
5144
  && !hasExplicitRankingMeasure(request.question, evidence)) {
2001
5145
  return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
2002
5146
  }
2003
- const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2004
- return selectedEvidence && decision.requiresClarification
2005
- ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
5147
+ const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates, selectedDimensionBinding ? [selectedDimensionBinding.dimensionId] : [], selectedDimensionBinding ? [selectedDimensionBinding.selectedDimensionId] : []), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
5148
+ // A valid stable semantic choice may still be pre-freeze-ineligible
5149
+ // in this local snapshot (for example, the semantic adapter cannot
5150
+ // execute the selected tuple). That is a normal cascade condition:
5151
+ // keep the chosen meaning, then consider the same-snapshot safe
5152
+ // exploratory path. An invalid/stale choice never reaches here, and
5153
+ // a frozen plan/policy denial is deliberately not downgraded.
5154
+ const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
5155
+ && decision.resolvedAnalyticalPlan?.capability === 'blocked'
5156
+ && decision.analyticalCascadeDecision?.planFrozen === false;
5157
+ return explicit
5158
+ && structuredSelection?.ok
5159
+ && (decision.requiresClarification || selectedPreFreezeModelingGap)
5160
+ ? continueCascadeAfterIncompleteSelection(base, evidence, candidates,
5161
+ // Preserve the display-key identity the user clicked in
5162
+ // user-facing gap/continuation evidence; the paired metric
5163
+ // remains recorded below as execution provenance.
5164
+ selectedEvidence ?? explicit, request.question, selectedDimensionBinding
5165
+ ? [selectedDimensionBinding.metricCandidate.id, selectedDimensionBinding.selectedDimensionId]
5166
+ : [explicit.id])
2006
5167
  : decision;
2007
5168
  }
2008
5169
  const multiMetricPrimary = !shouldUseMeaningCall
@@ -2011,29 +5172,14 @@ export function createHybridRouter(options = {}) {
2011
5172
  if (multiMetricPrimary) {
2012
5173
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2013
5174
  }
2014
- // THE FAST LANE. Evaluated regardless of `shouldUseMeaningCall`, so an
2015
- // exact certified hit short-circuits BEFORE the ~10s meaning call
2016
- // rather than paying for it. Previously this shortcut only applied
2017
- // when the call was already being skipped, which meant a perfect
2018
- // certified match — the cheapest, most certain answer DQL can give —
2019
- // was also one of the slowest.
2020
- //
2021
- // The precondition is deliberately the strictest one available:
2022
- // exactly one certified block, compatible, whose AUTHORED EXAMPLE the
2023
- // question matches. `meaning-evidence.ts` sets that flag only for an
2024
- // authored-example fit and explicitly notes it is the one signal that
2025
- // may grant this shortcut — lexical equality to a block name does not
2026
- // qualify.
2027
- const authoredExample = authoritativeExactCertifiedExample(candidates);
2028
- if (authoredExample) {
2029
- return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
2030
- }
2031
- const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
5175
+ const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch
5176
+ && candidate.compatibility !== "incompatible"
5177
+ && candidateMayTerminateCertifiedForMeasures(candidate, evidence?.parsedIntent?.measures ?? [])) : [];
2032
5178
  if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
2033
5179
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
2034
5180
  }
2035
5181
  const dominant = !shouldUseMeaningCall
2036
- ? dominantCompatibleGovernedCandidate(candidates)
5182
+ ? dominantCompatibleGovernedCandidate(candidates, evidence.parsedIntent?.measures ?? [])
2037
5183
  : undefined;
2038
5184
  if (dominant) {
2039
5185
  return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
@@ -2056,33 +5202,90 @@ export function createHybridRouter(options = {}) {
2056
5202
  if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
2057
5203
  return softBoundaryDecision(request, base, 'clarify');
2058
5204
  }
2059
- const resolution = options.resolveMeaning
2060
- ? await options.resolveMeaning({
2061
- question: request.question,
2062
- history: effectiveConversationHistory(request),
2063
- // The resolver/provider receives the same bounded evidence
2064
- // package as its candidate argument. Supplemental qualified
2065
- // cards are a host-only clarification aid and must not leak
2066
- // through this richer carrier.
2067
- evidence: {
2068
- ...evidence,
5205
+ let resolution;
5206
+ try {
5207
+ resolution = options.resolveMeaning
5208
+ ? await options.resolveMeaning({
5209
+ question: requirementSeed.sourceQuestion,
5210
+ history: request.conversationBinding && request.conversationBinding !== 'none'
5211
+ ? effectiveConversationHistory(request)
5212
+ : undefined,
5213
+ // The resolver/provider receives the same bounded evidence
5214
+ // package as its candidate argument. Supplemental qualified
5215
+ // cards are a host-only clarification aid and must not leak
5216
+ // through this richer carrier.
5217
+ evidence: {
5218
+ ...evidence,
5219
+ candidates,
5220
+ clarificationCandidates: undefined,
5221
+ },
2069
5222
  candidates,
2070
- clarificationCandidates: undefined,
2071
- },
2072
- candidates,
2073
- signal: request.signal ?? options.signal,
2074
- })
2075
- : options.complete
2076
- ? parseMeaningResolution(await options.complete({
2077
- system: buildMeaningSystemPrompt(),
2078
- user: buildMeaningUserPrompt(request, evidence, candidates),
5223
+ requirementSeed,
2079
5224
  signal: request.signal ?? options.signal,
2080
- }))
2081
- : undefined;
5225
+ })
5226
+ : options.complete
5227
+ ? parseMeaningResolution(await options.complete({
5228
+ system: buildMeaningSystemPrompt(),
5229
+ user: buildMeaningUserPrompt(request, evidence, candidates, requirementSeed),
5230
+ signal: request.signal ?? options.signal,
5231
+ request,
5232
+ phase: 'meaning_resolution',
5233
+ }), requirementSeed)
5234
+ : undefined;
5235
+ }
5236
+ catch (error) {
5237
+ throw error;
5238
+ }
2082
5239
  if (resolution) {
2083
- const validated = validateMeaningResolution(resolution, candidates);
5240
+ // A model is allowed to say that it cannot bind any supplied
5241
+ // card, but that is not a new business ambiguity by itself. If
5242
+ // the host can prove one exact current-turn semantic identity,
5243
+ // take the ordinary direct/cascade path instead of preserving an
5244
+ // empty selection as a clarification. This is intentionally
5245
+ // evaluated before merge/validation so the host never treats an
5246
+ // empty provider response as an authoritative analytical frame.
5247
+ if (meaningResolutionHasNoBinding(resolution)) {
5248
+ const hostFallback = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', true);
5249
+ if (isFrozenExecutableHostFallback(hostFallback)) {
5250
+ return remember(key, hostFallback);
5251
+ }
5252
+ }
5253
+ // Merge exactly once at the model boundary. Candidate IDs remain
5254
+ // subject to the bounded-package validator below; model route,
5255
+ // SQL-adjacent frame, rephrased question, and query intent never
5256
+ // become downstream authority.
5257
+ resolution = mergeMeaningResolutionWithRequirementSeed({
5258
+ seed: requirementSeed,
5259
+ resolution,
5260
+ candidates,
5261
+ });
5262
+ // Repair an incomplete certified nomination before generic
5263
+ // evidence validation. This preserves the real semantic choices
5264
+ // instead of turning a false certified selection into an opaque
5265
+ // invalid-evidence gap.
5266
+ const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
5267
+ const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures, { requirements: analyticalRequirements });
2084
5268
  if (validated.ok) {
2085
- const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
5269
+ // The meaning model may select only supplied candidate IDs. It
5270
+ // intentionally cannot return an analytical frame, because a
5271
+ // model-owned frame could add a metric, dimension, output, or
5272
+ // prior-turn scope after the host froze the requirement seed.
5273
+ //
5274
+ // Do not leave that candidate-only result on the legacy V1
5275
+ // path, though: a valid selected semantic metric plus its
5276
+ // selected supplied members must receive the same *host-built*
5277
+ // V2 frame as a zero-call/direct resolution. Otherwise the
5278
+ // router freezes a V1 semantic plan, the answer loop skips the
5279
+ // immutable semantic execution graph, and legacy SQL is
5280
+ // incorrectly checked against generic aggregation metadata.
5281
+ const frameBoundResolution = attachHostOwnedAnalyticalFrame({
5282
+ request,
5283
+ evidence,
5284
+ candidates,
5285
+ requirementSeed,
5286
+ resolution: validated.resolution,
5287
+ });
5288
+ const safeResolution = preventDegenerateRankingResolution(frameBoundResolution, evidence, candidates, request.question);
2086
5289
  // Meaning interpretation is still required for a fresh turn,
2087
5290
  // but it cannot invent a ranking measure when the user only
2088
5291
  // supplied an entity. Preserve the precise follow-up after the
@@ -2131,24 +5334,27 @@ export function createHybridRouter(options = {}) {
2131
5334
  .some((item) => /measure|metric/i.test(item));
2132
5335
  const assumedRanking = resolverFlagged
2133
5336
  ? undefined
2134
- : assumableRankingMeasure(request.question, clarificationCandidates);
5337
+ : assumableRankingMeasure(request.question, admittedClarificationCandidates);
2135
5338
  if (assumedRanking) {
2136
5339
  return {
2137
5340
  ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
2138
5341
  assumptions: [assumedRanking.assumption],
2139
5342
  };
2140
5343
  }
2141
- return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
2142
- // Supplemental clarification cards carry the ranking
2143
- // measures for the requested entity, which the execution
2144
- // candidate set deliberately does not.
2145
- clarificationCandidates, !resolverFlagged);
5344
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, admittedClarificationCandidates, !resolverFlagged);
2146
5345
  }
2147
- const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5346
+ const deterministicGap = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
2148
5347
  if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
2149
5348
  return deterministicGap;
2150
5349
  }
2151
- return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
5350
+ const meaningDecision = routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
5351
+ return remember(key, continuePreFreezeModelingGapThroughPhysicalSnapshot({
5352
+ decision: meaningDecision,
5353
+ base,
5354
+ evidence,
5355
+ candidates,
5356
+ question: request.question,
5357
+ }));
2152
5358
  }
2153
5359
  const invalidResolution = {
2154
5360
  interpretedQuestion: request.question,
@@ -2184,14 +5390,49 @@ export function createHybridRouter(options = {}) {
2184
5390
  const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
2185
5391
  if (!shouldUseMeaningCall)
2186
5392
  return fallbackDecision;
5393
+ if (isFrozenExecutableHostFallback(fallbackDecision)) {
5394
+ return fallbackDecision;
5395
+ }
2187
5396
  // The provider was unavailable or returned malformed JSON. Apply the
2188
5397
  // deterministic clarification only after the bounded meaning attempt
2189
5398
  // has been exhausted; this preserves a precise recovery path without
2190
5399
  // allowing the generic governed error to terminate the question.
2191
- return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
5400
+ return deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates)
2192
5401
  ?? fallbackDecision;
2193
5402
  }
2194
5403
  }
5404
+ // A structured selection cannot enter the legacy/no-evidence classifier.
5405
+ // Without a fresh retrieval snapshot it has no proof that the server
5406
+ // option still exists, so fail closed before any provider dispatch or
5407
+ // generated-SQL fallback.
5408
+ if (request.selectedEvidenceId) {
5409
+ const unavailableEvidence = {
5410
+ snapshotId: 'unavailable:structured-selection',
5411
+ sourceFingerprint: 'unavailable:structured-selection',
5412
+ candidates: [],
5413
+ };
5414
+ const validation = validateStructuredClarificationSelection({
5415
+ request,
5416
+ evidence: unavailableEvidence,
5417
+ candidates: [],
5418
+ selected: undefined,
5419
+ });
5420
+ return invalidStructuredSelectionDecision({
5421
+ base,
5422
+ request,
5423
+ evidence: unavailableEvidence,
5424
+ candidates: [],
5425
+ selectedId: request.selectedEvidenceId,
5426
+ validation: validation.ok
5427
+ ? {
5428
+ ok: false,
5429
+ requirements: validation.requirements,
5430
+ choiceIds: validation.choiceIds,
5431
+ reason: 'The selected governed identifier could not be revalidated because retrieval is unavailable.',
5432
+ }
5433
+ : validation,
5434
+ });
5435
+ }
2195
5436
  // Legacy/no-evidence path. A confident analytical heuristic stays offline;
2196
5437
  // only the ambiguous middle pays the old classification call. Importantly,
2197
5438
  // load the catalog context before the model may choose general knowledge.
@@ -2219,6 +5460,8 @@ export function createHybridRouter(options = {}) {
2219
5460
  system: buildSystemPrompt(),
2220
5461
  user: buildUserPrompt(request, catalogContext),
2221
5462
  signal: request.signal ?? options.signal,
5463
+ request,
5464
+ phase: 'classification',
2222
5465
  });
2223
5466
  const classification = parseClassification(raw);
2224
5467
  if (classification)