@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
@@ -13,6 +13,133 @@
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
15
  export const ANALYTICAL_ORCHESTRATION_CONTRACT_VERSION = 1;
16
+ /** Build the immutable host request tuple before a meaning model can respond. */
17
+ export function buildAnalyticalRequirementSeedV1(input) {
18
+ // Retrieval/parser output is intentionally broad: it may contain useful
19
+ // context from a prior turn, vector hit, or search expansion. It is not an
20
+ // authority for a new free-text request. Keep only refinements that the
21
+ // source question itself demonstrates before they can contribute to the
22
+ // frozen host tuple. Structured clarification selections are merged by the
23
+ // router into `requirements` before this function is called.
24
+ const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
25
+ const requirements = input.requirements ?? buildAnalyticalRequirementSet({
26
+ question: input.question,
27
+ parsedIntent: parsed,
28
+ });
29
+ // Order and limit are lexical requirements, not parser defaults. In
30
+ // particular, a prior ranking must not turn a complete new question into a
31
+ // top-N query just because retrieval retained an old `limit` or `order`.
32
+ const order = requirements.ranking
33
+ ? requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'
34
+ : undefined;
35
+ const limit = requirements.ranking?.limit;
36
+ const filters = [...(parsed?.filters ?? [])].map((filter) => ({ field: filter.field, value: filter.value }));
37
+ if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
38
+ && !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
39
+ filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
40
+ }
41
+ return {
42
+ version: 1,
43
+ sourceQuestion: input.question,
44
+ requirements,
45
+ queryIntent: {
46
+ measures: [...requirements.measures],
47
+ // A ranking's entity display key is an execution requirement, not a
48
+ // prompt nicety. Keep it in the host-owned query tuple, but do not put
49
+ // the broad entity noun (for example `customer`) in the categorical
50
+ // dimension lane. A metric can legitimately expose customer type and
51
+ // customer order number as groupings; neither is interchangeable with
52
+ // the requested customer display/rank key. The frame resolves the
53
+ // display term against the selected metric's native display/rank role.
54
+ dimensions: [...new Set([
55
+ ...categoricalDimensionRequirementTerms(requirements),
56
+ ...requirements.entityDisplayTerms,
57
+ ])],
58
+ filters,
59
+ ...(parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
60
+ ...(requirements.time?.grain
61
+ ? { timeGrain: requirements.time.grain }
62
+ : parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
63
+ ...(order ? { order } : {}),
64
+ ...(limit !== undefined ? { limit } : {}),
65
+ // A calendar/date role is declared snapshot metadata, never an ID
66
+ // copied from retrieval/parser evidence.
67
+ ...(input.fiscalCalendar?.id ? { fiscalCalendarId: input.fiscalCalendar.id } : {}),
68
+ ...(input.fiscalCalendar?.dateRoleId ? { fiscalDateRoleId: input.fiscalCalendar.dateRoleId } : {}),
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Return only parser refinements whose business words occur in the current
74
+ * source question. This deliberately does not try to recover previous turn
75
+ * context: continuation is represented by a server-issued structured choice
76
+ * and merged separately by the host. The helper is exported for regression
77
+ * tests and for router paths that construct a requirement set before a seed.
78
+ */
79
+ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
80
+ if (!parsedIntent)
81
+ return undefined;
82
+ // These are intentionally tiny, product-wide vocabulary aliases rather
83
+ // than semantic guessing. They let a retrieval parser retain the same
84
+ // current-turn business phrase (`drink revenue` for `beverage revenue`, or
85
+ // `sales` for `revenue`) while still rejecting an unrelated prior-turn
86
+ // phrase such as `rollover balance`. Do not add customer/model-specific
87
+ // synonyms here: those require a qualified selected candidate and an
88
+ // override receipt at the meaning boundary.
89
+ const canonicalGroundingToken = (term) => {
90
+ if (term === 'sale' || term === 'sales')
91
+ return 'revenue';
92
+ if (term === 'drink' || term === 'drinks')
93
+ return 'beverage';
94
+ return term;
95
+ };
96
+ const questionTerms = new Set(normalizeRequirementTerm(question)
97
+ .split(' ')
98
+ .filter((term) => term.length > 1)
99
+ .map(canonicalGroundingToken));
100
+ const groundedTerm = (value) => {
101
+ const terms = normalizeRequirementTerm(value ?? '')
102
+ .split(' ')
103
+ .filter((term) => term.length > 1 && !/^(?:the|a|an|by|for|with|and|or|of|to|in|on|at)$/.test(term))
104
+ .map(canonicalGroundingToken);
105
+ return terms.length > 0 && terms.every((term) => {
106
+ if (questionTerms.has(term))
107
+ return true;
108
+ // Preserve only a trivial singular/plural normalization. Anything more
109
+ // permissive would let a stale retrieved phrase become a new request.
110
+ return term.endsWith('s')
111
+ ? questionTerms.has(term.slice(0, -1))
112
+ : questionTerms.has(`${term}s`);
113
+ });
114
+ };
115
+ const timeRangeGrounded = (value) => {
116
+ const normalized = normalizeRequirementTerm(value ?? '');
117
+ if (!normalized)
118
+ return false;
119
+ if (groundedTerm(normalized))
120
+ return true;
121
+ return /^(?:last|previous|past) \d+ (?:day|days|week|weeks|month|months|quarter|quarters|year|years)$/.test(normalized)
122
+ && normalizeRequirementTerm(question).includes(normalized);
123
+ };
124
+ const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
125
+ const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
126
+ // A parser may contribute the column that a reader's explicitly named value
127
+ // belongs to, but the value itself must appear in this request. This drops
128
+ // stale rollover-balance/member filters while retaining a current named
129
+ // member that the host can bind to a qualified field.
130
+ const filters = (parsedIntent.filters ?? []).filter((filter) => groundedTerm(filter.value));
131
+ const timeRange = timeRangeGrounded(parsedIntent.timeRange)
132
+ ? parsedIntent.timeRange
133
+ : undefined;
134
+ return {
135
+ ...(measures.length > 0 ? { measures } : {}),
136
+ ...(dimensions.length > 0 ? { dimensions } : {}),
137
+ ...(filters.length > 0 ? { filters } : {}),
138
+ ...(timeRange ? { timeRange } : {}),
139
+ // Grain/ranking/limit are derived deterministically from the source
140
+ // question below. Never promote parser values by themselves.
141
+ };
142
+ }
16
143
  /**
17
144
  * Small, explainable intent vocabulary used before the physical plan exists.
18
145
  * It is deliberately not a second semantic matcher: candidate identity still
@@ -41,6 +168,768 @@ export function inferAnalyticalTurnKind(question) {
41
168
  return 'lookup';
42
169
  return 'aggregation';
43
170
  }
171
+ function normalizeRequirementTerm(value) {
172
+ return value.toLowerCase()
173
+ .replace(/[_./:-]+/g, ' ')
174
+ .replace(/[^a-z0-9 ]+/g, ' ')
175
+ .replace(/\s+/g, ' ')
176
+ .trim();
177
+ }
178
+ function uniqueRequirementTerms(values) {
179
+ return [...new Set(values
180
+ .filter((value) => typeof value === 'string')
181
+ .map(normalizeRequirementTerm)
182
+ .filter(Boolean))];
183
+ }
184
+ /**
185
+ * Entity/display terms are represented in the historical `dimensions` seed
186
+ * so ranking plans can retain their requested grain. They must not consume
187
+ * the separate categorical-dimension admission lane. For example, in "top
188
+ * customers by product category", `customer` is the entity/rank role while
189
+ * `product category` is the required categorical grouping role.
190
+ */
191
+ export function categoricalDimensionRequirementTerms(requirements) {
192
+ const entityTerms = new Set(uniqueRequirementTerms([
193
+ ...requirements.entityTerms,
194
+ ...requirements.entityDisplayTerms,
195
+ ]));
196
+ return uniqueRequirementTerms(requirements.dimensions)
197
+ .filter((term) => !entityTerms.has(term));
198
+ }
199
+ /**
200
+ * A small, typed vocabulary bridge for categorical field identities. It is
201
+ * intentionally not a general synonym engine: only the field-kind suffix is
202
+ * canonicalized, while the scoped business noun must still match. Thus
203
+ * `product category` can bind the snapshot-declared `product_type`, whereas
204
+ * `customer_type` cannot satisfy it. A bare `category` remains potentially
205
+ * ambiguous when more than one qualified `*_type` field exists.
206
+ */
207
+ export function categoricalDimensionTermsMatch(left, right) {
208
+ const normalizedLeft = normalizeRequirementTerm(left);
209
+ const normalizedRight = normalizeRequirementTerm(right);
210
+ if (!normalizedLeft || !normalizedRight)
211
+ return false;
212
+ const phraseMatch = (a, b) => a === b || a.endsWith(` ${b}`) || b.endsWith(` ${a}`);
213
+ if (phraseMatch(normalizedLeft, normalizedRight))
214
+ return true;
215
+ const canonicalizeKind = (value) => value
216
+ .replace(/\bcategories\b/g, 'type')
217
+ .replace(/\bcategory\b/g, 'type');
218
+ return phraseMatch(canonicalizeKind(normalizedLeft), canonicalizeKind(normalizedRight));
219
+ }
220
+ function isTemporalTerm(term) {
221
+ return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
222
+ }
223
+ /**
224
+ * Parser output occasionally retains the grammatical wrapper around an
225
+ * aggregation (for example "count for each customer") as though it were a
226
+ * second metric. The stable requirement is `count`; the rest describes the
227
+ * requested grain and is already represented by the entity/dimension roles.
228
+ * Keeping the wrapper makes a physically complete customer table look
229
+ * incomplete and prematurely terminates the pre-freeze cascade.
230
+ */
231
+ function isStructuralMeasurePhrase(value) {
232
+ const term = normalizeRequirementTerm(value);
233
+ return /^(?:count|sum|total|average|avg)?\s*for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term)
234
+ || /^for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term);
235
+ }
236
+ const AGGREGATION_REQUIREMENT_OPERATORS = new Set([
237
+ 'total', 'sum', 'average', 'avg', 'minimum', 'min', 'maximum', 'max',
238
+ ]);
239
+ /**
240
+ * A parsed intent may preserve every grammatical fragment of an aggregate
241
+ * request ("total", "total supply cost", "supply", and "product"). Those
242
+ * fragments are useful while retrieving, but they are not independent
243
+ * physical requirements. Normalize only the explicit aggregation + grouping
244
+ * construction so ordinary named metrics such as `total_revenue` keep their
245
+ * authored identity.
246
+ */
247
+ function typedAggregationRequirementRoles(question) {
248
+ const match = /\b(?:total|sum|average|avg|minimum|min|maximum|max)\s+([a-z][a-z0-9_ -]{1,60}?)\s+(?:per|by|for\s+each)\s+([a-z][a-z0-9_-]*)\b/i.exec(question);
249
+ const measure = match?.[1] ? normalizeRequirementTerm(match[1]) : '';
250
+ const dimension = match?.[2] ? normalizeRequirementTerm(match[2]) : '';
251
+ return measure && dimension ? { measure, dimension } : undefined;
252
+ }
253
+ function normalizedTypedAggregationRequirements(input) {
254
+ const typed = typedAggregationRequirementRoles(input.question);
255
+ if (!typed)
256
+ return input;
257
+ const measureParts = new Set(typed.measure.split(' ').filter(Boolean));
258
+ const measures = uniqueRequirementTerms([
259
+ typed.measure,
260
+ ...input.measures.filter((value) => {
261
+ const normalized = normalizeRequirementTerm(value);
262
+ return normalized !== typed.measure
263
+ && !AGGREGATION_REQUIREMENT_OPERATORS.has(normalized)
264
+ && normalized !== `total ${typed.measure}`
265
+ && !(normalized.split(' ').length === 1 && measureParts.has(normalized));
266
+ }),
267
+ ]);
268
+ const dimensions = uniqueRequirementTerms([
269
+ typed.dimension,
270
+ ...input.dimensions.filter((value) => {
271
+ const normalized = normalizeRequirementTerm(value);
272
+ return normalized !== typed.dimension
273
+ && !(normalized.split(' ').length === 1 && measureParts.has(normalized));
274
+ }),
275
+ ]);
276
+ return { measures, dimensions };
277
+ }
278
+ /**
279
+ * Keep grammatical wrappers out of the physical tuple. In particular, a
280
+ * parser can return `sales based on the region` as a dimension for a simple
281
+ * revenue-by-region request. That is neither a business dimension nor an
282
+ * object DQL may report as absent.
283
+ */
284
+ function normalizeAnalyticalDimensionTerms(question, values) {
285
+ const hasRegion = /\b(?:by|based\s+on(?:\s+the)?|across|per)\s+(?:the\s+)?region\b/i.test(question);
286
+ const hasProductCategory = /\bproduct\s+categor(?:y|ies)\b/i.test(question);
287
+ // A planner can surface the noun from a projected field as a grouping
288
+ // dimension (for example `product` from “with product ID and product
289
+ // price”). An output is not a `by product` group. Keep the noun only when
290
+ // the reader actually supplied a grouping construction; otherwise the
291
+ // host-owned row-level/output tuple would acquire a fake dimension and
292
+ // make an otherwise single-table exploratory plan ambiguous.
293
+ const outputRoots = new Set(explicitOutputTerms(question)
294
+ .map((term) => term.replace(/\s+(?:id|name|price)$/i, '').trim())
295
+ .filter(Boolean));
296
+ const isExplicitGroupingRoot = (term) => {
297
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
+ return new RegExp(`\\b(?:by|per|across|for\\s+each)\\s+(?:the\\s+)?${escaped}(?:s)?\\b`, 'i').test(question);
299
+ };
300
+ const normalized = uniqueRequirementTerms([...values])
301
+ .filter((value) => !/\b(?:revenue|sales)\b.*\b(?:based\s+on|by)\b.*\bregion\b/.test(value))
302
+ .filter((value) => !(hasProductCategory && /^(?:product|category)$/.test(value)))
303
+ .filter((value) => !outputRoots.has(value) || isExplicitGroupingRoot(value));
304
+ return uniqueRequirementTerms([
305
+ ...normalized,
306
+ ...(hasRegion ? ['region'] : []),
307
+ ...(hasProductCategory ? ['product category'] : []),
308
+ ]);
309
+ }
310
+ function explicitOutputTerms(question) {
311
+ const terms = [...question.matchAll(/\b(?:order|product|customer|account)\s+(?:id|name|price)\b/gi)]
312
+ .map((match) => match[0] ?? '');
313
+ return uniqueRequirementTerms(terms);
314
+ }
315
+ /**
316
+ * Normalize grammatical aggregation wrappers before they become a plan
317
+ * requirement. Retrieval/parser output is allowed to retain useful search
318
+ * phrases, but an immutable plan must never treat "count for each customer"
319
+ * or "for each customer" as separate physical measures. The grouping entity
320
+ * is represented by the dimension/entity roles instead.
321
+ *
322
+ * `order count for each customer` is the common prose form for a count
323
+ * aggregation at customer grain. Keep the aggregation (`count`) and remove
324
+ * the object noun (`order`) only for that exact grouped construction; a named
325
+ * metric such as `order_value` remains untouched.
326
+ */
327
+ export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
328
+ const normalizedQuestion = normalizeRequirementTerm(question);
329
+ const groupedOrderCount = /\borders?\s+count\s+(?:for|per)\s+(?:each|every)\s+(?:the\s+)?(?:account|customer|client|company|product|order|item|row)s?\b/.test(normalizedQuestion);
330
+ // A parser often singularizes the business alias `sales` into `sale`. That
331
+ // is not a second measure beside revenue: it is the same current-question
332
+ // request. Canonicalize only that standalone vocabulary alias here; named
333
+ // measures such as `sales_tax` or `sales_pipeline` keep their identity.
334
+ const canonicalMeasureAlias = (value) => {
335
+ const normalized = normalizeRequirementTerm(value);
336
+ return normalized === 'sale' || normalized === 'sales' ? 'revenue' : value;
337
+ };
338
+ const terms = values
339
+ .map(canonicalMeasureAlias)
340
+ .filter((value) => !isStructuralMeasurePhrase(value))
341
+ .filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
342
+ if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
343
+ terms.push('count');
344
+ }
345
+ // An inherited measure can already be a stable semantic/dbt identity. Keep
346
+ // that identity intact for the planner/meaning handoff; matching and display
347
+ // have their own normalizers. Rewriting `total_consumption_units` to prose
348
+ // here lost the only sticky reference a measure-less refinement carried.
349
+ if (options.preserveIdentity) {
350
+ const seen = new Set();
351
+ return terms.flatMap((value) => {
352
+ const exact = value.replace(/\s+/g, ' ').trim();
353
+ const normalized = normalizeRequirementTerm(exact);
354
+ if (!exact || !normalized || seen.has(normalized))
355
+ return [];
356
+ seen.add(normalized);
357
+ return [exact];
358
+ });
359
+ }
360
+ return uniqueRequirementTerms(terms);
361
+ }
362
+ /**
363
+ * Parsed measure phrases are the most specific typed evidence available before
364
+ * meaning resolution. A lexical root is useful only when the parser found no
365
+ * phrase that already owns it: adding both `beverage revenue` and `revenue`
366
+ * turns one requested metric into two and incorrectly rejects a block whose
367
+ * own declared output is `beverage_revenue`. The same holds for `order count`
368
+ * and its generic `count` root; grouped prose is normalized to `count` before
369
+ * this helper runs, so retaining both is neither necessary nor correct.
370
+ */
371
+ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
372
+ const lexical = ['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count']
373
+ .filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
374
+ return lexical.filter((term) => {
375
+ if (term === 'count') {
376
+ return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
377
+ }
378
+ const token = term === 'refunds' ? 'refund' : term;
379
+ return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
380
+ .split(' ')
381
+ .some((word) => word === token || (token === 'refund' && word === 'refunds')));
382
+ });
383
+ }
384
+ /**
385
+ * A ranking question can contain a business qualifier immediately before the
386
+ * ranked entity (for example, "BCM customers") as well as the measure that
387
+ * actually orders the result ("highest revenue"). The retrieval parser keeps
388
+ * both phrases because both are useful for recall, but they are not equivalent
389
+ * plan requirements. Prefer a direct ranking clause over the broad parser
390
+ * hint before any candidate is admitted as a metric.
391
+ *
392
+ * This deliberately remains narrow. It only disambiguates when the parser
393
+ * supplied competing measures and the user also wrote an explicit comparator;
394
+ * parser-absent and already-unambiguous ranking requests retain their existing
395
+ * normal meaning-resolution path.
396
+ */
397
+ function explicitRankingMeasureTerms(question, parsedMeasures) {
398
+ const parsed = uniqueRequirementTerms([...parsedMeasures]);
399
+ if (parsed.length < 2)
400
+ return [];
401
+ const phrases = [];
402
+ const endOfMeasure = String.raw `(?=\s+(?:across|among|for|per|in|where|during|over|with|that|which|who|and|or)\b|[?.!,;]|$)`;
403
+ for (const pattern of [
404
+ new RegExp(String.raw `\b(?:highest|lowest|most|least)\s+(?:the\s+)?([a-z][a-z0-9_. -]{0,80}?)${endOfMeasure}`, 'gi'),
405
+ ]) {
406
+ for (const match of question.matchAll(pattern)) {
407
+ const phrase = normalizeRequirementTerm(match[1] ?? '');
408
+ if (phrase && !isTemporalTerm(phrase))
409
+ phrases.push(phrase);
410
+ }
411
+ }
412
+ const direct = uniqueRequirementTerms(phrases);
413
+ if (direct.length === 0)
414
+ return [];
415
+ // Preserve the parser's more stable authored phrase when it is the same
416
+ // measure. This avoids replacing `net revenue` with a looser lexical root,
417
+ // while still removing an entity modifier such as `BCM` in `BCM customers`.
418
+ const matchedParsed = parsed.filter((measure) => direct.some((phrase) => measure === phrase || measure.includes(phrase) || phrase.includes(measure)));
419
+ return matchedParsed.length > 0 ? matchedParsed : direct;
420
+ }
421
+ /**
422
+ * Parse only stable analytical roles. This is purposefully narrower than an
423
+ * LLM interpretation: unknown business phrases remain available to the normal
424
+ * bounded meaning resolver instead of being guessed here.
425
+ */
426
+ export function buildAnalyticalRequirementSet(input) {
427
+ const question = input.question;
428
+ const lower = question.toLowerCase();
429
+ // Parser/retrieval evidence may be broad or stale. A requirement set is
430
+ // host authority, so only source-question-grounded refinements may enter it.
431
+ const parsed = currentQuestionGroundedParsedIntent(question, input.parsedIntent);
432
+ const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
433
+ const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
434
+ const grain = grainWord === 'daily' ? 'day'
435
+ : grainWord === 'weekly' ? 'week'
436
+ : grainWord === 'monthly' ? 'month'
437
+ : grainWord === 'quarterly' ? 'quarter'
438
+ : grainWord === 'yearly' ? 'year'
439
+ : /^(day|week|month|quarter|year)$/.test(grainWord) ? grainWord
440
+ : undefined;
441
+ const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
442
+ const fiscalPeriod = fiscal ? `FY${fiscal[1] ?? fiscal[2]}`.toUpperCase() : undefined;
443
+ const ranking = lower.match(/\b(top|bottom|highest|lowest|most|least|expensive|cheapest)\s*(\d+)?\b/i);
444
+ const leadingOrdinalRanking = lower.match(/\b(\d+)\s+(?:most|least|expensive|cheapest)\b/i);
445
+ const requestedDimensions = normalizeAnalyticalDimensionTerms(question, parsed?.dimensions ?? []);
446
+ const entityTerms = uniqueRequirementTerms([
447
+ ...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
448
+ ]).map((term) => term.replace(/s$/, ''));
449
+ // A ranking result needs a human-readable entity output even when the
450
+ // wording starts with "what" rather than "who" or "which". Treat the
451
+ // entity's display key as a required role for `top accounts` / `top
452
+ // customers`; an entity key, owner field, or sentiment attribute is not a
453
+ // substitute for the result label.
454
+ const entityDisplayTerms = (/\b(?:who|which)\b/i.test(question) || Boolean(ranking && entityTerms.length > 0))
455
+ ? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
456
+ : [];
457
+ // "this amount" is a deictic reference to a prior result, not a request to
458
+ // choose an `amount` metric. Treating it as a new explicit measure made a
459
+ // compositional follow-up reject every otherwise-valid display/predicate
460
+ // option. Concrete metric words remain typed requirements, including the
461
+ // common revenue/refunds pair used by multi-metric requests.
462
+ const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
463
+ // `sales` is a common business synonym for revenue. Do not add the broad
464
+ // revenue root merely because a named metric contains it (`beverage revenue`)
465
+ // or an exact certified block would suddenly look multi-metric.
466
+ const salesIsRevenueAlias = /\bsales\b/i.test(question);
467
+ const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
468
+ .filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
469
+ const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
470
+ ...parsedMeasures,
471
+ ...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
472
+ ...(salesIsRevenueAlias ? ['revenue'] : []),
473
+ ...(/\b(?:most|highest|expensive)\b.*\bproduct\s+price\b|\bproduct\s+price\b.*\b(?:most|highest|expensive)\b/i.test(question)
474
+ ? ['product price']
475
+ : []),
476
+ ...(!deicticAmount
477
+ && /\bamount\b/i.test(question)
478
+ && !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
479
+ ? ['amount']
480
+ : []),
481
+ ]);
482
+ const typedRequirements = normalizedTypedAggregationRequirements({
483
+ question,
484
+ measures: parsedMeasuresWithLexicalTerms,
485
+ dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
486
+ });
487
+ const explicitRankingMeasures = ranking
488
+ ? explicitRankingMeasureTerms(question, typedRequirements.measures)
489
+ : [];
490
+ // A direct ranking clause is the explicit analytical measure. Keep broad
491
+ // parser/retrieval phrases out of the execution tuple so a contextual term
492
+ // cannot become a second ranking metric or force a false clarification.
493
+ const measures = explicitRankingMeasures.length > 0
494
+ ? explicitRankingMeasures
495
+ : typedRequirements.measures;
496
+ const dimensions = typedRequirements.dimensions;
497
+ const rankingMetricTerms = ranking ? measures : [];
498
+ // A context planner supplies its own safety default (`topN: 10`) for bare
499
+ // rankings. It is a useful execution bound, but it is not user intent. Read
500
+ // an explicit count only from the actual question so the cascade/answer
501
+ // receipt can disclose that a bare “top” used DQL's default rather than
502
+ // misleadingly presenting it as a requested limit.
503
+ const wordRankingLimit = lower.match(/\b(?:top|bottom|highest|lowest|most|least|expensive|cheapest)\s+(one|two|three|four|five|six|seven|eight|nine|ten)\b|\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:most|least|expensive|cheapest)\b/i)?.slice(1).find(Boolean)?.toLowerCase();
504
+ const explicitWordLimit = {
505
+ one: 1, two: 2, three: 3, four: 4, five: 5,
506
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
507
+ };
508
+ const explicitLimit = ranking?.[2]
509
+ ? Number(ranking[2])
510
+ : leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
511
+ : wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
512
+ const time = grain || fiscalPeriod
513
+ ? {
514
+ role: grain ? 'time_axis' : 'time_filter',
515
+ ...(grain ? { grain: grain } : {}),
516
+ ...(fiscalPeriod ? { fiscalPeriod } : {}),
517
+ requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
518
+ }
519
+ : undefined;
520
+ return {
521
+ version: 1,
522
+ measures,
523
+ dimensions,
524
+ entityTerms,
525
+ entityDisplayTerms,
526
+ memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
527
+ ...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
528
+ ...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
529
+ ...(ranking
530
+ ? {
531
+ ranking: {
532
+ metricTerms: rankingMetricTerms,
533
+ entityTerms,
534
+ direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
535
+ limit: explicitLimit ?? 10,
536
+ defaultedLimit: explicitLimit === undefined,
537
+ },
538
+ }
539
+ : {}),
540
+ ...(time ? { time } : {}),
541
+ };
542
+ }
543
+ const DECLARED_CANDIDATE_ROLE_ALIASES = [
544
+ ['metric', ['metric']],
545
+ ['entity_key', ['entity key', 'entity id']],
546
+ ['entity_label', ['entity label', 'display key', 'display label']],
547
+ ['categorical_dimension', ['categorical dimension', 'category dimension']],
548
+ ['time_dimension', ['time dimension', 'date dimension']],
549
+ ['member', ['member']],
550
+ ['relationship', ['relationship']],
551
+ ['context', ['context']],
552
+ ];
553
+ /**
554
+ * Only an authored declaration may let an object fill an additional role. A
555
+ * metric's capability lists dimensions, time grains, and relationship paths it
556
+ * *uses*; that does not make the metric itself a display key, time column, or
557
+ * relationship candidate. Compatibility facts are snapshot-authored metadata
558
+ * and therefore the only additive role declaration accepted here.
559
+ */
560
+ function explicitlyDeclaredCandidateRoles(candidate) {
561
+ const declared = new Set();
562
+ for (const fact of candidate.compatibilityFacts ?? []) {
563
+ const normalized = normalizeRequirementTerm(fact);
564
+ if (!/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\b/.test(normalized))
565
+ continue;
566
+ const suffix = normalized.replace(/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\s*/, '');
567
+ for (const [role, aliases] of DECLARED_CANDIDATE_ROLE_ALIASES) {
568
+ if (aliases.some((alias) => new RegExp(`(?:^| )${alias}(?:$| )`).test(suffix)))
569
+ declared.add(role);
570
+ }
571
+ }
572
+ return declared;
573
+ }
574
+ function intrinsicCandidateIdentity(candidate) {
575
+ return uniqueRequirementTerms([
576
+ candidate.id,
577
+ candidate.qualifiedId,
578
+ candidate.name,
579
+ ]).join(' ');
580
+ }
581
+ /**
582
+ * Keep account display-key selection separate from common account attributes.
583
+ * Candidate names originate in dbt/semantic identifiers, so underscores and
584
+ * dots must be normalized before testing (`account_sentiment_rating` is just
585
+ * as much an attribute as "Account Sentiment Rating").
586
+ */
587
+ export function hasEntityAttributeTerm(value) {
588
+ return /\b(?:owner|sentiment|email)\b/i.test(normalizeRequirementTerm(value));
589
+ }
590
+ export function isEntityAttributeCandidate(candidate) {
591
+ return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
592
+ }
593
+ /** Classify the role an already-qualified candidate may fill. */
594
+ export function evidenceCandidateRoles(candidate) {
595
+ const identity = intrinsicCandidateIdentity(candidate);
596
+ const roles = new Set();
597
+ const physicalColumn = candidate.kind === 'sql_column';
598
+ const metricCandidate = candidate.kind === 'semantic_metric'
599
+ || candidate.semanticObjectType === 'metric'
600
+ || candidate.semanticObjectType === 'measure'
601
+ || /\bmetric\b/.test(identity)
602
+ || (physicalColumn && /\b(?:revenue|amount|count|rate|bcm|spend|cost|margin|total)\b/.test(identity));
603
+ if (metricCandidate)
604
+ roles.add('metric');
605
+ // Capability metadata belongs to the metric's execution contract. It must
606
+ // not be treated as the metric object's own entity, display, time, or join
607
+ // identity. Explicit snapshot metadata is the only exception.
608
+ if (!metricCandidate) {
609
+ if (candidate.semanticObjectType === 'entity'
610
+ || /(?:^| )(?:account|customer|client|company) (?:id|key)\b/.test(identity)
611
+ || /\bentity\b/.test(identity))
612
+ roles.add('entity_key');
613
+ // Entity identity (for example `semantic:entity:account`) proves an
614
+ // entity key/grain but not the field a person can read in a ranking
615
+ // result. Require an intrinsic display-name declaration instead of
616
+ // allowing every identifier that merely contains "account" or
617
+ // "customer" to fill the entity-label role. Explicit authored role
618
+ // facts below remain the only additive exception.
619
+ if (/\b(?:account|customer|client|company)\b/.test(identity)
620
+ && /\b(?:name|label|display)\b/.test(identity)
621
+ && !hasEntityAttributeTerm(identity))
622
+ roles.add('entity_label');
623
+ if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b|\bfiscal\b|\bperiod\b)/.test(identity)
624
+ || (candidate.timeGrains?.length ?? 0) > 0)
625
+ roles.add('time_dimension');
626
+ if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
627
+ || /\b(?:relationship|join|bridge)\b/.test(identity))
628
+ roles.add('relationship');
629
+ if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension'
630
+ || (physicalColumn && /\b(?:competitor|region|category|segment|status|type|owner|sentiment|active)\b/.test(identity)))
631
+ roles.add('categorical_dimension');
632
+ }
633
+ for (const role of explicitlyDeclaredCandidateRoles(candidate))
634
+ roles.add(role);
635
+ if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
636
+ roles.add('context');
637
+ if (roles.size === 0)
638
+ roles.add('context');
639
+ return [...roles];
640
+ }
641
+ function candidateMatchesTerms(candidate, terms, options = {}) {
642
+ if (terms.length === 0)
643
+ return false;
644
+ const identity = uniqueRequirementTerms([
645
+ candidate.id,
646
+ candidate.qualifiedId,
647
+ candidate.name,
648
+ ...(candidate.aliases ?? []),
649
+ ...(candidate.dimensions ?? []),
650
+ ]).join(' ');
651
+ if (terms.some((term) => identity.includes(term) || term.includes(identity)))
652
+ return true;
653
+ if (options.categoricalDimension === true
654
+ && terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
655
+ return true;
656
+ return options.categoricalDimension === true
657
+ && candidateMatchesCategoricalDimensionRequirement(candidate, terms);
658
+ }
659
+ /**
660
+ * A metric's attached dimensions, source model, and relationship path are
661
+ * execution context. They are deliberately useful for binding a complete
662
+ * plan, but must never make one metric match the name of another. In
663
+ * particular, `bcm_run_rate` from an `account_revenue` model is not the
664
+ * `revenue` ranking measure merely because its source-model identity contains
665
+ * that word.
666
+ */
667
+ function metricCandidateIdentityTerms(candidate) {
668
+ const terminalMetricIdentity = (value) => {
669
+ if (!value)
670
+ return undefined;
671
+ const namespaceLeaf = value.split(':').filter(Boolean).at(-1) ?? value;
672
+ const metricLeaf = namespaceLeaf.split(/[./]/).filter(Boolean).at(-1) ?? namespaceLeaf;
673
+ return normalizeRequirementTerm(metricLeaf);
674
+ };
675
+ return uniqueRequirementTerms([
676
+ // Metadata cards sometimes use their source-qualified identifier as the
677
+ // display label (for example `account_revenue.bcm_run_rate`). Treat that
678
+ // exactly like an ID: its terminal metric leaf is intrinsic identity and
679
+ // its model prefix is execution context. A human label such as `Total
680
+ // Revenue` has no namespace separator and is retained intact.
681
+ terminalMetricIdentity(candidate.name),
682
+ ...(candidate.aliases ?? []).map(terminalMetricIdentity),
683
+ terminalMetricIdentity(candidate.id),
684
+ terminalMetricIdentity(candidate.qualifiedId),
685
+ ]);
686
+ }
687
+ function metricCandidateMatchesTerms(candidate, terms) {
688
+ const identities = metricCandidateIdentityTerms(candidate);
689
+ return terms.some((term) => {
690
+ const normalizedTerm = normalizeRequirementTerm(term);
691
+ if (!normalizedTerm)
692
+ return false;
693
+ return identities.some((identity) => identity === normalizedTerm
694
+ || identity.endsWith(` ${normalizedTerm}`)
695
+ || normalizedTerm.endsWith(` ${identity}`));
696
+ });
697
+ }
698
+ /**
699
+ * A direct ranking measure is authoritative for metric admission. Other
700
+ * retrieved metrics remain visible in the lifecycle receipt, but they cannot
701
+ * become a second metric choice merely because they are correlated with the
702
+ * entity phrase in the question.
703
+ */
704
+ export function candidateConflictsWithExplicitRankingMeasure(candidate, requirements) {
705
+ const metricTerms = requirements.ranking?.metricTerms ?? [];
706
+ return metricTerms.length > 0
707
+ && evidenceCandidateRoles(candidate).includes('metric')
708
+ && !metricCandidateMatchesTerms(candidate, metricTerms);
709
+ }
710
+ /**
711
+ * A categorical dimension may satisfy a requested business role only through
712
+ * its own snapshot-authored declaration. In particular, `location_name` is
713
+ * not a synonym for `region`: it can fill a region lane only when metadata
714
+ * explicitly says `alternative-for:region`, or when the dimension itself is
715
+ * declared with the semantic geography role. This protects admission from
716
+ * broad lexical geography expansion while retaining role-balanced recall.
717
+ */
718
+ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
719
+ const extension = candidate.sameSnapshotRoleExtension;
720
+ if (extension?.role === 'categorical_dimension'
721
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
722
+ || extension.basis === 'exact_metricflow_grouping_dimension')) {
723
+ const requested = new Set(terms.map(normalizeRequirementTerm).filter(Boolean));
724
+ if (requested.has(normalizeRequirementTerm(extension.requestedTerm)))
725
+ return true;
726
+ }
727
+ const facts = new Set((candidate.compatibilityFacts ?? [])
728
+ .map(normalizeRequirementTerm)
729
+ .filter(Boolean));
730
+ if (facts.size === 0)
731
+ return false;
732
+ const requestedRoles = [...new Set(terms.flatMap((term) => {
733
+ const normalized = normalizeRequirementTerm(term);
734
+ const terminal = normalized.split(' ').at(-1) ?? '';
735
+ return [normalized, terminal].filter(Boolean);
736
+ }))];
737
+ const hasDeclaredAlternative = requestedRoles.some((role) => facts.has(`alternative for ${role}`)
738
+ || facts.has(`dimension alternative for ${role}`));
739
+ if (hasDeclaredAlternative)
740
+ return true;
741
+ const declaredGeography = facts.has('semantic role geography')
742
+ || facts.has('semantic geography role');
743
+ return declaredGeography && requestedRoles.some((role) => role === 'region' || role === 'geography' || role === 'geographic');
744
+ }
745
+ /**
746
+ * Keep an internal retrieval result broad while making the provider package
747
+ * role-balanced. Exact/alias matches stay pinned; each requested role gets up
748
+ * to two candidates before relevance fills remaining cards.
749
+ */
750
+ export function selectRoleBalancedMeaningCandidates(input) {
751
+ const max = Math.max(1, Math.min(16, Math.floor(input.maxCandidates ?? 16)));
752
+ const ranked = [...new Map(input.candidates
753
+ .filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
754
+ .map((candidate) => [candidate.id, candidate])).values()]
755
+ .sort((left, right) => Number(Boolean(right.exactMatch)) - Number(Boolean(left.exactMatch))
756
+ || (right.relevanceScore ?? 0) - (left.relevanceScore ?? 0)
757
+ || left.id.localeCompare(right.id));
758
+ const selected = [];
759
+ const add = (candidate) => {
760
+ if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
761
+ return;
762
+ if (selected.length < max && !selected.some((item) => item.id === candidate.id))
763
+ selected.push(candidate);
764
+ };
765
+ const servesRequestedRole = (candidate) => {
766
+ const roles = evidenceCandidateRoles(candidate);
767
+ const metricTerms = input.requirements.ranking?.metricTerms.length
768
+ ? input.requirements.ranking.metricTerms
769
+ : input.requirements.measures;
770
+ if (roles.includes('metric') && candidateMatchesTerms(candidate, metricTerms))
771
+ return true;
772
+ // An entity term such as "account" is deliberately insufficient for an
773
+ // attribute (Account Owner Email) to displace the requested display key.
774
+ // Only an actual entity-label candidate may satisfy this binding.
775
+ if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
776
+ ...input.requirements.entityTerms,
777
+ ...input.requirements.entityDisplayTerms,
778
+ ]))
779
+ return true;
780
+ if (roles.includes('time_dimension') && Boolean(input.requirements.time))
781
+ return true;
782
+ const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
783
+ if (roles.includes('categorical_dimension')
784
+ && categoricalTerms.length > 0
785
+ && candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
786
+ return true;
787
+ if (roles.includes('relationship')
788
+ && (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
789
+ return true;
790
+ return false;
791
+ };
792
+ for (const candidate of ranked.filter((candidate) => candidate.exactMatch)) {
793
+ // In a pin-only prepass, an exact match is only a pin when it serves a
794
+ // requested analytical role. Otherwise a pile of exact members consumes
795
+ // the whole package before the requested metric/entity can be reserved.
796
+ if (input.pinOnly && !servesRequestedRole(candidate))
797
+ continue;
798
+ add(candidate);
799
+ }
800
+ const required = [
801
+ ['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
802
+ ['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
803
+ ['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
804
+ ['categorical_dimension', categoricalDimensionRequirementTerms(input.requirements)],
805
+ ['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
806
+ ];
807
+ for (const [role, terms] of required) {
808
+ // No requested categorical dimension means that high-scoring arbitrary
809
+ // members are noise, not a role reservation. This is the subtle path that
810
+ // used to admit Account Owner and Sentiment immediately after Account Name.
811
+ if (terms.length === 0)
812
+ continue;
813
+ let admitted = 0;
814
+ for (const candidate of ranked) {
815
+ if (admitted >= 2 || selected.length >= max)
816
+ break;
817
+ const roles = evidenceCandidateRoles(candidate);
818
+ if (!roles.includes(role))
819
+ continue;
820
+ // "top accounts" needs the account display key, not any field whose
821
+ // label happens to contain account. Once a display candidate is
822
+ // available, owner/e-mail/sentiment attributes are neither the entity
823
+ // role nor a useful categorical reservation unless the user explicitly
824
+ // named that attribute. This runs during the pre-cap pin pass so noisy
825
+ // same-kind cards cannot enter through the categorical role.
826
+ const explicitlyRequestsAttribute = hasEntityAttributeTerm([
827
+ ...input.requirements.dimensions,
828
+ ...input.requirements.entityTerms,
829
+ ...input.requirements.entityDisplayTerms,
830
+ ].join(' '));
831
+ const hasRequestedEntityLabel = ranked.some((item) => evidenceCandidateRoles(item).includes('entity_label')
832
+ && candidateMatchesTerms(item, [
833
+ ...input.requirements.entityTerms,
834
+ ...input.requirements.entityDisplayTerms,
835
+ ]));
836
+ if (role === 'categorical_dimension'
837
+ && hasRequestedEntityLabel
838
+ && !explicitlyRequestsAttribute
839
+ && isEntityAttributeCandidate(candidate))
840
+ continue;
841
+ // For entity labels, role is more important than a lexical owner/email
842
+ // hit. For all other roles, prefer an identity matching the requested
843
+ // business term but retain a role candidate when the request is terse.
844
+ if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
845
+ && role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
846
+ continue;
847
+ add(candidate);
848
+ admitted += 1;
849
+ }
850
+ }
851
+ if (!input.pinOnly) {
852
+ for (const candidate of ranked)
853
+ add(candidate);
854
+ }
855
+ return selected;
856
+ }
857
+ export function classifyProviderFailure(input) {
858
+ const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
859
+ const cause = /cancel/.test(text) ? 'cancelled'
860
+ : /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
861
+ : /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
862
+ : /run.?deadline|time limit/.test(text) ? 'run_deadline'
863
+ : /timeout|timed out/.test(text) ? 'provider_timeout'
864
+ : /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
865
+ : /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
866
+ : /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
867
+ : /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
868
+ : /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
869
+ : 'unknown';
870
+ const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
871
+ const safeAction = retryable ? (cause === 'rate_limited' ? 'wait_and_retry' : 'retry_same_provider')
872
+ : cause === 'authentication' || cause === 'model_not_found' ? 'fix_provider_configuration'
873
+ : cause === 'cancelled' ? 'none'
874
+ : 'inspect_run';
875
+ const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
876
+ : /\b5\d{2}\b/.test(text) ? '5xx'
877
+ : undefined;
878
+ return {
879
+ version: 1,
880
+ cause,
881
+ phase: input.phase ?? 'unknown',
882
+ retryable,
883
+ safeAction,
884
+ ...(httpStatusClass ? { httpStatusClass } : {}),
885
+ ...(input.providerFingerprint ? { providerFingerprint: input.providerFingerprint } : {}),
886
+ ...(input.modelFingerprint ? { modelFingerprint: input.modelFingerprint } : {}),
887
+ ...(input.baseOriginFingerprint ? { baseOriginFingerprint: input.baseOriginFingerprint } : {}),
888
+ };
889
+ }
890
+ export function buildAnalyticalCascadeDecision(input) {
891
+ // A frozen tier is the end of the authoritative cascade. Later tiers are
892
+ // neither evaluated nor eligible as a fallback, even when the selected
893
+ // compiler, adapter, or execution target subsequently fails. Retaining
894
+ // pre-built later attempts in the receipt makes a truthful post-freeze
895
+ // failure look like a silent downgrade and invalidates portable replay.
896
+ // Normalize at the shared construction boundary so every router path and
897
+ // every emitted trace receives the same immutable attempt prefix.
898
+ const attempts = [];
899
+ for (const inputAttempt of input.attempts) {
900
+ const attempt = {
901
+ ...inputAttempt,
902
+ version: 1,
903
+ candidateIds: [...new Set(inputAttempt.candidateIds)].slice(0, 32),
904
+ // Decision-level `planFrozen` is the server-owned source of truth. A
905
+ // legacy caller may have stamped it only on the decision; keep the
906
+ // selected attempt coherent before enforcing the immutable prefix.
907
+ planFrozen: inputAttempt.planFrozen
908
+ || (input.planFrozen === true && input.selectedTier === inputAttempt.tier),
909
+ };
910
+ attempts.push(attempt);
911
+ if (attempt.planFrozen)
912
+ break;
913
+ }
914
+ return {
915
+ version: 1,
916
+ ...input,
917
+ sourceCoverage: input.sourceCoverage.map((coverage) => ({
918
+ ...coverage,
919
+ version: 1,
920
+ candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
921
+ })),
922
+ attempts,
923
+ ...(input.terminalGap ? {
924
+ terminalGap: {
925
+ version: 1,
926
+ code: 'MISSING_RELATIONSHIP',
927
+ requirement: 'certified_relationship_or_allocation_proof',
928
+ witnessCandidateIds: [...new Set(input.terminalGap.witnessCandidateIds)].sort().slice(0, 32),
929
+ },
930
+ } : {}),
931
+ };
932
+ }
44
933
  /**
45
934
  * `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
46
935
  * quoted value, with nothing else in the clause. Deliberately narrow: it must
@@ -560,6 +1449,124 @@ export function buildResearchEvidenceLedger(input) {
560
1449
  stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
561
1450
  };
562
1451
  }
1452
+ export function buildResearchEvidenceLedgerV2(input) {
1453
+ const v1 = buildResearchEvidenceLedger(input);
1454
+ const entries = v1.entries.map((entry, index) => {
1455
+ const source = input.entries[index];
1456
+ const validator = normalizeResearchEvidenceValidator(source?.validator, entry);
1457
+ const requestedVerdict = /\b(?:because|caused?|driven by|due to)\b/i.test(source?.hypothesis ?? '')
1458
+ ? undefined
1459
+ : source?.verdict;
1460
+ const verdict = researchVerdictFromValidatedObservation({
1461
+ status: entry.status,
1462
+ requestedVerdict,
1463
+ validator,
1464
+ });
1465
+ const validFactIds = new Set(entry.facts);
1466
+ const counterEvidenceFactIds = [...new Set(source?.counterEvidenceFactIds ?? [])]
1467
+ .filter((factId) => validFactIds.has(factId));
1468
+ return {
1469
+ ...entry,
1470
+ verdict,
1471
+ ...(source?.hypothesis?.trim() ? { hypothesis: source.hypothesis.trim() } : {}),
1472
+ ...(validator ? { validator } : {}),
1473
+ counterEvidenceFactIds,
1474
+ };
1475
+ });
1476
+ const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'failed' && entry.verdict !== 'skipped').length;
1477
+ const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
1478
+ const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
1479
+ return {
1480
+ version: 2,
1481
+ rootQuestion: v1.rootQuestion,
1482
+ ...(v1.planId ? { planId: v1.planId } : {}),
1483
+ ...(v1.snapshotId ? { snapshotId: v1.snapshotId } : {}),
1484
+ entries,
1485
+ factIds: [...new Set(entries.flatMap((entry) => [...entry.facts, ...entry.counterEvidenceFactIds]))],
1486
+ groundableBranchCount,
1487
+ limitedScope: groundableBranchCount < 3,
1488
+ stoppingReason: v1.stoppingReason,
1489
+ };
1490
+ }
1491
+ /**
1492
+ * Normalise a hypothesis plan into the bounded research contract. The caller
1493
+ * may supply fewer than three grounded hypotheses; that is retained honestly as
1494
+ * limited scope rather than padded with invented joins or explanations.
1495
+ */
1496
+ export function buildResearchHypothesisPlanV2(input) {
1497
+ const seen = new Set();
1498
+ const hypotheses = [];
1499
+ for (const candidate of input.hypotheses) {
1500
+ const statement = candidate.statement.trim();
1501
+ const expectation = candidate.expectation.trim();
1502
+ const targetId = candidate.targetId.trim();
1503
+ if (!statement || !expectation || !targetId)
1504
+ continue;
1505
+ const key = `${statement.toLowerCase()}\u0000${targetId.toLowerCase()}`;
1506
+ if (seen.has(key))
1507
+ continue;
1508
+ seen.add(key);
1509
+ hypotheses.push({
1510
+ id: candidate.id?.trim() || `hypothesis:${hypotheses.length + 1}`,
1511
+ statement,
1512
+ expectation,
1513
+ targetId,
1514
+ validatorKind: candidate.validatorKind ?? inferResearchValidatorKind(statement, expectation),
1515
+ });
1516
+ if (hypotheses.length >= 6)
1517
+ break;
1518
+ }
1519
+ return { version: 2, hypotheses, limitedScope: hypotheses.length < 3 };
1520
+ }
1521
+ /** Map an action/expectation to a deterministic observation class only. */
1522
+ export function inferResearchValidatorKind(statement, expectation = '') {
1523
+ const text = `${statement} ${expectation}`.toLowerCase();
1524
+ if (/fresh|updated|stale|as of|recency/.test(text))
1525
+ return 'freshness';
1526
+ if (/contribut|driver|segment|breakdown|dominant/.test(text))
1527
+ return 'contributor';
1528
+ if (/trend|time|month|week|quarter|year|shift|change/.test(text))
1529
+ return 'trend';
1530
+ if (/compare|versus|vs\.?|difference/.test(text))
1531
+ return 'comparison';
1532
+ if (/anomal|outlier|spike|drop/.test(text))
1533
+ return 'anomaly';
1534
+ return 'counter_evidence';
1535
+ }
1536
+ /**
1537
+ * A verdict is promoted only from a validator that evaluated a deterministic
1538
+ * observation against a branch receipt. Rows by themselves stay inconclusive;
1539
+ * causal statements are never supported by this helper.
1540
+ */
1541
+ export function researchVerdictFromValidatedObservation(input) {
1542
+ if (input.status === 'failed')
1543
+ return 'failed';
1544
+ if (input.status === 'skipped')
1545
+ return 'skipped';
1546
+ if (!input.validator?.evaluated || input.validator.receiptFingerprints.length === 0)
1547
+ return 'inconclusive';
1548
+ if (input.requestedVerdict === 'supported' && input.validator.outcome === 'supports_observation')
1549
+ return 'supported';
1550
+ if (input.requestedVerdict === 'contradicted' && input.validator.outcome === 'contradicts_observation')
1551
+ return 'contradicted';
1552
+ return 'inconclusive';
1553
+ }
1554
+ function normalizeResearchEvidenceValidator(validator, entry) {
1555
+ if (!validator || validator.version !== 1)
1556
+ return undefined;
1557
+ const knownReceipt = entry.resultFingerprint;
1558
+ const receiptFingerprints = [...new Set(validator.receiptFingerprints)]
1559
+ .map(normalizeAnalyticalExecutionFingerprint)
1560
+ .filter((fingerprint) => Boolean(fingerprint))
1561
+ .filter((fingerprint) => !knownReceipt || fingerprint === knownReceipt);
1562
+ return {
1563
+ version: 1,
1564
+ kind: validator.kind,
1565
+ evaluated: validator.evaluated === true && receiptFingerprints.length > 0,
1566
+ ...(validator.outcome ? { outcome: validator.outcome } : {}),
1567
+ receiptFingerprints,
1568
+ };
1569
+ }
563
1570
  /** The only accepted host-side execution identity is a SHA-256 fingerprint. */
564
1571
  export function normalizeAnalyticalExecutionFingerprint(value) {
565
1572
  return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())