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

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 (145) hide show
  1. package/dist/agent-run-engine.d.ts +63 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +864 -29
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-planner.d.ts +6 -0
  6. package/dist/agent-run-planner.d.ts.map +1 -1
  7. package/dist/agent-run-planner.js +2 -0
  8. package/dist/agent-run-planner.js.map +1 -1
  9. package/dist/agent-run-store.d.ts.map +1 -1
  10. package/dist/agent-run-store.js +48 -3
  11. package/dist/agent-run-store.js.map +1 -1
  12. package/dist/agentic/analyst-loop.d.ts +7 -1
  13. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  14. package/dist/agentic/analyst-loop.js +163 -0
  15. package/dist/agentic/analyst-loop.js.map +1 -1
  16. package/dist/agentic/assumptions.d.ts +1 -1
  17. package/dist/agentic/assumptions.d.ts.map +1 -1
  18. package/dist/agentic/assumptions.js.map +1 -1
  19. package/dist/agentic/research-agent.d.ts.map +1 -1
  20. package/dist/agentic/research-agent.js +10 -1
  21. package/dist/agentic/research-agent.js.map +1 -1
  22. package/dist/agentic/sql-authorization.d.ts +8 -0
  23. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  24. package/dist/agentic/sql-authorization.js +3 -0
  25. package/dist/agentic/sql-authorization.js.map +1 -1
  26. package/dist/analytical-compatibility.d.ts.map +1 -1
  27. package/dist/analytical-compatibility.js +8 -1
  28. package/dist/analytical-compatibility.js.map +1 -1
  29. package/dist/analytical-failure-repair.d.ts.map +1 -1
  30. package/dist/analytical-failure-repair.js +6 -2
  31. package/dist/analytical-failure-repair.js.map +1 -1
  32. package/dist/analytical-frame.d.ts +23 -1
  33. package/dist/analytical-frame.d.ts.map +1 -1
  34. package/dist/analytical-frame.js +322 -16
  35. package/dist/analytical-frame.js.map +1 -1
  36. package/dist/analytical-orchestration.d.ts +314 -3
  37. package/dist/analytical-orchestration.d.ts.map +1 -1
  38. package/dist/analytical-orchestration.js +486 -43
  39. package/dist/analytical-orchestration.js.map +1 -1
  40. package/dist/answer-loop.d.ts +64 -8
  41. package/dist/answer-loop.d.ts.map +1 -1
  42. package/dist/answer-loop.js +528 -67
  43. package/dist/answer-loop.js.map +1 -1
  44. package/dist/ask-observability/index.d.ts +11 -0
  45. package/dist/ask-observability/index.d.ts.map +1 -0
  46. package/dist/ask-observability/index.js +7 -0
  47. package/dist/ask-observability/index.js.map +1 -0
  48. package/dist/ask-observability/instrumentation.d.ts +35 -0
  49. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  50. package/dist/ask-observability/instrumentation.js +472 -0
  51. package/dist/ask-observability/instrumentation.js.map +1 -0
  52. package/dist/ask-observability/observer.d.ts +62 -0
  53. package/dist/ask-observability/observer.d.ts.map +1 -0
  54. package/dist/ask-observability/observer.js +537 -0
  55. package/dist/ask-observability/observer.js.map +1 -0
  56. package/dist/ask-observability/portable.d.ts +98 -0
  57. package/dist/ask-observability/portable.d.ts.map +1 -0
  58. package/dist/ask-observability/portable.js +527 -0
  59. package/dist/ask-observability/portable.js.map +1 -0
  60. package/dist/ask-observability/store.d.ts +130 -0
  61. package/dist/ask-observability/store.d.ts.map +1 -0
  62. package/dist/ask-observability/store.js +1114 -0
  63. package/dist/ask-observability/store.js.map +1 -0
  64. package/dist/ask-observability/types.d.ts +357 -0
  65. package/dist/ask-observability/types.d.ts.map +1 -0
  66. package/dist/ask-observability/types.js +2 -0
  67. package/dist/ask-observability/types.js.map +1 -0
  68. package/dist/ask-observability/utils.d.ts +21 -0
  69. package/dist/ask-observability/utils.d.ts.map +1 -0
  70. package/dist/ask-observability/utils.js +102 -0
  71. package/dist/ask-observability/utils.js.map +1 -0
  72. package/dist/generated-analytical-proposal.d.ts +37 -1
  73. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  74. package/dist/generated-analytical-proposal.js +211 -3
  75. package/dist/generated-analytical-proposal.js.map +1 -1
  76. package/dist/governed-relational-compiler.d.ts +15 -0
  77. package/dist/governed-relational-compiler.d.ts.map +1 -1
  78. package/dist/governed-relational-compiler.js +99 -1
  79. package/dist/governed-relational-compiler.js.map +1 -1
  80. package/dist/index.d.ts +9 -7
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +6 -4
  83. package/dist/index.js.map +1 -1
  84. package/dist/intent-controller.d.ts +10 -0
  85. package/dist/intent-controller.d.ts.map +1 -1
  86. package/dist/intent-controller.js +1 -1
  87. package/dist/intent-controller.js.map +1 -1
  88. package/dist/meaning-resolution.d.ts +78 -6
  89. package/dist/meaning-resolution.d.ts.map +1 -1
  90. package/dist/meaning-resolution.js +331 -38
  91. package/dist/meaning-resolution.js.map +1 -1
  92. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  93. package/dist/metadata/analysis-planner.js +21 -1
  94. package/dist/metadata/analysis-planner.js.map +1 -1
  95. package/dist/metadata/catalog.d.ts.map +1 -1
  96. package/dist/metadata/catalog.js +1 -1
  97. package/dist/metadata/catalog.js.map +1 -1
  98. package/dist/metadata/meaning-evidence.d.ts +9 -2
  99. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  100. package/dist/metadata/meaning-evidence.js +261 -11
  101. package/dist/metadata/meaning-evidence.js.map +1 -1
  102. package/dist/plan-execution-adapter.d.ts +9 -1
  103. package/dist/plan-execution-adapter.d.ts.map +1 -1
  104. package/dist/plan-execution-adapter.js +93 -13
  105. package/dist/plan-execution-adapter.js.map +1 -1
  106. package/dist/provider-egress.d.ts +24 -10
  107. package/dist/provider-egress.d.ts.map +1 -1
  108. package/dist/provider-egress.js +22 -17
  109. package/dist/provider-egress.js.map +1 -1
  110. package/dist/providers/claude.d.ts.map +1 -1
  111. package/dist/providers/claude.js +10 -9
  112. package/dist/providers/claude.js.map +1 -1
  113. package/dist/providers/dispatch.d.ts +27 -1
  114. package/dist/providers/dispatch.d.ts.map +1 -1
  115. package/dist/providers/dispatch.js +63 -1
  116. package/dist/providers/dispatch.js.map +1 -1
  117. package/dist/providers/gemini.js +10 -10
  118. package/dist/providers/gemini.js.map +1 -1
  119. package/dist/providers/index.d.ts +2 -2
  120. package/dist/providers/index.d.ts.map +1 -1
  121. package/dist/providers/index.js +1 -1
  122. package/dist/providers/index.js.map +1 -1
  123. package/dist/providers/ollama.js +15 -15
  124. package/dist/providers/ollama.js.map +1 -1
  125. package/dist/providers/openai.js +22 -22
  126. package/dist/providers/openai.js.map +1 -1
  127. package/dist/providers/types.d.ts +52 -0
  128. package/dist/providers/types.d.ts.map +1 -1
  129. package/dist/providers/types.js.map +1 -1
  130. package/dist/relationship-proof.d.ts +81 -0
  131. package/dist/relationship-proof.d.ts.map +1 -1
  132. package/dist/relationship-proof.js +228 -4
  133. package/dist/relationship-proof.js.map +1 -1
  134. package/dist/resolved-analytical-plan.d.ts +18 -4
  135. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  136. package/dist/resolved-analytical-plan.js +764 -47
  137. package/dist/resolved-analytical-plan.js.map +1 -1
  138. package/dist/router.d.ts +10 -1
  139. package/dist/router.d.ts.map +1 -1
  140. package/dist/router.js +1785 -213
  141. package/dist/router.js.map +1 -1
  142. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  143. package/dist/semantic-bridge/compose.js +23 -4
  144. package/dist/semantic-bridge/compose.js.map +1 -1
  145. package/package.json +4 -4
