@duckcodeailabs/dql-agent 1.14.2 → 1.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/agent-run-engine.d.ts +239 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2924 -85
  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 +51 -3
  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 +591 -11
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +9 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +204 -4
  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/ledger-tools.d.ts.map +1 -1
  23. package/dist/agentic/ledger-tools.js +8 -1
  24. package/dist/agentic/ledger-tools.js.map +1 -1
  25. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  26. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  27. package/dist/agentic/orchestrator-policy.js +1 -1
  28. package/dist/agentic/orchestrator-policy.js.map +1 -1
  29. package/dist/agentic/research-agent.d.ts.map +1 -1
  30. package/dist/agentic/research-agent.js +10 -1
  31. package/dist/agentic/research-agent.js.map +1 -1
  32. package/dist/agentic/sql-authorization.d.ts +8 -0
  33. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  34. package/dist/agentic/sql-authorization.js +3 -0
  35. package/dist/agentic/sql-authorization.js.map +1 -1
  36. package/dist/agentic/tool-loop.d.ts +13 -1
  37. package/dist/agentic/tool-loop.d.ts.map +1 -1
  38. package/dist/agentic/tool-loop.js +441 -32
  39. package/dist/agentic/tool-loop.js.map +1 -1
  40. package/dist/analytical-compatibility.d.ts.map +1 -1
  41. package/dist/analytical-compatibility.js +8 -1
  42. package/dist/analytical-compatibility.js.map +1 -1
  43. package/dist/analytical-failure-repair.d.ts.map +1 -1
  44. package/dist/analytical-failure-repair.js +6 -2
  45. package/dist/analytical-failure-repair.js.map +1 -1
  46. package/dist/analytical-frame.d.ts +42 -1
  47. package/dist/analytical-frame.d.ts.map +1 -1
  48. package/dist/analytical-frame.js +490 -26
  49. package/dist/analytical-frame.js.map +1 -1
  50. package/dist/analytical-orchestration.d.ts +1259 -9
  51. package/dist/analytical-orchestration.d.ts.map +1 -1
  52. package/dist/analytical-orchestration.js +970 -84
  53. package/dist/analytical-orchestration.js.map +1 -1
  54. package/dist/answer-loop.d.ts +230 -10
  55. package/dist/answer-loop.d.ts.map +1 -1
  56. package/dist/answer-loop.js +1182 -85
  57. package/dist/answer-loop.js.map +1 -1
  58. package/dist/answer-shape.d.ts +27 -0
  59. package/dist/answer-shape.d.ts.map +1 -1
  60. package/dist/answer-shape.js +55 -0
  61. package/dist/answer-shape.js.map +1 -1
  62. package/dist/ask-observability/index.d.ts +11 -0
  63. package/dist/ask-observability/index.d.ts.map +1 -0
  64. package/dist/ask-observability/index.js +7 -0
  65. package/dist/ask-observability/index.js.map +1 -0
  66. package/dist/ask-observability/instrumentation.d.ts +35 -0
  67. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  68. package/dist/ask-observability/instrumentation.js +523 -0
  69. package/dist/ask-observability/instrumentation.js.map +1 -0
  70. package/dist/ask-observability/observer.d.ts +62 -0
  71. package/dist/ask-observability/observer.d.ts.map +1 -0
  72. package/dist/ask-observability/observer.js +537 -0
  73. package/dist/ask-observability/observer.js.map +1 -0
  74. package/dist/ask-observability/portable.d.ts +98 -0
  75. package/dist/ask-observability/portable.d.ts.map +1 -0
  76. package/dist/ask-observability/portable.js +1089 -0
  77. package/dist/ask-observability/portable.js.map +1 -0
  78. package/dist/ask-observability/store.d.ts +130 -0
  79. package/dist/ask-observability/store.d.ts.map +1 -0
  80. package/dist/ask-observability/store.js +1114 -0
  81. package/dist/ask-observability/store.js.map +1 -0
  82. package/dist/ask-observability/types.d.ts +380 -0
  83. package/dist/ask-observability/types.d.ts.map +1 -0
  84. package/dist/ask-observability/types.js +2 -0
  85. package/dist/ask-observability/types.js.map +1 -0
  86. package/dist/ask-observability/utils.d.ts +21 -0
  87. package/dist/ask-observability/utils.d.ts.map +1 -0
  88. package/dist/ask-observability/utils.js +102 -0
  89. package/dist/ask-observability/utils.js.map +1 -0
  90. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  91. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  92. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  93. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  94. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  95. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  96. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  97. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  98. package/dist/conversation/session-store.d.ts +31 -0
  99. package/dist/conversation/session-store.d.ts.map +1 -1
  100. package/dist/conversation/session-store.js +38 -0
  101. package/dist/conversation/session-store.js.map +1 -1
  102. package/dist/conversation/snapshot.d.ts +4 -1
  103. package/dist/conversation/snapshot.d.ts.map +1 -1
  104. package/dist/conversation/snapshot.js +85 -3
  105. package/dist/conversation/snapshot.js.map +1 -1
  106. package/dist/conversation/turn-trust.d.ts.map +1 -1
  107. package/dist/conversation/turn-trust.js +2 -0
  108. package/dist/conversation/turn-trust.js.map +1 -1
  109. package/dist/embeddings/provider.d.ts +1 -0
  110. package/dist/embeddings/provider.d.ts.map +1 -1
  111. package/dist/embeddings/provider.js +18 -0
  112. package/dist/embeddings/provider.js.map +1 -1
  113. package/dist/generated-analytical-proposal.d.ts +37 -1
  114. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  115. package/dist/generated-analytical-proposal.js +246 -3
  116. package/dist/generated-analytical-proposal.js.map +1 -1
  117. package/dist/governed-relational-compiler.d.ts +15 -0
  118. package/dist/governed-relational-compiler.d.ts.map +1 -1
  119. package/dist/governed-relational-compiler.js +99 -1
  120. package/dist/governed-relational-compiler.js.map +1 -1
  121. package/dist/index.d.ts +21 -12
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +14 -7
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +87 -1
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +2 -2
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/kg/build.js +32 -2
  130. package/dist/kg/build.js.map +1 -1
  131. package/dist/meaning-resolution.d.ts +207 -6
  132. package/dist/meaning-resolution.d.ts.map +1 -1
  133. package/dist/meaning-resolution.js +439 -53
  134. package/dist/meaning-resolution.js.map +1 -1
  135. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  136. package/dist/metadata/analysis-planner.js +78 -7
  137. package/dist/metadata/analysis-planner.js.map +1 -1
  138. package/dist/metadata/catalog.d.ts +14 -0
  139. package/dist/metadata/catalog.d.ts.map +1 -1
  140. package/dist/metadata/catalog.js +29 -2
  141. package/dist/metadata/catalog.js.map +1 -1
  142. package/dist/metadata/meaning-evidence.d.ts +28 -2
  143. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  144. package/dist/metadata/meaning-evidence.js +373 -16
  145. package/dist/metadata/meaning-evidence.js.map +1 -1
  146. package/dist/plan-execution-adapter.d.ts +9 -1
  147. package/dist/plan-execution-adapter.d.ts.map +1 -1
  148. package/dist/plan-execution-adapter.js +93 -13
  149. package/dist/plan-execution-adapter.js.map +1 -1
  150. package/dist/provider-egress.d.ts +28 -11
  151. package/dist/provider-egress.d.ts.map +1 -1
  152. package/dist/provider-egress.js +72 -22
  153. package/dist/provider-egress.js.map +1 -1
  154. package/dist/providers/claude.d.ts +2 -2
  155. package/dist/providers/claude.d.ts.map +1 -1
  156. package/dist/providers/claude.js +201 -37
  157. package/dist/providers/claude.js.map +1 -1
  158. package/dist/providers/dispatch.d.ts +27 -1
  159. package/dist/providers/dispatch.d.ts.map +1 -1
  160. package/dist/providers/dispatch.js +63 -1
  161. package/dist/providers/dispatch.js.map +1 -1
  162. package/dist/providers/gemini.js +10 -10
  163. package/dist/providers/gemini.js.map +1 -1
  164. package/dist/providers/index.d.ts +2 -2
  165. package/dist/providers/index.d.ts.map +1 -1
  166. package/dist/providers/index.js +1 -1
  167. package/dist/providers/index.js.map +1 -1
  168. package/dist/providers/ollama.js +15 -15
  169. package/dist/providers/ollama.js.map +1 -1
  170. package/dist/providers/openai.d.ts +2 -2
  171. package/dist/providers/openai.d.ts.map +1 -1
  172. package/dist/providers/openai.js +213 -43
  173. package/dist/providers/openai.js.map +1 -1
  174. package/dist/providers/types.d.ts +131 -1
  175. package/dist/providers/types.d.ts.map +1 -1
  176. package/dist/providers/types.js.map +1 -1
  177. package/dist/relationship-proof.d.ts +81 -0
  178. package/dist/relationship-proof.d.ts.map +1 -1
  179. package/dist/relationship-proof.js +228 -4
  180. package/dist/relationship-proof.js.map +1 -1
  181. package/dist/requirement-clauses.d.ts +23 -0
  182. package/dist/requirement-clauses.d.ts.map +1 -0
  183. package/dist/requirement-clauses.js +70 -0
  184. package/dist/requirement-clauses.js.map +1 -0
  185. package/dist/resolved-analytical-plan.d.ts +18 -4
  186. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  187. package/dist/resolved-analytical-plan.js +953 -50
  188. package/dist/resolved-analytical-plan.js.map +1 -1
  189. package/dist/router.d.ts +118 -2
  190. package/dist/router.d.ts.map +1 -1
  191. package/dist/router.js +2481 -232
  192. package/dist/router.js.map +1 -1
  193. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  194. package/dist/semantic-bridge/compose.js +23 -4
  195. package/dist/semantic-bridge/compose.js.map +1 -1
  196. package/package.json +4 -4
@@ -12,7 +12,265 @@
12
12
  * Acceptance: AGT-027..033, CTX-007, PERF-003, E2E-022.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
+ import { parseAnalyticalTimeWindow } from './requirement-clauses.js';
15
16
  export const ANALYTICAL_ORCHESTRATION_CONTRACT_VERSION = 1;
