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

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 (128) hide show
  1. package/dist/agent-run-engine.d.ts +177 -3
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2067 -57
  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-store.d.ts.map +1 -1
  9. package/dist/agent-run-store.js +544 -9
  10. package/dist/agent-run-store.js.map +1 -1
  11. package/dist/agentic/analyst-loop.d.ts +2 -0
  12. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  13. package/dist/agentic/analyst-loop.js +41 -4
  14. package/dist/agentic/analyst-loop.js.map +1 -1
  15. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  16. package/dist/agentic/ledger-tools.js +8 -1
  17. package/dist/agentic/ledger-tools.js.map +1 -1
  18. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  19. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  20. package/dist/agentic/orchestrator-policy.js +1 -1
  21. package/dist/agentic/orchestrator-policy.js.map +1 -1
  22. package/dist/agentic/tool-loop.d.ts +13 -1
  23. package/dist/agentic/tool-loop.d.ts.map +1 -1
  24. package/dist/agentic/tool-loop.js +441 -32
  25. package/dist/agentic/tool-loop.js.map +1 -1
  26. package/dist/analytical-frame.d.ts +19 -0
  27. package/dist/analytical-frame.d.ts.map +1 -1
  28. package/dist/analytical-frame.js +170 -12
  29. package/dist/analytical-frame.js.map +1 -1
  30. package/dist/analytical-orchestration.d.ts +949 -10
  31. package/dist/analytical-orchestration.d.ts.map +1 -1
  32. package/dist/analytical-orchestration.js +499 -56
  33. package/dist/analytical-orchestration.js.map +1 -1
  34. package/dist/answer-loop.d.ts +166 -2
  35. package/dist/answer-loop.d.ts.map +1 -1
  36. package/dist/answer-loop.js +654 -18
  37. package/dist/answer-loop.js.map +1 -1
  38. package/dist/answer-shape.d.ts +27 -0
  39. package/dist/answer-shape.d.ts.map +1 -1
  40. package/dist/answer-shape.js +55 -0
  41. package/dist/answer-shape.js.map +1 -1
  42. package/dist/ask-observability/instrumentation.d.ts.map +1 -1
  43. package/dist/ask-observability/instrumentation.js +52 -1
  44. package/dist/ask-observability/instrumentation.js.map +1 -1
  45. package/dist/ask-observability/portable.js +562 -0
  46. package/dist/ask-observability/portable.js.map +1 -1
  47. package/dist/ask-observability/types.d.ts +26 -3
  48. package/dist/ask-observability/types.d.ts.map +1 -1
  49. package/dist/ask-observability/types.js.map +1 -1
  50. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  51. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  52. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  53. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  54. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  55. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  56. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  57. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  58. package/dist/conversation/session-store.d.ts +31 -0
  59. package/dist/conversation/session-store.d.ts.map +1 -1
  60. package/dist/conversation/session-store.js +38 -0
  61. package/dist/conversation/session-store.js.map +1 -1
  62. package/dist/conversation/snapshot.d.ts +4 -1
  63. package/dist/conversation/snapshot.d.ts.map +1 -1
  64. package/dist/conversation/snapshot.js +85 -3
  65. package/dist/conversation/snapshot.js.map +1 -1
  66. package/dist/conversation/turn-trust.d.ts.map +1 -1
  67. package/dist/conversation/turn-trust.js +2 -0
  68. package/dist/conversation/turn-trust.js.map +1 -1
  69. package/dist/embeddings/provider.d.ts +1 -0
  70. package/dist/embeddings/provider.d.ts.map +1 -1
  71. package/dist/embeddings/provider.js +18 -0
  72. package/dist/embeddings/provider.js.map +1 -1
  73. package/dist/generated-analytical-proposal.js +37 -2
  74. package/dist/generated-analytical-proposal.js.map +1 -1
  75. package/dist/index.d.ts +15 -8
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +9 -4
  78. package/dist/index.js.map +1 -1
  79. package/dist/intent-controller.d.ts +77 -1
  80. package/dist/intent-controller.d.ts.map +1 -1
  81. package/dist/intent-controller.js +1 -1
  82. package/dist/intent-controller.js.map +1 -1
  83. package/dist/kg/build.js +32 -2
  84. package/dist/kg/build.js.map +1 -1
  85. package/dist/meaning-resolution.d.ts +129 -0
  86. package/dist/meaning-resolution.d.ts.map +1 -1
  87. package/dist/meaning-resolution.js +108 -15
  88. package/dist/meaning-resolution.js.map +1 -1
  89. package/dist/metadata/analysis-planner.js +57 -6
  90. package/dist/metadata/analysis-planner.js.map +1 -1
  91. package/dist/metadata/catalog.d.ts +14 -0
  92. package/dist/metadata/catalog.d.ts.map +1 -1
  93. package/dist/metadata/catalog.js +28 -1
  94. package/dist/metadata/catalog.js.map +1 -1
  95. package/dist/metadata/meaning-evidence.d.ts +19 -0
  96. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  97. package/dist/metadata/meaning-evidence.js +113 -6
  98. package/dist/metadata/meaning-evidence.js.map +1 -1
  99. package/dist/provider-egress.d.ts +4 -1
  100. package/dist/provider-egress.d.ts.map +1 -1
  101. package/dist/provider-egress.js +50 -5
  102. package/dist/provider-egress.js.map +1 -1
  103. package/dist/providers/claude.d.ts +2 -2
  104. package/dist/providers/claude.d.ts.map +1 -1
  105. package/dist/providers/claude.js +191 -28
  106. package/dist/providers/claude.js.map +1 -1
  107. package/dist/providers/index.d.ts +1 -1
  108. package/dist/providers/index.d.ts.map +1 -1
  109. package/dist/providers/index.js.map +1 -1
  110. package/dist/providers/openai.d.ts +2 -2
  111. package/dist/providers/openai.d.ts.map +1 -1
  112. package/dist/providers/openai.js +191 -21
  113. package/dist/providers/openai.js.map +1 -1
  114. package/dist/providers/types.d.ts +79 -1
  115. package/dist/providers/types.d.ts.map +1 -1
  116. package/dist/providers/types.js.map +1 -1
  117. package/dist/requirement-clauses.d.ts +23 -0
  118. package/dist/requirement-clauses.d.ts.map +1 -0
  119. package/dist/requirement-clauses.js +70 -0
  120. package/dist/requirement-clauses.js.map +1 -0
  121. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  122. package/dist/resolved-analytical-plan.js +201 -15
  123. package/dist/resolved-analytical-plan.js.map +1 -1
  124. package/dist/router.d.ts +109 -2
  125. package/dist/router.d.ts.map +1 -1
  126. package/dist/router.js +753 -76
  127. package/dist/router.js.map +1 -1
  128. package/package.json +4 -4