@@ -13,6 +13,133 @@
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
15
  export const ANALYTICAL_ORCHESTRATION_CONTRACT_VERSION = 1;
16
+ /** Build the immutable host request tuple before a meaning model can respond. */
17
+ export function buildAnalyticalRequirementSeedV1(input) {
18
+ // Retrieval/parser output is intentionally broad: it may contain useful
19
+ // context from a prior turn, vector hit, or search expansion. It is not an
20
+ // authority for a new free-text request. Keep only refinements that the
21
+ // source question itself demonstrates before they can contribute to the
22
+ // frozen host tuple. Structured clarification selections are merged by the
23
+ // router into `requirements` before this function is called.
24
+ const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
25
+ const requirements = input.requirements ?? buildAnalyticalRequirementSet({
26
+ question: input.question,
27
+ parsedIntent: parsed,
28
+ });
29
+ // Order and limit are lexical requirements, not parser defaults. In
30
+ // particular, a prior ranking must not turn a complete new question into a
31
+ // top-N query just because retrieval retained an old `limit` or `order`.
32
+ const order = requirements.ranking
33
+ ? requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'
34
+ : undefined;
35
+ const limit = requirements.ranking?.limit;
36
+ const filters = [...(parsed?.filters ?? [])].map((filter) => ({ field: filter.field, value: filter.value }));
37
+ if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
38
+ && !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
39
+ filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
40
+ }
41
+ return {
42
+ version: 1,
43
+ sourceQuestion: input.question,
44
+ requirements,
45
+ queryIntent: {
46
+ measures: [...requirements.measures],
47
+ // A ranking's entity display key is an execution requirement, not a
48
+ // prompt nicety. Keep it in the host-owned query tuple, but do not put
49
+ // the broad entity noun (for example `customer`) in the categorical
50
+ // dimension lane. A metric can legitimately expose customer type and
51
+ // customer order number as groupings; neither is interchangeable with
52
+ // the requested customer display/rank key. The frame resolves the
53
+ // display term against the selected metric's native display/rank role.
54
+ dimensions: [...new Set([
55
+ ...categoricalDimensionRequirementTerms(requirements),
56
+ ...requirements.entityDisplayTerms,
57
+ ])],
58
+ filters,
59
+ ...(parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
60
+ ...(requirements.time?.grain
61
+ ? { timeGrain: requirements.time.grain }
62
+ : parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
63
+ ...(order ? { order } : {}),
64
+ ...(limit !== undefined ? { limit } : {}),
65
+ // A calendar/date role is declared snapshot metadata, never an ID
66
+ // copied from retrieval/parser evidence.
67
+ ...(input.fiscalCalendar?.id ? { fiscalCalendarId: input.fiscalCalendar.id } : {}),
68
+ ...(input.fiscalCalendar?.dateRoleId ? { fiscalDateRoleId: input.fiscalCalendar.dateRoleId } : {}),
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Return only parser refinements whose business words occur in the current
74
+ * source question. This deliberately does not try to recover previous turn
75
+ * context: continuation is represented by a server-issued structured choice
76
+ * and merged separately by the host. The helper is exported for regression
77
+ * tests and for router paths that construct a requirement set before a seed.
78
+ */
79
+ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
80
+ if (!parsedIntent)
81
+ return undefined;
82
+ // These are intentionally tiny, product-wide vocabulary aliases rather
83
+ // than semantic guessing. They let a retrieval parser retain the same
84
+ // current-turn business phrase (`drink revenue` for `beverage revenue`, or
85
+ // `sales` for `revenue`) while still rejecting an unrelated prior-turn
86
+ // phrase such as `rollover balance`. Do not add customer/model-specific
87
+ // synonyms here: those require a qualified selected candidate and an
88
+ // override receipt at the meaning boundary.
89
+ const canonicalGroundingToken = (term) => {
90
+ if (term === 'sale' || term === 'sales')
91
+ return 'revenue';
92
+ if (term === 'drink' || term === 'drinks')
93
+ return 'beverage';
94
+ return term;
95
+ };
96
+ const questionTerms = new Set(normalizeRequirementTerm(question)
97
+ .split(' ')
98
+ .filter((term) => term.length > 1)
99
+ .map(canonicalGroundingToken));
100
+ const groundedTerm = (value) => {
101
+ const terms = normalizeRequirementTerm(value ?? '')
102
+ .split(' ')
103
+ .filter((term) => term.length > 1 && !/^(?:the|a|an|by|for|with|and|or|of|to|in|on|at)$/.test(term))
104
+ .map(canonicalGroundingToken);
105
+ return terms.length > 0 && terms.every((term) => {
106
+ if (questionTerms.has(term))
107
+ return true;
108
+ // Preserve only a trivial singular/plural normalization. Anything more
109
+ // permissive would let a stale retrieved phrase become a new request.
110
+ return term.endsWith('s')
111
+ ? questionTerms.has(term.slice(0, -1))
112
+ : questionTerms.has(`${term}s`);
113
+ });
114
+ };
115
+ const timeRangeGrounded = (value) => {
116
+ const normalized = normalizeRequirementTerm(value ?? '');
117
+ if (!normalized)
118
+ return false;
119
+ if (groundedTerm(normalized))
120
+ 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);
123
+ };
124
+ const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
125
+ const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
126
+ // A parser may contribute the column that a reader's explicitly named value
127
+ // belongs to, but the value itself must appear in this request. This drops
128
+ // stale rollover-balance/member filters while retaining a current named
129
+ // member that the host can bind to a qualified field.
130
+ const filters = (parsedIntent.filters ?? []).filter((filter) => groundedTerm(filter.value));
131
+ const timeRange = timeRangeGrounded(parsedIntent.timeRange)
132
+ ? parsedIntent.timeRange
133
+ : undefined;
134
+ return {
135
+ ...(measures.length > 0 ? { measures } : {}),
136
+ ...(dimensions.length > 0 ? { dimensions } : {}),
137
+ ...(filters.length > 0 ? { filters } : {}),
138
+ ...(timeRange ? { timeRange } : {}),
139
+ // Grain/ranking/limit are derived deterministically from the source
140
+ // question below. Never promote parser values by themselves.
141
+ };
142
+ }
16
143
  /**
17
144
  * Small, explainable intent vocabulary used before the physical plan exists.
18
145
  * It is deliberately not a second semantic matcher: candidate identity still
@@ -54,6 +181,42 @@ function uniqueRequirementTerms(values) {
54
181
  .map(normalizeRequirementTerm)
55
182
  .filter(Boolean))];
56
183
  }
184
+ /**
185
+ * Entity/display terms are represented in the historical `dimensions` seed
186
+ * so ranking plans can retain their requested grain. They must not consume
187
+ * the separate categorical-dimension admission lane. For example, in "top
188
+ * customers by product category", `customer` is the entity/rank role while
189
+ * `product category` is the required categorical grouping role.
190
+ */
191
+ export function categoricalDimensionRequirementTerms(requirements) {
192
+ const entityTerms = new Set(uniqueRequirementTerms([
193
+ ...requirements.entityTerms,
194
+ ...requirements.entityDisplayTerms,
195
+ ]));
196
+ return uniqueRequirementTerms(requirements.dimensions)
197
+ .filter((term) => !entityTerms.has(term));
198
+ }
199
+ /**
200
+ * A small, typed vocabulary bridge for categorical field identities. It is
201
+ * intentionally not a general synonym engine: only the field-kind suffix is
202
+ * canonicalized, while the scoped business noun must still match. Thus
203
+ * `product category` can bind the snapshot-declared `product_type`, whereas
204
+ * `customer_type` cannot satisfy it. A bare `category` remains potentially
205
+ * ambiguous when more than one qualified `*_type` field exists.
206
+ */
207
+ export function categoricalDimensionTermsMatch(left, right) {
208
+ const normalizedLeft = normalizeRequirementTerm(left);
209
+ const normalizedRight = normalizeRequirementTerm(right);
210
+ if (!normalizedLeft || !normalizedRight)
211
+ return false;
212
+ const phraseMatch = (a, b) => a === b || a.endsWith(` ${b}`) || b.endsWith(` ${a}`);
213
+ if (phraseMatch(normalizedLeft, normalizedRight))
214
+ return true;
215
+ const canonicalizeKind = (value) => value
216
+ .replace(/\bcategories\b/g, 'type')
217
+ .replace(/\bcategory\b/g, 'type');
218
+ return phraseMatch(canonicalizeKind(normalizedLeft), canonicalizeKind(normalizedRight));
219
+ }
57
220
  function isTemporalTerm(term) {
58
221
  return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
59
222
  }
@@ -112,6 +275,43 @@ function normalizedTypedAggregationRequirements(input) {
112
275
  ]);
113
276
  return { measures, dimensions };
114
277
  }