17
+ /**
18
+ * Preserve a validated prior-result predicate as typed analytical context.
19
+ * The selected display key supplies the entity/display requirements needed to
20
+ * retrieve relationship closure; the literal values remain an immutable host
21
+ * filter and are never treated as a provider-selected member meaning.
22
+ */
23
+ export function withAnalyticalPriorResultMemberBinding(requirements, binding) {
24
+ const displayDimension = binding?.displayDimension?.trim();
25
+ const seenValues = new Set();
26
+ const values = (binding?.values ?? []).flatMap((value) => {
27
+ const trimmed = value.trim();
28
+ const normalized = normalizeRequirementTerm(trimmed);
29
+ if (!trimmed || !normalized || seenValues.has(normalized))
30
+ return [];
31
+ seenValues.add(normalized);
32
+ return [trimmed];
33
+ });
34
+ if (!displayDimension || values.length === 0)
35
+ return requirements;
36
+ const normalizedDisplay = normalizeRequirementTerm(displayDimension);
37
+ const entityTerm = normalizedDisplay
38
+ .replace(/\b(?:name|id|key|label|email)\b/g, ' ')
39
+ .replace(/\s+/g, ' ')
40
+ .trim();
41
+ return {
42
+ ...requirements,
43
+ entityTerms: uniqueRequirementTerms([
44
+ ...requirements.entityTerms,
45
+ ...(entityTerm ? [entityTerm] : []),
46
+ ]),
47
+ entityDisplayTerms: uniqueRequirementTerms([
48
+ ...requirements.entityDisplayTerms,
49
+ normalizedDisplay,
50
+ ]),
51
+ // These values are retained for local compiler/filter construction. The
52
+ // planner request redacts this host-only binding and does not require a
53
+ // member card when it is present.
54
+ memberTerms: uniqueRequirementTerms([...requirements.memberTerms, ...values]),
55
+ priorResultMemberBinding: {
56
+ version: 1,
57
+ // Preserve the canonical host field spelling for the immutable filter;
58
+ // normalized text above is only for role matching/admission.
59
+ displayDimension,
60
+ values,
61
+ ...(binding?.sourceTurnId ? { sourceTurnId: binding.sourceTurnId } : {}),
62
+ ...(binding?.resultFingerprint ? { resultFingerprint: binding.resultFingerprint } : {}),
63
+ },
64
+ };
65
+ }
66
+ /** Build the immutable host request tuple before a meaning model can respond. */
67
+ export function buildAnalyticalRequirementSeedV1(input) {
68
+ // Retrieval/parser output is intentionally broad: it may contain useful
69
+ // context from a prior turn, vector hit, or search expansion. It is not an
70
+ // authority for a new free-text request. Keep only refinements that the
71
+ // source question itself demonstrates before they can contribute to the
72
+ // frozen host tuple. Structured clarification selections are merged by the
73
+ // router into `requirements` before this function is called.
74
+ const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
75
+ const baseRequirements = input.requirements ?? buildAnalyticalRequirementSet({
76
+ question: input.question,
77
+ parsedIntent: parsed,
78
+ });
79
+ const requirements = withAnalyticalPriorResultMemberBinding(baseRequirements, input.priorResultMemberBinding ?? baseRequirements.priorResultMemberBinding);
80
+ // Order and limit are lexical requirements, not parser defaults. In
81
+ // particular, a prior ranking must not turn a complete new question into a
82
+ // top-N query just because retrieval retained an old `limit` or `order`.
83
+ const order = requirements.ranking
84
+ ? requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'
85
+ : undefined;
86
+ const limit = requirements.ranking?.limit;
87
+ const filters = [...(parsed?.filters ?? [])].map((filter) => ({ field: filter.field, value: filter.value }));
88
+ for (const value of requirements.priorResultMemberBinding?.values ?? []) {
89
+ const field = requirements.priorResultMemberBinding?.displayDimension;
90
+ if (!field || filters.some((filter) => normalizeRequirementTerm(filter.field) === normalizeRequirementTerm(field)
91
+ && normalizeRequirementTerm(filter.value) === normalizeRequirementTerm(value)))
92
+ continue;
93
+ filters.push({ field, value });
94
+ }
95
+ if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
96
+ && !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
97
+ filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
98
+ }
99
+ return {
100
+ version: 1,
101
+ sourceQuestion: input.question,
102
+ requirements,
103
+ queryIntent: {
104
+ measures: [...requirements.measures],
105
+ // A ranking's entity display key is an execution requirement, not a
106
+ // prompt nicety. Keep it in the host-owned query tuple, but do not put
107
+ // the broad entity noun (for example `customer`) in the categorical
108
+ // dimension lane. A metric can legitimately expose customer type and
109
+ // customer order number as groupings; neither is interchangeable with
110
+ // the requested customer display/rank key. The frame resolves the
111
+ // display term against the selected metric's native display/rank role.
112
+ dimensions: [...new Set([
113
+ ...categoricalDimensionRequirementTerms(requirements),
114
+ ...requirements.entityDisplayTerms,
115
+ ])],
116
+ filters,
117
+ // The typed window is the host's own reading of the question and wins
118
+ // over a retrieval refinement. Its canonical expression is exactly the
119
+ // input `resolvePlanTimeRange` accepts, so populating it is what makes
120
+ // that (previously never-called) resolver finally produce timeBounds.
121
+ ...(requirements.time?.window
122
+ ? { timeRange: requirements.time.window.expression }
123
+ : parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
124
+ ...(requirements.time?.grain
125
+ ? { timeGrain: requirements.time.grain }
126
+ : parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
127
+ ...(order ? { order } : {}),
128
+ ...(limit !== undefined ? { limit } : {}),
129
+ // A calendar/date role is declared snapshot metadata, never an ID
130
+ // copied from retrieval/parser evidence.
131
+ ...(input.fiscalCalendar?.id ? { fiscalCalendarId: input.fiscalCalendar.id } : {}),
132
+ ...(input.fiscalCalendar?.dateRoleId ? { fiscalDateRoleId: input.fiscalCalendar.dateRoleId } : {}),
133
+ },
134
+ };
135
+ }
136
+ /**
137
+ * Return only parser refinements whose business words occur in the current
138
+ * source question. This deliberately does not try to recover previous turn
139
+ * context: continuation is represented by a server-issued structured choice
140
+ * and merged separately by the host. The helper is exported for regression
141
+ * tests and for router paths that construct a requirement set before a seed.
142
+ */
143
+ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
144
+ if (!parsedIntent)
145
+ return undefined;
146
+ // These are intentionally tiny, product-wide vocabulary aliases rather
147
+ // than semantic guessing. They let a retrieval parser retain the same
148
+ // current-turn business phrase (`drink revenue` for `beverage revenue`, or
149
+ // `sales` for `revenue`) while still rejecting an unrelated prior-turn
150
+ // phrase such as `rollover balance`. Do not add customer/model-specific
151
+ // synonyms here: those require a qualified selected candidate and an
152
+ // override receipt at the meaning boundary.
153
+ const canonicalGroundingToken = (term) => {
154
+ if (term === 'sale' || term === 'sales')
155
+ return 'revenue';
156
+ if (term === 'drink' || term === 'drinks')
157
+ return 'beverage';
158
+ return term;
159
+ };
160
+ const questionTerms = new Set(normalizeRequirementTerm(question)
161
+ .split(' ')
162
+ .filter((term) => term.length > 1)
163
+ .map(canonicalGroundingToken));
164
+ const groundedTerm = (value) => {
165
+ const terms = normalizeRequirementTerm(value ?? '')
166
+ .split(' ')
167
+ .filter((term) => term.length > 1 && !/^(?:the|a|an|by|for|with|and|or|of|to|in|on|at)$/.test(term))
168
+ .map(canonicalGroundingToken);
169
+ return terms.length > 0 && terms.every((term) => {
170
+ if (questionTerms.has(term))
171
+ return true;
172
+ // Preserve only a trivial singular/plural normalization. Anything more
173
+ // permissive would let a stale retrieved phrase become a new request.
174
+ return term.endsWith('s')
175
+ ? questionTerms.has(term.slice(0, -1))
176
+ : questionTerms.has(`${term}s`);
177
+ });
178
+ };
179
+ const timeRangeGrounded = (value) => {
180
+ const normalized = normalizeRequirementTerm(value ?? '');
181
+ if (!normalized)
182
+ return false;
183
+ if (groundedTerm(normalized))
184
+ return true;
185
+ if (!/^(?:last|previous|past) (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten|twelve) (?:day|days|week|weeks|month|months|quarter|quarters|year|years)$/.test(normalized))
186
+ return false;
187
+ // The question may spell the count ("last two months") while the parsed
188
+ // range uses digits ("last 2 months"). Both spell the same window; ground
189
+ // on the digit-normalized forms so the wording difference cannot unground
190
+ // a range the question itself established.
191
+ const digits = (value2) => value2
192
+ .replace(/\bone\b/g, '1').replace(/\btwo\b/g, '2').replace(/\bthree\b/g, '3')
193
+ .replace(/\bfour\b/g, '4').replace(/\bfive\b/g, '5').replace(/\bsix\b/g, '6')
194
+ .replace(/\bseven\b/g, '7').replace(/\beight\b/g, '8').replace(/\bnine\b/g, '9')
195
+ .replace(/\btwelve\b/g, '12').replace(/\bten\b/g, '10');
196
+ return digits(normalizeRequirementTerm(question)).includes(digits(normalized));
197
+ };
198
+ const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
199
+ const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
200
+ // A parser may contribute the column that a reader's explicitly named value
201
+ // belongs to, but the value itself must appear in this request. This drops
202
+ // stale rollover-balance/member filters while retaining a current named
203
+ // member that the host can bind to a qualified field.
204
+ const filters = (parsedIntent.filters ?? []).filter((filter) => groundedTerm(filter.value));
205
+ const timeRange = timeRangeGrounded(parsedIntent.timeRange)
206
+ ? parsedIntent.timeRange
207
+ : undefined;
208
+ return {
209
+ ...(measures.length > 0 ? { measures } : {}),
210
+ ...(dimensions.length > 0 ? { dimensions } : {}),
211
+ ...(filters.length > 0 ? { filters } : {}),
212
+ ...(timeRange ? { timeRange } : {}),
213
+ // Grain/ranking/limit are derived deterministically from the source
214
+ // question below. Never promote parser values by themselves.
215
+ };
216
+ }
217
+ /**
218
+ * Preserve a small set of explicit current-question literals even when a
219
+ * retriever did not emit a parser filter. This is deliberately not a value
220
+ * search or synonym engine: it records only quoted values, capitalized proper
221
+ * names, and values introduced by an explicit predicate phrase. The planner
222
+ * must still bind each term to an admitted, qualified member/dimension card
223
+ * before it can become an executable filter.
224
+ *
225
+ * Without this host-owned atom, "customers in Philadelphia" could reach a
226
+ * broad certified fit after retrieval silently omitted the parser filter.
227
+ */
228
+ export function currentQuestionLiteralMemberTerms(question) {
229
+ const literals = [];
230
+ const append = (value, source = 'predicate') => {
231
+ const trimmed = (value ?? '').trim()
232
+ .replace(/^(?:the\s+)/i, '')
233
+ .replace(/[?.!,;:]+$/g, '')
234
+ .trim();
235
+ const normalized = normalizeRequirementTerm(trimmed);
236
+ if (!normalized || isTemporalTerm(normalized))
237
+ return;
238
+ // Do not mistake grammatical/analytical words for a member literal.
239
+ if (/^(?:by|with|and|or|for|where|that|which|who|having|have)\b/i.test(normalized))
240
+ return;
241
+ if (/^(?:show|who|what|which|where|when|why|how|top|bottom|highest|lowest|revenue|sales|customers?|accounts?|products?|orders?|regions?|categories?|category|region|customer|account|product|order)$/i.test(normalized))
242
+ return;
243
+ // A two-word title-cased fragment is not automatically a proper name.
244
+ // Sentence-leading analytical phrases such as `Show Revenue` and `Top
245
+ // Customers` otherwise become fake member atoms, then falsely demand a
246
+ // member field before the compiler can reach a safe physical fallback.
247
+ // Quoted text remains an explicit reader literal; only heuristic proper
248
+ // name extraction applies this conservative vocabulary guard.
249
+ if (source === 'proper_name') {
250
+ const words = normalized.split(/\s+/);
251
+ if (words.some((word) => /^(?:show|list|give|find|get|top|bottom|highest|lowest|revenue|sales|customer|customers|account|accounts|product|products|order|orders|region|regions|category|categories|metric|metrics|amount|count|total|average|avg|monthly|daily|yearly)$/i.test(word)))
252
+ return;
253
+ }
254
+ literals.push(trimmed);
255
+ };
256
+ // A reader can make a value unambiguous with quotes regardless of casing.
257
+ for (const match of question.matchAll(/["“]([^"”]{2,96})["”]/g))
258
+ append(match[1], 'quoted');
259
+ // Keep a literal only when the question itself supplies a predicate-like
260
+ // construction. The bounded lookahead avoids swallowing "by revenue" or a
261
+ // second clause into the value.
262
+ const predicate = /\b(?:in|from|at|named|called)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9'/-]*(?:\s+[A-Za-z][A-Za-z0-9'/-]*){0,3})(?=\s*(?:\b(?:by|with|and|or|for|where|that|which|who|having|have)\b|[?.!,;]|$))/gi;
263
+ for (const match of question.matchAll(predicate))
264
+ append(match[1], 'predicate');
265
+ // Proper names such as "Brittany Barrera" are a member requirement even
266
+ // when the reader phrases an attribute lookup rather than a SQL-style
267
+ // predicate. Single capitalized words are intentionally handled only by the
268
+ // predicate branch above so sentence-leading generic words do not become
269
+ // fake filters.
270
+ for (const match of question.matchAll(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g))
271
+ append(match[1], 'proper_name');
272
+ return uniqueRequirementTerms(literals);
273
+ }
16
274
  /**
17
275
  * Small, explainable intent vocabulary used before the physical plan exists.
18
276
  * It is deliberately not a second semantic matcher: candidate identity still
@@ -54,6 +312,42 @@ function uniqueRequirementTerms(values) {
54
312
  .map(normalizeRequirementTerm)
55
313
  .filter(Boolean))];
56
314
  }
315
+ /**
316
+ * Entity/display terms are represented in the historical `dimensions` seed
317
+ * so ranking plans can retain their requested grain. They must not consume
318
+ * the separate categorical-dimension admission lane. For example, in "top
319
+ * customers by product category", `customer` is the entity/rank role while
320
+ * `product category` is the required categorical grouping role.
321
+ */
322
+ export function categoricalDimensionRequirementTerms(requirements) {
323
+ const entityTerms = new Set(uniqueRequirementTerms([
324
+ ...requirements.entityTerms,
325
+ ...requirements.entityDisplayTerms,
326
+ ]));
327
+ return uniqueRequirementTerms(requirements.dimensions)
328
+ .filter((term) => !entityTerms.has(term));
329
+ }
330
+ /**
331
+ * A small, typed vocabulary bridge for categorical field identities. It is
332
+ * intentionally not a general synonym engine: only the field-kind suffix is
333
+ * canonicalized, while the scoped business noun must still match. Thus
334
+ * `product category` can bind the snapshot-declared `product_type`, whereas
335
+ * `customer_type` cannot satisfy it. A bare `category` remains potentially
336
+ * ambiguous when more than one qualified `*_type` field exists.
337
+ */
338
+ export function categoricalDimensionTermsMatch(left, right) {
339
+ const normalizedLeft = normalizeRequirementTerm(left);
340
+ const normalizedRight = normalizeRequirementTerm(right);
341
+ if (!normalizedLeft || !normalizedRight)
342
+ return false;
343
+ const phraseMatch = (a, b) => a === b || a.endsWith(` ${b}`) || b.endsWith(` ${a}`);
344
+ if (phraseMatch(normalizedLeft, normalizedRight))
345
+ return true;
346
+ const canonicalizeKind = (value) => value
347
+ .replace(/\bcategories\b/g, 'type')
348
+ .replace(/\bcategory\b/g, 'type');
349
+ return phraseMatch(canonicalizeKind(normalizedLeft), canonicalizeKind(normalizedRight));
350
+ }
57
351
  function isTemporalTerm(term) {
58
352
  return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
59
353
  }