@@ -12,7 +12,57 @@
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
+ }
16
66
  /** Build the immutable host request tuple before a meaning model can respond. */
17
67
  export function buildAnalyticalRequirementSeedV1(input) {
18
68
  // Retrieval/parser output is intentionally broad: it may contain useful
@@ -22,10 +72,11 @@ export function buildAnalyticalRequirementSeedV1(input) {
22
72
  // frozen host tuple. Structured clarification selections are merged by the
23
73
  // router into `requirements` before this function is called.
24
74
  const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
25
- const requirements = input.requirements ?? buildAnalyticalRequirementSet({
75
+ const baseRequirements = input.requirements ?? buildAnalyticalRequirementSet({
26
76
  question: input.question,
27
77
  parsedIntent: parsed,
28
78
  });
79
+ const requirements = withAnalyticalPriorResultMemberBinding(baseRequirements, input.priorResultMemberBinding ?? baseRequirements.priorResultMemberBinding);
29
80
  // Order and limit are lexical requirements, not parser defaults. In
30
81
  // particular, a prior ranking must not turn a complete new question into a
31
82
  // top-N query just because retrieval retained an old `limit` or `order`.
@@ -34,6 +85,13 @@ export function buildAnalyticalRequirementSeedV1(input) {
34
85
  : undefined;
35
86
  const limit = requirements.ranking?.limit;
36
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
+ }
37
95
  if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
38
96
  && !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
39
97
  filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
@@ -56,7 +114,13 @@ export function buildAnalyticalRequirementSeedV1(input) {
56
114
  ...requirements.entityDisplayTerms,
57
115
  ])],
58
116
  filters,
59
- ...(parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
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 } : {}),
60
124
  ...(requirements.time?.grain
61
125
  ? { timeGrain: requirements.time.grain }
62
126
  : parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
@@ -118,8 +182,18 @@ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
118
182
  return false;
119
183
  if (groundedTerm(normalized))
120
184
  return true;
121
- return /^(?:last|previous|past) \d+ (?:day|days|week|weeks|month|months|quarter|quarters|year|years)$/.test(normalized)
122
- && normalizeRequirementTerm(question).includes(normalized);
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));
123
197
  };
