@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
@@ -35,9 +35,18 @@ export function projectResolvedAnalyticalFrame(input) {
35
35
  ? [binding.qualifiedId]
36
36
  : []));
37
37
  const rankingRequested = Boolean(sourceFrame.ranking || plan.query.order || plan.query.limit !== undefined);
38
+ const sourceRankEntityDimensionId = sourceFrame.ranking?.entityDimensionId;
38
39
  const dimensions = groupedDimensionIds.flatMap((dimensionId, index) => [
39
40
  { dimensionId, role: 'group_by' },
40
- ...(rankingRequested && index === 0 ? [{ dimensionId, role: 'rank_entity' }] : []),
41
+ // Preserve the source frame's rank entity. Reassigning rank ownership to
42
+ // the first grouped dimension silently changed "top customers by product
43
+ // category" into "top product categories", then the compatibility guard
44
+ // correctly rejected the category for lacking `rank_entity` support.
45
+ ...(rankingRequested && (sourceRankEntityDimensionId
46
+ ? dimensionId === sourceRankEntityDimensionId
47
+ : index === 0)
48
+ ? [{ dimensionId, role: 'rank_entity' }]
49
+ : []),
41
50
  ]);
42
51
  for (const filter of plan.query.filters) {
43
52
  const dimensionId = filter.binding.status === 'resolved' ? filter.binding.qualifiedId : undefined;
@@ -48,11 +57,97 @@ export function projectResolvedAnalyticalFrame(input) {
48
57
  if (sourceFrame.timeContext?.timeDimensionId) {
49
58
  dimensions.push({ dimensionId: sourceFrame.timeContext.timeDimensionId, role: 'time_axis' });
50
59
  }
51
- const ambiguity = [
52
- ...plan.query.measures.flatMap((binding) => frameBindingAmbiguity('metrics', binding)),
53
- ...plan.query.dimensions.flatMap((binding) => frameBindingAmbiguity('dimensions', binding)),
54
- ...plan.query.filters.flatMap((filter) => frameBindingAmbiguity('filters', filter.binding)),
55
- ];
60
+ // Keep an unresolved host-built ambiguity intact while projecting the RAP.
61
+ // Its candidate IDs came from the selected metric's native capability and
62
+ // are the only valid structured clarification choices. Reconstructing only
63
+ // from a failed lexical RAP rebind turns two viable `billing account` /
64
+ // `service account` meanings into an empty generic gap. If RAP resolved the
65
+ // corresponding binding, the authoritative plan supersedes the source
66
+ // ambiguity as usual.
67
+ // Ambiguity is lane-scoped. A physical/semantic ID may legitimately occur
68
+ // in more than one role, but a group-by binding must never consume a filter
69
+ // clarification (or vice versa). Keep the source-frame and frozen RAP proof
70
+ // sets separated by the meaning lane before intersecting them below.
71
+ const resolvedPlanBindingIds = {
72
+ metrics: new Set(plan.query.measures.flatMap((binding) => binding.status === 'resolved' && binding.qualifiedId ? [binding.qualifiedId] : [])),
73
+ dimensions: new Set(plan.query.dimensions.flatMap((binding) => binding.status === 'resolved' && binding.qualifiedId ? [binding.qualifiedId] : [])),
74
+ filters: new Set(plan.query.filters.flatMap((filter) => filter.binding.status === 'resolved' && filter.binding.qualifiedId ? [filter.binding.qualifiedId] : [])),
75
+ };
76
+ const sourceFrameBindingIds = {
77
+ metrics: new Set(sourceFrame.metricConceptIds),
78
+ dimensions: new Set(sourceFrame.dimensions
79
+ .filter((binding) => binding.role !== 'filter')
80
+ .map((binding) => binding.dimensionId)),
81
+ filters: new Set([
82
+ ...sourceFrame.dimensions
83
+ .filter((binding) => binding.role === 'filter')
84
+ .map((binding) => binding.dimensionId),
85
+ ...sourceFrame.memberBindings.map((binding) => binding.dimensionId),
86
+ ]),
87
+ };
88
+ const resolvedSourceAmbiguityKeys = new Set();
89
+ const unresolvedSourceAmbiguity = sourceFrame.ambiguity.filter((entry) => {
90
+ const [lane, ...parts] = entry.field.split('.');
91
+ const requested = normalize(parts.join('.'));
92
+ if (!requested)
93
+ return true;
94
+ // A server-issued clarification selection is already represented as one
95
+ // exact capability binding in the host-built frame and the RAP. Do not
96
+ // retain the lexical ambiguity that prompted that selection merely because
97
+ // the original natural-language phrase (for example, "name") is still in
98
+ // the audit query list. This is intentionally identity-based rather than
99
+ // label-based and applies equally to metric, dimension, and filter lanes.
100
+ // The exact offered ID must be resolved in that same lane on both sides;
101
+ // a genuine ambiguity remains when zero or more than one offered candidate
102
+ // is resolved in the frozen tuple.
103
+ const laneBindingIds = lane === 'metrics' || lane === 'dimensions' || lane === 'filters'
104
+ ? { source: sourceFrameBindingIds[lane], plan: resolvedPlanBindingIds[lane] }
105
+ : undefined;
106
+ const exactlyResolvedCandidateIds = laneBindingIds
107
+ ? [...new Set(entry.candidateIds.filter((candidateId) => laneBindingIds.source.has(candidateId)
108
+ && laneBindingIds.plan.has(candidateId)))]
109
+ : [];
110
+ if (exactlyResolvedCandidateIds.length === 1) {
111
+ resolvedSourceAmbiguityKeys.add(`${lane}.${requested}`);
112
+ return false;
113
+ }
114
+ // Candidate-bearing source ambiguity is an identity contract. A RAP
115
+ // binding with the same lexical request may be an unrelated third field;
116
+ // it cannot clear the offered A/B choice. Retain that ambiguity unless the
117
+ // exact candidate proof above resolved one and only one offered ID.
118
+ // Legacy source ambiguity without candidates has no such identity proof,
119
+ // so it retains the historical lexical fallback below.
120
+ if (entry.candidateIds.length > 0)
121
+ return true;
122
+ const binding = lane === 'dimensions'
123
+ ? plan.query.dimensions.find((item) => normalize(item.requested) === requested)
124
+ : lane === 'metrics'
125
+ ? plan.query.measures.find((item) => normalize(item.requested) === requested)
126
+ : lane === 'filters'
127
+ ? plan.query.filters.find((item) => normalize(item.binding.requested) === requested)?.binding
128
+ : undefined;
129
+ return !binding || binding.status !== 'resolved';
130
+ });
131
+ const ambiguity = uniqueAmbiguity([
132
+ ...unresolvedSourceAmbiguity,
133
+ // Preserve the original query for audit, but do not let its unresolved
134
+ // lexical binding recreate an ambiguity that was discharged by exactly one
135
+ // of the source-offered IDs. This applies to every binding lane: a metric
136
+ // ambiguity has the same identity contract as a display-dimension one.
137
+ ...plan.query.measures.flatMap((binding) => resolvedSourceAmbiguityKeys.has(`metrics.${normalize(binding.requested)}`)
138
+ ? []
139
+ : frameBindingAmbiguity('metrics', binding)),
140
+ // The query list retains the original lexical phrase for audit. Once an
141
+ // exact server-issued candidate resolved the corresponding source
142
+ // ambiguity, that audit binding must not re-create the same ambiguity in
143
+ // the projected frame.
144
+ ...plan.query.dimensions.flatMap((binding) => resolvedSourceAmbiguityKeys.has(`dimensions.${normalize(binding.requested)}`)
145
+ ? []
146
+ : frameBindingAmbiguity('dimensions', binding)),
147
+ ...plan.query.filters.flatMap((filter) => resolvedSourceAmbiguityKeys.has(`filters.${normalize(filter.binding.requested)}`)
148
+ ? []
149
+ : frameBindingAmbiguity('filters', filter.binding)),
150
+ ]);
56
151
  const dimensionOutputs = groupedDimensionIds.map((dimensionId) => ({
57
152
  id: localId(dimensionId),
58
153
  kind: 'dimension',
@@ -90,14 +185,7 @@ export function projectResolvedAnalyticalFrame(input) {
90
185
  metricConceptIds,
91
186
  entityGrainIds: plan.entityGrain ? [plan.entityGrain] : sourceFrame.entityGrainIds,
92
187
  dimensions,
93
- memberBindings: plan.query.filters.flatMap((filter) => filter.binding.status === 'resolved' && filter.binding.qualifiedId
94
- ? [{
95
- dimensionId: filter.binding.qualifiedId,
96
- canonicalValues: [filter.value],
97
- source: 'question',
98
- confidence: 'exact',
99
- }]
100
- : []),
188
+ memberBindings: memberBindingsForResolvedPlanFilters(plan.query.filters),
101
189
  ...(ranking ? { ranking } : {}),
102
190
  requestedOutputs,
103
191
  ambiguity,
@@ -114,9 +202,47 @@ function frameBindingAmbiguity(lane, binding) {
114
202
  : `${lane.slice(0, -1).toUpperCase()}_UNRESOLVED`,
115
203
  }];
116
204
  }
205
+ function uniqueAmbiguity(entries) {
206
+ return entries.filter((entry, index, all) => all.findIndex((candidate) => candidate.field === entry.field
207
+ && candidate.reasonCode === entry.reasonCode
208
+ && candidate.candidateIds.join('\u0000') === entry.candidateIds.join('\u0000')) === index);
209
+ }
117
210
  function uniqueOutputs(outputs) {
118
211
  return outputs.filter((output, index, all) => all.findIndex((candidate) => candidate.id === output.id) === index);
119
212
  }
213
+ /**
214
+ * A plural prior-result continuation is one membership predicate (`IN`), not
215
+ * ten independent equality predicates. Both the deterministic and resolved
216
+ * frame paths use this helper so a persisted member set cannot become an
217
+ * impossible `customer_name = A AND customer_name = B` condition after a
218
+ * restart.
219
+ */
220
+ function appendMemberBinding(bindings, next) {
221
+ const index = bindings.findIndex((binding) => binding.dimensionId === next.dimensionId);
222
+ if (index < 0) {
223
+ bindings.push({ ...next, canonicalValues: unique(next.canonicalValues) });
224
+ return;
225
+ }
226
+ const existing = bindings[index];
227
+ bindings[index] = {
228
+ ...existing,
229
+ canonicalValues: unique([...existing.canonicalValues, ...next.canonicalValues]),
230
+ };
231
+ }
232
+ function memberBindingsForResolvedPlanFilters(filters) {
233
+ const bindings = [];
234
+ for (const filter of filters) {
235
+ if (filter.binding.status !== 'resolved' || !filter.binding.qualifiedId)
236
+ continue;
237
+ appendMemberBinding(bindings, {
238
+ dimensionId: filter.binding.qualifiedId,
239
+ canonicalValues: [filter.value],
240
+ source: 'question',
241
+ confidence: 'exact',
242
+ });
243
+ }
244
+ return bindings;
245
+ }
120
246
  function unique(values) {
121
247
  return [...new Set(values)];
122
248
  }
@@ -145,11 +271,26 @@ export function buildDeterministicAnalyticalFrame(input) {
145
271
  ...(input.evidence.parsedIntent?.order ? { order: input.evidence.parsedIntent.order } : {}),
146
272
  ...(input.evidence.parsedIntent?.limit !== undefined ? { limit: input.evidence.parsedIntent.limit } : {}),
147
273
  };
274
+ const rankingRequested = meaningType === 'ranking' || queryIntent.limit !== undefined || /\b(top|bottom|highest|lowest|rank)\b/i.test(input.question);
275
+ const requestedEntityTerms = unique(input.entityTerms ?? []).map(normalize).filter(Boolean);
276
+ const requestedEntityDisplayTerms = unique(input.entityDisplayTerms ?? []).map(normalize).filter(Boolean);
148
277
  const requestedDimensionTerms = new Set(queryIntent.dimensions.map(normalize).filter(Boolean));
278
+ // The immutable host seed has already separated the entity role from the
279
+ // categorical lane. Retain this defensive removal for older callers whose
280
+ // query intent still contains both forms; otherwise a generic `customer`
281
+ // candidate can manufacture a false ambiguity among Customer Name, Customer
282
+ // Type, and Customer Order Number.
283
+ for (const term of [...requestedEntityTerms, ...requestedEntityDisplayTerms]) {
284
+ requestedDimensionTerms.delete(term);
285
+ }
149
286
  for (const filter of queryIntent.filters)
150
287
  requestedDimensionTerms.add(normalize(filter.field));
151
288
  for (const candidate of input.candidates) {
152
- if (candidate.kind !== 'semantic_member')
289
+ // Entity/member cards may be useful to the bounded meaning call, but they
290
+ // are not dimension identities. Letting a selected Customer entity card
291
+ // pass through this loop reintroduced its broad lexical name as a generic
292
+ // dimension request and made every customer-* capability child compete.
293
+ if (candidate.kind !== 'semantic_member' || candidate.semanticObjectType !== 'dimension')
153
294
  continue;
154
295
  const capabilityDimension = resolveCapabilityDimension(candidate.qualifiedId ?? candidate.id, capability);
155
296
  if (!capabilityDimension)
@@ -161,7 +302,81 @@ export function buildDeterministicAnalyticalFrame(input) {
161
302
  }
162
303
  const ambiguity = [];
163
304
  const resolvedDimensions = new Map();
305
+ const structuredSelectedDimensionIds = new Set();
306
+ // A ranking may group by more than one field, but only the explicitly
307
+ // resolved entity display key is the entity being ranked. Keep that role
308
+ // separate from generic rankable capability metadata: adapters sometimes
309
+ // advertise `rank_entity` for a categorical grouping such as product type,
310
+ // which permits ranking *by* that field but does not make it an answer to
311
+ // “top customers by product category”.
312
+ const entityDisplayDimensionIds = new Set();
313
+ // A structured dimension choice is already a qualified, server-bound
314
+ // meaning. Bind it only through the metric's authored capability contract;
315
+ // this is deliberately stronger than adding the option label back into the
316
+ // natural-language question and prevents a selected member from inventing a
317
+ // join or a display role it does not own.
318
+ for (const selectedId of unique(input.selectedDimensionIds ?? [])) {
319
+ // A model may select a supplied semantic entity card to identify the
320
+ // entity. It is not a qualified display dimension and must never resolve
321
+ // by lexical fallback to the first same-named capability child.
322
+ const dimension = resolveExactCapabilityDimension(selectedId, capability);
323
+ if (dimension) {
324
+ resolvedDimensions.set(dimension.dimensionId, dimension);
325
+ structuredSelectedDimensionIds.add(dimension.dimensionId);
326
+ }
327
+ }
328
+ for (const requested of requestedEntityDisplayTerms) {
329
+ // A structured clarification click is a server-issued, capability-bound
330
+ // answer to this exact display-key ambiguity. Once it has proved a single
331
+ // native display/rank field for the requested label, do not recreate the
332
+ // original Account Name vs Customer Name ambiguity from the broad word
333
+ // "names". The selected field was already checked against this metric's
334
+ // authored capability above; this branch only consumes that binding.
335
+ const selectedDisplayMatches = [...structuredSelectedDimensionIds]
336
+ .map((dimensionId) => resolvedDimensions.get(dimensionId))
337
+ .filter((dimension) => Boolean(dimension))
338
+ .filter((dimension) => dimension.supportedRoles.includes('group_by')
339
+ && (rankingRequested
340
+ ? dimension.supportedRoles.includes('rank_entity')
341
+ : dimension.supportedRoles.includes('display')));
342
+ if (selectedDisplayMatches.length === 1) {
343
+ entityDisplayDimensionIds.add(selectedDisplayMatches[0].dimensionId);
344
+ continue;
345
+ }
346
+ const matches = resolveEntityDisplayTerm({
347
+ requested,
348
+ entityTerms: requestedEntityTerms,
349
+ capability,
350
+ rankingRequested,
351
+ });
352
+ if (matches.length === 1) {
353
+ resolvedDimensions.set(matches[0].dimensionId, matches[0]);
354
+ entityDisplayDimensionIds.add(matches[0].dimensionId);
355
+ }
356
+ else if (matches.length > 1) {
357
+ ambiguity.push({
358
+ field: `dimensions.${requested}`,
359
+ candidateIds: matches.map((dimension) => dimension.dimensionId).sort(),
360
+ reasonCode: 'DIMENSION_AMBIGUOUS',
361
+ });
362
+ }
363
+ }
164
364
  for (const requested of requestedDimensionTerms) {
365
+ // Generic parser terms such as `name` can match more than one capability
366
+ // field. A server-issued qualified selection has already resolved that
367
+ // exact ambiguity, so preserve its native field rather than re-opening
368
+ // the broad lexical candidate set on a restart/follow-up.
369
+ const selectedMatches = [...structuredSelectedDimensionIds]
370
+ .map((dimensionId) => resolvedDimensions.get(dimensionId))
371
+ .filter((dimension) => Boolean(dimension))
372
+ .filter((dimension) => dimensionMatchesTerm(dimension, requested)
373
+ || selectedSameSnapshotDimensionBindsRequestedTerm({
374
+ dimension,
375
+ requested,
376
+ candidates: input.candidates,
377
+ }));
378
+ if (selectedMatches.length === 1)
379
+ continue;
165
380
  const matches = resolveDimensionTerm(requested, capability, input.candidates);
166
381
  if (matches.length === 1)
167
382
  resolvedDimensions.set(matches[0].dimensionId, matches[0]);
@@ -182,14 +397,13 @@ export function buildDeterministicAnalyticalFrame(input) {
182
397
  const dimension = matches[0];
183
398
  filterDimensionIds.add(dimension.dimensionId);
184
399
  resolvedDimensions.set(dimension.dimensionId, dimension);
185
- memberBindings.push({
400
+ appendMemberBinding(memberBindings, {
186
401
  dimensionId: dimension.dimensionId,
187
402
  canonicalValues: [filter.value],
188
403
  source: 'question',
189
404
  confidence: 'exact',
190
405
  });
191
406
  }
192
- const rankingRequested = meaningType === 'ranking' || queryIntent.limit !== undefined || /\b(top|bottom|highest|lowest|rank)\b/i.test(input.question);
193
407
  const groupRequested = rankingRequested || /\b(by|per|for each|breakdown)\b/i.test(input.question);
194
408
  const dimensions = [];
195
409
  for (const dimension of resolvedDimensions.values()) {
@@ -200,7 +414,10 @@ export function buildDeterministicAnalyticalFrame(input) {
200
414
  }
201
415
  if (dimension.supportedRoles.includes('group_by')) {
202
416
  dimensions.push({ dimensionId: dimension.dimensionId, role: 'group_by' });
203
- if (rankingRequested && dimension.supportedRoles.includes('rank_entity')) {
417
+ const isExplicitEntityDisplay = entityDisplayDimensionIds.has(dimension.dimensionId);
418
+ if (rankingRequested
419
+ && dimension.supportedRoles.includes('rank_entity')
420
+ && (entityDisplayDimensionIds.size === 0 || isExplicitEntityDisplay)) {
204
421
  dimensions.push({
205
422
  dimensionId: dimension.dimensionId,
206
423
  role: 'rank_entity',
@@ -211,10 +428,20 @@ export function buildDeterministicAnalyticalFrame(input) {
211
428
  dimensions.push({ dimensionId: dimension.dimensionId, role: 'filter' });
212
429
  }
213
430
  }
214
- const timeRequested = Boolean(queryIntent.timeRange || queryIntent.timeGrain) ||
215
- /\b(today|current|this (?:day|week|month|quarter|year)|month[ -]to[ -]date|mtd|last year|previous year|year over year|yoy)\b/i.test(input.question);
431
+ // `current` can be an intrinsic business qualifier ("current BCM run
432
+ // rate"), not a request for a date axis. Creating a timeContext for that
433
+ // bare modifier forces every metric through time-dimension compatibility and
434
+ // incorrectly blocks snapshot metrics that already encode their current/as-
435
+ // of semantics. Only require a declared time role for an actual time grain
436
+ // or temporal filter. The metric's own governed definition remains the
437
+ // authority for an unqualified current/latest snapshot value.
438
+ const requestedTimeGrain = explicitTimeGrain(queryIntent.timeGrain) ?? inferTimeGrain(input.question);
439
+ const timeRequested = Boolean(queryIntent.timeRange && !isSnapshotOnlyTimeRange(queryIntent.timeRange))
440
+ || Boolean(requestedTimeGrain)
441
+ || hasExplicitTemporalFilter(input.question);
216
442
  const previousYearRequested = /\b(last year|previous year|year over year|yoy)\b/i.test(input.question);
217
- const currentRequested = /\b(today|current|this (?:day|week|month|quarter|year)|month[ -]to[ -]date|mtd)\b/i.test(input.question) || previousYearRequested;
443
+ const currentRequested = /\b(?:today|yesterday)\b|\b(?:this|current)\s+(?:day|week|month|quarter|year)\b|\b(?:month|quarter|year)[ -]to[ -]date\b|\b(?:mtd|qtd|ytd)\b/i.test(input.question)
444
+ || previousYearRequested;
218
445
  const timeContext = timeRequested
219
446
  ? {
220
447
  ...(capability.timeDimensions.length === 1
@@ -223,7 +450,7 @@ export function buildDeterministicAnalyticalFrame(input) {
223
450
  timeRole: capability.timeDimensions[0].role,
224
451
  }
225
452
  : {}),
226
- grain: queryIntent.timeGrain ?? inferTimeGrain(input.question),
453
+ ...(requestedTimeGrain ? { grain: requestedTimeGrain } : {}),
227
454
  ...(capability.freshness?.defaultCompletenessPolicy
228
455
  ? {
229
456
  completenessPolicy: capability.freshness.defaultCompletenessPolicy,
@@ -347,6 +574,68 @@ export function buildDeterministicAnalyticalFrame(input) {
347
574
  ambiguity,
348
575
  };
349
576
  }
577
+ /**
578
+ * Resolve a selected semantic member through one metric's authored capability
579
+ * contract. Router validation uses this to compose a persisted metric frame
580
+ * with a selected display/grouping field without treating the field itself as
581
+ * a metric meaning.
582
+ */
583
+ export function resolveMetricCapabilityDimension(metricCandidate, selectedDimensionId) {
584
+ const capability = normalizeMetricCapabilityContract(metricCandidate.analyticalCapability);
585
+ return capability ? resolveExactCapabilityDimension(selectedDimensionId, capability) : undefined;
586
+ }
587
+ /**
588
+ * Prove the narrowly-scoped same-snapshot MetricFlow extension contract.
589
+ *
590
+ * This is intentionally a pure capability check shared by the router,
591
+ * frozen-plan compiler gate, and Ask workspace admission. An extension is
592
+ * not a lexical synonym: it is valid only when the currently eligible metric
593
+ * declares the exact (or protocol-alias-equivalent) semantic member as a
594
+ * `group_by` dimension in its normalized capability. Keeping this one proof
595
+ * in front of every authority boundary prevents an old, mismatched, or
596
+ * hand-shaped card from entering the planner package and later looking like
597
+ * compiler-approved evidence.
598
+ */
599
+ export function proveSameSnapshotMetricflowRoleExtensionV1(input) {
600
+ const { candidate, metricCandidate } = input;
601
+ const extension = candidate.sameSnapshotRoleExtension;
602
+ if (!extension
603
+ || extension.version !== 1
604
+ || extension.role !== 'categorical_dimension'
605
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
606
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
607
+ || !extension.requestedTerm.trim()
608
+ || !extension.metricId.trim()
609
+ || !extension.dimensionId.trim()
610
+ || candidate.kind !== 'semantic_member'
611
+ || (candidate.semanticObjectType !== undefined && candidate.semanticObjectType !== 'dimension')
612
+ || candidate.eligible === false
613
+ || candidate.compatibility === 'incompatible'
614
+ || metricCandidate.kind !== 'semantic_metric'
615
+ || metricCandidate.eligible === false
616
+ || metricCandidate.compatibility === 'incompatible')
617
+ return undefined;
618
+ const capability = normalizeMetricCapabilityContract(metricCandidate.analyticalCapability);
619
+ if (!capability)
620
+ return undefined;
621
+ const metricAuthorityIds = new Set([
622
+ metricCandidate.id,
623
+ metricCandidate.qualifiedId,
624
+ capability.metricId,
625
+ ...capability.measureIds,
626
+ ].filter((id) => Boolean(id)));
627
+ if (!metricAuthorityIds.has(extension.metricId))
628
+ return undefined;
629
+ const candidateIdentity = candidate.qualifiedId ?? candidate.id;
630
+ if (!sameCapabilityDimensionIdentity(candidateIdentity, extension.dimensionId))
631
+ return undefined;
632
+ const dimension = resolveExactCapabilityDimension(extension.dimensionId, capability);
633
+ if (!dimension
634
+ || !sameCapabilityDimensionIdentity(dimension.dimensionId, extension.dimensionId)
635
+ || !dimension.supportedRoles.includes('group_by'))
636
+ return undefined;
637
+ return { capability, dimension };
638
+ }
350
639
  function resolveDimensionTerm(requested, capability, candidates) {
351
640
  const candidateIds = new Set();
352
641
  for (const candidate of candidates) {
@@ -357,10 +646,153 @@ function resolveDimensionTerm(requested, capability, candidates) {
357
646
  candidateIds.add(candidate.qualifiedId ?? candidate.id);
358
647
  }
359
648
  }
360
- return capability.dimensions.filter((dimension) => termsMatch(normalize(dimension.dimensionId), requested) || candidateIds.has(dimension.dimensionId));
649
+ return capability.dimensions.filter((dimension) => dimensionMatchesTerm(dimension, requested) || candidateIds.has(dimension.dimensionId));
361
650
  }
362
651
  function resolveCapabilityDimension(id, capability) {
363
- return capability.dimensions.find((dimension) => dimension.dimensionId === id || termsMatch(normalize(dimension.dimensionId), normalize(id)));
652
+ // A server-issued clarification can render the capability child under the
653
+ // stable display namespace `semantic:uncategorized:dimension:` while the
654
+ // same snapshot's executable contract stores its canonical
655
+ // `semantic:dimension:` identity. This is an exact protocol alias, not a
656
+ // leaf-name fallback: normalize only that namespace and require equality
657
+ // with a dimension declared by the selected metric capability. It keeps a
658
+ // persisted display choice usable after reload without allowing a client to
659
+ // substitute a same-named field from another model.
660
+ const canonicalId = canonicalCapabilityDimensionId(id);
661
+ return capability.dimensions.find((dimension) => dimension.dimensionId === canonicalId
662
+ || dimensionMatchesTerm(dimension, normalize(id)));
663
+ }
664
+ function resolveExactCapabilityDimension(id, capability) {
665
+ const exactId = id.trim();
666
+ const canonicalId = canonicalCapabilityDimensionId(exactId);
667
+ return capability.dimensions.find((dimension) => dimension.dimensionId === exactId
668
+ || dimension.dimensionId === canonicalId
669
+ // The local manifest adapter may retain the display namespace on the
670
+ // capability itself while a persisted structured option carries the
671
+ // executable namespace (or vice versa). Both normalize to the same
672
+ // protocol identity; no leaf-name or cross-model fallback is allowed.
673
+ || canonicalCapabilityDimensionId(dimension.dimensionId) === canonicalId);
674
+ }
675
+ /**
676
+ * A same-snapshot MetricFlow extension is an exact server-side binding from
677
+ * a business term (for example `region`) to one declared capability child
678
+ * (for example `locations.location_name`). Once that child was selected and
679
+ * revalidated above, do not reopen the raw word match merely because the
680
+ * authored field label does not literally contain "region". This is limited
681
+ * to the persisted extension contract and exact capability identity; it is
682
+ * not a geographic synonym or a cross-model fallback.
683
+ */
684
+ function selectedSameSnapshotDimensionBindsRequestedTerm(input) {
685
+ const requested = normalize(input.requested);
686
+ if (!requested)
687
+ return false;
688
+ return input.candidates.some((candidate) => {
689
+ const extension = candidate.sameSnapshotRoleExtension;
690
+ if (!extension
691
+ || extension.version !== 1
692
+ || extension.role !== 'categorical_dimension'
693
+ || normalize(extension.requestedTerm) !== requested)
694
+ return false;
695
+ const candidateIdentity = candidate.qualifiedId ?? candidate.id;
696
+ return sameCapabilityDimensionIdentity(candidateIdentity, input.dimension.dimensionId)
697
+ && sameCapabilityDimensionIdentity(extension.dimensionId, input.dimension.dimensionId);
698
+ });
699
+ }
700
+ /**
701
+ * Resolve a host-owned entity display requirement only through the selected
702
+ * metric's authored native capability. A bare entity card is not a display
703
+ * field, and an arbitrary categorical attribute such as `customer_type`
704
+ * cannot satisfy `top customers`. If two native display/rank fields match the
705
+ * exact display phrase, retain a genuine typed ambiguity instead of guessing.
706
+ */
707
+ function resolveEntityDisplayTerm(input) {
708
+ // New semantic indexes expose `display` explicitly, while a compatible
709
+ // older MetricFlow capability may expose only `rank_entity`. Both describe
710
+ // a metric-native entity result role. Do not relax this to every group-by
711
+ // field: a customer type or owner grouping is still not an answer to
712
+ // “top customers”.
713
+ const nativeEntityDimensions = input.capability.dimensions.filter((dimension) => dimension.supportedRoles.includes('group_by')
714
+ && (input.rankingRequested
715
+ ? dimension.supportedRoles.includes('rank_entity')
716
+ : dimension.supportedRoles.includes('display'))
717
+ && (input.entityTerms.length === 0 || input.entityTerms.some((entityTerm) => termsMatch(normalize(dimension.entityId), entityTerm))));
718
+ // A requirement seed can carry the full capability-qualified display
719
+ // identity after local retrieval (`customers.customer_name`). Prefer that
720
+ // exact identity before the friendly-label matcher below: an older local
721
+ // index may also expose the unscoped display alias (`customer_name`) as a
722
+ // second card, but it is not a second business meaning when the selected
723
+ // capability identifies one exact native child. This is intentionally an
724
+ // identifier comparison, never a leaf-name fallback across models.
725
+ const normalizedRequested = normalize(input.requested);
726
+ const exactQualifiedMatches = nativeEntityDimensions.filter((dimension) => normalize(dimension.dimensionId) === normalizedRequested
727
+ || (dimension.aliases ?? []).some((alias) => normalize(alias) === normalizedRequested));
728
+ if (exactQualifiedMatches.length > 0)
729
+ return exactQualifiedMatches;
730
+ const exactDisplayMatches = nativeEntityDimensions.filter((dimension) => dimensionMatchesTerm(dimension, input.requested));
731
+ if (exactDisplayMatches.length > 0)
732
+ return exactDisplayMatches;
733
+ // `top customers` is host-normalized to the output requirement `customer
734
+ // name`. A legacy capability can still prove that requirement when it has a
735
+ // unique native entity field literally named `customer`. Do not let a
736
+ // broader lexical match turn `customer_type`, `customer_owner`, or
737
+ // `customer_order_number` into that label. If no exact native identity
738
+ // exists, preserve only non-attribute entity alternatives so two genuine
739
+ // authored account paths remain an identifier-bound ambiguity.
740
+ const entityNativeMatches = nativeEntityDimensions.filter((dimension) => input.entityTerms.some((entityTerm) => nativeEntityIdentity(dimension, entityTerm) === 'exact'));
741
+ if (entityNativeMatches.length > 0)
742
+ return entityNativeMatches;
743
+ return nativeEntityDimensions.filter((dimension) => input.entityTerms.some((entityTerm) => nativeEntityIdentity(dimension, entityTerm) === 'qualified'));
744
+ }
745
+ function nativeEntityIdentity(dimension, entityTerm) {
746
+ const normalizedEntity = normalize(entityTerm);
747
+ if (!normalizedEntity)
748
+ return undefined;
749
+ // Use the field's own label/qualified leaf for the entity-key fallback.
750
+ // Retrieval aliases are deliberately excluded here: an attribute card may
751
+ // carry the broad entity word as a discovery alias (`customer_type` ↔
752
+ // `customer`) but that does not make the attribute the entity display key.
753
+ const identities = [
754
+ dimension.label,
755
+ localId(dimension.dimensionId),
756
+ ].map((value) => value ? normalize(value) : '').filter(Boolean);
757
+ if (identities.some((identity) => identity === normalizedEntity))
758
+ return 'exact';
759
+ // A field whose own identity begins with the generic entity name is an
760
+ // attribute of that entity, not its display/rank key. It must not satisfy a
761
+ // request for the entity itself merely because it is the only field left.
762
+ if (identities.some((identity) => identity.startsWith(`${normalizedEntity} `)))
763
+ return undefined;
764
+ // A qualified authored identity such as `billing account` or `service
765
+ // account` is a genuine alternative entity role. Return it only to create a
766
+ // typed ambiguity when there is no exact native entity key.
767
+ return identities.some((identity) => identity.endsWith(` ${normalizedEntity}`))
768
+ ? 'qualified'
769
+ : undefined;
770
+ }
771
+ function canonicalCapabilityDimensionId(id) {
772
+ return id.trim().replace(/^semantic:uncategorized:dimension:/, 'semantic:dimension:');
773
+ }
774
+ function sameCapabilityDimensionIdentity(left, right) {
775
+ return canonicalCapabilityDimensionId(left) === canonicalCapabilityDimensionId(right);
776
+ }
777
+ /**
778
+ * Match a requested business dimension against the dimension's own authored
779
+ * label/leaf alias, not its model-qualified namespace. A metric model named
780
+ * `account_revenue` prefixes every one of its dimensions; treating that
781
+ * prefix as a match for "account" made fiscal period and customer name look
782
+ * like equally valid account display keys.
783
+ */
784
+ function dimensionMatchesTerm(dimension, requested) {
785
+ const normalizedRequested = normalize(requested);
786
+ if (!normalizedRequested)
787
+ return false;
788
+ if (normalize(dimension.dimensionId) === normalizedRequested)
789
+ return true;
790
+ const identities = [
791
+ dimension.label,
792
+ localId(dimension.dimensionId),
793
+ ...(dimension.aliases ?? []).map(localId),
794
+ ].map((value) => value ? normalize(value) : '').filter(Boolean);
795
+ return identities.some((identity) => termsMatch(identity, normalizedRequested));
364
796
  }
365
797
  function phraseAppears(question, phrase) {
366
798
  const questionTokens = new Set(normalize(question).split(' ').map(singularize));
@@ -368,7 +800,12 @@ function phraseAppears(question, phrase) {
368
800
  .split(' ')
369
801
  .map(singularize)
370
802
  .filter((token) => token.length >= 3 && !['name', 'dimension'].includes(token));
371
- return significant.length > 0 && significant.some((token) => questionTokens.has(token));
803
+ // Candidate aliases such as `customer name` must not be promoted into a
804
+ // separately requested dimension merely because the question says
805
+ // `customer`. The host seed already owns the display role. Requiring every
806
+ // meaningful token makes this an explicit phrase-presence check while
807
+ // retaining singular/plural normalization.
808
+ return significant.length > 0 && significant.every((token) => questionTokens.has(token));
372
809
  }
373
810
  function termsMatch(left, right) {
374
811
  if (!left || !right)
@@ -405,13 +842,40 @@ function inferTimeGrain(question) {
405
842
  if (/\b(week|weekly)\b/i.test(question))
406
843
  return 'week';
407
844
  if (/\b(month|monthly|mtd)\b/i.test(question))
408
- return 'day';
845
+ return 'month';
409
846
  if (/\b(quarter|quarterly)\b/i.test(question))
410
847
  return 'quarter';
411
848
  if (/\b(year|yearly|annual|yoy)\b/i.test(question))
412
849
  return 'year';
413
850
  return undefined;
414
851
  }
852
+ function explicitTimeGrain(value) {
853
+ const normalized = value?.trim().toLowerCase();
854
+ return normalized === 'day'
855
+ || normalized === 'week'
856
+ || normalized === 'month'
857
+ || normalized === 'quarter'
858
+ || normalized === 'year'
859
+ ? normalized
860
+ : undefined;
861
+ }
862
+ /**
863
+ * The parser can represent an explicit time range without preserving every
864
+ * source token. Keep snapshot-only modifiers out of the physical date-role
865
+ * contract: a bare "current" / "latest" metric should be answered by its
866
+ * authored snapshot semantics, not fabricated into a time-axis request.
867
+ */
868
+ function isSnapshotOnlyTimeRange(value) {
869
+ return /^(?:current|latest|snapshot|as[ -]?of[ -]?(?:latest|current))$/i.test(value.trim());
870
+ }
871
+ /**
872
+ * Detect temporal filters in the user's actual wording. `current` by itself
873
+ * deliberately does not match: `current BCM run rate` is a metric qualifier,
874
+ * whereas `current month` and `today` require a date role.
875
+ */
876
+ function hasExplicitTemporalFilter(question) {
877
+ return /\b(?:today|yesterday)\b|\b(?:this|current|last|previous|past)\s+(?:(?:\d{1,3}|one|two|three|four|five|six|seven|eight|nine|ten|twelve)\s+)?(?:day|days|week|weeks|month|months|quarter|quarters|year|years)\b|\b(?:month|quarter|year)[ -]to[ -]date\b|\b(?:mtd|qtd|ytd|last year|previous year|year over year|yoy)\b|\bfy\s?\d{2,4}\b|\bfiscal\s+year\s+\d{2,4}\b/i.test(question);
878
+ }
415
879
  function extractLimit(question) {
416
880
  const match = /\b(?:top|bottom)\s+(\d{1,4})\b/i.exec(question);
417
881
  return match ? Math.max(1, Number(match[1])) : undefined;