@@ -112,6 +406,43 @@ function normalizedTypedAggregationRequirements(input) {
112
406
  ]);
113
407
  return { measures, dimensions };
114
408
  }
409
+ /**
410
+ * Keep grammatical wrappers out of the physical tuple. In particular, a
411
+ * parser can return `sales based on the region` as a dimension for a simple
412
+ * revenue-by-region request. That is neither a business dimension nor an
413
+ * object DQL may report as absent.
414
+ */
415
+ function normalizeAnalyticalDimensionTerms(question, values) {
416
+ const hasRegion = /\b(?:by|based\s+on(?:\s+the)?|across|per)\s+(?:the\s+)?region\b/i.test(question);
417
+ const hasProductCategory = /\bproduct\s+categor(?:y|ies)\b/i.test(question);
418
+ // A planner can surface the noun from a projected field as a grouping
419
+ // dimension (for example `product` from “with product ID and product
420
+ // price”). An output is not a `by product` group. Keep the noun only when
421
+ // the reader actually supplied a grouping construction; otherwise the
422
+ // host-owned row-level/output tuple would acquire a fake dimension and
423
+ // make an otherwise single-table exploratory plan ambiguous.
424
+ const outputRoots = new Set(explicitOutputTerms(question)
425
+ .map((term) => term.replace(/\s+(?:id|name|price)$/i, '').trim())
426
+ .filter(Boolean));
427
+ const isExplicitGroupingRoot = (term) => {
428
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
429
+ return new RegExp(`\\b(?:by|per|across|for\\s+each)\\s+(?:the\\s+)?${escaped}(?:s)?\\b`, 'i').test(question);
430
+ };
431
+ const normalized = uniqueRequirementTerms([...values])
432
+ .filter((value) => !/\b(?:revenue|sales)\b.*\b(?:based\s+on|by)\b.*\bregion\b/.test(value))
433
+ .filter((value) => !(hasProductCategory && /^(?:product|category)$/.test(value)))
434
+ .filter((value) => !outputRoots.has(value) || isExplicitGroupingRoot(value));
435
+ return uniqueRequirementTerms([
436
+ ...normalized,
437
+ ...(hasRegion ? ['region'] : []),
438
+ ...(hasProductCategory ? ['product category'] : []),
439
+ ]);
440
+ }
441
+ function explicitOutputTerms(question) {
442
+ const terms = [...question.matchAll(/\b(?:order|product|customer|account)\s+(?:id|name|price)\b/gi)]
443
+ .map((match) => match[0] ?? '');
444
+ return uniqueRequirementTerms(terms);
445
+ }
115
446
  /**
116
447
  * Normalize grammatical aggregation wrappers before they become a plan
117
448
  * requirement. Retrieval/parser output is allowed to retain useful search
@@ -119,20 +450,26 @@ function normalizedTypedAggregationRequirements(input) {
119
450
  * or "for each customer" as separate physical measures. The grouping entity
120
451
  * is represented by the dimension/entity roles instead.
121
452
  *
122
- * `order count for each customer` is the common prose form for a count
123
- * aggregation at customer grain. Keep the aggregation (`count`) and remove
124
- * the object noun (`order`) only for that exact grouped construction; a named
125
- * metric such as `order_value` remains untouched.
453
+ * `order count for each customer` is the common prose form for the authored
454
+ * `order_count` semantic measure at customer grain. Keep that compound
455
+ * identity intact: reducing it to the generic aggregation `count` makes the
456
+ * unfiltered `Orders` MetricFlow metric indistinguishable from scoped metrics
457
+ * such as `Drink Orders` and `Food Orders`. The grouping entity is still
458
+ * represented by the dimension/entity roles rather than becoming another
459
+ * measure.
126
460
  */
127
461
  export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
128
- const normalizedQuestion = normalizeRequirementTerm(question);
129
- 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);
462
+ // A parser often singularizes the business alias `sales` into `sale`. That
463
+ // is not a second measure beside revenue: it is the same current-question
464
+ // request. Canonicalize only that standalone vocabulary alias here; named
465
+ // measures such as `sales_tax` or `sales_pipeline` keep their identity.
466
+ const canonicalMeasureAlias = (value) => {
467
+ const normalized = normalizeRequirementTerm(value);
468
+ return normalized === 'sale' || normalized === 'sales' ? 'revenue' : value;
469
+ };
130
470
  const terms = values