278
+ /**
279
+ * Keep grammatical wrappers out of the physical tuple. In particular, a
280
+ * parser can return `sales based on the region` as a dimension for a simple
281
+ * revenue-by-region request. That is neither a business dimension nor an
282
+ * object DQL may report as absent.
283
+ */
284
+ function normalizeAnalyticalDimensionTerms(question, values) {
285
+ const hasRegion = /\b(?:by|based\s+on(?:\s+the)?|across|per)\s+(?:the\s+)?region\b/i.test(question);
286
+ const hasProductCategory = /\bproduct\s+categor(?:y|ies)\b/i.test(question);
287
+ // A planner can surface the noun from a projected field as a grouping
288
+ // dimension (for example `product` from “with product ID and product
289
+ // price”). An output is not a `by product` group. Keep the noun only when
290
+ // the reader actually supplied a grouping construction; otherwise the
291
+ // host-owned row-level/output tuple would acquire a fake dimension and
292
+ // make an otherwise single-table exploratory plan ambiguous.
293
+ const outputRoots = new Set(explicitOutputTerms(question)
294
+ .map((term) => term.replace(/\s+(?:id|name|price)$/i, '').trim())
295
+ .filter(Boolean));
296
+ const isExplicitGroupingRoot = (term) => {
297
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
+ return new RegExp(`\\b(?:by|per|across|for\\s+each)\\s+(?:the\\s+)?${escaped}(?:s)?\\b`, 'i').test(question);
299
+ };
300
+ const normalized = uniqueRequirementTerms([...values])
301
+ .filter((value) => !/\b(?:revenue|sales)\b.*\b(?:based\s+on|by)\b.*\bregion\b/.test(value))
302
+ .filter((value) => !(hasProductCategory && /^(?:product|category)$/.test(value)))
303
+ .filter((value) => !outputRoots.has(value) || isExplicitGroupingRoot(value));
304
+ return uniqueRequirementTerms([
305
+ ...normalized,
306
+ ...(hasRegion ? ['region'] : []),
307
+ ...(hasProductCategory ? ['product category'] : []),
308
+ ]);
309
+ }
310
+ function explicitOutputTerms(question) {
311
+ const terms = [...question.matchAll(/\b(?:order|product|customer|account)\s+(?:id|name|price)\b/gi)]
312
+ .map((match) => match[0] ?? '');
313
+ return uniqueRequirementTerms(terms);
314
+ }
115
315
  /**
116
316
  * Normalize grammatical aggregation wrappers before they become a plan
117
317
  * requirement. Retrieval/parser output is allowed to retain useful search
@@ -127,7 +327,16 @@ function normalizedTypedAggregationRequirements(input) {
127
327
  export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
128
328
  const normalizedQuestion = normalizeRequirementTerm(question);
129
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
+ // A parser often singularizes the business alias `sales` into `sale`. That
331
+ // is not a second measure beside revenue: it is the same current-question
332
+ // request. Canonicalize only that standalone vocabulary alias here; named
333
+ // measures such as `sales_tax` or `sales_pipeline` keep their identity.
334
+ const canonicalMeasureAlias = (value) => {
335
+ const normalized = normalizeRequirementTerm(value);
336
+ return normalized === 'sale' || normalized === 'sales' ? 'revenue' : value;
337
+ };
130
338
  const terms = values
339
+ .map(canonicalMeasureAlias)
131
340
  .filter((value) => !isStructuralMeasurePhrase(value))
132
341
  .filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
133
342
  if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
@@ -172,6 +381,43 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
172
381
  .some((word) => word === token || (token === 'refund' && word === 'refunds')));
173
382
  });
174
383
  }
384
+ /**
385
+ * A ranking question can contain a business qualifier immediately before the
386
+ * ranked entity (for example, "BCM customers") as well as the measure that
387
+ * actually orders the result ("highest revenue"). The retrieval parser keeps
388
+ * both phrases because both are useful for recall, but they are not equivalent
389
+ * plan requirements. Prefer a direct ranking clause over the broad parser
390
+ * hint before any candidate is admitted as a metric.
391
+ *
392
+ * This deliberately remains narrow. It only disambiguates when the parser
393
+ * supplied competing measures and the user also wrote an explicit comparator;
394
+ * parser-absent and already-unambiguous ranking requests retain their existing
395
+ * normal meaning-resolution path.
396
+ */
397
+ function explicitRankingMeasureTerms(question, parsedMeasures) {
398
+ const parsed = uniqueRequirementTerms([...parsedMeasures]);
399
+ if (parsed.length < 2)
400
+ return [];
401
+ const phrases = [];
402
+ const endOfMeasure = String.raw `(?=\s+(?:across|among|for|per|in|where|during|over|with|that|which|who|and|or)\b|[?.!,;]|$)`;
403
+ for (const pattern of [
404
+ new RegExp(String.raw `\b(?:highest|lowest|most|least)\s+(?:the\s+)?([a-z][a-z0-9_. -]{0,80}?)${endOfMeasure}`, 'gi'),
405
+ ]) {
406
+ for (const match of question.matchAll(pattern)) {
407
+ const phrase = normalizeRequirementTerm(match[1] ?? '');
408
+ if (phrase && !isTemporalTerm(phrase))
409
+ phrases.push(phrase);
410
+ }
411
+ }
412
+ const direct = uniqueRequirementTerms(phrases);
413
+ if (direct.length === 0)
414
+ return [];
415
+ // Preserve the parser's more stable authored phrase when it is the same
416
+ // measure. This avoids replacing `net revenue` with a looser lexical root,
417
+ // while still removing an entity modifier such as `BCM` in `BCM customers`.
418
+ const matchedParsed = parsed.filter((measure) => direct.some((phrase) => measure === phrase || measure.includes(phrase) || phrase.includes(measure)));
419
+ return matchedParsed.length > 0 ? matchedParsed : direct;
420
+ }
175
421
  /**
176
422
  * Parse only stable analytical roles. This is purposefully narrower than an
177
423
  * LLM interpretation: unknown business phrases remain available to the normal
@@ -180,7 +426,9 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
180
426
  export function buildAnalyticalRequirementSet(input) {
181
427
  const question = input.question;
182
428
  const lower = question.toLowerCase();
183
- const parsed = input.parsedIntent;
429
+ // Parser/retrieval evidence may be broad or stale. A requirement set is
430
+ // host authority, so only source-question-grounded refinements may enter it.
431
+ const parsed = currentQuestionGroundedParsedIntent(question, input.parsedIntent);
184
432
  const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
185
433
  const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
186
434
  const grain = grainWord === 'daily' ? 'day'
@@ -192,12 +440,18 @@ export function buildAnalyticalRequirementSet(input) {
192
440
  : undefined;
193
441
  const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
194
442
  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 ?? []);
443
+ const ranking = lower.match(/\b(top|bottom|highest|lowest|most|least|expensive|cheapest)\s*(\d+)?\b/i);
444
+ const leadingOrdinalRanking = lower.match(/\b(\d+)\s+(?:most|least|expensive|cheapest)\b/i);
445
+ const requestedDimensions = normalizeAnalyticalDimensionTerms(question, parsed?.dimensions ?? []);
197
446
  const entityTerms = uniqueRequirementTerms([
198
447
  ...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
199
448
  ]).map((term) => term.replace(/s$/, ''));
200
- const entityDisplayTerms = /\b(?:who|which)\b/i.test(question)
449
+ // A ranking result needs a human-readable entity output even when the
450
+ // wording starts with "what" rather than "who" or "which". Treat the
451
+ // entity's display key as a required role for `top accounts` / `top
452
+ // customers`; an entity key, owner field, or sentiment attribute is not a
453
+ // substitute for the result label.
454
+ const entityDisplayTerms = (/\b(?:who|which)\b/i.test(question) || Boolean(ranking && entityTerms.length > 0))
201
455
  ? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
202
456
  : [];
203
457
  // "this amount" is a deictic reference to a prior result, not a request to
@@ -206,10 +460,19 @@ export function buildAnalyticalRequirementSet(input) {
206
460
  // option. Concrete metric words remain typed requirements, including the
207
461
  // common revenue/refunds pair used by multi-metric requests.
208
462
  const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
209
- const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? []);
463
+ // `sales` is a common business synonym for revenue. Do not add the broad
464
+ // revenue root merely because a named metric contains it (`beverage revenue`)
465
+ // or an exact certified block would suddenly look multi-metric.
466
+ const salesIsRevenueAlias = /\bsales\b/i.test(question);
467
+ const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
468
+ .filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
210
469
  const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
211
470
  ...parsedMeasures,
212
471
  ...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
472
+ ...(salesIsRevenueAlias ? ['revenue'] : []),
473
+ ...(/\b(?:most|highest|expensive)\b.*\bproduct\s+price\b|\bproduct\s+price\b.*\b(?:most|highest|expensive)\b/i.test(question)
474
+ ? ['product price']
475
+ : []),
213
476
  ...(!deicticAmount
214
477
  && /\bamount\b/i.test(question)
215
478
  && !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
@@ -221,13 +484,31 @@ export function buildAnalyticalRequirementSet(input) {
221
484
  measures: parsedMeasuresWithLexicalTerms,
222
485
  dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
223
486
  });
224
- const measures = typedRequirements.measures;
487
+ const explicitRankingMeasures = ranking
488
+ ? explicitRankingMeasureTerms(question, typedRequirements.measures)
489
+ : [];
490
+ // A direct ranking clause is the explicit analytical measure. Keep broad
491
+ // parser/retrieval phrases out of the execution tuple so a contextual term
492
+ // cannot become a second ranking metric or force a false clarification.
493
+ const measures = explicitRankingMeasures.length > 0
494
+ ? explicitRankingMeasures
495
+ : typedRequirements.measures;
225
496
  const dimensions = typedRequirements.dimensions;
226
497
  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;
498
+ // A context planner supplies its own safety default (`topN: 10`) for bare
499
+ // rankings. It is a useful execution bound, but it is not user intent. Read
500
+ // an explicit count only from the actual question so the cascade/answer
501
+ // receipt can disclose that a bare “top” used DQL's default rather than
502
+ // misleadingly presenting it as a requested limit.
503
+ const wordRankingLimit = lower.match(/\b(?:top|bottom|highest|lowest|most|least|expensive|cheapest)\s+(one|two|three|four|five|six|seven|eight|nine|ten)\b|\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:most|least|expensive|cheapest)\b/i)?.slice(1).find(Boolean)?.toLowerCase();
504
+ const explicitWordLimit = {
505
+ one: 1, two: 2, three: 3, four: 4, five: 5,
506
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
507
+ };
508
+ const explicitLimit = ranking?.[2]
509
+ ? Number(ranking[2])
510
+ : leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
511
+ : wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
231
512
  const time = grain || fiscalPeriod
232
513
  ? {
233
514
  role: grain ? 'time_axis' : 'time_filter',
@@ -243,12 +524,14 @@ export function buildAnalyticalRequirementSet(input) {
243
524
  entityTerms,
244
525
  entityDisplayTerms,
245
526
  memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
527
+ ...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
528
+ ...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
246
529
  ...(ranking
247
530
  ? {
248
531
  ranking: {
249
532
  metricTerms: rankingMetricTerms,
250
533
  entityTerms,
251
- direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
534
+ direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
252
535
  limit: explicitLimit ?? 10,
253
536
  defaultedLimit: explicitLimit === undefined,
254
537
  },
@@ -257,34 +540,98 @@ export function buildAnalyticalRequirementSet(input) {
257
540
  ...(time ? { time } : {}),
258
541
  };
259
542
  }
260
- /** Classify the role an already-qualified candidate may fill. */
261
- export function evidenceCandidateRoles(candidate) {
262
- const identity = uniqueRequirementTerms([
543
+ const DECLARED_CANDIDATE_ROLE_ALIASES = [
544
+ ['metric', ['metric']],
545
+ ['entity_key', ['entity key', 'entity id']],
546
+ ['entity_label', ['entity label', 'display key', 'display label']],
547
+ ['categorical_dimension', ['categorical dimension', 'category dimension']],
548
+ ['time_dimension', ['time dimension', 'date dimension']],
549
+ ['member', ['member']],
550
+ ['relationship', ['relationship']],
551
+ ['context', ['context']],
552
+ ];
553
+ /**
554
+ * Only an authored declaration may let an object fill an additional role. A
555
+ * metric's capability lists dimensions, time grains, and relationship paths it
556
+ * *uses*; that does not make the metric itself a display key, time column, or
557
+ * relationship candidate. Compatibility facts are snapshot-authored metadata
558
+ * and therefore the only additive role declaration accepted here.
559
+ */
560
+ function explicitlyDeclaredCandidateRoles(candidate) {
561
+ const declared = new Set();
562
+ for (const fact of candidate.compatibilityFacts ?? []) {
563
+ const normalized = normalizeRequirementTerm(fact);
564
+ if (!/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\b/.test(normalized))
565
+ continue;
566
+ const suffix = normalized.replace(/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\s*/, '');
567
+ for (const [role, aliases] of DECLARED_CANDIDATE_ROLE_ALIASES) {
568
+ if (aliases.some((alias) => new RegExp(`(?:^| )${alias}(?:$| )`).test(suffix)))
569
+ declared.add(role);
570
+ }
571
+ }
572
+ return declared;
573
+ }
574
+ function intrinsicCandidateIdentity(candidate) {
575
+ return uniqueRequirementTerms([
263
576
  candidate.id,
264
577
  candidate.qualifiedId,
265
578
  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
579
  ]).join(' ');
580
+ }
581
+ /**
582
+ * Keep account display-key selection separate from common account attributes.
583
+ * Candidate names originate in dbt/semantic identifiers, so underscores and
584
+ * dots must be normalized before testing (`account_sentiment_rating` is just
585
+ * as much an attribute as "Account Sentiment Rating").
586
+ */
587
+ export function hasEntityAttributeTerm(value) {
588
+ return /\b(?:owner|sentiment|email)\b/i.test(normalizeRequirementTerm(value));
589
+ }
590
+ export function isEntityAttributeCandidate(candidate) {
591
+ return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
592
+ }
593
+ /** Classify the role an already-qualified candidate may fill. */
594
+ export function evidenceCandidateRoles(candidate) {
595
+ const identity = intrinsicCandidateIdentity(candidate);
271
596
  const roles = new Set();
272
- if (candidate.kind === 'semantic_metric' || candidate.semanticObjectType === 'metric' || /\bmetric\b/.test(identity))
597
+ const physicalColumn = candidate.kind === 'sql_column';
598
+ const metricCandidate = candidate.kind === 'semantic_metric'
599
+ || 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));
603
+ if (metricCandidate)
273
604
  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');
605
+ // Capability metadata belongs to the metric's execution contract. It must
606
+ // not be treated as the metric object's own entity, display, time, or join
607
+ // identity. Explicit snapshot metadata is the only exception.
608
+ if (!metricCandidate) {
609
+ if (candidate.semanticObjectType === 'entity'
610
+ || /(?:^| )(?:account|customer|client|company) (?:id|key)\b/.test(identity)
611
+ || /\bentity\b/.test(identity))
612
+ roles.add('entity_key');
613
+ // Entity identity (for example `semantic:entity:account`) proves an
614
+ // entity key/grain but not the field a person can read in a ranking
615
+ // result. Require an intrinsic display-name declaration instead of
616
+ // allowing every identifier that merely contains "account" or
617
+ // "customer" to fill the entity-label role. Explicit authored role
618
+ // facts below remain the only additive exception.
619
+ if (/\b(?:account|customer|client|company)\b/.test(identity)
620
+ && /\b(?:name|label|display)\b/.test(identity)
621
+ && !hasEntityAttributeTerm(identity))
622
+ 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)
625
+ roles.add('time_dimension');
626
+ if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
627
+ || /\b(?:relationship|join|bridge)\b/.test(identity))
628
+ 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)))
631
+ roles.add('categorical_dimension');
632
+ }
633
+ for (const role of explicitlyDeclaredCandidateRoles(candidate))
634
+ roles.add(role);
288
635
  if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
