@duckcodeailabs/dql-agent 1.14.0 → 1.14.2

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 (173) hide show
  1. package/dist/agent-run-engine.d.ts +68 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +520 -26
  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 +25 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agentic/analyst-loop.d.ts +91 -0
  9. package/dist/agentic/analyst-loop.d.ts.map +1 -0
  10. package/dist/agentic/analyst-loop.js +331 -0
  11. package/dist/agentic/analyst-loop.js.map +1 -0
  12. package/dist/agentic/assumptions.d.ts +62 -0
  13. package/dist/agentic/assumptions.d.ts.map +1 -0
  14. package/dist/agentic/assumptions.js +73 -0
  15. package/dist/agentic/assumptions.js.map +1 -0
  16. package/dist/agentic/business-explanation.d.ts +56 -0
  17. package/dist/agentic/business-explanation.d.ts.map +1 -0
  18. package/dist/agentic/business-explanation.js +130 -0
  19. package/dist/agentic/business-explanation.js.map +1 -0
  20. package/dist/agentic/identifier-ledger.d.ts +74 -0
  21. package/dist/agentic/identifier-ledger.d.ts.map +1 -0
  22. package/dist/agentic/identifier-ledger.js +151 -0
  23. package/dist/agentic/identifier-ledger.js.map +1 -0
  24. package/dist/agentic/ledger-tools.d.ts +68 -0
  25. package/dist/agentic/ledger-tools.d.ts.map +1 -0
  26. package/dist/agentic/ledger-tools.js +198 -0
  27. package/dist/agentic/ledger-tools.js.map +1 -0
  28. package/dist/agentic/orchestrator-policy.d.ts +60 -0
  29. package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
  30. package/dist/agentic/orchestrator-policy.js +73 -0
  31. package/dist/agentic/orchestrator-policy.js.map +1 -0
  32. package/dist/agentic/orchestrator.d.ts +54 -0
  33. package/dist/agentic/orchestrator.d.ts.map +1 -0
  34. package/dist/agentic/orchestrator.js +67 -0
  35. package/dist/agentic/orchestrator.js.map +1 -0
  36. package/dist/agentic/preview-tool.d.ts +36 -0
  37. package/dist/agentic/preview-tool.d.ts.map +1 -0
  38. package/dist/agentic/preview-tool.js +71 -0
  39. package/dist/agentic/preview-tool.js.map +1 -0
  40. package/dist/agentic/rerank.d.ts +50 -0
  41. package/dist/agentic/rerank.d.ts.map +1 -0
  42. package/dist/agentic/rerank.js +140 -0
  43. package/dist/agentic/rerank.js.map +1 -0
  44. package/dist/agentic/research/hypothesis.d.ts +110 -0
  45. package/dist/agentic/research/hypothesis.d.ts.map +1 -0
  46. package/dist/agentic/research/hypothesis.js +125 -0
  47. package/dist/agentic/research/hypothesis.js.map +1 -0
  48. package/dist/agentic/research/synthesis.d.ts +36 -0
  49. package/dist/agentic/research/synthesis.d.ts.map +1 -0
  50. package/dist/agentic/research/synthesis.js +109 -0
  51. package/dist/agentic/research/synthesis.js.map +1 -0
  52. package/dist/agentic/research-agent.d.ts +54 -0
  53. package/dist/agentic/research-agent.d.ts.map +1 -0
  54. package/dist/agentic/research-agent.js +177 -0
  55. package/dist/agentic/research-agent.js.map +1 -0
  56. package/dist/agentic/sql-authorization.d.ts +131 -0
  57. package/dist/agentic/sql-authorization.d.ts.map +1 -0
  58. package/dist/agentic/sql-authorization.js +460 -0
  59. package/dist/agentic/sql-authorization.js.map +1 -0
  60. package/dist/agentic/tool-loop.d.ts +29 -0
  61. package/dist/agentic/tool-loop.d.ts.map +1 -1
  62. package/dist/agentic/tool-loop.js +129 -52
  63. package/dist/agentic/tool-loop.js.map +1 -1
  64. package/dist/agentic/toolset.d.ts.map +1 -1
  65. package/dist/agentic/toolset.js +155 -0
  66. package/dist/agentic/toolset.js.map +1 -1
  67. package/dist/agentic/turn-plan.d.ts +35 -0
  68. package/dist/agentic/turn-plan.d.ts.map +1 -0
  69. package/dist/agentic/turn-plan.js +108 -0
  70. package/dist/agentic/turn-plan.js.map +1 -0
  71. package/dist/agentic/value-probe.d.ts +89 -0
  72. package/dist/agentic/value-probe.d.ts.map +1 -0
  73. package/dist/agentic/value-probe.js +304 -0
  74. package/dist/agentic/value-probe.js.map +1 -0
  75. package/dist/analytical-frame.d.ts.map +1 -1
  76. package/dist/analytical-frame.js +10 -1
  77. package/dist/analytical-frame.js.map +1 -1
  78. package/dist/analytical-orchestration.d.ts +382 -0
  79. package/dist/analytical-orchestration.d.ts.map +1 -1
  80. package/dist/analytical-orchestration.js +782 -6
  81. package/dist/analytical-orchestration.js.map +1 -1
  82. package/dist/analytical-request-policy.d.ts +26 -0
  83. package/dist/analytical-request-policy.d.ts.map +1 -0
  84. package/dist/analytical-request-policy.js +82 -0
  85. package/dist/analytical-request-policy.js.map +1 -0
  86. package/dist/analytical-result-facts.d.ts +13 -0
  87. package/dist/analytical-result-facts.d.ts.map +1 -1
  88. package/dist/analytical-result-facts.js +37 -1
  89. package/dist/analytical-result-facts.js.map +1 -1
  90. package/dist/answer-loop.d.ts +105 -16
  91. package/dist/answer-loop.d.ts.map +1 -1
  92. package/dist/answer-loop.js +833 -108
  93. package/dist/answer-loop.js.map +1 -1
  94. package/dist/answer-shape.d.ts +32 -1
  95. package/dist/answer-shape.d.ts.map +1 -1
  96. package/dist/answer-shape.js +49 -3
  97. package/dist/answer-shape.js.map +1 -1
  98. package/dist/cascade/budgets.d.ts.map +1 -1
  99. package/dist/cascade/budgets.js +8 -1
  100. package/dist/cascade/budgets.js.map +1 -1
  101. package/dist/cascade/packer.d.ts +88 -0
  102. package/dist/cascade/packer.d.ts.map +1 -0
  103. package/dist/cascade/packer.js +106 -0
  104. package/dist/cascade/packer.js.map +1 -0
  105. package/dist/conversation/result-ops.d.ts +18 -3
  106. package/dist/conversation/result-ops.d.ts.map +1 -1
  107. package/dist/conversation/result-ops.js +77 -5
  108. package/dist/conversation/result-ops.js.map +1 -1
  109. package/dist/conversation/rolling-summary.d.ts.map +1 -1
  110. package/dist/conversation/rolling-summary.js +7 -2
  111. package/dist/conversation/rolling-summary.js.map +1 -1
  112. package/dist/conversation/session-store.d.ts +3 -0
  113. package/dist/conversation/session-store.d.ts.map +1 -1
  114. package/dist/conversation/session-store.js +8 -4
  115. package/dist/conversation/session-store.js.map +1 -1
  116. package/dist/conversation/snapshot.d.ts +20 -0
  117. package/dist/conversation/snapshot.d.ts.map +1 -1
  118. package/dist/conversation/snapshot.js +40 -2
  119. package/dist/conversation/snapshot.js.map +1 -1
  120. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  121. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  122. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  123. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  124. package/dist/hints/store.d.ts.map +1 -1
  125. package/dist/hints/store.js +15 -5
  126. package/dist/hints/store.js.map +1 -1
  127. package/dist/index.d.ts +29 -9
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +29 -6
  130. package/dist/index.js.map +1 -1
  131. package/dist/intent-controller.d.ts +36 -0
  132. package/dist/intent-controller.d.ts.map +1 -1
  133. package/dist/intent-controller.js +95 -0
  134. package/dist/intent-controller.js.map +1 -1
  135. package/dist/meaning-resolution.d.ts +69 -2
  136. package/dist/meaning-resolution.d.ts.map +1 -1
  137. package/dist/meaning-resolution.js +128 -7
  138. package/dist/meaning-resolution.js.map +1 -1
  139. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  140. package/dist/metadata/analysis-planner.js +124 -7
  141. package/dist/metadata/analysis-planner.js.map +1 -1
  142. package/dist/metadata/block-fit.d.ts +36 -0
  143. package/dist/metadata/block-fit.d.ts.map +1 -1
  144. package/dist/metadata/block-fit.js +290 -38
  145. package/dist/metadata/block-fit.js.map +1 -1
  146. package/dist/metadata/catalog.d.ts +20 -23
  147. package/dist/metadata/catalog.d.ts.map +1 -1
  148. package/dist/metadata/catalog.js +193 -22
  149. package/dist/metadata/catalog.js.map +1 -1
  150. package/dist/metadata/meaning-evidence.d.ts +10 -1
  151. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  152. package/dist/metadata/meaning-evidence.js +275 -53
  153. package/dist/metadata/meaning-evidence.js.map +1 -1
  154. package/dist/metadata/metric-match.d.ts +32 -0
  155. package/dist/metadata/metric-match.d.ts.map +1 -1
  156. package/dist/metadata/metric-match.js +120 -9
  157. package/dist/metadata/metric-match.js.map +1 -1
  158. package/dist/research-loop.d.ts +33 -2
  159. package/dist/research-loop.d.ts.map +1 -1
  160. package/dist/research-loop.js +66 -4
  161. package/dist/research-loop.js.map +1 -1
  162. package/dist/resolved-analytical-plan.d.ts +7 -0
  163. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  164. package/dist/resolved-analytical-plan.js +11 -4
  165. package/dist/resolved-analytical-plan.js.map +1 -1
  166. package/dist/router.d.ts +32 -19
  167. package/dist/router.d.ts.map +1 -1
  168. package/dist/router.js +1977 -73
  169. package/dist/router.js.map +1 -1
  170. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  171. package/dist/semantic-bridge/member-select.js +39 -21
  172. package/dist/semantic-bridge/member-select.js.map +1 -1
  173. package/package.json +5 -5
