@duckcodeailabs/dql-agent 1.14.1 → 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 +9 -1
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +199 -17
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-gates.d.ts.map +1 -1
- package/dist/agent-run-gates.js +12 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +48 -28
- package/dist/agentic/analyst-loop.js.map +1 -1
- package/dist/agentic/research/synthesis.d.ts +4 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -1
- package/dist/agentic/research/synthesis.js +23 -8
- package/dist/agentic/research/synthesis.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +3 -2
- package/dist/agentic/research-agent.js.map +1 -1
- package/dist/agentic/sql-authorization.d.ts.map +1 -1
- package/dist/agentic/sql-authorization.js +230 -2
- package/dist/agentic/sql-authorization.js.map +1 -1
- 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 +302 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +564 -0
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +48 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +679 -85
- 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/conversation/snapshot.d.ts +20 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +30 -1
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
- package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
- package/dist/fixtures/ask-ai-office-shaped.js +94 -0
- package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +28 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +30 -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 +18 -0
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +250 -38
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +57 -11
- 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 +261 -47
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +23 -1
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +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 +4 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1796 -125
- 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
|
@@ -16,9 +16,10 @@ import { assumeDominantCandidate } from './agentic/assumptions.js';
|
|
|
16
16
|
* question never pays twice.
|
|
17
17
|
*/
|
|
18
18
|
import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
|
|
19
|
-
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
19
|
+
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
20
20
|
import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
|
|
21
21
|
import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
|
|
22
|
+
import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
|
|
22
23
|
import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
|
|
23
24
|
import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
|
|
24
25
|
import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
|
|
@@ -414,13 +415,871 @@ function retrievalTrace(evidence, candidates) {
|
|
|
414
415
|
candidateIds: candidates.map((candidate) => candidate.id),
|
|
415
416
|
};
|
|
416
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
|
+
}
|
|
417
1268
|
function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
|
|
418
|
-
|
|
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);
|
|
419
1274
|
if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
|
|
420
|
-
const
|
|
1275
|
+
const sourceFrame = resolution.analyticalFrame;
|
|
421
1276
|
const bindingPlan = buildResolvedAnalyticalPlan({
|
|
422
1277
|
question,
|
|
423
|
-
|
|
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,
|
|
424
1283
|
evidence,
|
|
425
1284
|
candidates,
|
|
426
1285
|
mode,
|
|
@@ -445,6 +1304,17 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
445
1304
|
const analytical = routedResolution.questionType === "diagnosis" ||
|
|
446
1305
|
routedResolution.questionType === "research";
|
|
447
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;
|
|
448
1318
|
return {
|
|
449
1319
|
...base,
|
|
450
1320
|
action: needsClarification
|
|
@@ -465,6 +1335,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
465
1335
|
depth: analytical ? "deep" : "quick",
|
|
466
1336
|
meaningResolution: routedResolution,
|
|
467
1337
|
resolvedAnalyticalPlan,
|
|
1338
|
+
analyticalCascadeDecision,
|
|
468
1339
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
469
1340
|
requiresClarification: needsClarification,
|
|
470
1341
|
...(terminallyBlocked
|
|
@@ -478,6 +1349,7 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
478
1349
|
: 'ANALYTICAL_MODELING_GAP',
|
|
479
1350
|
message: reconciliation.reason,
|
|
480
1351
|
candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
|
|
1352
|
+
...(terminalGap ? { gap: terminalGap } : {}),
|
|
481
1353
|
},
|
|
482
1354
|
}
|
|
483
1355
|
: {}),
|
|
@@ -491,6 +1363,151 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
491
1363
|
: {}),
|
|
492
1364
|
};
|
|
493
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
|
+
}
|
|
494
1511
|
/**
|
|
495
1512
|
* The immutable RAP is the final routing authority. Meaning may nominate an
|
|
496
1513
|
* execution route, but cannot leave the router claiming an answer after the
|
|
@@ -510,6 +1527,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
510
1527
|
].filter(({ binding }) => binding.status !== 'resolved');
|
|
511
1528
|
const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
|
|
512
1529
|
const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
|
|
1530
|
+
const clarificationRequirements = clarificationRequirementsForResolution(resolution);
|
|
513
1531
|
if (plan.resolutionFailure?.outcome === 'policy_blocked') {
|
|
514
1532
|
return {
|
|
515
1533
|
outcome: 'policy_blocked',
|
|
@@ -526,9 +1544,19 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
526
1544
|
const optionIds = qualifiedChoiceIds.length > 0
|
|
527
1545
|
? qualifiedChoiceIds
|
|
528
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);
|
|
529
1551
|
const options = optionIds.length > 0
|
|
530
|
-
? clarificationOptionsForQualifiedIds(optionIds, candidates)
|
|
531
|
-
: 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
|
+
}
|
|
532
1560
|
const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
|
|
533
1561
|
const question = routedClarificationQuestion(resolution, bindings, options);
|
|
534
1562
|
return {
|
|
@@ -539,11 +1567,18 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
539
1567
|
};
|
|
540
1568
|
}
|
|
541
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
|
+
}
|
|
542
1577
|
return {
|
|
543
1578
|
outcome: 'clarify',
|
|
544
1579
|
reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
|
|
545
1580
|
question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
|
|
546
|
-
options
|
|
1581
|
+
options,
|
|
547
1582
|
};
|
|
548
1583
|
}
|
|
549
1584
|
return {
|
|
@@ -551,12 +1586,85 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
|
551
1586
|
reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
|
|
552
1587
|
};
|
|
553
1588
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 }) => {
|
|
557
1665
|
return {
|
|
558
|
-
id,
|
|
559
|
-
label: candidate?.name
|
|
1666
|
+
id: candidate?.id ?? id,
|
|
1667
|
+
label: candidate?.name || qualifiedIdLabel(candidate?.qualifiedId ?? id),
|
|
560
1668
|
...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
|
|
561
1669
|
kind: candidate?.kind ?? 'semantic_member',
|
|
562
1670
|
};
|
|
@@ -578,27 +1686,122 @@ function routedClarificationQuestion(resolution, bindings, options) {
|
|
|
578
1686
|
return resolution.clarifyingQuestion
|
|
579
1687
|
?? `Which governed binding should I use before running this query?`;
|
|
580
1688
|
}
|
|
581
|
-
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
|
|
582
|
-
|
|
583
|
-
|
|
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
|
+
}
|
|
584
1792
|
return {
|
|
585
1793
|
...base,
|
|
586
|
-
action: '
|
|
587
|
-
confidence:
|
|
1794
|
+
action: 'answer',
|
|
1795
|
+
confidence: 0.55,
|
|
588
1796
|
reason: message,
|
|
589
1797
|
source: 'heuristic',
|
|
590
1798
|
category: 'data_lookup',
|
|
591
1799
|
depth: 'quick',
|
|
592
1800
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
593
1801
|
requiresClarification: false,
|
|
594
|
-
terminalOutcome: {
|
|
595
|
-
kind: 'modeling_gap',
|
|
596
|
-
code: 'ANALYTICAL_MODELING_GAP',
|
|
597
|
-
message,
|
|
598
|
-
candidateIds: [selectedId],
|
|
599
|
-
},
|
|
600
1802
|
resolvedAnalyticalPlan: undefined,
|
|
601
|
-
meaningResolution
|
|
1803
|
+
meaningResolution,
|
|
1804
|
+
analyticalCascadeDecision,
|
|
602
1805
|
};
|
|
603
1806
|
}
|
|
604
1807
|
function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
@@ -713,14 +1916,15 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
713
1916
|
"The requested analytical tuple is not executable from the current governed model."),
|
|
714
1917
|
};
|
|
715
1918
|
}
|
|
716
|
-
function buildClarificationOptions(candidates) {
|
|
717
|
-
const
|
|
1919
|
+
function buildClarificationOptions(candidates, requirements) {
|
|
1920
|
+
const roleCompatible = compatibleClarificationCandidates(candidates, requirements);
|
|
1921
|
+
const governed = roleCompatible.filter((candidate) => candidate.compatibility !== "incompatible" &&
|
|
718
1922
|
(candidate.kind === "certified_block" ||
|
|
719
1923
|
candidate.kind === "semantic_metric" ||
|
|
720
1924
|
candidate.kind === "semantic_member"));
|
|
721
1925
|
const pool = governed.length > 1
|
|
722
1926
|
? governed
|
|
723
|
-
:
|
|
1927
|
+
: roleCompatible;
|
|
724
1928
|
const chosen = pool.slice(0, 3);
|
|
725
1929
|
// Two candidates can legitimately share a display name (a dbt model and its
|
|
726
1930
|
// MetricFlow measure are both "customers"). Rendering both as "customers"
|
|
@@ -743,6 +1947,301 @@ function buildClarificationOptions(candidates) {
|
|
|
743
1947
|
};
|
|
744
1948
|
});
|
|
745
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
|
+
}
|
|
746
2245
|
function candidateKindLabel(kind) {
|
|
747
2246
|
if (kind === 'certified_block')
|
|
748
2247
|
return 'certified block';
|
|
@@ -931,7 +2430,22 @@ function directResolution(request, evidence, candidate, candidates) {
|
|
|
931
2430
|
metricCandidates,
|
|
932
2431
|
candidates,
|
|
933
2432
|
});
|
|
934
|
-
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;
|
|
935
2449
|
const memberCandidates = candidates.filter((item) => {
|
|
936
2450
|
if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
|
|
937
2451
|
return false;
|
|
@@ -1071,7 +2585,8 @@ mayAssumeInterpretation = true) {
|
|
|
1071
2585
|
}
|
|
1072
2586
|
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
|
|
1073
2587
|
&& candidate.compatibility !== "incompatible"
|
|
1074
|
-
&& rankingCandidates.includes(candidate)
|
|
2588
|
+
&& rankingCandidates.includes(candidate)
|
|
2589
|
+
&& candidateMayTerminateCertifiedForMeasures(candidate, evidence.parsedIntent?.measures ?? []));
|
|
1075
2590
|
if (exactCompatible.length === 1 &&
|
|
1076
2591
|
!hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
1077
2592
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
|
|
@@ -1085,7 +2600,7 @@ mayAssumeInterpretation = true) {
|
|
|
1085
2600
|
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
1086
2601
|
// were offered as three competing "meanings" of the same number.
|
|
1087
2602
|
const best = mayAssumeInterpretation
|
|
1088
|
-
? bestGovernedInterpretation(request.question, rankingCandidates)
|
|
2603
|
+
? bestGovernedInterpretation(request.question, rankingCandidates, evidence.parsedIntent?.measures ?? [])
|
|
1089
2604
|
: undefined;
|
|
1090
2605
|
if (best) {
|
|
1091
2606
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
@@ -1244,7 +2759,7 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
1244
2759
|
* question made ordinary lookups feel like an interrogation, and most of those
|
|
1245
2760
|
* questions had no real ambiguity behind them.
|
|
1246
2761
|
*/
|
|
1247
|
-
export function bestGovernedInterpretation(question, candidates) {
|
|
2762
|
+
export function bestGovernedInterpretation(question, candidates, requestedMeasures = []) {
|
|
1248
2763
|
// Take the best candidate that is actually executable, rather than refusing
|
|
1249
2764
|
// because the top-ranked hit happens to be a descriptive modeling entity.
|
|
1250
2765
|
// `compatibility: 'unknown'` is common for governed objects that execute
|
|
@@ -1254,7 +2769,9 @@ export function bestGovernedInterpretation(question, candidates) {
|
|
|
1254
2769
|
return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
|
|
1255
2770
|
&& (candidate.kind === 'certified_block'
|
|
1256
2771
|
|| candidate.kind === 'semantic_metric'
|
|
1257
|
-
|| candidate.kind === 'semantic_member')
|
|
2772
|
+
|| candidate.kind === 'semantic_member')
|
|
2773
|
+
&& (candidate.kind !== 'certified_block'
|
|
2774
|
+
|| certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures)));
|
|
1258
2775
|
}
|
|
1259
2776
|
/**
|
|
1260
2777
|
* Retrieval may nominate qualified candidates, but only the resolved plan may
|
|
@@ -1272,8 +2789,69 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1272
2789
|
candidateCount: 0,
|
|
1273
2790
|
candidateIds: [],
|
|
1274
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
|
+
};
|
|
1275
2822
|
if (eligible.length > 1) {
|
|
1276
|
-
|
|
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
|
+
}
|
|
1277
2855
|
return {
|
|
1278
2856
|
...base,
|
|
1279
2857
|
action: 'clarify',
|
|
@@ -1284,7 +2862,7 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1284
2862
|
reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
|
|
1285
2863
|
requiresClarification: true,
|
|
1286
2864
|
clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
|
|
1287
|
-
clarificationOptions: buildClarificationOptions(choices),
|
|
2865
|
+
clarificationOptions: buildClarificationOptions(choices, clarificationRequirements),
|
|
1288
2866
|
retrievalEvidence: trace,
|
|
1289
2867
|
resolvedAnalyticalPlan: undefined,
|
|
1290
2868
|
meaningResolution: undefined,
|
|
@@ -1294,6 +2872,9 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1294
2872
|
const message = candidateIds.length === 1
|
|
1295
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.`
|
|
1296
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;
|
|
1297
2878
|
return {
|
|
1298
2879
|
...base,
|
|
1299
2880
|
action: 'block',
|
|
@@ -1321,6 +2902,10 @@ function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], quest
|
|
|
1321
2902
|
*/
|
|
1322
2903
|
function deterministicPrePlanClarification(request, base, evidence, candidates) {
|
|
1323
2904
|
const asksForRanking = questionTypeFromText(request.question) === 'ranking';
|
|
2905
|
+
const requirements = buildAnalyticalRequirementSet({
|
|
2906
|
+
question: request.question,
|
|
2907
|
+
parsedIntent: evidence.parsedIntent,
|
|
2908
|
+
});
|
|
1324
2909
|
const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
|
|
1325
2910
|
.map(normalizeMetricPhrase)
|
|
1326
2911
|
.filter(Boolean);
|
|
@@ -1329,7 +2914,13 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1329
2914
|
|| hasStrongQualifiedMetricEvidence(candidates)
|
|
1330
2915
|
|| hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
|
|
1331
2916
|
const retrievalEvidence = retrievalTrace(evidence, candidates);
|
|
1332
|
-
|
|
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));
|
|
1333
2924
|
const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
|
|
1334
2925
|
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
|
|
1335
2926
|
? [normalizeMetricPhrase(filter.field)]
|
|
@@ -1347,9 +2938,29 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1347
2938
|
.slice(0, 3);
|
|
1348
2939
|
if (alternatives.length === 0) {
|
|
1349
2940
|
// Bare rankings need a measure choice, not a dimension gap. Retain the
|
|
1350
|
-
// no-options escape hatch
|
|
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.
|
|
1351
2947
|
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1352
|
-
|
|
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
|
+
});
|
|
1353
2964
|
}
|
|
1354
2965
|
// Parsed-intent hints can include inherited/default dimensions that the
|
|
1355
2966
|
// user never asked for. Only turn a missing field into a product-facing
|
|
@@ -1359,47 +2970,19 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1359
2970
|
if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
|
|
1360
2971
|
return undefined;
|
|
1361
2972
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
code: 'ANALYTICAL_MODELING_GAP',
|
|
1376
|
-
message,
|
|
1377
|
-
candidateIds: [],
|
|
1378
|
-
},
|
|
1379
|
-
meaningResolution: {
|
|
1380
|
-
interpretedQuestion: request.question,
|
|
1381
|
-
questionType: questionTypeFromText(request.question),
|
|
1382
|
-
selectedConceptIds: [],
|
|
1383
|
-
queryIntent: {
|
|
1384
|
-
...defaultQueryIntent(evidence),
|
|
1385
|
-
measures: evidence.parsedIntent?.measures ?? [],
|
|
1386
|
-
dimensions: evidence.parsedIntent?.dimensions ?? [],
|
|
1387
|
-
filters: evidence.parsedIntent?.filters ?? [],
|
|
1388
|
-
},
|
|
1389
|
-
rejectedCandidates: [],
|
|
1390
|
-
confidence: 'low',
|
|
1391
|
-
missingInformation: [message],
|
|
1392
|
-
recommendedRoute: 'clarify',
|
|
1393
|
-
compatibilityOutcome: 'modeling_gap',
|
|
1394
|
-
compatibilityFailures: missingDimensions.map((dimension) => ({
|
|
1395
|
-
code: 'MISSING_DIMENSION',
|
|
1396
|
-
field: dimension,
|
|
1397
|
-
message: `${dimension} is not modeled.`,
|
|
1398
|
-
candidateIds: [],
|
|
1399
|
-
})),
|
|
1400
|
-
},
|
|
1401
|
-
resolvedAnalyticalPlan: undefined,
|
|
1402
|
-
};
|
|
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
|
+
});
|
|
1403
2986
|
}
|
|
1404
2987
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1405
2988
|
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
@@ -1650,7 +3233,11 @@ function candidateProvesMetricTerm(candidate, requested) {
|
|
|
1650
3233
|
if (candidate.compatibility === 'incompatible')
|
|
1651
3234
|
return false;
|
|
1652
3235
|
if (candidate.kind === 'certified_block') {
|
|
1653
|
-
|
|
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]);
|
|
1654
3241
|
}
|
|
1655
3242
|
if (candidate.kind !== 'semantic_metric')
|
|
1656
3243
|
return false;
|
|
@@ -1821,9 +3408,10 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
|
|
|
1821
3408
|
* against a uniquely compatible certified block; a second compatible block or
|
|
1822
3409
|
* metric still goes to the bounded AI resolver.
|
|
1823
3410
|
*/
|
|
1824
|
-
function dominantCompatibleGovernedCandidate(candidates) {
|
|
3411
|
+
function dominantCompatibleGovernedCandidate(candidates, requestedMeasures = []) {
|
|
1825
3412
|
const compatible = candidates.filter((candidate) => candidate.compatibility === "compatible"
|
|
1826
|
-
&& (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));
|
|
1827
3415
|
if (compatible.length === 0)
|
|
1828
3416
|
return undefined;
|
|
1829
3417
|
const best = compatible[0];
|
|
@@ -1845,13 +3433,19 @@ function dominantCompatibleGovernedCandidate(candidates) {
|
|
|
1845
3433
|
const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
|
|
1846
3434
|
return hasExecutableCompetitor ? undefined : best;
|
|
1847
3435
|
}
|
|
1848
|
-
function authoritativeExactCertifiedExample(candidates) {
|
|
3436
|
+
function authoritativeExactCertifiedExample(candidates, requestedMeasures = []) {
|
|
1849
3437
|
const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
|
|
1850
3438
|
&& candidate.exactMatch
|
|
1851
3439
|
&& candidate.compatibility === 'compatible'
|
|
1852
|
-
&& candidate.analyticalFitClass === 'exact'
|
|
3440
|
+
&& candidate.analyticalFitClass === 'exact'
|
|
3441
|
+
&& certifiedCandidateExplicitlyCoversMeasures(candidate, requestedMeasures));
|
|
1853
3442
|
return exact.length === 1 ? exact[0] : undefined;
|
|
1854
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
|
+
}
|
|
1855
3449
|
function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
|
|
1856
3450
|
if (!base.followsUp)
|
|
1857
3451
|
return false;
|
|
@@ -1922,7 +3516,7 @@ export function createHybridRouter(options = {}) {
|
|
|
1922
3516
|
const base = deterministic(request);
|
|
1923
3517
|
// The only pre-retrieval exits are deliberately narrow and unambiguously
|
|
1924
3518
|
// non-analytical. App composition has its own catalog-grounded workflow.
|
|
1925
|
-
if (base.action === "converse" || base.action === "compose_app") {
|
|
3519
|
+
if (!request.selectedEvidenceId && (base.action === "converse" || base.action === "compose_app")) {
|
|
1926
3520
|
return { ...base, source: base.source ?? "heuristic" };
|
|
1927
3521
|
}
|
|
1928
3522
|
const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
|
|
@@ -1941,6 +3535,12 @@ export function createHybridRouter(options = {}) {
|
|
|
1941
3535
|
}
|
|
1942
3536
|
}
|
|
1943
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);
|
|
1944
3544
|
evidence = {
|
|
1945
3545
|
...evidence,
|
|
1946
3546
|
candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
|
|
@@ -1951,31 +3551,69 @@ export function createHybridRouter(options = {}) {
|
|
|
1951
3551
|
]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
|
|
1952
3552
|
: {}),
|
|
1953
3553
|
};
|
|
1954
|
-
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1958
|
-
|
|
1959
|
-
//
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
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.
|
|
1963
3571
|
const selectedEvidence = request.selectedEvidenceId
|
|
1964
|
-
?
|
|
1965
|
-
.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
|
|
3572
|
+
? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
|
|
1966
3573
|
: undefined;
|
|
3574
|
+
const structuredSelection = request.selectedEvidenceId
|
|
3575
|
+
? validateStructuredClarificationSelection({
|
|
3576
|
+
request,
|
|
3577
|
+
evidence,
|
|
3578
|
+
candidates: clarificationCandidates,
|
|
3579
|
+
selected: selectedEvidence,
|
|
3580
|
+
})
|
|
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
|
+
}
|
|
1967
3592
|
if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
|
|
1968
3593
|
candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
|
|
1969
|
-
.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
|
+
}
|
|
1970
3609
|
}
|
|
1971
3610
|
if (candidates.length > 0) {
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
const
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
].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;
|
|
1979
3617
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
1980
3618
|
const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
|
|
1981
3619
|
|| /@(metric|block|model|table|column)\(/i.test(request.question)));
|
|
@@ -2001,8 +3639,19 @@ export function createHybridRouter(options = {}) {
|
|
|
2001
3639
|
return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
|
|
2002
3640
|
}
|
|
2003
3641
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
2004
|
-
|
|
2005
|
-
|
|
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)
|
|
2006
3655
|
: decision;
|
|
2007
3656
|
}
|
|
2008
3657
|
const multiMetricPrimary = !shouldUseMeaningCall
|
|
@@ -2011,29 +3660,14 @@ export function createHybridRouter(options = {}) {
|
|
|
2011
3660
|
if (multiMetricPrimary) {
|
|
2012
3661
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
2013
3662
|
}
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
// when the call was already being skipped, which meant a perfect
|
|
2018
|
-
// certified match — the cheapest, most certain answer DQL can give —
|
|
2019
|
-
// was also one of the slowest.
|
|
2020
|
-
//
|
|
2021
|
-
// The precondition is deliberately the strictest one available:
|
|
2022
|
-
// exactly one certified block, compatible, whose AUTHORED EXAMPLE the
|
|
2023
|
-
// question matches. `meaning-evidence.ts` sets that flag only for an
|
|
2024
|
-
// authored-example fit and explicitly notes it is the one signal that
|
|
2025
|
-
// may grant this shortcut — lexical equality to a block name does not
|
|
2026
|
-
// qualify.
|
|
2027
|
-
const authoredExample = authoritativeExactCertifiedExample(candidates);
|
|
2028
|
-
if (authoredExample) {
|
|
2029
|
-
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
2030
|
-
}
|
|
2031
|
-
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
|
|
3663
|
+
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch
|
|
3664
|
+
&& candidate.compatibility !== "incompatible"
|
|
3665
|
+
&& candidateMayTerminateCertifiedForMeasures(candidate, evidence?.parsedIntent?.measures ?? [])) : [];
|
|
2032
3666
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
2033
3667
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
2034
3668
|
}
|
|
2035
3669
|
const dominant = !shouldUseMeaningCall
|
|
2036
|
-
? dominantCompatibleGovernedCandidate(candidates)
|
|
3670
|
+
? dominantCompatibleGovernedCandidate(candidates, evidence.parsedIntent?.measures ?? [])
|
|
2037
3671
|
: undefined;
|
|
2038
3672
|
if (dominant) {
|
|
2039
3673
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
@@ -2080,7 +3714,12 @@ export function createHybridRouter(options = {}) {
|
|
|
2080
3714
|
}))
|
|
2081
3715
|
: undefined;
|
|
2082
3716
|
if (resolution) {
|
|
2083
|
-
|
|
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);
|
|
2084
3723
|
if (validated.ok) {
|
|
2085
3724
|
const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
|
|
2086
3725
|
// Meaning interpretation is still required for a fresh turn,
|
|
@@ -2192,6 +3831,38 @@ export function createHybridRouter(options = {}) {
|
|
|
2192
3831
|
?? fallbackDecision;
|
|
2193
3832
|
}
|
|
2194
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
|
+
}
|
|
2195
3866
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|
|
2196
3867
|
// only the ambiguous middle pays the old classification call. Importantly,
|
|
2197
3868
|
// load the catalog context before the model may choose general knowledge.
|