131
- .filter((value) => !isStructuralMeasurePhrase(value))
132
- .filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
133
- if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
134
- terms.push('count');
135
- }
471
+ .map(canonicalMeasureAlias)
472
+ .filter((value) => !isStructuralMeasurePhrase(value));
136
473
  // An inherited measure can already be a stable semantic/dbt identity. Keep
137
474
  // that identity intact for the planner/meaning handoff; matching and display
138
475
  // have their own normalizers. Rewriting `total_consumption_units` to prose
@@ -156,22 +493,111 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
156
493
  * phrase that already owns it: adding both `beverage revenue` and `revenue`
157
494
  * turns one requested metric into two and incorrectly rejects a block whose
158
495
  * own declared output is `beverage_revenue`. The same holds for `order count`
159
- * and its generic `count` root; grouped prose is normalized to `count` before
160
- * this helper runs, so retaining both is neither necessary nor correct.
496
+ * and its generic `count` root; the authored compound is retained and owns
497
+ * that generic aggregation vocabulary for this question.
161
498
  */
162
499
  function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
163
- const lexical = ['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count']
500
+ const compoundTerms = explicitQuestionCompoundMeasureTerms(question);
501
+ const lexical = [
502
+ ...compoundTerms,
503
+ ...['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count'],
504
+ ]
505
+ // `BCM run rate` is one named business measure, not the independent
506
+ // lexical roots `bcm` and `run rate`.
507
+ .filter((term) => !compoundTerms.some((compound) => compound !== term && compound.includes(term)))
164
508
  .filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
165
509
  return lexical.filter((term) => {
166
- if (term === 'count') {
167
- return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
168
- }
169
- const token = term === 'refunds' ? 'refund' : term;
170
- return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
510
+ // A multi-word lexical root such as `run rate` must be considered owned
511
+ // by `BCM run rate`. The old word-by-word comparison only worked for a
512
+ // one-word root and added both phrases as independent measures, which
513
+ // prevented an otherwise exact semantic metric from binding without a
514
+ // provider. Treat the lexical phrase as redundant when all of its tokens
515
+ // are already present in one parsed measure. Keep the narrow refund
516
+ // singular/plural equivalence so existing vocabulary remains stable.
517
+ const lexicalTokens = normalizeRequirementTerm(term)
171
518
  .split(' ')
172
- .some((word) => word === token || (token === 'refund' && word === 'refunds')));
519
+ .filter(Boolean)
520
+ .map((token) => token === 'refunds' ? 'refund' : token);
521
+ return !parsedMeasures.some((measure) => {
522
+ const measureTokens = new Set(normalizeRequirementTerm(measure)
523
+ .split(' ')
524
+ .filter(Boolean)
525
+ .map((token) => token === 'refunds' ? 'refund' : token));
526
+ return lexicalTokens.every((token) => measureTokens.has(token));
527
+ });
173
528
  });
174
529
  }