@@ -41,6 +41,459 @@ export function inferAnalyticalTurnKind(question) {
41
41
  return 'lookup';
42
42
  return 'aggregation';
43
43
  }
44
+ function normalizeRequirementTerm(value) {
45
+ return value.toLowerCase()
46
+ .replace(/[_./:-]+/g, ' ')
47
+ .replace(/[^a-z0-9 ]+/g, ' ')
48
+ .replace(/\s+/g, ' ')
49
+ .trim();
50
+ }
51
+ function uniqueRequirementTerms(values) {
52
+ return [...new Set(values
53
+ .filter((value) => typeof value === 'string')
54
+ .map(normalizeRequirementTerm)
55
+ .filter(Boolean))];
56
+ }
57
+ function isTemporalTerm(term) {
58
+ return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
59
+ }
60
+ /**
61
+ * Parser output occasionally retains the grammatical wrapper around an
62
+ * aggregation (for example "count for each customer") as though it were a
63
+ * second metric. The stable requirement is `count`; the rest describes the
64
+ * requested grain and is already represented by the entity/dimension roles.
65
+ * Keeping the wrapper makes a physically complete customer table look
66
+ * incomplete and prematurely terminates the pre-freeze cascade.
67
+ */
68
+ function isStructuralMeasurePhrase(value) {
69
+ const term = normalizeRequirementTerm(value);
70
+ return /^(?:count|sum|total|average|avg)?\s*for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term)
71
+ || /^for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term);
72
+ }
73
+ const AGGREGATION_REQUIREMENT_OPERATORS = new Set([
74
+ 'total', 'sum', 'average', 'avg', 'minimum', 'min', 'maximum', 'max',
75
+ ]);
76
+ /**
77
+ * A parsed intent may preserve every grammatical fragment of an aggregate
78
+ * request ("total", "total supply cost", "supply", and "product"). Those
79
+ * fragments are useful while retrieving, but they are not independent
80
+ * physical requirements. Normalize only the explicit aggregation + grouping
81
+ * construction so ordinary named metrics such as `total_revenue` keep their
82
+ * authored identity.
83
+ */
84
+ function typedAggregationRequirementRoles(question) {
85
+ const match = /\b(?:total|sum|average|avg|minimum|min|maximum|max)\s+([a-z][a-z0-9_ -]{1,60}?)\s+(?:per|by|for\s+each)\s+([a-z][a-z0-9_-]*)\b/i.exec(question);
86
+ const measure = match?.[1] ? normalizeRequirementTerm(match[1]) : '';
87
+ const dimension = match?.[2] ? normalizeRequirementTerm(match[2]) : '';
88
+ return measure && dimension ? { measure, dimension } : undefined;
89
+ }
90
+ function normalizedTypedAggregationRequirements(input) {
91
+ const typed = typedAggregationRequirementRoles(input.question);
92
+ if (!typed)
93
+ return input;
94
+ const measureParts = new Set(typed.measure.split(' ').filter(Boolean));
95
+ const measures = uniqueRequirementTerms([
96
+ typed.measure,
97
+ ...input.measures.filter((value) => {
98
+ const normalized = normalizeRequirementTerm(value);
99
+ return normalized !== typed.measure
100
+ && !AGGREGATION_REQUIREMENT_OPERATORS.has(normalized)
101
+ && normalized !== `total ${typed.measure}`
102
+ && !(normalized.split(' ').length === 1 && measureParts.has(normalized));
103
+ }),
104
+ ]);
105
+ const dimensions = uniqueRequirementTerms([
106
+ typed.dimension,
107
+ ...input.dimensions.filter((value) => {
108
+ const normalized = normalizeRequirementTerm(value);
109
+ return normalized !== typed.dimension
110
+ && !(normalized.split(' ').length === 1 && measureParts.has(normalized));
111
+ }),
112
+ ]);
113
+ return { measures, dimensions };
114
+ }
115
+ /**
116
+ * Normalize grammatical aggregation wrappers before they become a plan
117
+ * requirement. Retrieval/parser output is allowed to retain useful search
118
+ * phrases, but an immutable plan must never treat "count for each customer"
119
+ * or "for each customer" as separate physical measures. The grouping entity
120
+ * is represented by the dimension/entity roles instead.
121
+ *
122
+ * `order count for each customer` is the common prose form for a count
123
+ * aggregation at customer grain. Keep the aggregation (`count`) and remove
124
+ * the object noun (`order`) only for that exact grouped construction; a named
125
+ * metric such as `order_value` remains untouched.
126
+ */
127
+ export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
128
+ const normalizedQuestion = normalizeRequirementTerm(question);
129
+ const groupedOrderCount = /\borders?\s+count\s+(?:for|per)\s+(?:each|every)\s+(?:the\s+)?(?:account|customer|client|company|product|order|item|row)s?\b/.test(normalizedQuestion);
130
+ const terms = values
131
+ .filter((value) => !isStructuralMeasurePhrase(value))
132
+ .filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
133
+ if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
134
+ terms.push('count');
135
+ }
136
+ // An inherited measure can already be a stable semantic/dbt identity. Keep
137
+ // that identity intact for the planner/meaning handoff; matching and display
138
+ // have their own normalizers. Rewriting `total_consumption_units` to prose
139
+ // here lost the only sticky reference a measure-less refinement carried.
140
+ if (options.preserveIdentity) {
141
+ const seen = new Set();
142
+ return terms.flatMap((value) => {
143
+ const exact = value.replace(/\s+/g, ' ').trim();
144
+ const normalized = normalizeRequirementTerm(exact);
145
+ if (!exact || !normalized || seen.has(normalized))
146
+ return [];
147
+ seen.add(normalized);
148
+ return [exact];
149
+ });
150
+ }
151
+ return uniqueRequirementTerms(terms);
152
+ }
153
+ /**
154
+ * Parsed measure phrases are the most specific typed evidence available before
155
+ * meaning resolution. A lexical root is useful only when the parser found no
156
+ * phrase that already owns it: adding both `beverage revenue` and `revenue`
157
+ * turns one requested metric into two and incorrectly rejects a block whose
158
+ * own declared output is `beverage_revenue`. The same holds for `order count`
159
+ * and its generic `count` root; grouped prose is normalized to `count` before
160
+ * this helper runs, so retaining both is neither necessary nor correct.
161
+ */
162
+ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
163
+ const lexical = ['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count']
164
+ .filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
165
+ return lexical.filter((term) => {
166
+ if (term === 'count') {
167
+ return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
168
+ }
169
+ const token = term === 'refunds' ? 'refund' : term;
170
+ return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
171
+ .split(' ')
172
+ .some((word) => word === token || (token === 'refund' && word === 'refunds')));
173
+ });
174
+ }
175
+ /**
176
+ * Parse only stable analytical roles. This is purposefully narrower than an
177
+ * LLM interpretation: unknown business phrases remain available to the normal
178
+ * bounded meaning resolver instead of being guessed here.
179
+ */
180
+ export function buildAnalyticalRequirementSet(input) {
181
+ const question = input.question;
182
+ const lower = question.toLowerCase();
183
+ const parsed = input.parsedIntent;
184
+ const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
185
+ const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
186
+ const grain = grainWord === 'daily' ? 'day'
187
+ : grainWord === 'weekly' ? 'week'
188
+ : grainWord === 'monthly' ? 'month'
189
+ : grainWord === 'quarterly' ? 'quarter'
190
+ : grainWord === 'yearly' ? 'year'
191
+ : /^(day|week|month|quarter|year)$/.test(grainWord) ? grainWord
192
+ : undefined;
193
+ const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
194
+ const fiscalPeriod = fiscal ? `FY${fiscal[1] ?? fiscal[2]}`.toUpperCase() : undefined;
195
+ const ranking = lower.match(/\b(top|bottom|highest|lowest)\s*(\d+)?\b/i);
196
+ const requestedDimensions = uniqueRequirementTerms(parsed?.dimensions ?? []);
197
+ const entityTerms = uniqueRequirementTerms([
198
+ ...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
199
+ ]).map((term) => term.replace(/s$/, ''));
200
+ const entityDisplayTerms = /\b(?:who|which)\b/i.test(question)
201
+ ? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
202
+ : [];
203
+ // "this amount" is a deictic reference to a prior result, not a request to
204
+ // choose an `amount` metric. Treating it as a new explicit measure made a
205
+ // compositional follow-up reject every otherwise-valid display/predicate
206
+ // option. Concrete metric words remain typed requirements, including the
207
+ // common revenue/refunds pair used by multi-metric requests.
208
+ const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
209
+ const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? []);
210
+ const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
211
+ ...parsedMeasures,
212
+ ...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
213
+ ...(!deicticAmount
214
+ && /\bamount\b/i.test(question)
215
+ && !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
216
+ ? ['amount']
217
+ : []),
218
+ ]);
219
+ const typedRequirements = normalizedTypedAggregationRequirements({
220
+ question,
221
+ measures: parsedMeasuresWithLexicalTerms,
222
+ dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
223
+ });
224
+ const measures = typedRequirements.measures;
225
+ const dimensions = typedRequirements.dimensions;
226
+ const rankingMetricTerms = ranking ? measures : [];
227
+ const parsedLimit = typeof parsed?.limit === 'number' && Number.isFinite(parsed.limit) && parsed.limit > 0
228
+ ? Math.floor(parsed.limit)
229
+ : undefined;
230
+ const explicitLimit = ranking?.[2] ? Number(ranking[2]) : parsedLimit;
231
+ const time = grain || fiscalPeriod
232
+ ? {
233
+ role: grain ? 'time_axis' : 'time_filter',
234
+ ...(grain ? { grain: grain } : {}),
235
+ ...(fiscalPeriod ? { fiscalPeriod } : {}),
236
+ requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
237
+ }
238
+ : undefined;
239
+ return {
240
+ version: 1,
241
+ measures,
242
+ dimensions,
243
+ entityTerms,
244
+ entityDisplayTerms,
245
+ memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
246
+ ...(ranking
247
+ ? {
248
+ ranking: {
249
+ metricTerms: rankingMetricTerms,
250
+ entityTerms,
251
+ direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
252
+ limit: explicitLimit ?? 10,
253
+ defaultedLimit: explicitLimit === undefined,
254
+ },
255
+ }
256
+ : {}),
257
+ ...(time ? { time } : {}),
258
+ };
259
+ }
260
+ /** Classify the role an already-qualified candidate may fill. */
261
+ export function evidenceCandidateRoles(candidate) {
262
+ const identity = uniqueRequirementTerms([
263
+ candidate.id,
264
+ candidate.qualifiedId,
265
+ candidate.name,
266
+ ...(candidate.aliases ?? []),
267
+ ...(candidate.dimensions ?? []),
268
+ ...(candidate.analyticalCapability?.dimensions ?? []).map((dimension) => dimension.dimensionId),
269
+ ...(candidate.analyticalCapability?.timeDimensions ?? []).map((dimension) => dimension.dimensionId),
270
+ ]).join(' ');
271
+ const roles = new Set();
272
+ if (candidate.kind === 'semantic_metric' || candidate.semanticObjectType === 'metric' || /\bmetric\b/.test(identity))
273
+ roles.add('metric');
274
+ if (candidate.semanticObjectType === 'entity' || /(?:^| )(?:account|customer|client|company) id\b/.test(identity) || /\bentity\b/.test(identity))
275
+ roles.add('entity_key');
276
+ if (/\b(?:account|customer|client|company)(?: name)?\b/.test(identity)
277
+ && /\b(?:name|label|display|account|customer|client|company)\b/.test(identity)
278
+ && !/\b(?:owner|sentiment|email)\b/.test(identity))
279
+ roles.add('entity_label');
280
+ if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b)/.test(identity)
281
+ || (candidate.timeGrains?.length ?? 0) > 0
282
+ || (candidate.analyticalCapability?.timeDimensions?.length ?? 0) > 0)
283
+ roles.add('time_dimension');
284
+ if ((candidate.relationshipEvidence?.length ?? 0) > 0 || /\b(?:relationship|join|bridge)\b/.test(identity))
285
+ roles.add('relationship');
286
+ if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension')
287
+ roles.add('categorical_dimension');
288
+ if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
289
+ roles.add('context');
290
+ if (roles.size === 0)
291
+ roles.add('context');
292
+ return [...roles];
293
+ }
294
+ function candidateMatchesTerms(candidate, terms, options = {}) {
295
+ if (terms.length === 0)
296
+ return false;
297
+ const identity = uniqueRequirementTerms([
298
+ candidate.id,
299
+ candidate.qualifiedId,
300
+ candidate.name,
301
+ ...(candidate.aliases ?? []),
302
+ ...(candidate.dimensions ?? []),
303
+ ]).join(' ');
304
+ if (terms.some((term) => identity.includes(term) || term.includes(identity)))
305
+ return true;
306
+ return options.categoricalDimension === true
307
+ && candidateMatchesCategoricalDimensionRequirement(candidate, terms);
308
+ }
309
+ /**
310
+ * A categorical dimension may satisfy a requested business role only through
311
+ * its own snapshot-authored declaration. In particular, `location_name` is
312
+ * not a synonym for `region`: it can fill a region lane only when metadata
313
+ * explicitly says `alternative-for:region`, or when the dimension itself is
314
+ * declared with the semantic geography role. This protects admission from
315
+ * broad lexical geography expansion while retaining role-balanced recall.
316
+ */
317
+ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
318
+ const facts = new Set((candidate.compatibilityFacts ?? [])
319
+ .map(normalizeRequirementTerm)
320
+ .filter(Boolean));
321
+ if (facts.size === 0)
322
+ return false;
323
+ const requestedRoles = [...new Set(terms.flatMap((term) => {
324
+ const normalized = normalizeRequirementTerm(term);
325
+ const terminal = normalized.split(' ').at(-1) ?? '';
326
+ return [normalized, terminal].filter(Boolean);
327
+ }))];
328
+ const hasDeclaredAlternative = requestedRoles.some((role) => facts.has(`alternative for ${role}`)
329
+ || facts.has(`dimension alternative for ${role}`));
330
+ if (hasDeclaredAlternative)
331
+ return true;
332
+ const declaredGeography = facts.has('semantic role geography')
333
+ || facts.has('semantic geography role');
334
+ return declaredGeography && requestedRoles.some((role) => role === 'region' || role === 'geography' || role === 'geographic');
335
+ }
336
+ /**
337
+ * Keep an internal retrieval result broad while making the provider package
338
+ * role-balanced. Exact/alias matches stay pinned; each requested role gets up
339
+ * to two candidates before relevance fills remaining cards.
340
+ */
341
+ export function selectRoleBalancedMeaningCandidates(input) {
342
+ const max = Math.max(1, Math.min(16, Math.floor(input.maxCandidates ?? 16)));
343
+ const ranked = [...new Map(input.candidates
344
+ .filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
345
+ .map((candidate) => [candidate.id, candidate])).values()]
346
+ .sort((left, right) => Number(Boolean(right.exactMatch)) - Number(Boolean(left.exactMatch))
347
+ || (right.relevanceScore ?? 0) - (left.relevanceScore ?? 0)
348
+ || left.id.localeCompare(right.id));
349
+ const selected = [];
350
+ const add = (candidate) => {
351
+ if (candidate && selected.length < max && !selected.some((item) => item.id === candidate.id))
352
+ selected.push(candidate);
353
+ };
354
+ const servesRequestedRole = (candidate) => {
355
+ const roles = evidenceCandidateRoles(candidate);
356
+ const metricTerms = input.requirements.ranking?.metricTerms.length
357
+ ? input.requirements.ranking.metricTerms
358
+ : input.requirements.measures;
359
+ if (roles.includes('metric') && candidateMatchesTerms(candidate, metricTerms))
360
+ return true;
361
+ // An entity term such as "account" is deliberately insufficient for an
362
+ // attribute (Account Owner Email) to displace the requested display key.
363
+ // Only an actual entity-label candidate may satisfy this binding.
364
+ if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
365
+ ...input.requirements.entityTerms,
366
+ ...input.requirements.entityDisplayTerms,
367
+ ]))
368
+ return true;
369
+ if (roles.includes('time_dimension') && Boolean(input.requirements.time))
370
+ return true;
371
+ if (roles.includes('categorical_dimension')
372
+ && input.requirements.dimensions.length > 0
373
+ && candidateMatchesTerms(candidate, input.requirements.dimensions, { categoricalDimension: true }))
374
+ return true;
375
+ if (roles.includes('relationship')
376
+ && (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
377
+ return true;
378
+ return false;
379
+ };
380
+ for (const candidate of ranked.filter((candidate) => candidate.exactMatch)) {
381
+ // In a pin-only prepass, an exact match is only a pin when it serves a
382
+ // requested analytical role. Otherwise a pile of exact members consumes
383
+ // the whole package before the requested metric/entity can be reserved.
384
+ if (input.pinOnly && !servesRequestedRole(candidate))
385
+ continue;
386
+ add(candidate);
387
+ }
388
+ const required = [
389
+ ['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
390
+ ['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
391
+ ['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
392
+ ['categorical_dimension', input.requirements.dimensions],
393
+ ['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
394
+ ];
395
+ for (const [role, terms] of required) {
396
+ // No requested categorical dimension means that high-scoring arbitrary
397
+ // members are noise, not a role reservation. This is the subtle path that
398
+ // used to admit Account Owner and Sentiment immediately after Account Name.
399
+ if (terms.length === 0)
400
+ continue;
401
+ let admitted = 0;
402
+ for (const candidate of ranked) {
403
+ if (admitted >= 2 || selected.length >= max)
404
+ break;
405
+ const roles = evidenceCandidateRoles(candidate);
406
+ if (!roles.includes(role))
407
+ continue;
408
+ // "top accounts" needs the account display key, not any field whose
409
+ // label happens to contain account. Once a display candidate is
410
+ // available, owner/e-mail/sentiment attributes are neither the entity
411
+ // role nor a useful categorical reservation unless the user explicitly
412
+ // named that attribute. This runs during the pre-cap pin pass so noisy
413
+ // same-kind cards cannot enter through the categorical role.
414
+ const explicitlyRequestsAttribute = /\b(?:owner|sentiment|email)\b/i.test([
415
+ ...input.requirements.dimensions,
416
+ ...input.requirements.entityTerms,
417
+ ...input.requirements.entityDisplayTerms,
418
+ ].join(' '));
419
+ const hasRequestedEntityLabel = ranked.some((item) => evidenceCandidateRoles(item).includes('entity_label')
420
+ && candidateMatchesTerms(item, [
421
+ ...input.requirements.entityTerms,
422
+ ...input.requirements.entityDisplayTerms,
423
+ ]));
424
+ if (role === 'categorical_dimension'
425
+ && hasRequestedEntityLabel
426
+ && !explicitlyRequestsAttribute
427
+ && /\b(?:owner|sentiment|email)\b/i.test(candidate.name ?? candidate.id))
428
+ continue;
429
+ // For entity labels, role is more important than a lexical owner/email
430
+ // hit. For all other roles, prefer an identity matching the requested
431
+ // business term but retain a role candidate when the request is terse.
432
+ if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
433
+ && role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
434
+ continue;
435
+ add(candidate);
436
+ admitted += 1;
437
+ }
438
+ }
439
+ if (!input.pinOnly) {
440
+ for (const candidate of ranked)
441
+ add(candidate);
442
+ }
443
+ return selected;
444
+ }
445
+ export function classifyProviderFailure(input) {
446
+ const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
447
+ const cause = /cancel/.test(text) ? 'cancelled'
448
+ : /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
449
+ : /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
450
+ : /run.?deadline|time limit/.test(text) ? 'run_deadline'
451
+ : /timeout|timed out/.test(text) ? 'provider_timeout'
452
+ : /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
453
+ : /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
454
+ : /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
455
+ : /502|503|504|gateway/.test(text) ? 'gateway'
456
+ : /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
457
+ : 'unknown';
458
+ const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
459
+ const safeAction = retryable ? (cause === 'rate_limited' ? 'wait_and_retry' : 'retry_same_provider')
460
+ : cause === 'authentication' || cause === 'model_not_found' ? 'fix_provider_configuration'
461
+ : cause === 'cancelled' ? 'none'
462
+ : 'inspect_run';
463
+ const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
464
+ : /\b(?:502|503|504)\b/.test(text) ? '5xx'
465
+ : undefined;
466
+ return {
467
+ version: 1,
468
+ cause,
469
+ phase: input.phase ?? 'unknown',
470
+ retryable,
471
+ safeAction,
472
+ ...(httpStatusClass ? { httpStatusClass } : {}),
473
+ ...(input.providerFingerprint ? { providerFingerprint: input.providerFingerprint } : {}),
474
+ ...(input.modelFingerprint ? { modelFingerprint: input.modelFingerprint } : {}),
475
+ ...(input.baseOriginFingerprint ? { baseOriginFingerprint: input.baseOriginFingerprint } : {}),
476
+ };
477
+ }
478
+ export function buildAnalyticalCascadeDecision(input) {
479
+ return {
480
+ version: 1,
481
+ ...input,
482
+ sourceCoverage: input.sourceCoverage.map((coverage) => ({
483
+ ...coverage,
484
+ version: 1,
485
+ candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
486
+ })),
487
+ attempts: input.attempts.map((attempt) => ({ ...attempt, version: 1, candidateIds: [...new Set(attempt.candidateIds)].slice(0, 32) })),
488
+ };
489
+ }
490
+ /**
491
+ * `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
492
+ * quoted value, with nothing else in the clause. Deliberately narrow: it must
493
+ * be the WHOLE clause and the value must be quoted, so an ordinary question
494
+ * that happens to contain a colon is untouched.
495
+ */
496
+ const ANNOTATION_CLAUSE = /^\s*[A-Za-z][A-Za-z ]{0,24}:\s*["“'‘][^"”'’]*["”'’]\s*$/;
44
497
  export function splitAnalyticalTasks(question) {
45
498
  // The separator belongs to the SPLIT, not to the clause. Carrying it through
46
499
  // produced a child task whose question was literally
@@ -49,22 +502,36 @@ export function splitAnalyticalTasks(question) {
49
502
  // the task title.
50
503
  const leadingJunk = /^(?:[\s"'“”‘’,:;.\-]+|\b(?:then|and|also)\b)+/i;
51
504
  const trailingJunk = /[\s"'“”‘’,:;.\-]+$/;
52
- const parts = question
505
+ const raw = question
53
506
  .split(/\s*(?:\?|;|\band then\b|\balso\b)\s*/i)
54
- .flatMap((part) => part.split(/\s+\band\s+(?=(?:what|who|which|show|list|tell|give|how|why)\b)/i))
507
+ .flatMap((part) => part.split(/\s+\band\s+(?=(?:what|who|which|show|list|tell|give|how|why)\b)/i));
508
+ // A `Label: "value"` clause is an ANNOTATION, not a question. The composer
509
+ // appends `Regarding: "<selected row>"` when the reader follows up on
510
+ // something they clicked, and splitting on the `?` before it turned that
511
+ // referent into a task of its own — titled `Regarding: "Mr. Matthew Meyer`,
512
+ // which then "answered" by computing an unrelated maximum over the rows still
513
+ // on screen. Context says WHO the real task is about; it is never a task.
514
+ // Filtered before the junk strip, which would unbalance the quotes.
515
+ const asked = raw.filter((part) => !ANNOTATION_CLAUSE.test(part));
516
+ const parts = (asked.length > 0 ? asked : raw)
55
517
  .map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
56
518
  .filter(Boolean);
57
519
  return parts.length > 0 ? parts : [question.trim()];
58
520
  }
59
521
  export function buildAnalyticalTaskGraph(input) {
60
- const clauses = splitAnalyticalTasks(input.question).slice(0, Math.max(1, Math.min(6, input.maxTasks ?? 6)));
522
+ const rootKind = input.mode === 'research' ? 'research' : inferAnalyticalTurnKind(input.question);
523
+ // A research turn may later create bounded evidence branches, but that is a
524
+ // research planner's job. Splitting at ingress loses the surrounding story
525
+ // before it has an opportunity to reason about it.
526
+ const clauses = (rootKind === 'research' ? [input.question.trim()] : splitAnalyticalTasks(input.question))
527
+ .slice(0, Math.max(1, Math.min(6, input.maxTasks ?? 6)));
61
528
  const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
62
529
  const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
63
530
  const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
64
531
  const filters = input.filters ?? [];
65
532
  const inheritedBindings = input.inheritedBindings ?? [];
66
- const tasks = clauses.map((clause, index) => {
67
- const kind = inferAnalyticalTurnKind(clause);
533
+ const unboundTasks = clauses.map((clause, index) => {
534
+ const kind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
68
535
  const research = kind === 'research' || kind === 'diagnosis';
69
536
  const taskKind = research
70
537
  ? 'research_branch'
@@ -96,12 +563,36 @@ export function buildAnalyticalTaskGraph(input) {
96
563
  inheritedBindings,
97
564
  };
98
565
  });
566
+ const tasks = bindTopRankedRegionDependencies(unboundTasks);
99
567
  return {
100
- kind: tasks.length > 1 ? 'compound' : inferAnalyticalTurnKind(input.question),
568
+ kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
101
569
  tasks,
102
570
  partial: false,
103
571
  };
104
572
  }
573
+ const TOP_RANKED_REGION_RE = /(?:\b(?:top|highest|most)\b[^?.!]{0,72}\bregions?\b|\bregions?\b[^?.!]{0,72}\b(?:top|highest|most)\b)/i;
574
+ const DEPENDENT_REGION_CUSTOMERS_RE = /\b(?:that|this|same)\s+region\b/i;
575
+ /**
576
+ * Recognise only the demonstrated two-step dependency. Broader pronoun
577
+ * resolution belongs to the normal conversation layer; turning every compound
578
+ * question into a dependency would serialise unrelated work and invent filters.
579
+ */
580
+ function bindTopRankedRegionDependencies(tasks) {
581
+ return tasks.map((task, index) => {
582
+ if (!DEPENDENT_REGION_CUSTOMERS_RE.test(task.question) || !/\bcustomers?\b/i.test(task.question))
583
+ return task;
584
+ const parent = tasks.slice(0, index).reverse().find((candidate) => TOP_RANKED_REGION_RE.test(candidate.question));
585
+ if (!parent)
586
+ return task;
587
+ const dependency = {
588
+ version: 1,
589
+ kind: 'top_ranked_region',
590
+ sourceTaskId: parent.id,
591
+ targetDimension: 'region',
592
+ };
593
+ return { ...task, dependencies: [parent.id], dependency };
594
+ });
595
+ }
105
596
  export function buildAnalyticalTurnPlan(input) {
106
597
  const graph = buildAnalyticalTaskGraph(input);
107
598
  const zeroCall = input.zeroCallReason === 'explicit_binding'
@@ -134,6 +625,173 @@ export function summarizeTaskOutcomes(tasks) {
134
625
  : 'gap';
135
626
  return { status, completed, gaps };
136
627
  }
628
+ /** Stable scalar transport for a selected canonical result cell. */
629
+ export function canonicalResultBindingValue(value) {
630
+ if (typeof value === 'string')
631
+ return value;
632
+ if (typeof value === 'number' && Number.isFinite(value))
633
+ return String(value);
634
+ if (typeof value === 'boolean')
635
+ return String(value);
636
+ return undefined;
637
+ }
638
+ /** A row proof includes all canonical columns, not just the selected cell. */
639
+ export function canonicalResultRowFingerprint(result, row) {
640
+ return stableFingerprint({
641
+ columns: result.columns,
642
+ row: Object.fromEntries(result.columns.map((column) => [column, row[column]])),
643
+ });
644
+ }
645
+ /**
646
+ * Validate an untrusted selected-result reference against a persisted
647
+ * canonical result. The source run/artifact identities are checked by the
648
+ * host before calling this pure result-level validator.
649
+ */
650
+ export function validateSelectedResultBinding(binding, result) {
651
+ if (!binding
652
+ || binding.version !== 1
653
+ || !binding.sourceRunId.trim()
654
+ || !binding.sourceArtifactId.trim()
655
+ || !binding.canonicalColumn.trim()
656
+ || !binding.value.trim()
657
+ || !normalizeAnalyticalExecutionFingerprint(binding.rowFingerprint)
658
+ || !normalizeAnalyticalExecutionFingerprint(binding.resultFingerprint)) {
659
+ return {
660
+ ok: false,
661
+ code: 'RESULT_BINDING_INVALID',
662
+ message: 'The selected result reference is incomplete or malformed.',
663
+ };
664
+ }
665
+ if (!result || binding.resultFingerprint !== result.resultFingerprint) {
666
+ return {
667
+ ok: false,
668
+ code: 'RESULT_BINDING_RESULT_MISMATCH',
669
+ message: 'The selected result is no longer the persisted result for this run.',
670
+ };
671
+ }
672
+ if (!result.columns.includes(binding.canonicalColumn)) {
673
+ return {
674
+ ok: false,
675
+ code: 'RESULT_BINDING_ROW_MISMATCH',
676
+ message: `The selected column ${binding.canonicalColumn} is not present in the persisted result.`,
677
+ };
678
+ }
679
+ const matched = result.rows.some((row) => canonicalResultRowFingerprint(result, row) === binding.rowFingerprint
680
+ && canonicalResultBindingValue(row[binding.canonicalColumn]) === binding.value);
681
+ if (!matched) {
682
+ return {
683
+ ok: false,
684
+ code: 'RESULT_BINDING_ROW_MISMATCH',
685
+ message: 'The selected row/value does not match the persisted result.',
686
+ };
687
+ }
688
+ return { ok: true, binding: { ...binding, resultFingerprint: result.resultFingerprint } };
689
+ }
690
+ /**
691
+ * Derive the one value a `top region -> customers in that region` child may
692
+ * consume. A first row alone is never proof. The parent must either return an
693
+ * explicit rank, or be the server-computed descending ranking task and return
694
+ * at least two rows with one strictly-leading numeric measure. `rowCount` is
695
+ * the returned-row contract, not proof that a singleton is the complete group
696
+ * set. Ties and ambiguous result shapes remain a typed child gap.
697
+ */
698
+ export function resolveTopRankedRegionDependency(sourceTaskId, result, parentTask) {
699
+ if (!result || result.rows.length === 0) {
700
+ return {
701
+ ok: false,
702
+ code: 'RESULT_CONTRACT_MISMATCH',
703
+ message: 'The top-region task did not produce a canonical result that can bind the dependent customer task.',
704
+ };
705
+ }
706
+ const canonicalResultFingerprint = normalizeAnalyticalExecutionFingerprint(result.resultFingerprint);
707
+ const executionReceipt = normalizeAnalyticalExecutionReceipt(result.executionReceipt);
708
+ if (!executionReceipt) {
709
+ return {
710
+ ok: false,
711
+ code: 'RESULT_CONTRACT_MISMATCH',
712
+ message: 'The top-region result did not retain a complete normalized execution receipt, so the dependent customer task was not run.',
713
+ };
714
+ }
715
+ if (!canonicalResultFingerprint
716
+ || result.resultFingerprint !== canonicalResultFingerprint
717
+ || executionReceipt.resultFingerprint !== canonicalResultFingerprint) {
718
+ return {
719
+ ok: false,
720
+ code: 'RESULT_CONTRACT_MISMATCH',
721
+ message: 'The top-region execution receipt does not match the canonical result, so the dependent customer task was not run.',
722
+ };
723
+ }
724
+ const regionColumns = result.columns.filter((column) => /(?:^|_)region(?:_name)?$/i.test(column));
725
+ if (regionColumns.length !== 1) {
726
+ return {
727
+ ok: false,
728
+ code: 'RESULT_CONTRACT_MISMATCH',
729
+ message: 'The top-region result did not contain exactly one canonical region column.',
730
+ };
731
+ }
732
+ const canonicalColumn = regionColumns[0];
733
+ const first = result.rows[0];
734
+ const value = canonicalResultBindingValue(first[canonicalColumn]);
735
+ if (!value) {
736
+ return {
737
+ ok: false,
738
+ code: 'RESULT_CONTRACT_MISMATCH',
739
+ message: 'The leading top-region row did not contain a usable region value.',
740
+ };
741
+ }
742
+ if (!hasUnambiguousTopRow(result, parentTask)) {
743
+ return {
744
+ ok: false,
745
+ code: 'RESULT_CONTRACT_MISMATCH',
746
+ message: 'The top-region result did not prove a single leading region, so the dependent customer task was not run.',
747
+ };
748
+ }
749
+ return {
750
+ ok: true,
751
+ binding: {
752
+ version: 1,
753
+ sourceTaskId,
754
+ sourceResultFingerprint: canonicalResultFingerprint,
755
+ canonicalColumn,
756
+ value,
757
+ rowFingerprint: canonicalResultRowFingerprint(result, first),
758
+ },
759
+ };
760
+ }
761
+ function hasUnambiguousTopRow(result, parentTask) {
762
+ const rankColumn = result.columns.find((column) => /(?:^|_)(?:rank|row_number)$/i.test(column));
763
+ if (rankColumn) {
764
+ const ranks = result.rows.map((row) => numericCell(row[rankColumn]));
765
+ if (ranks[0] === 1 && ranks.slice(1).every((rank) => rank === undefined || rank > 1))
766
+ return true;
767
+ }
768
+ // A plain numerical result column does not say that its first row was ranked.
769
+ // It becomes a ranking proof only when the server's own parent task explicitly
770
+ // asked for a descending ranking AND the returned rows demonstrate a strictly
771
+ // leading value. A singleton needs an explicit rank = 1: it cannot establish
772
+ // that unreturned groups are lower.
773
+ if (parentTask?.kind !== 'ranking' || parentTask.output.order !== 'desc')
774
+ return false;
775
+ if (result.rows.length < 2)
776
+ return false;
777
+ const measures = result.columns.filter((column) => {
778
+ if (column === rankColumn)
779
+ return false;
780
+ const values = result.rows.map((row) => numericCell(row[column]));
781
+ return values.every((value) => value !== undefined);
782
+ });
783
+ if (measures.length !== 1)
784
+ return false;
785
+ const values = result.rows.map((row) => numericCell(row[measures[0]]));
786
+ return values.slice(1).every((value) => values[0] > value);
787
+ }
788
+ function numericCell(value) {
789
+ if (typeof value === 'number' && Number.isFinite(value))
790
+ return value;
791
+ if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value)))
792
+ return Number(value);
793
+ return undefined;
794
+ }
137
795
  /**
138
796
  * Normalize connector, MetricFlow, and provider result shapes at one boundary.
139
797
  * Connectors may return `ColumnMeta[]` and rows as arrays; UI surfaces should
@@ -348,6 +1006,124 @@ export function buildResearchEvidenceLedger(input) {
348
1006
  stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
349
1007
  };
350
1008
  }
1009
+ export function buildResearchEvidenceLedgerV2(input) {
1010
+ const v1 = buildResearchEvidenceLedger(input);
1011
+ const entries = v1.entries.map((entry, index) => {
1012
+ const source = input.entries[index];
1013
+ const validator = normalizeResearchEvidenceValidator(source?.validator, entry);
1014
+ const requestedVerdict = /\b(?:because|caused?|driven by|due to)\b/i.test(source?.hypothesis ?? '')
1015
+ ? undefined
1016
+ : source?.verdict;
1017
+ const verdict = researchVerdictFromValidatedObservation({
1018
+ status: entry.status,
1019
+ requestedVerdict,
1020
+ validator,
1021
+ });
1022
+ const validFactIds = new Set(entry.facts);
1023
+ const counterEvidenceFactIds = [...new Set(source?.counterEvidenceFactIds ?? [])]
1024
+ .filter((factId) => validFactIds.has(factId));
1025
+ return {
1026
+ ...entry,
1027
+ verdict,
1028
+ ...(source?.hypothesis?.trim() ? { hypothesis: source.hypothesis.trim() } : {}),
1029
+ ...(validator ? { validator } : {}),
1030
+ counterEvidenceFactIds,
1031
+ };
1032
+ });
1033
+ const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'failed' && entry.verdict !== 'skipped').length;
1034
+ const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
1035
+ const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
1036
+ return {
1037
+ version: 2,
1038
+ rootQuestion: v1.rootQuestion,
1039
+ ...(v1.planId ? { planId: v1.planId } : {}),
1040
+ ...(v1.snapshotId ? { snapshotId: v1.snapshotId } : {}),
1041
+ entries,
1042
+ factIds: [...new Set(entries.flatMap((entry) => [...entry.facts, ...entry.counterEvidenceFactIds]))],
1043
+ groundableBranchCount,
1044
+ limitedScope: groundableBranchCount < 3,
1045
+ stoppingReason: v1.stoppingReason,
1046
+ };
1047
+ }
1048
+ /**
1049
+ * Normalise a hypothesis plan into the bounded research contract. The caller
1050
+ * may supply fewer than three grounded hypotheses; that is retained honestly as
1051
+ * limited scope rather than padded with invented joins or explanations.
1052
+ */
1053
+ export function buildResearchHypothesisPlanV2(input) {
1054
+ const seen = new Set();
1055
+ const hypotheses = [];
1056
+ for (const candidate of input.hypotheses) {
1057
+ const statement = candidate.statement.trim();
1058
+ const expectation = candidate.expectation.trim();
1059
+ const targetId = candidate.targetId.trim();
1060
+ if (!statement || !expectation || !targetId)
1061
+ continue;
1062
+ const key = `${statement.toLowerCase()}\u0000${targetId.toLowerCase()}`;
1063
+ if (seen.has(key))
1064
+ continue;
1065
+ seen.add(key);
1066
+ hypotheses.push({
1067
+ id: candidate.id?.trim() || `hypothesis:${hypotheses.length + 1}`,
1068
+ statement,
1069
+ expectation,
1070
+ targetId,
1071
+ validatorKind: candidate.validatorKind ?? inferResearchValidatorKind(statement, expectation),
1072
+ });
1073
+ if (hypotheses.length >= 6)
1074
+ break;
1075
+ }
1076
+ return { version: 2, hypotheses, limitedScope: hypotheses.length < 3 };
1077
+ }
1078
+ /** Map an action/expectation to a deterministic observation class only. */
1079
+ export function inferResearchValidatorKind(statement, expectation = '') {
1080
+ const text = `${statement} ${expectation}`.toLowerCase();
1081
+ if (/fresh|updated|stale|as of|recency/.test(text))
1082
+ return 'freshness';
1083
+ if (/contribut|driver|segment|breakdown|dominant/.test(text))
1084
+ return 'contributor';
1085
+ if (/trend|time|month|week|quarter|year|shift|change/.test(text))
1086
+ return 'trend';
1087
+ if (/compare|versus|vs\.?|difference/.test(text))
1088
+ return 'comparison';
1089
+ if (/anomal|outlier|spike|drop/.test(text))
1090
+ return 'anomaly';
1091
+ return 'counter_evidence';
1092
+ }
1093
+ /**
1094
+ * A verdict is promoted only from a validator that evaluated a deterministic
1095
+ * observation against a branch receipt. Rows by themselves stay inconclusive;
1096
+ * causal statements are never supported by this helper.
1097
+ */
1098
+ export function researchVerdictFromValidatedObservation(input) {
1099
+ if (input.status === 'failed')
1100
+ return 'failed';
1101
+ if (input.status === 'skipped')
1102
+ return 'skipped';
1103
+ if (!input.validator?.evaluated || input.validator.receiptFingerprints.length === 0)
1104
+ return 'inconclusive';
1105
+ if (input.requestedVerdict === 'supported' && input.validator.outcome === 'supports_observation')
1106
+ return 'supported';
1107
+ if (input.requestedVerdict === 'contradicted' && input.validator.outcome === 'contradicts_observation')
1108
+ return 'contradicted';
1109
+ return 'inconclusive';
1110
+ }
1111
+ function normalizeResearchEvidenceValidator(validator, entry) {
1112
+ if (!validator || validator.version !== 1)
1113
+ return undefined;
1114
+ const knownReceipt = entry.resultFingerprint;
1115
+ const receiptFingerprints = [...new Set(validator.receiptFingerprints)]
1116
+ .map(normalizeAnalyticalExecutionFingerprint)
1117
+ .filter((fingerprint) => Boolean(fingerprint))
1118
+ .filter((fingerprint) => !knownReceipt || fingerprint === knownReceipt);
1119
+ return {
1120
+ version: 1,
1121
+ kind: validator.kind,
1122
+ evaluated: validator.evaluated === true && receiptFingerprints.length > 0,
1123
+ ...(validator.outcome ? { outcome: validator.outcome } : {}),
1124
+ receiptFingerprints,
1125
+ };
1126
+ }
351
1127
  /** The only accepted host-side execution identity is a SHA-256 fingerprint. */
352
1128
  export function normalizeAnalyticalExecutionFingerprint(value) {
353
1129
  return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())