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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/agent-run-engine.d.ts +71 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +1048 -31
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +12 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts +6 -0
  9. package/dist/agent-run-planner.d.ts.map +1 -1
  10. package/dist/agent-run-planner.js +2 -0
  11. package/dist/agent-run-planner.js.map +1 -1
  12. package/dist/agent-run-store.d.ts.map +1 -1
  13. package/dist/agent-run-store.js +48 -3
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +7 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +211 -28
  18. package/dist/agentic/analyst-loop.js.map +1 -1
  19. package/dist/agentic/assumptions.d.ts +1 -1
  20. package/dist/agentic/assumptions.d.ts.map +1 -1
  21. package/dist/agentic/assumptions.js.map +1 -1
  22. package/dist/agentic/research/synthesis.d.ts +4 -0
  23. package/dist/agentic/research/synthesis.d.ts.map +1 -1
  24. package/dist/agentic/research/synthesis.js +23 -8
  25. package/dist/agentic/research/synthesis.js.map +1 -1
  26. package/dist/agentic/research-agent.d.ts.map +1 -1
  27. package/dist/agentic/research-agent.js +13 -3
  28. package/dist/agentic/research-agent.js.map +1 -1
  29. package/dist/agentic/sql-authorization.d.ts +8 -0
  30. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  31. package/dist/agentic/sql-authorization.js +233 -2
  32. package/dist/agentic/sql-authorization.js.map +1 -1
  33. package/dist/analytical-compatibility.d.ts.map +1 -1
  34. package/dist/analytical-compatibility.js +8 -1
  35. package/dist/analytical-compatibility.js.map +1 -1
  36. package/dist/analytical-failure-repair.d.ts.map +1 -1
  37. package/dist/analytical-failure-repair.js +6 -2
  38. package/dist/analytical-failure-repair.js.map +1 -1
  39. package/dist/analytical-frame.d.ts +23 -1
  40. package/dist/analytical-frame.d.ts.map +1 -1
  41. package/dist/analytical-frame.js +332 -17
  42. package/dist/analytical-frame.js.map +1 -1
  43. package/dist/analytical-orchestration.d.ts +613 -0
  44. package/dist/analytical-orchestration.d.ts.map +1 -1
  45. package/dist/analytical-orchestration.js +1007 -0
  46. package/dist/analytical-orchestration.js.map +1 -1
  47. package/dist/answer-loop.d.ts +110 -6
  48. package/dist/answer-loop.d.ts.map +1 -1
  49. package/dist/answer-loop.js +1180 -125
  50. package/dist/answer-loop.js.map +1 -1
  51. package/dist/answer-shape.d.ts +32 -1
  52. package/dist/answer-shape.d.ts.map +1 -1
  53. package/dist/answer-shape.js +49 -3
  54. package/dist/answer-shape.js.map +1 -1
  55. package/dist/ask-observability/index.d.ts +11 -0
  56. package/dist/ask-observability/index.d.ts.map +1 -0
  57. package/dist/ask-observability/index.js +7 -0
  58. package/dist/ask-observability/index.js.map +1 -0
  59. package/dist/ask-observability/instrumentation.d.ts +35 -0
  60. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  61. package/dist/ask-observability/instrumentation.js +472 -0
  62. package/dist/ask-observability/instrumentation.js.map +1 -0
  63. package/dist/ask-observability/observer.d.ts +62 -0
  64. package/dist/ask-observability/observer.d.ts.map +1 -0
  65. package/dist/ask-observability/observer.js +537 -0
  66. package/dist/ask-observability/observer.js.map +1 -0
  67. package/dist/ask-observability/portable.d.ts +98 -0
  68. package/dist/ask-observability/portable.d.ts.map +1 -0
  69. package/dist/ask-observability/portable.js +527 -0
  70. package/dist/ask-observability/portable.js.map +1 -0
  71. package/dist/ask-observability/store.d.ts +130 -0
  72. package/dist/ask-observability/store.d.ts.map +1 -0
  73. package/dist/ask-observability/store.js +1114 -0
  74. package/dist/ask-observability/store.js.map +1 -0
  75. package/dist/ask-observability/types.d.ts +357 -0
  76. package/dist/ask-observability/types.d.ts.map +1 -0
  77. package/dist/ask-observability/types.js +2 -0
  78. package/dist/ask-observability/types.js.map +1 -0
  79. package/dist/ask-observability/utils.d.ts +21 -0
  80. package/dist/ask-observability/utils.d.ts.map +1 -0
  81. package/dist/ask-observability/utils.js +102 -0
  82. package/dist/ask-observability/utils.js.map +1 -0
  83. package/dist/conversation/snapshot.d.ts +20 -0
  84. package/dist/conversation/snapshot.d.ts.map +1 -1
  85. package/dist/conversation/snapshot.js +30 -1
  86. package/dist/conversation/snapshot.js.map +1 -1
  87. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  88. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  89. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  90. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  91. package/dist/generated-analytical-proposal.d.ts +37 -1
  92. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  93. package/dist/generated-analytical-proposal.js +211 -3
  94. package/dist/generated-analytical-proposal.js.map +1 -1
  95. package/dist/governed-relational-compiler.d.ts +15 -0
  96. package/dist/governed-relational-compiler.d.ts.map +1 -1
  97. package/dist/governed-relational-compiler.js +99 -1
  98. package/dist/governed-relational-compiler.js.map +1 -1
  99. package/dist/index.d.ts +11 -9
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +7 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/intent-controller.d.ts +38 -0
  104. package/dist/intent-controller.d.ts.map +1 -1
  105. package/dist/intent-controller.js +31 -1
  106. package/dist/intent-controller.js.map +1 -1
  107. package/dist/meaning-resolution.d.ts +141 -2
  108. package/dist/meaning-resolution.d.ts.map +1 -1
  109. package/dist/meaning-resolution.js +444 -30
  110. package/dist/meaning-resolution.js.map +1 -1
  111. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  112. package/dist/metadata/analysis-planner.js +144 -7
  113. package/dist/metadata/analysis-planner.js.map +1 -1
  114. package/dist/metadata/block-fit.d.ts +18 -0
  115. package/dist/metadata/block-fit.d.ts.map +1 -1
  116. package/dist/metadata/block-fit.js +250 -38
  117. package/dist/metadata/block-fit.js.map +1 -1
  118. package/dist/metadata/catalog.d.ts.map +1 -1
  119. package/dist/metadata/catalog.js +58 -12
  120. package/dist/metadata/catalog.js.map +1 -1
  121. package/dist/metadata/meaning-evidence.d.ts +19 -3
  122. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  123. package/dist/metadata/meaning-evidence.js +518 -54
  124. package/dist/metadata/meaning-evidence.js.map +1 -1
  125. package/dist/plan-execution-adapter.d.ts +9 -1
  126. package/dist/plan-execution-adapter.d.ts.map +1 -1
  127. package/dist/plan-execution-adapter.js +93 -13
  128. package/dist/plan-execution-adapter.js.map +1 -1
  129. package/dist/provider-egress.d.ts +24 -10
  130. package/dist/provider-egress.d.ts.map +1 -1
  131. package/dist/provider-egress.js +22 -17
  132. package/dist/provider-egress.js.map +1 -1
  133. package/dist/providers/claude.d.ts.map +1 -1
  134. package/dist/providers/claude.js +10 -9
  135. package/dist/providers/claude.js.map +1 -1
  136. package/dist/providers/dispatch.d.ts +27 -1
  137. package/dist/providers/dispatch.d.ts.map +1 -1
  138. package/dist/providers/dispatch.js +63 -1
  139. package/dist/providers/dispatch.js.map +1 -1
  140. package/dist/providers/gemini.js +10 -10
  141. package/dist/providers/gemini.js.map +1 -1
  142. package/dist/providers/index.d.ts +2 -2
  143. package/dist/providers/index.d.ts.map +1 -1
  144. package/dist/providers/index.js +1 -1
  145. package/dist/providers/index.js.map +1 -1
  146. package/dist/providers/ollama.js +15 -15
  147. package/dist/providers/ollama.js.map +1 -1
  148. package/dist/providers/openai.js +22 -22
  149. package/dist/providers/openai.js.map +1 -1
  150. package/dist/providers/types.d.ts +52 -0
  151. package/dist/providers/types.d.ts.map +1 -1
  152. package/dist/providers/types.js.map +1 -1
  153. package/dist/relationship-proof.d.ts +81 -0
  154. package/dist/relationship-proof.d.ts.map +1 -1
  155. package/dist/relationship-proof.js +228 -4
  156. package/dist/relationship-proof.js.map +1 -1
  157. package/dist/research-loop.d.ts.map +1 -1
  158. package/dist/research-loop.js +23 -1
  159. package/dist/research-loop.js.map +1 -1
  160. package/dist/resolved-analytical-plan.d.ts +22 -1
  161. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  162. package/dist/resolved-analytical-plan.js +773 -49
  163. package/dist/resolved-analytical-plan.js.map +1 -1
  164. package/dist/router.d.ts +14 -4
  165. package/dist/router.d.ts.map +1 -1
  166. package/dist/router.js +3668 -425
  167. package/dist/router.js.map +1 -1
  168. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  169. package/dist/semantic-bridge/compose.js +23 -4
  170. package/dist/semantic-bridge/compose.js.map +1 -1
  171. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  172. package/dist/semantic-bridge/member-select.js +39 -21
  173. package/dist/semantic-bridge/member-select.js.map +1 -1
  174. package/package.json +5 -5