530
+ /**
531
+ * Preserve explicit multi-word business measures when a retrieval parser
532
+ * emits only a suffix such as `rate`. This is intentionally a compact,
533
+ * vocabulary-backed list rather than a speculative phrase synthesizer: it
534
+ * upgrades only a phrase the user actually wrote and whose complete meaning
535
+ * is common in the governed analytics catalog.
536
+ */
537
+ function explicitQuestionCompoundMeasureTerms(question) {
538
+ return [
539
+ ...(/\borders?\s+count\b/i.test(question) ? ['order count'] : []),
540
+ ...(/\bbcm\s+run\s+rate\b/i.test(question) ? ['bcm run rate'] : []),
541
+ ];
542
+ }
543
+ /**
544
+ * A parser fragment must not become a second measure when the source question
545
+ * contains an explicit compound business measure that subsumes it. For
546
+ * example, `rate` plus `BCM run rate` is one requested metric. We only remove
547
+ * strict token subsets of a phrase explicitly present in the user question;
548
+ * independent named measures remain separate requirements.
549
+ */
550
+ function preferExplicitQuestionCompoundMeasures(terms, question) {
551
+ const compounds = explicitQuestionCompoundMeasureTerms(question)
552
+ .map((term) => ({ term, tokens: normalizeRequirementTerm(term).split(' ').filter(Boolean) }));
553
+ if (compounds.length === 0)
554
+ return uniqueRequirementTerms([...terms]);
555
+ return uniqueRequirementTerms([...terms]).filter((term) => {
556
+ const normalized = normalizeRequirementTerm(term);
557
+ const tokens = normalized.split(' ').filter(Boolean);
558
+ return !compounds.some((compound) => normalized !== compound.term
559
+ && tokens.length > 0
560
+ && tokens.length < compound.tokens.length
561
+ && tokens.every((token) => compound.tokens.includes(token)));
562
+ });
563
+ }
564
+ /**
565
+ * A ranking question can contain a business qualifier immediately before the
566
+ * ranked entity (for example, "BCM customers") as well as the measure that
567
+ * actually orders the result ("highest revenue"). The retrieval parser keeps
568
+ * both phrases because both are useful for recall, but they are not equivalent
569
+ * plan requirements. Prefer a direct ranking clause over the broad parser
570
+ * hint before any candidate is admitted as a metric.
571
+ *
572
+ * This deliberately remains narrow. It only disambiguates when the parser
573
+ * supplied competing measures and the user also wrote an explicit comparator;
574
+ * parser-absent and already-unambiguous ranking requests retain their existing
575
+ * normal meaning-resolution path.
576
+ */
577
+ function explicitRankingMeasureTerms(question, parsedMeasures) {
578
+ const parsed = uniqueRequirementTerms([...parsedMeasures]);
579
+ if (parsed.length < 2)
580
+ return [];
581
+ const phrases = [];
582
+ const endOfMeasure = String.raw `(?=\s+(?:across|among|for|per|in|where|during|over|with|that|which|who|and|or)\b|[?.!,;]|$)`;
583
+ for (const pattern of [
584
+ new RegExp(String.raw `\b(?:highest|lowest|most|least)\s+(?:the\s+)?([a-z][a-z0-9_. -]{0,80}?)${endOfMeasure}`, 'gi'),
585
+ ]) {
586
+ for (const match of question.matchAll(pattern)) {
587
+ const phrase = normalizeRequirementTerm(match[1] ?? '');
588
+ if (phrase && !isTemporalTerm(phrase))
589
+ phrases.push(phrase);
590
+ }
591
+ }
592
+ const direct = uniqueRequirementTerms(phrases);
593
+ if (direct.length === 0)
594
+ return [];
595
+ // Preserve the parser's more stable authored phrase when it is the same
596
+ // measure. This avoids replacing `net revenue` with a looser lexical root,
597
+ // while still removing an entity modifier such as `BCM` in `BCM customers`.
598
+ const matchedParsed = parsed.filter((measure) => direct.some((phrase) => measure === phrase || measure.includes(phrase) || phrase.includes(measure)));
599
+ return matchedParsed.length > 0 ? matchedParsed : direct;
600
+ }
175
601
  /**
176
602
  * Parse only stable analytical roles. This is purposefully narrower than an
177
603
  * LLM interpretation: unknown business phrases remain available to the normal
@@ -180,7 +606,9 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
180
606
  export function buildAnalyticalRequirementSet(input) {
181
607
  const question = input.question;
182
608
  const lower = question.toLowerCase();
183
- const parsed = input.parsedIntent;
609
+ // Parser/retrieval evidence may be broad or stale. A requirement set is
610
+ // host authority, so only source-question-grounded refinements may enter it.
611
+ const parsed = currentQuestionGroundedParsedIntent(question, input.parsedIntent);
184
612
  const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
185
613
  const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
186
614
  const grain = grainWord === 'daily' ? 'day'
@@ -192,12 +620,18 @@ export function buildAnalyticalRequirementSet(input) {
192
620
  : undefined;
193
621
  const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
194
622
  const fiscalPeriod = fiscal ? `FY${fiscal[1] ?? fiscal[2]}`.toUpperCase() : undefined;
195
- const ranking = lower.match(/\b(top|bottom|highest|lowest)\s*(\d+)?\b/i);
196
- const requestedDimensions = uniqueRequirementTerms(parsed?.dimensions ?? []);
623
+ const ranking = lower.match(/\b(top|bottom|highest|lowest|most|least|expensive|cheapest)\s*(\d+)?\b/i);
624
+ const leadingOrdinalRanking = lower.match(/\b(\d+)\s+(?:most|least|expensive|cheapest)\b/i);
625
+ const requestedDimensions = normalizeAnalyticalDimensionTerms(question, parsed?.dimensions ?? []);
197
626
  const entityTerms = uniqueRequirementTerms([
198
627
  ...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
199
628
  ]).map((term) => term.replace(/s$/, ''));
200
- const entityDisplayTerms = /\b(?:who|which)\b/i.test(question)
629
+ // A ranking result needs a human-readable entity output even when the
630
+ // wording starts with "what" rather than "who" or "which". Treat the
631
+ // entity's display key as a required role for `top accounts` / `top
632
+ // customers`; an entity key, owner field, or sentiment attribute is not a
633
+ // substitute for the result label.
634
+ const entityDisplayTerms = (/\b(?:who|which)\b/i.test(question) || Boolean(ranking && entityTerms.length > 0))
201
635
  ? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
202
636
  : [];
203
637
  // "this amount" is a deictic reference to a prior result, not a request to
@@ -206,32 +640,70 @@ export function buildAnalyticalRequirementSet(input) {
206
640
  // option. Concrete metric words remain typed requirements, including the
207
641
  // common revenue/refunds pair used by multi-metric requests.
208
642
  const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
209
- const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? []);
210
- const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
643
+ // `sales` is a common business synonym for revenue. Do not add the broad
644
+ // revenue root merely because a named metric contains it (`beverage revenue`)
645
+ // or an exact certified block would suddenly look multi-metric.
646
+ const salesIsRevenueAlias = /\bsales\b/i.test(question);
647
+ const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
648
+ .filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
649
+ const parsedMeasuresWithLexicalTerms = preferExplicitQuestionCompoundMeasures(uniqueRequirementTerms([
211
650
  ...parsedMeasures,
212
651
  ...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
652
+ ...(salesIsRevenueAlias ? ['revenue'] : []),
653
+ ...(/\b(?:most|highest|expensive)\b.*\bproduct\s+price\b|\bproduct\s+price\b.*\b(?:most|highest|expensive)\b/i.test(question)
654
+ ? ['product price']
655
+ : []),
213
656
  ...(!deicticAmount
214
657
  && /\bamount\b/i.test(question)
215
658
  && !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
216
659
  ? ['amount']
217
660
  : []),
218
- ]);
661
+ ]), question);
219
662
  const typedRequirements = normalizedTypedAggregationRequirements({
220
663
  question,
221
664
  measures: parsedMeasuresWithLexicalTerms,
222
665
  dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
223
666
  });
224
- const measures = typedRequirements.measures;
225
- const dimensions = typedRequirements.dimensions;
667
+ const explicitRankingMeasures = ranking
668
+ ? explicitRankingMeasureTerms(question, typedRequirements.measures)
669
+ : [];
670
+ // A direct ranking clause is the explicit analytical measure. Keep broad
671
+ // parser/retrieval phrases out of the execution tuple so a contextual term
672
+ // cannot become a second ranking metric or force a false clarification.
673
+ const measures = explicitRankingMeasures.length > 0
674
+ ? explicitRankingMeasures
675
+ : typedRequirements.measures;
676
+ // Retrieval/parser hints sometimes repeat an explicit measure as a
677
+ // dimension (for example `revenue` in "show revenue by region"). Keep
678
+ // the user-authored measure authoritative and remove only an exact
679
+ // normalized duplicate. A broader substring rule would incorrectly drop
680
+ // legitimate dimensions such as `product revenue category`.
681
+ const measureTerms = new Set(measures.map((measure) => normalizeRequirementTerm(measure)));
682
+ const dimensions = typedRequirements.dimensions.filter((dimension) => !measureTerms.has(normalizeRequirementTerm(dimension)));
226
683
  const rankingMetricTerms = ranking ? measures : [];
227
- const parsedLimit = typeof parsed?.limit === 'number' && Number.isFinite(parsed.limit) && parsed.limit > 0
228
- ? Math.floor(parsed.limit)
229
- : undefined;
230
- const explicitLimit = ranking?.[2] ? Number(ranking[2]) : parsedLimit;
231
- const time = grain || fiscalPeriod
684
+ // A context planner supplies its own safety default (`topN: 10`) for bare
685
+ // rankings. It is a useful execution bound, but it is not user intent. Read
686
+ // an explicit count only from the actual question so the cascade/answer
687
+ // receipt can disclose that a bare “top” used DQL's default rather than
688
+ // misleadingly presenting it as a requested limit.
689
+ 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();
690
+ const explicitWordLimit = {
691
+ one: 1, two: 2, three: 3, four: 4, five: 5,
692
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
693
+ };
694
+ const explicitLimit = ranking?.[2]
695
+ ? Number(ranking[2])
696
+ : leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
697
+ : wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
698
+ // A bounded window is a restriction on WHEN, distinct from grouping grain.
699
+ // It gets its own typed clause; without one, "last two months" had nowhere
700
+ // to live and silently vanished between the question and the query.
701
+ const window = parseAnalyticalTimeWindow(question);
702
+ const time = grain || fiscalPeriod || window
232
703
  ? {
233
704
  role: grain ? 'time_axis' : 'time_filter',
234
705
  ...(grain ? { grain: grain } : {}),
706
+ ...(window ? { window } : {}),
235
707
  ...(fiscalPeriod ? { fiscalPeriod } : {}),
236
708
  requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
237
709
  }
@@ -242,13 +714,22 @@ export function buildAnalyticalRequirementSet(input) {
242
714
  dimensions,
243
715
  entityTerms,
244
716
  entityDisplayTerms,
245
- memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
717
+ // A parser filter is useful but not the sole authority for an explicit
718
+ // current-turn member. Preserve bounded literal atoms too; downstream
719
+ // planning must still bind them to qualified snapshot evidence before a
720
+ // field/value predicate can be frozen.
721
+ memberTerms: uniqueRequirementTerms([
722
+ ...(parsed?.filters ?? []).map((filter) => filter.value),
723
+ ...currentQuestionLiteralMemberTerms(question),
724
+ ]),
725
+ ...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
726
+ ...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
246
727
  ...(ranking
247
728
  ? {
248
729
  ranking: {
249
730
  metricTerms: rankingMetricTerms,
250
731
  entityTerms,
251
- direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
732
+ direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
252
733
  limit: explicitLimit ?? 10,
253
734
  defaultedLimit: explicitLimit === undefined,
254
735
  },
@@ -257,34 +738,163 @@ export function buildAnalyticalRequirementSet(input) {
257
738
  ...(time ? { time } : {}),
258
739
  };
259
740
  }
260
- /** Classify the role an already-qualified candidate may fill. */
261
- export function evidenceCandidateRoles(candidate) {
262
- const identity = uniqueRequirementTerms([
741
+ const DECLARED_CANDIDATE_ROLE_ALIASES = [
742
+ ['metric', ['metric']],
743
+ ['entity_key', ['entity key', 'entity id']],
744
+ ['entity_label', ['entity label', 'display key', 'display label']],
745
+ ['categorical_dimension', ['categorical dimension', 'category dimension']],
746
+ ['time_dimension', ['time dimension', 'date dimension']],
747
+ ['member', ['member']],
748
+ ['relationship', ['relationship']],
749
+ ['context', ['context']],
750
+ ];
751
+ /**
752
+ * Only an authored declaration may let an object fill an additional role. A
753
+ * metric's capability lists dimensions, time grains, and relationship paths it
754
+ * *uses*; that does not make the metric itself a display key, time column, or
755
+ * relationship candidate. Compatibility facts are snapshot-authored metadata
756
+ * and therefore the only additive role declaration accepted here.
757
+ */
758
+ function explicitlyDeclaredCandidateRoles(candidate) {
759
+ const declared = new Set();
760
+ for (const fact of candidate.compatibilityFacts ?? []) {
761
+ const normalized = normalizeRequirementTerm(fact);
762
+ if (!/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\b/.test(normalized))
763
+ continue;
764
+ const suffix = normalized.replace(/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\s*/, '');
765
+ for (const [role, aliases] of DECLARED_CANDIDATE_ROLE_ALIASES) {
766
+ if (aliases.some((alias) => new RegExp(`(?:^| )${alias}(?:$| )`).test(suffix)))
767
+ declared.add(role);
768
+ }
769
+ }
770
+ return declared;
771
+ }
772
+ function intrinsicCandidateIdentity(candidate) {
773
+ return uniqueRequirementTerms([
263
774
  candidate.id,
264
775
  candidate.qualifiedId,
265
776
  candidate.name,
266
- ...(candidate.aliases ?? []),
267
- ...(candidate.dimensions ?? []),
268
- ...(candidate.analyticalCapability?.dimensions ?? []).map((dimension) => dimension.dimensionId),
269
- ...(candidate.analyticalCapability?.timeDimensions ?? []).map((dimension) => dimension.dimensionId),
270
777
  ]).join(' ');
778
+ }
779
+ /**
780
+ * Keep account display-key selection separate from common account attributes.
781
+ * Candidate names originate in dbt/semantic identifiers, so underscores and
782
+ * dots must be normalized before testing (`account_sentiment_rating` is just
783
+ * as much an attribute as "Account Sentiment Rating").
784
+ */
785
+ export function hasEntityAttributeTerm(value) {
786
+ return /\b(?:owner|sentiment|email)\b/i.test(normalizeRequirementTerm(value));
787
+ }
788
+ export function isEntityAttributeCandidate(candidate) {
789
+ return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
790
+ }
791
+ /**
792
+ * Time is a type-level role, not a lexical synonym. Semantic indexes from
793
+ * dbt/MetricFlow commonly retain all members as `dimension`, so a typed
794
+ * `opened_date` must not also enter the ordinary categorical/geographic
795
+ * fallback lane. A card-level semantic-time class or physical/semantic type
796
+ * is authoritative. `timeGrains` alone is deliberately not: older retrieval
797
+ * adapters can inherit a model/metric's supported grains onto unrelated
798
+ * entity and display cards. Names are used only for legacy cards with no
799
+ * source type.
800
+ */
801
+ function candidateHasDeclaredTimeRole(candidate) {
802
+ if (candidate.semanticObjectType === 'time_dimension')
803
+ return true;
804
+ const dataType = normalizeRequirementTerm(candidate.dataType ?? '');
805
+ return /(?:^| )(?:date|datetime|timestamp|timestamptz|timestampntz|time)(?:$| )/.test(dataType);
806
+ }
807
+ function candidateUsesLegacyTimeNameFallback(candidate) {
808
+ // A supplied type is authoritative even when a legacy name happens to
809
+ // contain `date` (for example a text display label). A missing/empty
810
+ // time-grain list is not a positive type declaration, so old untyped cards
811
+ // retain this safe fallback.
812
+ if (normalizeRequirementTerm(candidate.dataType ?? ''))
813
+ return false;
814
+ return /(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b|\bfiscal\b|\bperiod\b)/
815
+ .test(intrinsicCandidateIdentity(candidate));
816
+ }
817
+ /** Classify the role an already-qualified candidate may fill. */
818
+ export function evidenceCandidateRoles(candidate) {
819
+ const identity = intrinsicCandidateIdentity(candidate);
271
820
  const roles = new Set();
272
- if (candidate.kind === 'semantic_metric' || candidate.semanticObjectType === 'metric' || /\bmetric\b/.test(identity))
821
+ const physicalColumn = candidate.kind === 'sql_column';
822
+ // An explicit semantic metric remains a metric even if an old index also
823
+ // carries an imprecise type. Conversely, a typed semantic time dimension
824
+ // such as `metric_time` must not become a metric merely because its local
825
+ // compiler name contains the word "metric". The latter was causing V2 to
826
+ // admit the time card as a metric and discard its declared grains before
827
+ // semantic validation.
828
+ const explicitMetricCandidate = candidate.kind === 'semantic_metric'
829
+ || candidate.semanticObjectType === 'metric'
830
+ || candidate.semanticObjectType === 'measure';
831
+ const sourceDeclaredTimeRole = !explicitMetricCandidate && candidateHasDeclaredTimeRole(candidate);
832
+ const metricCandidate = explicitMetricCandidate
833
+ || (!sourceDeclaredTimeRole && (/\bmetric\b/.test(identity)
834
+ || (physicalColumn && /\b(?:revenue|amount|count|rate|bcm|spend|cost|margin|total)\b/.test(identity))));
835
+ // A temporal type/grain is stronger than an authored compatibility-role
836
+ // label. Index migrations can leave an old `roles categorical dimension`
837
+ // fact on a date field, but allowing that contradictory fact back into the
838
+ // ordinary inference lane turns time fields into false geography choices.
839
+ // Metrics retain their explicit compatibility roles because capability
840
+ // metadata is not the metric object's own temporal identity.
841
+ const declaredTimeRole = !metricCandidate && sourceDeclaredTimeRole;
842
+ const legacyTimeName = !metricCandidate
843
+ && !declaredTimeRole
844
+ && candidateUsesLegacyTimeNameFallback(candidate);
845
+ const temporalCandidate = declaredTimeRole || legacyTimeName;
846
+ if (metricCandidate)
273
847
  roles.add('metric');
274
- if (candidate.semanticObjectType === 'entity' || /(?:^| )(?:account|customer|client|company) id\b/.test(identity) || /\bentity\b/.test(identity))
275
- roles.add('entity_key');
276
- if (/\b(?:account|customer|client|company)(?: name)?\b/.test(identity)
277
- && /\b(?:name|label|display|account|customer|client|company)\b/.test(identity)
278
- && !/\b(?:owner|sentiment|email)\b/.test(identity))
279
- roles.add('entity_label');
280
- if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b)/.test(identity)
281
- || (candidate.timeGrains?.length ?? 0) > 0
282
- || (candidate.analyticalCapability?.timeDimensions?.length ?? 0) > 0)
283
- roles.add('time_dimension');
284
- if ((candidate.relationshipEvidence?.length ?? 0) > 0 || /\b(?:relationship|join|bridge)\b/.test(identity))
285
- roles.add('relationship');
286
- if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension')
287
- roles.add('categorical_dimension');
848
+ // Capability metadata belongs to the metric's execution contract. It must
849
+ // not be treated as the metric object's own entity, display, time, or join
850
+ // identity. Explicit snapshot metadata is the only exception.
851
+ if (!metricCandidate) {
852
+ if (candidate.semanticObjectType === 'entity'
853
+ || /(?:^| )(?:account|customer|client|company) (?:id|key)\b/.test(identity)
854
+ || /\bentity\b/.test(identity))
855
+ roles.add('entity_key');
856
+ // Entity identity (for example `semantic:entity:account`) proves an
857
+ // entity key/grain but not the field a person can read in a ranking
858
+ // result. Require an intrinsic display-name declaration instead of
859
+ // allowing every identifier that merely contains "account" or
860
+ // "customer" to fill the entity-label role. Explicit authored role
861
+ // facts below remain the only additive exception.
862
+ if (/\b(?:account|customer|client|company)\b/.test(identity)
863
+ && /\b(?:name|label|display)\b/.test(identity)
864
+ && !hasEntityAttributeTerm(identity))
865
+ roles.add('entity_label');
866
+ if (temporalCandidate)
867
+ roles.add('time_dimension');
868
+ if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
869
+ || /\b(?:relationship|join|bridge)\b/.test(identity))
870
+ roles.add('relationship');
871
+ // `semantic_member` intentionally collapses dimensions, entities, models,
872
+ // and saved queries in older local indexes. Only a real (or legacy
873
+ // unclassified) dimension can be a categorical field. A semantic model or
874
+ // entity is execution context, never a user-visible grouping dimension.
875
+ const semanticDimension = candidate.semanticObjectType === 'dimension'
876
+ || (candidate.kind === 'semantic_member' && candidate.semanticObjectType === undefined);
877
+ // A time dimension can be grouped at a time grain, but it is not an
878
+ // ordinary categorical/geographic alternative for a business term such
879
+ // as `region`. Keep the role sets mutually exclusive here; the semantic
880
+ // compiler still receives the same qualified identity when time is asked.
881
+ if (!temporalCandidate
882
+ && (semanticDimension
883
+ || (physicalColumn && /\b(?:competitor|region|category|segment|status|type|owner|sentiment|active|product|description)\b/.test(identity)))) {
884
+ roles.add('categorical_dimension');
885
+ }
886
+ if (candidate.kind === 'semantic_member' && candidate.semanticObjectType === 'member')
887
+ roles.add('member');
888
+ }
889
+ for (const role of explicitlyDeclaredCandidateRoles(candidate)) {
890
+ // Do not let a stale/contradictory authored compatibility declaration
891
+ // reverse a source-authored temporal type or legacy temporal identity.
892
+ // The temporal role remains visible; only the conflicting ordinary
893
+ // categorical role is rejected.
894
+ if (temporalCandidate && role === 'categorical_dimension')
895
+ continue;
896
+ roles.add(role);
897
+ }
288
898
  if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
289
899
  roles.add('context');
290
900
  if (roles.size === 0)
@@ -303,9 +913,63 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
303
913
  ]).join(' ');