289
636
  roles.add('context');
290
637
  if (roles.size === 0)
@@ -303,9 +650,63 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
303
650
  ]).join(' ');
304
651
  if (terms.some((term) => identity.includes(term) || term.includes(identity)))
305
652
  return true;
653
+ if (options.categoricalDimension === true
654
+ && terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
655
+ return true;
306
656
  return options.categoricalDimension === true
307
657
  && candidateMatchesCategoricalDimensionRequirement(candidate, terms);
308
658
  }
659
+ /**
660
+ * A metric's attached dimensions, source model, and relationship path are
661
+ * execution context. They are deliberately useful for binding a complete
662
+ * plan, but must never make one metric match the name of another. In
663
+ * particular, `bcm_run_rate` from an `account_revenue` model is not the
664
+ * `revenue` ranking measure merely because its source-model identity contains
665
+ * that word.
666
+ */
667
+ function metricCandidateIdentityTerms(candidate) {
668
+ const terminalMetricIdentity = (value) => {
669
+ if (!value)
670
+ return undefined;
671
+ const namespaceLeaf = value.split(':').filter(Boolean).at(-1) ?? value;
672
+ const metricLeaf = namespaceLeaf.split(/[./]/).filter(Boolean).at(-1) ?? namespaceLeaf;
673
+ return normalizeRequirementTerm(metricLeaf);
674
+ };
675
+ return uniqueRequirementTerms([
676
+ // Metadata cards sometimes use their source-qualified identifier as the
677
+ // display label (for example `account_revenue.bcm_run_rate`). Treat that
678
+ // exactly like an ID: its terminal metric leaf is intrinsic identity and
679
+ // its model prefix is execution context. A human label such as `Total
680
+ // Revenue` has no namespace separator and is retained intact.
681
+ terminalMetricIdentity(candidate.name),
682
+ ...(candidate.aliases ?? []).map(terminalMetricIdentity),
683
+ terminalMetricIdentity(candidate.id),
684
+ terminalMetricIdentity(candidate.qualifiedId),
685
+ ]);
686
+ }
687
+ function metricCandidateMatchesTerms(candidate, terms) {
688
+ const identities = metricCandidateIdentityTerms(candidate);
689
+ return terms.some((term) => {
690
+ const normalizedTerm = normalizeRequirementTerm(term);
691
+ if (!normalizedTerm)
692
+ return false;
693
+ return identities.some((identity) => identity === normalizedTerm
694
+ || identity.endsWith(` ${normalizedTerm}`)
695
+ || normalizedTerm.endsWith(` ${identity}`));
696
+ });
697
+ }
698
+ /**
699
+ * A direct ranking measure is authoritative for metric admission. Other
700
+ * retrieved metrics remain visible in the lifecycle receipt, but they cannot
701
+ * become a second metric choice merely because they are correlated with the
702
+ * entity phrase in the question.
703
+ */
704
+ export function candidateConflictsWithExplicitRankingMeasure(candidate, requirements) {
705
+ const metricTerms = requirements.ranking?.metricTerms ?? [];
706
+ return metricTerms.length > 0
707
+ && evidenceCandidateRoles(candidate).includes('metric')
708
+ && !metricCandidateMatchesTerms(candidate, metricTerms);
709
+ }
309
710
  /**
310
711
  * A categorical dimension may satisfy a requested business role only through
311
712
  * its own snapshot-authored declaration. In particular, `location_name` is
@@ -315,6 +716,14 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
315
716
  * broad lexical geography expansion while retaining role-balanced recall.
316
717
  */
