@duckcodeailabs/dql-agent 1.14.1 → 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 (84) hide show
  1. package/dist/agent-run-engine.d.ts +9 -1
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +199 -17
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +12 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  9. package/dist/agentic/analyst-loop.js +48 -28
  10. package/dist/agentic/analyst-loop.js.map +1 -1
  11. package/dist/agentic/research/synthesis.d.ts +4 -0
  12. package/dist/agentic/research/synthesis.d.ts.map +1 -1
  13. package/dist/agentic/research/synthesis.js +23 -8
  14. package/dist/agentic/research/synthesis.js.map +1 -1
  15. package/dist/agentic/research-agent.d.ts.map +1 -1
  16. package/dist/agentic/research-agent.js +3 -2
  17. package/dist/agentic/research-agent.js.map +1 -1
  18. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  19. package/dist/agentic/sql-authorization.js +230 -2
  20. package/dist/agentic/sql-authorization.js.map +1 -1
  21. package/dist/analytical-frame.d.ts.map +1 -1
  22. package/dist/analytical-frame.js +10 -1
  23. package/dist/analytical-frame.js.map +1 -1
  24. package/dist/analytical-orchestration.d.ts +302 -0
  25. package/dist/analytical-orchestration.d.ts.map +1 -1
  26. package/dist/analytical-orchestration.js +564 -0
  27. package/dist/analytical-orchestration.js.map +1 -1
  28. package/dist/answer-loop.d.ts +48 -0
  29. package/dist/answer-loop.d.ts.map +1 -1
  30. package/dist/answer-loop.js +679 -85
  31. package/dist/answer-loop.js.map +1 -1
  32. package/dist/answer-shape.d.ts +32 -1
  33. package/dist/answer-shape.d.ts.map +1 -1
  34. package/dist/answer-shape.js +49 -3
  35. package/dist/answer-shape.js.map +1 -1
  36. package/dist/conversation/snapshot.d.ts +20 -0
  37. package/dist/conversation/snapshot.d.ts.map +1 -1
  38. package/dist/conversation/snapshot.js +30 -1
  39. package/dist/conversation/snapshot.js.map +1 -1
  40. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  41. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  42. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  43. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  44. package/dist/index.d.ts +4 -4
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/intent-controller.d.ts +28 -0
  49. package/dist/intent-controller.d.ts.map +1 -1
  50. package/dist/intent-controller.js +30 -0
  51. package/dist/intent-controller.js.map +1 -1
  52. package/dist/meaning-resolution.d.ts +69 -2
  53. package/dist/meaning-resolution.d.ts.map +1 -1
  54. package/dist/meaning-resolution.js +128 -7
  55. package/dist/meaning-resolution.js.map +1 -1
  56. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  57. package/dist/metadata/analysis-planner.js +124 -7
  58. package/dist/metadata/analysis-planner.js.map +1 -1
  59. package/dist/metadata/block-fit.d.ts +18 -0
  60. package/dist/metadata/block-fit.d.ts.map +1 -1
  61. package/dist/metadata/block-fit.js +250 -38
  62. package/dist/metadata/block-fit.js.map +1 -1
  63. package/dist/metadata/catalog.d.ts.map +1 -1
  64. package/dist/metadata/catalog.js +57 -11
  65. package/dist/metadata/catalog.js.map +1 -1
  66. package/dist/metadata/meaning-evidence.d.ts +10 -1
  67. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  68. package/dist/metadata/meaning-evidence.js +261 -47
  69. package/dist/metadata/meaning-evidence.js.map +1 -1
  70. package/dist/research-loop.d.ts.map +1 -1
  71. package/dist/research-loop.js +23 -1
  72. package/dist/research-loop.js.map +1 -1
  73. package/dist/resolved-analytical-plan.d.ts +7 -0
  74. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  75. package/dist/resolved-analytical-plan.js +11 -4
  76. package/dist/resolved-analytical-plan.js.map +1 -1
  77. package/dist/router.d.ts +4 -3
  78. package/dist/router.d.ts.map +1 -1
  79. package/dist/router.js +1796 -125
  80. package/dist/router.js.map +1 -1
  81. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  82. package/dist/semantic-bridge/member-select.js +39 -21
  83. package/dist/semantic-bridge/member-select.js.map +1 -1
  84. package/package.json +5 -5
@@ -41,6 +41,452 @@ 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
+ }
44
490
  /**
45
491
  * `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
46
492
  * quoted value, with nothing else in the clause. Deliberately narrow: it must
@@ -560,6 +1006,124 @@ export function buildResearchEvidenceLedger(input) {
560
1006
  stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
561
1007
  };
562
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
+ }
563
1127
  /** The only accepted host-side execution identity is a SHA-256 fingerprint. */
564
1128
  export function normalizeAnalyticalExecutionFingerprint(value) {
565
1129
  return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())