304
914
  if (terms.some((term) => identity.includes(term) || term.includes(identity)))
305
915
  return true;
916
+ if (options.categoricalDimension === true
917
+ && terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
918
+ return true;
306
919
  return options.categoricalDimension === true
307
920
  && candidateMatchesCategoricalDimensionRequirement(candidate, terms);
308
921
  }
922
+ /**
923
+ * A metric's attached dimensions, source model, and relationship path are
924
+ * execution context. They are deliberately useful for binding a complete
925
+ * plan, but must never make one metric match the name of another. In
926
+ * particular, `bcm_run_rate` from an `account_revenue` model is not the
927
+ * `revenue` ranking measure merely because its source-model identity contains
928
+ * that word.
929
+ */
930
+ function metricCandidateIdentityTerms(candidate) {
931
+ const terminalMetricIdentity = (value) => {
932
+ if (!value)
933
+ return undefined;
934
+ const namespaceLeaf = value.split(':').filter(Boolean).at(-1) ?? value;
935
+ const metricLeaf = namespaceLeaf.split(/[./]/).filter(Boolean).at(-1) ?? namespaceLeaf;
936
+ return normalizeRequirementTerm(metricLeaf);
937
+ };
938
+ return uniqueRequirementTerms([
939
+ // Metadata cards sometimes use their source-qualified identifier as the
940
+ // display label (for example `account_revenue.bcm_run_rate`). Treat that
941
+ // exactly like an ID: its terminal metric leaf is intrinsic identity and
942
+ // its model prefix is execution context. A human label such as `Total
943
+ // Revenue` has no namespace separator and is retained intact.
944
+ terminalMetricIdentity(candidate.name),
945
+ ...(candidate.aliases ?? []).map(terminalMetricIdentity),
946
+ terminalMetricIdentity(candidate.id),
947
+ terminalMetricIdentity(candidate.qualifiedId),
948
+ ]);
949
+ }
950
+ function metricCandidateMatchesTerms(candidate, terms) {
951
+ const identities = metricCandidateIdentityTerms(candidate);
952
+ return terms.some((term) => {
953
+ const normalizedTerm = normalizeRequirementTerm(term);
954
+ if (!normalizedTerm)
955
+ return false;
956
+ return identities.some((identity) => identity === normalizedTerm
957
+ || identity.endsWith(` ${normalizedTerm}`)
958
+ || normalizedTerm.endsWith(` ${identity}`));
959
+ });
960
+ }
961
+ /**
962
+ * A direct ranking measure is authoritative for metric admission. Other
963
+ * retrieved metrics remain visible in the lifecycle receipt, but they cannot
964
+ * become a second metric choice merely because they are correlated with the
965
+ * entity phrase in the question.
966
+ */
967
+ export function candidateConflictsWithExplicitRankingMeasure(candidate, requirements) {
968
+ const metricTerms = requirements.ranking?.metricTerms ?? [];
969
+ return metricTerms.length > 0
970
+ && evidenceCandidateRoles(candidate).includes('metric')
971
+ && !metricCandidateMatchesTerms(candidate, metricTerms);
972
+ }
309
973
  /**
310
974
  * A categorical dimension may satisfy a requested business role only through
311
975
  * its own snapshot-authored declaration. In particular, `location_name` is
@@ -315,6 +979,14 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
315
979
  * broad lexical geography expansion while retaining role-balanced recall.
316
980
  */
317
981
  export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
982
+ const extension = candidate.sameSnapshotRoleExtension;
983
+ if (extension?.role === 'categorical_dimension'
984
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
985
+ || extension.basis === 'exact_metricflow_grouping_dimension')) {
986
+ const requested = new Set(terms.map(normalizeRequirementTerm).filter(Boolean));
987
+ if (requested.has(normalizeRequirementTerm(extension.requestedTerm)))
988
+ return true;
989
+ }
318
990
  const facts = new Set((candidate.compatibilityFacts ?? [])
319
991
  .map(normalizeRequirementTerm)
320
992
  .filter(Boolean));
@@ -339,7 +1011,12 @@ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms
339
1011
  * to two candidates before relevance fills remaining cards.
340
1012
  */