317
718
  export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
719
+ const extension = candidate.sameSnapshotRoleExtension;
720
+ if (extension?.role === 'categorical_dimension'
721
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
722
+ || extension.basis === 'exact_metricflow_grouping_dimension')) {
723
+ const requested = new Set(terms.map(normalizeRequirementTerm).filter(Boolean));
724
+ if (requested.has(normalizeRequirementTerm(extension.requestedTerm)))
725
+ return true;
726
+ }
318
727
  const facts = new Set((candidate.compatibilityFacts ?? [])
319
728
  .map(normalizeRequirementTerm)
320
729
  .filter(Boolean));
@@ -348,7 +757,9 @@ export function selectRoleBalancedMeaningCandidates(input) {
348
757
  || left.id.localeCompare(right.id));
349
758
  const selected = [];
350
759
  const add = (candidate) => {
351
- if (candidate && selected.length < max && !selected.some((item) => item.id === candidate.id))
760
+ if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
761
+ return;
762
+ if (selected.length < max && !selected.some((item) => item.id === candidate.id))
352
763
  selected.push(candidate);
353
764
  };
354
765
  const servesRequestedRole = (candidate) => {
@@ -368,9 +779,10 @@ export function selectRoleBalancedMeaningCandidates(input) {
368
779
  return true;
369
780
  if (roles.includes('time_dimension') && Boolean(input.requirements.time))
370
781
  return true;
782
+ const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
371
783
  if (roles.includes('categorical_dimension')
372
- && input.requirements.dimensions.length > 0
373
- && candidateMatchesTerms(candidate, input.requirements.dimensions, { categoricalDimension: true }))
784
+ && categoricalTerms.length > 0
785
+ && candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
374
786
  return true;
375
787
  if (roles.includes('relationship')
376
788
  && (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
@@ -389,7 +801,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
389
801
  ['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
390
802
  ['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
391
803
  ['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
392
- ['categorical_dimension', input.requirements.dimensions],
804
+ ['categorical_dimension', categoricalDimensionRequirementTerms(input.requirements)],
393
805
  ['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
394
806
  ];
395
807
  for (const [role, terms] of required) {
@@ -411,7 +823,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
411
823
  // role nor a useful categorical reservation unless the user explicitly
412
824
  // named that attribute. This runs during the pre-cap pin pass so noisy
413
825
  // same-kind cards cannot enter through the categorical role.
414
- const explicitlyRequestsAttribute = /\b(?:owner|sentiment|email)\b/i.test([
826
+ const explicitlyRequestsAttribute = hasEntityAttributeTerm([
415
827
  ...input.requirements.dimensions,
416
828
  ...input.requirements.entityTerms,
417
829
  ...input.requirements.entityDisplayTerms,
@@ -424,7 +836,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
424
836
  if (role === 'categorical_dimension'
425
837
  && hasRequestedEntityLabel
426
838
  && !explicitlyRequestsAttribute
427
- && /\b(?:owner|sentiment|email)\b/i.test(candidate.name ?? candidate.id))
839
+ && isEntityAttributeCandidate(candidate))
428
840
  continue;
429
841
  // For entity labels, role is more important than a lexical owner/email
430
842
  // hit. For all other roles, prefer an identity matching the requested
@@ -446,13 +858,13 @@ export function classifyProviderFailure(input) {
446
858
  const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
447
859
  const cause = /cancel/.test(text) ? 'cancelled'
448
860
  : /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
449
- : /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
861
+ : /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
450
862
  : /run.?deadline|time limit/.test(text) ? 'run_deadline'
451
863
  : /timeout|timed out/.test(text) ? 'provider_timeout'
452
864
  : /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
453
865
  : /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
454
866
  : /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
455
- : /502|503|504|gateway/.test(text) ? 'gateway'
867
+ : /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
456
868
  : /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
457
869
  : 'unknown';
458
870
  const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
@@ -461,7 +873,7 @@ export function classifyProviderFailure(input) {
461
873
  : cause === 'cancelled' ? 'none'
462
874
  : 'inspect_run';
463
875
  const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
464
- : /\b(?:502|503|504)\b/.test(text) ? '5xx'
876
+ : /\b5\d{2}\b/.test(text) ? '5xx'
465
877
  : undefined;
466
878
  return {
467
879
  version: 1,
@@ -476,6 +888,29 @@ export function classifyProviderFailure(input) {
476
888
  };
477
889
  }
478
890
  export function buildAnalyticalCascadeDecision(input) {
891
+ // A frozen tier is the end of the authoritative cascade. Later tiers are
892
+ // neither evaluated nor eligible as a fallback, even when the selected
893
+ // compiler, adapter, or execution target subsequently fails. Retaining
894
+ // pre-built later attempts in the receipt makes a truthful post-freeze
895
+ // failure look like a silent downgrade and invalidates portable replay.
896
+ // Normalize at the shared construction boundary so every router path and
897
+ // every emitted trace receives the same immutable attempt prefix.
898
+ const attempts = [];
899
+ for (const inputAttempt of input.attempts) {
900
+ const attempt = {
901
+ ...inputAttempt,
902
+ version: 1,
903
+ candidateIds: [...new Set(inputAttempt.candidateIds)].slice(0, 32),
904
+ // Decision-level `planFrozen` is the server-owned source of truth. A
905
+ // legacy caller may have stamped it only on the decision; keep the
906
+ // selected attempt coherent before enforcing the immutable prefix.
907
+ planFrozen: inputAttempt.planFrozen
908
+ || (input.planFrozen === true && input.selectedTier === inputAttempt.tier),
909
+ };
910
+ attempts.push(attempt);
911
+ if (attempt.planFrozen)
912
+ break;
913
+ }
479
914
  return {
480
915
  version: 1,
481
916
  ...input,
@@ -484,7 +919,15 @@ export function buildAnalyticalCascadeDecision(input) {
484
919
  version: 1,
485
920
  candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
486
921
  })),
487
- attempts: input.attempts.map((attempt) => ({ ...attempt, version: 1, candidateIds: [...new Set(attempt.candidateIds)].slice(0, 32) })),
922
+ attempts,
923
+ ...(input.terminalGap ? {
924
+ terminalGap: {
925
+ version: 1,
926
+ code: 'MISSING_RELATIONSHIP',
927
+ requirement: 'certified_relationship_or_allocation_proof',
928
+ witnessCandidateIds: [...new Set(input.terminalGap.witnessCandidateIds)].sort().slice(0, 32),
929
+ },
930
+ } : {}),
488
931
  };
489
932
  }
490
933
  /**