@duckcodeailabs/dql-agent 1.14.2 → 1.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-run-engine.d.ts +239 -4
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +2924 -85
- 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 +51 -3
- 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 +591 -11
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +9 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +204 -4
- 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/ledger-tools.d.ts.map +1 -1
- package/dist/agentic/ledger-tools.js +8 -1
- package/dist/agentic/ledger-tools.js.map +1 -1
- package/dist/agentic/orchestrator-policy.d.ts +3 -1
- package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
- package/dist/agentic/orchestrator-policy.js +1 -1
- package/dist/agentic/orchestrator-policy.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +10 -1
- 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 +3 -0
- package/dist/agentic/sql-authorization.js.map +1 -1
- package/dist/agentic/tool-loop.d.ts +13 -1
- package/dist/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +441 -32
- package/dist/agentic/tool-loop.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 +42 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +490 -26
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +1259 -9
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +970 -84
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +230 -10
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +1182 -85
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.d.ts +27 -0
- package/dist/answer-shape.d.ts.map +1 -1
- package/dist/answer-shape.js +55 -0
- 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 +523 -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 +1089 -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 +380 -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/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
- package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
- package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
- package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
- package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
- package/dist/conversation/session-store.d.ts +31 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +38 -0
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +4 -1
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +85 -3
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/conversation/turn-trust.d.ts.map +1 -1
- package/dist/conversation/turn-trust.js +2 -0
- package/dist/conversation/turn-trust.js.map +1 -1
- package/dist/embeddings/provider.d.ts +1 -0
- package/dist/embeddings/provider.d.ts.map +1 -1
- package/dist/embeddings/provider.js +18 -0
- package/dist/embeddings/provider.js.map +1 -1
- 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 +246 -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 +21 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -7
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +87 -1
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +2 -2
- package/dist/intent-controller.js.map +1 -1
- package/dist/kg/build.js +32 -2
- package/dist/kg/build.js.map +1 -1
- package/dist/meaning-resolution.d.ts +207 -6
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +439 -53
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +78 -7
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/catalog.d.ts +14 -0
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +29 -2
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +28 -2
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +373 -16
- 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 +28 -11
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +72 -22
- package/dist/provider-egress.js.map +1 -1
- package/dist/providers/claude.d.ts +2 -2
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +201 -37
- 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.d.ts +2 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +213 -43
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +131 -1
- 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/requirement-clauses.d.ts +23 -0
- package/dist/requirement-clauses.d.ts.map +1 -0
- package/dist/requirement-clauses.js +70 -0
- package/dist/requirement-clauses.js.map +1 -0
- package/dist/resolved-analytical-plan.d.ts +18 -4
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +953 -50
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +118 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +2481 -232
- 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/package.json +4 -4
package/dist/router.js
CHANGED
|
@@ -16,12 +16,13 @@ import { assumeDominantCandidate } from './agentic/assumptions.js';
|
|
|
16
16
|
* question never pays twice.
|
|
17
17
|
*/
|
|
18
18
|
import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
|
|
19
|
-
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
19
|
+
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, mergeMeaningResolutionWithRequirementSeed, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
20
20
|
import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
|
|
21
21
|
import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
|
|
22
|
-
import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
|
|
22
|
+
import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, categoricalDimensionRequirementTerms, candidateConflictsWithExplicitRankingMeasure, candidateMatchesCategoricalDimensionRequirement, evidenceCandidateRoles, isEntityAttributeCandidate, } from './analytical-orchestration.js';
|
|
23
|
+
import { askTraceObserverForV1 } from './ask-observability/index.js';
|
|
23
24
|
import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
|
|
24
|
-
import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
|
|
25
|
+
import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, proveSameSnapshotMetricflowRoleExtensionV1, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
|
|
25
26
|
import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
|
|
26
27
|
const DEFAULT_THRESHOLD = 0.7;
|
|
27
28
|
const DEFAULT_CACHE_SIZE = 200;
|
|
@@ -194,20 +195,22 @@ function parseClassification(raw) {
|
|
|
194
195
|
rationale,
|
|
195
196
|
};
|
|
196
197
|
}
|
|
197
|
-
function buildMeaningSystemPrompt() {
|
|
198
|
+
export function buildMeaningSystemPrompt() {
|
|
198
199
|
return [
|
|
199
200
|
"You resolve business meaning for DQL, a governed analytics system.",
|
|
200
|
-
"The host already performed broad retrieval. Compare ONLY the supplied candidate cards.",
|
|
201
|
-
"Select the
|
|
202
|
-
"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.",
|
|
203
204
|
"You may reference ONLY candidate IDs supplied below. Never invent an ID, table, column, metric, relationship, or filter value.",
|
|
205
|
+
"Do not rewrite, add, remove, or replace any host-owned requirement. Do not use prior conversation unless the host seed says it is a continuation.",
|
|
206
|
+
"Do not emit SQL, trust labels, an execution route, query intent, or an analytical frame. Those fields are ignored if present for legacy compatibility.",
|
|
204
207
|
"Use low confidence and recommend clarify when material business meanings remain unresolved.",
|
|
205
|
-
"Respond with ONLY one JSON object matching this shape:",
|
|
206
|
-
'{"
|
|
207
|
-
"
|
|
208
|
+
"Respond with ONLY one JSON object matching this minimal candidate-ID shape:",
|
|
209
|
+
'{"selectedCandidateIds":string[],"recommendedExecutionId"?:string,"confidence"?:"high"|"medium"|"low","missingInformation"?:string[]}',
|
|
210
|
+
"`selectedConceptIds` is accepted only for legacy compatibility. All omitted optional fields receive host-owned safe defaults.",
|
|
208
211
|
].join("\n");
|
|
209
212
|
}
|
|
210
|
-
function buildMeaningUserPrompt(request, evidence, candidates) {
|
|
213
|
+
export function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
|
|
211
214
|
const cards = candidates.map((candidate) => ({
|
|
212
215
|
id: candidate.id,
|
|
213
216
|
kind: candidate.kind,
|
|
@@ -219,6 +222,7 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
|
|
|
219
222
|
aggregation: compactText(candidate.aggregation, 120),
|
|
220
223
|
domain: compactText(candidate.domain, 120),
|
|
221
224
|
semanticModel: compactText(candidate.semanticModel, 160),
|
|
225
|
+
dataType: compactText(candidate.dataType, 80),
|
|
222
226
|
primaryEntity: compactText(candidate.primaryEntity, 160),
|
|
223
227
|
dimensions: compactArray(candidate.dimensions, 16, 120),
|
|
224
228
|
timeGrains: compactArray(candidate.timeGrains, 8, 80),
|
|
@@ -232,21 +236,23 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
|
|
|
232
236
|
analyticalCapability: candidate.analyticalCapability,
|
|
233
237
|
}));
|
|
234
238
|
const lines = [
|
|
235
|
-
`
|
|
236
|
-
`Parsed request hints: ${JSON.stringify(compactQueryIntent(defaultQueryIntent(evidence)))}`,
|
|
239
|
+
`Host-owned requirement seed: ${JSON.stringify(requirementSeed)}`,
|
|
237
240
|
`Candidate cards: ${JSON.stringify(cards)}`,
|
|
238
241
|
];
|
|
239
242
|
const history = effectiveConversationHistory(request);
|
|
240
|
-
|
|
243
|
+
const continuation = request.conversationBinding ?? 'none';
|
|
244
|
+
if (history.length && continuation !== 'none') {
|
|
241
245
|
lines.push(`Recent conversation: ${JSON.stringify(history.slice(-4).map((turn) => ({
|
|
242
246
|
role: turn.role,
|
|
243
247
|
text: compactText(turn.text, 1_200),
|
|
244
248
|
})))}`);
|
|
245
249
|
}
|
|
246
|
-
const envelope =
|
|
250
|
+
const envelope = continuation === 'none'
|
|
251
|
+
? undefined
|
|
252
|
+
: renderConversationEnvelopeForPrompt(request.conversationContext);
|
|
247
253
|
if (envelope)
|
|
248
254
|
lines.push(`Structured conversation state: ${JSON.stringify(envelope)}`);
|
|
249
|
-
lines.push("
|
|
255
|
+
lines.push("Bind only supplied candidate IDs to the host-owned seed and return JSON only.");
|
|
250
256
|
return lines.join("\n");
|
|
251
257
|
}
|
|
252
258
|
function compactText(value, maxLength) {
|
|
@@ -275,42 +281,62 @@ function compactQueryIntent(intent) {
|
|
|
275
281
|
const QUESTION_TYPES = new Set(["definition", "value", "ranking", "trend", "comparison", "diagnosis", "research"]);
|
|
276
282
|
const MEANING_CONFIDENCES = new Set(["high", "medium", "low"]);
|
|
277
283
|
const MEANING_ROUTES = new Set(["certified", "semantic", "governed_sql", "exploratory", "clarify"]);
|
|
278
|
-
function parseMeaningResolution(raw) {
|
|
284
|
+
function parseMeaningResolution(raw, requirementSeed) {
|
|
279
285
|
const parsed = extractJsonObject(raw);
|
|
280
286
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
281
287
|
return undefined;
|
|
282
288
|
const record = parsed;
|
|
283
|
-
|
|
289
|
+
const interpretedQuestion = typeof record.interpretedQuestion === "string" && record.interpretedQuestion.trim()
|
|
290
|
+
? record.interpretedQuestion.trim()
|
|
291
|
+
: requirementSeed?.sourceQuestion;
|
|
292
|
+
if (!interpretedQuestion)
|
|
284
293
|
return undefined;
|
|
285
|
-
|
|
294
|
+
const questionType = typeof record.questionType === "string" && QUESTION_TYPES.has(record.questionType)
|
|
295
|
+
? record.questionType
|
|
296
|
+
: requirementSeed ? questionTypeFromText(requirementSeed.sourceQuestion) : undefined;
|
|
297
|
+
if (!questionType)
|
|
286
298
|
return undefined;
|
|
287
|
-
|
|
299
|
+
const selectedConceptIds = Array.isArray(record.selectedCandidateIds)
|
|
300
|
+
? record.selectedCandidateIds
|
|
301
|
+
: record.selectedConceptIds;
|
|
302
|
+
if (!Array.isArray(selectedConceptIds) || selectedConceptIds.some((id) => typeof id !== "string"))
|
|
288
303
|
return undefined;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
304
|
+
const confidence = typeof record.confidence === "string" && MEANING_CONFIDENCES.has(record.confidence)
|
|
305
|
+
? record.confidence
|
|
306
|
+
: requirementSeed
|
|
307
|
+
? selectedConceptIds.length > 0 ? 'medium' : 'low'
|
|
308
|
+
: undefined;
|
|
309
|
+
if (!confidence)
|
|
292
310
|
return undefined;
|
|
293
|
-
|
|
311
|
+
const recommendedRoute = typeof record.recommendedRoute === "string" && MEANING_ROUTES.has(record.recommendedRoute)
|
|
312
|
+
? record.recommendedRoute
|
|
313
|
+
: requirementSeed ? "clarify" : undefined;
|
|
314
|
+
if (!recommendedRoute)
|
|
294
315
|
return undefined;
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
const dimensions = stringArray(query.dimensions);
|
|
298
|
-
const missingInformation = stringArray(record.missingInformation);
|
|
299
|
-
if (!measures || !dimensions || !missingInformation || !Array.isArray(query.filters))
|
|
316
|
+
const missingInformation = stringArray(record.missingInformation) ?? (requirementSeed ? [] : undefined);
|
|
317
|
+
if (!missingInformation)
|
|
300
318
|
return undefined;
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
319
|
+
const query = record.queryIntent && typeof record.queryIntent === "object" && !Array.isArray(record.queryIntent)
|
|
320
|
+
? record.queryIntent
|
|
321
|
+
: undefined;
|
|
322
|
+
const measures = query ? stringArray(query.measures) : undefined;
|
|
323
|
+
const dimensions = query ? stringArray(query.dimensions) : undefined;
|
|
324
|
+
const filters = Array.isArray(query?.filters)
|
|
325
|
+
? query.filters.flatMap((filter) => {
|
|
326
|
+
if (!filter || typeof filter !== "object" || Array.isArray(filter))
|
|
327
|
+
return [];
|
|
328
|
+
const item = filter;
|
|
329
|
+
return typeof item.field === "string" && typeof item.value === "string"
|
|
330
|
+
? [{ field: item.field, value: item.value }]
|
|
331
|
+
: [];
|
|
332
|
+
})
|
|
333
|
+
: undefined;
|
|
334
|
+
if (!requirementSeed && (!measures || !dimensions || !filters || !Array.isArray(query?.filters) || filters.length !== query.filters.length))
|
|
310
335
|
return undefined;
|
|
311
|
-
|
|
336
|
+
const rejectedCandidatesRaw = record.rejectedCandidates;
|
|
337
|
+
if (rejectedCandidatesRaw !== undefined && !Array.isArray(rejectedCandidatesRaw))
|
|
312
338
|
return undefined;
|
|
313
|
-
const rejectedCandidates =
|
|
339
|
+
const rejectedCandidates = (rejectedCandidatesRaw ?? []).flatMap((candidate) => {
|
|
314
340
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
315
341
|
return [];
|
|
316
342
|
const item = candidate;
|
|
@@ -318,42 +344,66 @@ function parseMeaningResolution(raw) {
|
|
|
318
344
|
? [{ id: item.id, reason: item.reason }]
|
|
319
345
|
: [];
|
|
320
346
|
});
|
|
321
|
-
if (rejectedCandidates.length !==
|
|
347
|
+
if (rejectedCandidates.length !== (rejectedCandidatesRaw?.length ?? 0))
|
|
322
348
|
return undefined;
|
|
323
349
|
const recommendedExecutionId = typeof record.recommendedExecutionId === "string"
|
|
324
350
|
? record.recommendedExecutionId
|
|
325
351
|
: undefined;
|
|
326
|
-
const clarifyingQuestion = typeof record.clarifyingQuestion === "string" &&
|
|
327
|
-
record.clarifyingQuestion.trim()
|
|
352
|
+
const clarifyingQuestion = typeof record.clarifyingQuestion === "string" && record.clarifyingQuestion.trim()
|
|
328
353
|
? record.clarifyingQuestion.trim()
|
|
329
354
|
: undefined;
|
|
330
|
-
const
|
|
355
|
+
const providerExplicitlyClarified = (recommendedRoute === 'clarify'
|
|
356
|
+
&& typeof record.recommendedRoute === 'string')
|
|
357
|
+
|| Boolean(clarifyingQuestion)
|
|
358
|
+
|| missingInformation.length > 0
|
|
359
|
+
|| rejectedCandidates.length > 0;
|
|
360
|
+
// Legacy callers may still send a V2 frame. It remains readable without a
|
|
361
|
+
// seed, but a seeded meaning call must never grant a model frame authority.
|
|
362
|
+
const analyticalFrame = requirementSeed || record.analyticalFrame === undefined
|
|
331
363
|
? undefined
|
|
332
364
|
: normalizeAnalyticalQuestionFrameV2(record.analyticalFrame);
|
|
333
|
-
if (record.analyticalFrame !== undefined && !analyticalFrame)
|
|
365
|
+
if (!requirementSeed && record.analyticalFrame !== undefined && !analyticalFrame)
|
|
334
366
|
return undefined;
|
|
335
367
|
return {
|
|
336
|
-
interpretedQuestion
|
|
337
|
-
questionType
|
|
338
|
-
selectedConceptIds:
|
|
368
|
+
interpretedQuestion,
|
|
369
|
+
questionType,
|
|
370
|
+
selectedConceptIds: selectedConceptIds,
|
|
339
371
|
...(recommendedExecutionId ? { recommendedExecutionId } : {}),
|
|
340
|
-
queryIntent:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
? {
|
|
349
|
-
|
|
350
|
-
|
|
372
|
+
queryIntent: requirementSeed
|
|
373
|
+
? queryIntentFromRequirementSeed(requirementSeed)
|
|
374
|
+
: {
|
|
375
|
+
measures: measures,
|
|
376
|
+
dimensions: dimensions,
|
|
377
|
+
filters: filters,
|
|
378
|
+
...(typeof query?.timeRange === "string" ? { timeRange: query.timeRange } : {}),
|
|
379
|
+
...(typeof query?.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
|
|
380
|
+
...(query?.order === "asc" || query?.order === "desc" ? { order: query.order } : {}),
|
|
381
|
+
...(typeof query?.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
|
|
382
|
+
? { limit: Math.floor(query.limit) }
|
|
383
|
+
: {}),
|
|
384
|
+
},
|
|
351
385
|
rejectedCandidates,
|
|
352
|
-
confidence
|
|
386
|
+
confidence,
|
|
353
387
|
missingInformation,
|
|
354
|
-
recommendedRoute
|
|
388
|
+
recommendedRoute,
|
|
355
389
|
...(clarifyingQuestion ? { clarifyingQuestion } : {}),
|
|
356
390
|
...(analyticalFrame ? { analyticalFrame } : {}),
|
|
391
|
+
...(selectedConceptIds.length === 0 && !recommendedExecutionId && !providerExplicitlyClarified
|
|
392
|
+
? { emptyCandidateBinding: true }
|
|
393
|
+
: {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function queryIntentFromRequirementSeed(seed) {
|
|
397
|
+
return {
|
|
398
|
+
measures: [...seed.queryIntent.measures],
|
|
399
|
+
dimensions: [...seed.queryIntent.dimensions],
|
|
400
|
+
filters: seed.queryIntent.filters.map((filter) => ({ field: filter.field, value: filter.value })),
|
|
401
|
+
...(seed.queryIntent.timeRange ? { timeRange: seed.queryIntent.timeRange } : {}),
|
|
402
|
+
...(seed.queryIntent.timeGrain ? { timeGrain: seed.queryIntent.timeGrain } : {}),
|
|
403
|
+
...(seed.queryIntent.order ? { order: seed.queryIntent.order } : {}),
|
|
404
|
+
...(seed.queryIntent.limit !== undefined ? { limit: seed.queryIntent.limit } : {}),
|
|
405
|
+
...(seed.queryIntent.fiscalCalendarId ? { fiscalCalendarId: seed.queryIntent.fiscalCalendarId } : {}),
|
|
406
|
+
...(seed.queryIntent.fiscalDateRoleId ? { fiscalDateRoleId: seed.queryIntent.fiscalDateRoleId } : {}),
|
|
357
407
|
};
|
|
358
408
|
}
|
|
359
409
|
function stringArray(value) {
|
|
@@ -408,13 +458,228 @@ function conversationPositionToken(context) {
|
|
|
408
458
|
return `${latest}:${turns.length}:${recent}:${tailId}`;
|
|
409
459
|
}
|
|
410
460
|
function retrievalTrace(evidence, candidates) {
|
|
461
|
+
const candidateTraceMetadata = candidates.flatMap((candidate) => evidenceCandidateRoles(candidate).map((role) => ({
|
|
462
|
+
// `id` is the identity accepted by the meaning resolver. It is already
|
|
463
|
+
// source-qualified by the retrieval contract; keep it so later selection
|
|
464
|
+
// IDs can be joined without guessing a legacy-to-canonical mapping.
|
|
465
|
+
candidateId: candidate.id,
|
|
466
|
+
role,
|
|
467
|
+
source: traceSourceForCandidate(candidate),
|
|
468
|
+
...(candidate.retrievalLanes?.length ? { lanes: candidate.retrievalLanes } : {}),
|
|
469
|
+
})));
|
|
411
470
|
return {
|
|
412
471
|
...(evidence.snapshotId ? { snapshotId: evidence.snapshotId } : {}),
|
|
413
472
|
...(evidence.sourceFingerprint ? { sourceFingerprint: evidence.sourceFingerprint } : {}),
|
|
473
|
+
...(evidence.continuityFingerprint ? { continuityFingerprint: evidence.continuityFingerprint } : {}),
|
|
414
474
|
candidateCount: candidates.length,
|
|
415
475
|
candidateIds: candidates.map((candidate) => candidate.id),
|
|
476
|
+
...(candidateTraceMetadata.length ? { candidateTraceMetadata } : {}),
|
|
416
477
|
};
|
|
417
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Observability is a projection of snapshot-bound evidence, never a second
|
|
481
|
+
* retriever or a route authority. Keep source classification conservative so
|
|
482
|
+
* an absent lane is not manufactured from an arbitrary ID prefix.
|
|
483
|
+
*/
|
|
484
|
+
function traceSourceForCandidate(candidate) {
|
|
485
|
+
// Qualified candidate identities preserve their originating index. Prefer
|
|
486
|
+
// that declared provenance for collapsed `sql_column`/`sql_table` kinds: a
|
|
487
|
+
// dbt manifest column must not be relabelled as runtime-schema evidence.
|
|
488
|
+
const identity = candidate.qualifiedId ?? candidate.id;
|
|
489
|
+
if (identity.startsWith('dbt:'))
|
|
490
|
+
return 'dbt_manifest';
|
|
491
|
+
if (identity.startsWith('runtime:'))
|
|
492
|
+
return 'runtime_schema';
|
|
493
|
+
if (identity.startsWith('semantic:'))
|
|
494
|
+
return 'semantic';
|
|
495
|
+
if (identity.startsWith('dql:'))
|
|
496
|
+
return candidate.kind === 'certified_block' ? 'certified' : 'governed_relational';
|
|
497
|
+
if (candidate.kind === 'certified_block')
|
|
498
|
+
return 'certified';
|
|
499
|
+
if (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
|
|
500
|
+
return 'semantic';
|
|
501
|
+
if (candidate.kind === 'dql_modeling')
|
|
502
|
+
return 'governed_relational';
|
|
503
|
+
if (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source')
|
|
504
|
+
return 'dbt_manifest';
|
|
505
|
+
if (candidate.kind === 'sql_table' || candidate.kind === 'sql_column')
|
|
506
|
+
return 'runtime_schema';
|
|
507
|
+
return 'exploratory';
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Persist the immutable snapshot's candidate lifecycle for either the legacy
|
|
511
|
+
* router or AskAnalystRuntimeV1. This is an observability projection only: it
|
|
512
|
+
* cannot retrieve, rank, admit, or alter a program.
|
|
513
|
+
*/
|
|
514
|
+
export function recordAskCandidateLifecycleV1(request, evidence, candidates, packageCandidates) {
|
|
515
|
+
const observer = askTraceObserverForV1(request);
|
|
516
|
+
if (!observer.enabled)
|
|
517
|
+
return;
|
|
518
|
+
const packageIds = new Set(packageCandidates.map((candidate) => candidate.id));
|
|
519
|
+
const fuse = observer.startSpan({
|
|
520
|
+
name: 'retrieval.fuse',
|
|
521
|
+
stage: 'retrieval',
|
|
522
|
+
payload: { kind: 'retrieval', candidateCount: candidates.length },
|
|
523
|
+
});
|
|
524
|
+
const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
|
|
525
|
+
const requestedRoles = new Set([
|
|
526
|
+
...(requirements.measures.length || requirements.ranking?.metricTerms.length ? ['metric'] : []),
|
|
527
|
+
...(requirements.entityTerms.length || requirements.entityDisplayTerms.length ? ['entity_label'] : []),
|
|
528
|
+
...(requirements.time ? ['time_dimension'] : []),
|
|
529
|
+
...(requirements.dimensions.length ? ['categorical_dimension'] : []),
|
|
530
|
+
...(requirements.dimensions.length > 1 || requirements.entityTerms.length ? ['relationship'] : []),
|
|
531
|
+
]);
|
|
532
|
+
// Candidate lifecycle is an Advanced trace projection over the immutable
|
|
533
|
+
// prequalification/search backing pool. It intentionally has a larger cap
|
|
534
|
+
// than EvidenceWorkspaceV2 (32) so users can see a relevant candidate was
|
|
535
|
+
// pruned as `not_admitted` rather than falsely told it was absent. This
|
|
536
|
+
// projection is not an admission path: planner cards stay <=16 and the
|
|
537
|
+
// frozen execution closure stays <=32 plus an explicit bounded extension.
|
|
538
|
+
const rawCandidateTraceLimit = 80;
|
|
539
|
+
const hasRequestedEntityLabel = requirements.entityTerms.length > 0 || requirements.entityDisplayTerms.length > 0;
|
|
540
|
+
for (const [index, candidate] of candidates.slice(0, rawCandidateTraceLimit).entries()) {
|
|
541
|
+
const roles = evidenceCandidateRoles(candidate);
|
|
542
|
+
const source = traceSourceForCandidate(candidate);
|
|
543
|
+
// This receipt must retain the actual retrieval memberships captured by
|
|
544
|
+
// the snapshot. Do not infer a lane from aliases, IDs, or a relationship
|
|
545
|
+
// attribute after fusion has already lost that physical provenance.
|
|
546
|
+
const lanes = candidate.retrievalLanes
|
|
547
|
+
?.map((entry) => ({ ...entry }))
|
|
548
|
+
.sort((left, right) => (left.rank ?? Number.MAX_SAFE_INTEGER) - (right.rank ?? Number.MAX_SAFE_INTEGER)
|
|
549
|
+
|| left.lane.localeCompare(right.lane));
|
|
550
|
+
const lane = lanes?.[0]?.lane;
|
|
551
|
+
const laneRank = lanes?.[0]?.rank;
|
|
552
|
+
const configuredRuntimeValueGroundingPin = candidate.matchReasons
|
|
553
|
+
.includes('host configured runtime value grounding pin');
|
|
554
|
+
const initialReason = configuredRuntimeValueGroundingPin
|
|
555
|
+
? 'configured_runtime_value_grounding_pin'
|
|
556
|
+
: candidate.exactMatch
|
|
557
|
+
? 'exact_name_match'
|
|
558
|
+
: 'unknown';
|
|
559
|
+
const compatibilityCode = candidate.compatibility === 'compatible' ? 'compatible'
|
|
560
|
+
: candidate.compatibility === 'incompatible' ? 'operation_unsupported'
|
|
561
|
+
: 'unknown';
|
|
562
|
+
for (const role of roles) {
|
|
563
|
+
const common = {
|
|
564
|
+
// The resolver and cascade use `id`; keeping that exact stable identity
|
|
565
|
+
// makes retrieval, admission, and model-selection receipts joinable.
|
|
566
|
+
candidateId: candidate.id,
|
|
567
|
+
role,
|
|
568
|
+
source,
|
|
569
|
+
...(lane ? { lane } : {}),
|
|
570
|
+
...(laneRank !== undefined ? { laneRank } : {}),
|
|
571
|
+
...(lanes?.length ? { lanes } : {}),
|
|
572
|
+
fusedRank: index + 1,
|
|
573
|
+
compatibilityCode,
|
|
574
|
+
};
|
|
575
|
+
observer.recordCandidateDecision({
|
|
576
|
+
...common,
|
|
577
|
+
decision: 'retrieved',
|
|
578
|
+
reasonCode: candidate.eligible === false ? 'role_mismatch' : initialReason,
|
|
579
|
+
});
|
|
580
|
+
// An explicit ranking measure is a typed request, not a relevance hint.
|
|
581
|
+
// Keep correlated metrics in the receipt, but record their exclusion at
|
|
582
|
+
// the metric admission boundary instead of allowing them to become
|
|
583
|
+
// clarification options or to fill an unrelated analytical role.
|
|
584
|
+
if (role === 'metric' && candidateConflictsWithExplicitRankingMeasure(candidate, requirements)) {
|
|
585
|
+
observer.recordCandidateDecision({
|
|
586
|
+
...common,
|
|
587
|
+
decision: 'excluded',
|
|
588
|
+
reasonCode: 'explicit_measure_conflict',
|
|
589
|
+
});
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (candidate.eligible === false || candidate.compatibility === 'incompatible') {
|
|
593
|
+
observer.recordCandidateDecision({
|
|
594
|
+
...common,
|
|
595
|
+
decision: 'excluded',
|
|
596
|
+
reasonCode: candidate.compatibility === 'incompatible' ? 'capability_incompatible' : 'role_mismatch',
|
|
597
|
+
});
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (packageIds.has(candidate.id)) {
|
|
601
|
+
const reservedForRole = requestedRoles.has(role);
|
|
602
|
+
const unresolvedRoleAdmission = candidate.matchReasons.some((reason) => reason.startsWith(`candidate_for_unresolved_role:${role}:`));
|
|
603
|
+
observer.recordCandidateDecision({
|
|
604
|
+
...common,
|
|
605
|
+
decision: 'reserved',
|
|
606
|
+
reasonCode: configuredRuntimeValueGroundingPin ? 'configured_runtime_value_grounding_pin'
|
|
607
|
+
: candidate.exactMatch ? 'exact_name_match'
|
|
608
|
+
: unresolvedRoleAdmission ? 'candidate_for_unresolved_role'
|
|
609
|
+
: reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
|
|
610
|
+
});
|
|
611
|
+
observer.recordCandidateDecision({
|
|
612
|
+
...common,
|
|
613
|
+
decision: 'admitted',
|
|
614
|
+
reasonCode: configuredRuntimeValueGroundingPin ? 'configured_runtime_value_grounding_pin'
|
|
615
|
+
: candidate.exactMatch ? 'exact_name_match'
|
|
616
|
+
: unresolvedRoleAdmission ? 'candidate_for_unresolved_role'
|
|
617
|
+
: reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const isNoisyEntityAttribute = hasRequestedEntityLabel
|
|
622
|
+
&& isEntityAttributeCandidate(candidate)
|
|
623
|
+
&& role === 'categorical_dimension';
|
|
624
|
+
observer.recordCandidateDecision({
|
|
625
|
+
...common,
|
|
626
|
+
decision: 'excluded',
|
|
627
|
+
reasonCode: isNoisyEntityAttribute ? 'entity_label_mismatch' : 'below_fused_limit',
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
observer.finishSpan(fuse, { outcome: 'ok', reasonCode: 'completed' });
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Emit source-bound lane summaries before the meaning-package cap applies.
|
|
636
|
+
* These spans are evidence about the existing retrieval result only; they do
|
|
637
|
+
* not invoke, retry, or reinterpret any retrieval source.
|
|
638
|
+
*/
|
|
639
|
+
function traceRetrievalLanesBeforePruning(request, evidence, candidates) {
|
|
640
|
+
const observer = askTraceObserverForV1(request);
|
|
641
|
+
if (!observer.enabled)
|
|
642
|
+
return;
|
|
643
|
+
const sourceSpan = {
|
|
644
|
+
certified: 'retrieval.certified',
|
|
645
|
+
semantic: 'retrieval.semantic',
|
|
646
|
+
governed_relational: 'retrieval.governed_relational',
|
|
647
|
+
dbt_manifest: 'retrieval.dbt_manifest',
|
|
648
|
+
runtime_schema: 'retrieval.runtime_schema',
|
|
649
|
+
vector: 'retrieval.vector',
|
|
650
|
+
conversation: 'retrieval.conversation',
|
|
651
|
+
};
|
|
652
|
+
const record = (name, source, count, coverage, lane) => {
|
|
653
|
+
const span = observer.startSpan({
|
|
654
|
+
name,
|
|
655
|
+
stage: 'retrieval',
|
|
656
|
+
reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
|
|
657
|
+
payload: { kind: 'retrieval', ...(source ? { source } : {}), ...(lane ? { lane } : {}), candidateCount: count, ...(coverage ? { coverage } : {}) },
|
|
658
|
+
});
|
|
659
|
+
observer.finishSpan(span, {
|
|
660
|
+
outcome: coverage === 'unavailable' ? 'unavailable' : 'ok',
|
|
661
|
+
reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
for (const coverage of sourceCoverageFromEvidence(evidence, candidates)) {
|
|
665
|
+
const name = sourceSpan[coverage.source];
|
|
666
|
+
if (!name)
|
|
667
|
+
continue;
|
|
668
|
+
record(name, coverage.source, coverage.candidateIds.length, coverage.status, coverage.source === 'vector' ? 'vector' : coverage.source === 'conversation' ? 'conversation' : undefined);
|
|
669
|
+
}
|
|
670
|
+
const laneSpan = {
|
|
671
|
+
exact: 'retrieval.exact',
|
|
672
|
+
lexical: 'retrieval.lexical',
|
|
673
|
+
vector: 'retrieval.vector',
|
|
674
|
+
graph: 'retrieval.graph',
|
|
675
|
+
conversation: 'retrieval.conversation',
|
|
676
|
+
};
|
|
677
|
+
for (const lane of Object.keys(laneSpan)) {
|
|
678
|
+
const count = candidates.filter((candidate) => candidate.retrievalLanes?.some((membership) => membership.lane === lane)).length;
|
|
679
|
+
if (count > 0)
|
|
680
|
+
record(laneSpan[lane], undefined, count, 'available', lane);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
418
683
|
/** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
|
|
419
684
|
function sourceCoverageFromEvidence(evidence, candidates) {
|
|
420
685
|
const supplied = new Map((evidence.diagnostics?.sourceCoverage ?? []).map((coverage) => [coverage.source, coverage]));
|
|
@@ -535,6 +800,9 @@ function withNormalizedAnalyticalRequirements(evidence, question) {
|
|
|
535
800
|
...evidence.parsedIntent,
|
|
536
801
|
measures: requirements.measures,
|
|
537
802
|
dimensions: requirements.dimensions,
|
|
803
|
+
...(requirements.time?.grain && !evidence.parsedIntent.timeGrain
|
|
804
|
+
? { timeGrain: requirements.time.grain }
|
|
805
|
+
: {}),
|
|
538
806
|
},
|
|
539
807
|
};
|
|
540
808
|
}
|
|
@@ -577,11 +845,160 @@ function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
|
|
|
577
845
|
return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
|
|
578
846
|
|| metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
|
|
579
847
|
}
|
|
580
|
-
|
|
848
|
+
/**
|
|
849
|
+
* Relationship evidence has three deliberately different authorities:
|
|
850
|
+
*
|
|
851
|
+
* - governed: a certified, fresh proof may compile a governed relational plan;
|
|
852
|
+
* - exploratory: an explicitly allowed, validated relation may close a raw
|
|
853
|
+
* review-required SQL plan after normal runtime validation; and
|
|
854
|
+
* - hint: anything weaker may improve retrieval, but can never add a join.
|
|
855
|
+
*
|
|
856
|
+
* The first authority remains intentionally stricter. Reusing it for raw
|
|
857
|
+
* fallback is what made a missing semantic capability look like a terminal
|
|
858
|
+
* absence even when the dbt/runtime snapshot had the exact relations, keys,
|
|
859
|
+
* and fields required to inspect it safely. This helper never upgrades a
|
|
860
|
+
* draft relationship to governed trust: callers that use it must select the
|
|
861
|
+
* exploratory tier and retain review_required provenance.
|
|
862
|
+
*/
|
|
863
|
+
function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
|
|
864
|
+
const validation = safety.validation;
|
|
865
|
+
const checkedAt = Date.parse(validation?.checkedAt ?? '');
|
|
866
|
+
const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
|
|
867
|
+
const expirationInvalid = safety.evidenceExpiresAt !== undefined
|
|
868
|
+
&& (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
|
|
869
|
+
const lifecycleAllowsExploration = safety.status === 'certified'
|
|
870
|
+
|| safety.status === 'validated'
|
|
871
|
+
|| safety.status === 'draft';
|
|
872
|
+
// A local runtime may mint this *after* it has evaluated the immutable DQL
|
|
873
|
+
// relationship graph. It is not a weaker spelling of automaticJoinAllowed:
|
|
874
|
+
// it can only admit a declared same-domain draft/review edge to the
|
|
875
|
+
// review-required exploratory compiler. Keep generic retrieved evidence on
|
|
876
|
+
// the stricter validated/automatic route below.
|
|
877
|
+
const hostAttestedExploratory = safety.exploratoryJoinAllowed === true
|
|
878
|
+
&& Boolean(safety.exploratoryPathFingerprint?.trim());
|
|
879
|
+
const lifecycleAllowsHostAttestedExploration = safety.status === 'draft'
|
|
880
|
+
|| safety.status === 'evaluated'
|
|
881
|
+
|| safety.status === 'review'
|
|
882
|
+
|| safety.status === 'reviewed';
|
|
883
|
+
const keysAreUsable = safety.keys.length > 0
|
|
884
|
+
&& safety.keys.every((key) => Boolean(key.from.trim() && key.to.trim()));
|
|
885
|
+
if (hostAttestedExploratory) {
|
|
886
|
+
if (!lifecycleAllowsHostAttestedExploration
|
|
887
|
+
|| safety.staleCertification === true
|
|
888
|
+
|| safety.fanout !== 'safe'
|
|
889
|
+
|| !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
|
|
890
|
+
|| !safety.from?.trim()
|
|
891
|
+
|| !safety.to?.trim()
|
|
892
|
+
|| !keysAreUsable)
|
|
893
|
+
return false;
|
|
894
|
+
if (!requiredJoinKey)
|
|
895
|
+
return true;
|
|
896
|
+
const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
|
|
897
|
+
return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
|
|
898
|
+
|| metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
|
|
899
|
+
}
|
|
900
|
+
if (!lifecycleAllowsExploration
|
|
901
|
+
|| safety.staleCertification === true
|
|
902
|
+
|| safety.automaticJoinAllowed !== true
|
|
903
|
+
|| safety.fanout !== 'safe'
|
|
904
|
+
|| !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
|
|
905
|
+
|| !safety.from?.trim()
|
|
906
|
+
|| !safety.to?.trim()
|
|
907
|
+
|| !validation
|
|
908
|
+
|| validation.status !== 'passed'
|
|
909
|
+
|| !validation.queryFingerprint?.trim()
|
|
910
|
+
|| !validation.proofFingerprint?.trim()
|
|
911
|
+
|| !Number.isFinite(checkedAt)
|
|
912
|
+
|| expirationInvalid
|
|
913
|
+
|| !keysAreUsable)
|
|
914
|
+
return false;
|
|
915
|
+
if (!requiredJoinKey)
|
|
916
|
+
return true;
|
|
917
|
+
const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
|
|
918
|
+
return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
|
|
919
|
+
|| metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Resolve a card's complete same-snapshot relationship proof set.
|
|
923
|
+
*
|
|
924
|
+
* Every evidence edge must map to exactly one structured proof (an alias may
|
|
925
|
+
* perform that mapping); every supplied proof must be used by an evidence
|
|
926
|
+
* edge. This rejects partial cards, unrelated safety records, and aliases
|
|
927
|
+
* that ambiguously name two proofs before a host-authored path card can carry
|
|
928
|
+
* them into planner context. The router/compiler still revalidates the
|
|
929
|
+
* selected raw edges at freeze time.
|
|
930
|
+
*/
|
|
931
|
+
export function relationshipSafetyProofSelectionForCandidateV1(candidate) {
|
|
932
|
+
const evidenceByIdentity = new Map();
|
|
933
|
+
for (const rawEvidenceId of candidate.relationshipEvidence ?? []) {
|
|
934
|
+
const canonicalInput = rawEvidenceId.trim();
|
|
935
|
+
const identity = normalizedRelationshipIdentity(canonicalInput);
|
|
936
|
+
if (!identity)
|
|
937
|
+
return undefined;
|
|
938
|
+
// Duplicate spellings of the same input identity do not create another
|
|
939
|
+
// edge. They are canonicalized below to the matched safety ID.
|
|
940
|
+
if (!evidenceByIdentity.has(identity))
|
|
941
|
+
evidenceByIdentity.set(identity, canonicalInput);
|
|
942
|
+
}
|
|
943
|
+
if (evidenceByIdentity.size === 0)
|
|
944
|
+
return undefined;
|
|
945
|
+
const proofs = candidate.relationshipSafety ?? [];
|
|
946
|
+
if (proofs.length === 0)
|
|
947
|
+
return undefined;
|
|
948
|
+
const matchedProofIndexes = new Set();
|
|
949
|
+
const matchedProofs = [];
|
|
950
|
+
for (const evidenceIdentity of evidenceByIdentity.keys()) {
|
|
951
|
+
const matches = proofs
|
|
952
|
+
.map((safety, index) => ({ safety, index }))
|
|
953
|
+
.filter(({ safety }) => {
|
|
954
|
+
// A canonical record without an ID cannot be safely serialized even
|
|
955
|
+
// if a loose alias happens to match the retrieval edge.
|
|
956
|
+
if (!safety.id?.trim())
|
|
957
|
+
return false;
|
|
958
|
+
return relationshipSafetyIdentities(safety).includes(evidenceIdentity);
|
|
959
|
+
});
|
|
960
|
+
// One evidence edge must identify one and only one canonical proof. A
|
|
961
|
+
// duplicate ID, duplicate alias, or conflicting alias is unsafe rather
|
|
962
|
+
// than a tie the path-card builder may resolve heuristically.
|
|
963
|
+
if (matches.length !== 1)
|
|
964
|
+
return undefined;
|
|
965
|
+
const match = matches[0];
|
|
966
|
+
matchedProofIndexes.add(match.index);
|
|
967
|
+
if (!matchedProofs.some((proof) => proof === match.safety))
|
|
968
|
+
matchedProofs.push(match.safety);
|
|
969
|
+
}
|
|
970
|
+
// A card that carries an unrelated proof is not a compact proof set. Do
|
|
971
|
+
// not silently discard it: the raw card may represent a stale/mixed
|
|
972
|
+
// snapshot and cannot become a trusted atomic planner path.
|
|
973
|
+
if (matchedProofIndexes.size !== proofs.length)
|
|
974
|
+
return undefined;
|
|
975
|
+
const proofClass = matchedProofs.every((safety) => relationshipSafetyAllowsAutomaticJoin(safety))
|
|
976
|
+
? 'governed'
|
|
977
|
+
: matchedProofs.every((safety) => relationshipSafetyAllowsExploratoryJoin(safety))
|
|
978
|
+
? 'exploratory'
|
|
979
|
+
: undefined;
|
|
980
|
+
if (!proofClass)
|
|
981
|
+
return undefined;
|
|
982
|
+
return {
|
|
983
|
+
proofClass,
|
|
984
|
+
relationshipEvidence: [...new Set(matchedProofs.map((safety) => safety.id.trim()))].sort(),
|
|
985
|
+
relationshipSafety: [...matchedProofs]
|
|
986
|
+
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
export function relationshipSafetyProofClassForCandidateV1(candidate) {
|
|
990
|
+
return relationshipSafetyProofSelectionForCandidateV1(candidate)?.proofClass;
|
|
991
|
+
}
|
|
992
|
+
function relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey) {
|
|
993
|
+
return authority === 'governed'
|
|
994
|
+
? relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey)
|
|
995
|
+
: relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey);
|
|
996
|
+
}
|
|
997
|
+
function safeRelationshipProofsForCandidate(candidate, requiredJoinKey, authority = 'governed') {
|
|
581
998
|
const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
|
|
582
999
|
const proofs = new Map();
|
|
583
1000
|
for (const safety of candidate.relationshipSafety ?? []) {
|
|
584
|
-
if (!
|
|
1001
|
+
if (!relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey))
|
|
585
1002
|
continue;
|
|
586
1003
|
if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
|
|
587
1004
|
continue;
|
|
@@ -596,6 +1013,210 @@ function candidateRelationshipEndpoints(candidate) {
|
|
|
596
1013
|
candidate.analyticalCapability?.primaryEntityId ?? '',
|
|
597
1014
|
].map(normalizedRelationshipIdentity).filter(Boolean));
|
|
598
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* An attribution-required relationship is intentionally not a generic join
|
|
1018
|
+
* failure. It becomes terminal only when the user explicitly asks to rank an
|
|
1019
|
+
* un-attributed/attribution-scoped signal and the same immutable snapshot
|
|
1020
|
+
* proves that getting from that signal to the requested entity would require
|
|
1021
|
+
* the declared attribution edge. This keeps neutral unsafe relationship
|
|
1022
|
+
* evidence available for the normal clarification path (AGT-029), while
|
|
1023
|
+
* preventing a bare-ranking metric picker from masking a known governance
|
|
1024
|
+
* boundary with unrelated options.
|
|
1025
|
+
*/
|
|
1026
|
+
const EXPLICIT_ATTRIBUTION_REQUEST_RE = /\b(?:un[-\s]?attributed|attribution|allocation|allocate)\b/i;
|
|
1027
|
+
function relationshipEndpointMatchesQuestionConcept(endpoint, question) {
|
|
1028
|
+
const leaf = normalizedRelationshipIdentity(endpoint).split('::').at(-1) ?? endpoint;
|
|
1029
|
+
const concept = normalizeMetricPhrase(leaf);
|
|
1030
|
+
const conceptTokens = substantiveLexicalTokens(concept);
|
|
1031
|
+
const questionTokens = new Set(substantiveLexicalTokens(question));
|
|
1032
|
+
return conceptTokens.length >= 2 && conceptTokens.every((token) => questionTokens.has(token));
|
|
1033
|
+
}
|
|
1034
|
+
function relationshipEndpointMatchesEntityTerms(endpoint, entityTerms) {
|
|
1035
|
+
const normalizedEndpoint = normalizeMetricPhrase(endpoint);
|
|
1036
|
+
return entityTerms.some((term) => metricTermsMatch(normalizedEndpoint, normalizeMetricPhrase(term)));
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Modeling files commonly use local entity IDs for relationship endpoints while
|
|
1040
|
+
* candidate cards retain the domain-qualified entity identity. Relationship
|
|
1041
|
+
* safety is only meaningful inside the declared relationship's domain, so
|
|
1042
|
+
* bridge that representation here rather than falling back to an unscoped
|
|
1043
|
+
* leaf-name match. This helper is used exclusively by the attribution-policy
|
|
1044
|
+
* graph below; it never authorizes a join.
|
|
1045
|
+
*/
|
|
1046
|
+
function scopedRelationshipEndpointIdentity(endpoint, relationship) {
|
|
1047
|
+
const normalizedEndpoint = normalizedRelationshipIdentity(endpoint ?? '');
|
|
1048
|
+
if (!normalizedEndpoint || normalizedEndpoint.includes('::entity::'))
|
|
1049
|
+
return normalizedEndpoint;
|
|
1050
|
+
const relationshipIdentity = relationshipSafetyIdentities(relationship)
|
|
1051
|
+
.find((identity) => identity.includes('::relationship::'));
|
|
1052
|
+
const domainPrefix = relationshipIdentity?.split('::relationship::')[0];
|
|
1053
|
+
return domainPrefix ? `${domainPrefix}::entity::${normalizedEndpoint}` : normalizedEndpoint;
|
|
1054
|
+
}
|
|
1055
|
+
function relationshipGraphReaches(starts, targets, relationships) {
|
|
1056
|
+
if (starts.size === 0 || targets.size === 0)
|
|
1057
|
+
return false;
|
|
1058
|
+
const graph = new Map();
|
|
1059
|
+
for (const relationship of relationships) {
|
|
1060
|
+
const from = scopedRelationshipEndpointIdentity(relationship.from, relationship);
|
|
1061
|
+
const to = scopedRelationshipEndpointIdentity(relationship.to, relationship);
|
|
1062
|
+
if (!from || !to)
|
|
1063
|
+
continue;
|
|
1064
|
+
const fromNeighbors = graph.get(from) ?? new Set();
|
|
1065
|
+
fromNeighbors.add(to);
|
|
1066
|
+
graph.set(from, fromNeighbors);
|
|
1067
|
+
const toNeighbors = graph.get(to) ?? new Set();
|
|
1068
|
+
toNeighbors.add(from);
|
|
1069
|
+
graph.set(to, toNeighbors);
|
|
1070
|
+
}
|
|
1071
|
+
const pending = [...starts];
|
|
1072
|
+
const visited = new Set(pending);
|
|
1073
|
+
while (pending.length > 0) {
|
|
1074
|
+
const current = pending.shift();
|
|
1075
|
+
if (targets.has(current))
|
|
1076
|
+
return true;
|
|
1077
|
+
for (const next of graph.get(current) ?? []) {
|
|
1078
|
+
if (!visited.has(next)) {
|
|
1079
|
+
visited.add(next);
|
|
1080
|
+
pending.push(next);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Evaluate an attribution/allocation policy boundary from one immutable
|
|
1088
|
+
* snapshot. AskAnalystRuntimeV1 invokes this before an optional provider
|
|
1089
|
+
* meaning call: a declared denial is neither an ambiguity nor a provider
|
|
1090
|
+
* failure. This helper only evaluates supplied evidence; it never retrieves
|
|
1091
|
+
* additional context or executes a query.
|
|
1092
|
+
*/
|
|
1093
|
+
export function attributionRequiredRelationshipGapDecision(input) {
|
|
1094
|
+
if (!EXPLICIT_ATTRIBUTION_REQUEST_RE.test(input.request.question)
|
|
1095
|
+
|| input.requirements.entityTerms.length === 0)
|
|
1096
|
+
return undefined;
|
|
1097
|
+
// Use the complete same-snapshot retrieval result. The compact provider
|
|
1098
|
+
// package is intentionally allowed to omit a relationship card; omission
|
|
1099
|
+
// cannot erase an authored governance restriction before it is evaluated.
|
|
1100
|
+
const snapshotCandidates = [
|
|
1101
|
+
...input.evidence.candidates,
|
|
1102
|
+
...(input.evidence.clarificationCandidates ?? []),
|
|
1103
|
+
].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
|
|
1104
|
+
const relationships = [...new Map(snapshotCandidates
|
|
1105
|
+
.flatMap((candidate) => candidate.relationshipSafety ?? [])
|
|
1106
|
+
.map((relationship) => [relationship.id, relationship])).values()]
|
|
1107
|
+
.filter((relationship) => Boolean(relationship.from?.trim() && relationship.to?.trim()));
|
|
1108
|
+
const attributionRequired = relationships.filter((relationship) => relationship.fanout === 'attribution_required');
|
|
1109
|
+
if (attributionRequired.length === 0)
|
|
1110
|
+
return undefined;
|
|
1111
|
+
const targetEndpoints = new Set(snapshotCandidates
|
|
1112
|
+
.flatMap((candidate) => [...candidateRelationshipEndpoints(candidate)])
|
|
1113
|
+
.filter((endpoint) => relationshipEndpointMatchesEntityTerms(endpoint, input.requirements.entityTerms)));
|
|
1114
|
+
if (targetEndpoints.size === 0)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const attributed = attributionRequired.find((relationship) => {
|
|
1117
|
+
const endpoints = [
|
|
1118
|
+
scopedRelationshipEndpointIdentity(relationship.from, relationship),
|
|
1119
|
+
scopedRelationshipEndpointIdentity(relationship.to, relationship),
|
|
1120
|
+
].filter(Boolean);
|
|
1121
|
+
const requestedSignalEndpoints = new Set(endpoints.filter((endpoint) => relationshipEndpointMatchesQuestionConcept(endpoint, input.request.question)));
|
|
1122
|
+
if (requestedSignalEndpoints.size === 0)
|
|
1123
|
+
return false;
|
|
1124
|
+
// The declared graph can demonstrate why the requested entity would need
|
|
1125
|
+
// this relation, but only a fully certified/fanout-safe graph may
|
|
1126
|
+
// authorize automatic composition. A path that requires this edge is a
|
|
1127
|
+
// typed modeling gap, never a reason to invent an attribution join.
|
|
1128
|
+
const allReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, relationships);
|
|
1129
|
+
const safeRelationships = relationships.filter((candidate) => relationshipSafetyAllowsAutomaticJoin(candidate));
|
|
1130
|
+
const safelyReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, safeRelationships);
|
|
1131
|
+
if (safelyReachable)
|
|
1132
|
+
return false;
|
|
1133
|
+
if (allReachable)
|
|
1134
|
+
return true;
|
|
1135
|
+
// The compact route workspace may omit an otherwise-safe continuation
|
|
1136
|
+
// edge, but it must not erase a declared attribution boundary for the
|
|
1137
|
+
// exact signal the user named. We have already established a direct,
|
|
1138
|
+
// snapshot-bound match for that signal and a requested entity. Returning
|
|
1139
|
+
// a typed modeling gap is safer than presenting unrelated metric choices
|
|
1140
|
+
// or attempting an unapproved attribution join.
|
|
1141
|
+
return true;
|
|
1142
|
+
});
|
|
1143
|
+
if (!attributed)
|
|
1144
|
+
return undefined;
|
|
1145
|
+
const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
|
|
1146
|
+
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
1147
|
+
const skipped = (tier) => {
|
|
1148
|
+
const item = coverageFor(tier);
|
|
1149
|
+
return {
|
|
1150
|
+
version: 1,
|
|
1151
|
+
tier,
|
|
1152
|
+
outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
1153
|
+
candidateIds: item?.candidateIds ?? [],
|
|
1154
|
+
reason: item?.status === 'available'
|
|
1155
|
+
? `The ${tier} tier did not prove the complete attribution-safe requested tuple before plan freeze.`
|
|
1156
|
+
: `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
|
|
1157
|
+
planFrozen: false,
|
|
1158
|
+
};
|
|
1159
|
+
};
|
|
1160
|
+
const message = 'The requested un-attributed signal requires a certified attribution relationship or approved allocation policy before it can be ranked by the requested entity. DQL did not infer a relationship or execute a query.';
|
|
1161
|
+
const witnessCandidateIds = [attributed.id];
|
|
1162
|
+
const terminalGap = {
|
|
1163
|
+
code: 'MISSING_RELATIONSHIP',
|
|
1164
|
+
missing: ['a certified attribution relationship or approved allocation policy for the requested un-attributed signal'],
|
|
1165
|
+
witnessCandidateIds,
|
|
1166
|
+
};
|
|
1167
|
+
const observer = askTraceObserverForV1(input.request);
|
|
1168
|
+
for (const candidate of snapshotCandidates.filter((candidate) => (candidate.relationshipSafety ?? []).some((relationship) => relationship.id === attributed.id))) {
|
|
1169
|
+
observer.recordCandidateDecision({
|
|
1170
|
+
candidateId: candidate.id,
|
|
1171
|
+
role: 'relationship',
|
|
1172
|
+
source: traceSourceForCandidate(candidate),
|
|
1173
|
+
decision: 'excluded',
|
|
1174
|
+
reasonCode: 'policy_denied',
|
|
1175
|
+
compatibilityCode: 'unknown',
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
...input.base,
|
|
1180
|
+
action: 'block',
|
|
1181
|
+
confidence: 1,
|
|
1182
|
+
reason: message,
|
|
1183
|
+
source: 'heuristic',
|
|
1184
|
+
category: 'data_lookup',
|
|
1185
|
+
depth: 'quick',
|
|
1186
|
+
requiresClarification: false,
|
|
1187
|
+
clarifyingQuestion: undefined,
|
|
1188
|
+
clarificationOptions: undefined,
|
|
1189
|
+
retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
|
|
1190
|
+
terminalOutcome: {
|
|
1191
|
+
kind: 'modeling_gap',
|
|
1192
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
1193
|
+
message,
|
|
1194
|
+
candidateIds: witnessCandidateIds,
|
|
1195
|
+
gap: terminalGap,
|
|
1196
|
+
},
|
|
1197
|
+
analyticalCascadeDecision: buildAnalyticalCascadeDecision({
|
|
1198
|
+
requirements: input.requirements,
|
|
1199
|
+
sourceCoverage: coverage,
|
|
1200
|
+
attempts: [
|
|
1201
|
+
skipped('certified'),
|
|
1202
|
+
skipped('semantic'),
|
|
1203
|
+
{
|
|
1204
|
+
version: 1,
|
|
1205
|
+
tier: 'governed_relational',
|
|
1206
|
+
outcome: 'denied',
|
|
1207
|
+
candidateIds: witnessCandidateIds,
|
|
1208
|
+
reason: 'The declared relationship requires attribution or allocation approval and cannot authorize the requested ranking.',
|
|
1209
|
+
planFrozen: false,
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
planFrozen: false,
|
|
1213
|
+
terminalGap: cascadeTerminalRelationshipGap(terminalGap),
|
|
1214
|
+
stopReason: 'denied',
|
|
1215
|
+
}),
|
|
1216
|
+
resolvedAnalyticalPlan: undefined,
|
|
1217
|
+
meaningResolution: undefined,
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
599
1220
|
/**
|
|
600
1221
|
* A proof may be structurally safe yet belong to a different domain's relation
|
|
601
1222
|
* with the same leaf name. The selected pair must match the proof's canonical
|
|
@@ -609,9 +1230,9 @@ function relationshipProofMatchesSelectedPair(safety, left, right) {
|
|
|
609
1230
|
return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
|
|
610
1231
|
|| (leftEndpoints.has(to) && rightEndpoints.has(from))));
|
|
611
1232
|
}
|
|
612
|
-
function safeRelationshipProofsForPair(left, right, requiredJoinKey) {
|
|
613
|
-
const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey);
|
|
614
|
-
const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey);
|
|
1233
|
+
function safeRelationshipProofsForPair(left, right, requiredJoinKey, authority = 'governed') {
|
|
1234
|
+
const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey, authority);
|
|
1235
|
+
const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey, authority);
|
|
615
1236
|
const shared = new Map();
|
|
616
1237
|
for (const [id, proof] of leftProofs) {
|
|
617
1238
|
const pairedProof = rightProofs.get(id);
|
|
@@ -637,7 +1258,13 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
637
1258
|
// their safe-value validation happens independently from proving the raw
|
|
638
1259
|
// physical closure. Treating them as fields made a valid relation appear
|
|
639
1260
|
// unmodeled and produced a false absence diagnostic.
|
|
640
|
-
const terms = [...new Set([
|
|
1261
|
+
const terms = [...new Set([
|
|
1262
|
+
...missingDimensions,
|
|
1263
|
+
...requirements.measures,
|
|
1264
|
+
...requirements.entityDisplayTerms,
|
|
1265
|
+
...(requirements.outputTerms ?? []),
|
|
1266
|
+
...requiredPhysicalFieldTerms,
|
|
1267
|
+
]
|
|
641
1268
|
.map(normalizeMetricPhrase)
|
|
642
1269
|
.filter(Boolean))];
|
|
643
1270
|
if (terms.length === 0) {
|
|
@@ -668,6 +1295,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
668
1295
|
const physicalRoleAliases = {
|
|
669
1296
|
'product category': ['product type'],
|
|
670
1297
|
category: ['product type'],
|
|
1298
|
+
customer: ['customer name'],
|
|
1299
|
+
account: ['account name'],
|
|
671
1300
|
// `product_price` is a local revenue witness only when the same request
|
|
672
1301
|
// also requires product-category grain. It must not let a bare selected
|
|
673
1302
|
// revenue metric bypass its semantic contract through an unrelated raw
|
|
@@ -762,7 +1391,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
762
1391
|
// routinely contains unrelated raw tables; requiring every one to join made
|
|
763
1392
|
// a complete local path appear unavailable. Conversely, never infer a join
|
|
764
1393
|
// from names or shared column strings: the proof must retain the structured
|
|
765
|
-
//
|
|
1394
|
+
// exploratory-safe disposition above. A selected path remains
|
|
1395
|
+
// review-required; it is not a governed relational plan.
|
|
766
1396
|
const adjacent = relations.map(() => new Set());
|
|
767
1397
|
const joinColumnsForProof = (proof, left, right) => {
|
|
768
1398
|
const leftEndpoints = candidateRelationshipEndpoints(left);
|
|
@@ -793,7 +1423,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
793
1423
|
const witnessesForEdge = new Map();
|
|
794
1424
|
for (let left = 0; left < relations.length; left += 1) {
|
|
795
1425
|
for (let right = left + 1; right < relations.length; right += 1) {
|
|
796
|
-
const shared = [...safeRelationshipProofsForPair(relations[left], relations[right]).values()]
|
|
1426
|
+
const shared = [...safeRelationshipProofsForPair(relations[left], relations[right], undefined, 'exploratory').values()]
|
|
797
1427
|
.sort((first, second) => first.id.localeCompare(second.id))
|
|
798
1428
|
.flatMap((proof) => {
|
|
799
1429
|
const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
|
|
@@ -853,7 +1483,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
853
1483
|
return {
|
|
854
1484
|
ok: false,
|
|
855
1485
|
candidateIds: [],
|
|
856
|
-
reason: 'Multiple physical relations lacked one connected,
|
|
1486
|
+
reason: 'Multiple physical relations lacked one connected, structured, fanout-safe exploratory join path.',
|
|
857
1487
|
// This is not a lexical conclusion. Every requested physical field was
|
|
858
1488
|
// found above, no one relation covered the tuple, and the structured
|
|
859
1489
|
// relationship/fanout proof graph could not connect the required
|
|
@@ -861,7 +1491,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
861
1491
|
// receipts and repair guidance.
|
|
862
1492
|
gap: {
|
|
863
1493
|
code: 'MISSING_RELATIONSHIP',
|
|
864
|
-
missing: ['a connected
|
|
1494
|
+
missing: ['a connected, explicitly allowed, validated, fanout-safe relationship proof'],
|
|
865
1495
|
witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
|
|
866
1496
|
},
|
|
867
1497
|
};
|
|
@@ -875,7 +1505,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
875
1505
|
if (selectedWitnesses.length !== selected.edges.length) {
|
|
876
1506
|
return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
|
|
877
1507
|
}
|
|
878
|
-
return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and
|
|
1508
|
+
return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and explicitly allowed relationship proofs support a bounded exploratory plan.');
|
|
879
1509
|
}
|
|
880
1510
|
/**
|
|
881
1511
|
* The pre-freeze cascade is router authority, including when the bounded
|
|
@@ -888,27 +1518,84 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
|
|
|
888
1518
|
function preFreezePhysicalCascadeDecision(input) {
|
|
889
1519
|
// `candidates` can be the capped meaning package. Physical eligibility is
|
|
890
1520
|
// allowed one same-snapshot extension, never a new retrieval/domain scope.
|
|
891
|
-
const snapshotCandidates = input.evidence.candidates.
|
|
892
|
-
? input.evidence.candidates
|
|
893
|
-
: input.candidates;
|
|
1521
|
+
const snapshotCandidates = targetScopedSameSnapshotCandidates(input.evidence, input.candidates, input.targetScope, input.executionCandidateIds);
|
|
894
1522
|
const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
|
|
895
1523
|
const missingRankingMetric = Boolean(physicalPath.ok
|
|
896
1524
|
&& input.requireRankingMetric
|
|
897
1525
|
&& input.requirements.ranking
|
|
898
1526
|
&& input.requirements.ranking.metricTerms.length === 0);
|
|
899
1527
|
const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
|
|
1528
|
+
const selectedPhysicalLegacyIds = snapshotCandidates
|
|
1529
|
+
.filter((candidate) => physicalPath.candidateIds.includes(candidate.qualifiedId ?? candidate.id))
|
|
1530
|
+
.map((candidate) => candidate.id);
|
|
1531
|
+
const selectedPhysicalIdsForPlan = selectedPhysicalLegacyIds.length > 0
|
|
1532
|
+
? selectedPhysicalLegacyIds
|
|
1533
|
+
: physicalPath.candidateIds;
|
|
1534
|
+
// The physical cascade is still an authoritative Ask route, not a legacy
|
|
1535
|
+
// escape hatch. Recreate the same host-owned seed used before the meaning
|
|
1536
|
+
// call so a semantic miss cannot erase an explicit entity display key,
|
|
1537
|
+
// member/filter, ranking limit, time role, or output tuple on the way to
|
|
1538
|
+
// review-required exploration.
|
|
1539
|
+
const hostRequirementSeed = buildAnalyticalRequirementSeedV1({
|
|
1540
|
+
question: input.question,
|
|
1541
|
+
parsedIntent: input.evidence.parsedIntent,
|
|
1542
|
+
requirements: input.requirements,
|
|
1543
|
+
fiscalCalendar: declaredFiscalCalendar(input.evidence, snapshotCandidates),
|
|
1544
|
+
});
|
|
1545
|
+
// The router, not the SQL generator, owns analytical meaning and the
|
|
1546
|
+
// physical closure. Freeze that selected exploratory plan before a model
|
|
1547
|
+
// sees SQL work. The host later attaches an authorization receipt for the
|
|
1548
|
+
// exact read-only SQL/target; it must never be the event that changes the
|
|
1549
|
+
// selected tier or reinterprets this request.
|
|
1550
|
+
const exploratoryMeaning = exploratoryExecutable
|
|
1551
|
+
? {
|
|
1552
|
+
interpretedQuestion: input.question,
|
|
1553
|
+
questionType: questionTypeFromText(input.question),
|
|
1554
|
+
selectedConceptIds: selectedPhysicalIdsForPlan,
|
|
1555
|
+
recommendedExecutionId: selectedPhysicalIdsForPlan[0],
|
|
1556
|
+
queryIntent: queryIntentFromRequirementSeed(hostRequirementSeed),
|
|
1557
|
+
rejectedCandidates: [],
|
|
1558
|
+
// Low confidence is intentionally non-executable in a resolved plan.
|
|
1559
|
+
// This is a router-proven physical closure, not a speculative model
|
|
1560
|
+
// answer, so the confidence describes the selected route only.
|
|
1561
|
+
confidence: 'high',
|
|
1562
|
+
missingInformation: [],
|
|
1563
|
+
recommendedRoute: 'exploratory',
|
|
1564
|
+
hostRequirementSeed,
|
|
1565
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1566
|
+
compatibilityFailures: input.missingTerms.map((term) => ({
|
|
1567
|
+
code: 'MISSING_DIMENSION',
|
|
1568
|
+
field: term,
|
|
1569
|
+
message: `${term} was not complete in the earlier governed tiers.`,
|
|
1570
|
+
candidateIds: [],
|
|
1571
|
+
})),
|
|
1572
|
+
}
|
|
1573
|
+
: undefined;
|
|
1574
|
+
const resolvedAnalyticalPlan = exploratoryMeaning
|
|
1575
|
+
? buildResolvedAnalyticalPlan({
|
|
1576
|
+
question: input.question,
|
|
1577
|
+
resolution: exploratoryMeaning,
|
|
1578
|
+
evidence: input.evidence,
|
|
1579
|
+
candidates: snapshotCandidates,
|
|
1580
|
+
mode: 'authoritative',
|
|
1581
|
+
})
|
|
1582
|
+
: undefined;
|
|
1583
|
+
const exploratoryPlanFrozen = resolvedAnalyticalPlan?.capability === 'bounded_exploration';
|
|
900
1584
|
const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
|
|
901
1585
|
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
902
1586
|
const skippedAttempt = (tier, source) => {
|
|
903
1587
|
const item = coverageFor(source);
|
|
1588
|
+
const semanticCompilerUnavailable = tier === 'semantic' && input.semanticTierUnavailable;
|
|
904
1589
|
return {
|
|
905
1590
|
version: 1,
|
|
906
1591
|
tier,
|
|
907
|
-
outcome: item?.status
|
|
1592
|
+
outcome: semanticCompilerUnavailable || item?.status !== 'available' ? 'unavailable' : 'ineligible',
|
|
908
1593
|
candidateIds: item?.candidateIds ?? [],
|
|
909
|
-
reason:
|
|
910
|
-
?
|
|
911
|
-
:
|
|
1594
|
+
reason: semanticCompilerUnavailable
|
|
1595
|
+
? 'The selected semantic compiler was unavailable before plan freeze.'
|
|
1596
|
+
: item?.status === 'available'
|
|
1597
|
+
? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
|
|
1598
|
+
: `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
|
|
912
1599
|
planFrozen: false,
|
|
913
1600
|
};
|
|
914
1601
|
};
|
|
@@ -934,7 +1621,7 @@ function preFreezePhysicalCascadeDecision(input) {
|
|
|
934
1621
|
reason: missingRankingMetric
|
|
935
1622
|
? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
|
|
936
1623
|
: physicalPath.reason,
|
|
937
|
-
planFrozen:
|
|
1624
|
+
planFrozen: exploratoryPlanFrozen,
|
|
938
1625
|
},
|
|
939
1626
|
];
|
|
940
1627
|
const message = exploratoryExecutable
|
|
@@ -956,7 +1643,7 @@ function preFreezePhysicalCascadeDecision(input) {
|
|
|
956
1643
|
planFrozen: false,
|
|
957
1644
|
}],
|
|
958
1645
|
...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
|
|
959
|
-
planFrozen:
|
|
1646
|
+
planFrozen: exploratoryPlanFrozen,
|
|
960
1647
|
stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
|
|
961
1648
|
});
|
|
962
1649
|
if (!exploratoryExecutable) {
|
|
@@ -997,29 +1684,131 @@ function preFreezePhysicalCascadeDecision(input) {
|
|
|
997
1684
|
clarificationOptions: undefined,
|
|
998
1685
|
retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
|
|
999
1686
|
analyticalCascadeDecision,
|
|
1000
|
-
meaningResolution:
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1687
|
+
meaningResolution: exploratoryMeaning,
|
|
1688
|
+
resolvedAnalyticalPlan,
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* The compact meaning package is deliberately not a proof-of-absence set.
|
|
1693
|
+
* Preserve the complete immutable retrieval snapshot for physical closure and
|
|
1694
|
+
* structured-continuation checks, while retaining a caller's server-issued
|
|
1695
|
+
* selection when it was supplied as a same-snapshot clarification card.
|
|
1696
|
+
*/
|
|
1697
|
+
function immutableSnapshotCandidates(evidence, candidates, executionCandidateIds) {
|
|
1698
|
+
const byId = new Map();
|
|
1699
|
+
for (const candidate of [
|
|
1700
|
+
...evidence.candidates,
|
|
1701
|
+
...(evidence.clarificationCandidates ?? []),
|
|
1702
|
+
...candidates,
|
|
1703
|
+
]) {
|
|
1704
|
+
if (!byId.has(candidate.id))
|
|
1705
|
+
byId.set(candidate.id, candidate);
|
|
1706
|
+
}
|
|
1707
|
+
const snapshot = [...byId.values()];
|
|
1708
|
+
if (!executionCandidateIds)
|
|
1709
|
+
return snapshot;
|
|
1710
|
+
const allowed = new Set(executionCandidateIds);
|
|
1711
|
+
return snapshot.filter((candidate) => allowed.has(candidate.id) || allowed.has(candidate.qualifiedId ?? candidate.id));
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* The one permitted physical extension stays inside the already acquired
|
|
1715
|
+
* snapshot and, when the active target is represented in source provenance,
|
|
1716
|
+
* restricts physical cards to that target. This prevents a semantic target
|
|
1717
|
+
* mismatch from being misreported as global schema absence while retaining
|
|
1718
|
+
* relationship cards needed to prove a safe closure.
|
|
1719
|
+
*/
|
|
1720
|
+
function targetScopedSameSnapshotCandidates(evidence, candidates, targetScope, executionCandidateIds) {
|
|
1721
|
+
const snapshot = immutableSnapshotCandidates(evidence, candidates, executionCandidateIds);
|
|
1722
|
+
if (!targetScope)
|
|
1723
|
+
return snapshot;
|
|
1724
|
+
const normalized = targetScope.toLowerCase();
|
|
1725
|
+
const physical = snapshot.filter((candidate) => candidate.kind === 'sql_table'
|
|
1726
|
+
|| candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source');
|
|
1727
|
+
const scopedPhysical = physical.filter((candidate) => [
|
|
1728
|
+
...(candidate.sourceObjects ?? []),
|
|
1729
|
+
candidate.provenance ?? '',
|
|
1730
|
+
candidate.semanticModel ?? '',
|
|
1731
|
+
candidate.domain ?? '',
|
|
1732
|
+
].some((value) => value.toLowerCase().includes(normalized)));
|
|
1733
|
+
// Provenance often does not carry a connection name in older indexes. In
|
|
1734
|
+
// that case retain the immutable snapshot rather than inventing absence.
|
|
1735
|
+
if (scopedPhysical.length === 0)
|
|
1736
|
+
return snapshot;
|
|
1737
|
+
const scopedIds = new Set(scopedPhysical.map((candidate) => candidate.id));
|
|
1738
|
+
return snapshot.filter((candidate) => !physical.includes(candidate)
|
|
1739
|
+
|| scopedIds.has(candidate.id)
|
|
1740
|
+
|| candidate.kind === 'dql_modeling'
|
|
1741
|
+
|| (candidate.relationshipEvidence?.length ?? 0) > 0);
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* A normal meaning call may nominate either a semantic metric or a governed
|
|
1745
|
+
* modeling card whose declared execution contract cannot satisfy the exact
|
|
1746
|
+
* tuple. Before presenting that *pre-freeze* modeling gap as terminal,
|
|
1747
|
+
* inspect the complete immutable snapshot for one bounded, relationship-safe
|
|
1748
|
+
* physical closure. This is not a post-freeze downgrade: policy, fiscal, and
|
|
1749
|
+
* unsafe failures remain blocked and a safe result is explicitly
|
|
1750
|
+
* review-required exploratory work.
|
|
1751
|
+
*/
|
|
1752
|
+
function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
|
|
1753
|
+
const { decision, evidence } = input;
|
|
1754
|
+
const plan = decision.resolvedAnalyticalPlan;
|
|
1755
|
+
if (!plan
|
|
1756
|
+
|| decision.terminalOutcome?.kind !== 'modeling_gap'
|
|
1757
|
+
|| decision.analyticalCascadeDecision?.planFrozen === true
|
|
1758
|
+
|| (plan.recommendedRoute !== 'semantic' && plan.recommendedRoute !== 'governed_sql'))
|
|
1759
|
+
return decision;
|
|
1760
|
+
const selectedPreFreezeTier = plan.recommendedRoute;
|
|
1761
|
+
const requirements = buildAnalyticalRequirementSet({
|
|
1762
|
+
question: input.question,
|
|
1763
|
+
parsedIntent: input.evidence.parsedIntent,
|
|
1764
|
+
});
|
|
1765
|
+
const unresolved = plan.resolutionFailure?.bindings
|
|
1766
|
+
.filter((binding) => binding.status !== 'resolved')
|
|
1767
|
+
.map((binding) => binding.requested) ?? [];
|
|
1768
|
+
const missingTerms = [...new Set([
|
|
1769
|
+
...unresolved,
|
|
1770
|
+
...requirements.dimensions,
|
|
1771
|
+
...requirements.entityTerms,
|
|
1772
|
+
...requirements.entityDisplayTerms,
|
|
1773
|
+
...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
|
|
1774
|
+
])];
|
|
1775
|
+
const requiredPhysicalFieldTerms = [...new Set([
|
|
1776
|
+
...requirements.dimensions,
|
|
1777
|
+
...requirements.entityTerms,
|
|
1778
|
+
...requirements.entityDisplayTerms,
|
|
1779
|
+
...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
|
|
1780
|
+
...(plan.query.filters ?? []).map((filter) => filter.field),
|
|
1781
|
+
...unresolved,
|
|
1782
|
+
])];
|
|
1783
|
+
const physicalContinuation = preFreezePhysicalCascadeDecision({
|
|
1784
|
+
base: input.base,
|
|
1785
|
+
evidence,
|
|
1786
|
+
candidates: immutableSnapshotCandidates(evidence, input.candidates, input.executionCandidateIds),
|
|
1787
|
+
question: input.question,
|
|
1788
|
+
requirements,
|
|
1789
|
+
missingTerms,
|
|
1790
|
+
requiredPhysicalFieldTerms,
|
|
1791
|
+
messagePrefix: selectedPreFreezeTier === 'governed_sql'
|
|
1792
|
+
? 'The selected governed relational interpretation did not prove a compiler-owned DQL projection before plan freeze.'
|
|
1793
|
+
: 'The selected semantic interpretation was pre-freeze-ineligible for the complete requested tuple.',
|
|
1794
|
+
terminalCandidateIds: plan.selectedConceptIds,
|
|
1795
|
+
...(input.executionCandidateIds ? { executionCandidateIds: input.executionCandidateIds } : {}),
|
|
1796
|
+
});
|
|
1797
|
+
if (physicalContinuation.action === 'answer'
|
|
1798
|
+
&& physicalContinuation.analyticalCascadeDecision?.selectedTier === 'exploratory_sql') {
|
|
1799
|
+
return physicalContinuation;
|
|
1800
|
+
}
|
|
1801
|
+
// The physical extension was considered and was not safe/executable. Keep
|
|
1802
|
+
// the original immutable semantic plan and its typed failure rather than
|
|
1803
|
+
// replacing it with a synthetic exploratory meaning; surface the full
|
|
1804
|
+
// snapshot cascade evidence so the terminal block explains why it could not
|
|
1805
|
+
// continue. This retains policy/unsafe/fiscal terminal semantics.
|
|
1806
|
+
return {
|
|
1807
|
+
...decision,
|
|
1808
|
+
reason: physicalContinuation.reason,
|
|
1809
|
+
retrievalEvidence: physicalContinuation.retrievalEvidence,
|
|
1810
|
+
analyticalCascadeDecision: physicalContinuation.analyticalCascadeDecision,
|
|
1811
|
+
terminalOutcome: physicalContinuation.terminalOutcome ?? decision.terminalOutcome,
|
|
1023
1812
|
};
|
|
1024
1813
|
}
|
|
1025
1814
|
/**
|
|
@@ -1102,7 +1891,7 @@ function findSafeComplementaryPhysicalComposition(question, candidates) {
|
|
|
1102
1891
|
const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
|
|
1103
1892
|
if (!joinKey)
|
|
1104
1893
|
continue;
|
|
1105
|
-
const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey).keys()];
|
|
1894
|
+
const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey, 'exploratory').keys()];
|
|
1106
1895
|
if (sharedRelationshipProofIds.length === 0)
|
|
1107
1896
|
continue;
|
|
1108
1897
|
const selectedPhysical = [
|
|
@@ -1148,10 +1937,40 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
|
|
|
1148
1937
|
// Use all already-retrieved cards from the same snapshot. The bounded meaning
|
|
1149
1938
|
// package can omit a supporting raw join column, but no new retrieval/domain
|
|
1150
1939
|
// scope is opened here.
|
|
1151
|
-
const
|
|
1940
|
+
const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
|
|
1941
|
+
const composition = findSafeComplementaryPhysicalComposition(question, snapshotCandidates);
|
|
1152
1942
|
if (!composition)
|
|
1153
1943
|
return undefined;
|
|
1154
|
-
const coverage = sourceCoverageFromEvidence(evidence,
|
|
1944
|
+
const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
|
|
1945
|
+
const exploratoryMeaning = {
|
|
1946
|
+
interpretedQuestion: question,
|
|
1947
|
+
questionType: questionTypeFromText(question),
|
|
1948
|
+
selectedConceptIds: composition.candidateIds,
|
|
1949
|
+
recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
|
|
1950
|
+
queryIntent: {
|
|
1951
|
+
measures: [],
|
|
1952
|
+
dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
|
|
1953
|
+
filters: [{
|
|
1954
|
+
field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
|
|
1955
|
+
value: 'true',
|
|
1956
|
+
}],
|
|
1957
|
+
},
|
|
1958
|
+
rejectedCandidates: [],
|
|
1959
|
+
confidence: 'high',
|
|
1960
|
+
missingInformation: [],
|
|
1961
|
+
recommendedRoute: 'exploratory',
|
|
1962
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1963
|
+
};
|
|
1964
|
+
const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
|
|
1965
|
+
question,
|
|
1966
|
+
resolution: exploratoryMeaning,
|
|
1967
|
+
evidence,
|
|
1968
|
+
candidates: snapshotCandidates,
|
|
1969
|
+
mode: 'authoritative',
|
|
1970
|
+
});
|
|
1971
|
+
const exploratoryPlanFrozen = resolvedAnalyticalPlan.capability === 'bounded_exploration';
|
|
1972
|
+
if (!exploratoryPlanFrozen)
|
|
1973
|
+
return undefined;
|
|
1155
1974
|
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
1156
1975
|
const skippedAttempt = (tier, source, reason) => {
|
|
1157
1976
|
const item = coverageFor(source);
|
|
@@ -1184,7 +2003,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
|
|
|
1184
2003
|
outcome: 'executable',
|
|
1185
2004
|
candidateIds: composition.candidateIds,
|
|
1186
2005
|
reason: composition.reason,
|
|
1187
|
-
planFrozen:
|
|
2006
|
+
planFrozen: exploratoryPlanFrozen,
|
|
1188
2007
|
},
|
|
1189
2008
|
];
|
|
1190
2009
|
const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
|
|
@@ -1192,7 +2011,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
|
|
|
1192
2011
|
sourceCoverage: coverage,
|
|
1193
2012
|
attempts,
|
|
1194
2013
|
selectedTier: 'exploratory_sql',
|
|
1195
|
-
planFrozen:
|
|
2014
|
+
planFrozen: exploratoryPlanFrozen,
|
|
1196
2015
|
stopReason: 'selected',
|
|
1197
2016
|
});
|
|
1198
2017
|
return {
|
|
@@ -1204,27 +2023,10 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
|
|
|
1204
2023
|
depth: 'quick',
|
|
1205
2024
|
reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
|
|
1206
2025
|
requiresClarification: false,
|
|
1207
|
-
retrievalEvidence: retrievalTrace(evidence,
|
|
2026
|
+
retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
|
|
1208
2027
|
analyticalCascadeDecision,
|
|
1209
|
-
resolvedAnalyticalPlan
|
|
1210
|
-
meaningResolution:
|
|
1211
|
-
interpretedQuestion: question,
|
|
1212
|
-
questionType: questionTypeFromText(question),
|
|
1213
|
-
selectedConceptIds: composition.candidateIds,
|
|
1214
|
-
recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
|
|
1215
|
-
queryIntent: {
|
|
1216
|
-
measures: [],
|
|
1217
|
-
dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
|
|
1218
|
-
filters: [{
|
|
1219
|
-
field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
|
|
1220
|
-
value: 'true',
|
|
1221
|
-
}],
|
|
1222
|
-
},
|
|
1223
|
-
rejectedCandidates: [],
|
|
1224
|
-
confidence: 'low',
|
|
1225
|
-
missingInformation: ['No certified or semantic route proved the complete composed request.'],
|
|
1226
|
-
recommendedRoute: 'exploratory',
|
|
1227
|
-
},
|
|
2028
|
+
resolvedAnalyticalPlan,
|
|
2029
|
+
meaningResolution: exploratoryMeaning,
|
|
1228
2030
|
};
|
|
1229
2031
|
}
|
|
1230
2032
|
function cascadeForResolution(input) {
|
|
@@ -1262,6 +2064,7 @@ function cascadeForResolution(input) {
|
|
|
1262
2064
|
attempts,
|
|
1263
2065
|
...(selectedTier ? { selectedTier } : {}),
|
|
1264
2066
|
planFrozen: frozen,
|
|
2067
|
+
...(input.terminalGap ? { terminalGap: input.terminalGap } : {}),
|
|
1265
2068
|
stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
|
|
1266
2069
|
});
|
|
1267
2070
|
}
|
|
@@ -1304,6 +2107,9 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
1304
2107
|
const analytical = routedResolution.questionType === "diagnosis" ||
|
|
1305
2108
|
routedResolution.questionType === "research";
|
|
1306
2109
|
const reason = reconciliation.reason;
|
|
2110
|
+
const terminalGap = reconciliation.outcome === 'modeling_gap'
|
|
2111
|
+
? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
|
|
2112
|
+
: undefined;
|
|
1307
2113
|
const analyticalCascadeDecision = cascadeForResolution({
|
|
1308
2114
|
evidence,
|
|
1309
2115
|
candidates,
|
|
@@ -1311,12 +2117,22 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
1311
2117
|
plan: resolvedAnalyticalPlan,
|
|
1312
2118
|
reconciliation,
|
|
1313
2119
|
question,
|
|
2120
|
+
...(terminalGap ? { terminalGap: cascadeTerminalRelationshipGap(terminalGap) } : {}),
|
|
1314
2121
|
});
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
2122
|
+
const declaredDimensionAssumptions = assumptionsForDeclaredDimensionAlternatives({
|
|
2123
|
+
question,
|
|
2124
|
+
evidence,
|
|
2125
|
+
candidates,
|
|
2126
|
+
plan: resolvedAnalyticalPlan,
|
|
2127
|
+
});
|
|
2128
|
+
// The deterministic pre-router may attach a generic soft clarification
|
|
2129
|
+
// while retrieval is still incomplete. Once this same turn has bound and
|
|
2130
|
+
// frozen a plan, that stale prompt must not leak into a completed answer or
|
|
2131
|
+
// its trace. Keep a clarification only when reconciliation itself says the
|
|
2132
|
+
// final immutable tuple is genuinely ambiguous.
|
|
2133
|
+
const { clarifyingQuestion: _staleClarifyingQuestion, clarificationOptions: _staleClarificationOptions, clarifySoft: _staleClarifySoft, ...baseWithoutStaleClarification } = base;
|
|
1318
2134
|
return {
|
|
1319
|
-
...
|
|
2135
|
+
...baseWithoutStaleClarification,
|
|
1320
2136
|
action: needsClarification
|
|
1321
2137
|
? "clarify"
|
|
1322
2138
|
: terminallyBlocked
|
|
@@ -1359,8 +2175,270 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
1359
2175
|
...(needsClarification
|
|
1360
2176
|
? {
|
|
1361
2177
|
clarifyingQuestion: reconciliation.question,
|
|
2178
|
+
...(_staleClarifySoft ? { clarifySoft: true } : {}),
|
|
1362
2179
|
}
|
|
1363
2180
|
: {}),
|
|
2181
|
+
...(declaredDimensionAssumptions.length > 0
|
|
2182
|
+
? { assumptions: declaredDimensionAssumptions }
|
|
2183
|
+
: {}),
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Compiler-only bridge for AskAnalystRuntimeV1. The runtime owns question
|
|
2188
|
+
* framing, candidate admission, meaning selection, and the immutable program;
|
|
2189
|
+
* this adapter only validates that selected program against existing safe
|
|
2190
|
+
* certified/MetricFlow/relational/exploratory compilers. It must never call a
|
|
2191
|
+
* provider, retrieve a new snapshot, or nominate a different business meaning.
|
|
2192
|
+
*/
|
|
2193
|
+
export function compileAskAnalyticalProgramV1(input) {
|
|
2194
|
+
const readiness = input.request.askAnalystTierReadiness;
|
|
2195
|
+
// A compiler must never widen the program that the runtime froze. A stale
|
|
2196
|
+
// adapter-selected identity is a compilation failure, not an invitation to
|
|
2197
|
+
// rank/retrieve/interpret the question again.
|
|
2198
|
+
const executionCandidates = input.executionCandidates ?? input.candidates;
|
|
2199
|
+
const executionIds = new Set(input.program.executionCandidateIds ?? input.program.candidateIds);
|
|
2200
|
+
const executionCandidateIds = [...executionIds];
|
|
2201
|
+
const isProgramExecutionCandidate = (candidate) => executionIds.has(candidate.id) || executionIds.has(candidate.qualifiedId ?? candidate.id);
|
|
2202
|
+
if (executionCandidates.some((candidate) => !isProgramExecutionCandidate(candidate))) {
|
|
2203
|
+
return immutableProgramBlocked(input.base, 'A compiler received a candidate outside the frozen Ask execution closure.');
|
|
2204
|
+
}
|
|
2205
|
+
const seedFilters = input.request.hostRequirementSeed?.queryIntent.filters ?? [];
|
|
2206
|
+
if (!validProgramFilterBindings(input.program, seedFilters)) {
|
|
2207
|
+
return immutableProgramBlocked(input.base, 'The immutable Ask program did not retain valid field, operator, and value bindings for every requested filter.');
|
|
2208
|
+
}
|
|
2209
|
+
const unboundCurrentQuestionLiterals = unboundProgramV3CurrentQuestionLiterals(input.program);
|
|
2210
|
+
if (unboundCurrentQuestionLiterals.length > 0) {
|
|
2211
|
+
// This is a normal pre-freeze analytical coverage gap, not an immutable
|
|
2212
|
+
// program corruption or policy breach. The current question named a
|
|
2213
|
+
// value, but no single selected qualified field/value binding survived
|
|
2214
|
+
// verification. Reporting it as a typed gap prevents an unknown or
|
|
2215
|
+
// ambiguous literal from broadening the SQL while preserving the ordinary
|
|
2216
|
+
// cascade vocabulary for repair and trace rendering.
|
|
2217
|
+
return currentQuestionLiteralCoverageGapDecision({
|
|
2218
|
+
base: input.base,
|
|
2219
|
+
evidence: input.evidence,
|
|
2220
|
+
executionCandidates,
|
|
2221
|
+
requirements: input.requirements,
|
|
2222
|
+
program: input.program,
|
|
2223
|
+
literals: unboundCurrentQuestionLiterals,
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
const programIds = new Set(input.program.candidateIds);
|
|
2227
|
+
// The compiler receives the full frozen 32-card execution closure for
|
|
2228
|
+
// relationship/physical compilation, but only the program's verified
|
|
2229
|
+
// selected cards may authorize meaning. Do not treat closure membership as
|
|
2230
|
+
// selection authority: that would let a tail candidate change the question
|
|
2231
|
+
// after planner verification.
|
|
2232
|
+
const programIdentityIds = new Set(input.candidates
|
|
2233
|
+
.filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id))
|
|
2234
|
+
.flatMap((candidate) => [candidate.id, candidate.qualifiedId].filter((id) => Boolean(id))));
|
|
2235
|
+
const selectedIds = [
|
|
2236
|
+
...input.resolution.selectedConceptIds,
|
|
2237
|
+
...(input.resolution.recommendedExecutionId ? [input.resolution.recommendedExecutionId] : []),
|
|
2238
|
+
];
|
|
2239
|
+
if (selectedIds.some((id) => !programIds.has(id) && !programIdentityIds.has(id))) {
|
|
2240
|
+
return {
|
|
2241
|
+
...input.base,
|
|
2242
|
+
action: 'block',
|
|
2243
|
+
confidence: 1,
|
|
2244
|
+
source: 'heuristic',
|
|
2245
|
+
followsUp: false,
|
|
2246
|
+
reason: 'The immutable Ask program did not authorize every selected compiler identity.',
|
|
2247
|
+
terminalOutcome: {
|
|
2248
|
+
kind: 'policy_blocked',
|
|
2249
|
+
code: 'ANALYTICAL_POLICY_BLOCKED',
|
|
2250
|
+
message: 'A compiler attempted to consume a candidate outside the frozen Ask program.',
|
|
2251
|
+
candidateIds: selectedIds,
|
|
2252
|
+
},
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
// `candidates` is the runtime-verified business tuple. The larger frozen
|
|
2256
|
+
// execution closure is deliberately supplied only to the physical
|
|
2257
|
+
// continuation below. Passing it into the semantic router lets a related
|
|
2258
|
+
// metric or numeric attribute re-open meaning selection after program
|
|
2259
|
+
// verification (for example Orders + Drink Orders + customer order number
|
|
2260
|
+
// for the singular request "order count for each customer").
|
|
2261
|
+
const selectedMeaningCandidates = input.candidates.filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id));
|
|
2262
|
+
const decision = routeDecisionForResolution(input.base, input.evidence, selectedMeaningCandidates, input.resolution, 'heuristic', input.request.question, input.mode ?? 'authoritative');
|
|
2263
|
+
// A material business choice is resolved before target readiness. A target
|
|
2264
|
+
// being unavailable does not make two independently valid semantic display
|
|
2265
|
+
// meanings become one physical meaning; collapsing it first caused offline
|
|
2266
|
+
// structured clarification/restart flows to report a false coverage gap.
|
|
2267
|
+
// Once an immutable meaning is selected, readiness still advances an
|
|
2268
|
+
// unavailable semantic target through the same-snapshot physical cascade.
|
|
2269
|
+
if (decision.requiresClarification === true || decision.action === 'clarify') {
|
|
2270
|
+
return decision;
|
|
2271
|
+
}
|
|
2272
|
+
// Connector/active-target readiness is recorded separately because it is an
|
|
2273
|
+
// execution boundary, not necessarily a semantic *compiler* boundary. A
|
|
2274
|
+
// native semantic plan can be proven and frozen from an authored local
|
|
2275
|
+
// snapshot without a configured connection; execution then reports the
|
|
2276
|
+
// normal post-freeze setup failure. Adapter/target binding for MetricFlow or
|
|
2277
|
+
// another external semantic compiler is folded into semanticCompiler by the
|
|
2278
|
+
// host readiness probe, so only that state authorizes pre-freeze fallback.
|
|
2279
|
+
const semanticUnavailable = readiness?.semanticCompiler === 'unavailable';
|
|
2280
|
+
// A semantic adapter/target mismatch is a pre-freeze availability result,
|
|
2281
|
+
// not a modeling absence. A safe physical closure from this exact snapshot
|
|
2282
|
+
// may still answer as review-required exploratory SQL.
|
|
2283
|
+
if (semanticUnavailable && input.resolution.recommendedRoute === 'semantic') {
|
|
2284
|
+
return preFreezePhysicalCascadeDecision({
|
|
2285
|
+
base: input.base,
|
|
2286
|
+
evidence: input.evidence,
|
|
2287
|
+
candidates: executionCandidates,
|
|
2288
|
+
question: input.request.question,
|
|
2289
|
+
requirements: input.requirements,
|
|
2290
|
+
missingTerms: [
|
|
2291
|
+
...input.requirements.measures,
|
|
2292
|
+
...input.requirements.dimensions,
|
|
2293
|
+
...input.requirements.entityTerms,
|
|
2294
|
+
...input.requirements.entityDisplayTerms,
|
|
2295
|
+
],
|
|
2296
|
+
requiredPhysicalFieldTerms: [
|
|
2297
|
+
...input.requirements.measures,
|
|
2298
|
+
...input.requirements.dimensions,
|
|
2299
|
+
...input.requirements.entityTerms,
|
|
2300
|
+
...input.requirements.entityDisplayTerms,
|
|
2301
|
+
],
|
|
2302
|
+
messagePrefix: 'The semantic compiler or active target was unavailable before plan freeze; DQL evaluated the same-snapshot safe physical path independently.',
|
|
2303
|
+
terminalCandidateIds: input.resolution.selectedConceptIds,
|
|
2304
|
+
...(input.request.executionTarget?.target === 'connection' && input.request.executionTarget.connectionName
|
|
2305
|
+
? { targetScope: input.request.executionTarget.connectionName }
|
|
2306
|
+
: {}),
|
|
2307
|
+
executionCandidateIds,
|
|
2308
|
+
semanticTierUnavailable: true,
|
|
2309
|
+
requireRankingMetric: Boolean(input.requirements.ranking && input.requirements.ranking.metricTerms.length === 0),
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
// A selected semantic/governed plan may be invalid before it freezes. That
|
|
2313
|
+
// is explicitly allowed to advance, but a frozen, denied, or unsafe plan is
|
|
2314
|
+
// never silently downgraded.
|
|
2315
|
+
return continuePreFreezeModelingGapThroughPhysicalSnapshot({
|
|
2316
|
+
decision,
|
|
2317
|
+
base: input.base,
|
|
2318
|
+
evidence: input.evidence,
|
|
2319
|
+
candidates: executionCandidates,
|
|
2320
|
+
question: input.request.question,
|
|
2321
|
+
executionCandidateIds,
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
function immutableProgramBlocked(base, message) {
|
|
2325
|
+
return {
|
|
2326
|
+
...base,
|
|
2327
|
+
action: 'block',
|
|
2328
|
+
confidence: 1,
|
|
2329
|
+
source: 'heuristic',
|
|
2330
|
+
followsUp: false,
|
|
2331
|
+
reason: message,
|
|
2332
|
+
terminalOutcome: {
|
|
2333
|
+
kind: 'policy_blocked',
|
|
2334
|
+
code: 'ANALYTICAL_POLICY_BLOCKED',
|
|
2335
|
+
message,
|
|
2336
|
+
candidateIds: [],
|
|
2337
|
+
},
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function validProgramFilterBindings(program, seedFilters) {
|
|
2341
|
+
if (program.filters.some((filter) => filter.fieldTerms.length === 0 || !filter.fieldTerms.every(Boolean) || !filter.value.trim() || filter.operator === 'unknown'))
|
|
2342
|
+
return false;
|
|
2343
|
+
// Direct compiler consumers from before AskAnalystRuntimeV1 do not carry a
|
|
2344
|
+
// host requirement seed. They still must provide internally valid filters,
|
|
2345
|
+
// but cannot be compared to a seed that was never supplied. Authoritative
|
|
2346
|
+
// Ask always supplies one and therefore takes the exact-binding branch.
|
|
2347
|
+
if (seedFilters.length === 0)
|
|
2348
|
+
return true;
|
|
2349
|
+
// A program with no predicate is valid only when the host seed also has no
|
|
2350
|
+
// predicate. When a seed exists, exact normalized bindings make it
|
|
2351
|
+
// impossible for a compiler to drop, broaden, or replace a user filter.
|
|
2352
|
+
const normalize = (field, operator, value) => `${field.trim().toLowerCase()}|${operator.trim().toLowerCase()}|${value.trim().toLowerCase()}`;
|
|
2353
|
+
const expected = seedFilters.map((filter) => normalize(filter.field, 'equals', filter.value)).sort();
|
|
2354
|
+
const actual = program.filters
|
|
2355
|
+
.map((filter) => filter.fieldTerms.map((field) => normalize(field, filter.operator, filter.value)))
|
|
2356
|
+
.flat()
|
|
2357
|
+
.sort();
|
|
2358
|
+
return expected.length === actual.length && expected.every((value, index) => value === actual[index]);
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* V3 carries raw current-turn atoms specifically so an incomplete parser or
|
|
2362
|
+
* compatibility adapter cannot erase a literal and allow a broad certified
|
|
2363
|
+
* block to look complete. A literal must be represented by a frozen predicate
|
|
2364
|
+
* before any compiler can select a tier. Prior-result anchors are distinct
|
|
2365
|
+
* host-bound continuity evidence and therefore have their own source.
|
|
2366
|
+
*/
|
|
2367
|
+
function unboundProgramV3CurrentQuestionLiterals(program) {
|
|
2368
|
+
if (program.version !== 3)
|
|
2369
|
+
return [];
|
|
2370
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
2371
|
+
const frozenValues = new Set(program.filters.map((filter) => normalize(filter.value)));
|
|
2372
|
+
const trustedValues = new Set(program.trustedTaskAnchors
|
|
2373
|
+
.filter((anchor) => anchor.kind === 'member_binding' || anchor.kind === undefined)
|
|
2374
|
+
.flatMap((anchor) => anchor.values)
|
|
2375
|
+
.map(normalize));
|
|
2376
|
+
return program.inputAtoms
|
|
2377
|
+
.filter((atom) => atom.source === 'current_question' && atom.role === 'member')
|
|
2378
|
+
.map((atom) => atom.term)
|
|
2379
|
+
.filter((term) => !frozenValues.has(normalize(term)) && !trustedValues.has(normalize(term)));
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* A missing/ambiguous literal binding is an answerability gap, not evidence
|
|
2383
|
+
* that the already-built program was tampered with. Keep it pre-freeze and
|
|
2384
|
+
* show the actual terminal cause, so Ask can safely explain why it did not
|
|
2385
|
+
* generate a broad exploratory query.
|
|
2386
|
+
*/
|
|
2387
|
+
function currentQuestionLiteralCoverageGapDecision(input) {
|
|
2388
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
2389
|
+
const literalSet = new Set(input.literals.map(normalize));
|
|
2390
|
+
const matchingColumns = input.executionCandidates.filter((candidate) => candidate.kind === 'sql_column'
|
|
2391
|
+
&& evidenceCandidateRoles(candidate).includes('categorical_dimension')
|
|
2392
|
+
&& (candidate.safeValueEvidence ?? []).some((evidence) => literalSet.has(normalize(evidence.normalizedValue)) || literalSet.has(normalize(evidence.value))));
|
|
2393
|
+
const qualifiedColumnIds = [...new Set(matchingColumns.map((candidate) => candidate.qualifiedId ?? candidate.id))].sort();
|
|
2394
|
+
const ambiguity = qualifiedColumnIds.length > 1;
|
|
2395
|
+
const literalLabel = input.literals.map((literal) => `“${literal}”`).join(', ');
|
|
2396
|
+
const message = ambiguity
|
|
2397
|
+
? `The current-question value ${literalLabel} matched multiple qualified categorical fields in this metadata snapshot, so DQL did not choose or broaden a generated SQL filter.`
|
|
2398
|
+
: `The current-question value ${literalLabel} was not verified on one selected qualified categorical field in this metadata snapshot, so DQL did not broaden a generated SQL filter.`;
|
|
2399
|
+
const candidateIds = qualifiedColumnIds.length > 0
|
|
2400
|
+
? qualifiedColumnIds
|
|
2401
|
+
: [...new Set(input.program.candidateIds)].sort();
|
|
2402
|
+
const coverage = sourceCoverageFromEvidence(input.evidence, input.executionCandidates);
|
|
2403
|
+
const attempts = [
|
|
2404
|
+
{ version: 1, tier: 'certified', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [], reason: 'A current-question literal is not bound in the frozen program.', planFrozen: false },
|
|
2405
|
+
{ version: 1, tier: 'semantic', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [], reason: 'No selected semantic member binding proved the current literal.', planFrozen: false },
|
|
2406
|
+
{ version: 1, tier: 'governed_relational', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'governed_relational')?.candidateIds ?? [], reason: 'A relationship cannot bind an unverified literal value by itself.', planFrozen: false },
|
|
2407
|
+
{ version: 1, tier: 'exploratory_sql', outcome: 'unavailable', candidateIds: qualifiedColumnIds, reason: ambiguity ? 'More than one qualified physical field matched the literal.' : 'No selected qualified physical field had exact safe-value evidence for the literal.', planFrozen: false },
|
|
2408
|
+
{ version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds, reason: message, planFrozen: false },
|
|
2409
|
+
];
|
|
2410
|
+
return {
|
|
2411
|
+
...input.base,
|
|
2412
|
+
action: 'block',
|
|
2413
|
+
confidence: 1,
|
|
2414
|
+
followsUp: false,
|
|
2415
|
+
source: 'heuristic',
|
|
2416
|
+
category: 'data_lookup',
|
|
2417
|
+
depth: 'quick',
|
|
2418
|
+
reason: message,
|
|
2419
|
+
requiresClarification: false,
|
|
2420
|
+
retrievalEvidence: retrievalTrace(input.evidence, input.executionCandidates),
|
|
2421
|
+
resolvedAnalyticalPlan: undefined,
|
|
2422
|
+
analyticalCascadeDecision: buildAnalyticalCascadeDecision({
|
|
2423
|
+
requirements: input.requirements,
|
|
2424
|
+
sourceCoverage: coverage,
|
|
2425
|
+
attempts,
|
|
2426
|
+
planFrozen: false,
|
|
2427
|
+
stopReason: 'coverage_gap',
|
|
2428
|
+
}),
|
|
2429
|
+
terminalOutcome: {
|
|
2430
|
+
kind: 'modeling_gap',
|
|
2431
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
2432
|
+
message,
|
|
2433
|
+
candidateIds,
|
|
2434
|
+
gap: {
|
|
2435
|
+
code: 'MISSING_ATTRIBUTE',
|
|
2436
|
+
missing: ambiguity
|
|
2437
|
+
? ['one unambiguous qualified categorical field for the current-question value']
|
|
2438
|
+
: ['one qualified categorical field with exact safe-value evidence for the current-question value'],
|
|
2439
|
+
witnessCandidateIds: candidateIds,
|
|
2440
|
+
},
|
|
2441
|
+
},
|
|
1364
2442
|
};
|
|
1365
2443
|
}
|
|
1366
2444
|
/**
|
|
@@ -1524,6 +2602,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
1524
2602
|
...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
|
|
1525
2603
|
...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
|
|
1526
2604
|
...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
|
|
2605
|
+
...(plan.outputContract.requiredOutputs ?? []).map((binding) => ({ kind: 'output', binding })),
|
|
1527
2606
|
].filter(({ binding }) => binding.status !== 'resolved');
|
|
1528
2607
|
const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
|
|
1529
2608
|
const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
|
|
@@ -1664,7 +2743,7 @@ function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
|
|
|
1664
2743
|
.map(({ id, candidate }) => {
|
|
1665
2744
|
return {
|
|
1666
2745
|
id: candidate?.id ?? id,
|
|
1667
|
-
label: candidate?.name
|
|
2746
|
+
label: clarificationOptionLabel(candidate?.name, candidate?.qualifiedId ?? id),
|
|
1668
2747
|
...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
|
|
1669
2748
|
kind: candidate?.kind ?? 'semantic_member',
|
|
1670
2749
|
};
|
|
@@ -1674,6 +2753,19 @@ function qualifiedIdLabel(id) {
|
|
|
1674
2753
|
const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
|
|
1675
2754
|
return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
|
|
1676
2755
|
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Candidate names are canonical metadata values, not necessarily presentation
|
|
2758
|
+
* labels. Keep a curated label intact, but humanize local snake/kebab names so
|
|
2759
|
+
* a stable clarification never asks a business user to choose `account_name`.
|
|
2760
|
+
*/
|
|
2761
|
+
function clarificationOptionLabel(name, fallbackId) {
|
|
2762
|
+
const trimmed = name?.trim();
|
|
2763
|
+
if (!trimmed)
|
|
2764
|
+
return qualifiedIdLabel(fallbackId);
|
|
2765
|
+
return /[_-]/.test(trimmed)
|
|
2766
|
+
? trimmed.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
|
|
2767
|
+
: trimmed;
|
|
2768
|
+
}
|
|
1677
2769
|
function routedClarificationQuestion(resolution, bindings, options) {
|
|
1678
2770
|
if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
|
|
1679
2771
|
return resolution.clarifyingQuestion;
|
|
@@ -1686,19 +2778,57 @@ function routedClarificationQuestion(resolution, bindings, options) {
|
|
|
1686
2778
|
return resolution.clarifyingQuestion
|
|
1687
2779
|
?? `Which governed binding should I use before running this query?`;
|
|
1688
2780
|
}
|
|
1689
|
-
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question) {
|
|
2781
|
+
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question, selectedConceptIds = [selected.id]) {
|
|
1690
2782
|
// Preserve the exact stable ID emitted by the clarification option. A
|
|
1691
2783
|
// candidate may also carry a source-qualified execution alias, but replacing
|
|
1692
2784
|
// the clicked ID here makes the persisted ambiguity contract and the later
|
|
1693
2785
|
// diagnostic receipt disagree even though the user chose a legitimate item.
|
|
1694
2786
|
const selectedId = selected.id;
|
|
2787
|
+
// A server-issued display-key click has already passed the snapshot and
|
|
2788
|
+
// metric-capability checks. If its tuple is pre-freeze-ineligible, preserve
|
|
2789
|
+
// both the metric authority and the exact clicked ID through the typed gap
|
|
2790
|
+
// or same-snapshot exploratory continuation. Reconstructing only the
|
|
2791
|
+
// metric made restart receipts falsely look as though no choice was made.
|
|
2792
|
+
const preservedSelectionIds = [...new Set([
|
|
2793
|
+
...selectedConceptIds.filter((id) => Boolean(id?.trim())),
|
|
2794
|
+
selectedId,
|
|
2795
|
+
])];
|
|
2796
|
+
// A structured choice is validated against its exact stable identity, but an
|
|
2797
|
+
// incomplete selected capability must evaluate physical eligibility against
|
|
2798
|
+
// the entire immutable snapshot—not the compact model package that happened
|
|
2799
|
+
// to carry the clarification. This is one same-snapshot extension only.
|
|
2800
|
+
const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
|
|
1695
2801
|
const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
|
|
1696
2802
|
const requiredPhysicalFieldTerms = [
|
|
1697
2803
|
...(evidence.parsedIntent?.dimensions ?? []),
|
|
1698
2804
|
...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
|
|
1699
2805
|
];
|
|
1700
|
-
const physicalPath = hasSafeExploratoryPhysicalPath(requirements,
|
|
1701
|
-
|
|
2806
|
+
const physicalPath = hasSafeExploratoryPhysicalPath(requirements, snapshotCandidates, requirements.dimensions, requiredPhysicalFieldTerms);
|
|
2807
|
+
// A structured clarification consumes the exact selected ID once, but an
|
|
2808
|
+
// incomplete governed capability is not a post-freeze terminal. Reuse the
|
|
2809
|
+
// router-owned physical cascade so the same-snapshot exploratory closure is
|
|
2810
|
+
// resolved and frozen *before* SQL is generated. The host may later attach
|
|
2811
|
+
// only an authorization receipt for that frozen plan; it must not select or
|
|
2812
|
+
// freeze another plan after SQL exists.
|
|
2813
|
+
if (physicalPath.ok) {
|
|
2814
|
+
return preFreezePhysicalCascadeDecision({
|
|
2815
|
+
base,
|
|
2816
|
+
evidence,
|
|
2817
|
+
candidates: snapshotCandidates,
|
|
2818
|
+
question,
|
|
2819
|
+
requirements,
|
|
2820
|
+
missingTerms: [...new Set([
|
|
2821
|
+
...requirements.dimensions,
|
|
2822
|
+
...requirements.entityTerms,
|
|
2823
|
+
...requirements.entityDisplayTerms,
|
|
2824
|
+
])],
|
|
2825
|
+
requiredPhysicalFieldTerms,
|
|
2826
|
+
messagePrefix: `The selected governed meaning ${selectedId} did not prove the complete requested tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact;`,
|
|
2827
|
+
terminalCandidateIds: preservedSelectionIds,
|
|
2828
|
+
requireRankingMetric: Boolean(requirements.ranking),
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
|
|
1702
2832
|
const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
|
|
1703
2833
|
const attempts = [
|
|
1704
2834
|
{
|
|
@@ -1713,7 +2843,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1713
2843
|
version: 1,
|
|
1714
2844
|
tier: 'semantic',
|
|
1715
2845
|
outcome: 'ineligible',
|
|
1716
|
-
candidateIds: [...new Set([
|
|
2846
|
+
candidateIds: [...new Set([...preservedSelectionIds, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
|
|
1717
2847
|
reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
|
|
1718
2848
|
planFrozen: false,
|
|
1719
2849
|
},
|
|
@@ -1744,7 +2874,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1744
2874
|
sourceCoverage: coverage,
|
|
1745
2875
|
attempts: physicalPath.ok
|
|
1746
2876
|
? attempts
|
|
1747
|
-
: [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds:
|
|
2877
|
+
: [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: preservedSelectionIds, reason: message, planFrozen: false }],
|
|
1748
2878
|
...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
|
|
1749
2879
|
planFrozen: false,
|
|
1750
2880
|
stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
|
|
@@ -1752,7 +2882,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1752
2882
|
const meaningResolution = {
|
|
1753
2883
|
interpretedQuestion: question,
|
|
1754
2884
|
questionType: 'value',
|
|
1755
|
-
selectedConceptIds:
|
|
2885
|
+
selectedConceptIds: preservedSelectionIds,
|
|
1756
2886
|
queryIntent: defaultQueryIntent(evidence),
|
|
1757
2887
|
rejectedCandidates: [],
|
|
1758
2888
|
confidence: 'low',
|
|
@@ -1763,7 +2893,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1763
2893
|
code: 'INCOMPLETE_SELECTED_CAPABILITY',
|
|
1764
2894
|
field: 'selected capability',
|
|
1765
2895
|
message,
|
|
1766
|
-
candidateIds:
|
|
2896
|
+
candidateIds: preservedSelectionIds,
|
|
1767
2897
|
}],
|
|
1768
2898
|
};
|
|
1769
2899
|
if (!physicalPath.ok) {
|
|
@@ -1775,7 +2905,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1775
2905
|
source: 'heuristic',
|
|
1776
2906
|
category: 'data_lookup',
|
|
1777
2907
|
depth: 'quick',
|
|
1778
|
-
retrievalEvidence: retrievalTrace(evidence,
|
|
2908
|
+
retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
|
|
1779
2909
|
requiresClarification: false,
|
|
1780
2910
|
resolvedAnalyticalPlan: undefined,
|
|
1781
2911
|
meaningResolution,
|
|
@@ -1784,7 +2914,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1784
2914
|
kind: 'modeling_gap',
|
|
1785
2915
|
code: 'ANALYTICAL_MODELING_GAP',
|
|
1786
2916
|
message,
|
|
1787
|
-
candidateIds:
|
|
2917
|
+
candidateIds: preservedSelectionIds,
|
|
1788
2918
|
...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
|
|
1789
2919
|
},
|
|
1790
2920
|
};
|
|
@@ -1797,7 +2927,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
|
|
|
1797
2927
|
source: 'heuristic',
|
|
1798
2928
|
category: 'data_lookup',
|
|
1799
2929
|
depth: 'quick',
|
|
1800
|
-
retrievalEvidence: retrievalTrace(evidence,
|
|
2930
|
+
retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
|
|
1801
2931
|
requiresClarification: false,
|
|
1802
2932
|
resolvedAnalyticalPlan: undefined,
|
|
1803
2933
|
meaningResolution,
|
|
@@ -1857,12 +2987,38 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
1857
2987
|
const metricEvidence = candidates.filter((candidate) => candidate.kind === "semantic_metric" &&
|
|
1858
2988
|
candidate.analyticalCapability?.metricId &&
|
|
1859
2989
|
metricIds.has(candidate.analyticalCapability.metricId));
|
|
2990
|
+
// Preserve an already validated semantic member when the resulting frame
|
|
2991
|
+
// proves that the selected metric owns that exact dimension. This is how a
|
|
2992
|
+
// server-issued “Account Name” clarification continues alongside the
|
|
2993
|
+
// explicit Revenue metric; stripping it here made trace meaning IDs and
|
|
2994
|
+
// durable continuation state look empty even though the frame was valid.
|
|
2995
|
+
const selectedFrameMembers = resolution.selectedConceptIds.flatMap((id) => {
|
|
2996
|
+
const member = candidates.find((candidate) => candidate.id === id);
|
|
2997
|
+
if (member?.kind !== 'semantic_member')
|
|
2998
|
+
return [];
|
|
2999
|
+
const resolvesIntoFrame = metricEvidence.some((metric) => {
|
|
3000
|
+
const identities = [
|
|
3001
|
+
member.qualifiedId,
|
|
3002
|
+
member.id,
|
|
3003
|
+
member.name,
|
|
3004
|
+
...(member.aliases ?? []),
|
|
3005
|
+
].filter((identity) => Boolean(identity));
|
|
3006
|
+
return identities.some((identity) => {
|
|
3007
|
+
const dimension = resolveMetricCapabilityDimension(metric, identity);
|
|
3008
|
+
return Boolean(dimension && result.frame.dimensions.some((binding) => binding.dimensionId === dimension.dimensionId));
|
|
3009
|
+
});
|
|
3010
|
+
});
|
|
3011
|
+
return resolvesIntoFrame ? [member.id] : [];
|
|
3012
|
+
});
|
|
1860
3013
|
return {
|
|
1861
3014
|
...resolution,
|
|
1862
3015
|
analyticalFrame: result.frame,
|
|
1863
3016
|
analyticalPolicyIds: result.policyIds,
|
|
1864
3017
|
...(metricEvidence.length > 0
|
|
1865
|
-
? { selectedConceptIds:
|
|
3018
|
+
? { selectedConceptIds: [...new Set([
|
|
3019
|
+
...metricEvidence.map((candidate) => candidate.id),
|
|
3020
|
+
...selectedFrameMembers,
|
|
3021
|
+
])] }
|
|
1866
3022
|
: {}),
|
|
1867
3023
|
recommendedExecutionId: result.candidateId,
|
|
1868
3024
|
recommendedRoute: result.route,
|
|
@@ -1904,7 +3060,12 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
1904
3060
|
analyticalFrame: result.frame,
|
|
1905
3061
|
analyticalPolicyIds: result.policyIds,
|
|
1906
3062
|
confidence: result.status === "clarify" ? "low" : resolution.confidence,
|
|
1907
|
-
|
|
3063
|
+
// A compatibility block is a pre-freeze failure of the nominated tier,
|
|
3064
|
+
// not a new model decision to route somewhere else. Retain the semantic
|
|
3065
|
+
// nomination so the authoritative cascade can inspect the complete
|
|
3066
|
+
// same-snapshot qualified physical closure. Only genuine multi-option
|
|
3067
|
+
// ambiguity changes the route to clarification.
|
|
3068
|
+
recommendedRoute: result.status === 'clarify' ? 'clarify' : resolution.recommendedRoute,
|
|
1908
3069
|
compatibilityOutcome,
|
|
1909
3070
|
compatibilityFailures,
|
|
1910
3071
|
missingInformation: [
|
|
@@ -1940,8 +3101,8 @@ function buildClarificationOptions(candidates, requirements) {
|
|
|
1940
3101
|
return {
|
|
1941
3102
|
id: candidate.id,
|
|
1942
3103
|
label: ambiguousName
|
|
1943
|
-
? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
|
|
1944
|
-
: candidate.name,
|
|
3104
|
+
? `${clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id)} (${candidateKindLabel(candidate.kind)})`
|
|
3105
|
+
: clarificationOptionLabel(candidate.name, candidate.qualifiedId ?? candidate.id),
|
|
1945
3106
|
...(description ? { description } : {}),
|
|
1946
3107
|
kind: candidate.kind,
|
|
1947
3108
|
};
|
|
@@ -1981,6 +3142,9 @@ function persistedClarificationSelectionContext(request) {
|
|
|
1981
3142
|
const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
|
|
1982
3143
|
? pendingRecord.sourceTurnId.trim()
|
|
1983
3144
|
: undefined;
|
|
3145
|
+
const sourceQuestion = typeof pendingRecord.sourceQuestion === 'string' && pendingRecord.sourceQuestion.trim()
|
|
3146
|
+
? pendingRecord.sourceQuestion.trim()
|
|
3147
|
+
: undefined;
|
|
1984
3148
|
const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
|
|
1985
3149
|
? envelope.threadId.trim()
|
|
1986
3150
|
: undefined;
|
|
@@ -2016,6 +3180,7 @@ function persistedClarificationSelectionContext(request) {
|
|
|
2016
3180
|
...(requirements ? { requirements } : {}),
|
|
2017
3181
|
...(snapshotId ? { snapshotId } : {}),
|
|
2018
3182
|
...(sourceTurnId ? { sourceTurnId } : {}),
|
|
3183
|
+
...(sourceQuestion ? { sourceQuestion } : {}),
|
|
2019
3184
|
...(threadId ? { threadId } : {}),
|
|
2020
3185
|
serverIssued: !invalidReason,
|
|
2021
3186
|
...(invalidReason ? { invalidReason } : {}),
|
|
@@ -2100,17 +3265,182 @@ function mergeClarificationRequirements(current, persisted) {
|
|
|
2100
3265
|
entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
|
|
2101
3266
|
entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
|
|
2102
3267
|
memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
|
|
3268
|
+
...(current.ranking || persisted.ranking
|
|
3269
|
+
? {
|
|
3270
|
+
ranking: current.ranking && persisted.ranking
|
|
3271
|
+
? {
|
|
3272
|
+
...current.ranking,
|
|
3273
|
+
metricTerms: [...new Set([...current.ranking.metricTerms, ...persisted.ranking.metricTerms])],
|
|
3274
|
+
entityTerms: [...new Set([...current.ranking.entityTerms, ...persisted.ranking.entityTerms])],
|
|
3275
|
+
}
|
|
3276
|
+
: current.ranking ?? persisted.ranking,
|
|
3277
|
+
}
|
|
3278
|
+
: {}),
|
|
3279
|
+
...(current.time || persisted.time
|
|
3280
|
+
? {
|
|
3281
|
+
time: current.time && persisted.time
|
|
3282
|
+
? {
|
|
3283
|
+
...current.time,
|
|
3284
|
+
grain: current.time.grain ?? persisted.time.grain,
|
|
3285
|
+
fiscalPeriod: current.time.fiscalPeriod ?? persisted.time.fiscalPeriod,
|
|
3286
|
+
requiresDeclaredFiscalCalendar: current.time.requiresDeclaredFiscalCalendar
|
|
3287
|
+
|| persisted.time.requiresDeclaredFiscalCalendar,
|
|
3288
|
+
}
|
|
3289
|
+
: current.time ?? persisted.time,
|
|
3290
|
+
}
|
|
3291
|
+
: {}),
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
/**
|
|
3295
|
+
* A structured choice continues the server-rendered analytical frame. The
|
|
3296
|
+
* browser may echo `clarificationSourceQuestion`, but the persisted server
|
|
3297
|
+
* envelope wins whenever it is available so a choice label cannot be parsed as
|
|
3298
|
+
* a new question on reload or restart.
|
|
3299
|
+
*/
|
|
3300
|
+
function structuredClarificationRequirements(request, persisted) {
|
|
3301
|
+
const sourceQuestion = persisted?.serverIssued && persisted.sourceQuestion
|
|
3302
|
+
? persisted.sourceQuestion
|
|
3303
|
+
: request.clarificationSourceQuestion?.trim() || request.question;
|
|
3304
|
+
return mergeClarificationRequirements(buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.serverIssued ? persisted.requirements : undefined);
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* A dimension clarification is not an alternate definition of the measure.
|
|
3308
|
+
* Find the exact semantic metric that owns the persisted explicit measure
|
|
3309
|
+
* before accepting a server-issued semantic member as a display/grouping
|
|
3310
|
+
* selection. Multiple metrics remain a real ambiguity; we never use a
|
|
3311
|
+
* dimension click to guess one of them.
|
|
3312
|
+
*/
|
|
3313
|
+
function uniqueStructuredSelectionMetric(candidates, requirements) {
|
|
3314
|
+
const measures = explicitMeasureTermsForClarification(requirements);
|
|
3315
|
+
if (measures.length !== 1)
|
|
3316
|
+
return undefined;
|
|
3317
|
+
const byMetricId = new Map();
|
|
3318
|
+
for (const candidate of canonicalizeMetricMeasureCandidates(candidates)) {
|
|
3319
|
+
if (candidate.kind !== 'semantic_metric'
|
|
3320
|
+
|| candidate.eligible === false
|
|
3321
|
+
// The first clarification deliberately exists because the metric's
|
|
3322
|
+
// display/entity role was not yet fully bound. A partial fit can still
|
|
3323
|
+
// be the unique, complete semantic capability for the explicit measure;
|
|
3324
|
+
// after the server-issued dimension choice binds it, the ordinary
|
|
3325
|
+
// immutable-plan compatibility checks remain authoritative. Only a
|
|
3326
|
+
// proven incompatible metric is disqualified at this identity boundary.
|
|
3327
|
+
|| candidate.compatibility === 'incompatible'
|
|
3328
|
+
|| candidateConflictsWithExplicitRankingMeasure(candidate, requirements)
|
|
3329
|
+
|| !candidateProvesMetricTerm(candidate, measures[0]))
|
|
3330
|
+
continue;
|
|
3331
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
3332
|
+
if (normalized.status !== 'complete' || !normalized.capability)
|
|
3333
|
+
continue;
|
|
3334
|
+
const current = byMetricId.get(normalized.capability.metricId);
|
|
3335
|
+
if (!current
|
|
3336
|
+
|| candidate.exactMatch && !current.exactMatch
|
|
3337
|
+
|| candidate.relevanceScore > current.relevanceScore) {
|
|
3338
|
+
byMetricId.set(normalized.capability.metricId, candidate);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* A resolver can offer a qualified semantic capability dimension without a
|
|
3345
|
+
* standalone retrieval card. That is expected: the option is an authored
|
|
3346
|
+
* child of a metric capability, not an independently ranked candidate. On a
|
|
3347
|
+
* later click, restore that child only when the active server-issued
|
|
3348
|
+
* clarification contract names the exact option and the *same immutable
|
|
3349
|
+
* snapshot* still proves it through one exact metric capability.
|
|
3350
|
+
*
|
|
3351
|
+
* This is deliberately not a fuzzy retrieval fallback. A malformed,
|
|
3352
|
+
* foreign, stale, or client-invented ID never reaches this helper because the
|
|
3353
|
+
* host-only envelope/snapshot checks occur before the capability projection.
|
|
3354
|
+
*/
|
|
3355
|
+
function rehydrateServerIssuedCapabilityDimensionSelection(input) {
|
|
3356
|
+
const selectedId = input.request.selectedEvidenceId?.trim();
|
|
3357
|
+
if (!selectedId)
|
|
3358
|
+
return undefined;
|
|
3359
|
+
const persisted = persistedClarificationSelectionContext(input.request);
|
|
3360
|
+
if (!persisted?.serverIssued || persisted.snapshotId !== input.evidence.snapshotId)
|
|
3361
|
+
return undefined;
|
|
3362
|
+
const offeredIds = new Set([...persisted.optionIds, ...persisted.ambiguityCandidateIds]);
|
|
3363
|
+
if (!offeredIds.has(selectedId))
|
|
3364
|
+
return undefined;
|
|
3365
|
+
const requirements = structuredClarificationRequirements(input.request, persisted);
|
|
3366
|
+
const metricCandidate = uniqueStructuredSelectionMetric(input.candidates, requirements);
|
|
3367
|
+
if (!metricCandidate)
|
|
3368
|
+
return undefined;
|
|
3369
|
+
const dimension = resolveMetricCapabilityDimension(metricCandidate, selectedId);
|
|
3370
|
+
if (!dimension)
|
|
3371
|
+
return undefined;
|
|
3372
|
+
if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
|
|
3373
|
+
return undefined;
|
|
3374
|
+
if (!requirements.ranking
|
|
3375
|
+
&& !dimension.supportedRoles.includes('group_by')
|
|
3376
|
+
&& !dimension.supportedRoles.includes('filter'))
|
|
3377
|
+
return undefined;
|
|
3378
|
+
return {
|
|
3379
|
+
// Keep the exact server-issued ID as the router identity and persistable
|
|
3380
|
+
// choice binding. The metric capability's authored ID stays available as
|
|
3381
|
+
// provenance for semantic-frame construction.
|
|
3382
|
+
id: selectedId,
|
|
3383
|
+
qualifiedId: dimension.dimensionId,
|
|
3384
|
+
kind: 'semantic_member',
|
|
3385
|
+
semanticObjectType: 'dimension',
|
|
3386
|
+
trustTier: 'semantic',
|
|
3387
|
+
name: dimension.label || qualifiedIdLabel(dimension.dimensionId),
|
|
3388
|
+
...(dimension.aliases?.length ? { aliases: dimension.aliases } : {}),
|
|
3389
|
+
...(metricCandidate.domain ? { domain: metricCandidate.domain } : {}),
|
|
3390
|
+
...(metricCandidate.semanticModel ? { semanticModel: metricCandidate.semanticModel } : {}),
|
|
3391
|
+
relevanceScore: metricCandidate.relevanceScore,
|
|
3392
|
+
matchReasons: ['server-issued snapshot capability binding'],
|
|
3393
|
+
compatibility: 'compatible',
|
|
3394
|
+
eligible: true,
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Validate a selected display/grouping member against the selected metric's
|
|
3399
|
+
* physical semantic capability. The candidate must already have been offered
|
|
3400
|
+
* by the server; this only determines which typed role it can complete.
|
|
3401
|
+
*/
|
|
3402
|
+
function structuredDimensionSelection(selected, candidates, requirements) {
|
|
3403
|
+
if (selected.kind !== 'semantic_member'
|
|
3404
|
+
|| selected.eligible === false
|
|
3405
|
+
|| selected.compatibility === 'incompatible')
|
|
3406
|
+
return undefined;
|
|
3407
|
+
const roles = evidenceCandidateRoles(selected);
|
|
3408
|
+
if (!roles.some((role) => role === 'entity_label'
|
|
3409
|
+
|| role === 'categorical_dimension'
|
|
3410
|
+
|| role === 'time_dimension'))
|
|
3411
|
+
return undefined;
|
|
3412
|
+
const metricCandidate = uniqueStructuredSelectionMetric(candidates, requirements);
|
|
3413
|
+
if (!metricCandidate)
|
|
3414
|
+
return undefined;
|
|
3415
|
+
const dimension = resolveMetricCapabilityDimension(metricCandidate, selected.qualifiedId ?? selected.id);
|
|
3416
|
+
if (!dimension)
|
|
3417
|
+
return undefined;
|
|
3418
|
+
// A top/bottom clarification must select an authored rankable entity, not a
|
|
3419
|
+
// filter-only field. For ordinary dimensional requests, grouping is enough.
|
|
3420
|
+
if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
|
|
3421
|
+
return undefined;
|
|
3422
|
+
if (!requirements.ranking
|
|
3423
|
+
&& !dimension.supportedRoles.includes('group_by')
|
|
3424
|
+
&& !dimension.supportedRoles.includes('filter'))
|
|
3425
|
+
return undefined;
|
|
3426
|
+
return {
|
|
3427
|
+
kind: 'dimension',
|
|
3428
|
+
metricCandidate,
|
|
3429
|
+
selectedDimensionId: selected.id,
|
|
3430
|
+
dimensionId: dimension.dimensionId,
|
|
2103
3431
|
};
|
|
2104
3432
|
}
|
|
3433
|
+
function compatibleStructuredClarificationCandidates(candidates, requirements) {
|
|
3434
|
+
return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements)
|
|
3435
|
+
|| Boolean(structuredDimensionSelection(candidate, candidates, requirements)));
|
|
3436
|
+
}
|
|
2105
3437
|
function validateStructuredClarificationSelection(input) {
|
|
2106
3438
|
const persisted = persistedClarificationSelectionContext(input.request);
|
|
2107
|
-
const sourceQuestion = input.request.clarificationSourceQuestion?.trim() || input.request.question;
|
|
2108
|
-
const requirements = mergeClarificationRequirements(
|
|
2109
3439
|
// A click/reload continuation is an identity action. Use the original user
|
|
2110
|
-
// question
|
|
2111
|
-
//
|
|
2112
|
-
|
|
2113
|
-
const compatible =
|
|
3440
|
+
// question and server-persisted typed contract, not arbitrary inherited
|
|
3441
|
+
// parser hints or the rendered option label.
|
|
3442
|
+
const requirements = structuredClarificationRequirements(input.request, persisted);
|
|
3443
|
+
const compatible = compatibleStructuredClarificationCandidates(input.candidates, requirements);
|
|
2114
3444
|
const choiceIds = compatible.map((candidate) => candidate.id);
|
|
2115
3445
|
if (!persisted?.serverIssued) {
|
|
2116
3446
|
return {
|
|
@@ -2132,7 +3462,10 @@ function validateStructuredClarificationSelection(input) {
|
|
|
2132
3462
|
if (!input.selected) {
|
|
2133
3463
|
return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
|
|
2134
3464
|
}
|
|
2135
|
-
|
|
3465
|
+
const selection = candidateOwnsExplicitClarificationRoles(input.selected, requirements)
|
|
3466
|
+
? { kind: 'metric' }
|
|
3467
|
+
: structuredDimensionSelection(input.selected, input.candidates, requirements);
|
|
3468
|
+
if (!selection) {
|
|
2136
3469
|
return {
|
|
2137
3470
|
ok: false,
|
|
2138
3471
|
requirements,
|
|
@@ -2161,7 +3494,7 @@ function validateStructuredClarificationSelection(input) {
|
|
|
2161
3494
|
reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
|
|
2162
3495
|
};
|
|
2163
3496
|
}
|
|
2164
|
-
return { ok: true, requirements, choiceIds };
|
|
3497
|
+
return { ok: true, requirements, choiceIds, selection };
|
|
2165
3498
|
}
|
|
2166
3499
|
function invalidStructuredSelectionDecision(input) {
|
|
2167
3500
|
const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
|
|
@@ -2383,6 +3716,7 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
|
|
|
2383
3716
|
if (hasExplicitRankingMeasure(question, evidence))
|
|
2384
3717
|
return resolution;
|
|
2385
3718
|
const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
|
|
3719
|
+
|| candidate.qualifiedId === resolution.recommendedExecutionId
|
|
2386
3720
|
|| resolution.selectedConceptIds.includes(candidate.id));
|
|
2387
3721
|
if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
|
|
2388
3722
|
return resolution;
|
|
@@ -2411,7 +3745,110 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
|
|
|
2411
3745
|
: `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use for the ranking?`,
|
|
2412
3746
|
};
|
|
2413
3747
|
}
|
|
2414
|
-
function
|
|
3748
|
+
function isSameSnapshotCategoricalExtensionForMetric(candidate, metricCandidate) {
|
|
3749
|
+
return Boolean(proveSameSnapshotMetricflowRoleExtensionV1({ candidate, metricCandidate }));
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* A planner/runtime selection can name a semantic display card through its
|
|
3753
|
+
* registry identity while the metric capability names the same field through
|
|
3754
|
+
* its MetricFlow identity. That is common for dbt MetricFlow exports:
|
|
3755
|
+
* `semantic:dimension:customers.customer_name` is the selected registry card,
|
|
3756
|
+
* while the metric declares
|
|
3757
|
+
* `semantic:uncategorized:dimension:customers.customer_name` as its native
|
|
3758
|
+
* grouping field.
|
|
3759
|
+
*
|
|
3760
|
+
* Do not reopen retrieval to bridge those forms. When (and only when) the
|
|
3761
|
+
* already-selected semantic member maps uniquely to an exact group-by
|
|
3762
|
+
* dimension on the already-selected metric capability, record the
|
|
3763
|
+
* same-snapshot proof on that selected card. The existing semantic compiler
|
|
3764
|
+
* still checks the normalized capability, additivity, and native grouping
|
|
3765
|
+
* path before freezing. This lets a complete singular tuple execute without
|
|
3766
|
+
* permitting a correlated metric, a numeric order attribute, or any
|
|
3767
|
+
* unselected workspace card to become meaning authority.
|
|
3768
|
+
*/
|
|
3769
|
+
function bindSelectedMetricFlowGroupingExtensions(metricCandidate, candidates) {
|
|
3770
|
+
const capability = normalizeEvidenceAnalyticalCapability(metricCandidate).capability;
|
|
3771
|
+
if (!capability)
|
|
3772
|
+
return candidates;
|
|
3773
|
+
const normalizedIdentitySet = (candidate) => new Set([
|
|
3774
|
+
candidate.id,
|
|
3775
|
+
candidate.qualifiedId,
|
|
3776
|
+
candidate.name,
|
|
3777
|
+
...(candidate.aliases ?? []),
|
|
3778
|
+
].filter((identity) => Boolean(identity)).map(normalizeMetricPhrase).filter(Boolean));
|
|
3779
|
+
const dimensionIdentities = (dimension) => new Set([
|
|
3780
|
+
dimension.dimensionId,
|
|
3781
|
+
dimension.label,
|
|
3782
|
+
...(dimension.aliases ?? []),
|
|
3783
|
+
].filter((identity) => Boolean(identity)).map(normalizeMetricPhrase).filter(Boolean));
|
|
3784
|
+
return candidates.map((candidate) => {
|
|
3785
|
+
if (candidate.kind !== 'semantic_member'
|
|
3786
|
+
|| candidate.compatibility === 'incompatible'
|
|
3787
|
+
|| candidate.sameSnapshotRoleExtension)
|
|
3788
|
+
return candidate;
|
|
3789
|
+
const identities = normalizedIdentitySet(candidate);
|
|
3790
|
+
const matchedDimensions = capability.dimensions.filter((dimension) => dimension.supportedRoles.includes('group_by')
|
|
3791
|
+
&& [...dimensionIdentities(dimension)].some((identity) => identities.has(identity)));
|
|
3792
|
+
if (matchedDimensions.length !== 1)
|
|
3793
|
+
return candidate;
|
|
3794
|
+
const dimension = matchedDimensions[0];
|
|
3795
|
+
return {
|
|
3796
|
+
...candidate,
|
|
3797
|
+
sameSnapshotRoleExtension: {
|
|
3798
|
+
version: 1,
|
|
3799
|
+
role: 'categorical_dimension',
|
|
3800
|
+
requestedTerm: candidate.name,
|
|
3801
|
+
metricId: capability.metricId,
|
|
3802
|
+
dimensionId: dimension.dimensionId,
|
|
3803
|
+
basis: 'exact_metricflow_grouping_dimension',
|
|
3804
|
+
},
|
|
3805
|
+
};
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
/**
|
|
3809
|
+
* A model chooses from the bounded package, but it cannot remove a unique
|
|
3810
|
+
* host-required categorical grouping whose exact qualified field is already
|
|
3811
|
+
* declared by the selected metric's immutable capability. This is a binding,
|
|
3812
|
+
* not a semantic guess: zero or multiple capability-backed cards leave the
|
|
3813
|
+
* requirement unresolved for the normal clarify/gap path.
|
|
3814
|
+
*/
|
|
3815
|
+
function hostBoundCategoricalExtensions(input) {
|
|
3816
|
+
const bound = [];
|
|
3817
|
+
for (const requested of categoricalDimensionRequirementTerms(input.requirements)) {
|
|
3818
|
+
const matches = input.candidates.filter((candidate) => isSameSnapshotCategoricalExtensionForMetric(candidate, input.metricCandidate)
|
|
3819
|
+
&& normalizeMetricPhrase(candidate.sameSnapshotRoleExtension.requestedTerm)
|
|
3820
|
+
=== normalizeMetricPhrase(requested));
|
|
3821
|
+
if (matches.length === 1)
|
|
3822
|
+
bound.push(matches[0]);
|
|
3823
|
+
}
|
|
3824
|
+
return bound.filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
|
|
3825
|
+
}
|
|
3826
|
+
function directResolution(request, evidence, candidate, candidates, selectedDimensionIds = [], selectedDimensionConceptIds = selectedDimensionIds) {
|
|
3827
|
+
// Keep deterministic/direct routing on the same host-owned tuple as the
|
|
3828
|
+
// model path. Raw parser terms are retrieval hints only: they may not turn a
|
|
3829
|
+
// normalized business alias back into a phantom metric or dimension after
|
|
3830
|
+
// the meaning boundary has been intentionally skipped.
|
|
3831
|
+
const requirementSeed = request.hostRequirementSeed?.version === 1
|
|
3832
|
+
&& request.hostRequirementSeed.sourceQuestion === request.question
|
|
3833
|
+
? request.hostRequirementSeed
|
|
3834
|
+
: buildAnalyticalRequirementSeedV1({
|
|
3835
|
+
question: request.question,
|
|
3836
|
+
parsedIntent: evidence.parsedIntent,
|
|
3837
|
+
fiscalCalendar: declaredFiscalCalendar(evidence, candidates),
|
|
3838
|
+
});
|
|
3839
|
+
const hostOwnedEvidence = {
|
|
3840
|
+
...evidence,
|
|
3841
|
+
parsedIntent: {
|
|
3842
|
+
...evidence.parsedIntent,
|
|
3843
|
+
measures: requirementSeed.queryIntent.measures,
|
|
3844
|
+
dimensions: requirementSeed.queryIntent.dimensions,
|
|
3845
|
+
filters: requirementSeed.queryIntent.filters,
|
|
3846
|
+
...(requirementSeed.queryIntent.timeRange ? { timeRange: requirementSeed.queryIntent.timeRange } : {}),
|
|
3847
|
+
...(requirementSeed.queryIntent.timeGrain ? { timeGrain: requirementSeed.queryIntent.timeGrain } : {}),
|
|
3848
|
+
...(requirementSeed.queryIntent.order ? { order: requirementSeed.queryIntent.order } : {}),
|
|
3849
|
+
...(requirementSeed.queryIntent.limit !== undefined ? { limit: requirementSeed.queryIntent.limit } : {}),
|
|
3850
|
+
},
|
|
3851
|
+
};
|
|
2415
3852
|
const inferredQuestionType = questionTypeFromText(request.question);
|
|
2416
3853
|
const questionType = inferredQuestionType === 'definition'
|
|
2417
3854
|
&& candidate.kind === 'semantic_metric'
|
|
@@ -2421,16 +3858,40 @@ function directResolution(request, evidence, candidate, candidates) {
|
|
|
2421
3858
|
&& !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
|
|
2422
3859
|
? 'value'
|
|
2423
3860
|
: inferredQuestionType;
|
|
2424
|
-
const metricCandidates = explicitlyRequestedMetricCandidates(request.question,
|
|
3861
|
+
const metricCandidates = explicitlyRequestedMetricCandidates(request.question, hostOwnedEvidence, candidate, candidates);
|
|
3862
|
+
const requirements = requirementSeed.requirements;
|
|
3863
|
+
const hostBoundExtensions = hostBoundCategoricalExtensions({
|
|
3864
|
+
metricCandidate: candidate,
|
|
3865
|
+
candidates,
|
|
3866
|
+
requirements,
|
|
3867
|
+
});
|
|
3868
|
+
const resolvedSelectedDimensionIds = [...new Set([
|
|
3869
|
+
...selectedDimensionIds,
|
|
3870
|
+
...hostBoundExtensions.map((item) => item.qualifiedId ?? item.id),
|
|
3871
|
+
])];
|
|
2425
3872
|
const analyticalFrame = buildDeterministicAnalyticalFrame({
|
|
2426
3873
|
question: request.question,
|
|
2427
3874
|
questionType,
|
|
2428
|
-
evidence,
|
|
3875
|
+
evidence: hostOwnedEvidence,
|
|
2429
3876
|
metricCandidate: candidate,
|
|
2430
3877
|
metricCandidates,
|
|
3878
|
+
entityTerms: requirements.entityTerms,
|
|
3879
|
+
entityDisplayTerms: requirements.entityDisplayTerms,
|
|
3880
|
+
selectedDimensionIds: resolvedSelectedDimensionIds,
|
|
2431
3881
|
candidates,
|
|
2432
3882
|
});
|
|
2433
|
-
const defaultIntent = defaultQueryIntent(
|
|
3883
|
+
const defaultIntent = defaultQueryIntent(hostOwnedEvidence);
|
|
3884
|
+
// `top` without a count is a deterministic product convention, not a
|
|
3885
|
+
// missing business meaning. Bind the default into the direct execution
|
|
3886
|
+
// nomination while the typed requirements retain `defaultedLimit: true` for
|
|
3887
|
+
// the answer/receipt to disclose the assumption.
|
|
3888
|
+
const rankedDefaultIntent = requirements.ranking
|
|
3889
|
+
? {
|
|
3890
|
+
...defaultIntent,
|
|
3891
|
+
order: defaultIntent.order ?? (requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'),
|
|
3892
|
+
limit: defaultIntent.limit ?? requirements.ranking.limit,
|
|
3893
|
+
}
|
|
3894
|
+
: defaultIntent;
|
|
2434
3895
|
// An exact authored certified example has already proved this block's own
|
|
2435
3896
|
// output contract. Parser wording such as "food and drink" describes the
|
|
2436
3897
|
// values of the block's declared `category` output; it must not manufacture
|
|
@@ -2444,15 +3905,45 @@ function directResolution(request, evidence, candidate, candidates) {
|
|
|
2444
3905
|
&& certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
|
|
2445
3906
|
&& (candidate.dimensions?.length ?? 0) > 0;
|
|
2446
3907
|
const queryIntent = exactCertifiedExample
|
|
2447
|
-
? { ...
|
|
2448
|
-
:
|
|
3908
|
+
? { ...rankedDefaultIntent, dimensions: candidate.dimensions ?? [] }
|
|
3909
|
+
: rankedDefaultIntent;
|
|
3910
|
+
// Preserve a selected semantic field as a typed dimension binding rather
|
|
3911
|
+
// than folding its label into the question. The ID comes from the selected
|
|
3912
|
+
// metric's capability contract, so the immutable plan will revalidate it
|
|
3913
|
+
// against the same snapshot before it can freeze.
|
|
3914
|
+
const selectedQueryDimensions = analyticalFrame?.dimensions
|
|
3915
|
+
// Server-issued structured selections may add their qualified identity to
|
|
3916
|
+
// the intent carrier. A host-bound same-snapshot extension instead binds
|
|
3917
|
+
// through the V2 frame below; adding its ID as a second text dimension
|
|
3918
|
+
// would create a duplicate request (`region` plus its qualified field).
|
|
3919
|
+
.filter((binding) => selectedDimensionIds.includes(binding.dimensionId))
|
|
3920
|
+
.map((binding) => binding.dimensionId) ?? [];
|
|
3921
|
+
// A direct host resolution can bind a categorical capability dimension from
|
|
3922
|
+
// the current question without a model-selected member card. Carry the
|
|
3923
|
+
// *actual* same-snapshot card into the resolution as well, otherwise the
|
|
3924
|
+
// immutable solver sees a valid V2 frame but cannot prove the selected
|
|
3925
|
+
// MetricFlow grouping evidence and incorrectly marks the tuple partial.
|
|
3926
|
+
// This is not a fuzzy name lookup: the candidate must be the dimension's
|
|
3927
|
+
// own qualified identity or the already-recorded same-snapshot extension.
|
|
3928
|
+
const hostBoundDimensionConceptIds = analyticalFrame?.dimensions.flatMap((binding) => hostBoundExtensions
|
|
3929
|
+
.filter((item) => (item.qualifiedId ?? item.id) === binding.dimensionId)
|
|
3930
|
+
.map((item) => item.id)) ?? [];
|
|
3931
|
+
const queryIntentWithSelectedDimensions = selectedQueryDimensions.length > 0
|
|
3932
|
+
? {
|
|
3933
|
+
...queryIntent,
|
|
3934
|
+
dimensions: [...new Set([
|
|
3935
|
+
...queryIntent.dimensions,
|
|
3936
|
+
...selectedQueryDimensions,
|
|
3937
|
+
])],
|
|
3938
|
+
}
|
|
3939
|
+
: queryIntent;
|
|
2449
3940
|
const memberCandidates = candidates.filter((item) => {
|
|
2450
3941
|
if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
|
|
2451
3942
|
return false;
|
|
2452
3943
|
const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
|
|
2453
|
-
return
|
|
3944
|
+
return queryIntentWithSelectedDimensions.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
|
|
2454
3945
|
});
|
|
2455
|
-
const canonicalFilters =
|
|
3946
|
+
const canonicalFilters = queryIntentWithSelectedDimensions.filters.map((filter) => {
|
|
2456
3947
|
const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
|
|
2457
3948
|
.map(normalizeMetricPhrase)
|
|
2458
3949
|
.includes(normalizeMetricPhrase(filter.value)));
|
|
@@ -2461,18 +3952,225 @@ function directResolution(request, evidence, candidate, candidates) {
|
|
|
2461
3952
|
return {
|
|
2462
3953
|
interpretedQuestion: request.question,
|
|
2463
3954
|
questionType,
|
|
2464
|
-
selectedConceptIds: [...metricCandidates, ...memberCandidates]
|
|
2465
|
-
|
|
2466
|
-
|
|
3955
|
+
selectedConceptIds: [...metricCandidates, ...memberCandidates]
|
|
3956
|
+
.map((item) => item.id)
|
|
3957
|
+
.concat(selectedDimensionConceptIds, hostBoundDimensionConceptIds)
|
|
3958
|
+
.filter((id, index, all) => all.indexOf(id) === index),
|
|
3959
|
+
// Meaning receipts retain the source-qualified identity selected from the
|
|
3960
|
+
// immutable workspace. The frozen semantic compiler plan may normalize
|
|
3961
|
+
// that handle to its canonical execution ID later, but rewriting it here
|
|
3962
|
+
// breaks the provenance link between the model/meaning choice and the
|
|
3963
|
+
// selected semantic evidence card.
|
|
3964
|
+
recommendedExecutionId: candidate.kind === 'semantic_metric'
|
|
3965
|
+
? candidate.qualifiedId ?? candidate.id
|
|
3966
|
+
: candidate.id,
|
|
3967
|
+
queryIntent: { ...queryIntentWithSelectedDimensions, filters: canonicalFilters },
|
|
2467
3968
|
rejectedCandidates: [],
|
|
2468
3969
|
confidence: "high",
|
|
2469
3970
|
missingInformation: [],
|
|
2470
3971
|
recommendedRoute: routeForEvidenceCandidate(candidate),
|
|
3972
|
+
// Direct/exact routing deliberately bypasses the model, not the host
|
|
3973
|
+
// meaning boundary. Carry the same immutable seed into plan binding so
|
|
3974
|
+
// display-key projection and downstream receipts cannot fall back to raw
|
|
3975
|
+
// parser wording.
|
|
3976
|
+
hostRequirementSeed: requirementSeed,
|
|
3977
|
+
...(selectedDimensionIds.length > 0
|
|
3978
|
+
? { structuredDimensionIds: [...new Set(selectedDimensionIds)] }
|
|
3979
|
+
: {}),
|
|
2471
3980
|
...(analyticalFrame ? { analyticalFrame } : {}),
|
|
2472
3981
|
};
|
|
2473
3982
|
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Build the V2 execution frame after the one candidate-ID meaning call.
|
|
3985
|
+
*
|
|
3986
|
+
* The model's response has already passed package membership validation. It
|
|
3987
|
+
* may therefore identify a supplied semantic metric and supplied semantic
|
|
3988
|
+
* members, but it is never allowed to author the frame itself. This helper
|
|
3989
|
+
* rebuilds the frame from the immutable host seed plus those qualified
|
|
3990
|
+
* identities, exactly as {@link directResolution} does for a zero-call path.
|
|
3991
|
+
*
|
|
3992
|
+
* Keeping this boundary here is important: a candidate-only model response
|
|
3993
|
+
* used to bind a perfectly valid semantic tuple into a V1 plan. V1 plans
|
|
3994
|
+
* bypass the immutable analytical execution graph and fall into the legacy
|
|
3995
|
+
* semantic SQL path, where generic aggregation validation cannot prove a
|
|
3996
|
+
* MetricFlow capability. A host-built V2 frame keeps the selected semantic
|
|
3997
|
+
* route compiler-owned without letting the model change the requested tuple.
|
|
3998
|
+
*/
|
|
3999
|
+
function attachHostOwnedAnalyticalFrame(input) {
|
|
4000
|
+
const { request, evidence, candidates, requirementSeed, resolution } = input;
|
|
4001
|
+
if (resolution.recommendedRoute !== 'semantic' || resolution.questionType === 'definition') {
|
|
4002
|
+
return resolution;
|
|
4003
|
+
}
|
|
4004
|
+
const matchesIdentity = (candidate, identity) => Boolean(identity && (candidate.id === identity || candidate.qualifiedId === identity));
|
|
4005
|
+
const metricCandidate = candidates.find((candidate) => candidate.kind === 'semantic_metric'
|
|
4006
|
+
&& matchesIdentity(candidate, resolution.recommendedExecutionId))
|
|
4007
|
+
?? candidates.find((candidate) => candidate.kind === 'semantic_metric'
|
|
4008
|
+
&& resolution.selectedConceptIds.some((identity) => matchesIdentity(candidate, identity)));
|
|
4009
|
+
if (!metricCandidate || normalizeEvidenceAnalyticalCapability(metricCandidate).status !== 'complete') {
|
|
4010
|
+
return resolution;
|
|
4011
|
+
}
|
|
4012
|
+
// Parsed intent is retrieval evidence only. The deterministic frame reads
|
|
4013
|
+
// the same host-owned seed that the plan will later bind, so stale retrieval
|
|
4014
|
+
// terms cannot re-enter as a metric, dimension, filter, output, or time
|
|
4015
|
+
// constraint merely because the model selected an otherwise valid card.
|
|
4016
|
+
const hostOwnedEvidence = {
|
|
4017
|
+
...evidence,
|
|
4018
|
+
parsedIntent: {
|
|
4019
|
+
...evidence.parsedIntent,
|
|
4020
|
+
measures: [...requirementSeed.queryIntent.measures],
|
|
4021
|
+
dimensions: [...requirementSeed.queryIntent.dimensions],
|
|
4022
|
+
filters: requirementSeed.queryIntent.filters.map((filter) => ({ ...filter })),
|
|
4023
|
+
...(requirementSeed.queryIntent.timeRange
|
|
4024
|
+
? { timeRange: requirementSeed.queryIntent.timeRange }
|
|
4025
|
+
: {}),
|
|
4026
|
+
...(requirementSeed.queryIntent.timeGrain
|
|
4027
|
+
? { timeGrain: requirementSeed.queryIntent.timeGrain }
|
|
4028
|
+
: {}),
|
|
4029
|
+
...(requirementSeed.queryIntent.order
|
|
4030
|
+
? { order: requirementSeed.queryIntent.order }
|
|
4031
|
+
: {}),
|
|
4032
|
+
...(requirementSeed.queryIntent.limit !== undefined
|
|
4033
|
+
? { limit: requirementSeed.queryIntent.limit }
|
|
4034
|
+
: {}),
|
|
4035
|
+
},
|
|
4036
|
+
};
|
|
4037
|
+
const modelSelectedDimensionIds = resolution.selectedConceptIds.flatMap((identity) => {
|
|
4038
|
+
const candidate = candidates.find((item) => matchesIdentity(item, identity));
|
|
4039
|
+
return candidate?.kind === 'semantic_member'
|
|
4040
|
+
? [candidate.qualifiedId ?? candidate.id]
|
|
4041
|
+
: [];
|
|
4042
|
+
});
|
|
4043
|
+
const hostBoundExtensions = hostBoundCategoricalExtensions({
|
|
4044
|
+
metricCandidate,
|
|
4045
|
+
candidates,
|
|
4046
|
+
requirements: requirementSeed.requirements,
|
|
4047
|
+
});
|
|
4048
|
+
const selectedDimensionIds = [...new Set([
|
|
4049
|
+
...modelSelectedDimensionIds,
|
|
4050
|
+
...hostBoundExtensions.map((candidate) => candidate.qualifiedId ?? candidate.id),
|
|
4051
|
+
])];
|
|
4052
|
+
const analyticalFrame = buildDeterministicAnalyticalFrame({
|
|
4053
|
+
question: requirementSeed.sourceQuestion,
|
|
4054
|
+
questionType: resolution.questionType,
|
|
4055
|
+
evidence: hostOwnedEvidence,
|
|
4056
|
+
metricCandidate,
|
|
4057
|
+
metricCandidates: explicitlyRequestedMetricCandidates(requirementSeed.sourceQuestion, hostOwnedEvidence, metricCandidate, candidates),
|
|
4058
|
+
entityTerms: requirementSeed.requirements.entityTerms,
|
|
4059
|
+
entityDisplayTerms: requirementSeed.requirements.entityDisplayTerms,
|
|
4060
|
+
selectedDimensionIds,
|
|
4061
|
+
candidates,
|
|
4062
|
+
});
|
|
4063
|
+
if (!analyticalFrame)
|
|
4064
|
+
return resolution;
|
|
4065
|
+
const hostBoundConceptIds = hostBoundExtensions.map((candidate) => candidate.id);
|
|
4066
|
+
const selectedConceptIds = [...new Set([
|
|
4067
|
+
...resolution.selectedConceptIds,
|
|
4068
|
+
...hostBoundConceptIds,
|
|
4069
|
+
])];
|
|
4070
|
+
const overrideReceipts = hostBoundConceptIds.length > 0
|
|
4071
|
+
? [
|
|
4072
|
+
...(resolution.overrideReceipts ?? []),
|
|
4073
|
+
{
|
|
4074
|
+
version: 1,
|
|
4075
|
+
field: 'candidate_selection',
|
|
4076
|
+
action: 'host_preserved',
|
|
4077
|
+
reason: 'A unique, same-snapshot MetricFlow grouping field is required by the host-owned categorical dimension and cannot be removed by a model omission.',
|
|
4078
|
+
candidateIds: hostBoundConceptIds,
|
|
4079
|
+
},
|
|
4080
|
+
]
|
|
4081
|
+
: resolution.overrideReceipts;
|
|
4082
|
+
return {
|
|
4083
|
+
...resolution,
|
|
4084
|
+
selectedConceptIds,
|
|
4085
|
+
analyticalFrame,
|
|
4086
|
+
...(overrideReceipts?.length ? { overrideReceipts } : {}),
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
/**
|
|
4090
|
+
* Bind an already validated runtime meaning selection to the immutable
|
|
4091
|
+
* host-owned analytical frame. This is intentionally a narrow compiler
|
|
4092
|
+
* adapter: it does not retrieve, rank candidates, call a provider, or change
|
|
4093
|
+
* the runtime program. Keeping the V2 frame construction here lets the
|
|
4094
|
+
* existing MetricFlow compiler retain its capability/additivity validation
|
|
4095
|
+
* without returning ownership of interpretation to the legacy router.
|
|
4096
|
+
*/
|
|
4097
|
+
export function bindAskAnalystProgramMeaningV1(input) {
|
|
4098
|
+
// The runtime has already verified which cards express the business tuple.
|
|
4099
|
+
// `input.candidates` can still contain the 16-card planner package so the
|
|
4100
|
+
// compiler has definition/relationship context, but it is not permission
|
|
4101
|
+
// to rebuild the frame with every correlated metric in that package. In the
|
|
4102
|
+
// retained Jaffle snapshot, that turned the singular Orders metric into
|
|
4103
|
+
// Orders + Large Orders + Food Orders + Drink Orders after verification.
|
|
4104
|
+
// Preserve only server-validated selection identities here; execution
|
|
4105
|
+
// closure remains a separate compiler input later in the cascade.
|
|
4106
|
+
const selectedIdentityIds = new Set([
|
|
4107
|
+
...input.resolution.selectedConceptIds,
|
|
4108
|
+
...(input.resolution.recommendedExecutionId ? [input.resolution.recommendedExecutionId] : []),
|
|
4109
|
+
]);
|
|
4110
|
+
const selectedCandidates = input.candidates.filter((candidate) => [candidate.id, candidate.qualifiedId]
|
|
4111
|
+
.filter((identity) => Boolean(identity))
|
|
4112
|
+
.some((identity) => selectedIdentityIds.has(identity)));
|
|
4113
|
+
// Legacy/direct callers can lack a selected card only when they supplied an
|
|
4114
|
+
// incomplete resolution. Keep their prior behavior rather than fabricating
|
|
4115
|
+
// an empty frame; authoritative Ask always takes the narrow branch above.
|
|
4116
|
+
const framingCandidates = selectedCandidates.length > 0 ? selectedCandidates : input.candidates;
|
|
4117
|
+
const metricCandidate = framingCandidates.find((candidate) => candidate.kind === 'semantic_metric'
|
|
4118
|
+
&& (candidate.id === input.resolution.recommendedExecutionId
|
|
4119
|
+
|| candidate.qualifiedId === input.resolution.recommendedExecutionId
|
|
4120
|
+
|| input.resolution.selectedConceptIds.some((identity) => candidate.id === identity || candidate.qualifiedId === identity)));
|
|
4121
|
+
const compilerFramingCandidates = metricCandidate
|
|
4122
|
+
? bindSelectedMetricFlowGroupingExtensions(metricCandidate, framingCandidates)
|
|
4123
|
+
: framingCandidates;
|
|
4124
|
+
// The Ask runtime may rebuild the compiler-facing seed after verifying
|
|
4125
|
+
// canonical planner bindings. `attachHostOwnedAnalyticalFrame` deliberately
|
|
4126
|
+
// preserves most of the incoming resolution, so explicitly carry that
|
|
4127
|
+
// verified seed forward here. Otherwise a valid same-snapshot extension
|
|
4128
|
+
// can produce a frame with `locations.location_name`, only for RAP binding
|
|
4129
|
+
// to reopen the stale pre-planner phrase `region` and mark it unresolved.
|
|
4130
|
+
const frameBound = {
|
|
4131
|
+
...attachHostOwnedAnalyticalFrame({ ...input, candidates: compilerFramingCandidates }),
|
|
4132
|
+
hostRequirementSeed: input.requirementSeed,
|
|
4133
|
+
};
|
|
4134
|
+
const protectedResolution = preventDegenerateRankingResolution(frameBound, input.evidence, compilerFramingCandidates, input.requirementSeed.sourceQuestion);
|
|
4135
|
+
// This adapter may enrich a selected MetricFlow tuple with host-owned
|
|
4136
|
+
// dimensions, but it may not silently subtract a verified runtime
|
|
4137
|
+
// selection. `attachHostOwnedAnalyticalFrame` rebuilds legacy resolution
|
|
4138
|
+
// fields for compiler compatibility; some native Measure -> MetricFlow
|
|
4139
|
+
// Metric pairs intentionally have different text identities (for example
|
|
4140
|
+
// `order_count` -> `Orders`). Keep every already-validated candidate ID
|
|
4141
|
+
// from the authoritative program, limited to this same candidate package.
|
|
4142
|
+
// The verifier/compiler still prove the metric's capability, grain, and
|
|
4143
|
+
// relationship closure before freeze.
|
|
4144
|
+
// The MetricFlow framing subset is allowed to shape legacy metric/grouping
|
|
4145
|
+
// metadata, but it is not the authority boundary for a V3 selection. A
|
|
4146
|
+
// verified planner can legitimately retain a qualified physical output or
|
|
4147
|
+
// context field beside its semantic metric. Keep those already-admitted
|
|
4148
|
+
// identities through the compatibility carrier; they remain subject to the
|
|
4149
|
+
// immutable compiler program's physical/semantic safety proof below.
|
|
4150
|
+
const allowed = new Set(input.candidates.flatMap((candidate) => [
|
|
4151
|
+
candidate.id,
|
|
4152
|
+
candidate.qualifiedId ?? candidate.id,
|
|
4153
|
+
]));
|
|
4154
|
+
const preservedSelectedConceptIds = input.resolution.selectedConceptIds
|
|
4155
|
+
.filter((identity) => allowed.has(identity));
|
|
4156
|
+
const selectedConceptIds = [...new Set([
|
|
4157
|
+
...protectedResolution.selectedConceptIds,
|
|
4158
|
+
...preservedSelectedConceptIds,
|
|
4159
|
+
])];
|
|
4160
|
+
const originalRecommendedExecutionId = input.resolution.recommendedExecutionId;
|
|
4161
|
+
return {
|
|
4162
|
+
...protectedResolution,
|
|
4163
|
+
selectedConceptIds,
|
|
4164
|
+
...(originalRecommendedExecutionId && allowed.has(originalRecommendedExecutionId)
|
|
4165
|
+
? { recommendedExecutionId: originalRecommendedExecutionId }
|
|
4166
|
+
: {}),
|
|
4167
|
+
};
|
|
4168
|
+
}
|
|
2474
4169
|
function explicitlyRequestedMetricCandidates(question, evidence, primary, candidates) {
|
|
2475
|
-
const
|
|
4170
|
+
const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
|
|
4171
|
+
const requested = requirements.ranking?.metricTerms.length
|
|
4172
|
+
? requirements.ranking.metricTerms
|
|
4173
|
+
: requirements.measures;
|
|
2476
4174
|
const requestedTerms = [...new Set(requested.map(normalizeMetricPhrase).filter(Boolean))];
|
|
2477
4175
|
// A single business metric can be retrieved alongside technical dbt measure
|
|
2478
4176
|
// shims and registry aliases that share its words. Those are execution
|
|
@@ -2489,6 +4187,8 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
|
|
|
2489
4187
|
const metrics = candidates.filter((candidate) => {
|
|
2490
4188
|
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
2491
4189
|
return false;
|
|
4190
|
+
if (candidateConflictsWithExplicitRankingMeasure(candidate, requirements))
|
|
4191
|
+
return false;
|
|
2492
4192
|
if (!normalizeEvidenceAnalyticalCapability(candidate).capability)
|
|
2493
4193
|
return false;
|
|
2494
4194
|
if (candidate.id === primary.id)
|
|
@@ -2607,6 +4307,29 @@ mayAssumeInterpretation = true) {
|
|
|
2607
4307
|
}
|
|
2608
4308
|
return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
|
|
2609
4309
|
}
|
|
4310
|
+
/**
|
|
4311
|
+
* A bounded meaning call may return malformed JSON or a syntactically valid
|
|
4312
|
+
* low-confidence response with no candidate binding. It must not override a
|
|
4313
|
+
* plan the host can prove from one exact current-turn semantic identity. The
|
|
4314
|
+
* predicate deliberately requires a frozen executable plan: ordinary metric
|
|
4315
|
+
* ambiguity, a coverage gap, policy denial, and every unfrozen candidate still
|
|
4316
|
+
* take their existing clarification/block paths.
|
|
4317
|
+
*/
|
|
4318
|
+
function isFrozenExecutableHostFallback(decision) {
|
|
4319
|
+
return decision.analyticalCascadeDecision?.planFrozen === true
|
|
4320
|
+
&& Boolean(decision.resolvedAnalyticalPlan)
|
|
4321
|
+
&& !decision.requiresClarification
|
|
4322
|
+
&& !decision.terminalOutcome;
|
|
4323
|
+
}
|
|
4324
|
+
function meaningResolutionHasNoBinding(resolution) {
|
|
4325
|
+
return resolution.selectedConceptIds.length === 0
|
|
4326
|
+
&& !resolution.recommendedExecutionId
|
|
4327
|
+
&& (resolution.emptyCandidateBinding === true || (!resolution.clarifyingQuestion
|
|
4328
|
+
&& resolution.recommendedRoute !== 'clarify'
|
|
4329
|
+
&& resolution.compatibilityOutcome !== 'clarify'
|
|
4330
|
+
&& resolution.missingInformation.length === 0
|
|
4331
|
+
&& resolution.rejectedCandidates.length === 0));
|
|
4332
|
+
}
|
|
2610
4333
|
/** Leaf identity of a governed candidate, ignoring its source qualification. */
|
|
2611
4334
|
function candidateLeafName(candidate) {
|
|
2612
4335
|
const identity = candidate.qualifiedId ?? candidate.id;
|
|
@@ -2919,7 +4642,11 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
2919
4642
|
// requires a declared calendar at compilation time; this router simply must
|
|
2920
4643
|
// not manufacture one or report an absence before relational/runtime context
|
|
2921
4644
|
// has had a chance to bind the declared date role.
|
|
2922
|
-
|
|
4645
|
+
// The host-owned requirements are the canonical reader tuple. Parsed intent
|
|
4646
|
+
// is only a retrieval seed: carrying its raw phrase here made "sales based
|
|
4647
|
+
// on the region" look like a literal physical dimension even after the
|
|
4648
|
+
// requirement seed had correctly normalized it to `region`.
|
|
4649
|
+
const requestedDimensions = requirements.dimensions
|
|
2923
4650
|
.filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
|
|
2924
4651
|
const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
|
|
2925
4652
|
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
|
|
@@ -2984,6 +4711,16 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
2984
4711
|
messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
|
|
2985
4712
|
});
|
|
2986
4713
|
}
|
|
4714
|
+
// A single declared alternative can only be assumed when an executable
|
|
4715
|
+
// metric capability in this same snapshot explicitly contains it. A bare
|
|
4716
|
+
// standalone semantic member (for example `location_name` marked as an
|
|
4717
|
+
// alternative for `region`) is evidence of a possible wording, not proof
|
|
4718
|
+
// that it can safely answer this tuple. Without that closure, retain the
|
|
4719
|
+
// concise modeled-gap clarification rather than letting a later meaning
|
|
4720
|
+
// path substitute an unrelated field or metric.
|
|
4721
|
+
if (alternatives.length === 1
|
|
4722
|
+
&& declaredDimensionAlternativeCompletesExecutableTuple(alternatives[0], candidates, requirements))
|
|
4723
|
+
return undefined;
|
|
2987
4724
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
2988
4725
|
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
2989
4726
|
return {
|
|
@@ -3009,6 +4746,22 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
3009
4746
|
}
|
|
3010
4747
|
return undefined;
|
|
3011
4748
|
}
|
|
4749
|
+
/**
|
|
4750
|
+
* The broad router terminal witness can carry reader-safe missing-role prose
|
|
4751
|
+
* for several gap classes. Observability persists only the relationship
|
|
4752
|
+
* variant, and only as an enumerated proof requirement, so an export cannot
|
|
4753
|
+
* accidentally turn an unresolved question term into trace content.
|
|
4754
|
+
*/
|
|
4755
|
+
function cascadeTerminalRelationshipGap(gap) {
|
|
4756
|
+
if (!gap || gap.code !== 'MISSING_RELATIONSHIP')
|
|
4757
|
+
return undefined;
|
|
4758
|
+
return {
|
|
4759
|
+
version: 1,
|
|
4760
|
+
code: 'MISSING_RELATIONSHIP',
|
|
4761
|
+
requirement: 'certified_relationship_or_allocation_proof',
|
|
4762
|
+
witnessCandidateIds: [...new Set(gap.witnessCandidateIds)].sort().slice(0, 32),
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
3012
4765
|
/**
|
|
3013
4766
|
* A clarification with NO selectable options is unanswerable, and asking it is a
|
|
3014
4767
|
* dead end rather than a safety measure.
|
|
@@ -3249,10 +5002,73 @@ function candidateProvesMetricTerm(candidate, requested) {
|
|
|
3249
5002
|
.some((term) => metricTermsMatch(term, requested));
|
|
3250
5003
|
}
|
|
3251
5004
|
function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
|
|
5005
|
+
const extension = candidate.sameSnapshotRoleExtension;
|
|
5006
|
+
if (extension?.role === 'categorical_dimension'
|
|
5007
|
+
&& (extension.basis === 'sole_metricflow_grouping_dimension'
|
|
5008
|
+
|| extension.basis === 'exact_metricflow_grouping_dimension')
|
|
5009
|
+
&& missingDimensions.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
|
|
5010
|
+
return true;
|
|
3252
5011
|
const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
|
|
3253
5012
|
return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
|
|
3254
5013
|
|| facts.includes(`dimension alternative for ${requested}`));
|
|
3255
5014
|
}
|
|
5015
|
+
function declaredDimensionAlternativeCompletesExecutableTuple(alternative, candidates, requirements) {
|
|
5016
|
+
// A substitution is executable only when a metric capability in the same
|
|
5017
|
+
// snapshot names this exact qualified dimension. Do not treat a lone
|
|
5018
|
+
// semantic-member card as an authorization to change the reader's request.
|
|
5019
|
+
if (requirements.measures.length === 0)
|
|
5020
|
+
return false;
|
|
5021
|
+
const alternativeId = alternative.qualifiedId ?? alternative.id;
|
|
5022
|
+
return candidates.some((candidate) => {
|
|
5023
|
+
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
5024
|
+
return false;
|
|
5025
|
+
if (!requirements.measures.every((measure) => candidateProvesMetricTerm(candidate, measure)))
|
|
5026
|
+
return false;
|
|
5027
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
5028
|
+
return normalized.status === 'complete'
|
|
5029
|
+
&& Boolean(normalized.capability?.dimensions.some((dimension) => dimension.dimensionId === alternativeId));
|
|
5030
|
+
});
|
|
5031
|
+
}
|
|
5032
|
+
/**
|
|
5033
|
+
* A declared dimension alternative is safe only after the immutable plan has
|
|
5034
|
+
* bound that exact qualified field. This makes the one permitted vocabulary
|
|
5035
|
+
* substitution visible to the reader without letting a raw parser phrase or
|
|
5036
|
+
* model response authorize it.
|
|
5037
|
+
*/
|
|
5038
|
+
function assumptionsForDeclaredDimensionAlternatives(input) {
|
|
5039
|
+
const requirements = buildAnalyticalRequirementSet({
|
|
5040
|
+
question: input.question,
|
|
5041
|
+
parsedIntent: input.evidence.parsedIntent,
|
|
5042
|
+
});
|
|
5043
|
+
const boundDimensionIds = new Set(input.plan.query.dimensions
|
|
5044
|
+
.filter((binding) => binding.status === 'resolved' && binding.qualifiedId)
|
|
5045
|
+
.map((binding) => binding.qualifiedId));
|
|
5046
|
+
const assumptions = [];
|
|
5047
|
+
for (const requested of requirements.dimensions) {
|
|
5048
|
+
const alternatives = input.candidates
|
|
5049
|
+
.filter(isCompatibleQualifiedMember)
|
|
5050
|
+
.filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, [requested]))
|
|
5051
|
+
.filter((candidate) => boundDimensionIds.has(candidate.qualifiedId ?? candidate.id));
|
|
5052
|
+
if (alternatives.length !== 1)
|
|
5053
|
+
continue;
|
|
5054
|
+
const alternative = alternatives[0];
|
|
5055
|
+
const extension = alternative.sameSnapshotRoleExtension;
|
|
5056
|
+
const assumption = assumeDominantCandidate({
|
|
5057
|
+
about: 'dimension',
|
|
5058
|
+
candidates: [{
|
|
5059
|
+
id: alternative.qualifiedId ?? alternative.id,
|
|
5060
|
+
label: alternative.name,
|
|
5061
|
+
score: alternative.relevanceScore,
|
|
5062
|
+
}],
|
|
5063
|
+
because: (chosen) => extension
|
|
5064
|
+
? `“${requested}” is bound to ${chosen.label ?? chosen.id}, the sole same-snapshot MetricFlow grouping field shared by the selected metric.`
|
|
5065
|
+
: `“${requested}” is not modeled directly; ${chosen.label ?? chosen.id} is the sole compatible, declared alternative in this snapshot.`,
|
|
5066
|
+
});
|
|
5067
|
+
if (assumption)
|
|
5068
|
+
assumptions.push(assumption);
|
|
5069
|
+
}
|
|
5070
|
+
return assumptions;
|
|
5071
|
+
}
|
|
3256
5072
|
/**
|
|
3257
5073
|
* A missing optional parsed-intent projection must not erase positive metric
|
|
3258
5074
|
* evidence already retrieved for the question. Only complete, compatible,
|
|
@@ -3350,12 +5166,50 @@ function renderCandidateChoice(candidate) {
|
|
|
3350
5166
|
* metrics therefore remain ambiguous and continue to clarification/resolution.
|
|
3351
5167
|
*/
|
|
3352
5168
|
function uniqueExecutableSemanticMetric(evidence, candidates) {
|
|
3353
|
-
const
|
|
5169
|
+
const requestedMeasures = [...new Set((evidence.parsedIntent?.measures ?? [])
|
|
5170
|
+
.map(normalizeMetricPhrase)
|
|
5171
|
+
.filter(Boolean))];
|
|
5172
|
+
const requestedTokens = metricTokens(requestedMeasures);
|
|
3354
5173
|
if (requestedTokens.size === 0)
|
|
3355
5174
|
return undefined;
|
|
5175
|
+
// Do this before capability normalization. Local metadata can retain an
|
|
5176
|
+
// executable semantic metric card while its pre-plan compatibility hint is
|
|
5177
|
+
// still partial/unknown; a selected provider binding correctly reaches the
|
|
5178
|
+
// capability solver in that state. The exact leaf identity is nevertheless
|
|
5179
|
+
// a real, qualified current-snapshot meaning. Dedupe only representations
|
|
5180
|
+
// of the same qualified metric, never two same-named metrics from different
|
|
5181
|
+
// models, and let the immutable cascade prove execution afterwards.
|
|
5182
|
+
if (requestedMeasures.length === 1) {
|
|
5183
|
+
const requested = requestedMeasures[0];
|
|
5184
|
+
const exactByMetricIdentity = new Map();
|
|
5185
|
+
for (const candidate of candidates) {
|
|
5186
|
+
if (candidate.kind !== 'semantic_metric'
|
|
5187
|
+
|| candidate.compatibility === 'incompatible'
|
|
5188
|
+
|| candidate.eligible === false
|
|
5189
|
+
|| !candidateMetricLeafIdentities(candidate).includes(requested))
|
|
5190
|
+
continue;
|
|
5191
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
5192
|
+
const metricIdentity = normalized.capability?.metricId
|
|
5193
|
+
?? candidate.qualifiedId
|
|
5194
|
+
?? candidate.id;
|
|
5195
|
+
const current = exactByMetricIdentity.get(metricIdentity);
|
|
5196
|
+
if (!current || candidate.relevanceScore > current.relevanceScore) {
|
|
5197
|
+
exactByMetricIdentity.set(metricIdentity, candidate);
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
if (exactByMetricIdentity.size === 1) {
|
|
5201
|
+
return [...exactByMetricIdentity.values()][0];
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
3356
5204
|
const byMetricId = new Map();
|
|
3357
5205
|
for (const candidate of candidates) {
|
|
3358
|
-
|
|
5206
|
+
// Retrieval compatibility is a pre-plan hint. A current snapshot can mark
|
|
5207
|
+
// a MetricFlow metric `unknown`/`partial` until its selected grouping is
|
|
5208
|
+
// bound, even though the capability itself is complete. The direct route
|
|
5209
|
+
// below still solves the full tuple before freeze, so exclude only an
|
|
5210
|
+
// explicitly incompatible metric here rather than turning exact `revenue`
|
|
5211
|
+
// into an artificial choice against `product_revenue`.
|
|
5212
|
+
if (candidate.kind !== "semantic_metric" || candidate.compatibility === "incompatible")
|
|
3359
5213
|
continue;
|
|
3360
5214
|
if (/\bdbt\s+measure\b/i.test(candidate.provenance ?? ""))
|
|
3361
5215
|
continue;
|
|
@@ -3374,8 +5228,33 @@ function uniqueExecutableSemanticMetric(evidence, candidates) {
|
|
|
3374
5228
|
byMetricId.set(normalized.capability.metricId, candidate);
|
|
3375
5229
|
}
|
|
3376
5230
|
}
|
|
5231
|
+
// A malformed/empty candidate-ID meaning response may also safely fall back
|
|
5232
|
+
// when a complete capability proves one exact semantic metric identity from
|
|
5233
|
+
// the current-turn requirement seed. This is intentionally stricter than
|
|
5234
|
+
// the token fallback below: `order_items.revenue` has the leaf identity
|
|
5235
|
+
// `revenue`, whereas `order_items.product_revenue` is a different meaning
|
|
5236
|
+
// and must not force a clarification when the reader asked only for
|
|
5237
|
+
// revenue. Multi-metric questions retain their dedicated exact tuple path.
|
|
5238
|
+
if (requestedMeasures.length === 1) {
|
|
5239
|
+
const requested = requestedMeasures[0];
|
|
5240
|
+
const exactMatches = [...byMetricId.values()].filter((candidate) => candidateMetricLeafIdentities(candidate).includes(requested));
|
|
5241
|
+
if (exactMatches.length === 1)
|
|
5242
|
+
return exactMatches[0];
|
|
5243
|
+
}
|
|
3377
5244
|
return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
|
|
3378
5245
|
}
|
|
5246
|
+
function candidateMetricLeafIdentities(candidate) {
|
|
5247
|
+
return [...new Set([
|
|
5248
|
+
candidate.id,
|
|
5249
|
+
candidate.qualifiedId ?? '',
|
|
5250
|
+
candidate.name,
|
|
5251
|
+
...(candidate.aliases ?? []),
|
|
5252
|
+
].flatMap((identity) => {
|
|
5253
|
+
const normalized = normalizeMetricPhrase(identity);
|
|
5254
|
+
const leaf = normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity);
|
|
5255
|
+
return [normalized, leaf].filter(Boolean);
|
|
5256
|
+
}))];
|
|
5257
|
+
}
|
|
3379
5258
|
function metricTokens(values) {
|
|
3380
5259
|
const ignored = new Set([
|
|
3381
5260
|
"a", "an", "and", "as", "at", "by", "current", "for", "from", "is",
|
|
@@ -3478,6 +5357,81 @@ function rethrowCancellation(error, ...signals) {
|
|
|
3478
5357
|
if (error instanceof Error && error.name === "AbortError")
|
|
3479
5358
|
throw error;
|
|
3480
5359
|
}
|
|
5360
|
+
/**
|
|
5361
|
+
* Admit the one narrowly typed extension produced by metadata only when it
|
|
5362
|
+
* closes an actually unmet requested categorical role and its source metric is
|
|
5363
|
+
* already in the compact meaning package. The extension was derived from the
|
|
5364
|
+
* same immutable metric capability snapshot; this function does not search,
|
|
5365
|
+
* infer a join, or create an alias. Multiple candidates for a role stay an
|
|
5366
|
+
* ambiguity instead of being silently ranked into a new business meaning.
|
|
5367
|
+
*/
|
|
5368
|
+
function sameSnapshotRoleTargetedMeaningExtensions(input) {
|
|
5369
|
+
const extensionsByRole = new Map();
|
|
5370
|
+
const requestedCategoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
5371
|
+
for (const candidate of input.clarificationCandidates) {
|
|
5372
|
+
const extension = candidate.sameSnapshotRoleExtension;
|
|
5373
|
+
if (!extension)
|
|
5374
|
+
continue;
|
|
5375
|
+
if (!requestedCategoricalTerms.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
|
|
5376
|
+
continue;
|
|
5377
|
+
// A raw/dbt column can lexically satisfy "product category", but it is
|
|
5378
|
+
// not a MetricFlow binding for this semantic metric. Do not let that
|
|
5379
|
+
// broad retrieval hit suppress the exact same-snapshot semantic member:
|
|
5380
|
+
// otherwise a package contains only the raw column, the model cannot bind
|
|
5381
|
+
// the metric-native grouping, and the cascade falsely reports absence.
|
|
5382
|
+
// An already-admitted semantic member remains sufficient and keeps this
|
|
5383
|
+
// extension bounded to genuinely unmet semantic roles.
|
|
5384
|
+
if (input.candidates.some((admitted) => admitted.kind === 'semantic_member'
|
|
5385
|
+
&& candidateMatchesCategoricalDimensionRequirement(admitted, [extension.requestedTerm])))
|
|
5386
|
+
continue;
|
|
5387
|
+
// The compact package may retain a metric card across reloads, but only
|
|
5388
|
+
// its *current* normalized capability can authorize the companion
|
|
5389
|
+
// grouping card. Reuse the router/compiler/workspace proof rather than
|
|
5390
|
+
// accepting matching strings for metric/dimension identities.
|
|
5391
|
+
const sourceMetricProof = input.candidates.flatMap((admitted) => {
|
|
5392
|
+
const proof = proveSameSnapshotMetricflowRoleExtensionV1({
|
|
5393
|
+
candidate,
|
|
5394
|
+
metricCandidate: admitted,
|
|
5395
|
+
});
|
|
5396
|
+
return proof ? [proof] : [];
|
|
5397
|
+
});
|
|
5398
|
+
if (sourceMetricProof.length === 0)
|
|
5399
|
+
continue;
|
|
5400
|
+
const role = normalizeMetricPhrase(extension.requestedTerm);
|
|
5401
|
+
const values = extensionsByRole.get(role) ?? [];
|
|
5402
|
+
if (!values.some((value) => value.id === candidate.id))
|
|
5403
|
+
values.push(candidate);
|
|
5404
|
+
extensionsByRole.set(role, values);
|
|
5405
|
+
}
|
|
5406
|
+
return [...extensionsByRole.values()]
|
|
5407
|
+
.flatMap((values) => values.length === 1 ? values : [])
|
|
5408
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
5409
|
+
}
|
|
5410
|
+
/**
|
|
5411
|
+
* Keep the role-targeted form of a qualified candidate when retrieval has
|
|
5412
|
+
* emitted both the ordinary catalog card and a same-snapshot extension with
|
|
5413
|
+
* the identical stable ID. The extension is not a second object or a new
|
|
5414
|
+
* join: it is the metadata proof explaining why that exact qualified object
|
|
5415
|
+
* closes a currently unmet role for a selected metric. Dropping it merely
|
|
5416
|
+
* because the less-specific catalog card appeared first makes the compact
|
|
5417
|
+
* meaning package lose a declared MetricFlow grouping and can manufacture a
|
|
5418
|
+
* false coverage gap.
|
|
5419
|
+
*/
|
|
5420
|
+
function consolidateClarificationCandidates(input) {
|
|
5421
|
+
const byId = new Map();
|
|
5422
|
+
for (const candidate of input.candidates) {
|
|
5423
|
+
if (candidate.eligible === false
|
|
5424
|
+
|| candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
|
|
5425
|
+
continue;
|
|
5426
|
+
const existing = byId.get(candidate.id);
|
|
5427
|
+
if (!existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension)) {
|
|
5428
|
+
// Map replacement preserves the original stable ordering while retaining
|
|
5429
|
+
// the stricter same-snapshot proof for this exact candidate identity.
|
|
5430
|
+
byId.set(candidate.id, candidate);
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
return [...byId.values()];
|
|
5434
|
+
}
|
|
3481
5435
|
/**
|
|
3482
5436
|
* Build a retrieval-first hybrid router. Narrow conversational/app preflight is
|
|
3483
5437
|
* deterministic. Every other turn loads structured evidence before any
|
|
@@ -3523,8 +5477,12 @@ export function createHybridRouter(options = {}) {
|
|
|
3523
5477
|
if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
|
|
3524
5478
|
return softBoundaryDecision(request, base, initialDiscoveryRoute);
|
|
3525
5479
|
}
|
|
3526
|
-
|
|
3527
|
-
|
|
5480
|
+
// AskAnalystRuntimeV1 acquires one immutable snapshot before invoking
|
|
5481
|
+
// this compiler broker. Reusing that host-only handoff prevents a second
|
|
5482
|
+
// retrieval/ranking decision from silently becoming another source of
|
|
5483
|
+
// truth. Legacy callers retain the original retrieval behavior.
|
|
5484
|
+
let evidence = request.askAnalystEvidence;
|
|
5485
|
+
if (!evidence && options.getEvidence) {
|
|
3528
5486
|
try {
|
|
3529
5487
|
evidence = await options.getEvidence(request);
|
|
3530
5488
|
}
|
|
@@ -3541,6 +5499,10 @@ export function createHybridRouter(options = {}) {
|
|
|
3541
5499
|
// boundary so every downstream plan/meaning path consumes the same
|
|
3542
5500
|
// typed measure + entity/dimension requirements.
|
|
3543
5501
|
evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
|
|
5502
|
+
// Keep the complete snapshot result for trace-only lifecycle evidence
|
|
5503
|
+
// before canonicalization and bounded meaning-package admission prune it.
|
|
5504
|
+
const retrievedCandidates = evidence.candidates;
|
|
5505
|
+
traceRetrievalLanesBeforePruning(request, evidence, retrievedCandidates);
|
|
3544
5506
|
evidence = {
|
|
3545
5507
|
...evidence,
|
|
3546
5508
|
candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
|
|
@@ -3551,26 +5513,140 @@ export function createHybridRouter(options = {}) {
|
|
|
3551
5513
|
]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
|
|
3552
5514
|
: {}),
|
|
3553
5515
|
};
|
|
5516
|
+
// Authoritative AskAnalystRuntimeV1 already completed the bounded
|
|
5517
|
+
// interpretation/tool phase. Do not re-run legacy candidate ranking,
|
|
5518
|
+
// clarification heuristics, or a provider call here: this layer is a
|
|
5519
|
+
// compiler broker only and can validate exactly the immutable program
|
|
5520
|
+
// selection against safe compiler primitives.
|
|
5521
|
+
if (request.askAnalystProgram && request.askAnalystMeaningResolution) {
|
|
5522
|
+
const programIds = new Set(request.askAnalystProgram.candidateIds);
|
|
5523
|
+
const executionIds = new Set(request.askAnalystProgram.executionCandidateIds
|
|
5524
|
+
?? request.askAnalystProgram.candidateIds);
|
|
5525
|
+
const programCandidates = evidence.candidates.filter((candidate) => programIds.has(candidate.id) || programIds.has(candidate.qualifiedId ?? candidate.id));
|
|
5526
|
+
const executionCandidates = evidence.candidates.filter((candidate) => executionIds.has(candidate.id) || executionIds.has(candidate.qualifiedId ?? candidate.id));
|
|
5527
|
+
const requirements = request.askAnalystState?.frame.requirements
|
|
5528
|
+
?? request.hostRequirementSeed?.requirements;
|
|
5529
|
+
if (!requirements || programCandidates.length === 0) {
|
|
5530
|
+
return {
|
|
5531
|
+
...base,
|
|
5532
|
+
action: 'block',
|
|
5533
|
+
confidence: 1,
|
|
5534
|
+
reason: 'The Ask runtime program could not be compiled because its snapshot-bound candidate selection was unavailable.',
|
|
5535
|
+
source: 'heuristic',
|
|
5536
|
+
followsUp: false,
|
|
5537
|
+
terminalOutcome: {
|
|
5538
|
+
kind: 'modeling_gap',
|
|
5539
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
5540
|
+
message: 'The immutable Ask runtime program did not retain a compiler-eligible candidate selection.',
|
|
5541
|
+
candidateIds: request.askAnalystProgram.candidateIds,
|
|
5542
|
+
},
|
|
5543
|
+
};
|
|
5544
|
+
}
|
|
5545
|
+
return compileAskAnalyticalProgramV1({
|
|
5546
|
+
base,
|
|
5547
|
+
request,
|
|
5548
|
+
evidence,
|
|
5549
|
+
program: request.askAnalystProgram,
|
|
5550
|
+
candidates: programCandidates,
|
|
5551
|
+
executionCandidates,
|
|
5552
|
+
resolution: request.askAnalystMeaningResolution,
|
|
5553
|
+
requirements,
|
|
5554
|
+
mode: options.resolvedPlanMode ?? 'authoritative',
|
|
5555
|
+
});
|
|
5556
|
+
}
|
|
5557
|
+
// Ranking measure identity is resolved from the same immutable
|
|
5558
|
+
// request/evidence frame that drives package reservation. A metric
|
|
5559
|
+
// may remain in broad retrieval context, but it cannot become a
|
|
5560
|
+
// clarification or meaning-selection alternative after an explicit
|
|
5561
|
+
// comparator already bound a different ranking measure.
|
|
5562
|
+
const persistedSelection = request.selectedEvidenceId
|
|
5563
|
+
? persistedClarificationSelectionContext(request)
|
|
5564
|
+
: undefined;
|
|
5565
|
+
// A structured click is a continuation of the original server turn,
|
|
5566
|
+
// not a new parse of its display label. Retain the typed partial frame
|
|
5567
|
+
// (ranking/time included) before candidate pruning can affect it.
|
|
5568
|
+
const continuationQuestion = persistedSelection?.serverIssued && persistedSelection.sourceQuestion
|
|
5569
|
+
? persistedSelection.sourceQuestion
|
|
5570
|
+
// A free-text turn is always a new source question. A browser may
|
|
5571
|
+
// retain clarificationSourceQuestion for display, but it must never
|
|
5572
|
+
// make stale retrieval intent or a previous result authoritative.
|
|
5573
|
+
: request.question;
|
|
5574
|
+
// Retrieval parser output is never an independent source of request
|
|
5575
|
+
// authority, but it can refine a host-owned source question when every
|
|
5576
|
+
// business term is demonstrably present in that question. In
|
|
5577
|
+
// particular, "active seats" is a legitimate current-turn metric
|
|
5578
|
+
// refinement for "Which workspaces have the most active seats?";
|
|
5579
|
+
// omitting it altogether forced the seed to erase an explicit metric
|
|
5580
|
+
// before the model had a chance only to bind it. The shared
|
|
5581
|
+
// requirement builder rejects stale parser terms (for example a prior
|
|
5582
|
+
// rollover balance, member filter, or top-N), so this remains a
|
|
5583
|
+
// source-question-plus-server-selection tuple rather than retrieval
|
|
5584
|
+
// intent becoming plan authority.
|
|
5585
|
+
// A server-composed child Ask (currently a Research hypothesis) may
|
|
5586
|
+
// carry an exact host-owned seed. Planner prose is not user wording:
|
|
5587
|
+
// reparsing it can turn an asset namespace or explanatory phrase into
|
|
5588
|
+
// a second requested measure. Accept the seed only when it belongs
|
|
5589
|
+
// to this exact source question and there is no structured
|
|
5590
|
+
// continuation to merge; public request parsers never hydrate this
|
|
5591
|
+
// host-only field.
|
|
5592
|
+
const hostSeed = !request.selectedEvidenceId
|
|
5593
|
+
&& request.hostRequirementSeed?.version === 1
|
|
5594
|
+
&& request.hostRequirementSeed.sourceQuestion === continuationQuestion
|
|
5595
|
+
? request.hostRequirementSeed
|
|
5596
|
+
: undefined;
|
|
5597
|
+
const sourceQuestionRequirements = hostSeed?.requirements
|
|
5598
|
+
?? buildAnalyticalRequirementSet({
|
|
5599
|
+
question: continuationQuestion,
|
|
5600
|
+
parsedIntent: evidence.parsedIntent,
|
|
5601
|
+
});
|
|
5602
|
+
const analyticalRequirements = request.selectedEvidenceId
|
|
5603
|
+
? mergeClarificationRequirements(sourceQuestionRequirements, persistedSelection?.serverIssued ? persistedSelection.requirements : undefined)
|
|
5604
|
+
: sourceQuestionRequirements;
|
|
5605
|
+
// The host freezes the reader's requested tuple before the bounded
|
|
5606
|
+
// meaning call. A model may bind only supplied candidate identities;
|
|
5607
|
+
// it cannot erase explicit outputs, ranking, time, or a new question's
|
|
5608
|
+
// independent scope by rephrasing the request.
|
|
5609
|
+
const requirementSeed = hostSeed
|
|
5610
|
+
? hostSeed
|
|
5611
|
+
: buildAnalyticalRequirementSeedV1({
|
|
5612
|
+
question: continuationQuestion,
|
|
5613
|
+
parsedIntent: evidence.parsedIntent,
|
|
5614
|
+
requirements: analyticalRequirements,
|
|
5615
|
+
fiscalCalendar: declaredFiscalCalendar(evidence, evidence.candidates),
|
|
5616
|
+
});
|
|
3554
5617
|
// Retrieval can retain more context internally; only this compact,
|
|
3555
5618
|
// role-balanced package reaches the one meaning call. The question is
|
|
3556
5619
|
// supplied so explicit revenue, entity labels, and time roles cannot be
|
|
3557
5620
|
// pruned by unrelated lexical matches.
|
|
3558
|
-
let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16,
|
|
3559
|
-
|
|
3560
|
-
// complete already-retrieved set
|
|
3561
|
-
//
|
|
3562
|
-
// generic/generated answer
|
|
3563
|
-
// package
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
5621
|
+
let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, continuationQuestion);
|
|
5622
|
+
recordAskCandidateLifecycleV1(request, evidence, retrievedCandidates, candidates);
|
|
5623
|
+
// The complete already-retrieved set is retained only to validate a
|
|
5624
|
+
// server-issued stable selection before any route can fall through to
|
|
5625
|
+
// a generic/generated answer. New free-text choices are constrained to
|
|
5626
|
+
// the admitted package below, so a pruned candidate cannot reappear as
|
|
5627
|
+
// an unrelated clarification option.
|
|
5628
|
+
const clarificationCandidates = consolidateClarificationCandidates({
|
|
5629
|
+
candidates: [
|
|
5630
|
+
...evidence.candidates,
|
|
5631
|
+
...(evidence.clarificationCandidates ?? []),
|
|
5632
|
+
],
|
|
5633
|
+
requirements: analyticalRequirements,
|
|
5634
|
+
});
|
|
3568
5635
|
// A structured clarification selection is identity input, not a new
|
|
3569
5636
|
// fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
|
|
3570
5637
|
// supplemental clarification candidates, not execution candidates.
|
|
3571
|
-
|
|
5638
|
+
let selectedEvidence = request.selectedEvidenceId
|
|
3572
5639
|
? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
|
|
3573
5640
|
: undefined;
|
|
5641
|
+
if (!selectedEvidence && request.selectedEvidenceId) {
|
|
5642
|
+
selectedEvidence = rehydrateServerIssuedCapabilityDimensionSelection({
|
|
5643
|
+
request,
|
|
5644
|
+
evidence,
|
|
5645
|
+
candidates: clarificationCandidates,
|
|
5646
|
+
});
|
|
5647
|
+
if (selectedEvidence)
|
|
5648
|
+
clarificationCandidates.push(selectedEvidence);
|
|
5649
|
+
}
|
|
3574
5650
|
const structuredSelection = request.selectedEvidenceId
|
|
3575
5651
|
? validateStructuredClarificationSelection({
|
|
3576
5652
|
request,
|
|
@@ -3589,10 +5665,77 @@ export function createHybridRouter(options = {}) {
|
|
|
3589
5665
|
validation: structuredSelection,
|
|
3590
5666
|
});
|
|
3591
5667
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
5668
|
+
const selectedDimensionBinding = structuredSelection?.ok
|
|
5669
|
+
&& structuredSelection.selection.kind === 'dimension'
|
|
5670
|
+
? structuredSelection.selection
|
|
5671
|
+
: undefined;
|
|
5672
|
+
const stableSelectionCandidates = [
|
|
5673
|
+
...(selectedDimensionBinding ? [selectedDimensionBinding.metricCandidate] : []),
|
|
5674
|
+
...(selectedEvidence ? [selectedEvidence] : []),
|
|
5675
|
+
].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
|
|
5676
|
+
// One host-authored, role-targeted extension may enter the same
|
|
5677
|
+
// snapshot package before absence is declared. It is eligible only
|
|
5678
|
+
// when a selected/retrieved semantic metric itself proves one unique
|
|
5679
|
+
// MetricFlow grouping dimension for the unmet business role. This is
|
|
5680
|
+
// not a lexical location→region rule and never creates a raw join.
|
|
5681
|
+
const roleTargetedExtensions = sameSnapshotRoleTargetedMeaningExtensions({
|
|
5682
|
+
candidates,
|
|
5683
|
+
clarificationCandidates,
|
|
5684
|
+
requirements: analyticalRequirements,
|
|
5685
|
+
});
|
|
5686
|
+
const extensions = [
|
|
5687
|
+
...stableSelectionCandidates,
|
|
5688
|
+
...roleTargetedExtensions,
|
|
5689
|
+
].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index
|
|
5690
|
+
// A role-targeted card may intentionally share the stable ID of an
|
|
5691
|
+
// unscoped catalog card already admitted by generic relevance. In
|
|
5692
|
+
// that case replace the card in-place below so the provider sees the
|
|
5693
|
+
// same qualified object plus the immutable role proof; do not drop
|
|
5694
|
+
// the proof just because its base record was admitted first.
|
|
5695
|
+
&& (() => {
|
|
5696
|
+
const existing = candidates.find((item) => item.id === candidate.id);
|
|
5697
|
+
return !existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension);
|
|
5698
|
+
})());
|
|
5699
|
+
if (extensions.length > 0) {
|
|
5700
|
+
// Keep the exact metric and the separately selected semantic member
|
|
5701
|
+
// together. The choice never becomes a synthetic metric merely to
|
|
5702
|
+
// fit the bounded meaning package.
|
|
5703
|
+
candidates = [...extensions, ...candidates.filter((candidate) => !extensions.some((extension) => extension.id === candidate.id))]
|
|
5704
|
+
.slice(0, Math.max(1, Math.min(16, options.maxMeaningCandidates ?? 16)));
|
|
5705
|
+
const observer = askTraceObserverForV1(request);
|
|
5706
|
+
for (const extension of extensions) {
|
|
5707
|
+
const roles = evidenceCandidateRoles(extension);
|
|
5708
|
+
for (const role of roles) {
|
|
5709
|
+
observer.recordCandidateDecision({
|
|
5710
|
+
// This must stay on the router's stable resolver identity. The
|
|
5711
|
+
// qualifiedId is display/provenance data and can differ from the
|
|
5712
|
+
// candidate id used by meaning validation and model selection.
|
|
5713
|
+
candidateId: extension.id,
|
|
5714
|
+
role,
|
|
5715
|
+
source: traceSourceForCandidate(extension),
|
|
5716
|
+
decision: 'extended',
|
|
5717
|
+
reasonCode: 'same_snapshot_extension',
|
|
5718
|
+
compatibilityCode: 'unknown',
|
|
5719
|
+
});
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
3595
5722
|
}
|
|
5723
|
+
// Only package-admitted, role-compatible candidates may become a new
|
|
5724
|
+
// free-text clarification option. The complete snapshot is retained
|
|
5725
|
+
// above exclusively to validate a server-issued stable selection or
|
|
5726
|
+
// to prove a terminal safety boundary; it must never let a pruned,
|
|
5727
|
+
// unrelated metric re-enter the answer flow as a choice.
|
|
5728
|
+
const admittedClarificationCandidates = candidates.filter((candidate) => candidate.eligible !== false
|
|
5729
|
+
&& candidate.compatibility !== 'incompatible'
|
|
5730
|
+
&& !candidateConflictsWithExplicitRankingMeasure(candidate, analyticalRequirements));
|
|
5731
|
+
const attributionGap = attributionRequiredRelationshipGapDecision({
|
|
5732
|
+
request,
|
|
5733
|
+
base,
|
|
5734
|
+
evidence,
|
|
5735
|
+
requirements: analyticalRequirements,
|
|
5736
|
+
});
|
|
5737
|
+
if (attributionGap)
|
|
5738
|
+
return attributionGap;
|
|
3596
5739
|
// The compact meaning package can be empty when every ranked card was
|
|
3597
5740
|
// reserved for a role that the parser marked missing. A unique authored
|
|
3598
5741
|
// certified example is still an authoritative snapshot fact, so it
|
|
@@ -3614,9 +5757,18 @@ export function createHybridRouter(options = {}) {
|
|
|
3614
5757
|
const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
|
|
3615
5758
|
if (fiscalClarification)
|
|
3616
5759
|
return fiscalClarification;
|
|
3617
|
-
|
|
5760
|
+
// A selected display/grouping field completes a persisted frame; the
|
|
5761
|
+
// matched semantic metric remains the only execution/measure
|
|
5762
|
+
// authority. A selected dimension must therefore never flow through
|
|
5763
|
+
// the metric-only direct-resolution path as the primary candidate.
|
|
5764
|
+
const canonicalLiteral = canonicalLiteralEvidenceReference(request.question, candidates);
|
|
5765
|
+
const explicit = selectedDimensionBinding?.metricCandidate
|
|
5766
|
+
?? selectedEvidence
|
|
5767
|
+
?? findExplicitEvidenceReference(request.question, candidates)
|
|
5768
|
+
?? canonicalLiteral;
|
|
3618
5769
|
const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
|
|
3619
|
-
|| /@(metric|block|model|table|column)\(/i.test(request.question)
|
|
5770
|
+
|| /@(metric|block|model|table|column)\(/i.test(request.question)
|
|
5771
|
+
|| canonicalLiteral?.id === explicit.id));
|
|
3620
5772
|
const shouldUseMeaningCall = requireMeaningCall
|
|
3621
5773
|
&& !explicitMeaningBinding
|
|
3622
5774
|
&& Boolean(options.resolveMeaning || options.complete);
|
|
@@ -3627,7 +5779,7 @@ export function createHybridRouter(options = {}) {
|
|
|
3627
5779
|
// execution shim as the answer and never let the meaning model see
|
|
3628
5780
|
// the ranking entity/measure distinction.
|
|
3629
5781
|
if (!shouldUseMeaningCall && !explicitMeaningBinding) {
|
|
3630
|
-
const deterministicClarification = deterministicPrePlanClarification(request, base, evidence,
|
|
5782
|
+
const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
|
|
3631
5783
|
if (deterministicClarification)
|
|
3632
5784
|
return deterministicClarification;
|
|
3633
5785
|
}
|
|
@@ -3638,7 +5790,7 @@ export function createHybridRouter(options = {}) {
|
|
|
3638
5790
|
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
3639
5791
|
return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
|
|
3640
5792
|
}
|
|
3641
|
-
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
5793
|
+
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates, selectedDimensionBinding ? [selectedDimensionBinding.dimensionId] : [], selectedDimensionBinding ? [selectedDimensionBinding.selectedDimensionId] : []), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
3642
5794
|
// A valid stable semantic choice may still be pre-freeze-ineligible
|
|
3643
5795
|
// in this local snapshot (for example, the semantic adapter cannot
|
|
3644
5796
|
// execute the selected tuple). That is a normal cascade condition:
|
|
@@ -3648,10 +5800,16 @@ export function createHybridRouter(options = {}) {
|
|
|
3648
5800
|
const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
|
|
3649
5801
|
&& decision.resolvedAnalyticalPlan?.capability === 'blocked'
|
|
3650
5802
|
&& decision.analyticalCascadeDecision?.planFrozen === false;
|
|
3651
|
-
return
|
|
5803
|
+
return explicit
|
|
3652
5804
|
&& structuredSelection?.ok
|
|
3653
5805
|
&& (decision.requiresClarification || selectedPreFreezeModelingGap)
|
|
3654
|
-
? continueCascadeAfterIncompleteSelection(base, evidence, candidates,
|
|
5806
|
+
? continueCascadeAfterIncompleteSelection(base, evidence, candidates,
|
|
5807
|
+
// Preserve the display-key identity the user clicked in
|
|
5808
|
+
// user-facing gap/continuation evidence; the paired metric
|
|
5809
|
+
// remains recorded below as execution provenance.
|
|
5810
|
+
selectedEvidence ?? explicit, request.question, selectedDimensionBinding
|
|
5811
|
+
? [selectedDimensionBinding.metricCandidate.id, selectedDimensionBinding.selectedDimensionId]
|
|
5812
|
+
: [explicit.id])
|
|
3655
5813
|
: decision;
|
|
3656
5814
|
}
|
|
3657
5815
|
const multiMetricPrimary = !shouldUseMeaningCall
|
|
@@ -3687,41 +5845,97 @@ export function createHybridRouter(options = {}) {
|
|
|
3687
5845
|
// did run, committing to the best governed reading is the whole point.
|
|
3688
5846
|
let meaningResolverReachable = true;
|
|
3689
5847
|
try {
|
|
3690
|
-
|
|
3691
|
-
|
|
5848
|
+
// Meaning is the one bounded planning continuation of an ordinary
|
|
5849
|
+
// generated Ask, not a user clarification. Charging it to the
|
|
5850
|
+
// short clarify target was the direct cause of the 1.14.x
|
|
5851
|
+
// "discovery window ended" loop before semantic/raw fallback.
|
|
5852
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery('generated_answer')) {
|
|
5853
|
+
return softBoundaryDecision(request, base, 'generated_answer');
|
|
3692
5854
|
}
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
5855
|
+
let resolution;
|
|
5856
|
+
try {
|
|
5857
|
+
resolution = options.resolveMeaning
|
|
5858
|
+
? await options.resolveMeaning({
|
|
5859
|
+
question: requirementSeed.sourceQuestion,
|
|
5860
|
+
history: request.conversationBinding && request.conversationBinding !== 'none'
|
|
5861
|
+
? effectiveConversationHistory(request)
|
|
5862
|
+
: undefined,
|
|
5863
|
+
// The resolver/provider receives the same bounded evidence
|
|
5864
|
+
// package as its candidate argument. Supplemental qualified
|
|
5865
|
+
// cards are a host-only clarification aid and must not leak
|
|
5866
|
+
// through this richer carrier.
|
|
5867
|
+
evidence: {
|
|
5868
|
+
...evidence,
|
|
5869
|
+
candidates,
|
|
5870
|
+
clarificationCandidates: undefined,
|
|
5871
|
+
},
|
|
3703
5872
|
candidates,
|
|
3704
|
-
|
|
3705
|
-
},
|
|
3706
|
-
candidates,
|
|
3707
|
-
signal: request.signal ?? options.signal,
|
|
3708
|
-
})
|
|
3709
|
-
: options.complete
|
|
3710
|
-
? parseMeaningResolution(await options.complete({
|
|
3711
|
-
system: buildMeaningSystemPrompt(),
|
|
3712
|
-
user: buildMeaningUserPrompt(request, evidence, candidates),
|
|
5873
|
+
requirementSeed,
|
|
3713
5874
|
signal: request.signal ?? options.signal,
|
|
3714
|
-
})
|
|
3715
|
-
:
|
|
5875
|
+
})
|
|
5876
|
+
: options.complete
|
|
5877
|
+
? parseMeaningResolution(await options.complete({
|
|
5878
|
+
system: buildMeaningSystemPrompt(),
|
|
5879
|
+
user: buildMeaningUserPrompt(request, evidence, candidates, requirementSeed),
|
|
5880
|
+
signal: request.signal ?? options.signal,
|
|
5881
|
+
request,
|
|
5882
|
+
phase: 'meaning_resolution',
|
|
5883
|
+
}), requirementSeed)
|
|
5884
|
+
: undefined;
|
|
5885
|
+
}
|
|
5886
|
+
catch (error) {
|
|
5887
|
+
throw error;
|
|
5888
|
+
}
|
|
3716
5889
|
if (resolution) {
|
|
5890
|
+
// A model is allowed to say that it cannot bind any supplied
|
|
5891
|
+
// card, but that is not a new business ambiguity by itself. If
|
|
5892
|
+
// the host can prove one exact current-turn semantic identity,
|
|
5893
|
+
// take the ordinary direct/cascade path instead of preserving an
|
|
5894
|
+
// empty selection as a clarification. This is intentionally
|
|
5895
|
+
// evaluated before merge/validation so the host never treats an
|
|
5896
|
+
// empty provider response as an authoritative analytical frame.
|
|
5897
|
+
if (meaningResolutionHasNoBinding(resolution)) {
|
|
5898
|
+
const hostFallback = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', true);
|
|
5899
|
+
if (isFrozenExecutableHostFallback(hostFallback)) {
|
|
5900
|
+
return remember(key, hostFallback);
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5903
|
+
// Merge exactly once at the model boundary. Candidate IDs remain
|
|
5904
|
+
// subject to the bounded-package validator below; model route,
|
|
5905
|
+
// SQL-adjacent frame, rephrased question, and query intent never
|
|
5906
|
+
// become downstream authority.
|
|
5907
|
+
resolution = mergeMeaningResolutionWithRequirementSeed({
|
|
5908
|
+
seed: requirementSeed,
|
|
5909
|
+
resolution,
|
|
5910
|
+
candidates,
|
|
5911
|
+
});
|
|
3717
5912
|
// Repair an incomplete certified nomination before generic
|
|
3718
5913
|
// evidence validation. This preserves the real semantic choices
|
|
3719
5914
|
// instead of turning a false certified selection into an opaque
|
|
3720
5915
|
// invalid-evidence gap.
|
|
3721
5916
|
const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
|
|
3722
|
-
const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures);
|
|
5917
|
+
const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures, { requirements: analyticalRequirements });
|
|
3723
5918
|
if (validated.ok) {
|
|
3724
|
-
|
|
5919
|
+
// The meaning model may select only supplied candidate IDs. It
|
|
5920
|
+
// intentionally cannot return an analytical frame, because a
|
|
5921
|
+
// model-owned frame could add a metric, dimension, output, or
|
|
5922
|
+
// prior-turn scope after the host froze the requirement seed.
|
|
5923
|
+
//
|
|
5924
|
+
// Do not leave that candidate-only result on the legacy V1
|
|
5925
|
+
// path, though: a valid selected semantic metric plus its
|
|
5926
|
+
// selected supplied members must receive the same *host-built*
|
|
5927
|
+
// V2 frame as a zero-call/direct resolution. Otherwise the
|
|
5928
|
+
// router freezes a V1 semantic plan, the answer loop skips the
|
|
5929
|
+
// immutable semantic execution graph, and legacy SQL is
|
|
5930
|
+
// incorrectly checked against generic aggregation metadata.
|
|
5931
|
+
const frameBoundResolution = attachHostOwnedAnalyticalFrame({
|
|
5932
|
+
request,
|
|
5933
|
+
evidence,
|
|
5934
|
+
candidates,
|
|
5935
|
+
requirementSeed,
|
|
5936
|
+
resolution: validated.resolution,
|
|
5937
|
+
});
|
|
5938
|
+
const safeResolution = preventDegenerateRankingResolution(frameBoundResolution, evidence, candidates, request.question);
|
|
3725
5939
|
// Meaning interpretation is still required for a fresh turn,
|
|
3726
5940
|
// but it cannot invent a ranking measure when the user only
|
|
3727
5941
|
// supplied an entity. Preserve the precise follow-up after the
|
|
@@ -3770,24 +5984,27 @@ export function createHybridRouter(options = {}) {
|
|
|
3770
5984
|
.some((item) => /measure|metric/i.test(item));
|
|
3771
5985
|
const assumedRanking = resolverFlagged
|
|
3772
5986
|
? undefined
|
|
3773
|
-
: assumableRankingMeasure(request.question,
|
|
5987
|
+
: assumableRankingMeasure(request.question, admittedClarificationCandidates);
|
|
3774
5988
|
if (assumedRanking) {
|
|
3775
5989
|
return {
|
|
3776
5990
|
...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
|
|
3777
5991
|
assumptions: [assumedRanking.assumption],
|
|
3778
5992
|
};
|
|
3779
5993
|
}
|
|
3780
|
-
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
|
|
3781
|
-
// Supplemental clarification cards carry the ranking
|
|
3782
|
-
// measures for the requested entity, which the execution
|
|
3783
|
-
// candidate set deliberately does not.
|
|
3784
|
-
clarificationCandidates, !resolverFlagged);
|
|
5994
|
+
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, admittedClarificationCandidates, !resolverFlagged);
|
|
3785
5995
|
}
|
|
3786
|
-
const deterministicGap = deterministicPrePlanClarification(request, base, evidence,
|
|
5996
|
+
const deterministicGap = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
|
|
3787
5997
|
if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
|
|
3788
5998
|
return deterministicGap;
|
|
3789
5999
|
}
|
|
3790
|
-
|
|
6000
|
+
const meaningDecision = routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
6001
|
+
return remember(key, continuePreFreezeModelingGapThroughPhysicalSnapshot({
|
|
6002
|
+
decision: meaningDecision,
|
|
6003
|
+
base,
|
|
6004
|
+
evidence,
|
|
6005
|
+
candidates,
|
|
6006
|
+
question: request.question,
|
|
6007
|
+
}));
|
|
3791
6008
|
}
|
|
3792
6009
|
const invalidResolution = {
|
|
3793
6010
|
interpretedQuestion: request.question,
|
|
@@ -3823,11 +6040,14 @@ export function createHybridRouter(options = {}) {
|
|
|
3823
6040
|
const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
|
|
3824
6041
|
if (!shouldUseMeaningCall)
|
|
3825
6042
|
return fallbackDecision;
|
|
6043
|
+
if (isFrozenExecutableHostFallback(fallbackDecision)) {
|
|
6044
|
+
return fallbackDecision;
|
|
6045
|
+
}
|
|
3826
6046
|
// The provider was unavailable or returned malformed JSON. Apply the
|
|
3827
6047
|
// deterministic clarification only after the bounded meaning attempt
|
|
3828
6048
|
// has been exhausted; this preserves a precise recovery path without
|
|
3829
6049
|
// allowing the generic governed error to terminate the question.
|
|
3830
|
-
return deterministicPrePlanClarification(request, base, evidence,
|
|
6050
|
+
return deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates)
|
|
3831
6051
|
?? fallbackDecision;
|
|
3832
6052
|
}
|
|
3833
6053
|
}
|
|
@@ -3890,6 +6110,8 @@ export function createHybridRouter(options = {}) {
|
|
|
3890
6110
|
system: buildSystemPrompt(),
|
|
3891
6111
|
user: buildUserPrompt(request, catalogContext),
|
|
3892
6112
|
signal: request.signal ?? options.signal,
|
|
6113
|
+
request,
|
|
6114
|
+
phase: 'classification',
|
|
3893
6115
|
});
|
|
3894
6116
|
const classification = parseClassification(raw);
|
|
3895
6117
|
if (classification)
|
|
@@ -3918,15 +6140,42 @@ function softBoundaryDecision(request, base, route) {
|
|
|
3918
6140
|
const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
|
|
3919
6141
|
return {
|
|
3920
6142
|
...base,
|
|
3921
|
-
|
|
6143
|
+
// A budget/admission boundary is an operational condition, never a
|
|
6144
|
+
// business ambiguity. Returning `clarify` here made the UI ask users to
|
|
6145
|
+
// choose a metric or grain even though no competing executable meanings
|
|
6146
|
+
// had been validated. Keep this a typed terminal block so diagnostics can
|
|
6147
|
+
// offer a retry/recovery action without teaching users to compensate for a
|
|
6148
|
+
// runtime deadline.
|
|
6149
|
+
action: 'block',
|
|
3922
6150
|
confidence: 1,
|
|
3923
6151
|
source: 'heuristic',
|
|
3924
|
-
requiresClarification:
|
|
6152
|
+
requiresClarification: false,
|
|
6153
|
+
clarifyingQuestion: undefined,
|
|
3925
6154
|
reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
|
|
3926
|
-
clarifyingQuestion: request.runBudget?.mode === 'research'
|
|
3927
|
-
? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
|
|
3928
|
-
: 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
|
|
3929
6155
|
};
|
|
3930
6156
|
}
|
|
6157
|
+
/**
|
|
6158
|
+
* A literal stable metric/block identifier is already a user-selected meaning.
|
|
6159
|
+
* It is intentionally narrower than a human label such as "revenue": only
|
|
6160
|
+
* an identifier-shaped value (`foo_bar`, `namespace.metric`, `metric:foo`) or
|
|
6161
|
+
* a supplied exact qualified ID can bypass the one meaning call. This restores
|
|
6162
|
+
* the useful 1.13 zero-call semantic path without turning lexical retrieval
|
|
6163
|
+
* into permission to guess between similarly named business metrics.
|
|
6164
|
+
*/
|
|
6165
|
+
function canonicalLiteralEvidenceReference(question, candidates) {
|
|
6166
|
+
const normalizedQuestion = question.toLowerCase();
|
|
6167
|
+
const matches = candidates.filter((candidate) => {
|
|
6168
|
+
if (candidate.compatibility === 'incompatible' || candidate.eligible === false)
|
|
6169
|
+
return false;
|
|
6170
|
+
const identities = [candidate.qualifiedId, candidate.id, candidate.name, ...(candidate.aliases ?? [])]
|
|
6171
|
+
.filter((value) => Boolean(value))
|
|
6172
|
+
.filter((value) => /[_:.]/.test(value));
|
|
6173
|
+
return identities.some((identity) => {
|
|
6174
|
+
const escaped = identity.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6175
|
+
return new RegExp(`(^|[^a-z0-9_.:-])${escaped}($|[^a-z0-9_.:-])`, 'i').test(normalizedQuestion);
|
|
6176
|
+
});
|
|
6177
|
+
});
|
|
6178
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
6179
|
+
}
|
|
3931
6180
|
export { intentForCategory, parseMeaningResolution };
|
|
3932
6181
|
//# sourceMappingURL=router.js.map
|