124
198
  const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
125
199
  const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
@@ -140,6 +214,63 @@ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
140
214
  // question below. Never promote parser values by themselves.
141
215
  };
142
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
+ }
143
274
  /**
144
275
  * Small, explainable intent vocabulary used before the physical plan exists.
145
276
  * It is deliberately not a second semantic matcher: candidate identity still
@@ -319,14 +450,15 @@ function explicitOutputTerms(question) {
319
450
  * or "for each customer" as separate physical measures. The grouping entity
320
451
  * is represented by the dimension/entity roles instead.
321
452
  *
322
- * `order count for each customer` is the common prose form for a count
323
- * aggregation at customer grain. Keep the aggregation (`count`) and remove
324
- * the object noun (`order`) only for that exact grouped construction; a named
325
- * metric such as `order_value` remains untouched.
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.
326
460
  */
327
461
  export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
328
- const normalizedQuestion = normalizeRequirementTerm(question);
329
- const groupedOrderCount = /\borders?\s+count\s+(?:for|per)\s+(?:each|every)\s+(?:the\s+)?(?:account|customer|client|company|product|order|item|row)s?\b/.test(normalizedQuestion);
330
462
  // A parser often singularizes the business alias `sales` into `sale`. That
331
463
  // is not a second measure beside revenue: it is the same current-question
332
464
  // request. Canonicalize only that standalone vocabulary alias here; named
@@ -337,11 +469,7 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
337
469
  };
338
470
  const terms = values
339
471
  .map(canonicalMeasureAlias)
340
- .filter((value) => !isStructuralMeasurePhrase(value))
341
- .filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
342
- if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
343
- terms.push('count');
344
- }
472
+ .filter((value) => !isStructuralMeasurePhrase(value));
345
473
  // An inherited measure can already be a stable semantic/dbt identity. Keep
346
474
  // that identity intact for the planner/meaning handoff; matching and display
347
475
  // have their own normalizers. Rewriting `total_consumption_units` to prose
@@ -365,20 +493,72 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
365
493
  * phrase that already owns it: adding both `beverage revenue` and `revenue`
366
494
  * turns one requested metric into two and incorrectly rejects a block whose
367
495
  * own declared output is `beverage_revenue`. The same holds for `order count`
368
- * and its generic `count` root; grouped prose is normalized to `count` before
369
- * this helper runs, so retaining both is neither necessary nor correct.
496
+ * and its generic `count` root; the authored compound is retained and owns
497
+ * that generic aggregation vocabulary for this question.
370
498
  */
371
499
  function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
372
- 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)))
373
508
  .filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
374
509
  return lexical.filter((term) => {
375
- if (term === 'count') {
376
- return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
377
- }
378
- const token = term === 'refunds' ? 'refund' : term;
379
- return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
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)
380
518
  .split(' ')
381
- .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
+ });
528
+ });
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)));
382
562
  });
383
563
  }
384
564
  /**
@@ -466,7 +646,7 @@ export function buildAnalyticalRequirementSet(input) {
466
646
  const salesIsRevenueAlias = /\bsales\b/i.test(question);
467
647
  const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
468
648
  .filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
469
- const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
649
+ const parsedMeasuresWithLexicalTerms = preferExplicitQuestionCompoundMeasures(uniqueRequirementTerms([
470
650
  ...parsedMeasures,
471
651
  ...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
472
652
  ...(salesIsRevenueAlias ? ['revenue'] : []),
@@ -478,7 +658,7 @@ export function buildAnalyticalRequirementSet(input) {
478
658
  && !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
479
659
  ? ['amount']
480
660
  : []),
481
- ]);
661
+ ]), question);
482
662
  const typedRequirements = normalizedTypedAggregationRequirements({
483
663
  question,
484
664
  measures: parsedMeasuresWithLexicalTerms,
@@ -493,7 +673,13 @@ export function buildAnalyticalRequirementSet(input) {
493
673
  const measures = explicitRankingMeasures.length > 0
494
674
  ? explicitRankingMeasures
495
675
  : typedRequirements.measures;
496
- const dimensions = typedRequirements.dimensions;
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)));
497
683
  const rankingMetricTerms = ranking ? measures : [];
498
684
  // A context planner supplies its own safety default (`topN: 10`) for bare
499
685
  // rankings. It is a useful execution bound, but it is not user intent. Read
@@ -509,10 +695,15 @@ export function buildAnalyticalRequirementSet(input) {
509
695
  ? Number(ranking[2])
510
696
  : leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
511
697
  : wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
512
- const time = grain || fiscalPeriod
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
513
703
  ? {
514
704
  role: grain ? 'time_axis' : 'time_filter',
515
705
  ...(grain ? { grain: grain } : {}),
706
+ ...(window ? { window } : {}),
516
707
  ...(fiscalPeriod ? { fiscalPeriod } : {}),
517
708
  requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
518
709
  }
@@ -523,7 +714,14 @@ export function buildAnalyticalRequirementSet(input) {
523
714
  dimensions,
524
715
  entityTerms,
525
716
  entityDisplayTerms,
526
- 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
+ ]),
527
725
  ...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
528
726
  ...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
529
727
  ...(ranking
@@ -590,16 +788,61 @@ export function hasEntityAttributeTerm(value) {
590
788
  export function isEntityAttributeCandidate(candidate) {
591
789
  return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
592
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
+ }
593
817
  /** Classify the role an already-qualified candidate may fill. */