@@ -8,7 +8,9 @@
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
10
  import { normalizeMetricCapabilityContract, } from "@duckcodeailabs/dql-core";
11
- import { buildResolvedRelationshipProofsV1 } from './relationship-proof.js';
11
+ import { certifiedCandidateDeclaredDimensionOutput, certifiedCandidateDeclaredMeasureOutput, certifiedCandidateExplicitlyCoversMeasures, } from './meaning-resolution.js';
12
+ import { currentQuestionGroundedParsedIntent } from './analytical-orchestration.js';
13
+ import { buildResolvedRelationshipProofsV1, buildGovernedRelationshipSafetyProofsV1, governedCapabilityDimensionHasFreshAutomaticRelationshipProofV1, semanticDimensionUsesExactAdapterGrouping, } from './relationship-proof.js';
12
14
  export function buildResolvedAnalyticalPlan(input) {
13
15
  const byLegacyId = new Map(input.candidates.map((candidate) => [candidate.id, candidate]));
14
16
  const selectedCandidates = input.resolution.selectedConceptIds
@@ -21,7 +23,12 @@ export function buildResolvedAnalyticalPlan(input) {
21
23
  // complete tuple. Bind its declared inputs/outputs as one execution
22
24
  // authority instead of mixing in the semantic concept used to select it.
23
25
  ? [executionCandidate]
24
- : resolutionUsesRelationalEvidence(input.resolution)
26
+ // A governed-relational route may bind through its certified relationship
27
+ // closure. Exploratory is different: the router has already selected a
28
+ // minimal qualified physical closure before plan freeze. Re-opening the
29
+ // whole snapshot here lets unrelated columns re-enter an exact output
30
+ // contract and turns `order_id`/`product_id` into false ambiguities.
31
+ : input.resolution.recommendedRoute === 'governed_sql'
25
32
  ? input.candidates
26
33
  : selectedCandidates.length > 0
27
34
  ? selectedCandidates
@@ -29,7 +36,6 @@ export function buildResolvedAnalyticalPlan(input) {
29
36
  ? [executionCandidate]
30
37
  : input.candidates;
31
38
  const canonicalId = (candidate) => candidate.qualifiedId ?? candidate.id;
32
- const selectedConceptIds = selectedCandidates.map(canonicalId);
33
39
  const executionId = executionCandidate ? canonicalId(executionCandidate) : undefined;
34
40
  const measures = input.resolution.queryIntent.measures.length > 0
35
41
  ? input.resolution.queryIntent.measures.map((requested) => bindRequestedMember(requested, bindingCandidates, 'measure', input.question))
@@ -49,7 +55,134 @@ export function buildResolvedAnalyticalPlan(input) {
49
55
  ? input.candidates
50
56
  : bindingCandidates;
51
57
  const selectedCapability = normalizeMetricCapabilityContract(executionCandidate?.analyticalCapability);
58
+ const selectedCertifiedBlock = input.resolution.recommendedRoute === 'certified'
59
+ && executionCandidate?.kind === 'certified_block'
60
+ && executionCandidate.compatibility === 'compatible'
61
+ ? executionCandidate
62
+ : undefined;
63
+ // The direct router may canonicalize a literal request (for example,
64
+ // “food and drink”) to an exact block's declared output (`category`) after
65
+ // that exact block independently proved the tuple. Keep that narrow
66
+ // zero-provider projection distinct from the ordinary host requirement
67
+ // contract below; a model-selected block can never create it.
68
+ const exactCertifiedDimensionProjection = certifiedBlockCanUseExactCanonicalDimensionProjection(executionCandidate, input.resolution);
69
+ const hostEntityDisplayTerms = new Set(input.resolution.hostRequirementSeed?.requirements.entityDisplayTerms
70
+ .map(normalize)
71
+ .filter(Boolean) ?? []);
72
+ // A certified route is not a semantic capability route with a green badge.
73
+ // Every host-owned grouping/display/output term must be declared by the
74
+ // selected block itself before that block can freeze. The optional normalized
75
+ // capability can corroborate the selected block, but it may not substitute a
76
+ // contextual semantic field for a block output the block never promised.
77
+ const hostCertifiedProjectionTerms = new Set([
78
+ ...(input.resolution.hostRequirementSeed?.queryIntent.dimensions ?? []),
79
+ ...(input.resolution.hostRequirementSeed?.requirements.entityDisplayTerms ?? []),
80
+ ...(input.resolution.hostRequirementSeed?.requirements.outputTerms ?? []),
81
+ ].map(normalize).filter(Boolean));
82
+ const bindCertifiedDeclaredDimension = (requested) => {
83
+ const outputName = selectedCertifiedBlock
84
+ ? certifiedCandidateDeclaredDimensionOutput(selectedCertifiedBlock, requested)
85
+ : undefined;
86
+ if (!selectedCertifiedBlock || !outputName) {
87
+ return { requested, status: 'unresolved', candidateIds: [] };
88
+ }
89
+ const candidateId = canonicalId(selectedCertifiedBlock);
90
+ return {
91
+ requested,
92
+ qualifiedId: candidateId,
93
+ outputName,
94
+ status: 'resolved',
95
+ candidateIds: [candidateId],
96
+ };
97
+ };
98
+ const bindCertifiedDeclaredOutput = (requested) => {
99
+ const outputName = selectedCertifiedBlock
100
+ ? certifiedCandidateDeclaredDimensionOutput(selectedCertifiedBlock, requested)
101
+ ?? certifiedCandidateDeclaredMeasureOutput(selectedCertifiedBlock, requested)
102
+ : undefined;
103
+ if (!selectedCertifiedBlock || !outputName) {
104
+ return { requested, status: 'unresolved', candidateIds: [] };
105
+ }
106
+ const candidateId = canonicalId(selectedCertifiedBlock);
107
+ return {
108
+ requested,
109
+ qualifiedId: candidateId,
110
+ outputName,
111
+ status: 'resolved',
112
+ candidateIds: [candidateId],
113
+ };
114
+ };
52
115
  const bindDimension = (requested, roles) => {
116
+ const normalizedRequested = normalize(requested);
117
+ if (selectedCertifiedBlock && hostCertifiedProjectionTerms.has(normalizedRequested)) {
118
+ return bindCertifiedDeclaredDimension(requested);
119
+ }
120
+ // The candidate-ID meaning protocol cannot author a frame, but the host
121
+ // has already bound a V2 frame from the immutable requirement seed. A
122
+ // seeded rank/display requirement may use the human phrase `customer
123
+ // name` while the selected MetricFlow capability truthfully names the
124
+ // native field simply `customer`. Re-running that field through generic
125
+ // lexical scoring loses the authoritative host binding and makes an
126
+ // executable legacy capability look unmodeled. Reuse only the exact host
127
+ // frame rank entity, and only after the selected capability proves every
128
+ // required role. This is not a name-based fallback and cannot select
129
+ // Customer Type, Customer Owner, or a cross-model same-leaf field.
130
+ const hostRankEntityDimensionId = hostEntityDisplayTerms.has(normalizedRequested)
131
+ ? input.resolution.analyticalFrame?.ranking?.entityDimensionId
132
+ : undefined;
133
+ const hostRankEntityDimension = hostRankEntityDimensionId
134
+ ? selectedCapability?.dimensions.find((dimension) => dimension.dimensionId === hostRankEntityDimensionId)
135
+ : undefined;
136
+ if (hostRankEntityDimension && roles.every((role) => hostRankEntityDimension.supportedRoles.includes(role))) {
137
+ return {
138
+ requested,
139
+ qualifiedId: hostRankEntityDimension.dimensionId,
140
+ status: 'resolved',
141
+ candidateIds: [hostRankEntityDimension.dimensionId],
142
+ };
143
+ }
144
+ // The host-built V2 frame may intentionally retain a genuine native
145
+ // display-key ambiguity (for example Billing Account versus Service
146
+ // Account). Keep that exact, metric-owned choice set when RAP binds the
147
+ // same seeded phrase. Re-running it through lexical candidate matching
148
+ // would discard the qualified alternatives and turn a useful
149
+ // clarification into a false “unresolved” gap. The frame is already
150
+ // snapshot-bound; still require the selected capability to prove every
151
+ // requested role before carrying a choice forward.
152
+ const sourceAmbiguity = input.resolution.analyticalFrame?.ambiguity.find((entry) => {
153
+ const [lane, ...parts] = entry.field.split('.');
154
+ return lane === 'dimensions' && normalize(parts.join('.')) === normalizedRequested;
155
+ });
156
+ const sourceChoices = uniqueSorted((sourceAmbiguity?.candidateIds ?? []).filter((candidateId) => eligibleCapabilityDimensions(selectedCapability, roles)
157
+ .some((dimension) => dimension.dimensionId === candidateId)));
158
+ if (sourceChoices.length === 1) {
159
+ return {
160
+ requested,
161
+ qualifiedId: sourceChoices[0],
162
+ status: 'resolved',
163
+ candidateIds: sourceChoices,
164
+ };
165
+ }
166
+ if (sourceChoices.length > 1) {
167
+ return {
168
+ requested,
169
+ status: 'ambiguous',
170
+ candidateIds: sourceChoices,
171
+ };
172
+ }
173
+ // An exact certified block is its own execution authority. Its declared
174
+ // output contract may use the entity identity (`customer`) where the
175
+ // host-owned ranking seed deliberately retains the display phrase
176
+ // (`customer name`). Do not let the optional normalized semantic
177
+ // capability re-run that proven block output through lexical capability
178
+ // matching: the capability is context for the block, while the block's
179
+ // declared output is the certified proof. This remains narrow to the
180
+ // selected compatible block and cannot bind an unrelated retrieved field.
181
+ const certifiedOutput = selectedCertifiedBlock
182
+ ? certifiedCandidateDeclaredDimensionOutput(selectedCertifiedBlock, requested)
183
+ : undefined;
184
+ if (certifiedOutput && selectedCertifiedBlock)
185
+ return bindCertifiedDeclaredDimension(requested);
53
186
  const capabilityBinding = bindSelectedCapabilityDimension(requested, selectedCapability, roles, input.candidates);
54
187
  // A provider frame may describe a selected capability, but it cannot pick
55
188
  // one of that capability's grouping dimensions for the host. Once a
@@ -65,10 +198,8 @@ export function buildResolvedAnalyticalPlan(input) {
65
198
  }
66
199
  if (capabilityBinding && capabilityBinding.status !== 'unresolved')
67
200
  return capabilityBinding;
68
- const normalizedRequested = normalize(requested);
69
201
  const frameIds = uniqueSorted((input.resolution.analyticalFrame?.dimensions ?? [])
70
202
  .filter((binding) => capabilityEntailsFrameDimension(selectedCapability, binding)
71
- && (!selectedCapability || binding.role === 'time_axis')
72
203
  && memberTermMatches(normalize(binding.dimensionId), normalizedRequested))
73
204
  .map((binding) => binding.dimensionId));
74
205
  if (frameIds.length === 1) {
@@ -86,8 +217,68 @@ export function buildResolvedAnalyticalPlan(input) {
86
217
  const rankingRequested = input.resolution.questionType === 'ranking'
87
218
  || input.resolution.queryIntent.order !== undefined
88
219
  || input.resolution.queryIntent.limit !== undefined;
89
- const dimensions = input.resolution.queryIntent.dimensions
90
- .map((requested) => bindDimension(requested, rankingRequested ? ['group_by', 'rank_entity'] : ['group_by']));
220
+ // A ranked report can group by several dimensions, but exactly one of them
221
+ // is the entity being ranked. Requiring `rank_entity` on every grouping
222
+ // field made a complete tuple such as "top customers by product category"
223
+ // clarify because `product_type` correctly supports grouping but not being
224
+ // the ranked customer entity.
225
+ const rankEntityDimensionId = input.resolution.analyticalFrame?.ranking?.entityDimensionId;
226
+ const requiredDimensionRoles = (requested) => {
227
+ if (!rankingRequested)
228
+ return ['group_by'];
229
+ const rankEntity = selectedCapability?.dimensions.find((dimension) => dimension.dimensionId === rankEntityDimensionId);
230
+ const matchesHostEntityDisplay = hostEntityDisplayTerms.has(normalize(requested))
231
+ && Boolean(rankEntity && rankEntity.dimensionId === rankEntityDimensionId);
232
+ const matchesRankEntity = matchesHostEntityDisplay || Boolean(rankEntity && selectedCapability
233
+ && capabilityDimensionMatchScore(requested, rankEntity, selectedCapability, input.candidates) > 0);
234
+ return matchesRankEntity ? ['group_by', 'rank_entity'] : ['group_by'];
235
+ };
236
+ // The requirement seed is the immutable, host-owned tuple for a bounded
237
+ // meaning call. Route reconciliation may still carry a legacy/broad
238
+ // `queryIntent` alongside that seed for compatibility, but it must never
239
+ // reintroduce an entity noun that the seed deliberately separated from its
240
+ // display/rank requirement. For example, `top customers by product
241
+ // category` binds the host terms `customer name` and `product category`;
242
+ // a legacy `customer` dimension would make Customer Type and Customer Order
243
+ // Number compete with the metric-native Customer Name key after the frame
244
+ // had already resolved it. Structured selections are merged into the
245
+ // seed before this point, so the seeded dimensions remain complete.
246
+ //
247
+ // An exact authored certified asset is a zero-provider host decision. Its
248
+ // direct router canonicalizes literal value wording (for example, “food and
249
+ // drink”) to the block's declared output (`category`) only after that exact
250
+ // block itself proved the requested measures and output contract. A model
251
+ // cannot reach this branch: candidate-ID meaning merge restores the host
252
+ // seed before RAP construction. Preserve this narrow canonical projection
253
+ // instead of turning a certified answer back into two invented dimensions.
254
+ // Unseeded V1/legacy callers retain their historical query-intent path.
255
+ const seededDimensionTerms = exactCertifiedDimensionProjection
256
+ ? input.resolution.queryIntent.dimensions
257
+ : input.resolution.hostRequirementSeed
258
+ ? input.resolution.hostRequirementSeed.queryIntent.dimensions
259
+ : input.resolution.queryIntent.dimensions;
260
+ // A server-issued clarification choice is a typed continuation binding. It
261
+ // may complete a display/grouping role that did not occur literally in the
262
+ // source question (for example “Show the top names by revenue” → Customer
263
+ // Name), but only after the router revalidated the stable choice against
264
+ // this snapshot. Candidate-ID model output cannot use this carrier.
265
+ const authoritativeDimensionTerms = [...new Set([
266
+ ...seededDimensionTerms,
267
+ ...(input.resolution.structuredDimensionIds ?? []),
268
+ ])];
269
+ const rawDimensions = authoritativeDimensionTerms
270
+ .map((requested) => bindDimension(requested, requiredDimensionRoles(requested)));
271
+ // The host-owned seed retains an entity display term (for example, "customer
272
+ // name") so a model cannot quietly turn "top customers" into an anonymous
273
+ // entity id. Some authored semantic/certified capabilities deliberately
274
+ // expose that display role under the generic entity identity ("customer").
275
+ // In that case the label is not a second requested grouping dimension. Keep
276
+ // it in the immutable seed/receipt, but remove only a redundant execution
277
+ // binding when the same resolved entity proves it, or an unresolved label
278
+ // when a resolved generic entity already covers the role. A real explicit
279
+ // `by customer name` dimension remains untouched because it appears in the
280
+ // seed's ordinary dimensions as well as its display terms.
281
+ const dimensions = collapseHostEntityDisplayBindings(rawDimensions, input.resolution.hostRequirementSeed);
91
282
  // A provider may name the field it is grouping by and forget the MEMBER the
92
283
  // reader asked about: "What customer type is Wesley Jenkins?" came back with
93
284
  // `filters: []`, so the run returned all 200 customers and the narration
@@ -99,18 +290,52 @@ export function buildResolvedAnalyticalPlan(input) {
99
290
  // still win whenever it supplies any.
100
291
  const requestedFilters = input.resolution.queryIntent.filters.length > 0
101
292
  ? input.resolution.queryIntent.filters
102
- : (input.evidence.parsedIntent?.filters ?? []).filter((filter) => filter.field && filter.value);
293
+ : (input.resolution.hostRequirementSeed?.queryIntent.filters
294
+ ?? currentQuestionGroundedParsedIntent(input.question, input.evidence.parsedIntent)?.filters
295
+ ?? []).filter((filter) => filter.field && filter.value);
103
296
  const filters = requestedFilters.map((filter) => ({
104
297
  ...filter,
105
298
  binding: bindDimension(filter.field, ['filter']),
106
299
  }));
107
- const capabilityDimensionProof = resolvedCapabilityDimensionProof(selectedCapability, dimensions, filters);
108
- const proofCandidates = [
109
- ...selectedCandidates,
110
- ...(executionCandidate && !selectedCandidates.some((candidate) => candidate.id === executionCandidate.id)
300
+ // Output terms are a distinct host-owned projection. A model may choose a
301
+ // qualified binding for one only from the admitted snapshot, but it cannot
302
+ // turn explicit `order ID` / `product ID` output terms into dimensions or
303
+ // omit them from an exploratory result contract.
304
+ //
305
+ // Host-owned output bindings have to come from the qualified meaning
306
+ // closure, not from the provisional route label. A meaning response may
307
+ // still carry the legacy `governed_sql` recommendation before the cascade
308
+ // proves whether the tuple is relational or exploratory. Re-opening the
309
+ // complete snapshot in that state turns unrelated `orders.order_id` /
310
+ // `raw_items.order_id` cards into false alternatives to the explicitly
311
+ // selected `order_items.order_id` output.
312
+ //
313
+ // If no meaning candidate was selected, preserve the legacy evidence path
314
+ // so a deterministic/certified binding can still explain a genuine gap.
315
+ // Once selection exists, however, only that snapshot-bound closure can
316
+ // satisfy a user-named output. The authorizer still proves target,
317
+ // read-only, relation, and exact-expression safety before SQL dispatch.
318
+ const outputBindingCandidates = selectedCandidates.length > 0
319
+ ? selectedCandidates
320
+ : executionCandidate
111
321
  ? [executionCandidate]
112
- : []),
113
- ];
322
+ : bindingCandidates;
323
+ const requiredOutputs = (input.resolution.hostRequirementSeed?.requirements.outputTerms ?? [])
324
+ .map((requested) => selectedCertifiedBlock
325
+ ? bindCertifiedDeclaredOutput(requested)
326
+ : bindRequestedMember(requested, outputBindingCandidates, 'output', input.question));
327
+ const outputAuthorityCandidates = requiredOutputs.flatMap((binding) => {
328
+ const ids = new Set([binding.qualifiedId, ...binding.candidateIds].filter((id) => Boolean(id)));
329
+ return input.candidates.filter((candidate) => ids.has(canonicalId(candidate)) || ids.has(candidate.id));
330
+ });
331
+ const frozenAuthorityCandidates = uniqueCandidatesByCanonicalId([
332
+ ...selectedCandidates,
333
+ ...(executionCandidate ? [executionCandidate] : []),
334
+ ...outputAuthorityCandidates,
335
+ ], canonicalId);
336
+ const selectedConceptIds = frozenAuthorityCandidates.map(canonicalId);
337
+ const capabilityDimensionProof = resolvedCapabilityDimensionProof(selectedCapability, dimensions, filters);
338
+ const proofCandidates = frozenAuthorityCandidates;
114
339
  const compatibilityProof = proofCandidates.map((candidate) => ({
115
340
  candidateId: canonicalId(candidate),
116
341
  compatibility: candidate.compatibility,
@@ -121,8 +346,8 @@ export function buildResolvedAnalyticalPlan(input) {
121
346
  : []),
122
347
  ]),
123
348
  }));
124
- const capability = resolveCapability(input.resolution, executionCandidate, measures, dimensions, filters);
125
- const bindingGaps = bindingMissingInformation(measures, dimensions, filters);
349
+ const capability = resolveCapability(input.resolution, executionCandidate, measures, dimensions, filters, requiredOutputs, selectedCapability, frozenAuthorityCandidates, input.candidates);
350
+ const bindingGaps = bindingMissingInformation(measures, dimensions, filters, requiredOutputs);
126
351
  // The reader named a member that no grounding lane could bind. Answering the
127
352
  // question WITHOUT it silently changes what was asked — "what customer type
128
353
  // is Wesley Jenkins" became "list every customer" — so the plan carries it as
@@ -158,6 +383,15 @@ export function buildResolvedAnalyticalPlan(input) {
158
383
  snapshotId,
159
384
  })
160
385
  : [];
386
+ const governedRelationshipSafetyProofs = selectedCapability
387
+ && selectedExecutionCapability?.route === 'governed_sql'
388
+ ? buildGovernedRelationshipSafetyProofsV1({
389
+ capability: selectedCapability,
390
+ dimensions: capabilityDimensionProof,
391
+ candidates: input.candidates,
392
+ snapshotId,
393
+ })
394
+ : [];
161
395
  const payload = {
162
396
  schemaVersion: input.resolution.analyticalFrame
163
397
  ? 2
@@ -193,15 +427,17 @@ export function buildResolvedAnalyticalPlan(input) {
193
427
  entityGrain: selectedCapability
194
428
  ? resolvedCapabilityEntityGrain(selectedCapability, dimensions)
195
429
  : input.resolution.analyticalFrame?.entityGrainIds[0] ?? executionCandidate?.primaryEntity,
196
- sourceRelationIds: uniqueSorted(selectedCandidates.flatMap((candidate) => candidate.sourceObjects ?? [])),
430
+ sourceRelationIds: uniqueSorted(frozenAuthorityCandidates.flatMap((candidate) => candidate.sourceObjects ?? [])),
197
431
  relationshipPathIds: selectedCapability
198
432
  ? uniqueSorted(relationshipProofs.flatMap((proof) => proof.kind === 'dql_relationship_path' ? proof.relationshipPathIds : []))
199
- : uniqueSorted(selectedCandidates.flatMap((candidate) => candidate.relationshipEvidence ?? [])),
433
+ : uniqueSorted(frozenAuthorityCandidates.flatMap((candidate) => candidate.relationshipEvidence ?? [])),
200
434
  relationshipProofs,
435
+ governedRelationshipSafetyProofs,
201
436
  compatibilityProof,
202
437
  outputContract: {
203
- measures: uniqueSorted(measures.flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
204
- dimensions: uniqueSorted(dimensions.flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
438
+ measures: uniqueSorted(measures.flatMap((binding) => binding.outputName ? [binding.outputName] : binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
439
+ dimensions: uniqueSorted(dimensions.flatMap((binding) => binding.outputName ? [binding.outputName] : binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
440
+ requiredOutputs: requiredOutputs.map(cloneBinding),
205
441
  ...(input.resolution.queryIntent.timeGrain
206
442
  ? { timeGrain: input.resolution.queryIntent.timeGrain }
207
443
  : {}),
@@ -225,23 +461,23 @@ export function buildResolvedAnalyticalPlan(input) {
225
461
  outcome: input.resolution.compatibilityOutcome
226
462
  ?? (input.resolution.recommendedRoute === 'clarify' || input.resolution.confidence === 'low'
227
463
  ? 'clarify'
228
- : bindingCandidateIds(measures, dimensions, filters).length > 0
464
+ : bindingCandidateIds(measures, dimensions, filters, requiredOutputs).length > 0
229
465
  ? 'clarify'
230
466
  : 'modeling_gap'),
231
467
  codes: uniqueSorted([
232
468
  ...(input.resolution.compatibilityFailures ?? []).map((failure) => failure.code),
233
- ...bindingFailureCodes(measures, dimensions, filters),
469
+ ...bindingFailureCodes(measures, dimensions, filters, requiredOutputs),
234
470
  ]),
235
471
  candidateIds: uniqueSorted([
236
472
  ...(input.resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds),
237
- ...bindingCandidateIds(measures, dimensions, filters),
473
+ ...bindingCandidateIds(measures, dimensions, filters, requiredOutputs),
238
474
  ]),
239
475
  ...(selectedCapability?.metricId
240
476
  ? { selectedCapabilityId: selectedCapability.metricId }
241
477
  : {}),
242
478
  ...(executionId ? { selectedExecutionId: executionId } : {}),
243
479
  finalCapability: capability,
244
- bindings: unresolvedBindingReceipts(measures, dimensions, filters),
480
+ bindings: unresolvedBindingReceipts(measures, dimensions, filters, requiredOutputs),
245
481
  },
246
482
  }
247
483
  : {}),
@@ -409,13 +645,31 @@ function eligibleCapabilityDimensions(capability, requiredRoles) {
409
645
  if (!requiredRoles.every((role) => dimension.supportedRoles.includes(role)))
410
646
  return false;
411
647
  const sameEntity = dimension.entityId === capability.primaryEntityId;
412
- const declaredGrain = capability.resultGrainIds.includes(dimension.entityId);
413
- const relationshipProven = (dimension.relationshipPathIds?.length ?? 0) > 0;
414
- const nativeGroupingProven = Boolean(dimension.nativeGroupingReference)
415
- && capability.executionCapabilities.some((execution) => execution.route === 'semantic');
416
- return sameEntity || declaredGrain || relationshipProven || nativeGroupingProven;
648
+ // A semantic metric may only use a cross-model dimension when the
649
+ // capability carries the exact adapter-native grouping reference which
650
+ // the compiler can consume. Result-grain membership and a generic DQL
651
+ // relationship ID prove neither MetricFlow spelling nor semantic adapter
652
+ // reachability, so they must not admit a semantic freeze on their own.
653
+ // Non-semantic routes retain their separately governed relationship
654
+ // authority below.
655
+ const semanticNativeGroupingProven = semanticNativeGroupingIsExact(capability, dimension);
656
+ const nonSemanticRoute = capability.executionCapabilities.some((execution) => execution.route !== 'semantic');
657
+ const relationalProof = nonSemanticRoute
658
+ && (capability.resultGrainIds.includes(dimension.entityId)
659
+ || (dimension.relationshipPathIds?.length ?? 0) > 0);
660
+ return sameEntity || semanticNativeGroupingProven || relationalProof;
417
661
  });
418
662
  }
663
+ /**
664
+ * Verify a cross-model semantic grouping before it is allowed to bind the
665
+ * frozen plan. The capability is the only authority here: leaf-name equality,
666
+ * a broad result grain, and a physical/DQL relationship are intentionally not
667
+ * enough to claim MetricFlow can compose this tuple.
668
+ */
669
+ function semanticNativeGroupingIsExact(capability, dimension) {
670
+ return dimension.entityId === capability.primaryEntityId
671
+ || semanticDimensionUsesExactAdapterGrouping(capability, dimension);
672
+ }
419
673
  function capabilityEntailsFrameDimension(capability, binding) {
420
674
  if (!capability)
421
675
  return false;
@@ -460,11 +714,12 @@ function selectedCapabilityProofFacts(capability, dimensions) {
460
714
  ...capability.executionCapabilities.map((execution) => `capability:route:${execution.route}:${execution.adapterId ?? 'native'}`),
461
715
  ];
462
716
  }
463
- function unresolvedBindingReceipts(measures, dimensions, filters) {
717
+ function unresolvedBindingReceipts(measures, dimensions, filters, requiredOutputs = []) {
464
718
  return [
465
719
  ...measures.map((binding) => ({ kind: 'measure', binding })),
466
720
  ...dimensions.map((binding) => ({ kind: 'dimension', binding })),
467
721
  ...filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
722
+ ...requiredOutputs.map((binding) => ({ kind: 'output', binding })),
468
723
  ]
469
724
  .filter(({ binding }) => binding.status !== 'resolved')
470
725
  .map(({ kind, binding }) => ({
@@ -474,19 +729,20 @@ function unresolvedBindingReceipts(measures, dimensions, filters) {
474
729
  candidateIds: [...binding.candidateIds],
475
730
  }));
476
731
  }
477
- function bindingCandidateIds(measures, dimensions, filters) {
478
- return unresolvedBindingReceipts(measures, dimensions, filters)
732
+ function bindingCandidateIds(measures, dimensions, filters, requiredOutputs = []) {
733
+ return unresolvedBindingReceipts(measures, dimensions, filters, requiredOutputs)
479
734
  .flatMap((binding) => binding.candidateIds);
480
735
  }
481
- function bindingFailureCodes(measures, dimensions, filters) {
482
- return unresolvedBindingReceipts(measures, dimensions, filters)
736
+ function bindingFailureCodes(measures, dimensions, filters, requiredOutputs = []) {
737
+ return unresolvedBindingReceipts(measures, dimensions, filters, requiredOutputs)
483
738
  .map((binding) => `${binding.kind.toUpperCase()}_${binding.status.toUpperCase()}`);
484
739
  }
485
- function bindingMissingInformation(measures, dimensions, filters) {
740
+ function bindingMissingInformation(measures, dimensions, filters, requiredOutputs = []) {
486
741
  return [
487
742
  ...measures.map((binding) => ({ kind: 'measure', binding })),
488
743
  ...dimensions.map((binding) => ({ kind: 'dimension', binding })),
489
744
  ...filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
745
+ ...requiredOutputs.map((binding) => ({ kind: 'output', binding })),
490
746
  ].flatMap(({ kind, binding }) => {
491
747
  if (binding.status === 'resolved')
492
748
  return [];
@@ -522,6 +778,7 @@ export function deriveResolvedAnalyticalPlan(parent, delta) {
522
778
  ? (timeRange ? resolvePlanTimeRange(timeRange, delta.referenceTime ?? new Date()) : undefined)
523
779
  : parent.query.timeBounds;
524
780
  const unresolved = [...measures, ...dimensions, ...filters.map((filter) => filter.binding)]
781
+ .concat((parent.outputContract.requiredOutputs ?? []).map(cloneBinding))
525
782
  .filter((binding) => binding.status !== 'resolved');
526
783
  const payload = {
527
784
  ...parent,
@@ -542,8 +799,9 @@ export function deriveResolvedAnalyticalPlan(parent, delta) {
542
799
  ...(delta.limit !== undefined || parent.query.limit !== undefined ? { limit: delta.limit ?? parent.query.limit } : {}),
543
800
  },
544
801
  outputContract: {
545
- measures: uniqueSorted(measures.flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
546
- dimensions: uniqueSorted(dimensions.flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
802
+ measures: uniqueSorted(measures.flatMap((binding) => binding.outputName ? [binding.outputName] : binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
803
+ dimensions: uniqueSorted(dimensions.flatMap((binding) => binding.outputName ? [binding.outputName] : binding.qualifiedId ? [binding.qualifiedId] : [binding.requested])),
804
+ requiredOutputs: (parent.outputContract.requiredOutputs ?? []).map(cloneBinding),
547
805
  ...((delta.timeGrain ?? parent.query.timeGrain)
548
806
  ? { timeGrain: delta.timeGrain ?? parent.query.timeGrain }
549
807
  : {}),
@@ -621,13 +879,32 @@ function bindRequestedMember(requested, candidates, kind, question = '') {
621
879
  // a second binding identity into the frozen plan. Only candidates still
622
880
  // eligible for the requested tuple can contribute member authority.
623
881
  const eligibleCandidates = candidates.filter((candidate) => candidate.compatibility !== 'incompatible');
624
- const directCandidates = eligibleCandidates.filter((candidate) => {
882
+ const kindCandidates = eligibleCandidates.filter((candidate) => {
625
883
  const kindMatches = kind === 'measure'
626
884
  ? candidate.kind === 'semantic_metric' || candidate.kind === 'sql_column'
627
- : candidate.kind === 'semantic_member' || candidate.kind === 'sql_column';
628
- return kindMatches
629
- && candidateTerms(candidate).some((term) => memberTermMatches(term, normalized));
885
+ : kind === 'dimension'
886
+ ? candidate.kind === 'semantic_member' || candidate.kind === 'sql_column'
887
+ // Explicit projected fields are not grouping dimensions. A metric can
888
+ // satisfy a requested price/revenue output only when it is selected as
889
+ // the measure; the projection lane itself accepts qualified members or
890
+ // physical columns.
891
+ : candidate.kind === 'semantic_member' || candidate.kind === 'sql_column';
892
+ return kindMatches;
630
893
  });
894
+ // Explicit outputs are a projection contract, not an approximate semantic
895
+ // search. Prefer a physical qualified column whose leaf is exactly the
896
+ // requested field. This keeps `product_id` from becoming an alternative for
897
+ // `order id` through a noisy alias and collapses duplicate cards for the
898
+ // same physical relation.column. Different exact physical columns remain
899
+ // distinct, so genuine ambiguity is still surfaced for clarification.
900
+ const exactOutputCandidates = kind === 'output'
901
+ ? exactOutputBindingCandidates(kindCandidates, normalized)
902
+ : [];
903
+ const directCandidates = kind === 'output'
904
+ ? exactOutputCandidates.length > 0 || isExplicitOutputIdentity(normalized)
905
+ ? exactOutputCandidates
906
+ : uniqueOutputBindingCandidates(kindCandidates.filter((candidate) => candidateTerms(candidate).some((term) => memberTermMatches(term, normalized))))
907
+ : kindCandidates.filter((candidate) => candidateTerms(candidate).some((term) => memberTermMatches(term, normalized)));
631
908
  const collectedIds = uniqueSorted([
632
909
  ...directCandidates.map((candidate) => candidate.qualifiedId ?? candidate.id),
633
910
  ...(kind === 'dimension' ? eligibleCandidates.flatMap((candidate) => (candidate.dimensions ?? []).filter((dimension) => {
@@ -651,11 +928,32 @@ function bindRequestedMember(requested, candidates, kind, question = '') {
651
928
  });
652
929
  if (ids.length === 0 && kind === 'measure' && eligibleCandidates.length === 1) {
653
930
  const certified = eligibleCandidates[0];
654
- if (certified.kind === 'certified_block' && certified.compatibility === 'compatible') {
931
+ const outputName = certifiedCandidateDeclaredMeasureOutput(certified, requested);
932
+ if (certified.kind === 'certified_block'
933
+ && certified.compatibility === 'compatible'
934
+ && certifiedCandidateExplicitlyCoversMeasures(certified, [requested])
935
+ && outputName) {
936
+ const id = certified.qualifiedId ?? certified.id;
937
+ return {
938
+ requested,
939
+ qualifiedId: id,
940
+ outputName,
941
+ status: 'resolved',
942
+ candidateIds: [id],
943
+ };
944
+ }
945
+ }
946
+ if (ids.length === 0 && (kind === 'dimension' || kind === 'output') && eligibleCandidates.length === 1) {
947
+ const certified = eligibleCandidates[0];
948
+ const outputName = certifiedCandidateDeclaredDimensionOutput(certified, requested);
949
+ if (certified.kind === 'certified_block'
950
+ && certified.compatibility === 'compatible'
951
+ && outputName) {
655
952
  const id = certified.qualifiedId ?? certified.id;
656
953
  return {
657
954
  requested,
658
955
  qualifiedId: id,
956
+ outputName,
659
957
  status: 'resolved',
660
958
  candidateIds: [id],
661
959
  };
@@ -667,6 +965,7 @@ function bindRequestedMember(requested, candidates, kind, question = '') {
667
965
  return {
668
966
  requested,
669
967
  qualifiedId: namedWinner,
968
+ ...(kind === 'output' ? { outputName: outputNameForCandidate(winnerCandidate, namedWinner) } : {}),
670
969
  ...(winnerCandidate?.aggregation ? { aggregation: winnerCandidate.aggregation } : {}),
671
970
  status: 'resolved',
672
971
  candidateIds: [namedWinner],
@@ -678,11 +977,85 @@ function bindRequestedMember(requested, candidates, kind, question = '') {
678
977
  return {
679
978
  requested,
680
979
  ...(ids.length === 1 ? { qualifiedId: ids[0] } : {}),
980
+ ...(kind === 'output' && ids.length === 1
981
+ ? { outputName: outputNameForCandidate(direct, ids[0]) }
982
+ : {}),
681
983
  ...(direct?.aggregation ? { aggregation: direct.aggregation } : {}),
682
984
  status: ids.length === 1 ? 'resolved' : ids.length > 1 ? 'ambiguous' : 'unresolved',
683
985
  candidateIds: ids,
684
986
  };
685
987
  }
988
+ /**
989
+ * Return the exact host-authoritative output candidates for a requested
990
+ * projection. Physical columns win over descriptive semantic aliases: when a
991
+ * selected closure contains `order_items.order_id`, a semantic entity named
992
+ * `order_id` is useful retrieval context but is not a competing SQL output.
993
+ */
994
+ function exactOutputBindingCandidates(candidates, requested) {
995
+ const exactPhysical = candidates.filter((candidate) => physicalOutputColumnLeaf(candidate) === requested);
996
+ if (exactPhysical.length > 0)
997
+ return uniqueOutputBindingCandidates(exactPhysical);
998
+ // Semantic projections do not always carry a physical column identity. They
999
+ // may still bind if their declared name itself is exact; aliases alone never
1000
+ // have authority to substitute one requested identifier for another.
1001
+ return uniqueOutputBindingCandidates(candidates.filter((candidate) => normalize(candidate.name) === requested));
1002
+ }
1003
+ /**
1004
+ * A requested `... id` / `... identifier` must not fall back to lexical alias
1005
+ * matching. In particular, an alias attached to product_id cannot make it a
1006
+ * candidate for order_id when no actual order_id is selected.
1007
+ */
1008
+ function isExplicitOutputIdentity(requested) {
1009
+ return /(?:^| )(?:id|identifier)$/.test(requested);
1010
+ }
1011
+ function physicalOutputColumnLeaf(candidate) {
1012
+ if (candidate.kind !== 'sql_column')
1013
+ return undefined;
1014
+ const raw = candidate.qualifiedId ?? candidate.id;
1015
+ const columnReference = raw.replace(/^(?:dbt|runtime):column:/i, '');
1016
+ const leaf = columnReference.split('.').at(-1);
1017
+ const normalized = normalize(leaf ?? '');
1018
+ return normalized || undefined;
1019
+ }
1020
+ /**
1021
+ * Candidate IDs can be emitted once by the dbt manifest and again by runtime
1022
+ * schema/index lanes. They describe one output only when their canonical
1023
+ * physical relation.column is identical. Do not dedupe two relations that
1024
+ * merely share the same leaf: that remains a real ambiguity.
1025
+ */
1026
+ function uniqueOutputBindingCandidates(candidates) {
1027
+ const seen = new Set();
1028
+ return candidates.filter((candidate) => {
1029
+ const identity = canonicalOutputBindingIdentity(candidate);
1030
+ if (seen.has(identity))
1031
+ return false;
1032
+ seen.add(identity);
1033
+ return true;
1034
+ });
1035
+ }
1036
+ function canonicalOutputBindingIdentity(candidate) {
1037
+ if (candidate.kind === 'sql_column') {
1038
+ const raw = candidate.qualifiedId ?? candidate.id;
1039
+ const columnReference = raw.replace(/^(?:dbt|runtime):column:/i, '');
1040
+ if (columnReference.includes('.'))
1041
+ return `physical:${normalize(columnReference)}`;
1042
+ }
1043
+ return `candidate:${candidate.qualifiedId ?? candidate.id}`;
1044
+ }
1045
+ /**
1046
+ * A selected physical/semantic output's alias is part of the frozen result
1047
+ * contract. Prefer the inspected candidate name; a qualified-id leaf is a
1048
+ * safe fallback for machine-authored dbt/sql column identities.
1049
+ */
1050
+ function outputNameForCandidate(candidate, qualifiedId) {
1051
+ const candidateName = candidate?.name?.trim();
1052
+ if (candidateName && /^[A-Za-z_][A-Za-z0-9_]*$/.test(candidateName))
1053
+ return candidateName;
1054
+ const leaf = qualifiedId.split(/[:.]/).at(-1)?.replace(/[^A-Za-z0-9_]/g, '_');
1055
+ return leaf && /^[A-Za-z_][A-Za-z0-9_]*$/.test(leaf)
1056
+ ? leaf
1057
+ : normalize(candidateName || qualifiedId).replace(/\s+/g, '_');
1058
+ }
686
1059
  function qualifyDeclaredDimension(candidate, dimension) {
687
1060
  if (/[:./]/.test(dimension) || !candidate.domain)
688
1061
  return dimension;
@@ -690,9 +1063,6 @@ function qualifyDeclaredDimension(candidate, dimension) {
690
1063
  const local = dimension.toLowerCase().replace(/[^a-z0-9_-]+/g, '_');
691
1064
  return `semantic:${domain}:dimension:${local}`;
692
1065
  }
693
- function resolutionUsesRelationalEvidence(resolution) {
694
- return resolution.recommendedRoute === 'governed_sql' || resolution.recommendedRoute === 'exploratory';
695
- }
696
1066
  function memberTermMatches(candidateTerm, requested) {
697
1067
  if (candidateTerm === requested || candidateTerm.endsWith(` ${requested}`))
698
1068
  return true;
@@ -705,7 +1075,7 @@ function candidateTerms(candidate) {
705
1075
  .map(normalize)
706
1076
  .filter(Boolean);
707
1077
  }
708
- function resolveCapability(resolution, execution, measures, dimensions, filters) {
1078
+ function resolveCapability(resolution, execution, measures, dimensions, filters, requiredOutputs = [], selectedCapability, frozenAuthorityCandidates = [], snapshotCandidates = []) {
709
1079
  if (resolution.confidence === "low" ||
710
1080
  resolution.recommendedRoute === "clarify" ||
711
1081
  !execution ||
@@ -722,23 +1092,329 @@ function resolveCapability(resolution, execution, measures, dimensions, filters)
722
1092
  (resolution.recommendedRoute === "certified" ||
723
1093
  resolution.recommendedRoute === "semantic"))
724
1094
  return "blocked";
1095
+ // A frozen plan may not silently drop an explicitly requested projection.
1096
+ // This applies to exploratory SQL too: otherwise a query can execute and
1097
+ // display five rows that omit one of the user-named identifiers.
1098
+ if (requiredOutputs.some((binding) => binding.status !== 'resolved'))
1099
+ return 'blocked';
725
1100
  if (execution.compatibility === "incompatible")
726
1101
  return "blocked";
727
1102
  if (resolution.recommendedRoute === "certified" &&
728
1103
  execution.kind === "certified_block" &&
729
- execution.compatibility === "compatible") {
1104
+ execution.compatibility === "compatible" &&
1105
+ certifiedCandidateExplicitlyCoversMeasures(execution, measures.map((binding) => binding.requested)) &&
1106
+ certifiedBlockProvesHostProjection(execution, resolution)) {
730
1107
  return "certified_execution";
731
1108
  }
732
1109
  if (resolution.recommendedRoute === 'semantic'
733
1110
  && (execution.kind === 'semantic_metric' || execution.kind === 'semantic_member')
734
- && execution.compatibility === 'compatible')
1111
+ && semanticCapabilitiesProveFrozenTuple({
1112
+ capability: selectedCapability,
1113
+ measures,
1114
+ dimensions,
1115
+ filters,
1116
+ requiredOutputs,
1117
+ timeGrain: resolution.queryIntent.timeGrain,
1118
+ hasTimeRange: Boolean(resolution.queryIntent.timeRange),
1119
+ candidates: frozenAuthorityCandidates,
1120
+ })
1121
+ && (execution.compatibility === 'compatible'
1122
+ || sameSnapshotSemanticExtensionProvesFrozenTuple({
1123
+ execution,
1124
+ capability: selectedCapability,
1125
+ measures,
1126
+ dimensions,
1127
+ filters,
1128
+ requiredOutputs,
1129
+ candidates: frozenAuthorityCandidates,
1130
+ timeGrain: resolution.queryIntent.timeGrain,
1131
+ hasTimeRange: Boolean(resolution.queryIntent.timeRange),
1132
+ })))
735
1133
  return 'semantic_execution';
736
- if (resolution.recommendedRoute === 'governed_sql')
1134
+ // A relationship/entity card and a qualified physical closure are useful
1135
+ // evidence for exploratory SQL, but they are not themselves a governed DQL
1136
+ // projection. In particular, a DQL entity may bridge an order-items model
1137
+ // to related business context while the selected tuple still consists only
1138
+ // of dbt/runtime columns. Freezing that as governed relational defers the
1139
+ // first real eligibility check until the compiler, where it becomes a
1140
+ // post-freeze COMPILATION_FAILED with no permitted fallback.
1141
+ //
1142
+ // Keep the boundary before freeze: governed relational execution requires a
1143
+ // snapshot-declared capability with a concrete governed adapter and proof
1144
+ // for every bound measure/dimension/filter/output. A raw physical path is
1145
+ // deliberately returned as blocked here so the router can evaluate its
1146
+ // existing same-snapshot exploratory cascade, which rebuilds an immutable
1147
+ // review-required plan with `recommendedRoute: exploratory`.
1148
+ if (resolution.recommendedRoute === 'governed_sql'
1149
+ // A partial DQL modeling card can contribute relationship context, but it
1150
+ // cannot be the compiler authority for a frozen governed plan. In
1151
+ // particular, an entity card that merely names an order-item grain must
1152
+ // not turn selected dbt/runtime output columns into a governed DQL
1153
+ // projection. It remains eligible evidence for the router's safe
1154
+ // same-snapshot exploratory cascade below this pre-freeze gate.
1155
+ && execution.compatibility === 'compatible'
1156
+ && governedRelationalCapabilityProvesFrozenTuple({
1157
+ capability: selectedCapability,
1158
+ measures,
1159
+ dimensions,
1160
+ filters,
1161
+ requiredOutputs,
1162
+ timeGrain: resolution.queryIntent.timeGrain,
1163
+ hasTimeRange: Boolean(resolution.queryIntent.timeRange),
1164
+ snapshotCandidates,
1165
+ }))
737
1166
  return 'governed_relational';
738
1167
  if (resolution.recommendedRoute === 'exploratory')
739
1168
  return 'bounded_exploration';
740
1169
  return 'blocked';
741
1170
  }
1171
+ /**
1172
+ * Governed relational compilation has a distinct authority boundary from
1173
+ * physical SQL safety. The latter can prove a read-only, review-required
1174
+ * closure; it cannot mint a governed compiler projection. Require the
1175
+ * selected capability to declare the governed adapter and to prove the exact
1176
+ * frozen tuple before the router is allowed to freeze this tier.
1177
+ */
1178
+ function governedRelationalCapabilityProvesFrozenTuple(input) {
1179
+ const { capability } = input;
1180
+ if (!capability)
1181
+ return false;
1182
+ if (!capability.executionCapabilities.some((execution) => execution.route === 'governed_sql' && Boolean(execution.adapterId?.trim())))
1183
+ return false;
1184
+ const metricIds = new Set([capability.metricId, ...capability.measureIds]);
1185
+ if (!input.measures.every((binding) => binding.status === 'resolved'
1186
+ && Boolean(binding.qualifiedId)
1187
+ && metricIds.has(binding.qualifiedId)))
1188
+ return false;
1189
+ const provesDimension = (binding, roles) => {
1190
+ if (binding.status !== 'resolved' || !binding.qualifiedId)
1191
+ return false;
1192
+ const dimension = eligibleCapabilityDimensions(capability, roles)
1193
+ .find((candidate) => candidate.dimensionId === binding.qualifiedId);
1194
+ return Boolean(dimension
1195
+ && governedCapabilityDimensionHasFreshAutomaticRelationshipProofV1({
1196
+ capability,
1197
+ dimension,
1198
+ candidates: input.snapshotCandidates,
1199
+ }));
1200
+ };
1201
+ if (!input.dimensions.every((binding) => provesDimension(binding, ['group_by'])))
1202
+ return false;
1203
+ if (!input.filters.every((filter) => provesDimension(filter.binding, ['filter'])))
1204
+ return false;
1205
+ // An explicit output is a host-owned result-contract field, not an
1206
+ // implication of a physical relation. A `declaredOutputIds` entry is not a
1207
+ // compiler projection on its own: the governed compiler consumes metrics
1208
+ // and grouped dimensions. Therefore a non-measure output must be the exact
1209
+ // selected capability display/rank dimension *and* already be represented
1210
+ // in the frozen grouped tuple. This keeps raw `order_id` / `product_id`
1211
+ // columns and detached cross-entity display fields on the exploratory path
1212
+ // unless the authored governed graph can actually compile them.
1213
+ // Metrics follow the same rule: capability membership describes what could
1214
+ // be compiled, while the frozen query measures describe what *will* be
1215
+ // projected. A required `gross_profit` output may not piggyback on a
1216
+ // selected `revenue` measure just because both live in the capability.
1217
+ const groupedDimensionIds = new Set(input.dimensions
1218
+ .flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : []));
1219
+ const projectedMeasureIds = new Set(input.measures
1220
+ .filter((binding) => binding.status === 'resolved')
1221
+ .flatMap((binding) => binding.qualifiedId ? [binding.qualifiedId] : []));
1222
+ const provesOutput = (binding) => {
1223
+ if (binding.status !== 'resolved' || !binding.qualifiedId)
1224
+ return false;
1225
+ if (metricIds.has(binding.qualifiedId))
1226
+ return projectedMeasureIds.has(binding.qualifiedId);
1227
+ if (!groupedDimensionIds.has(binding.qualifiedId))
1228
+ return false;
1229
+ return provesDimension(binding, ['display'])
1230
+ || provesDimension(binding, ['rank_entity']);
1231
+ };
1232
+ if (!input.requiredOutputs.every(provesOutput))
1233
+ return false;
1234
+ if (input.timeGrain && !capability.timeDimensions.some((dimension) => dimension.supportedGrains.some((grain) => normalize(grain) === normalize(input.timeGrain))))
1235
+ return false;
1236
+ return !input.hasTimeRange || capability.timeDimensions.length > 0;
1237
+ }
1238
+ /**
1239
+ * A selected certified block must prove the host-owned projection itself.
1240
+ * Contextual semantic capability metadata is useful corroboration, but it is
1241
+ * never authority to add an entity label, grouping field, or explicit output
1242
+ * that the block does not declare. This defensive freeze gate covers paths
1243
+ * where a binding was collapsed/projected before capability resolution.
1244
+ */
1245
+ function certifiedBlockProvesHostProjection(block, resolution) {
1246
+ const seed = resolution.hostRequirementSeed;
1247
+ if (!seed)
1248
+ return true;
1249
+ const projectedDimensions = certifiedBlockCanUseExactCanonicalDimensionProjection(block, resolution)
1250
+ ? resolution.queryIntent.dimensions
1251
+ : seed.queryIntent.dimensions;
1252
+ const terms = uniqueSorted([
1253
+ ...projectedDimensions,
1254
+ ...seed.requirements.entityDisplayTerms,
1255
+ ...(seed.requirements.outputTerms ?? []),
1256
+ ].map(normalize).filter(Boolean));
1257
+ return terms.every((term) => Boolean(certifiedCandidateDeclaredDimensionOutput(block, term)
1258
+ || certifiedCandidateDeclaredMeasureOutput(block, term)));
1259
+ }
1260
+ function certifiedBlockCanUseExactCanonicalDimensionProjection(block, resolution) {
1261
+ const seed = resolution.hostRequirementSeed;
1262
+ return Boolean(seed
1263
+ && resolution.recommendedRoute === 'certified'
1264
+ && block?.kind === 'certified_block'
1265
+ && block.exactMatch
1266
+ && block.compatibility === 'compatible'
1267
+ && block.analyticalFitClass === 'exact'
1268
+ && certifiedCandidateExplicitlyCoversMeasures(block, resolution.queryIntent.measures)
1269
+ && resolution.queryIntent.dimensions.length > 0
1270
+ && resolution.queryIntent.dimensions.every((dimension) => Boolean(certifiedCandidateDeclaredDimensionOutput(block, dimension)))
1271
+ && !sameNormalizedTerms(resolution.queryIntent.dimensions, seed.queryIntent.dimensions));
1272
+ }
1273
+ /**
1274
+ * The semantic route may freeze only after the exact frozen tuple can be
1275
+ * expressed by the selected semantic adapter. A metric-relative native
1276
+ * grouping reference is required for every cross-model dimension/filter.
1277
+ * Same-model dimensions are already a declared group-by on the metric model.
1278
+ */
1279
+ function semanticCapabilityProvesFrozenTuple(input) {
1280
+ const { capability } = input;
1281
+ // Metric-free semantic member lookups retain their existing exact registry
1282
+ // path. A metric tuple, however, must have a normalized capability.
1283
+ if (!capability)
1284
+ return input.measures.length === 0;
1285
+ if (!capability.executionCapabilities.some((execution) => execution.route === 'semantic' && Boolean(execution.adapterId?.trim())))
1286
+ return false;
1287
+ const metricIds = new Set([capability.metricId, ...capability.measureIds]);
1288
+ if (!input.measures.every((binding) => binding.status === 'resolved' && Boolean(binding.qualifiedId) && metricIds.has(binding.qualifiedId)))
1289
+ return false;
1290
+ const provesDimension = (binding, roles) => {
1291
+ if (binding.status !== 'resolved' || !binding.qualifiedId)
1292
+ return false;
1293
+ const dimension = capability.dimensions.find((candidate) => candidate.dimensionId === binding.qualifiedId
1294
+ && roles.every((role) => candidate.supportedRoles.includes(role)));
1295
+ return Boolean(dimension && (dimension.entityId === capability.primaryEntityId
1296
+ || semanticNativeGroupingIsExact(capability, dimension)));
1297
+ };
1298
+ if (!input.dimensions.every((binding) => provesDimension(binding, ['group_by'])))
1299
+ return false;
1300
+ if (!input.filters.every((filter) => provesDimension(filter.binding, ['filter'])))
1301
+ return false;
1302
+ // Explicit output projections are not grouping dimensions. They have their
1303
+ // own frozen result contract and cannot be smuggled into a semantic tuple by
1304
+ // a same-snapshot extension.
1305
+ if (input.requiredOutputs.length > 0)
1306
+ return false;
1307
+ if (input.timeGrain && !capability.timeDimensions.some((dimension) => dimension.supportedGrains.some((grain) => normalize(grain) === normalize(input.timeGrain))))
1308
+ return false;
1309
+ return !input.hasTimeRange || capability.timeDimensions.length > 0;
1310
+ }
1311
+ /**
1312
+ * A multi-metric semantic plan remains one immutable request frame, but each
1313
+ * selected metric has to prove that frame independently before the route can
1314
+ * freeze. The first selected capability remains the stable execution anchor
1315
+ * for backward-compatible receipts; it is never allowed to stand in for a
1316
+ * second requested metric. This closes the old single-capability shortcut
1317
+ * where a multi-metric frame was marked blocked (or, worse, could later be
1318
+ * compiled against only its first measure).
1319
+ */
1320
+ function semanticCapabilitiesProveFrozenTuple(input) {
1321
+ const resolvedMeasures = input.measures.filter((binding) => binding.status === 'resolved' && Boolean(binding.qualifiedId));
1322
+ if (resolvedMeasures.length <= 1) {
1323
+ return semanticCapabilityProvesFrozenTuple(input);
1324
+ }
1325
+ // Every requested metric must have a selected normalized capability. The
1326
+ // identity match is exact; aliases, matching leaves, or a correlated metric
1327
+ // are not enough to enlarge the frozen tuple.
1328
+ const capabilities = uniqueMetricCapabilities([
1329
+ input.capability,
1330
+ ...input.candidates.map((candidate) => normalizeMetricCapabilityContract(candidate.analyticalCapability)),
1331
+ ]);
1332
+ const selected = resolvedMeasures.map((measure) => {
1333
+ const matches = capabilities.filter((capability) => capability.metricId === measure.qualifiedId
1334
+ || capability.measureIds.includes(measure.qualifiedId));
1335
+ return matches.length === 1 ? { measure, capability: matches[0] } : undefined;
1336
+ });
1337
+ if (selected.some((entry) => !entry))
1338
+ return false;
1339
+ const entries = selected;
1340
+ const semanticRoutes = entries.map((entry) => entry.capability.executionCapabilities
1341
+ .filter((route) => route.route === 'semantic' && Boolean(route.adapterId?.trim())));
1342
+ if (semanticRoutes.some((routes) => routes.length !== 1))
1343
+ return false;
1344
+ const adapterId = semanticRoutes[0][0].adapterId;
1345
+ if (!entries.every((entry, index) => semanticRoutes[index][0].adapterId === adapterId
1346
+ && entry.capability.semanticModelId === entries[0].capability.semanticModelId))
1347
+ return false;
1348
+ return entries.every(({ measure, capability }) => semanticCapabilityProvesFrozenTuple({
1349
+ capability,
1350
+ measures: [measure],
1351
+ dimensions: input.dimensions,
1352
+ filters: input.filters,
1353
+ requiredOutputs: input.requiredOutputs,
1354
+ ...(input.timeGrain ? { timeGrain: input.timeGrain } : {}),
1355
+ hasTimeRange: input.hasTimeRange,
1356
+ }));
1357
+ }
1358
+ function uniqueMetricCapabilities(capabilities) {
1359
+ const byFingerprint = new Map();
1360
+ for (const capability of capabilities) {
1361
+ if (!capability)
1362
+ continue;
1363
+ // The source fingerprint is the snapshot identity, not a metric identity:
1364
+ // one semantic model can legitimately expose several requested metrics
1365
+ // from the same immutable snapshot. A duplicate of the *same* metric can
1366
+ // appear as selected execution and selected evidence, but must not turn
1367
+ // that metric into an ambiguous capability choice.
1368
+ byFingerprint.set(`${capability.sourceFingerprint}:${capability.metricId}`, capability);
1369
+ }
1370
+ return [...byFingerprint.values()];
1371
+ }
1372
+ /**
1373
+ * Retrieval cards are scored before the role-balanced same-snapshot extension
1374
+ * is admitted. A semantic metric can therefore retain `partial` even after
1375
+ * the host has selected its one proven MetricFlow grouping field. Do not make
1376
+ * `partial` generally executable: advance it only when the immutable frozen
1377
+ * tuple proves the exact metric, every required role, and the extension's
1378
+ * metric/dimension identity from the same snapshot.
1379
+ *
1380
+ * This is semantic-adapter authority only. It never authorizes a physical join
1381
+ * or exploratory SQL, and an omitted output, unbound role, mismatched metric,
1382
+ * mismatched dimension, unsupported time grain, or absent extension remains
1383
+ * blocked for the cascade to evaluate safely.
1384
+ */
1385
+ function sameSnapshotSemanticExtensionProvesFrozenTuple(input) {
1386
+ const { execution, capability } = input;
1387
+ if (execution.kind !== 'semantic_metric' || execution.compatibility !== 'partial' || !capability)
1388
+ return false;
1389
+ if (!semanticCapabilityProvesFrozenTuple(input))
1390
+ return false;
1391
+ // Explicit output terms have their own source-column result proof. A
1392
+ // semantic role extension does not prove such an extra projection.
1393
+ if (input.requiredOutputs.length > 0 || input.measures.length === 0)
1394
+ return false;
1395
+ const metricAuthorityIds = new Set([
1396
+ execution.id,
1397
+ execution.qualifiedId,
1398
+ capability.metricId,
1399
+ ...capability.measureIds,
1400
+ ].filter((id) => Boolean(id)));
1401
+ if (!input.measures.every((binding) => binding.status === 'resolved'
1402
+ && Boolean(binding.qualifiedId)
1403
+ && metricAuthorityIds.has(binding.qualifiedId)))
1404
+ return false;
1405
+ return input.candidates.some((candidate) => {
1406
+ const extension = candidate.sameSnapshotRoleExtension;
1407
+ if (!extension
1408
+ || extension.version !== 1
1409
+ || extension.role !== 'categorical_dimension'
1410
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
1411
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
1412
+ || !metricAuthorityIds.has(extension.metricId)
1413
+ || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
1414
+ return false;
1415
+ return input.dimensions.some((binding) => binding.qualifiedId === extension.dimensionId);
1416
+ });
1417
+ }
742
1418
  function normalize(value) {
743
1419
  return value.toLowerCase()
744
1420
  .replace(/%/g, ' percentage ')
@@ -747,6 +1423,40 @@ function normalize(value) {
747
1423
  .replace(/\s+/g, ' ')
748
1424
  .trim();
749
1425
  }
1426
+ /**
1427
+ * Resolve a host-owned display-key requirement against a capability that uses
1428
+ * the generic entity field as its declared display output. This is a
1429
+ * deterministic capability projection, not an interpretation of the user
1430
+ * question and never mutates the requirement seed.
1431
+ */
1432
+ function collapseHostEntityDisplayBindings(bindings, seed) {
1433
+ if (!seed)
1434
+ return bindings;
1435
+ const displayOnlyTerms = new Set(seed.requirements.entityDisplayTerms
1436
+ .map(normalize)
1437
+ .filter((term) => term.length > 0 && !seed.requirements.dimensions.some((dimension) => normalize(dimension) === term)));
1438
+ const entityTerms = seed.requirements.entityTerms.map(normalize).filter(Boolean);
1439
+ if (displayOnlyTerms.size === 0 || entityTerms.length === 0)
1440
+ return bindings;
1441
+ const isEntityCompanion = (binding) => {
1442
+ const requested = normalize(binding.requested);
1443
+ return entityTerms.some((entity) => requested === entity || requested.startsWith(`${entity} `));
1444
+ };
1445
+ return bindings.filter((binding, index, all) => {
1446
+ const requested = normalize(binding.requested);
1447
+ if (!displayOnlyTerms.has(requested))
1448
+ return true;
1449
+ const companion = all.find((other, otherIndex) => otherIndex !== index && isEntityCompanion(other));
1450
+ // An ambiguous generic entity is still the sole decision the reader must
1451
+ // make. Do not add an unrelated "account name" unresolved gap beside the
1452
+ // two qualified account choices.
1453
+ if (!companion || companion.status === 'unresolved')
1454
+ return true;
1455
+ if (binding.status === 'unresolved')
1456
+ return false;
1457
+ return binding.status !== 'resolved' || binding.qualifiedId !== companion.qualifiedId;
1458
+ });
1459
+ }
750
1460
  /** Resolve common analytical ranges once so no executor reinterprets "last month". */
751
1461
  export function resolvePlanTimeRange(expression, referenceTime) {
752
1462
  const text = expression.trim().toLowerCase();
@@ -812,6 +1522,20 @@ function addUnits(value, unit, count) {
812
1522
  function uniqueSorted(values) {
813
1523
  return [...new Set(values.filter(Boolean))].sort();
814
1524
  }
1525
+ function sameNormalizedTerms(left, right) {
1526
+ const normalizeTerms = (values) => uniqueSorted(values.map(normalize));
1527
+ return normalizeTerms(left).join('\u0000') === normalizeTerms(right).join('\u0000');
1528
+ }
1529
+ function uniqueCandidatesByCanonicalId(candidates, canonicalId) {
1530
+ const seen = new Set();
1531
+ return candidates.filter((candidate) => {
1532
+ const id = canonicalId(candidate);
1533
+ if (!id || seen.has(id))
1534
+ return false;
1535
+ seen.add(id);
1536
+ return true;
1537
+ });
1538
+ }
815
1539
  function sha256(value) {
816
1540
  return createHash('sha256').update(value).digest('hex');
817
1541
  }