@duckcodeailabs/dql-agent 1.14.0 → 1.14.2
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 +68 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +520 -26
- 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 +25 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +91 -0
- package/dist/agentic/analyst-loop.d.ts.map +1 -0
- package/dist/agentic/analyst-loop.js +331 -0
- package/dist/agentic/analyst-loop.js.map +1 -0
- package/dist/agentic/assumptions.d.ts +62 -0
- package/dist/agentic/assumptions.d.ts.map +1 -0
- package/dist/agentic/assumptions.js +73 -0
- package/dist/agentic/assumptions.js.map +1 -0
- package/dist/agentic/business-explanation.d.ts +56 -0
- package/dist/agentic/business-explanation.d.ts.map +1 -0
- package/dist/agentic/business-explanation.js +130 -0
- package/dist/agentic/business-explanation.js.map +1 -0
- package/dist/agentic/identifier-ledger.d.ts +74 -0
- package/dist/agentic/identifier-ledger.d.ts.map +1 -0
- package/dist/agentic/identifier-ledger.js +151 -0
- package/dist/agentic/identifier-ledger.js.map +1 -0
- package/dist/agentic/ledger-tools.d.ts +68 -0
- package/dist/agentic/ledger-tools.d.ts.map +1 -0
- package/dist/agentic/ledger-tools.js +198 -0
- package/dist/agentic/ledger-tools.js.map +1 -0
- package/dist/agentic/orchestrator-policy.d.ts +60 -0
- package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
- package/dist/agentic/orchestrator-policy.js +73 -0
- package/dist/agentic/orchestrator-policy.js.map +1 -0
- package/dist/agentic/orchestrator.d.ts +54 -0
- package/dist/agentic/orchestrator.d.ts.map +1 -0
- package/dist/agentic/orchestrator.js +67 -0
- package/dist/agentic/orchestrator.js.map +1 -0
- package/dist/agentic/preview-tool.d.ts +36 -0
- package/dist/agentic/preview-tool.d.ts.map +1 -0
- package/dist/agentic/preview-tool.js +71 -0
- package/dist/agentic/preview-tool.js.map +1 -0
- package/dist/agentic/rerank.d.ts +50 -0
- package/dist/agentic/rerank.d.ts.map +1 -0
- package/dist/agentic/rerank.js +140 -0
- package/dist/agentic/rerank.js.map +1 -0
- package/dist/agentic/research/hypothesis.d.ts +110 -0
- package/dist/agentic/research/hypothesis.d.ts.map +1 -0
- package/dist/agentic/research/hypothesis.js +125 -0
- package/dist/agentic/research/hypothesis.js.map +1 -0
- package/dist/agentic/research/synthesis.d.ts +36 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -0
- package/dist/agentic/research/synthesis.js +109 -0
- package/dist/agentic/research/synthesis.js.map +1 -0
- package/dist/agentic/research-agent.d.ts +54 -0
- package/dist/agentic/research-agent.d.ts.map +1 -0
- package/dist/agentic/research-agent.js +177 -0
- package/dist/agentic/research-agent.js.map +1 -0
- package/dist/agentic/sql-authorization.d.ts +131 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -0
- package/dist/agentic/sql-authorization.js +460 -0
- package/dist/agentic/sql-authorization.js.map +1 -0
- package/dist/agentic/tool-loop.d.ts +29 -0
- package/dist/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +129 -52
- package/dist/agentic/tool-loop.js.map +1 -1
- package/dist/agentic/toolset.d.ts.map +1 -1
- package/dist/agentic/toolset.js +155 -0
- package/dist/agentic/toolset.js.map +1 -1
- package/dist/agentic/turn-plan.d.ts +35 -0
- package/dist/agentic/turn-plan.d.ts.map +1 -0
- package/dist/agentic/turn-plan.js +108 -0
- package/dist/agentic/turn-plan.js.map +1 -0
- package/dist/agentic/value-probe.d.ts +89 -0
- package/dist/agentic/value-probe.d.ts.map +1 -0
- package/dist/agentic/value-probe.js +304 -0
- package/dist/agentic/value-probe.js.map +1 -0
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +10 -1
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +382 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +782 -6
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/analytical-request-policy.d.ts +26 -0
- package/dist/analytical-request-policy.d.ts.map +1 -0
- package/dist/analytical-request-policy.js +82 -0
- package/dist/analytical-request-policy.js.map +1 -0
- package/dist/analytical-result-facts.d.ts +13 -0
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +37 -1
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +105 -16
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +833 -108
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.d.ts +32 -1
- package/dist/answer-shape.d.ts.map +1 -1
- package/dist/answer-shape.js +49 -3
- package/dist/answer-shape.js.map +1 -1
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +8 -1
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/cascade/packer.d.ts +88 -0
- package/dist/cascade/packer.d.ts.map +1 -0
- package/dist/cascade/packer.js +106 -0
- package/dist/cascade/packer.js.map +1 -0
- package/dist/conversation/result-ops.d.ts +18 -3
- package/dist/conversation/result-ops.d.ts.map +1 -1
- package/dist/conversation/result-ops.js +77 -5
- package/dist/conversation/result-ops.js.map +1 -1
- package/dist/conversation/rolling-summary.d.ts.map +1 -1
- package/dist/conversation/rolling-summary.js +7 -2
- package/dist/conversation/rolling-summary.js.map +1 -1
- package/dist/conversation/session-store.d.ts +3 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +8 -4
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +20 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +40 -2
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
- package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
- package/dist/fixtures/ask-ai-office-shaped.js +94 -0
- package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
- package/dist/hints/store.d.ts.map +1 -1
- package/dist/hints/store.js +15 -5
- package/dist/hints/store.js.map +1 -1
- package/dist/index.d.ts +29 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -6
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +36 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +95 -0
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +69 -2
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +128 -7
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +124 -7
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts +36 -0
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +290 -38
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +20 -23
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +193 -22
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +10 -1
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +275 -53
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/metadata/metric-match.d.ts +32 -0
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +120 -9
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/research-loop.d.ts +33 -2
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +66 -4
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +7 -0
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +11 -4
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +32 -19
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1977 -73
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/member-select.d.ts.map +1 -1
- package/dist/semantic-bridge/member-select.js +39 -21
- package/dist/semantic-bridge/member-select.js.map +1 -1
- package/package.json +5 -5
package/dist/router.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assumeDominantCandidate } from './agentic/assumptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* Hybrid router — deterministic-first, LLM-assisted for the ambiguous middle.
|
|
3
4
|
*
|
|
@@ -15,9 +16,10 @@
|
|
|
15
16
|
* question never pays twice.
|
|
16
17
|
*/
|
|
17
18
|
import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
|
|
18
|
-
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
19
|
+
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
19
20
|
import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
|
|
20
21
|
import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
|
|
22
|
+
import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
|
|
21
23
|
import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
|
|
22
24
|
import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
|
|
23
25
|
import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
|
|
@@ -413,13 +415,871 @@ function retrievalTrace(evidence, candidates) {
|
|
|
413
415
|
candidateIds: candidates.map((candidate) => candidate.id),
|
|
414
416
|
};
|
|
415
417
|
}
|
|
418
|
+
/** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
|
|
419
|
+
function sourceCoverageFromEvidence(evidence, candidates) {
|
|
420
|
+
const supplied = new Map((evidence.diagnostics?.sourceCoverage ?? []).map((coverage) => [coverage.source, coverage]));
|
|
421
|
+
const sourceKinds = [
|
|
422
|
+
['certified', (candidate) => candidate.kind === 'certified_block'],
|
|
423
|
+
['semantic', (candidate) => candidate.trustTier === 'semantic' || candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member'],
|
|
424
|
+
['governed_relational', (candidate) => candidate.kind === 'dql_modeling' || (candidate.relationshipEvidence?.length ?? 0) > 0],
|
|
425
|
+
['exploratory', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
|
|
426
|
+
['dbt_manifest', (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source'],
|
|
427
|
+
['runtime_schema', (candidate) => candidate.kind === 'sql_table' || candidate.kind === 'sql_column'],
|
|
428
|
+
];
|
|
429
|
+
const coverage = [];
|
|
430
|
+
for (const [source, matches] of sourceKinds) {
|
|
431
|
+
const explicit = supplied.get(source);
|
|
432
|
+
if (explicit) {
|
|
433
|
+
coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const ids = candidates.filter(matches).map((candidate) => candidate.qualifiedId ?? candidate.id).slice(0, 32);
|
|
437
|
+
const searched = evidence.diagnostics?.searchedKinds ?? [];
|
|
438
|
+
const relevantSearched = source === 'certified'
|
|
439
|
+
? searched.includes('certified_block')
|
|
440
|
+
: source === 'semantic'
|
|
441
|
+
? searched.includes('semantic_metric') || searched.includes('semantic_member')
|
|
442
|
+
: source === 'dbt_manifest'
|
|
443
|
+
? searched.includes('dbt_model') || searched.includes('dbt_source')
|
|
444
|
+
: source === 'runtime_schema'
|
|
445
|
+
? searched.includes('sql_table') || searched.includes('sql_column')
|
|
446
|
+
: source === 'exploratory'
|
|
447
|
+
? searched.some((kind) => kind === 'dbt_model' || kind === 'dbt_source' || kind === 'sql_table' || kind === 'sql_column')
|
|
448
|
+
: searched.includes('dql_modeling');
|
|
449
|
+
coverage.push({ version: 1, source, status: ids.length > 0 ? 'available' : relevantSearched ? 'empty' : 'unavailable', candidateIds: ids });
|
|
450
|
+
}
|
|
451
|
+
// Vector/conversation status is only included when its retrieval lane told us
|
|
452
|
+
// its real status. It is not a synthetic “skipped” placeholder.
|
|
453
|
+
for (const source of ['vector', 'conversation']) {
|
|
454
|
+
const explicit = supplied.get(source);
|
|
455
|
+
if (explicit)
|
|
456
|
+
coverage.push({ ...explicit, version: 1, candidateIds: [...new Set(explicit.candidateIds)].slice(0, 32) });
|
|
457
|
+
}
|
|
458
|
+
return coverage;
|
|
459
|
+
}
|
|
460
|
+
function declaredFiscalCalendar(evidence, candidates) {
|
|
461
|
+
const calendar = evidence.fiscalCalendar;
|
|
462
|
+
// A fiscal period value is meaningless without the date role it applies to.
|
|
463
|
+
// Older snapshots may not contain this field, but they must clarify instead
|
|
464
|
+
// of silently applying FY26 to an arbitrary date column.
|
|
465
|
+
if (!calendar?.dateRoleId)
|
|
466
|
+
return undefined;
|
|
467
|
+
const identifiers = new Set(candidates.flatMap((candidate) => [candidate.id, candidate.qualifiedId].filter((id) => Boolean(id))));
|
|
468
|
+
return identifiers.has(calendar.id)
|
|
469
|
+
&& identifiers.has(calendar.fiscalPeriodFieldId)
|
|
470
|
+
&& identifiers.has(calendar.dateRoleId)
|
|
471
|
+
? calendar
|
|
472
|
+
: undefined;
|
|
473
|
+
}
|
|
474
|
+
function withDeclaredFiscalBinding(resolution, evidence, candidates, question) {
|
|
475
|
+
const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
|
|
476
|
+
const fiscalPeriod = requirements.time?.fiscalPeriod;
|
|
477
|
+
const calendar = fiscalPeriod ? declaredFiscalCalendar(evidence, candidates) : undefined;
|
|
478
|
+
if (!fiscalPeriod || !calendar)
|
|
479
|
+
return resolution;
|
|
480
|
+
const filters = resolution.queryIntent.filters.filter((filter) => filter.field !== calendar.fiscalPeriodFieldId);
|
|
481
|
+
return {
|
|
482
|
+
...resolution,
|
|
483
|
+
queryIntent: {
|
|
484
|
+
...resolution.queryIntent,
|
|
485
|
+
filters: [...filters, { field: calendar.fiscalPeriodFieldId, value: fiscalPeriod }],
|
|
486
|
+
...(requirements.time?.grain && !resolution.queryIntent.timeGrain ? { timeGrain: requirements.time.grain } : {}),
|
|
487
|
+
fiscalCalendarId: calendar.id,
|
|
488
|
+
fiscalDateRoleId: calendar.dateRoleId,
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function fiscalCalendarClarification(request, base, evidence, candidates) {
|
|
493
|
+
const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
|
|
494
|
+
if (!requirements.time?.requiresDeclaredFiscalCalendar || declaredFiscalCalendar(evidence, candidates))
|
|
495
|
+
return undefined;
|
|
496
|
+
const fiscalPeriod = requirements.time.fiscalPeriod ?? 'the requested fiscal period';
|
|
497
|
+
const coverage = sourceCoverageFromEvidence(evidence, candidates);
|
|
498
|
+
return {
|
|
499
|
+
...base,
|
|
500
|
+
action: 'clarify',
|
|
501
|
+
confidence: 1,
|
|
502
|
+
source: 'heuristic',
|
|
503
|
+
category: 'unclear',
|
|
504
|
+
depth: 'quick',
|
|
505
|
+
reason: `${fiscalPeriod} requires one declared fiscal calendar and date-role mapping before a plan can freeze.`,
|
|
506
|
+
clarifyingQuestion: `Which declared fiscal calendar and date role should DQL use for ${fiscalPeriod}?`,
|
|
507
|
+
requiresClarification: true,
|
|
508
|
+
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
509
|
+
analyticalCascadeDecision: buildAnalyticalCascadeDecision({
|
|
510
|
+
requirements,
|
|
511
|
+
sourceCoverage: coverage,
|
|
512
|
+
attempts: [{ version: 1, tier: 'clarify_or_gap', outcome: 'ambiguous', candidateIds: [], reason: 'No declared fiscal calendar/date-role binding was present in the snapshot.', planFrozen: false }],
|
|
513
|
+
planFrozen: false,
|
|
514
|
+
stopReason: 'ambiguous',
|
|
515
|
+
}),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function normalizedRelationshipIdentity(value) {
|
|
519
|
+
return value.trim().toLowerCase();
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The retrieval parser is intentionally generous because its terms also seed
|
|
523
|
+
* search. Before the router turns those terms into a resolved plan, replace
|
|
524
|
+
* grammatical aggregation wrappers with the shared typed requirement set.
|
|
525
|
+
* This keeps all router paths (fast certified, no-meaning, structured
|
|
526
|
+
* continuation, and one-call meaning) on the same count/customer contract.
|
|
527
|
+
*/
|
|
528
|
+
function withNormalizedAnalyticalRequirements(evidence, question) {
|
|
529
|
+
if (!evidence.parsedIntent)
|
|
530
|
+
return evidence;
|
|
531
|
+
const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
|
|
532
|
+
return {
|
|
533
|
+
...evidence,
|
|
534
|
+
parsedIntent: {
|
|
535
|
+
...evidence.parsedIntent,
|
|
536
|
+
measures: requirements.measures,
|
|
537
|
+
dimensions: requirements.dimensions,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function relationshipSafetyIdentities(safety) {
|
|
542
|
+
return [...new Set([safety.id, ...(safety.aliases ?? [])]
|
|
543
|
+
.map(normalizedRelationshipIdentity)
|
|
544
|
+
.filter(Boolean))];
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* This mirrors the local manifest relationship admission gate with the compact
|
|
548
|
+
* facts preserved in retrieval evidence. IDs and names deliberately play no
|
|
549
|
+
* part in the decision: a neutral draft/many-to-many identity is still unsafe.
|
|
550
|
+
*/
|
|
551
|
+
function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
|
|
552
|
+
const validation = safety.validation;
|
|
553
|
+
const checkedAt = Date.parse(validation?.checkedAt ?? '');
|
|
554
|
+
const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
|
|
555
|
+
const expirationInvalid = safety.evidenceExpiresAt !== undefined
|
|
556
|
+
&& (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
|
|
557
|
+
if (safety.status !== 'certified'
|
|
558
|
+
|| safety.staleCertification !== false
|
|
559
|
+
|| safety.automaticJoinAllowed !== true
|
|
560
|
+
|| safety.fanout !== 'safe'
|
|
561
|
+
|| !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
|
|
562
|
+
|| !safety.from?.trim()
|
|
563
|
+
|| !safety.to?.trim()
|
|
564
|
+
|| !safety.certificationFingerprint?.trim()
|
|
565
|
+
|| !validation
|
|
566
|
+
|| validation.status !== 'passed'
|
|
567
|
+
|| !validation.queryFingerprint?.trim()
|
|
568
|
+
|| !validation.proofFingerprint?.trim()
|
|
569
|
+
|| !Number.isFinite(checkedAt)
|
|
570
|
+
|| expirationInvalid
|
|
571
|
+
|| safety.keys.length === 0
|
|
572
|
+
|| safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
|
|
573
|
+
return false;
|
|
574
|
+
if (!requiredJoinKey)
|
|
575
|
+
return true;
|
|
576
|
+
const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
|
|
577
|
+
return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
|
|
578
|
+
|| metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
|
|
579
|
+
}
|
|
580
|
+
function safeRelationshipProofsForCandidate(candidate, requiredJoinKey) {
|
|
581
|
+
const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
|
|
582
|
+
const proofs = new Map();
|
|
583
|
+
for (const safety of candidate.relationshipSafety ?? []) {
|
|
584
|
+
if (!relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey))
|
|
585
|
+
continue;
|
|
586
|
+
if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
|
|
587
|
+
continue;
|
|
588
|
+
proofs.set(safety.id, safety);
|
|
589
|
+
}
|
|
590
|
+
return proofs;
|
|
591
|
+
}
|
|
592
|
+
function candidateRelationshipEndpoints(candidate) {
|
|
593
|
+
return new Set([
|
|
594
|
+
...(candidate.relationshipEndpointIds ?? []),
|
|
595
|
+
candidate.primaryEntity ?? '',
|
|
596
|
+
candidate.analyticalCapability?.primaryEntityId ?? '',
|
|
597
|
+
].map(normalizedRelationshipIdentity).filter(Boolean));
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* A proof may be structurally safe yet belong to a different domain's relation
|
|
601
|
+
* with the same leaf name. The selected pair must match the proof's canonical
|
|
602
|
+
* endpoints in either direction before it can close an exploratory join.
|
|
603
|
+
*/
|
|
604
|
+
function relationshipProofMatchesSelectedPair(safety, left, right) {
|
|
605
|
+
const leftEndpoints = candidateRelationshipEndpoints(left);
|
|
606
|
+
const rightEndpoints = candidateRelationshipEndpoints(right);
|
|
607
|
+
const from = normalizedRelationshipIdentity(safety.from ?? '');
|
|
608
|
+
const to = normalizedRelationshipIdentity(safety.to ?? '');
|
|
609
|
+
return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
|
|
610
|
+
|| (leftEndpoints.has(to) && rightEndpoints.has(from))));
|
|
611
|
+
}
|
|
612
|
+
function safeRelationshipProofsForPair(left, right, requiredJoinKey) {
|
|
613
|
+
const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey);
|
|
614
|
+
const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey);
|
|
615
|
+
const shared = new Map();
|
|
616
|
+
for (const [id, proof] of leftProofs) {
|
|
617
|
+
const pairedProof = rightProofs.get(id);
|
|
618
|
+
if (!pairedProof
|
|
619
|
+
|| !relationshipProofMatchesSelectedPair(proof, left, right)
|
|
620
|
+
|| !relationshipProofMatchesSelectedPair(pairedProof, left, right))
|
|
621
|
+
continue;
|
|
622
|
+
shared.set(id, proof);
|
|
623
|
+
}
|
|
624
|
+
return shared;
|
|
625
|
+
}
|
|
626
|
+
function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensions, requiredPhysicalFieldTerms = []) {
|
|
627
|
+
const physical = candidates.filter((candidate) => candidate.eligible !== false
|
|
628
|
+
&& candidate.compatibility !== 'incompatible'
|
|
629
|
+
&& (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
|
|
630
|
+
const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
|
|
631
|
+
const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
|
|
632
|
+
if (relations.length === 0 || columns.length === 0) {
|
|
633
|
+
return { ok: false, candidateIds: [], reason: 'No qualified raw relation plus column coverage was retrieved from this snapshot.' };
|
|
634
|
+
}
|
|
635
|
+
// This list is intentionally field/role-only. Filter values such as
|
|
636
|
+
// `Datadog`, `FY26`, or `true` are member constraints, not column names;
|
|
637
|
+
// their safe-value validation happens independently from proving the raw
|
|
638
|
+
// physical closure. Treating them as fields made a valid relation appear
|
|
639
|
+
// unmodeled and produced a false absence diagnostic.
|
|
640
|
+
const terms = [...new Set([...missingDimensions, ...requirements.measures, ...requiredPhysicalFieldTerms]
|
|
641
|
+
.map(normalizeMetricPhrase)
|
|
642
|
+
.filter(Boolean))];
|
|
643
|
+
if (terms.length === 0) {
|
|
644
|
+
return { ok: false, candidateIds: [], reason: 'No typed physical fields were available to prove an exploratory plan.' };
|
|
645
|
+
}
|
|
646
|
+
const relationSources = (relation) => relation.sourceObjects?.length ? relation.sourceObjects : [relation.qualifiedId ?? relation.id];
|
|
647
|
+
const columnTouchesRelation = (column, relation) => column.sourceObjects?.length
|
|
648
|
+
? column.sourceObjects.some((source) => relationSources(relation).includes(source))
|
|
649
|
+
// A source that omitted its column edge cannot substantiate a multi-table
|
|
650
|
+
// plan. It remains usable only when a single relation is selected.
|
|
651
|
+
: relations.length === 1;
|
|
652
|
+
const columnFieldIdentityTerms = (column) => uniqueNormalizedTerms([
|
|
653
|
+
column.name,
|
|
654
|
+
...(column.aliases ?? []),
|
|
655
|
+
...(column.dimensions ?? []),
|
|
656
|
+
]);
|
|
657
|
+
// These are intentionally a tiny, role-only bridge for raw physical
|
|
658
|
+
// evidence. They are evaluated against a column's own name/aliases in this
|
|
659
|
+
// immutable snapshot; they neither infer a relation nor turn a member value
|
|
660
|
+
// into a column. The aliases cover common authored field terminology in the
|
|
661
|
+
// supplied dbt/runtime schema (`product category` -> `product_type`, and a
|
|
662
|
+
// product-level revenue ask -> `product_price`).
|
|
663
|
+
const hasProductCategoryRequirement = [
|
|
664
|
+
...requirements.dimensions,
|
|
665
|
+
...missingDimensions,
|
|
666
|
+
...requiredPhysicalFieldTerms,
|
|
667
|
+
].some((term) => ['product category', 'category'].includes(normalizeMetricPhrase(term)));
|
|
668
|
+
const physicalRoleAliases = {
|
|
669
|
+
'product category': ['product type'],
|
|
670
|
+
category: ['product type'],
|
|
671
|
+
// `product_price` is a local revenue witness only when the same request
|
|
672
|
+
// also requires product-category grain. It must not let a bare selected
|
|
673
|
+
// revenue metric bypass its semantic contract through an unrelated raw
|
|
674
|
+
// price column.
|
|
675
|
+
...(hasProductCategoryRequirement ? { revenue: ['product price'] } : {}),
|
|
676
|
+
};
|
|
677
|
+
const physicalRoleTerms = (term) => uniqueNormalizedTerms([
|
|
678
|
+
term,
|
|
679
|
+
...(physicalRoleAliases[normalizeMetricPhrase(term)] ?? []),
|
|
680
|
+
]);
|
|
681
|
+
const columnMatchesTerm = (column, term) =>
|
|
682
|
+
// A relation-qualified ID can contain a metric word even when the column
|
|
683
|
+
// itself does not. Raw `fact_revenue.competitor` is not revenue evidence.
|
|
684
|
+
// Physical role proof therefore starts with field-local name/alias facts,
|
|
685
|
+
// never a parent relation token.
|
|
686
|
+
columnFieldIdentityTerms(column).some((identity) => physicalRoleTerms(term).some((role) => metricTermsMatch(identity, role)));
|
|
687
|
+
const matchingColumns = new Map(terms.map((term) => [
|
|
688
|
+
term,
|
|
689
|
+
columns.filter((column) => columnMatchesTerm(column, term)),
|
|
690
|
+
]));
|
|
691
|
+
const uncovered = terms.filter((term) => (matchingColumns.get(term) ?? []).length === 0);
|
|
692
|
+
if (uncovered.length > 0) {
|
|
693
|
+
return { ok: false, candidateIds: [], reason: `Qualified physical columns did not cover ${uncovered.join(', ')}.` };
|
|
694
|
+
}
|
|
695
|
+
const stableCandidateId = (candidate) => candidate.qualifiedId ?? candidate.id;
|
|
696
|
+
const stableCandidates = (values) => [...values].sort((left, right) => Number(right.exactMatch === true) - Number(left.exactMatch === true)
|
|
697
|
+
|| right.relevanceScore - left.relevanceScore
|
|
698
|
+
|| stableCandidateId(left).localeCompare(stableCandidateId(right)));
|
|
699
|
+
const stableColumnsForTerm = (values, term) => [...values].sort((left, right) => {
|
|
700
|
+
const rank = (column) => {
|
|
701
|
+
const identities = columnFieldIdentityTerms(column);
|
|
702
|
+
if (identities.some((identity) => identity === term))
|
|
703
|
+
return 0;
|
|
704
|
+
if (identities.some((identity) => metricTermsMatch(identity, term)))
|
|
705
|
+
return 1;
|
|
706
|
+
return 2;
|
|
707
|
+
};
|
|
708
|
+
return rank(left) - rank(right)
|
|
709
|
+
|| Number(right.exactMatch === true) - Number(left.exactMatch === true)
|
|
710
|
+
|| right.relevanceScore - left.relevanceScore
|
|
711
|
+
|| stableCandidateId(left).localeCompare(stableCandidateId(right));
|
|
712
|
+
});
|
|
713
|
+
const relationCoverage = relations.map((relation) => new Set(terms.filter((term) => (matchingColumns.get(term) ?? []).some((column) => columnTouchesRelation(column, relation)))));
|
|
714
|
+
const selectRequiredColumns = (selectedRelations) => {
|
|
715
|
+
const selected = new Map();
|
|
716
|
+
for (const term of [...terms].sort()) {
|
|
717
|
+
const alreadySelected = [...selected.values()].some((column) => columnMatchesTerm(column, term));
|
|
718
|
+
if (alreadySelected)
|
|
719
|
+
continue;
|
|
720
|
+
const matches = stableColumnsForTerm((matchingColumns.get(term) ?? []).filter((column) => selectedRelations.some((relation) => columnTouchesRelation(column, relation))), term);
|
|
721
|
+
const selectedColumn = matches[0];
|
|
722
|
+
if (!selectedColumn)
|
|
723
|
+
return undefined;
|
|
724
|
+
selected.set(stableCandidateId(selectedColumn), selectedColumn);
|
|
725
|
+
}
|
|
726
|
+
return [...selected.values()];
|
|
727
|
+
};
|
|
728
|
+
const physicalEvidence = (selectedRelations, requiredColumns, joinColumns, relationshipProofIds, reason) => {
|
|
729
|
+
// Keep one deterministic witness for every requested role, then the
|
|
730
|
+
// necessary join-key witnesses and proof identities. Do not add an
|
|
731
|
+
// entire table schema: that used to hide required columns behind a broad
|
|
732
|
+
// 32-card truncation. If the minimal evidence itself exceeds the cap,
|
|
733
|
+
// decline the path rather than silently dropping a required witness.
|
|
734
|
+
const ids = [...new Set([
|
|
735
|
+
...stableCandidates(selectedRelations).map(stableCandidateId),
|
|
736
|
+
...stableCandidates(requiredColumns).map(stableCandidateId),
|
|
737
|
+
...stableCandidates(joinColumns).map(stableCandidateId),
|
|
738
|
+
...[...relationshipProofIds].sort(),
|
|
739
|
+
])];
|
|
740
|
+
if (ids.length > 32) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
candidateIds: [],
|
|
744
|
+
reason: `The minimal qualified exploratory closure needs ${ids.length} required relation, field, join-key, or proof witnesses, exceeding the 32-card safety cap; no required evidence was dropped.`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return { ok: true, candidateIds: ids, reason };
|
|
748
|
+
};
|
|
749
|
+
const completeRelation = relationCoverage
|
|
750
|
+
.map((coverage, index) => ({ coverage, index }))
|
|
751
|
+
.filter(({ coverage }) => coverage.size === terms.length)
|
|
752
|
+
.sort((left, right) => stableCandidateId(relations[left.index]).localeCompare(stableCandidateId(relations[right.index])))[0];
|
|
753
|
+
if (completeRelation) {
|
|
754
|
+
const relation = relations[completeRelation.index];
|
|
755
|
+
const requiredColumns = selectRequiredColumns([relation]);
|
|
756
|
+
if (!requiredColumns) {
|
|
757
|
+
return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relation.' };
|
|
758
|
+
}
|
|
759
|
+
return physicalEvidence([relation], requiredColumns, [], [], 'One qualified physical relation covers the requested fields without a join.');
|
|
760
|
+
}
|
|
761
|
+
// Build only from structured relationship proofs. A large retrieval snapshot
|
|
762
|
+
// routinely contains unrelated raw tables; requiring every one to join made
|
|
763
|
+
// a complete local path appear unavailable. Conversely, never infer a join
|
|
764
|
+
// from names or shared column strings: the proof must retain the structured
|
|
765
|
+
// certified, validated, fanout-safe disposition above.
|
|
766
|
+
const adjacent = relations.map(() => new Set());
|
|
767
|
+
const joinColumnsForProof = (proof, left, right) => {
|
|
768
|
+
const leftEndpoints = candidateRelationshipEndpoints(left);
|
|
769
|
+
const rightEndpoints = candidateRelationshipEndpoints(right);
|
|
770
|
+
const from = normalizedRelationshipIdentity(proof.from ?? '');
|
|
771
|
+
const to = normalizedRelationshipIdentity(proof.to ?? '');
|
|
772
|
+
const direct = leftEndpoints.has(from) && rightEndpoints.has(to);
|
|
773
|
+
const reverse = leftEndpoints.has(to) && rightEndpoints.has(from);
|
|
774
|
+
if (!direct && !reverse)
|
|
775
|
+
return undefined;
|
|
776
|
+
const witnesses = new Map();
|
|
777
|
+
for (const key of proof.keys) {
|
|
778
|
+
const leftKey = normalizeMetricPhrase(direct ? key.from : key.to);
|
|
779
|
+
const rightKey = normalizeMetricPhrase(direct ? key.to : key.from);
|
|
780
|
+
const leftColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, left)
|
|
781
|
+
&& columnMatchesTerm(column, leftKey)), leftKey)[0];
|
|
782
|
+
const rightColumn = stableColumnsForTerm(columns.filter((column) => columnTouchesRelation(column, right)
|
|
783
|
+
&& columnMatchesTerm(column, rightKey)), rightKey)[0];
|
|
784
|
+
// A relationship record alone cannot prove a compilable raw join. Both
|
|
785
|
+
// canonical endpoint columns must be present in this same snapshot.
|
|
786
|
+
if (!leftColumn || !rightColumn)
|
|
787
|
+
return undefined;
|
|
788
|
+
witnesses.set(stableCandidateId(leftColumn), leftColumn);
|
|
789
|
+
witnesses.set(stableCandidateId(rightColumn), rightColumn);
|
|
790
|
+
}
|
|
791
|
+
return [...witnesses.values()];
|
|
792
|
+
};
|
|
793
|
+
const witnessesForEdge = new Map();
|
|
794
|
+
for (let left = 0; left < relations.length; left += 1) {
|
|
795
|
+
for (let right = left + 1; right < relations.length; right += 1) {
|
|
796
|
+
const shared = [...safeRelationshipProofsForPair(relations[left], relations[right]).values()]
|
|
797
|
+
.sort((first, second) => first.id.localeCompare(second.id))
|
|
798
|
+
.flatMap((proof) => {
|
|
799
|
+
const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
|
|
800
|
+
return joinColumns ? [{ proof, columns: joinColumns }] : [];
|
|
801
|
+
});
|
|
802
|
+
if (shared.length === 0)
|
|
803
|
+
continue;
|
|
804
|
+
adjacent[left].add(right);
|
|
805
|
+
adjacent[right].add(left);
|
|
806
|
+
witnessesForEdge.set(`${Math.min(left, right)}:${Math.max(left, right)}`, shared);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const closures = [];
|
|
810
|
+
for (let root = 0; root < relations.length; root += 1) {
|
|
811
|
+
const nodes = new Set([root]);
|
|
812
|
+
const edges = [];
|
|
813
|
+
const covered = new Set(relationCoverage[root]);
|
|
814
|
+
while (covered.size < terms.length) {
|
|
815
|
+
let best;
|
|
816
|
+
const pending = [{ node: root, path: [root] }];
|
|
817
|
+
const seen = new Set([root]);
|
|
818
|
+
while (pending.length > 0) {
|
|
819
|
+
const current = pending.shift();
|
|
820
|
+
const gain = [...relationCoverage[current.node]].filter((term) => !covered.has(term)).length;
|
|
821
|
+
if (gain > 0 && (!best || gain > best.gain || (gain === best.gain && current.path.length < best.distance))) {
|
|
822
|
+
best = { path: current.path, gain, distance: current.path.length };
|
|
823
|
+
}
|
|
824
|
+
for (const neighbor of adjacent[current.node] ?? []) {
|
|
825
|
+
if (seen.has(neighbor))
|
|
826
|
+
continue;
|
|
827
|
+
seen.add(neighbor);
|
|
828
|
+
pending.push({ node: neighbor, path: [...current.path, neighbor] });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!best)
|
|
832
|
+
break;
|
|
833
|
+
for (const node of best.path) {
|
|
834
|
+
nodes.add(node);
|
|
835
|
+
for (const term of relationCoverage[node])
|
|
836
|
+
covered.add(term);
|
|
837
|
+
}
|
|
838
|
+
for (let index = 1; index < best.path.length; index += 1) {
|
|
839
|
+
const left = best.path[index - 1];
|
|
840
|
+
const right = best.path[index];
|
|
841
|
+
if (!edges.some(([edgeLeft, edgeRight]) => edgeLeft === left && edgeRight === right || edgeLeft === right && edgeRight === left)) {
|
|
842
|
+
edges.push([left, right]);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (covered.size === terms.length)
|
|
847
|
+
closures.push({ nodes, edges, covered });
|
|
848
|
+
}
|
|
849
|
+
const selected = closures.sort((left, right) => left.nodes.size - right.nodes.size
|
|
850
|
+
|| left.edges.length - right.edges.length
|
|
851
|
+
|| [...left.nodes].join(',').localeCompare([...right.nodes].join(',')))[0];
|
|
852
|
+
if (!selected) {
|
|
853
|
+
return {
|
|
854
|
+
ok: false,
|
|
855
|
+
candidateIds: [],
|
|
856
|
+
reason: 'Multiple physical relations lacked one connected, certified, validated, fanout-safe automatic-join path.',
|
|
857
|
+
// This is not a lexical conclusion. Every requested physical field was
|
|
858
|
+
// found above, no one relation covered the tuple, and the structured
|
|
859
|
+
// relationship/fanout proof graph could not connect the required
|
|
860
|
+
// relations. Preserve that proof-specific category for downstream
|
|
861
|
+
// receipts and repair guidance.
|
|
862
|
+
gap: {
|
|
863
|
+
code: 'MISSING_RELATIONSHIP',
|
|
864
|
+
missing: ['a connected certified, validated, fanout-safe relationship proof'],
|
|
865
|
+
witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const selectedRelations = [...selected.nodes].map((index) => relations[index]);
|
|
870
|
+
const requiredColumns = selectRequiredColumns(selectedRelations);
|
|
871
|
+
if (!requiredColumns) {
|
|
872
|
+
return { ok: false, candidateIds: [], reason: 'Qualified physical columns did not cover the requested fields on the selected relationship closure.' };
|
|
873
|
+
}
|
|
874
|
+
const selectedWitnesses = selected.edges.map(([left, right]) => witnessesForEdge.get(`${Math.min(left, right)}:${Math.max(left, right)}`)?.[0]).filter((witness) => Boolean(witness));
|
|
875
|
+
if (selectedWitnesses.length !== selected.edges.length) {
|
|
876
|
+
return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
|
|
877
|
+
}
|
|
878
|
+
return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and structured relationship safety proofs support a bounded exploratory plan.');
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* The pre-freeze cascade is router authority, including when the bounded
|
|
882
|
+
* meaning call could not run. A missing role in a compact meaning package is
|
|
883
|
+
* not terminal proof of absence: eligible certified/semantic tiers are marked
|
|
884
|
+
* ineligible, then the same immutable snapshot may prove a review-required
|
|
885
|
+
* physical path. Policy, fiscal, and relationship safety gates run before
|
|
886
|
+
* this helper and remain terminal where appropriate.
|
|
887
|
+
*/
|
|
888
|
+
function preFreezePhysicalCascadeDecision(input) {
|
|
889
|
+
// `candidates` can be the capped meaning package. Physical eligibility is
|
|
890
|
+
// allowed one same-snapshot extension, never a new retrieval/domain scope.
|
|
891
|
+
const snapshotCandidates = input.evidence.candidates.length > 0
|
|
892
|
+
? input.evidence.candidates
|
|
893
|
+
: input.candidates;
|
|
894
|
+
const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
|
|
895
|
+
const missingRankingMetric = Boolean(physicalPath.ok
|
|
896
|
+
&& input.requireRankingMetric
|
|
897
|
+
&& input.requirements.ranking
|
|
898
|
+
&& input.requirements.ranking.metricTerms.length === 0);
|
|
899
|
+
const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
|
|
900
|
+
const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
|
|
901
|
+
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
902
|
+
const skippedAttempt = (tier, source) => {
|
|
903
|
+
const item = coverageFor(source);
|
|
904
|
+
return {
|
|
905
|
+
version: 1,
|
|
906
|
+
tier,
|
|
907
|
+
outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
908
|
+
candidateIds: item?.candidateIds ?? [],
|
|
909
|
+
reason: item?.status === 'available'
|
|
910
|
+
? `The ${tier} tier did not prove the complete requested tuple before plan freeze.`
|
|
911
|
+
: `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
|
|
912
|
+
planFrozen: false,
|
|
913
|
+
};
|
|
914
|
+
};
|
|
915
|
+
const governedCoverage = coverageFor('governed_relational');
|
|
916
|
+
const attempts = [
|
|
917
|
+
skippedAttempt('certified', 'certified'),
|
|
918
|
+
skippedAttempt('semantic', 'semantic'),
|
|
919
|
+
{
|
|
920
|
+
version: 1,
|
|
921
|
+
tier: 'governed_relational',
|
|
922
|
+
outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
923
|
+
candidateIds: governedCoverage?.candidateIds ?? [],
|
|
924
|
+
reason: governedCoverage?.status === 'available'
|
|
925
|
+
? 'Retrieved governed relationship evidence did not prove a complete relational execution tuple.'
|
|
926
|
+
: `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
|
|
927
|
+
planFrozen: false,
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
version: 1,
|
|
931
|
+
tier: 'exploratory_sql',
|
|
932
|
+
outcome: exploratoryExecutable ? 'executable' : missingRankingMetric ? 'ambiguous' : 'unavailable',
|
|
933
|
+
candidateIds: physicalPath.candidateIds,
|
|
934
|
+
reason: missingRankingMetric
|
|
935
|
+
? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
|
|
936
|
+
: physicalPath.reason,
|
|
937
|
+
planFrozen: false,
|
|
938
|
+
},
|
|
939
|
+
];
|
|
940
|
+
const message = exploratoryExecutable
|
|
941
|
+
? `${input.messagePrefix} A same-snapshot qualified physical path is available for review-required exploratory SQL.`
|
|
942
|
+
: missingRankingMetric
|
|
943
|
+
? `${input.messagePrefix} ${physicalPath.reason} A ranking measure must be selected before DQL can freeze or explore this plan.`
|
|
944
|
+
: `${input.messagePrefix} ${physicalPath.reason}`;
|
|
945
|
+
const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
|
|
946
|
+
requirements: input.requirements,
|
|
947
|
+
sourceCoverage: coverage,
|
|
948
|
+
attempts: exploratoryExecutable
|
|
949
|
+
? attempts
|
|
950
|
+
: [...attempts, {
|
|
951
|
+
version: 1,
|
|
952
|
+
tier: 'clarify_or_gap',
|
|
953
|
+
outcome: 'unavailable',
|
|
954
|
+
candidateIds: [],
|
|
955
|
+
reason: message,
|
|
956
|
+
planFrozen: false,
|
|
957
|
+
}],
|
|
958
|
+
...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
|
|
959
|
+
planFrozen: false,
|
|
960
|
+
stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
|
|
961
|
+
});
|
|
962
|
+
if (!exploratoryExecutable) {
|
|
963
|
+
return {
|
|
964
|
+
...input.base,
|
|
965
|
+
action: 'block',
|
|
966
|
+
confidence: 1,
|
|
967
|
+
reason: message,
|
|
968
|
+
source: 'heuristic',
|
|
969
|
+
category: 'data_lookup',
|
|
970
|
+
depth: 'quick',
|
|
971
|
+
requiresClarification: false,
|
|
972
|
+
clarifyingQuestion: undefined,
|
|
973
|
+
clarificationOptions: undefined,
|
|
974
|
+
retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
|
|
975
|
+
terminalOutcome: {
|
|
976
|
+
kind: 'modeling_gap',
|
|
977
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
978
|
+
message,
|
|
979
|
+
candidateIds: input.terminalCandidateIds ?? [],
|
|
980
|
+
...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
|
|
981
|
+
},
|
|
982
|
+
analyticalCascadeDecision,
|
|
983
|
+
resolvedAnalyticalPlan: undefined,
|
|
984
|
+
meaningResolution: undefined,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
...input.base,
|
|
989
|
+
action: 'answer',
|
|
990
|
+
confidence: 0.55,
|
|
991
|
+
reason: message,
|
|
992
|
+
source: 'heuristic',
|
|
993
|
+
category: 'data_lookup',
|
|
994
|
+
depth: 'quick',
|
|
995
|
+
requiresClarification: false,
|
|
996
|
+
clarifyingQuestion: undefined,
|
|
997
|
+
clarificationOptions: undefined,
|
|
998
|
+
retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
|
|
999
|
+
analyticalCascadeDecision,
|
|
1000
|
+
meaningResolution: {
|
|
1001
|
+
interpretedQuestion: input.question,
|
|
1002
|
+
questionType: questionTypeFromText(input.question),
|
|
1003
|
+
selectedConceptIds: physicalPath.candidateIds,
|
|
1004
|
+
queryIntent: {
|
|
1005
|
+
...defaultQueryIntent(input.evidence),
|
|
1006
|
+
measures: input.requirements.measures,
|
|
1007
|
+
dimensions: input.requirements.dimensions,
|
|
1008
|
+
filters: input.evidence.parsedIntent?.filters ?? [],
|
|
1009
|
+
},
|
|
1010
|
+
rejectedCandidates: [],
|
|
1011
|
+
confidence: 'low',
|
|
1012
|
+
missingInformation: [message],
|
|
1013
|
+
recommendedRoute: 'exploratory',
|
|
1014
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1015
|
+
compatibilityFailures: input.missingTerms.map((term) => ({
|
|
1016
|
+
code: 'MISSING_DIMENSION',
|
|
1017
|
+
field: term,
|
|
1018
|
+
message: `${term} was not complete in the earlier governed tiers.`,
|
|
1019
|
+
candidateIds: [],
|
|
1020
|
+
})),
|
|
1021
|
+
},
|
|
1022
|
+
resolvedAnalyticalPlan: undefined,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* A pair of raw relations is not normally a reason to bypass a clarification:
|
|
1027
|
+
* `orders`, `customers`, and `products` can still be three competing meanings.
|
|
1028
|
+
* It is different when the reader asked for a display value from one relation
|
|
1029
|
+
* and a predicate that physically exists only on another relation. In that
|
|
1030
|
+
* narrow case the evidence is complementary rather than competing.
|
|
1031
|
+
*
|
|
1032
|
+
* This helper is intentionally conservative. It requires all of the
|
|
1033
|
+
* following from ONE retrieval snapshot before it can select review-required
|
|
1034
|
+
* exploration:
|
|
1035
|
+
* - a display-shaped column (for example `product_name`),
|
|
1036
|
+
* - a boolean/predicate-shaped column on a different relation (for example
|
|
1037
|
+
* `is_perishable_supply`),
|
|
1038
|
+
* - the same qualified join key on both relations, and
|
|
1039
|
+
* - a shared, non-fanout relationship proof that touches those relations.
|
|
1040
|
+
*
|
|
1041
|
+
* That means a lexical collection of tables can never suppress a genuine
|
|
1042
|
+
* ambiguity. The generated executor still validates read-only SQL and its
|
|
1043
|
+
* join/aggregation safety before anything runs.
|
|
1044
|
+
*/
|
|
1045
|
+
function findSafeComplementaryPhysicalComposition(question, candidates) {
|
|
1046
|
+
const physical = candidates.filter((candidate) => candidate.eligible !== false
|
|
1047
|
+
&& candidate.compatibility !== 'incompatible'
|
|
1048
|
+
&& (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column'));
|
|
1049
|
+
const relations = physical.filter((candidate) => candidate.kind !== 'sql_column');
|
|
1050
|
+
const columns = physical.filter((candidate) => candidate.kind === 'sql_column');
|
|
1051
|
+
if (relations.length < 2 || columns.length < 4)
|
|
1052
|
+
return undefined;
|
|
1053
|
+
const relationSources = (candidate) => candidate.sourceObjects?.length
|
|
1054
|
+
? candidate.sourceObjects
|
|
1055
|
+
: [candidate.qualifiedId ?? candidate.id];
|
|
1056
|
+
const relationForColumn = (column) => {
|
|
1057
|
+
if (!column.sourceObjects?.length)
|
|
1058
|
+
return [];
|
|
1059
|
+
return relations.filter((relation) => relationSources(relation).some((source) => column.sourceObjects.includes(source)));
|
|
1060
|
+
};
|
|
1061
|
+
const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
|
|
1062
|
+
const candidateTokens = (candidate) => new Set(candidateIdentityTerms(candidate)
|
|
1063
|
+
.flatMap((identity) => identity.split(' '))
|
|
1064
|
+
.map(singularize)
|
|
1065
|
+
.filter(Boolean));
|
|
1066
|
+
const matchesQuestion = (candidate) => [...candidateTokens(candidate)].filter((token) => questionTokens.has(token));
|
|
1067
|
+
const displayColumns = columns.filter((column) => {
|
|
1068
|
+
const name = normalizeMetricPhrase(column.name);
|
|
1069
|
+
return /(?:^| )(?:name|label|title)(?:$| )/.test(name)
|
|
1070
|
+
&& matchesQuestion(column).length > 0
|
|
1071
|
+
&& relationForColumn(column).length > 0;
|
|
1072
|
+
});
|
|
1073
|
+
if (displayColumns.length === 0)
|
|
1074
|
+
return undefined;
|
|
1075
|
+
const displayTokens = new Set(displayColumns.flatMap(matchesQuestion));
|
|
1076
|
+
const predicateColumns = columns.filter((column) => {
|
|
1077
|
+
const name = normalizeMetricPhrase(column.name);
|
|
1078
|
+
// Predicate shape is required. A text field such as `supply_name` may be
|
|
1079
|
+
// lexically close to "supplies", but it is not proof of the requested
|
|
1080
|
+
// condition and must keep the ordinary clarification behavior.
|
|
1081
|
+
const predicateShape = /(?:^| )(?:is|has|flag|status|active|enabled)(?: |$)/.test(name);
|
|
1082
|
+
return predicateShape
|
|
1083
|
+
&& matchesQuestion(column).some((token) => !displayTokens.has(token))
|
|
1084
|
+
&& relationForColumn(column).length > 0;
|
|
1085
|
+
});
|
|
1086
|
+
if (predicateColumns.length === 0)
|
|
1087
|
+
return undefined;
|
|
1088
|
+
const columnJoinKeys = (column) => uniqueNormalizedTerms([
|
|
1089
|
+
column.name,
|
|
1090
|
+
...(column.aliases ?? []),
|
|
1091
|
+
]).filter((term) => /(?:^| )id$/.test(term));
|
|
1092
|
+
for (const displayColumn of displayColumns) {
|
|
1093
|
+
for (const displayRelation of relationForColumn(displayColumn)) {
|
|
1094
|
+
for (const predicateColumn of predicateColumns) {
|
|
1095
|
+
for (const predicateRelation of relationForColumn(predicateColumn)) {
|
|
1096
|
+
const displaySources = new Set(relationSources(displayRelation));
|
|
1097
|
+
if (relationSources(predicateRelation).some((source) => displaySources.has(source)))
|
|
1098
|
+
continue;
|
|
1099
|
+
const displayColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === displayRelation.id));
|
|
1100
|
+
const predicateColumnsOnRelation = columns.filter((column) => relationForColumn(column).some((relation) => relation.id === predicateRelation.id));
|
|
1101
|
+
const predicateJoinKeys = new Set(predicateColumnsOnRelation.flatMap(columnJoinKeys));
|
|
1102
|
+
const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
|
|
1103
|
+
if (!joinKey)
|
|
1104
|
+
continue;
|
|
1105
|
+
const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey).keys()];
|
|
1106
|
+
if (sharedRelationshipProofIds.length === 0)
|
|
1107
|
+
continue;
|
|
1108
|
+
const selectedPhysical = [
|
|
1109
|
+
displayRelation,
|
|
1110
|
+
predicateRelation,
|
|
1111
|
+
...displayColumnsOnRelation,
|
|
1112
|
+
...predicateColumnsOnRelation,
|
|
1113
|
+
].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
|
|
1114
|
+
const baseRequirements = buildAnalyticalRequirementSet({ question });
|
|
1115
|
+
const requirements = {
|
|
1116
|
+
...baseRequirements,
|
|
1117
|
+
dimensions: uniqueNormalizedTerms([...baseRequirements.dimensions, displayColumn.name]),
|
|
1118
|
+
entityTerms: uniqueNormalizedTerms([...baseRequirements.entityTerms, ...matchesQuestion(displayColumn)]),
|
|
1119
|
+
entityDisplayTerms: uniqueNormalizedTerms([...baseRequirements.entityDisplayTerms, ...matchesQuestion(displayColumn)]),
|
|
1120
|
+
memberTerms: uniqueNormalizedTerms([...baseRequirements.memberTerms, predicateColumn.name]),
|
|
1121
|
+
};
|
|
1122
|
+
const physicalPath = hasSafeExploratoryPhysicalPath(requirements, selectedPhysical, [], [displayColumn.name, predicateColumn.name, joinKey]);
|
|
1123
|
+
if (!physicalPath.ok)
|
|
1124
|
+
continue;
|
|
1125
|
+
const relationshipCandidates = candidates.filter((candidate) => sharedRelationshipProofIds.includes(candidate.id)
|
|
1126
|
+
|| Boolean(candidate.qualifiedId && sharedRelationshipProofIds.includes(candidate.qualifiedId)));
|
|
1127
|
+
return {
|
|
1128
|
+
requirements,
|
|
1129
|
+
candidateIds: [...new Set([
|
|
1130
|
+
...physicalPath.candidateIds,
|
|
1131
|
+
...sharedRelationshipProofIds,
|
|
1132
|
+
...relationshipCandidates.map((candidate) => candidate.qualifiedId ?? candidate.id),
|
|
1133
|
+
])].slice(0, 32),
|
|
1134
|
+
displayColumn,
|
|
1135
|
+
predicateColumn,
|
|
1136
|
+
joinKey,
|
|
1137
|
+
relationshipProofIds: sharedRelationshipProofIds,
|
|
1138
|
+
reason: `Qualified ${displayRelation.name}.${displayColumn.name} and ${predicateRelation.name}.${predicateColumn.name} are complementary requirements joined by ${joinKey}; the shared relationship proof permits review-required exploration.`,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
/** Build the router-owned cascade for a safe raw relational composition. */
|
|
1147
|
+
function complementaryExploratoryDecision(base, evidence, candidates, question) {
|
|
1148
|
+
// Use all already-retrieved cards from the same snapshot. The bounded meaning
|
|
1149
|
+
// package can omit a supporting raw join column, but no new retrieval/domain
|
|
1150
|
+
// scope is opened here.
|
|
1151
|
+
const composition = findSafeComplementaryPhysicalComposition(question, evidence.candidates);
|
|
1152
|
+
if (!composition)
|
|
1153
|
+
return undefined;
|
|
1154
|
+
const coverage = sourceCoverageFromEvidence(evidence, evidence.candidates);
|
|
1155
|
+
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
1156
|
+
const skippedAttempt = (tier, source, reason) => {
|
|
1157
|
+
const item = coverageFor(source);
|
|
1158
|
+
return {
|
|
1159
|
+
version: 1,
|
|
1160
|
+
tier,
|
|
1161
|
+
outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
1162
|
+
candidateIds: item?.candidateIds ?? [],
|
|
1163
|
+
reason,
|
|
1164
|
+
planFrozen: false,
|
|
1165
|
+
};
|
|
1166
|
+
};
|
|
1167
|
+
const attempts = [
|
|
1168
|
+
skippedAttempt('certified', 'certified', 'No certified candidate proved the composed display and predicate tuple.'),
|
|
1169
|
+
skippedAttempt('semantic', 'semantic', 'No semantic candidate proved the composed display and predicate tuple.'),
|
|
1170
|
+
{
|
|
1171
|
+
version: 1,
|
|
1172
|
+
tier: 'governed_relational',
|
|
1173
|
+
outcome: 'ineligible',
|
|
1174
|
+
candidateIds: [...new Set([
|
|
1175
|
+
...(coverageFor('governed_relational')?.candidateIds ?? []),
|
|
1176
|
+
...composition.relationshipProofIds,
|
|
1177
|
+
])].slice(0, 32),
|
|
1178
|
+
reason: 'The same-snapshot relationship proof closes the physical path, but no complete governed relational execution tuple was retrieved.',
|
|
1179
|
+
planFrozen: false,
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
version: 1,
|
|
1183
|
+
tier: 'exploratory_sql',
|
|
1184
|
+
outcome: 'executable',
|
|
1185
|
+
candidateIds: composition.candidateIds,
|
|
1186
|
+
reason: composition.reason,
|
|
1187
|
+
planFrozen: false,
|
|
1188
|
+
},
|
|
1189
|
+
];
|
|
1190
|
+
const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
|
|
1191
|
+
requirements: composition.requirements,
|
|
1192
|
+
sourceCoverage: coverage,
|
|
1193
|
+
attempts,
|
|
1194
|
+
selectedTier: 'exploratory_sql',
|
|
1195
|
+
planFrozen: false,
|
|
1196
|
+
stopReason: 'selected',
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
...base,
|
|
1200
|
+
action: 'answer',
|
|
1201
|
+
confidence: 0.55,
|
|
1202
|
+
source: 'heuristic',
|
|
1203
|
+
category: 'data_lookup',
|
|
1204
|
+
depth: 'quick',
|
|
1205
|
+
reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
|
|
1206
|
+
requiresClarification: false,
|
|
1207
|
+
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
1208
|
+
analyticalCascadeDecision,
|
|
1209
|
+
resolvedAnalyticalPlan: undefined,
|
|
1210
|
+
meaningResolution: {
|
|
1211
|
+
interpretedQuestion: question,
|
|
1212
|
+
questionType: questionTypeFromText(question),
|
|
1213
|
+
selectedConceptIds: composition.candidateIds,
|
|
1214
|
+
recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
|
|
1215
|
+
queryIntent: {
|
|
1216
|
+
measures: [],
|
|
1217
|
+
dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
|
|
1218
|
+
filters: [{
|
|
1219
|
+
field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
|
|
1220
|
+
value: 'true',
|
|
1221
|
+
}],
|
|
1222
|
+
},
|
|
1223
|
+
rejectedCandidates: [],
|
|
1224
|
+
confidence: 'low',
|
|
1225
|
+
missingInformation: ['No certified or semantic route proved the complete composed request.'],
|
|
1226
|
+
recommendedRoute: 'exploratory',
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function cascadeForResolution(input) {
|
|
1231
|
+
const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
|
|
1232
|
+
const coverageFor = (source) => coverage.find((item) => item.source === source);
|
|
1233
|
+
const selectedTier = input.plan.capability === 'certified_execution' ? 'certified'
|
|
1234
|
+
: input.plan.capability === 'semantic_execution' ? 'semantic'
|
|
1235
|
+
: input.plan.capability === 'governed_relational' ? 'governed_relational'
|
|
1236
|
+
: input.plan.capability === 'bounded_exploration' ? 'exploratory_sql'
|
|
1237
|
+
: undefined;
|
|
1238
|
+
const frozen = input.reconciliation.outcome === 'ready' && input.plan.mode === 'authoritative';
|
|
1239
|
+
const tier = (name, source, selected) => {
|
|
1240
|
+
const item = coverageFor(source);
|
|
1241
|
+
return {
|
|
1242
|
+
version: 1,
|
|
1243
|
+
tier: name,
|
|
1244
|
+
outcome: selected ? 'executable' : item?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
1245
|
+
candidateIds: item?.candidateIds ?? [],
|
|
1246
|
+
reason: selected ? `The ${name} tier proved the complete requested tuple and froze the plan.` : item?.status === 'available' ? `The ${name} tier was retrieved but did not prove the complete tuple.` : `The ${name} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
|
|
1247
|
+
planFrozen: frozen && selected,
|
|
1248
|
+
};
|
|
1249
|
+
};
|
|
1250
|
+
const attempts = [
|
|
1251
|
+
tier('certified', 'certified', selectedTier === 'certified'),
|
|
1252
|
+
tier('semantic', 'semantic', selectedTier === 'semantic'),
|
|
1253
|
+
tier('governed_relational', 'governed_relational', selectedTier === 'governed_relational'),
|
|
1254
|
+
tier('exploratory_sql', 'exploratory', selectedTier === 'exploratory_sql'),
|
|
1255
|
+
];
|
|
1256
|
+
if (!selectedTier) {
|
|
1257
|
+
attempts.push({ version: 1, tier: 'clarify_or_gap', outcome: input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'unavailable', candidateIds: [], reason: input.reconciliation.reason, planFrozen: false });
|
|
1258
|
+
}
|
|
1259
|
+
return buildAnalyticalCascadeDecision({
|
|
1260
|
+
requirements: buildAnalyticalRequirementSet({ question: input.question, parsedIntent: input.evidence.parsedIntent }),
|
|
1261
|
+
sourceCoverage: coverage,
|
|
1262
|
+
attempts,
|
|
1263
|
+
...(selectedTier ? { selectedTier } : {}),
|
|
1264
|
+
planFrozen: frozen,
|
|
1265
|
+
stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
416
1268
|
function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
|
|
417
|
-
|
|
1269
|
+
// Treat an LLM/direct selection as a nomination, never as permission to
|
|
1270
|
+
// let a certified block borrow a metric from neighboring semantic cards.
|
|
1271
|
+
// This check runs immediately before plan construction so every fast lane,
|
|
1272
|
+
// cached selection, explicit choice, and provider response shares it.
|
|
1273
|
+
let planBoundResolution = withDeclaredFiscalBinding(repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates), evidence, candidates, question);
|
|
418
1274
|
if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
|
|
419
|
-
const
|
|
1275
|
+
const sourceFrame = resolution.analyticalFrame;
|
|
420
1276
|
const bindingPlan = buildResolvedAnalyticalPlan({
|
|
421
1277
|
question,
|
|
422
|
-
|
|
1278
|
+
// Keep the frame available while binding its time-axis roles. A
|
|
1279
|
+
// time dimension lives in `capability.timeDimensions`, not the ordinary
|
|
1280
|
+
// group-by list; stripping the frame first made a valid report_date
|
|
1281
|
+
// binding look unresolved after a certified→semantic recovery.
|
|
1282
|
+
resolution,
|
|
423
1283
|
evidence,
|
|
424
1284
|
candidates,
|
|
425
1285
|
mode,
|
|
@@ -444,6 +1304,17 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
444
1304
|
const analytical = routedResolution.questionType === "diagnosis" ||
|
|
445
1305
|
routedResolution.questionType === "research";
|
|
446
1306
|
const reason = reconciliation.reason;
|
|
1307
|
+
const analyticalCascadeDecision = cascadeForResolution({
|
|
1308
|
+
evidence,
|
|
1309
|
+
candidates,
|
|
1310
|
+
resolution: routedResolution,
|
|
1311
|
+
plan: resolvedAnalyticalPlan,
|
|
1312
|
+
reconciliation,
|
|
1313
|
+
question,
|
|
1314
|
+
});
|
|
1315
|
+
const terminalGap = reconciliation.outcome === 'modeling_gap'
|
|
1316
|
+
? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
|
|
1317
|
+
: undefined;
|
|
447
1318
|
return {
|
|
448
1319
|
...base,
|
|
449
1320
|
action: needsClarification
|
|
@@ -464,6 +1335,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
464
1335
|
depth: analytical ? "deep" : "quick",
|
|
465
1336
|
meaningResolution: routedResolution,
|
|
466
1337
|
resolvedAnalyticalPlan,
|
|
1338
|
+
analyticalCascadeDecision,
|
|
467
1339
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
468
1340
|
requiresClarification: needsClarification,
|
|
469
1341
|
...(terminallyBlocked
|
|
@@ -477,6 +1349,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
477
1349
|
: 'ANALYTICAL_MODELING_GAP',
|
|
478
1350
|
message: reconciliation.reason,
|
|
479
1351
|
candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
|
|
1352
|
+
...(terminalGap ? { gap: terminalGap } : {}),
|
|
480
1353
|
},
|
|
481
1354
|
}
|
|
482
1355
|
: {}),
|
|
@@ -490,6 +1363,151 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
490
1363
|
: {}),
|
|
491
1364
|
};
|
|
492
1365
|
}
|
|
1366
|
+
/**
|
|
1367
|
+
* A block may be relevant to a metric without returning that metric. When a
|
|
1368
|
+
* stale catalog result or provider selection nominates such a block, continue
|
|
1369
|
+
* with one exact semantic definition when it exists; otherwise preserve the
|
|
1370
|
+
* genuine metric ambiguity as stable semantic choices. Never construct a
|
|
1371
|
+
* certified plan whose output contract merely borrows a neighbor's identity.
|
|
1372
|
+
*/
|
|
1373
|
+
function repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates) {
|
|
1374
|
+
if (resolution.recommendedRoute !== 'certified')
|
|
1375
|
+
return resolution;
|
|
1376
|
+
const selected = resolution.recommendedExecutionId
|
|
1377
|
+
? candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId)
|
|
1378
|
+
: candidates.find((candidate) => resolution.selectedConceptIds.includes(candidate.id));
|
|
1379
|
+
const requestedMeasures = evidence.parsedIntent?.measures?.length
|
|
1380
|
+
? evidence.parsedIntent.measures
|
|
1381
|
+
: resolution.queryIntent.measures;
|
|
1382
|
+
if (!selected || selected.kind !== 'certified_block'
|
|
1383
|
+
|| certifiedCandidateExplicitlyCoversMeasures(selected, requestedMeasures))
|
|
1384
|
+
return resolution;
|
|
1385
|
+
const compatibleMetrics = candidates.filter((candidate) => candidate.kind === 'semantic_metric'
|
|
1386
|
+
&& candidate.compatibility === 'compatible'
|
|
1387
|
+
&& requestedMeasures.length > 0
|
|
1388
|
+
&& requestedMeasures.every((requested) => candidateProvesMetricTerm(candidate, requested))
|
|
1389
|
+
&& Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability));
|
|
1390
|
+
const exactMetrics = compatibleMetrics.filter((candidate) => requestedMeasures.every((requested) => semanticMetricIdentityExactlyMatches(candidate, requested)));
|
|
1391
|
+
if (exactMetrics.length === 1) {
|
|
1392
|
+
const metric = exactMetrics[0];
|
|
1393
|
+
return {
|
|
1394
|
+
...resolution,
|
|
1395
|
+
selectedConceptIds: [metric.id],
|
|
1396
|
+
recommendedExecutionId: metric.id,
|
|
1397
|
+
recommendedRoute: 'semantic',
|
|
1398
|
+
confidence: resolution.confidence === 'low' ? 'medium' : resolution.confidence,
|
|
1399
|
+
// The frame is a typed interpretation of the requested tuple, not a
|
|
1400
|
+
// claim that the rejected certified block can execute it. Preserve it
|
|
1401
|
+
// when the exact semantic metric owns the same request so the frozen
|
|
1402
|
+
// plan retains its V2 time/comparison/ranking contract. Clearing it
|
|
1403
|
+
// here downgraded a safe semantic recovery into a legacy V1 blocked
|
|
1404
|
+
// plan solely because the original certified nomination was incomplete.
|
|
1405
|
+
analyticalFrame: resolution.analyticalFrame,
|
|
1406
|
+
missingInformation: [...new Set([
|
|
1407
|
+
...resolution.missingInformation,
|
|
1408
|
+
`${selected.name} does not declare ${requestedMeasures.join(', ')} as an output; continued with the exact semantic metric ${metric.name}.`,
|
|
1409
|
+
])],
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
const choices = compatibleMetrics.map((candidate) => candidate.id);
|
|
1413
|
+
return {
|
|
1414
|
+
...resolution,
|
|
1415
|
+
selectedConceptIds: [],
|
|
1416
|
+
recommendedExecutionId: undefined,
|
|
1417
|
+
recommendedRoute: 'clarify',
|
|
1418
|
+
confidence: 'low',
|
|
1419
|
+
analyticalFrame: undefined,
|
|
1420
|
+
compatibilityOutcome: 'clarify',
|
|
1421
|
+
compatibilityFailures: [{
|
|
1422
|
+
code: 'CERTIFIED_MEASURE_OUTPUT_MISSING',
|
|
1423
|
+
field: 'measure',
|
|
1424
|
+
message: `${selected.name} does not declare ${requestedMeasures.join(', ')} as an output.`,
|
|
1425
|
+
candidateIds: choices,
|
|
1426
|
+
}],
|
|
1427
|
+
missingInformation: [...new Set([
|
|
1428
|
+
...resolution.missingInformation,
|
|
1429
|
+
choices.length > 1
|
|
1430
|
+
? `The selected certified block does not declare ${requestedMeasures.join(', ')}. Choose among the compatible semantic metric definitions.`
|
|
1431
|
+
: `The selected certified block does not declare ${requestedMeasures.join(', ')} as an output.`,
|
|
1432
|
+
])],
|
|
1433
|
+
clarifyingQuestion: choices.length > 1
|
|
1434
|
+
? 'Which compatible semantic metric should DQL use?'
|
|
1435
|
+
: resolution.clarifyingQuestion,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function semanticMetricIdentityExactlyMatches(candidate, requested) {
|
|
1439
|
+
const requestedIdentity = normalizeMetricPhrase(requested);
|
|
1440
|
+
if (!requestedIdentity)
|
|
1441
|
+
return false;
|
|
1442
|
+
return [candidate.name, ...(candidate.aliases ?? []), candidate.qualifiedId ?? candidate.id]
|
|
1443
|
+
.map((identity) => normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity))
|
|
1444
|
+
.some((identity) => identity === requestedIdentity);
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Clarification is an execution-affecting choice, not a generic search result
|
|
1448
|
+
* picker. In particular, a block tagged with revenue may be useful context
|
|
1449
|
+
* but cannot be offered for an explicit revenue request unless that block's
|
|
1450
|
+
* own output contract declares revenue. Applying this at the option boundary
|
|
1451
|
+
* keeps a stale pooled candidate from becoming a later structured bypass.
|
|
1452
|
+
*/
|
|
1453
|
+
function clarificationRequirementsForResolution(resolution) {
|
|
1454
|
+
const question = resolution.interpretedQuestion;
|
|
1455
|
+
const questionTerms = normalizeMetricPhrase(question);
|
|
1456
|
+
// A parser/result frame can carry inherited technical hints that do not
|
|
1457
|
+
// belong to the user's current wording. They are useful to planning, but
|
|
1458
|
+
// must not turn an unrelated structured option into an invalid selection.
|
|
1459
|
+
// Keep only parser measures the question actually says, then add the
|
|
1460
|
+
// resolver's own typed metric frame when it exists.
|
|
1461
|
+
const mentionedMeasures = resolution.queryIntent.measures.filter((measure) => {
|
|
1462
|
+
const normalized = normalizeMetricPhrase(measure);
|
|
1463
|
+
const tokens = normalized.split(' ').filter((token) => token.length >= 3);
|
|
1464
|
+
return tokens.length > 0 && tokens.every((token) => questionTerms.includes(token));
|
|
1465
|
+
});
|
|
1466
|
+
const frameMeasures = resolution.analyticalFrame?.metricConceptIds.map((id) => id.split(/[.:/]/).filter(Boolean).at(-1) ?? id) ?? [];
|
|
1467
|
+
return buildAnalyticalRequirementSet({
|
|
1468
|
+
question,
|
|
1469
|
+
parsedIntent: {
|
|
1470
|
+
...resolution.queryIntent,
|
|
1471
|
+
measures: [...new Set([...mentionedMeasures, ...frameMeasures])],
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
function explicitMeasureTermsForClarification(requirements) {
|
|
1476
|
+
return [...new Set([
|
|
1477
|
+
...(requirements?.measures ?? []),
|
|
1478
|
+
...(requirements?.ranking?.metricTerms ?? []),
|
|
1479
|
+
].map((term) => normalizeMetricPhrase(term.split(/[.:/]/).at(-1) ?? term)).filter(Boolean))];
|
|
1480
|
+
}
|
|
1481
|
+
function candidateOwnsExplicitClarificationRoles(candidate, requirements) {
|
|
1482
|
+
if (candidate.eligible === false || candidate.compatibility === 'incompatible')
|
|
1483
|
+
return false;
|
|
1484
|
+
const measures = explicitMeasureTermsForClarification(requirements);
|
|
1485
|
+
if (measures.length === 0)
|
|
1486
|
+
return true;
|
|
1487
|
+
if (candidate.kind === 'certified_block') {
|
|
1488
|
+
return certifiedCandidateExplicitlyCoversMeasures(candidate, measures);
|
|
1489
|
+
}
|
|
1490
|
+
// A structured choice for a measure must be the metric itself. An entity,
|
|
1491
|
+
// member, model, or physical relation can help compile the chosen metric but
|
|
1492
|
+
// cannot become its meaning merely because it happened to rank highly.
|
|
1493
|
+
// A semantic metric is a valid *meaning* choice when it owns at least one
|
|
1494
|
+
// explicitly requested metric. Multi-metric plans still cannot freeze until
|
|
1495
|
+
// every requested measure is bound by the immutable plan; this narrower
|
|
1496
|
+
// option check lets a user choose between the revenue/refunds definitions
|
|
1497
|
+
// without pretending the single metric already answers the whole tuple.
|
|
1498
|
+
return candidate.kind === 'semantic_metric'
|
|
1499
|
+
&& measures.some((measure) => candidateProvesMetricTerm(candidate, measure));
|
|
1500
|
+
}
|
|
1501
|
+
function compatibleClarificationCandidates(candidates, requirements) {
|
|
1502
|
+
return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements));
|
|
1503
|
+
}
|
|
1504
|
+
function withoutMeasureClarificationRequirements(requirements) {
|
|
1505
|
+
const { ranking: _ranking, ...withoutRanking } = requirements;
|
|
1506
|
+
return {
|
|
1507
|
+
...withoutRanking,
|
|
1508
|
+
measures: [],
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
493
1511
|
/**
|
|
494
1512
|
* The immutable RAP is the final routing authority. Meaning may nominate an
|
|
495
1513
|
* execution route, but cannot leave the router claiming an answer after the
|
|
@@ -509,6 +1527,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
509
1527
|
].filter(({ binding }) => binding.status !== 'resolved');
|
|
510
1528
|
const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
|
|
511
1529
|
const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
|
|
1530
|
+
const clarificationRequirements = clarificationRequirementsForResolution(resolution);
|
|
512
1531
|
if (plan.resolutionFailure?.outcome === 'policy_blocked') {
|
|
513
1532
|
return {
|
|
514
1533
|
outcome: 'policy_blocked',
|
|
@@ -525,9 +1544,19 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
525
1544
|
const optionIds = qualifiedChoiceIds.length > 0
|
|
526
1545
|
? qualifiedChoiceIds
|
|
527
1546
|
: [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
|
|
1547
|
+
const unresolvedMeasure = bindings.some(({ kind }) => kind === 'measure');
|
|
1548
|
+
const optionRequirements = unresolvedMeasure
|
|
1549
|
+
? clarificationRequirements
|
|
1550
|
+
: withoutMeasureClarificationRequirements(clarificationRequirements);
|
|
528
1551
|
const options = optionIds.length > 0
|
|
529
|
-
? clarificationOptionsForQualifiedIds(optionIds, candidates)
|
|
530
|
-
: buildClarificationOptions(candidates);
|
|
1552
|
+
? clarificationOptionsForQualifiedIds(optionIds, candidates, optionRequirements)
|
|
1553
|
+
: buildClarificationOptions(candidates, optionRequirements);
|
|
1554
|
+
if (options.length === 0) {
|
|
1555
|
+
return {
|
|
1556
|
+
outcome: 'modeling_gap',
|
|
1557
|
+
reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
531
1560
|
const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
|
|
532
1561
|
const question = routedClarificationQuestion(resolution, bindings, options);
|
|
533
1562
|
return {
|
|
@@ -538,11 +1567,18 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
538
1567
|
};
|
|
539
1568
|
}
|
|
540
1569
|
if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
|
|
1570
|
+
const options = buildClarificationOptions(candidates, clarificationRequirements);
|
|
1571
|
+
if (options.length === 0) {
|
|
1572
|
+
return {
|
|
1573
|
+
outcome: 'modeling_gap',
|
|
1574
|
+
reason: 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.',
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
541
1577
|
return {
|
|
542
1578
|
outcome: 'clarify',
|
|
543
1579
|
reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
|
|
544
1580
|
question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
|
|
545
|
-
options
|
|
1581
|
+
options,
|
|
546
1582
|
};
|
|
547
1583
|
}
|
|
548
1584
|
return {
|
|
@@ -550,12 +1586,85 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
550
1586
|
reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
|
|
551
1587
|
};
|
|
552
1588
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1589
|
+
/**
|
|
1590
|
+
* Keep a specific terminal gap only when its producer supplied a typed reason.
|
|
1591
|
+
* A generic modeling gap must never become a relationship-gap claim because a
|
|
1592
|
+
* later renderer happened to mention joins in its repair copy.
|
|
1593
|
+
*/
|
|
1594
|
+
function terminalGapWitnessForResolutionFailure(plan) {
|
|
1595
|
+
const failure = plan.resolutionFailure;
|
|
1596
|
+
if (!failure || failure.outcome !== 'modeling_gap')
|
|
1597
|
+
return undefined;
|
|
1598
|
+
const codes = new Set(failure.codes);
|
|
1599
|
+
const unresolved = [...new Set(failure.bindings
|
|
1600
|
+
.filter((binding) => binding.status !== 'resolved')
|
|
1601
|
+
.map((binding) => binding.requested)
|
|
1602
|
+
.filter(Boolean))];
|
|
1603
|
+
const witnessCandidateIds = [...new Set([
|
|
1604
|
+
...failure.candidateIds,
|
|
1605
|
+
...(failure.selectedCapabilityId ? [failure.selectedCapabilityId] : []),
|
|
1606
|
+
...(failure.selectedExecutionId ? [failure.selectedExecutionId] : []),
|
|
1607
|
+
])].sort();
|
|
1608
|
+
if (codes.has('RELATIONSHIP_PROOF_MISSING')) {
|
|
1609
|
+
return {
|
|
1610
|
+
code: 'MISSING_RELATIONSHIP',
|
|
1611
|
+
missing: ['a certified, validated, fanout-safe relationship proof'],
|
|
1612
|
+
witnessCandidateIds,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
if (codes.has('METRIC_CAPABILITY_MISSING') || codes.has('MISSING_MEASURE')) {
|
|
1616
|
+
return {
|
|
1617
|
+
code: 'MISSING_MEASURE',
|
|
1618
|
+
missing: unresolved.length > 0 ? unresolved : ['the requested governed measure'],
|
|
1619
|
+
witnessCandidateIds,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
if (codes.has('MEMBER_FILTER_UNSUPPORTED') || codes.has('MISSING_ATTRIBUTE')) {
|
|
1623
|
+
return {
|
|
1624
|
+
code: 'MISSING_ATTRIBUTE',
|
|
1625
|
+
missing: unresolved.length > 0 ? unresolved : ['the requested governed attribute or member filter'],
|
|
1626
|
+
witnessCandidateIds,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
if ([
|
|
1630
|
+
'DIMENSION_ROLE_UNSUPPORTED',
|
|
1631
|
+
'TIME_DIMENSION_REQUIRED',
|
|
1632
|
+
'TIME_DIMENSION_AMBIGUOUS',
|
|
1633
|
+
'TIME_ROLE_UNSUPPORTED',
|
|
1634
|
+
'TIME_GRAIN_UNSUPPORTED',
|
|
1635
|
+
'MISSING_DIMENSION',
|
|
1636
|
+
].some((code) => codes.has(code))) {
|
|
1637
|
+
return {
|
|
1638
|
+
code: 'MISSING_DIMENSION',
|
|
1639
|
+
missing: unresolved.length > 0 ? unresolved : ['the requested governed dimension or time role'],
|
|
1640
|
+
witnessCandidateIds,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
return {
|
|
1644
|
+
code: 'MISSING_RUNTIME_CAPABILITY',
|
|
1645
|
+
missing: unresolved.length > 0 ? unresolved : ['the complete requested analytical tuple'],
|
|
1646
|
+
witnessCandidateIds,
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
function clarificationOptionsForQualifiedIds(ids, candidates, requirements) {
|
|
1650
|
+
const requireArtifactLocalMeasureProof = explicitMeasureTermsForClarification(requirements).length > 0;
|
|
1651
|
+
return ids
|
|
1652
|
+
.map((id) => ({
|
|
1653
|
+
id,
|
|
1654
|
+
candidate: candidates.find((item) => item.id === id || item.qualifiedId === id),
|
|
1655
|
+
}))
|
|
1656
|
+
.filter(({ candidate }) => candidate
|
|
1657
|
+
? candidateOwnsExplicitClarificationRoles(candidate, requirements)
|
|
1658
|
+
// Qualified capability dimensions are synthesized by the resolved
|
|
1659
|
+
// plan, not necessarily returned as standalone retrieval cards. They
|
|
1660
|
+
// remain valid choices for a dimension/filter clarification, but never
|
|
1661
|
+
// for an explicit metric role whose local artifact proof is missing.
|
|
1662
|
+
: !requireArtifactLocalMeasureProof)
|
|
1663
|
+
.slice(0, 3)
|
|
1664
|
+
.map(({ id, candidate }) => {
|
|
556
1665
|
return {
|
|
557
|
-
id,
|
|
558
|
-
label: candidate?.name
|
|
1666
|
+
id: candidate?.id ?? id,
|
|
1667
|
+
label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
|
|
559
1668
|
...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
|
|
560
1669
|
kind: candidate?.kind ?? 'semantic_member',
|
|
561
1670
|
};
|
|
@@ -577,17 +1686,122 @@ function routedClarificationQuestion(resolution, bindings, options) {
|
|
|
577
1686
|
return resolution.clarifyingQuestion
|
|
578
1687
|
?? `Which governed binding should I use before running this query?`;
|
|
579
1688
|
}
|
|
580
|
-
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
|
|
1689
|
+
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question) {
|
|
1690
|
+
// Preserve the exact stable ID emitted by the clarification option. A
|
|
1691
|
+
// candidate may also carry a source-qualified execution alias, but replacing
|
|
1692
|
+
// the clicked ID here makes the persisted ambiguity contract and the later
|
|
1693
|
+
// diagnostic receipt disagree even though the user chose a legitimate item.
|
|
1694
|
+
const selectedId = selected.id;
|
|
1695
|
+
const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
|
|
1696
|
+
const requiredPhysicalFieldTerms = [
|
|
1697
|
+
...(evidence.parsedIntent?.dimensions ?? []),
|
|
1698
|
+
...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
|
|
1699
|
+
];
|
|
1700
|
+
const physicalPath = hasSafeExploratoryPhysicalPath(requirements, candidates, requirements.dimensions, requiredPhysicalFieldTerms);
|
|
1701
|
+
const coverage = sourceCoverageFromEvidence(evidence, candidates);
|
|
1702
|
+
const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
|
|
1703
|
+
const attempts = [
|
|
1704
|
+
{
|
|
1705
|
+
version: 1,
|
|
1706
|
+
tier: 'certified',
|
|
1707
|
+
outcome: 'ineligible',
|
|
1708
|
+
candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [],
|
|
1709
|
+
reason: `The explicit selection ${selectedId} did not prove a complete certified tuple.`,
|
|
1710
|
+
planFrozen: false,
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
version: 1,
|
|
1714
|
+
tier: 'semantic',
|
|
1715
|
+
outcome: 'ineligible',
|
|
1716
|
+
candidateIds: [...new Set([selectedId, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
|
|
1717
|
+
reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
|
|
1718
|
+
planFrozen: false,
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
version: 1,
|
|
1722
|
+
tier: 'governed_relational',
|
|
1723
|
+
outcome: governedCoverage?.status === 'available' ? 'ineligible' : 'unavailable',
|
|
1724
|
+
candidateIds: governedCoverage?.candidateIds ?? [],
|
|
1725
|
+
reason: governedCoverage?.status === 'available'
|
|
1726
|
+
? 'Retrieved governed relationship evidence did not prove a complete relational plan.'
|
|
1727
|
+
: `The governed relational source was ${governedCoverage?.status ?? 'unavailable'}; exploratory eligibility is evaluated independently.`,
|
|
1728
|
+
planFrozen: false,
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
version: 1,
|
|
1732
|
+
tier: 'exploratory_sql',
|
|
1733
|
+
outcome: physicalPath.ok ? 'executable' : 'unavailable',
|
|
1734
|
+
candidateIds: physicalPath.candidateIds,
|
|
1735
|
+
reason: physicalPath.reason,
|
|
1736
|
+
planFrozen: false,
|
|
1737
|
+
},
|
|
1738
|
+
];
|
|
1739
|
+
const message = physicalPath.ok
|
|
1740
|
+
? `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact; a same-snapshot qualified physical path is available for review-required exploratory SQL.`
|
|
1741
|
+
: `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL consumed that selection once and will not substitute a correlated metric or execute generated SQL because ${physicalPath.reason}`;
|
|
1742
|
+
const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
|
|
1743
|
+
requirements,
|
|
1744
|
+
sourceCoverage: coverage,
|
|
1745
|
+
attempts: physicalPath.ok
|
|
1746
|
+
? attempts
|
|
1747
|
+
: [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: [selectedId], reason: message, planFrozen: false }],
|
|
1748
|
+
...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
|
|
1749
|
+
planFrozen: false,
|
|
1750
|
+
stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
|
|
1751
|
+
});
|
|
1752
|
+
const meaningResolution = {
|
|
1753
|
+
interpretedQuestion: question,
|
|
1754
|
+
questionType: 'value',
|
|
1755
|
+
selectedConceptIds: [selectedId],
|
|
1756
|
+
queryIntent: defaultQueryIntent(evidence),
|
|
1757
|
+
rejectedCandidates: [],
|
|
1758
|
+
confidence: 'low',
|
|
1759
|
+
missingInformation: [message],
|
|
1760
|
+
recommendedRoute: physicalPath.ok ? 'exploratory' : 'clarify',
|
|
1761
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1762
|
+
compatibilityFailures: [{
|
|
1763
|
+
code: 'INCOMPLETE_SELECTED_CAPABILITY',
|
|
1764
|
+
field: 'selected capability',
|
|
1765
|
+
message,
|
|
1766
|
+
candidateIds: [selectedId],
|
|
1767
|
+
}],
|
|
1768
|
+
};
|
|
1769
|
+
if (!physicalPath.ok) {
|
|
1770
|
+
return {
|
|
1771
|
+
...base,
|
|
1772
|
+
action: 'block',
|
|
1773
|
+
confidence: 1,
|
|
1774
|
+
reason: message,
|
|
1775
|
+
source: 'heuristic',
|
|
1776
|
+
category: 'data_lookup',
|
|
1777
|
+
depth: 'quick',
|
|
1778
|
+
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
1779
|
+
requiresClarification: false,
|
|
1780
|
+
resolvedAnalyticalPlan: undefined,
|
|
1781
|
+
meaningResolution,
|
|
1782
|
+
analyticalCascadeDecision,
|
|
1783
|
+
terminalOutcome: {
|
|
1784
|
+
kind: 'modeling_gap',
|
|
1785
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
1786
|
+
message,
|
|
1787
|
+
candidateIds: [selectedId],
|
|
1788
|
+
...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
|
|
1789
|
+
},
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
581
1792
|
return {
|
|
582
1793
|
...base,
|
|
583
1794
|
action: 'answer',
|
|
584
|
-
confidence: 0.
|
|
585
|
-
reason:
|
|
1795
|
+
confidence: 0.55,
|
|
1796
|
+
reason: message,
|
|
586
1797
|
source: 'heuristic',
|
|
587
1798
|
category: 'data_lookup',
|
|
588
1799
|
depth: 'quick',
|
|
589
1800
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
590
1801
|
requiresClarification: false,
|
|
1802
|
+
resolvedAnalyticalPlan: undefined,
|
|
1803
|
+
meaningResolution,
|
|
1804
|
+
analyticalCascadeDecision,
|
|
591
1805
|
};
|
|
592
1806
|
}
|
|
593
1807
|
function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
@@ -702,14 +1916,15 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
702
1916
|
"The requested analytical tuple is not executable from the current governed model."),
|
|
703
1917
|
};
|
|
704
1918
|
}
|
|
705
|
-
function buildClarificationOptions(candidates) {
|
|
706
|
-
const
|
|
1919
|
+
function buildClarificationOptions(candidates, requirements) {
|
|
1920
|
+
const roleCompatible = compatibleClarificationCandidates(candidates, requirements);
|
|
1921
|
+
const governed = roleCompatible.filter((candidate) => candidate.compatibility !== "incompatible" &&
|
|
707
1922
|
(candidate.kind === "certified_block" ||
|
|
708
1923
|
candidate.kind === "semantic_metric" ||
|
|
709
1924
|
candidate.kind === "semantic_member"));
|
|
710
1925
|
const pool = governed.length > 1
|
|
711
1926
|
? governed
|
|
712
|
-
:
|
|
1927
|
+
: roleCompatible;
|
|
713
1928
|
const chosen = pool.slice(0, 3);
|
|
714
1929
|
// Two candidates can legitimately share a display name (a dbt model and its
|
|
715
1930
|
// MetricFlow measure are both "customers"). Rendering both as "customers"
|
|
@@ -732,6 +1947,301 @@ function buildClarificationOptions(candidates) {
|
|
|
732
1947
|
};
|
|
733
1948
|
});
|
|
734
1949
|
}
|
|
1950
|
+
/**
|
|
1951
|
+
* The server envelope persists the option/requirement contract that rendered a
|
|
1952
|
+
* clarification. Browser context is never authority: the local runtime adds
|
|
1953
|
+
* a host-only binding only after reading the persisted thread. Current snapshot
|
|
1954
|
+
* compatibility remains mandatory, so a client cannot forge an old option list
|
|
1955
|
+
* to admit an unrelated artifact.
|
|
1956
|
+
*/
|
|
1957
|
+
function persistedClarificationSelectionContext(request) {
|
|
1958
|
+
const context = request.conversationContext;
|
|
1959
|
+
const envelope = context?.conversationEnvelope && typeof context.conversationEnvelope === 'object'
|
|
1960
|
+
? context.conversationEnvelope
|
|
1961
|
+
: context?.serverSnapshot && typeof context.serverSnapshot === 'object'
|
|
1962
|
+
? context.serverSnapshot
|
|
1963
|
+
: undefined;
|
|
1964
|
+
const pending = envelope?.pendingClarification;
|
|
1965
|
+
if (!pending || typeof pending !== 'object')
|
|
1966
|
+
return undefined;
|
|
1967
|
+
const pendingRecord = pending;
|
|
1968
|
+
const selection = pendingRecord.selection;
|
|
1969
|
+
if (!selection || typeof selection !== 'object')
|
|
1970
|
+
return undefined;
|
|
1971
|
+
const record = selection;
|
|
1972
|
+
const ids = (value) => Array.isArray(value)
|
|
1973
|
+
? [...new Set(value.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
|
|
1974
|
+
: [];
|
|
1975
|
+
const requirements = normalizePersistedClarificationRequirements(record.requirements);
|
|
1976
|
+
const optionIds = ids(record.optionIds);
|
|
1977
|
+
const ambiguityCandidateIds = ids(record.ambiguityCandidateIds);
|
|
1978
|
+
const snapshotId = typeof record.snapshotId === 'string' && record.snapshotId.trim()
|
|
1979
|
+
? record.snapshotId.trim()
|
|
1980
|
+
: undefined;
|
|
1981
|
+
const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
|
|
1982
|
+
? pendingRecord.sourceTurnId.trim()
|
|
1983
|
+
: undefined;
|
|
1984
|
+
const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
|
|
1985
|
+
? envelope.threadId.trim()
|
|
1986
|
+
: undefined;
|
|
1987
|
+
const authority = context?.serverIssuedClarificationSelection;
|
|
1988
|
+
const authorityRecord = authority && typeof authority === 'object' && !Array.isArray(authority)
|
|
1989
|
+
? authority
|
|
1990
|
+
: undefined;
|
|
1991
|
+
const authorityMatches = Boolean(authorityRecord?.version === 1
|
|
1992
|
+
&& typeof authorityRecord.threadId === 'string'
|
|
1993
|
+
&& authorityRecord.threadId === threadId
|
|
1994
|
+
&& typeof authorityRecord.sourceTurnId === 'string'
|
|
1995
|
+
&& authorityRecord.sourceTurnId === sourceTurnId
|
|
1996
|
+
&& typeof authorityRecord.snapshotId === 'string'
|
|
1997
|
+
&& authorityRecord.snapshotId === snapshotId);
|
|
1998
|
+
const invalidReason = record.version !== 1
|
|
1999
|
+
? 'The structured selection envelope has an unsupported version.'
|
|
2000
|
+
: !request.threadId || !threadId || threadId !== request.threadId
|
|
2001
|
+
? 'The structured selection is not bound to the active server conversation thread.'
|
|
2002
|
+
: !sourceTurnId
|
|
2003
|
+
? 'The structured selection is missing its server turn binding.'
|
|
2004
|
+
: optionIds.length === 0
|
|
2005
|
+
? 'The structured selection is missing the options rendered by the server.'
|
|
2006
|
+
: !requirements
|
|
2007
|
+
? 'The structured selection is missing its typed analytical requirements.'
|
|
2008
|
+
: !snapshotId
|
|
2009
|
+
? 'The structured selection is missing its retrieval snapshot binding.'
|
|
2010
|
+
: !authorityMatches
|
|
2011
|
+
? 'The structured selection was not issued by the active server conversation.'
|
|
2012
|
+
: undefined;
|
|
2013
|
+
return {
|
|
2014
|
+
optionIds,
|
|
2015
|
+
ambiguityCandidateIds,
|
|
2016
|
+
...(requirements ? { requirements } : {}),
|
|
2017
|
+
...(snapshotId ? { snapshotId } : {}),
|
|
2018
|
+
...(sourceTurnId ? { sourceTurnId } : {}),
|
|
2019
|
+
...(threadId ? { threadId } : {}),
|
|
2020
|
+
serverIssued: !invalidReason,
|
|
2021
|
+
...(invalidReason ? { invalidReason } : {}),
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* The persisted envelope crosses a client/server boundary. It is only a
|
|
2026
|
+
* reject-only continuity hint, but it must still be parsed as data rather than
|
|
2027
|
+
* cast as an executable typed requirement set. In particular, a malformed
|
|
2028
|
+
* string must never be spread into one-character "measures" and weaken the
|
|
2029
|
+
* selected-ID validation path.
|
|
2030
|
+
*/
|
|
2031
|
+
function normalizePersistedClarificationRequirements(value) {
|
|
2032
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
2033
|
+
return undefined;
|
|
2034
|
+
const record = value;
|
|
2035
|
+
if (record.version !== 1)
|
|
2036
|
+
return undefined;
|
|
2037
|
+
const terms = (input) => Array.isArray(input)
|
|
2038
|
+
? [...new Set(input.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()))]
|
|
2039
|
+
: [];
|
|
2040
|
+
const rankingRecord = record.ranking && typeof record.ranking === 'object' && !Array.isArray(record.ranking)
|
|
2041
|
+
? record.ranking
|
|
2042
|
+
: undefined;
|
|
2043
|
+
const timeRecord = record.time && typeof record.time === 'object' && !Array.isArray(record.time)
|
|
2044
|
+
? record.time
|
|
2045
|
+
: undefined;
|
|
2046
|
+
const direction = rankingRecord?.direction === 'bottom' ? 'bottom' : rankingRecord?.direction === 'top' ? 'top' : undefined;
|
|
2047
|
+
const limit = typeof rankingRecord?.limit === 'number' && Number.isFinite(rankingRecord.limit) && rankingRecord.limit > 0
|
|
2048
|
+
? Math.floor(rankingRecord.limit)
|
|
2049
|
+
: undefined;
|
|
2050
|
+
const grain = timeRecord?.grain;
|
|
2051
|
+
const safeGrain = grain === 'day' || grain === 'week' || grain === 'month' || grain === 'quarter' || grain === 'year'
|
|
2052
|
+
? grain
|
|
2053
|
+
: undefined;
|
|
2054
|
+
const timeRole = timeRecord?.role === 'time_axis' || timeRecord?.role === 'time_filter'
|
|
2055
|
+
? timeRecord.role
|
|
2056
|
+
: undefined;
|
|
2057
|
+
const fiscalPeriod = typeof timeRecord?.fiscalPeriod === 'string' && timeRecord.fiscalPeriod.trim()
|
|
2058
|
+
? timeRecord.fiscalPeriod.trim()
|
|
2059
|
+
: undefined;
|
|
2060
|
+
return {
|
|
2061
|
+
version: 1,
|
|
2062
|
+
measures: terms(record.measures),
|
|
2063
|
+
dimensions: terms(record.dimensions),
|
|
2064
|
+
entityTerms: terms(record.entityTerms),
|
|
2065
|
+
entityDisplayTerms: terms(record.entityDisplayTerms),
|
|
2066
|
+
memberTerms: terms(record.memberTerms),
|
|
2067
|
+
...(direction && limit !== undefined
|
|
2068
|
+
? {
|
|
2069
|
+
ranking: {
|
|
2070
|
+
metricTerms: terms(rankingRecord?.metricTerms),
|
|
2071
|
+
entityTerms: terms(rankingRecord?.entityTerms),
|
|
2072
|
+
direction,
|
|
2073
|
+
limit,
|
|
2074
|
+
defaultedLimit: rankingRecord?.defaultedLimit === true,
|
|
2075
|
+
},
|
|
2076
|
+
}
|
|
2077
|
+
: {}),
|
|
2078
|
+
...(timeRole
|
|
2079
|
+
? {
|
|
2080
|
+
time: {
|
|
2081
|
+
role: timeRole,
|
|
2082
|
+
...(safeGrain ? { grain: safeGrain } : {}),
|
|
2083
|
+
...(fiscalPeriod ? { fiscalPeriod } : {}),
|
|
2084
|
+
requiresDeclaredFiscalCalendar: timeRecord?.requiresDeclaredFiscalCalendar === true,
|
|
2085
|
+
},
|
|
2086
|
+
}
|
|
2087
|
+
: {}),
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
function mergeClarificationRequirements(current, persisted) {
|
|
2091
|
+
if (!persisted)
|
|
2092
|
+
return current;
|
|
2093
|
+
// Persisted requirements are only a reject-only continuity check. Unioning
|
|
2094
|
+
// explicitly requested roles means an old selection cannot weaken the new
|
|
2095
|
+
// typed interpretation even if a caller fabricated its envelope.
|
|
2096
|
+
return {
|
|
2097
|
+
...current,
|
|
2098
|
+
measures: [...new Set([...current.measures, ...(persisted.measures ?? [])])],
|
|
2099
|
+
dimensions: [...new Set([...current.dimensions, ...(persisted.dimensions ?? [])])],
|
|
2100
|
+
entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
|
|
2101
|
+
entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
|
|
2102
|
+
memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
function validateStructuredClarificationSelection(input) {
|
|
2106
|
+
const persisted = persistedClarificationSelectionContext(input.request);
|
|
2107
|
+
const sourceQuestion = input.request.clarificationSourceQuestion?.trim() || input.request.question;
|
|
2108
|
+
const requirements = mergeClarificationRequirements(
|
|
2109
|
+
// A click/reload continuation is an identity action. Use the original user
|
|
2110
|
+
// question (and, when available, the server-persisted typed contract), not
|
|
2111
|
+
// arbitrary inherited parser hints from an unrelated previous turn.
|
|
2112
|
+
buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.requirements);
|
|
2113
|
+
const compatible = compatibleClarificationCandidates(input.candidates, requirements);
|
|
2114
|
+
const choiceIds = compatible.map((candidate) => candidate.id);
|
|
2115
|
+
if (!persisted?.serverIssued) {
|
|
2116
|
+
return {
|
|
2117
|
+
ok: false,
|
|
2118
|
+
requirements,
|
|
2119
|
+
choiceIds,
|
|
2120
|
+
reason: persisted?.invalidReason
|
|
2121
|
+
?? 'A structured selection requires a server-issued clarification envelope.',
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
if (persisted.snapshotId !== input.evidence.snapshotId) {
|
|
2125
|
+
return {
|
|
2126
|
+
ok: false,
|
|
2127
|
+
requirements,
|
|
2128
|
+
choiceIds,
|
|
2129
|
+
reason: 'The structured selection belongs to a stale retrieval snapshot and must be chosen again.',
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
if (!input.selected) {
|
|
2133
|
+
return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
|
|
2134
|
+
}
|
|
2135
|
+
if (!candidateOwnsExplicitClarificationRoles(input.selected, requirements)) {
|
|
2136
|
+
return {
|
|
2137
|
+
ok: false,
|
|
2138
|
+
requirements,
|
|
2139
|
+
choiceIds,
|
|
2140
|
+
reason: `The selected artifact ${input.selected.id} does not own every explicitly requested analytical role.`,
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
const persistedChoices = new Set([
|
|
2144
|
+
...(persisted?.optionIds ?? []),
|
|
2145
|
+
...(persisted?.ambiguityCandidateIds ?? []),
|
|
2146
|
+
]);
|
|
2147
|
+
if (!persistedChoices.has(input.selected.id)
|
|
2148
|
+
&& !persistedChoices.has(input.selected.qualifiedId ?? '')) {
|
|
2149
|
+
return {
|
|
2150
|
+
ok: false,
|
|
2151
|
+
requirements,
|
|
2152
|
+
choiceIds,
|
|
2153
|
+
reason: 'The selected governed identifier was not one of the persisted ambiguity choices for this question.',
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
if (choiceIds.length > 0 && !choiceIds.includes(input.selected.id)) {
|
|
2157
|
+
return {
|
|
2158
|
+
ok: false,
|
|
2159
|
+
requirements,
|
|
2160
|
+
choiceIds,
|
|
2161
|
+
reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
return { ok: true, requirements, choiceIds };
|
|
2165
|
+
}
|
|
2166
|
+
function invalidStructuredSelectionDecision(input) {
|
|
2167
|
+
const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
|
|
2168
|
+
const persisted = persistedClarificationSelectionContext(input.request);
|
|
2169
|
+
const persistedOptionIds = [...new Set([
|
|
2170
|
+
...(persisted?.serverIssued ? persisted.optionIds : []),
|
|
2171
|
+
...(persisted?.serverIssued ? persisted.ambiguityCandidateIds : []),
|
|
2172
|
+
])];
|
|
2173
|
+
// Re-render only the original server-persisted ambiguity set when it exists.
|
|
2174
|
+
// A rejected click cannot silently widen into a fresh block/metric choice
|
|
2175
|
+
// that was never offered for this question; each remaining item is still
|
|
2176
|
+
// filtered against current snapshot eligibility and artifact-local roles.
|
|
2177
|
+
const options = persistedOptionIds.length > 0
|
|
2178
|
+
? clarificationOptionsForQualifiedIds(persistedOptionIds, input.candidates, input.validation.requirements)
|
|
2179
|
+
: buildClarificationOptions(input.candidates, input.validation.requirements);
|
|
2180
|
+
const candidateIds = input.validation.choiceIds;
|
|
2181
|
+
const message = `${input.validation.reason} DQL did not freeze or execute a plan for that selection.`;
|
|
2182
|
+
const decision = buildAnalyticalCascadeDecision({
|
|
2183
|
+
requirements: input.validation.requirements,
|
|
2184
|
+
sourceCoverage: coverage,
|
|
2185
|
+
attempts: [
|
|
2186
|
+
{ version: 1, tier: 'certified', outcome: 'ineligible', candidateIds: coverage.find((item) => item.source === 'certified')?.candidateIds ?? [], reason: 'No selected certified artifact proved the requested tuple.', planFrozen: false },
|
|
2187
|
+
{ version: 1, tier: 'semantic', outcome: 'ambiguous', candidateIds, reason: 'The selected identifier was rejected before semantic ambiguity could be resolved.', planFrozen: false },
|
|
2188
|
+
{ version: 1, tier: 'governed_relational', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'governed_relational')?.candidateIds ?? [], reason: 'A structured selection cannot authorize relational execution while its meaning is invalid.', planFrozen: false },
|
|
2189
|
+
{ version: 1, tier: 'exploratory_sql', outcome: 'unavailable', candidateIds: coverage.find((item) => item.source === 'exploratory')?.candidateIds ?? [], reason: 'A structured selection cannot authorize exploratory execution while its meaning is invalid.', planFrozen: false },
|
|
2190
|
+
{ version: 1, tier: 'clarify_or_gap', outcome: options.length > 0 ? 'ambiguous' : 'unavailable', candidateIds, reason: message, planFrozen: false },
|
|
2191
|
+
],
|
|
2192
|
+
planFrozen: false,
|
|
2193
|
+
stopReason: options.length > 0 ? 'ambiguous' : 'coverage_gap',
|
|
2194
|
+
});
|
|
2195
|
+
if (options.length === 0) {
|
|
2196
|
+
return {
|
|
2197
|
+
...input.base,
|
|
2198
|
+
action: 'block',
|
|
2199
|
+
confidence: 1,
|
|
2200
|
+
source: 'heuristic',
|
|
2201
|
+
category: 'data_lookup',
|
|
2202
|
+
depth: 'quick',
|
|
2203
|
+
reason: message,
|
|
2204
|
+
requiresClarification: false,
|
|
2205
|
+
retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
|
|
2206
|
+
terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds },
|
|
2207
|
+
analyticalCascadeDecision: decision,
|
|
2208
|
+
resolvedAnalyticalPlan: undefined,
|
|
2209
|
+
meaningResolution: undefined,
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
return {
|
|
2213
|
+
...input.base,
|
|
2214
|
+
action: 'clarify',
|
|
2215
|
+
confidence: 1,
|
|
2216
|
+
source: 'heuristic',
|
|
2217
|
+
category: 'unclear',
|
|
2218
|
+
depth: 'quick',
|
|
2219
|
+
reason: message,
|
|
2220
|
+
requiresClarification: true,
|
|
2221
|
+
clarifyingQuestion: 'That selection does not match the requested analytical meaning. Which compatible governed metric should DQL use?',
|
|
2222
|
+
clarificationOptions: options,
|
|
2223
|
+
retrievalEvidence: retrievalTrace(input.evidence, input.candidates),
|
|
2224
|
+
analyticalCascadeDecision: decision,
|
|
2225
|
+
resolvedAnalyticalPlan: undefined,
|
|
2226
|
+
meaningResolution: {
|
|
2227
|
+
interpretedQuestion: input.request.question,
|
|
2228
|
+
questionType: questionTypeFromText(input.request.question),
|
|
2229
|
+
selectedConceptIds: [],
|
|
2230
|
+
queryIntent: defaultQueryIntent(input.evidence),
|
|
2231
|
+
rejectedCandidates: [{ id: input.selectedId, reason: input.validation.reason }],
|
|
2232
|
+
confidence: 'low',
|
|
2233
|
+
missingInformation: [message],
|
|
2234
|
+
recommendedRoute: 'clarify',
|
|
2235
|
+
compatibilityOutcome: 'clarify',
|
|
2236
|
+
compatibilityFailures: [{
|
|
2237
|
+
code: 'INVALID_STRUCTURED_SELECTION',
|
|
2238
|
+
field: 'selection',
|
|
2239
|
+
message: input.validation.reason,
|
|
2240
|
+
candidateIds,
|
|
2241
|
+
}],
|
|
2242
|
+
},
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
735
2245
|
function candidateKindLabel(kind) {
|
|
736
2246
|
if (kind === 'certified_block')
|
|
737
2247
|
return 'certified block';
|
|
@@ -848,9 +2358,10 @@ function rankingMetricChoiceDecision(base, evidence, candidates, selected, quest
|
|
|
848
2358
|
&& candidate.kind === 'semantic_metric'
|
|
849
2359
|
&& !isDegenerateRankingMetric(question, evidence, candidate))
|
|
850
2360
|
.slice(0, 3);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
2361
|
+
if (options.length === 0) {
|
|
2362
|
+
return unanswerableClarificationFallback(base, retrievalTrace(evidence, candidates), `${selected.name} counts customers and cannot rank them, and no alternative governed measure was retrieved, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.`);
|
|
2363
|
+
}
|
|
2364
|
+
const labels = options.map((candidate) => renderCandidateChoice(candidate)).join(' or ');
|
|
854
2365
|
return {
|
|
855
2366
|
...base,
|
|
856
2367
|
action: 'clarify',
|
|
@@ -862,7 +2373,7 @@ function rankingMetricChoiceDecision(base, evidence, candidates, selected, quest
|
|
|
862
2373
|
requiresClarification: true,
|
|
863
2374
|
reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
|
|
864
2375
|
clarifyingQuestion: `That metric counts unique customers and cannot rank individual customers. Which measure should rank them: ${labels}?`,
|
|
865
|
-
clarificationOptions:
|
|
2376
|
+
clarificationOptions: buildClarificationOptions(options),
|
|
866
2377
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
867
2378
|
resolvedAnalyticalPlan: undefined,
|
|
868
2379
|
meaningResolution: undefined,
|
|
@@ -919,7 +2430,22 @@ function directResolution(request, evidence, candidate, candidates) {
|
|
|
919
2430
|
metricCandidates,
|
|
920
2431
|
candidates,
|
|
921
2432
|
});
|
|
922
|
-
const
|
|
2433
|
+
const defaultIntent = defaultQueryIntent(evidence);
|
|
2434
|
+
// An exact authored certified example has already proved this block's own
|
|
2435
|
+
// output contract. Parser wording such as "food and drink" describes the
|
|
2436
|
+
// values of the block's declared `category` output; it must not manufacture
|
|
2437
|
+
// two literal dimensions and overrule that local contract. Only replace
|
|
2438
|
+
// dimensions when the selected block explicitly declares them, while the
|
|
2439
|
+
// strict requested-measure check remains unchanged.
|
|
2440
|
+
const exactCertifiedExample = candidate.kind === 'certified_block'
|
|
2441
|
+
&& candidate.exactMatch
|
|
2442
|
+
&& candidate.compatibility === 'compatible'
|
|
2443
|
+
&& candidate.analyticalFitClass === 'exact'
|
|
2444
|
+
&& certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
|
|
2445
|
+
&& (candidate.dimensions?.length ?? 0) > 0;
|
|
2446
|
+
const queryIntent = exactCertifiedExample
|
|
2447
|
+
? { ...defaultIntent, dimensions: candidate.dimensions ?? [] }
|
|
2448
|
+
: defaultIntent;
|
|
923
2449
|
const memberCandidates = candidates.filter((item) => {
|
|
924
2450
|
if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
|
|
925
2451
|
return false;
|
|
@@ -1042,11 +2568,25 @@ mayAssumeInterpretation = true) {
|
|
|
1042
2568
|
: candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
|
|
1043
2569
|
if (questionTypeFromText(request.question) === 'ranking'
|
|
1044
2570
|
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
2571
|
+
// Assume the measure where one is clearly indicated, and BIND it through the
|
|
2572
|
+
// same resolution path an explicit selection takes — an assumption that
|
|
2573
|
+
// cannot freeze a plan is refused downstream and surfaces as `blocked` with
|
|
2574
|
+
// no options, which is worse than the question it replaced.
|
|
2575
|
+
const assumed = mayAssumeInterpretation
|
|
2576
|
+
? assumableRankingMeasure(request.question, rankingCandidates)
|
|
2577
|
+
: undefined;
|
|
2578
|
+
if (assumed) {
|
|
2579
|
+
return {
|
|
2580
|
+
...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumed.candidate, candidates), 'heuristic', request.question, planMode),
|
|
2581
|
+
assumptions: [assumed.assumption],
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
1045
2584
|
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
|
|
1046
2585
|
}
|
|
1047
2586
|
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
|
|
1048
2587
|
&& candidate.compatibility !== "incompatible"
|
|
1049
|
-
&& rankingCandidates.includes(candidate)
|
|
2588
|
+
&& rankingCandidates.includes(candidate)
|
|
2589
|
+
&& candidateMayTerminateCertifiedForMeasures(candidate, evidence.parsedIntent?.measures ?? []));
|
|
1050
2590
|
if (exactCompatible.length === 1 &&
|
|
1051
2591
|
!hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
1052
2592
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
|
|
@@ -1060,7 +2600,7 @@ mayAssumeInterpretation = true) {
|
|
|
1060
2600
|
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
1061
2601
|
// were offered as three competing "meanings" of the same number.
|
|
1062
2602
|
const best = mayAssumeInterpretation
|
|
1063
|
-
? bestGovernedInterpretation(request.question, rankingCandidates)
|
|
2603
|
+
? bestGovernedInterpretation(request.question, rankingCandidates, evidence.parsedIntent?.measures ?? [])
|
|
1064
2604
|
: undefined;
|
|
1065
2605
|
if (best) {
|
|
1066
2606
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
@@ -1219,7 +2759,7 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
1219
2759
|
* question made ordinary lookups feel like an interrogation, and most of those
|
|
1220
2760
|
* questions had no real ambiguity behind them.
|
|
1221
2761
|
*/
|
|
1222
|
-
export function bestGovernedInterpretation(question, candidates) {
|
|
2762
|
+
export function bestGovernedInterpretation(question, candidates, requestedMeasures = []) {
|
|
1223
2763
|
// Take the best candidate that is actually executable, rather than refusing
|
|
1224
2764
|
// because the top-ranked hit happens to be a descriptive modeling entity.
|
|
1225
2765
|
// `compatibility: 'unknown'` is common for governed objects that execute
|
|
@@ -1229,7 +2769,9 @@ export function bestGovernedInterpretation(question, candidates) {
|
|
|
1229
2769
|
return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
|
|
1230
2770
|
&& (candidate.kind === 'certified_block'
|
|
1231
2771
|
|| candidate.kind === 'semantic_metric'
|
|
1232
|
-
|| candidate.kind === 'semantic_member')
|
|
2772
|
+
|| candidate.kind === 'semantic_member')
|
|
2773
|
+
&& (candidate.kind !== 'certified_block'
|
|
2774
|
+
|| certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures)));
|
|
1233
2775
|
}
|
|
1234
2776
|
/**
|
|
1235
2777
|
* Retrieval may nominate qualified candidates, but only the resolved plan may
|
|
@@ -1247,8 +2789,69 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1247
2789
|
candidateCount: 0,
|
|
1248
2790
|
candidateIds: [],
|
|
1249
2791
|
};
|
|
2792
|
+
// This generic fallback has no provider/plan-issued role target yet. Build
|
|
2793
|
+
// its strict option roles from the user's words only; inherited parsed-intent
|
|
2794
|
+
// hints can still guide later plan construction but must not hide every
|
|
2795
|
+
// valid compositional or dimension choice.
|
|
2796
|
+
const clarificationRequirements = buildAnalyticalRequirementSet({ question });
|
|
2797
|
+
const physicalRequirements = buildAnalyticalRequirementSet({
|
|
2798
|
+
question,
|
|
2799
|
+
parsedIntent: evidence?.parsedIntent,
|
|
2800
|
+
});
|
|
2801
|
+
const fallbackToPhysicalCascade = () => {
|
|
2802
|
+
if (!evidence)
|
|
2803
|
+
return undefined;
|
|
2804
|
+
const requiredPhysicalFieldTerms = [
|
|
2805
|
+
...physicalRequirements.dimensions,
|
|
2806
|
+
...physicalRequirements.entityTerms,
|
|
2807
|
+
...physicalRequirements.entityDisplayTerms,
|
|
2808
|
+
...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
|
|
2809
|
+
];
|
|
2810
|
+
return preFreezePhysicalCascadeDecision({
|
|
2811
|
+
base,
|
|
2812
|
+
evidence,
|
|
2813
|
+
candidates,
|
|
2814
|
+
question,
|
|
2815
|
+
requirements: physicalRequirements,
|
|
2816
|
+
missingTerms: physicalRequirements.dimensions,
|
|
2817
|
+
requiredPhysicalFieldTerms,
|
|
2818
|
+
messagePrefix: 'No certified, semantic, or governed relational candidate proved the complete requested tuple before plan freeze.',
|
|
2819
|
+
terminalCandidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id),
|
|
2820
|
+
});
|
|
2821
|
+
};
|
|
1250
2822
|
if (eligible.length > 1) {
|
|
1251
|
-
|
|
2823
|
+
// Raw candidates can be complementary rather than competing. Do not ask a
|
|
2824
|
+
// person to pick between the table that supplies the display value and the
|
|
2825
|
+
// table that supplies the requested predicate when their qualified,
|
|
2826
|
+
// same-snapshot relationship proof already closes a safe exploration path.
|
|
2827
|
+
// The helper is deliberately stricter than generic retrieval: missing a
|
|
2828
|
+
// display/predicate role, a shared key, or relationship/fanout proof keeps
|
|
2829
|
+
// this exact clarification path intact.
|
|
2830
|
+
const composition = evidence
|
|
2831
|
+
? complementaryExploratoryDecision(base, evidence, candidates, question)
|
|
2832
|
+
: undefined;
|
|
2833
|
+
if (composition)
|
|
2834
|
+
return composition;
|
|
2835
|
+
const choices = compatibleClarificationCandidates(eligible, clarificationRequirements).slice(0, 3);
|
|
2836
|
+
if (choices.length === 0) {
|
|
2837
|
+
return fallbackToPhysicalCascade() ?? (() => {
|
|
2838
|
+
const message = 'No retrieved artifact owns every explicitly requested analytical role, so DQL did not offer an incompatible structured selection.';
|
|
2839
|
+
return {
|
|
2840
|
+
...base,
|
|
2841
|
+
action: 'block',
|
|
2842
|
+
confidence: 1,
|
|
2843
|
+
source: 'heuristic',
|
|
2844
|
+
category: 'data_lookup',
|
|
2845
|
+
depth: 'quick',
|
|
2846
|
+
reason: message,
|
|
2847
|
+
requiresClarification: false,
|
|
2848
|
+
retrievalEvidence: trace,
|
|
2849
|
+
terminalOutcome: { kind: 'modeling_gap', code: 'ANALYTICAL_MODELING_GAP', message, candidateIds: eligible.map((candidate) => candidate.qualifiedId ?? candidate.id) },
|
|
2850
|
+
resolvedAnalyticalPlan: undefined,
|
|
2851
|
+
meaningResolution: undefined,
|
|
2852
|
+
};
|
|
2853
|
+
})();
|
|
2854
|
+
}
|
|
1252
2855
|
return {
|
|
1253
2856
|
...base,
|
|
1254
2857
|
action: 'clarify',
|
|
@@ -1259,7 +2862,7 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1259
2862
|
reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
|
|
1260
2863
|
requiresClarification: true,
|
|
1261
2864
|
clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
|
|
1262
|
-
clarificationOptions: buildClarificationOptions(choices),
|
|
2865
|
+
clarificationOptions: buildClarificationOptions(choices, clarificationRequirements),
|
|
1263
2866
|
retrievalEvidence: trace,
|
|
1264
2867
|
resolvedAnalyticalPlan: undefined,
|
|
1265
2868
|
meaningResolution: undefined,
|
|
@@ -1269,6 +2872,9 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1269
2872
|
const message = candidateIds.length === 1
|
|
1270
2873
|
? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
|
|
1271
2874
|
: 'No governed candidate proved the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability or choose a governed identifier before retrying.';
|
|
2875
|
+
const physicalFallback = fallbackToPhysicalCascade();
|
|
2876
|
+
if (physicalFallback)
|
|
2877
|
+
return physicalFallback;
|
|
1272
2878
|
return {
|
|
1273
2879
|
...base,
|
|
1274
2880
|
action: 'block',
|
|
@@ -1296,6 +2902,10 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1296
2902
|
*/
|
|
1297
2903
|
function deterministicPrePlanClarification(request, base, evidence, candidates) {
|
|
1298
2904
|
const asksForRanking = questionTypeFromText(request.question) === 'ranking';
|
|
2905
|
+
const requirements = buildAnalyticalRequirementSet({
|
|
2906
|
+
question: request.question,
|
|
2907
|
+
parsedIntent: evidence.parsedIntent,
|
|
2908
|
+
});
|
|
1299
2909
|
const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
|
|
1300
2910
|
.map(normalizeMetricPhrase)
|
|
1301
2911
|
.filter(Boolean);
|
|
@@ -1304,7 +2914,13 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1304
2914
|
|| hasStrongQualifiedMetricEvidence(candidates)
|
|
1305
2915
|
|| hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
|
|
1306
2916
|
const retrievalEvidence = retrievalTrace(evidence, candidates);
|
|
1307
|
-
|
|
2917
|
+
// “by month” is a time role/grain, and FY26 is a fiscal-period filter. They
|
|
2918
|
+
// are not requests for literal `month`/`year` columns. A fiscal token still
|
|
2919
|
+
// requires a declared calendar at compilation time; this router simply must
|
|
2920
|
+
// not manufacture one or report an absence before relational/runtime context
|
|
2921
|
+
// has had a chance to bind the declared date role.
|
|
2922
|
+
const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? [])
|
|
2923
|
+
.filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
|
|
1308
2924
|
const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
|
|
1309
2925
|
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
|
|
1310
2926
|
? [normalizeMetricPhrase(filter.field)]
|
|
@@ -1321,9 +2937,52 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1321
2937
|
.sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
|
|
1322
2938
|
.slice(0, 3);
|
|
1323
2939
|
if (alternatives.length === 0) {
|
|
1324
|
-
|
|
2940
|
+
// Bare rankings need a measure choice, not a dimension gap. Retain the
|
|
2941
|
+
// no-options escape hatch only when the snapshot does not also expose a
|
|
2942
|
+
// typed structural request. For "top customers for perishable products",
|
|
2943
|
+
// a no-option ranking response previously hid the missing safe
|
|
2944
|
+
// customer→order→item→supply closure behind an answer-shaped dead end.
|
|
2945
|
+
// Record that pre-freeze gap instead; this still never invents a ranking
|
|
2946
|
+
// measure or a relationship.
|
|
2947
|
+
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
2948
|
+
const rankingClarification = bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
2949
|
+
if (rankingClarification.action === 'clarify'
|
|
2950
|
+
&& (rankingClarification.clarificationOptions?.length ?? 0) > 0) {
|
|
2951
|
+
return rankingClarification;
|
|
2952
|
+
}
|
|
2953
|
+
return preFreezePhysicalCascadeDecision({
|
|
2954
|
+
base,
|
|
2955
|
+
evidence,
|
|
2956
|
+
candidates,
|
|
2957
|
+
question: request.question,
|
|
2958
|
+
requirements,
|
|
2959
|
+
missingTerms: missingDimensions,
|
|
2960
|
+
requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
|
|
2961
|
+
messagePrefix: `No governed ranking measure and no complete governed tuple proved ${missingDimensions.map((term) => `“${term}”`).join(' and ')} before plan freeze.`,
|
|
2962
|
+
requireRankingMetric: true,
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
// Parsed-intent hints can include inherited/default dimensions that the
|
|
2966
|
+
// user never asked for. Only turn a missing field into a product-facing
|
|
2967
|
+
// modeling gap when its wording is present in this turn; otherwise let
|
|
2968
|
+
// bounded meaning resolution preserve its own ambiguity contract.
|
|
2969
|
+
const normalizedQuestion = normalizeMetricPhrase(request.question);
|
|
2970
|
+
if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
|
|
1325
2971
|
return undefined;
|
|
1326
|
-
|
|
2972
|
+
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
2973
|
+
const temporalNote = requirements.time?.fiscalPeriod
|
|
2974
|
+
? ` ${requirements.time.fiscalPeriod} remains an unbound fiscal-period token until a declared calendar is available; DQL will not guess one.`
|
|
2975
|
+
: '';
|
|
2976
|
+
return preFreezePhysicalCascadeDecision({
|
|
2977
|
+
base,
|
|
2978
|
+
evidence,
|
|
2979
|
+
candidates,
|
|
2980
|
+
question: request.question,
|
|
2981
|
+
requirements,
|
|
2982
|
+
missingTerms: missingDimensions,
|
|
2983
|
+
requiredPhysicalFieldTerms: (evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
|
|
2984
|
+
messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
|
|
2985
|
+
});
|
|
1327
2986
|
}
|
|
1328
2987
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1329
2988
|
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
@@ -1350,6 +3009,39 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1350
3009
|
}
|
|
1351
3010
|
return undefined;
|
|
1352
3011
|
}
|
|
3012
|
+
/**
|
|
3013
|
+
* A clarification with NO selectable options is unanswerable, and asking it is a
|
|
3014
|
+
* dead end rather than a safety measure.
|
|
3015
|
+
*
|
|
3016
|
+
* Reported from production: "who are the top customers for BCM" returned
|
|
3017
|
+
* "Top by which governed metric?" with zero choices. Answering it in prose
|
|
3018
|
+
* ("...who have top revenue") produced the IDENTICAL question again, because the
|
|
3019
|
+
* reply carries no `selectedEvidenceId` and re-enters the same path with the
|
|
3020
|
+
* same evidence. A question that can only be answered by clicking a button that
|
|
3021
|
+
* was never rendered loops forever.
|
|
3022
|
+
*
|
|
3023
|
+
* When the option list is empty the failure is in RETRIEVAL, not in the user's
|
|
3024
|
+
* phrasing, so continue into the review-required generated lane and let the
|
|
3025
|
+
* answer carry the caveat. `requiresClarification` is cleared deliberately:
|
|
3026
|
+
* leaving it set would make `answerAnywayRoute` treat this as material ambiguity
|
|
3027
|
+
* and re-block the turn.
|
|
3028
|
+
*/
|
|
3029
|
+
function unanswerableClarificationFallback(base, retrievalEvidence, reason) {
|
|
3030
|
+
return {
|
|
3031
|
+
...base,
|
|
3032
|
+
action: 'answer',
|
|
3033
|
+
confidence: Math.min(base.confidence, 0.5),
|
|
3034
|
+
source: 'heuristic',
|
|
3035
|
+
category: 'data_lookup',
|
|
3036
|
+
requiresClarification: false,
|
|
3037
|
+
clarifyingQuestion: undefined,
|
|
3038
|
+
clarificationOptions: undefined,
|
|
3039
|
+
reason,
|
|
3040
|
+
...(retrievalEvidence ? { retrievalEvidence } : {}),
|
|
3041
|
+
resolvedAnalyticalPlan: undefined,
|
|
3042
|
+
meaningResolution: undefined,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
1353
3045
|
/**
|
|
1354
3046
|
* "Top by which governed metric?" with NO choices is a dead end: the asker
|
|
1355
3047
|
* cannot know which measures are both governed and valid at the ranked grain,
|
|
@@ -1365,7 +3057,101 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1365
3057
|
*
|
|
1366
3058
|
* Acceptance: AGT-030.
|
|
1367
3059
|
*/
|
|
1368
|
-
|
|
3060
|
+
/**
|
|
3061
|
+
* Generic measure vocabulary: words that say HOW MUCH, never WHICH SUBSET.
|
|
3062
|
+
* A candidate built only from these adds no scope the asker did not state.
|
|
3063
|
+
*/
|
|
3064
|
+
const GENERIC_MEASURE_TOKENS = new Set([
|
|
3065
|
+
'revenue', 'spend', 'spending', 'sale', 'amount', 'value', 'total', 'sum',
|
|
3066
|
+
'count', 'number', 'order', 'quantity', 'qty', 'price', 'cost', 'profit',
|
|
3067
|
+
'margin', 'gross', 'net', 'lifetime', 'avg', 'average', 'mean', 'median', 'score',
|
|
3068
|
+
'rate', 'ratio', 'percent', 'share', 'volume', 'unit', 'balance', 'pretax', 'ltv',
|
|
3069
|
+
'top', 'rank', 'ranking', 'by', 'per', 'the', 'of', 'and',
|
|
3070
|
+
]);
|
|
3071
|
+
/**
|
|
3072
|
+
* Crude, symmetric singularization. Applied to BOTH sides, so the only thing
|
|
3073
|
+
* that matters is that it agrees with itself — `address` becoming `addres` is
|
|
3074
|
+
* harmless when the question's `address` becomes `addres` too. Without it a
|
|
3075
|
+
* candidate named `customers.customer_value` is rejected against the question
|
|
3076
|
+
* "who are the top customers", because the singular `customer` is neither a
|
|
3077
|
+
* question word nor generic measure vocabulary. That is a morphology accident,
|
|
3078
|
+
* not an unrequested scope, and it silently refused good assumptions.
|
|
3079
|
+
*/
|
|
3080
|
+
function singularize(token) {
|
|
3081
|
+
if (token.length > 3 && token.endsWith('ies'))
|
|
3082
|
+
return `${token.slice(0, -3)}y`;
|
|
3083
|
+
if (token.length > 3 && (token.endsWith('ses') || token.endsWith('xes') || token.endsWith('zes'))) {
|
|
3084
|
+
return token.slice(0, -2);
|
|
3085
|
+
}
|
|
3086
|
+
if (token.length > 2 && token.endsWith('s') && !token.endsWith('ss'))
|
|
3087
|
+
return token.slice(0, -1);
|
|
3088
|
+
return token;
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Pick the measure a bare ranking should assume, or nothing.
|
|
3092
|
+
*
|
|
3093
|
+
* Returns the CANDIDATE, not a decision, because the assumption is only useful
|
|
3094
|
+
* where it can be bound: an `action: 'answer'` with no frozen analytical plan is
|
|
3095
|
+
* refused by the plan boundary and reaches the user as `blocked` with no
|
|
3096
|
+
* options, which is strictly worse than the clarification it replaced. Measured
|
|
3097
|
+
* exactly that way before this was moved to a caller that can bind it.
|
|
3098
|
+
*/
|
|
3099
|
+
export function assumableRankingMeasure(question, candidates) {
|
|
3100
|
+
// Only a MEASURE can be assumed. A certified block is a whole authored query,
|
|
3101
|
+
// not a ranking measure, so treating one as the answer to "by which metric?"
|
|
3102
|
+
// silently selects someone else's entire analysis.
|
|
3103
|
+
const assumable = candidates.filter((candidate) => (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
|
|
3104
|
+
&& candidate.compatibility !== 'incompatible'
|
|
3105
|
+
&& rankingCandidateFitsBareQuestion(question, candidate));
|
|
3106
|
+
if (assumable.length === 0)
|
|
3107
|
+
return undefined;
|
|
3108
|
+
const assumption = assumeDominantCandidate({
|
|
3109
|
+
about: 'metric',
|
|
3110
|
+
candidates: assumable.map((candidate) => ({
|
|
3111
|
+
id: candidate.qualifiedId ?? candidate.id,
|
|
3112
|
+
label: candidate.name,
|
|
3113
|
+
score: candidate.relevanceScore,
|
|
3114
|
+
})),
|
|
3115
|
+
because: (chosen) => `"${chosen.label ?? chosen.id}" was the highest-ranked governed measure that can rank this entity, and it adds no filter the question did not ask for.`,
|
|
3116
|
+
});
|
|
3117
|
+
if (!assumption)
|
|
3118
|
+
return undefined;
|
|
3119
|
+
const chosen = assumable.find((candidate) => (candidate.qualifiedId ?? candidate.id) === assumption.chose);
|
|
3120
|
+
return chosen ? { candidate: chosen, assumption } : undefined;
|
|
3121
|
+
}
|
|
3122
|
+
/**
|
|
3123
|
+
* Does this ranking measure fit a question that named no measure?
|
|
3124
|
+
*
|
|
3125
|
+
* "Top customers" must not be silently answered by `top_beverage_customers`:
|
|
3126
|
+
* that candidate carries a scope — beverages — the asker never asked for, and
|
|
3127
|
+
* answering with it returns a confidently wrong list under a different question
|
|
3128
|
+
* than the one posed. `assumeDominantCandidate` deliberately leaves this check
|
|
3129
|
+
* to the caller, because dominance is about ranking and this is about meaning.
|
|
3130
|
+
*
|
|
3131
|
+
* The rule: every substantive token in the candidate's name must be either
|
|
3132
|
+
* something the question already said, or generic measure vocabulary. Any
|
|
3133
|
+
* leftover token is an unrequested qualifier, and the turn keeps asking.
|
|
3134
|
+
*/
|
|
3135
|
+
export function rankingCandidateFitsBareQuestion(question, candidate) {
|
|
3136
|
+
const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
|
|
3137
|
+
// Identities arrive source-qualified (`semantic:metric:orders.revenue`). The
|
|
3138
|
+
// `semantic`/`metric` prefix is plumbing, not vocabulary — tokenizing it would
|
|
3139
|
+
// make every candidate look scoped and refuse every assumption.
|
|
3140
|
+
const identity = (candidate.qualifiedId ?? candidate.id ?? '').split(':').at(-1) ?? '';
|
|
3141
|
+
const nameTokens = substantiveLexicalTokens([candidate.name, identity].join(' '));
|
|
3142
|
+
if (nameTokens.length === 0)
|
|
3143
|
+
return false;
|
|
3144
|
+
return nameTokens.map(singularize).every((token) => questionTokens.has(token) || GENERIC_MEASURE_TOKENS.has(token));
|
|
3145
|
+
}
|
|
3146
|
+
function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates,
|
|
3147
|
+
/**
|
|
3148
|
+
* May this turn settle the measure by assumption rather than by asking?
|
|
3149
|
+
* False wherever the step whose job is to judge ambiguity has positively
|
|
3150
|
+
* reported some — assuming past a finding overrides it rather than filling a
|
|
3151
|
+
* gap, and AGT-017 already establishes that lexical rank alone must not
|
|
3152
|
+
* settle meaning when semantic judgment is unavailable.
|
|
3153
|
+
*/
|
|
3154
|
+
mayAssume = true) {
|
|
1369
3155
|
const rankingChoices = (candidates ?? []).filter((candidate) => {
|
|
1370
3156
|
if (candidate.compatibility === 'incompatible')
|
|
1371
3157
|
return false;
|
|
@@ -1397,6 +3183,9 @@ function bareRankingClarification(base, retrievalEvidence, question, evidence, c
|
|
|
1397
3183
|
return false;
|
|
1398
3184
|
return true;
|
|
1399
3185
|
});
|
|
3186
|
+
if (rankingChoices.length === 0) {
|
|
3187
|
+
return unanswerableClarificationFallback(base, retrievalEvidence, 'No retrieved governed measure can rank this entity, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.');
|
|
3188
|
+
}
|
|
1400
3189
|
return {
|
|
1401
3190
|
...base,
|
|
1402
3191
|
action: 'clarify',
|
|
@@ -1408,9 +3197,7 @@ function bareRankingClarification(base, retrievalEvidence, question, evidence, c
|
|
|
1408
3197
|
requiresClarification: true,
|
|
1409
3198
|
clarifyingQuestion: 'Top by which governed metric?',
|
|
1410
3199
|
retrievalEvidence,
|
|
1411
|
-
|
|
1412
|
-
? { clarificationOptions: buildClarificationOptions(rankingChoices) }
|
|
1413
|
-
: {}),
|
|
3200
|
+
clarificationOptions: buildClarificationOptions(rankingChoices),
|
|
1414
3201
|
resolvedAnalyticalPlan: undefined,
|
|
1415
3202
|
meaningResolution: undefined,
|
|
1416
3203
|
};
|
|
@@ -1446,7 +3233,11 @@ function candidateProvesMetricTerm(candidate, requested) {
|
|
|
1446
3233
|
if (candidate.compatibility === 'incompatible')
|
|
1447
3234
|
return false;
|
|
1448
3235
|
if (candidate.kind === 'certified_block') {
|
|
1449
|
-
|
|
3236
|
+
// A block's relevance/tag/example must never impersonate one of its
|
|
3237
|
+
// outputs. Only a declared block output may prove an explicit requested
|
|
3238
|
+
// metric (AGT-009/AGT-010).
|
|
3239
|
+
return candidate.compatibility === 'compatible'
|
|
3240
|
+
&& certifiedCandidateExplicitlyCoversMeasures(candidate, [requested]);
|
|
1450
3241
|
}
|
|
1451
3242
|
if (candidate.kind !== 'semantic_metric')
|
|
1452
3243
|
return false;
|
|
@@ -1617,9 +3408,10 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
|
|
|
1617
3408
|
* against a uniquely compatible certified block; a second compatible block or
|
|
1618
3409
|
* metric still goes to the bounded AI resolver.
|
|
1619
3410
|
*/
|
|
1620
|
-
function dominantCompatibleGovernedCandidate(candidates) {
|
|
3411
|
+
function dominantCompatibleGovernedCandidate(candidates, requestedMeasures = []) {
|
|
1621
3412
|
const compatible = candidates.filter((candidate) => candidate.compatibility === "compatible"
|
|
1622
|
-
&& (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member")
|
|
3413
|
+
&& (candidate.kind === "certified_block" || candidate.kind === "semantic_metric" || candidate.kind === "semantic_member")
|
|
3414
|
+
&& candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures));
|
|
1623
3415
|
if (compatible.length === 0)
|
|
1624
3416
|
return undefined;
|
|
1625
3417
|
const best = compatible[0];
|
|
@@ -1641,13 +3433,19 @@ function dominantCompatibleGovernedCandidate(candidates) {
|
|
|
1641
3433
|
const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
|
|
1642
3434
|
return hasExecutableCompetitor ? undefined : best;
|
|
1643
3435
|
}
|
|
1644
|
-
function authoritativeExactCertifiedExample(candidates) {
|
|
3436
|
+
function authoritativeExactCertifiedExample(candidates, requestedMeasures = []) {
|
|
1645
3437
|
const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
|
|
1646
3438
|
&& candidate.exactMatch
|
|
1647
3439
|
&& candidate.compatibility === 'compatible'
|
|
1648
|
-
&& candidate.analyticalFitClass === 'exact'
|
|
3440
|
+
&& candidate.analyticalFitClass === 'exact'
|
|
3441
|
+
&& certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures));
|
|
1649
3442
|
return exact.length === 1 ? exact[0] : undefined;
|
|
1650
3443
|
}
|
|
3444
|
+
/** Certified output coverage is a Tier-1 invariant; other routes pass through. */
|
|
3445
|
+
function candidateMayTerminateCertifiedForMeasures(candidate, requestedMeasures) {
|
|
3446
|
+
return candidate.kind !== 'certified_block'
|
|
3447
|
+
|| certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures);
|
|
3448
|
+
}
|
|
1651
3449
|
function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
|
|
1652
3450
|
if (!base.followsUp)
|
|
1653
3451
|
return false;
|
|
@@ -1718,7 +3516,7 @@ export function createHybridRouter(options = {}) {
|
|
|
1718
3516
|
const base = deterministic(request);
|
|
1719
3517
|
// The only pre-retrieval exits are deliberately narrow and unambiguously
|
|
1720
3518
|
// non-analytical. App composition has its own catalog-grounded workflow.
|
|
1721
|
-
if (base.action === "converse" || base.action === "compose_app") {
|
|
3519
|
+
if (!request.selectedEvidenceId && (base.action === "converse" || base.action === "compose_app")) {
|
|
1722
3520
|
return { ...base, source: base.source ?? "heuristic" };
|
|
1723
3521
|
}
|
|
1724
3522
|
const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
|
|
@@ -1737,6 +3535,12 @@ export function createHybridRouter(options = {}) {
|
|
|
1737
3535
|
}
|
|
1738
3536
|
}
|
|
1739
3537
|
if (evidence) {
|
|
3538
|
+
// The retrieval parser may carry broad search phrases such as
|
|
3539
|
+
// `count_for_each_customer`. They are useful before retrieval, but
|
|
3540
|
+
// they are not executable measures. Normalize once at the router
|
|
3541
|
+
// boundary so every downstream plan/meaning path consumes the same
|
|
3542
|
+
// typed measure + entity/dimension requirements.
|
|
3543
|
+
evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
|
|
1740
3544
|
evidence = {
|
|
1741
3545
|
...evidence,
|
|
1742
3546
|
candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
|
|
@@ -1747,31 +3551,69 @@ export function createHybridRouter(options = {}) {
|
|
|
1747
3551
|
]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
|
|
1748
3552
|
: {}),
|
|
1749
3553
|
};
|
|
1750
|
-
|
|
1751
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
//
|
|
3554
|
+
// Retrieval can retain more context internally; only this compact,
|
|
3555
|
+
// role-balanced package reaches the one meaning call. The question is
|
|
3556
|
+
// supplied so explicit revenue, entity labels, and time roles cannot be
|
|
3557
|
+
// pruned by unrelated lexical matches.
|
|
3558
|
+
let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, request.question);
|
|
3559
|
+
// Clarification is local and never provider-bound, so it can inspect the
|
|
3560
|
+
// complete already-retrieved set. A raw selectedEvidenceId must be
|
|
3561
|
+
// validated against this set before any route can fall through to a
|
|
3562
|
+
// generic/generated answer; this is deliberately outside the bounded
|
|
3563
|
+
// package length guard so an empty retrieval cannot fail open.
|
|
3564
|
+
const clarificationCandidates = [
|
|
3565
|
+
...evidence.candidates,
|
|
3566
|
+
...(evidence.clarificationCandidates ?? []),
|
|
3567
|
+
].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
|
|
3568
|
+
// A structured clarification selection is identity input, not a new
|
|
3569
|
+
// fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
|
|
3570
|
+
// supplemental clarification candidates, not execution candidates.
|
|
1759
3571
|
const selectedEvidence = request.selectedEvidenceId
|
|
1760
|
-
?
|
|
1761
|
-
|
|
3572
|
+
? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
|
|
3573
|
+
: undefined;
|
|
3574
|
+
const structuredSelection = request.selectedEvidenceId
|
|
3575
|
+
? validateStructuredClarificationSelection({
|
|
3576
|
+
request,
|
|
3577
|
+
evidence,
|
|
3578
|
+
candidates: clarificationCandidates,
|
|
3579
|
+
selected: selectedEvidence,
|
|
3580
|
+
})
|
|
1762
3581
|
: undefined;
|
|
3582
|
+
if (structuredSelection && !structuredSelection.ok) {
|
|
3583
|
+
return invalidStructuredSelectionDecision({
|
|
3584
|
+
base,
|
|
3585
|
+
request,
|
|
3586
|
+
evidence,
|
|
3587
|
+
candidates: clarificationCandidates,
|
|
3588
|
+
selectedId: request.selectedEvidenceId,
|
|
3589
|
+
validation: structuredSelection,
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
1763
3592
|
if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
|
|
1764
3593
|
candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
|
|
1765
|
-
.slice(0, options.maxMeaningCandidates ??
|
|
3594
|
+
.slice(0, options.maxMeaningCandidates ?? 16);
|
|
3595
|
+
}
|
|
3596
|
+
// The compact meaning package can be empty when every ranked card was
|
|
3597
|
+
// reserved for a role that the parser marked missing. A unique authored
|
|
3598
|
+
// certified example is still an authoritative snapshot fact, so it
|
|
3599
|
+
// must be considered before the package-length guard and before any
|
|
3600
|
+
// deterministic missing-dimension cascade.
|
|
3601
|
+
if (!request.selectedEvidenceId) {
|
|
3602
|
+
const authoredExample = authoritativeExactCertifiedExample(clarificationCandidates, evidence?.parsedIntent?.measures ?? []);
|
|
3603
|
+
if (authoredExample) {
|
|
3604
|
+
const candidatesForResolution = candidates.some((candidate) => candidate.id === authoredExample.id)
|
|
3605
|
+
? candidates
|
|
3606
|
+
: [authoredExample, ...candidates];
|
|
3607
|
+
return routeDecisionForResolution(base, evidence, candidatesForResolution, directResolution(request, evidence, authoredExample, candidatesForResolution), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
3608
|
+
}
|
|
1766
3609
|
}
|
|
1767
3610
|
if (candidates.length > 0) {
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
|
|
3611
|
+
// Fiscal tokens are execution requirements, not a semantic guess.
|
|
3612
|
+
// Ask exactly once before a meaning call or plan freeze when the
|
|
3613
|
+
// snapshot has no declared fiscal calendar/date-role binding.
|
|
3614
|
+
const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
|
|
3615
|
+
if (fiscalClarification)
|
|
3616
|
+
return fiscalClarification;
|
|
1775
3617
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
1776
3618
|
const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
|
|
1777
3619
|
|| /@(metric|block|model|table|column)\(/i.test(request.question)));
|
|
@@ -1797,8 +3639,19 @@ export function createHybridRouter(options = {}) {
|
|
|
1797
3639
|
return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
|
|
1798
3640
|
}
|
|
1799
3641
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1800
|
-
|
|
1801
|
-
|
|
3642
|
+
// A valid stable semantic choice may still be pre-freeze-ineligible
|
|
3643
|
+
// in this local snapshot (for example, the semantic adapter cannot
|
|
3644
|
+
// execute the selected tuple). That is a normal cascade condition:
|
|
3645
|
+
// keep the chosen meaning, then consider the same-snapshot safe
|
|
3646
|
+
// exploratory path. An invalid/stale choice never reaches here, and
|
|
3647
|
+
// a frozen plan/policy denial is deliberately not downgraded.
|
|
3648
|
+
const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
|
|
3649
|
+
&& decision.resolvedAnalyticalPlan?.capability === 'blocked'
|
|
3650
|
+
&& decision.analyticalCascadeDecision?.planFrozen === false;
|
|
3651
|
+
return selectedEvidence
|
|
3652
|
+
&& structuredSelection?.ok
|
|
3653
|
+
&& (decision.requiresClarification || selectedPreFreezeModelingGap)
|
|
3654
|
+
? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence, request.question)
|
|
1802
3655
|
: decision;
|
|
1803
3656
|
}
|
|
1804
3657
|
const multiMetricPrimary = !shouldUseMeaningCall
|
|
@@ -1807,18 +3660,14 @@ export function createHybridRouter(options = {}) {
|
|
|
1807
3660
|
if (multiMetricPrimary) {
|
|
1808
3661
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1809
3662
|
}
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
:
|
|
1813
|
-
if (authoredExample) {
|
|
1814
|
-
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1815
|
-
}
|
|
1816
|
-
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
|
|
3663
|
+
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch
|
|
3664
|
+
&& candidate.compatibility !== "incompatible"
|
|
3665
|
+
&& candidateMayTerminateCertifiedForMeasures(candidate, evidence?.parsedIntent?.measures ?? [])) : [];
|
|
1817
3666
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
1818
3667
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1819
3668
|
}
|
|
1820
3669
|
const dominant = !shouldUseMeaningCall
|
|
1821
|
-
? dominantCompatibleGovernedCandidate(candidates)
|
|
3670
|
+
? dominantCompatibleGovernedCandidate(candidates, evidence.parsedIntent?.measures ?? [])
|
|
1822
3671
|
: undefined;
|
|
1823
3672
|
if (dominant) {
|
|
1824
3673
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
@@ -1865,7 +3714,12 @@ export function createHybridRouter(options = {}) {
|
|
|
1865
3714
|
}))
|
|
1866
3715
|
: undefined;
|
|
1867
3716
|
if (resolution) {
|
|
1868
|
-
|
|
3717
|
+
// Repair an incomplete certified nomination before generic
|
|
3718
|
+
// evidence validation. This preserves the real semantic choices
|
|
3719
|
+
// instead of turning a false certified selection into an opaque
|
|
3720
|
+
// invalid-evidence gap.
|
|
3721
|
+
const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
|
|
3722
|
+
const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures);
|
|
1869
3723
|
if (validated.ok) {
|
|
1870
3724
|
const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
|
|
1871
3725
|
// Meaning interpretation is still required for a fresh turn,
|
|
@@ -1905,11 +3759,29 @@ export function createHybridRouter(options = {}) {
|
|
|
1905
3759
|
&& !hasExplicitRankingMeasure(request.question, evidence)
|
|
1906
3760
|
&& !resolutionResolvedRanking
|
|
1907
3761
|
&& !explicitRankingSelection) {
|
|
3762
|
+
// Meaning resolution ran here. If it named the ranking
|
|
3763
|
+
// measure as the missing piece, that is its judgment and the
|
|
3764
|
+
// turn asks rather than guessing past it. Otherwise a clearly
|
|
3765
|
+
// indicated measure is assumed and BOUND through the same
|
|
3766
|
+
// resolution path an explicit selection takes — an assumption
|
|
3767
|
+
// that cannot freeze a plan is refused downstream and reaches
|
|
3768
|
+
// the reader as `blocked` with no options.
|
|
3769
|
+
const resolverFlagged = (safeResolution.missingInformation ?? [])
|
|
3770
|
+
.some((item) => /measure|metric/i.test(item));
|
|
3771
|
+
const assumedRanking = resolverFlagged
|
|
3772
|
+
? undefined
|
|
3773
|
+
: assumableRankingMeasure(request.question, clarificationCandidates);
|
|
3774
|
+
if (assumedRanking) {
|
|
3775
|
+
return {
|
|
3776
|
+
...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
|
|
3777
|
+
assumptions: [assumedRanking.assumption],
|
|
3778
|
+
};
|
|
3779
|
+
}
|
|
1908
3780
|
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
|
|
1909
3781
|
// Supplemental clarification cards carry the ranking
|
|
1910
3782
|
// measures for the requested entity, which the execution
|
|
1911
3783
|
// candidate set deliberately does not.
|
|
1912
|
-
clarificationCandidates);
|
|
3784
|
+
clarificationCandidates, !resolverFlagged);
|
|
1913
3785
|
}
|
|
1914
3786
|
const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
1915
3787
|
if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
|
|
@@ -1959,6 +3831,38 @@ export function createHybridRouter(options = {}) {
|
|
|
1959
3831
|
?? fallbackDecision;
|
|
1960
3832
|
}
|
|
1961
3833
|
}
|
|
3834
|
+
// A structured selection cannot enter the legacy/no-evidence classifier.
|
|
3835
|
+
// Without a fresh retrieval snapshot it has no proof that the server
|
|
3836
|
+
// option still exists, so fail closed before any provider dispatch or
|
|
3837
|
+
// generated-SQL fallback.
|
|
3838
|
+
if (request.selectedEvidenceId) {
|
|
3839
|
+
const unavailableEvidence = {
|
|
3840
|
+
snapshotId: 'unavailable:structured-selection',
|
|
3841
|
+
sourceFingerprint: 'unavailable:structured-selection',
|
|
3842
|
+
candidates: [],
|
|
3843
|
+
};
|
|
3844
|
+
const validation = validateStructuredClarificationSelection({
|
|
3845
|
+
request,
|
|
3846
|
+
evidence: unavailableEvidence,
|
|
3847
|
+
candidates: [],
|
|
3848
|
+
selected: undefined,
|
|
3849
|
+
});
|
|
3850
|
+
return invalidStructuredSelectionDecision({
|
|
3851
|
+
base,
|
|
3852
|
+
request,
|
|
3853
|
+
evidence: unavailableEvidence,
|
|
3854
|
+
candidates: [],
|
|
3855
|
+
selectedId: request.selectedEvidenceId,
|
|
3856
|
+
validation: validation.ok
|
|
3857
|
+
? {
|
|
3858
|
+
ok: false,
|
|
3859
|
+
requirements: validation.requirements,
|
|
3860
|
+
choiceIds: validation.choiceIds,
|
|
3861
|
+
reason: 'The selected governed identifier could not be revalidated because retrieval is unavailable.',
|
|
3862
|
+
}
|
|
3863
|
+
: validation,
|
|
3864
|
+
});
|
|
3865
|
+
}
|
|
1962
3866
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|
|
1963
3867
|
// only the ambiguous middle pays the old classification call. Importantly,
|
|
1964
3868
|
// load the catalog context before the model may choose general knowledge.
|