594
818
  export function evidenceCandidateRoles(candidate) {
595
819
  const identity = intrinsicCandidateIdentity(candidate);
596
820
  const roles = new Set();
597
821
  const physicalColumn = candidate.kind === 'sql_column';
598
- const metricCandidate = candidate.kind === 'semantic_metric'
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'
599
829
  || candidate.semanticObjectType === 'metric'
600
- || candidate.semanticObjectType === 'measure'
601
- || /\bmetric\b/.test(identity)
602
- || (physicalColumn && /\b(?:revenue|amount|count|rate|bcm|spend|cost|margin|total)\b/.test(identity));
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;
603
846
  if (metricCandidate)
604
847
  roles.add('metric');
605
848
  // Capability metadata belongs to the metric's execution contract. It must
@@ -620,18 +863,38 @@ export function evidenceCandidateRoles(candidate) {
620
863
  && /\b(?:name|label|display)\b/.test(identity)
621
864
  && !hasEntityAttributeTerm(identity))
622
865
  roles.add('entity_label');
623
- if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b|\bfiscal\b|\bperiod\b)/.test(identity)
624
- || (candidate.timeGrains?.length ?? 0) > 0)
866
+ if (temporalCandidate)
625
867
  roles.add('time_dimension');
626
868
  if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
627
869
  || /\b(?:relationship|join|bridge)\b/.test(identity))
628
870
  roles.add('relationship');
629
- if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension'
630
- || (physicalColumn && /\b(?:competitor|region|category|segment|status|type|owner|sentiment|active)\b/.test(identity)))
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)))) {
631
884
  roles.add('categorical_dimension');
885
+ }
886
+ if (candidate.kind === 'semantic_member' && candidate.semanticObjectType === 'member')
887
+ roles.add('member');
632
888
  }
633
- for (const role of explicitlyDeclaredCandidateRoles(candidate))
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;
634
896
  roles.add(role);
897
+ }
635
898
  if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
636
899
  roles.add('context');
637
900
  if (roles.size === 0)
@@ -748,7 +1011,12 @@ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms
748
1011
  * to two candidates before relevance fills remaining cards.
749
1012
  */
750
1013
  export function selectRoleBalancedMeaningCandidates(input) {
751
- 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)));
752
1020
  const ranked = [...new Map(input.candidates
753
1021
  .filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
754
1022
  .map((candidate) => [candidate.id, candidate])).values()]
@@ -762,6 +1030,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
762
1030
  if (selected.length < max && !selected.some((item) => item.id === candidate.id))
763
1031
  selected.push(candidate);
764
1032
  };
