@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.
- package/dist/agent-run-engine.d.ts +71 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +1048 -31
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-gates.d.ts.map +1 -1
- package/dist/agent-run-gates.js +12 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-planner.d.ts +6 -0
- package/dist/agent-run-planner.d.ts.map +1 -1
- package/dist/agent-run-planner.js +2 -0
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agent-run-store.d.ts.map +1 -1
- package/dist/agent-run-store.js +48 -3
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +7 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +211 -28
- package/dist/agentic/analyst-loop.js.map +1 -1
- package/dist/agentic/assumptions.d.ts +1 -1
- package/dist/agentic/assumptions.d.ts.map +1 -1
- package/dist/agentic/assumptions.js.map +1 -1
- package/dist/agentic/research/synthesis.d.ts +4 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -1
- package/dist/agentic/research/synthesis.js +23 -8
- package/dist/agentic/research/synthesis.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +13 -3
- package/dist/agentic/research-agent.js.map +1 -1
- package/dist/agentic/sql-authorization.d.ts +8 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -1
- package/dist/agentic/sql-authorization.js +233 -2
- package/dist/agentic/sql-authorization.js.map +1 -1
- package/dist/analytical-compatibility.d.ts.map +1 -1
- package/dist/analytical-compatibility.js +8 -1
- package/dist/analytical-compatibility.js.map +1 -1
- package/dist/analytical-failure-repair.d.ts.map +1 -1
- package/dist/analytical-failure-repair.js +6 -2
- package/dist/analytical-failure-repair.js.map +1 -1
- package/dist/analytical-frame.d.ts +23 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +332 -17
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +613 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +1007 -0
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +110 -6
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +1180 -125
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.d.ts +32 -1
- package/dist/answer-shape.d.ts.map +1 -1
- package/dist/answer-shape.js +49 -3
- package/dist/answer-shape.js.map +1 -1
- package/dist/ask-observability/index.d.ts +11 -0
- package/dist/ask-observability/index.d.ts.map +1 -0
- package/dist/ask-observability/index.js +7 -0
- package/dist/ask-observability/index.js.map +1 -0
- package/dist/ask-observability/instrumentation.d.ts +35 -0
- package/dist/ask-observability/instrumentation.d.ts.map +1 -0
- package/dist/ask-observability/instrumentation.js +472 -0
- package/dist/ask-observability/instrumentation.js.map +1 -0
- package/dist/ask-observability/observer.d.ts +62 -0
- package/dist/ask-observability/observer.d.ts.map +1 -0
- package/dist/ask-observability/observer.js +537 -0
- package/dist/ask-observability/observer.js.map +1 -0
- package/dist/ask-observability/portable.d.ts +98 -0
- package/dist/ask-observability/portable.d.ts.map +1 -0
- package/dist/ask-observability/portable.js +527 -0
- package/dist/ask-observability/portable.js.map +1 -0
- package/dist/ask-observability/store.d.ts +130 -0
- package/dist/ask-observability/store.d.ts.map +1 -0
- package/dist/ask-observability/store.js +1114 -0
- package/dist/ask-observability/store.js.map +1 -0
- package/dist/ask-observability/types.d.ts +357 -0
- package/dist/ask-observability/types.d.ts.map +1 -0
- package/dist/ask-observability/types.js +2 -0
- package/dist/ask-observability/types.js.map +1 -0
- package/dist/ask-observability/utils.d.ts +21 -0
- package/dist/ask-observability/utils.d.ts.map +1 -0
- package/dist/ask-observability/utils.js +102 -0
- package/dist/ask-observability/utils.js.map +1 -0
- package/dist/conversation/snapshot.d.ts +20 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +30 -1
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
- package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
- package/dist/fixtures/ask-ai-office-shaped.js +94 -0
- package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
- package/dist/generated-analytical-proposal.d.ts +37 -1
- package/dist/generated-analytical-proposal.d.ts.map +1 -1
- package/dist/generated-analytical-proposal.js +211 -3
- package/dist/generated-analytical-proposal.js.map +1 -1
- package/dist/governed-relational-compiler.d.ts +15 -0
- package/dist/governed-relational-compiler.d.ts.map +1 -1
- package/dist/governed-relational-compiler.js +99 -1
- package/dist/governed-relational-compiler.js.map +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +38 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +31 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +141 -2
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +444 -30
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +144 -7
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts +18 -0
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +250 -38
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +58 -12
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +19 -3
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +518 -54
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/plan-execution-adapter.d.ts +9 -1
- package/dist/plan-execution-adapter.d.ts.map +1 -1
- package/dist/plan-execution-adapter.js +93 -13
- package/dist/plan-execution-adapter.js.map +1 -1
- package/dist/provider-egress.d.ts +24 -10
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +22 -17
- package/dist/provider-egress.js.map +1 -1
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +10 -9
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/dispatch.d.ts +27 -1
- package/dist/providers/dispatch.d.ts.map +1 -1
- package/dist/providers/dispatch.js +63 -1
- package/dist/providers/dispatch.js.map +1 -1
- package/dist/providers/gemini.js +10 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.js +15 -15
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.js +22 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +52 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/relationship-proof.d.ts +81 -0
- package/dist/relationship-proof.d.ts.map +1 -1
- package/dist/relationship-proof.js +228 -4
- package/dist/relationship-proof.js.map +1 -1
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +23 -1
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +22 -1
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +773 -49
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +14 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +3668 -425
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +23 -4
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/dist/semantic-bridge/member-select.d.ts.map +1 -1
- package/dist/semantic-bridge/member-select.js +39 -21
- package/dist/semantic-bridge/member-select.js.map +1 -1
- 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
|
|
201
|
-
"Trust
|
|
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
|
-
'{"
|
|
206
|
-
"
|
|
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
|
-
`
|
|
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
|
-
|
|
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 =
|
|
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("
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
335
|
+
const rejectedCandidatesRaw = record.rejectedCandidates;
|
|
336
|
+
if (rejectedCandidatesRaw !== undefined && !Array.isArray(rejectedCandidatesRaw))
|
|
311
337
|
return undefined;
|
|
312
|
-
const rejectedCandidates =
|
|
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 !==
|
|
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
|
|
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
|
|
336
|
-
questionType
|
|
337
|
-
selectedConceptIds:
|
|
367
|
+
interpretedQuestion,
|
|
368
|
+
questionType,
|
|
369
|
+
selectedConceptIds: selectedConceptIds,
|
|
338
370
|
...(recommendedExecutionId ? { recommendedExecutionId } : {}),
|
|
339
|
-
queryIntent:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
? {
|
|
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
|
|
385
|
+
confidence,
|
|
352
386
|
missingInformation,
|
|
353
|
-
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
||
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
return
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
566
|
-
|
|
567
|
-
return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
|
|
759
|
+
function normalizedRelationshipIdentity(value) {
|
|
760
|
+
return value.trim().toLowerCase();
|
|
568
761
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
|
582
|
-
|
|
583
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
683
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
3436
|
+
return queryIntentWithSelectedDimensions.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
|
|
940
3437
|
});
|
|
941
|
-
const canonicalFilters =
|
|
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]
|
|
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: { ...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1958
|
-
//
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
|
|
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
|
-
]
|
|
1979
|
-
|
|
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,
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
5346
|
+
const deterministicGap = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
|
|
2148
5347
|
if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
|
|
2149
5348
|
return deterministicGap;
|
|
2150
5349
|
}
|
|
2151
|
-
|
|
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,
|
|
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)
|