341
1013
  export function selectRoleBalancedMeaningCandidates(input) {
342
- const max = Math.max(1, Math.min(16, Math.floor(input.maxCandidates ?? 16)));
1014
+ // This selector is used twice by Ask Analyst Runtime: once to make the
1015
+ // immutable 32-item execution workspace and again to make the compact
1016
+ // 16-card planner package. Keep the ceiling here rather than allowing a
1017
+ // caller to accidentally turn a retrieval result into an unbounded prompt
1018
+ // or compiler closure.
1019
+ const max = Math.max(1, Math.min(32, Math.floor(input.maxCandidates ?? 16)));
343
1020
  const ranked = [...new Map(input.candidates
344
1021
  .filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
345
1022
  .map((candidate) => [candidate.id, candidate])).values()]
@@ -348,9 +1025,12 @@ export function selectRoleBalancedMeaningCandidates(input) {
348
1025
  || left.id.localeCompare(right.id));
349
1026
  const selected = [];
350
1027
  const add = (candidate) => {
351
- if (candidate && selected.length < max && !selected.some((item) => item.id === candidate.id))
1028
+ if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
1029
+ return;
1030
+ if (selected.length < max && !selected.some((item) => item.id === candidate.id))
352
1031
  selected.push(candidate);
353
1032
  };
1033
+ const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
354
1034
  const servesRequestedRole = (candidate) => {
355
1035
  const roles = evidenceCandidateRoles(candidate);
356
1036
  const metricTerms = input.requirements.ranking?.metricTerms.length
@@ -361,6 +1041,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
361
1041
  // An entity term such as "account" is deliberately insufficient for an
362
1042
  // attribute (Account Owner Email) to displace the requested display key.
363
1043
  // Only an actual entity-label candidate may satisfy this binding.
1044
+ if (roles.includes('entity_key') && candidateMatchesTerms(candidate, input.requirements.entityTerms))
1045
+ return true;
364
1046
  if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
365
1047
  ...input.requirements.entityTerms,
366
1048
  ...input.requirements.entityDisplayTerms,
@@ -369,8 +1051,14 @@ export function selectRoleBalancedMeaningCandidates(input) {
369
1051
  if (roles.includes('time_dimension') && Boolean(input.requirements.time))
370
1052
  return true;
371
1053
  if (roles.includes('categorical_dimension')
372
- && input.requirements.dimensions.length > 0
373
- && candidateMatchesTerms(candidate, input.requirements.dimensions, { categoricalDimension: true }))
1054
+ && categoricalTerms.length > 0
1055
+ && candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
1056
+ return true;
1057
+ if (roles.includes('member') && input.requirements.memberTerms.length > 0
1058
+ && candidateMatchesTerms(candidate, input.requirements.memberTerms))
1059
+ return true;
1060
+ if (roles.includes('categorical_dimension') && input.requirements.memberTerms.length > 0
1061
+ && candidateHasSafeValueForMemberTerms(candidate, input.requirements.memberTerms))
374
1062
  return true;
375
1063
  if (roles.includes('relationship')
376
1064
  && (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
@@ -386,13 +1074,19 @@ export function selectRoleBalancedMeaningCandidates(input) {
386
1074
  add(candidate);
387
1075
  }
388
1076
  const required = [
389
- ['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
390
- ['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
391
- ['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
392
- ['categorical_dimension', input.requirements.dimensions],
393
- ['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
1077
+ // An explicit ranking measure is never displaced by correlated metric
1078
+ // variants. Two cards leave room for a compatible canonical/alias pair.
1079
+ { role: 'metric', terms: input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures, limit: 2 },
1080
+ { role: 'entity_key', terms: input.requirements.entityTerms, limit: 1 },
1081
+ { role: 'entity_label', terms: [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms], limit: 2 },
1082
+ // Each requested breakdown gets two candidates, bounded to four total so
1083
+ // a product/category request cannot be crowded out by customer variants.
1084
+ ...categoricalTerms.slice(0, 2).map((term) => ({ role: 'categorical_dimension', terms: [term], limit: 2, categorical: true })),
1085
+ { role: 'time_dimension', terms: input.requirements.time ? [input.requirements.time.grain ?? 'time'] : [], limit: 2 },
1086
+ { role: 'member', terms: input.requirements.memberTerms, limit: 2 },
1087
+ { role: 'relationship', terms: input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : [], limit: 2 },
394
1088
  ];
395
- for (const [role, terms] of required) {
1089
+ for (const { role, terms, limit, categorical } of required) {
396
1090
  // No requested categorical dimension means that high-scoring arbitrary
397
1091
  // members are noise, not a role reservation. This is the subtle path that
398
1092
  // used to admit Account Owner and Sentiment immediately after Account Name.
@@ -400,10 +1094,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
400
1094
  continue;
401
1095
  let admitted = 0;
402
1096
  for (const candidate of ranked) {
403
- if (admitted >= 2 || selected.length >= max)
1097
+ if (admitted >= limit || selected.length >= max)
404
1098
  break;
405
1099
  const roles = evidenceCandidateRoles(candidate);
406
- if (!roles.includes(role))
1100
+ const safePhysicalMember = role === 'member'
1101
+ && roles.includes('categorical_dimension')
1102
+ && candidateHasSafeValueForMemberTerms(candidate, terms);
1103
+ if (!roles.includes(role) && !safePhysicalMember)
407
1104
  continue;
408
1105
  // "top accounts" needs the account display key, not any field whose
409
1106
  // label happens to contain account. Once a display candidate is
@@ -411,7 +1108,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
411
1108
  // role nor a useful categorical reservation unless the user explicitly
412
1109
  // named that attribute. This runs during the pre-cap pin pass so noisy
413
1110
  // same-kind cards cannot enter through the categorical role.
414
- const explicitlyRequestsAttribute = /\b(?:owner|sentiment|email)\b/i.test([
1111
+ const explicitlyRequestsAttribute = hasEntityAttributeTerm([
415
1112
  ...input.requirements.dimensions,
416
1113
  ...input.requirements.entityTerms,
417
1114
  ...input.requirements.entityDisplayTerms,
@@ -424,12 +1121,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
424
1121
  if (role === 'categorical_dimension'
425
1122
  && hasRequestedEntityLabel
426
1123
  && !explicitlyRequestsAttribute
427
- && /\b(?:owner|sentiment|email)\b/i.test(candidate.name ?? candidate.id))
1124
+ && isEntityAttributeCandidate(candidate))
428
1125
  continue;
429
1126
  // For entity labels, role is more important than a lexical owner/email
430
1127
  // hit. For all other roles, prefer an identity matching the requested
431
1128
  // business term but retain a role candidate when the request is terse.
432
- if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
1129
+ if (terms.length > 0 && !safePhysicalMember
1130
+ && !candidateMatchesTerms(candidate, terms, { categoricalDimension: categorical === true })
433
1131
  && role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
434
1132
  continue;
435
1133
  add(candidate);
@@ -442,17 +1140,38 @@ export function selectRoleBalancedMeaningCandidates(input) {
442
1140
  }
443
1141
  return selected;
444
1142
  }
1143
+ /** Exact snapshot-value equality only; no lexical/synonym member matching. */
1144
+ function candidateHasSafeValueForMemberTerms(candidate, terms) {
1145
+ const values = candidate.safeValueEvidence ?? [];
1146
+ return terms.some((term) => {
1147
+ const normalized = normalizeRequirementTerm(term);
1148
+ return Boolean(normalized) && values.some((value) => normalizeRequirementTerm(value.normalizedValue ?? value.value ?? '') === normalized);
1149
+ });
1150
+ }
1151
+ /**
1152
+ * Build the Ask execution workspace before planner admission. It is the
1153
+ * only helper that may create the 32-card closure; all later compiler paths
1154
+ * must intersect with this immutable result rather than reaching back into a
1155
+ * broad snapshot.
1156
+ */
1157
+ export function selectRoleBalancedWorkspaceCandidates(input) {
1158
+ return selectRoleBalancedMeaningCandidates({
1159
+ candidates: input.candidates,
1160
+ requirements: input.requirements,
1161
+ maxCandidates: 32,
1162
+ });
1163
+ }
445
1164
  export function classifyProviderFailure(input) {
446
1165
  const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
447
1166
  const cause = /cancel/.test(text) ? 'cancelled'
448
1167
  : /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
449
- : /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
1168
+ : /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
450
1169
  : /run.?deadline|time limit/.test(text) ? 'run_deadline'
451
1170
  : /timeout|timed out/.test(text) ? 'provider_timeout'
452
1171
  : /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
453
1172
  : /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
454
1173
  : /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
455
- : /502|503|504|gateway/.test(text) ? 'gateway'
1174
+ : /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
456
1175
  : /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
457
1176
  : 'unknown';
458
1177
  const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
@@ -461,7 +1180,7 @@ export function classifyProviderFailure(input) {
461
1180
  : cause === 'cancelled' ? 'none'
462
1181
  : 'inspect_run';
463
1182
  const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
464
- : /\b(?:502|503|504)\b/.test(text) ? '5xx'
1183
+ : /\b5\d{2}\b/.test(text) ? '5xx'
465
1184
  : undefined;
466
1185
  return {
467
1186
  version: 1,
@@ -476,6 +1195,29 @@ export function classifyProviderFailure(input) {
476
1195
  };
477
1196
  }
478
1197
  export function buildAnalyticalCascadeDecision(input) {
1198
+ // A frozen tier is the end of the authoritative cascade. Later tiers are
1199
+ // neither evaluated nor eligible as a fallback, even when the selected
1200
+ // compiler, adapter, or execution target subsequently fails. Retaining
1201
+ // pre-built later attempts in the receipt makes a truthful post-freeze
1202
+ // failure look like a silent downgrade and invalidates portable replay.
1203
+ // Normalize at the shared construction boundary so every router path and
1204
+ // every emitted trace receives the same immutable attempt prefix.
1205
+ const attempts = [];
1206
+ for (const inputAttempt of input.attempts) {
1207
+ const attempt = {
1208
+ ...inputAttempt,
1209
+ version: 1,
1210
+ candidateIds: [...new Set(inputAttempt.candidateIds)].slice(0, 32),
1211
+ // Decision-level `planFrozen` is the server-owned source of truth. A
1212
+ // legacy caller may have stamped it only on the decision; keep the
1213
+ // selected attempt coherent before enforcing the immutable prefix.
1214
+ planFrozen: inputAttempt.planFrozen
1215
+ || (input.planFrozen === true && input.selectedTier === inputAttempt.tier),
1216
+ };
1217
+ attempts.push(attempt);
1218
+ if (attempt.planFrozen)
1219
+ break;
1220
+ }
479
1221
  return {
480
1222
  version: 1,
481
1223
  ...input,
@@ -484,7 +1226,15 @@ export function buildAnalyticalCascadeDecision(input) {
484
1226
  version: 1,
485
1227
  candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
486
1228
  })),
487
- attempts: input.attempts.map((attempt) => ({ ...attempt, version: 1, candidateIds: [...new Set(attempt.candidateIds)].slice(0, 32) })),
1229
+ attempts,
1230
+ ...(input.terminalGap ? {
1231
+ terminalGap: {
1232
+ version: 1,
1233
+ code: 'MISSING_RELATIONSHIP',
1234
+ requirement: 'certified_relationship_or_allocation_proof',
1235
+ witnessCandidateIds: [...new Set(input.terminalGap.witnessCandidateIds)].sort().slice(0, 32),
1236
+ },
1237
+ } : {}),
488
1238
  };
489
1239
  }
490
1240
  /**
@@ -516,23 +1266,76 @@ export function splitAnalyticalTasks(question) {
516
1266
  const parts = (asked.length > 0 ? asked : raw)
517
1267
  .map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
518
1268
  .filter(Boolean);
519
- return parts.length > 0 ? parts : [question.trim()];
1269
+ // "… and give me top 5 rows" is a RANKING CLAUSE of the request before it,
1270
+ // not an independent question — it names no measure, no entity, nothing to
1271
+ // ask about on its own. Splitting it minted a task titled "give me top 5
1272
+ // rows" that could never resolve, and the phantom task then spent the
1273
+ // dispatch budget the real question needed. A fragment that is nothing but
1274
+ // shape vocabulary folds back into its predecessor.
1275
+ const merged = [];
1276
+ for (const part of parts) {
1277
+ if (merged.length > 0 && isPureShapeClause(part)) {
1278
+ merged[merged.length - 1] = `${merged[merged.length - 1]} and ${part}`;
1279
+ continue;
1280
+ }
1281
+ merged.push(part);
1282
+ }
1283
+ return merged.length > 0 ? merged : [question.trim()];
1284
+ }
1285
+ /**
1286
+ * Does this fragment carry ONLY result-shape vocabulary (ranking, limit,
1287
+ * projection), with no subject of its own? Checked by removal: strip the
1288
+ * shape words, counts and connectives; a real question leaves a residue.
1289
+ */
1290
+ function isPureShapeClause(fragment) {
1291
+ const lower = fragment.toLowerCase();
1292
+ if (!/\b(?:top|bottom|first|last|highest|lowest|limit|rows?|results?)\b/.test(lower))
1293
+ return false;
1294
+ const residue = lower
1295
+ .replace(/\b(?:and|then|please|give|me|show|list|just|only|the|a|an|top|bottom|first|last|highest|lowest|best|worst|limit|it|to|of|them|rows?|results?|records?|entries|items?|values?)\b/g, ' ')
1296
+ .replace(/\b(?:one|two|three|four|five|six|seven|eight|nine|ten|twelve)\b/g, ' ')
1297
+ .replace(/\d+/g, ' ')
1298
+ .replace(/[^a-z]+/g, ' ')
1299
+ .trim();
1300
+ return residue.length === 0;
520
1301
  }
521
1302
  export function buildAnalyticalTaskGraph(input) {
522
- const rootKind = input.mode === 'research' ? 'research' : inferAnalyticalTurnKind(input.question);
1303
+ // Research is an explicit mode boundary. Investigative wording in an
1304
+ // ordinary Ask can influence its operations but must not silently switch it
1305
+ // to the multi-branch Research budget/execution contract.
1306
+ const inferredKind = inferAnalyticalTurnKind(input.question);
1307
+ const rootKind = input.mode === 'research'
1308
+ ? 'research'
1309
+ : inferredKind === 'research'
1310
+ ? 'diagnosis'
1311
+ : inferredKind;
523
1312
  // A research turn may later create bounded evidence branches, but that is a
524
1313
  // research planner's job. Splitting at ingress loses the surrounding story
525
1314
  // before it has an opportunity to reason about it.
526
- const clauses = (rootKind === 'research' ? [input.question.trim()] : splitAnalyticalTasks(input.question))
527
- .slice(0, Math.max(1, Math.min(6, input.maxTasks ?? 6)));
1315
+ const sourceClauses = rootKind === 'research'
1316
+ ? [input.question.trim()]
1317
+ : splitAnalyticalTasks(input.question);
1318
+ const taskCap = Math.max(1, Math.min(6, input.maxTasks ?? 6));
1319
+ // Keep the overflow visible to the runtime. The prior `slice()` silently
1320
+ // accepted the first three ordinary-Ask clauses and returned `partial:
1321
+ // false`, which could make a four-question request look successfully
1322
+ // answered after only three frozen programs. Ordinary Ask must stop before
1323
+ // planning/execution in that case; explicit Research owns broader branching.
1324
+ const partial = sourceClauses.length > taskCap;
1325
+ const clauses = sourceClauses.slice(0, taskCap);
528
1326
  const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
529
1327
  const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
530
1328
  const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
531
1329
  const filters = input.filters ?? [];
532
1330
  const inheritedBindings = input.inheritedBindings ?? [];
533
1331
  const unboundTasks = clauses.map((clause, index) => {
534
- const kind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
535
- const research = kind === 'research' || kind === 'diagnosis';
1332
+ const inferredClauseKind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
1333
+ const kind = rootKind === 'research'
1334
+ ? 'research'
1335
+ : inferredClauseKind === 'research'
1336
+ ? 'diagnosis'
1337
+ : inferredClauseKind;
1338
+ const research = rootKind === 'research';
536
1339
  const taskKind = research
537
1340
  ? 'research_branch'
538
1341
  : kind === 'lookup'
@@ -567,7 +1370,7 @@ export function buildAnalyticalTaskGraph(input) {
567
1370
  return {
568
1371
  kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
569
1372
  tasks,
570
- partial: false,
1373
+ partial,
571
1374
  };
572
1375
  }
573
1376
  const TOP_RANKED_REGION_RE = /(?:\b(?:top|highest|most)\b[^?.!]{0,72}\bregions?\b|\bregions?\b[^?.!]{0,72}\b(?:top|highest|most)\b)/i;
@@ -957,6 +1760,89 @@ export async function retrieveContextLanes(lanes, limit = 32, maxConcurrent = 4)
957
1760
  }
958
1761
  return fused;
959
1762
  }
1763
+ function ledgerFactIdsV3(branchId, facts) {
1764
+ const byFact = new Map();
1765
+ const ids = [];
1766
+ for (const fact of facts) {
1767
+ const normalized = fact.trim();
1768
+ if (!normalized || byFact.has(normalized))
1769
+ continue;
1770
+ const id = `fact:${branchId}:${ids.length + 1}`;
1771
+ ids.push(id);
1772
+ byFact.set(normalized, id);
1773
+ }
1774
+ return { ids, byFact };
1775
+ }
1776
+ /**
1777
+ * Build a mixed V3 ledger without reinterpreting V1/V2. The legacy ledgers
1778
+ * continue to contain analytical branches only, because their `observed`
1779
+ * state requires an execution/result receipt and would otherwise falsely
1780
+ * represent a graph walk as query execution.
1781
+ */
1782
+ export function buildResearchEvidenceLedgerV3(input) {
1783
+ const entries = input.entries
1784
+ .slice(0, 6)
1785
+ .sort((left, right) => left.index - right.index || left.kind.localeCompare(right.kind))
1786
+ .map((source) => {
1787
+ if (source.kind === 'analytical_result') {
1788
+ const entry = source.entry;
1789
+ const facts = ledgerFactIdsV3(entry.branchId, entry.facts);
1790
+ const counterEvidenceFactIds = [...new Set(entry.counterEvidenceFactIds)]
1791
+ .flatMap((fact) => facts.byFact.get(fact) ? [facts.byFact.get(fact)] : []);
1792
+ const resultFingerprint = normalizeAnalyticalExecutionFingerprint(entry.resultFingerprint);
1793
+ return {
1794
+ version: 3,
1795
+ id: entry.id,
1796
+ branchId: entry.branchId,
1797
+ evidenceKind: 'analytical_result',
1798
+ status: entry.status,
1799
+ verdict: entry.verdict,
1800
+ ...(source.hypothesisFingerprint ? { hypothesisFingerprint: source.hypothesisFingerprint } : {}),
1801
+ factIds: facts.ids,
1802
+ counterEvidenceFactIds,
1803
+ receiptFingerprints: resultFingerprint ? [resultFingerprint] : [],
1804
+ ...(resultFingerprint ? { resultFingerprint } : {}),
1805
+ };
1806
+ }
1807
+ const facts = ledgerFactIdsV3(source.branchId, [
1808
+ `lineage:${source.receipt.status}`,
1809
+ `lineage:resolution:${source.receipt.resolution}`,
1810
+ ]);
1811
+ const status = source.status
1812
+ ?? (source.receipt.status === 'completed' || source.receipt.status === 'truncated'
1813
+ ? 'observed'
1814
+ : 'failed');
1815
+ return {
1816
+ version: 3,
1817
+ id: source.id,
1818
+ branchId: source.branchId,
1819
+ evidenceKind: 'lineage_graph',
1820
+ status,
1821
+ // The local graph can establish a structural dependency. It cannot
1822
+ // establish causation, even when a complete path exists.
1823
+ verdict: status === 'skipped' ? 'skipped' : 'inconclusive',
1824
+ ...(source.hypothesisFingerprint ? { hypothesisFingerprint: source.hypothesisFingerprint } : {}),
1825
+ factIds: facts.ids,
1826
+ counterEvidenceFactIds: [],
1827
+ receiptFingerprints: source.receipt.structuralFingerprint ? [source.receipt.structuralFingerprint] : [],
1828
+ lineageReceipt: source.receipt,
1829
+ };
1830
+ });
1831
+ const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'skipped').length;
1832
+ const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
1833
+ const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
1834
+ return {
1835
+ version: 3,
1836
+ rootQuestionFingerprint: input.rootQuestionFingerprint,
1837
+ ...(input.planId ? { planId: input.planId } : {}),
1838
+ ...(input.snapshotId ? { snapshotId: input.snapshotId } : {}),
1839
+ entries,
1840
+ factIds: [...new Set(entries.flatMap((entry) => [...entry.factIds, ...entry.counterEvidenceFactIds]))],
1841
+ groundableBranchCount,
1842
+ limitedScope: groundableBranchCount < 3,
1843
+ stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
1844
+ };
1845
+ }
960
1846
  export function capResearchBranches(branches, max = 6) {
961
1847
  return branches.slice(0, Math.max(1, Math.min(6, Math.trunc(max))));
962
1848
  }