1033
+ const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
765
1034
  const servesRequestedRole = (candidate) => {
766
1035
  const roles = evidenceCandidateRoles(candidate);
767
1036
  const metricTerms = input.requirements.ranking?.metricTerms.length
@@ -772,6 +1041,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
772
1041
  // An entity term such as "account" is deliberately insufficient for an
773
1042
  // attribute (Account Owner Email) to displace the requested display key.
774
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;
775
1046
  if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
776
1047
  ...input.requirements.entityTerms,
777
1048
  ...input.requirements.entityDisplayTerms,
@@ -779,11 +1050,16 @@ export function selectRoleBalancedMeaningCandidates(input) {
779
1050
  return true;
780
1051
  if (roles.includes('time_dimension') && Boolean(input.requirements.time))
781
1052
  return true;
782
- const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
783
1053
  if (roles.includes('categorical_dimension')
784
1054
  && categoricalTerms.length > 0
785
1055
  && candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
786
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))
1062
+ return true;
787
1063
  if (roles.includes('relationship')
788
1064
  && (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
789
1065
  return true;
@@ -798,13 +1074,19 @@ export function selectRoleBalancedMeaningCandidates(input) {
798
1074
  add(candidate);
799
1075
  }
800
1076
  const required = [
801
- ['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
802
- ['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
803
- ['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
804
- ['categorical_dimension', categoricalDimensionRequirementTerms(input.requirements)],
805
- ['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
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 },
806
1088
  ];
807
- for (const [role, terms] of required) {
1089
+ for (const { role, terms, limit, categorical } of required) {
808
1090
  // No requested categorical dimension means that high-scoring arbitrary
809
1091
  // members are noise, not a role reservation. This is the subtle path that
810
1092
  // used to admit Account Owner and Sentiment immediately after Account Name.
@@ -812,10 +1094,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
812
1094
  continue;
813
1095
  let admitted = 0;
814
1096
  for (const candidate of ranked) {
815
- if (admitted >= 2 || selected.length >= max)
1097
+ if (admitted >= limit || selected.length >= max)
816
1098
  break;
817
1099
  const roles = evidenceCandidateRoles(candidate);
818
- if (!roles.includes(role))
1100
+ const safePhysicalMember = role === 'member'
1101
+ && roles.includes('categorical_dimension')
1102
+ && candidateHasSafeValueForMemberTerms(candidate, terms);
1103
+ if (!roles.includes(role) && !safePhysicalMember)
819
1104
  continue;
820
1105
  // "top accounts" needs the account display key, not any field whose
821
1106
  // label happens to contain account. Once a display candidate is
@@ -841,7 +1126,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
841
1126
  // For entity labels, role is more important than a lexical owner/email
842
1127
  // hit. For all other roles, prefer an identity matching the requested
843
1128
  // business term but retain a role candidate when the request is terse.
844
- if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
1129
+ if (terms.length > 0 && !safePhysicalMember
1130
+ && !candidateMatchesTerms(candidate, terms, { categoricalDimension: categorical === true })
845
1131
  && role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
846
1132
  continue;
847
1133
  add(candidate);
@@ -854,6 +1140,27 @@ export function selectRoleBalancedMeaningCandidates(input) {
854
1140
  }
855
1141
  return selected;
856
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
+ }
857
1164
  export function classifyProviderFailure(input) {
858
1165
  const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
859
1166
  const cause = /cancel/.test(text) ? 'cancelled'
@@ -959,23 +1266,76 @@ export function splitAnalyticalTasks(question) {
959
1266
  const parts = (asked.length > 0 ? asked : raw)
960
1267
  .map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
961
1268
  .filter(Boolean);
962
- 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;
963
1301
  }
964
1302
  export function buildAnalyticalTaskGraph(input) {
965
- 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;
966
1312
  // A research turn may later create bounded evidence branches, but that is a
967
1313
  // research planner's job. Splitting at ingress loses the surrounding story
968
1314
  // before it has an opportunity to reason about it.
969
- const clauses = (rootKind === 'research' ? [input.question.trim()] : splitAnalyticalTasks(input.question))
970
- .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);
971
1326
  const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
972
1327
  const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
973
1328
  const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
974
1329
  const filters = input.filters ?? [];
975
1330
  const inheritedBindings = input.inheritedBindings ?? [];
976
1331
  const unboundTasks = clauses.map((clause, index) => {
977
- const kind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
978
- 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';
979
1339
  const taskKind = research
980
1340
  ? 'research_branch'
981
1341
  : kind === 'lookup'
@@ -1010,7 +1370,7 @@ export function buildAnalyticalTaskGraph(input) {
1010
1370
  return {
1011
1371
  kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
1012
1372
  tasks,
1013
- partial: false,
1373
+ partial,
1014
1374
  };
1015
1375
  }
1016
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;
@@ -1400,6 +1760,89 @@ export async function retrieveContextLanes(lanes, limit = 32, maxConcurrent = 4)
1400
1760
  }
1401
1761
  return fused;
1402
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
+ }
1403
1846
  export function capResearchBranches(branches, max = 6) {
1404
1847
  return branches.slice(0, Math.max(1, Math.min(6, Math.trunc(max))));
1405
1848
  }