@rudra-js/core 0.6.0 → 0.7.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 (66) hide show
  1. package/README.md +13 -12
  2. package/dist/claim-screening.d.ts +0 -23
  3. package/dist/claim-screening.d.ts.map +1 -1
  4. package/dist/claim-screening.js +0 -69
  5. package/dist/claim-screening.js.map +1 -1
  6. package/dist/component-generator.d.ts +0 -70
  7. package/dist/component-generator.d.ts.map +1 -1
  8. package/dist/component-generator.js +10 -52
  9. package/dist/component-generator.js.map +1 -1
  10. package/dist/component-spec.d.ts +2 -110
  11. package/dist/component-spec.d.ts.map +1 -1
  12. package/dist/component-spec.js +0 -78
  13. package/dist/component-spec.js.map +1 -1
  14. package/dist/fallback-component.d.ts +0 -9
  15. package/dist/fallback-component.d.ts.map +1 -1
  16. package/dist/fallback-component.js +0 -11
  17. package/dist/fallback-component.js.map +1 -1
  18. package/dist/fit-to-shopper.d.ts.map +1 -1
  19. package/dist/fit-to-shopper.js +1 -11
  20. package/dist/fit-to-shopper.js.map +1 -1
  21. package/dist/index.d.ts +0 -7
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -7
  24. package/dist/index.js.map +1 -1
  25. package/dist/model-prompt.d.ts +0 -29
  26. package/dist/model-prompt.d.ts.map +1 -1
  27. package/dist/model-prompt.js +0 -79
  28. package/dist/model-prompt.js.map +1 -1
  29. package/dist/product-selection.d.ts +0 -23
  30. package/dist/product-selection.d.ts.map +1 -1
  31. package/dist/product-selection.js +0 -19
  32. package/dist/product-selection.js.map +1 -1
  33. package/dist/provider.d.ts +0 -48
  34. package/dist/provider.d.ts.map +1 -1
  35. package/dist/provider.js +0 -5
  36. package/dist/provider.js.map +1 -1
  37. package/dist/reconciliation.d.ts +1 -44
  38. package/dist/reconciliation.d.ts.map +1 -1
  39. package/dist/reconciliation.js +2 -109
  40. package/dist/reconciliation.js.map +1 -1
  41. package/dist/signal-digest.d.ts +0 -27
  42. package/dist/signal-digest.d.ts.map +1 -1
  43. package/dist/signal-digest.js +1 -50
  44. package/dist/signal-digest.js.map +1 -1
  45. package/dist/spec-cache.d.ts +6 -80
  46. package/dist/spec-cache.d.ts.map +1 -1
  47. package/dist/spec-cache.js +1 -54
  48. package/dist/spec-cache.js.map +1 -1
  49. package/dist/tracking-input.d.ts +0 -49
  50. package/dist/tracking-input.d.ts.map +1 -1
  51. package/dist/tracking-input.js +0 -102
  52. package/dist/tracking-input.js.map +1 -1
  53. package/package.json +8 -7
  54. package/src/claim-screening.ts +17 -69
  55. package/src/component-generator.ts +24 -133
  56. package/src/component-spec.ts +15 -103
  57. package/src/fallback-component.ts +0 -11
  58. package/src/fit-to-shopper.ts +3 -12
  59. package/src/index.ts +0 -8
  60. package/src/model-prompt.ts +1 -82
  61. package/src/product-selection.ts +5 -43
  62. package/src/provider.ts +4 -53
  63. package/src/reconciliation.ts +12 -112
  64. package/src/signal-digest.ts +5 -64
  65. package/src/spec-cache.ts +8 -98
  66. package/src/tracking-input.ts +11 -108
@@ -3,26 +3,23 @@ import type { Product, TrackingInput } from './tracking-input.js';
3
3
 
4
4
  const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
5
5
  {
6
- // Customers scoring the product. "rated for winter use", "rated to -10C"
7
- // and "an IPX7 water rating" are about what the product can take.
8
6
  kind: 'rating',
9
7
  patterns: [
10
8
  /\breviews?\b|\breviewed\b/,
11
9
  /\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
12
10
  /\bstars?[\s-]?ratings?\b/,
13
- // A rating somebody gave it, rather than one it was built to.
11
+
14
12
  /\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
15
- // A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
13
+
16
14
  /\bratings?\s+of\s+[0-5]\.\d\b/,
17
- // The same score with the word "rating" nowhere near it: "4.8 out of 5".
15
+
18
16
  /\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
19
17
  /\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
20
- // "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
18
+
21
19
  /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
22
- // How many other people bought or liked it is a customer claim too.
20
+
23
21
  /\bbest[\s-]?sell(?:er|ers|ing)\b/,
24
- // The words a model reaches for when "best" is banned. Each branch carries its own
25
- // \b — one in front of the group demands a word character before the #, never there.
22
+
26
23
  /(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
27
24
  /\bloved by (?:thousands|hundreds|millions|\d)/,
28
25
  ],
@@ -30,32 +27,28 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
30
27
  {
31
28
  kind: 'price',
32
29
  patterns: [
33
- // No digit beside it: asking for one let "$thirty-nine and it is yours" through.
34
30
  /[$£€¥₹]/,
35
31
  /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
36
- // The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
32
+
37
33
  /\bdollars?\b|\beuros?\b/,
38
- // The same codes on the other side of the number: "USD 20", "EUR 5.99".
34
+
39
35
  /\b(?:usd|eur|gbp)\s?\d/,
40
- // The krona is spelled out rather than drawn, so it needs a number beside it.
36
+
41
37
  /\bkr\s?\d|\d\s?kr\b/,
42
38
  /\bpric(?:e|es|ed|ing)\b/,
43
- // A before-and-after is a price claim even with no currency on it.
39
+
44
40
  /\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
45
- // "does not feel cheap" is about quality. Only the comparison is money.
41
+
46
42
  /\bcheap(?:er|est)\b/,
47
43
  /\baffordable\b|\bbargain\b/,
48
- // "at no cost to comfort" is not money. "costs less" is.
44
+
49
45
  /\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
50
46
  /\blow(?:er)?[\s-]cost\b/,
51
- // "saves weight" and "saves on weight" are both about grams.
47
+
52
48
  /\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
53
49
  ],
54
50
  },
55
51
  {
56
- // A percentage in outdoor copy is a specification far more often than it is
57
- // money off, so it only counts beside a money word: "20% off", not
58
- // "100% recycled".
59
52
  kind: 'discount',
60
53
  patterns: [
61
54
  /\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
@@ -63,19 +56,15 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
63
56
  /\bdiscount(?:s|ed)?\b/,
64
57
  /\bsale\b|\bmarked down\b|\bdeal of\b/,
65
58
  /\bhalf[\s-]?(?:price|off)\b/,
66
- // Money off with no percent sign anywhere on it: "save 20 off".
59
+
67
60
  /\bsaves?\s+\d[\d.,]*\s+off\b/,
68
- // "extra clearance for thick socks" is room inside the shoe.
61
+
69
62
  /\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
70
- // "reduced weight" and "reduced to 900g" are specifications. Only a
71
- // reduced price, or a reduction with a date on it, is money off.
63
+
72
64
  /\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
73
65
  ],
74
66
  },
75
67
  {
76
- // When it turns up, not what turns up. "ships in a recycled box" and
77
- // "arrives flat-packed" describe the thing, so the verb alone is not enough
78
- // — it needs a day or a date beside it.
79
68
  kind: 'delivery',
80
69
  patterns: [
81
70
  /\bdelivery\b|\bshipping\b/,
@@ -91,14 +80,13 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
91
80
  /\b(?:in|out of|low on) stock\b/,
92
81
  /\blimited stock\b/,
93
82
  /\brestocked?\b|\bsold out\b/,
94
- // "the last few miles" is a distance, so a count needs "left" after it.
83
+
95
84
  /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
96
85
  /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
97
86
  ],
98
87
  },
99
88
  ];
100
89
 
101
- /** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
102
90
  const CONFUSABLES: Record<string, string> = {
103
91
  а: 'a',
104
92
  в: 'b',
@@ -131,24 +119,12 @@ const CONFUSABLES: Record<string, string> = {
131
119
  ϲ: 'c',
132
120
  };
133
121
 
134
- /**
135
- * Characters that render as nothing: format characters, default-ignorable code points and the
136
- * controls. Cf and Default_Ignorable each hold characters the other does not.
137
- * A second copy of the class in attested's `hidden.ts`, pinned to it by a test.
138
- */
139
122
  const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
140
123
 
141
- /** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
142
124
  const SPACING = /[\t\n\v\f\r\u0085]/;
143
125
 
144
- /**
145
- * Marks that hang on the character before them instead of taking a column of their own.
146
- * Dropped after composition, so an accented e keeps its accent while an overline,
147
- * which composes with nothing, cannot hide a word from its own rule.
148
- */
149
126
  const MARKS = /[\p{Mn}\p{Me}]/gu;
150
127
 
151
- /** Only the reading is normalised. What renders is what the model wrote. */
152
128
  function normaliseForClaims(text: string): string {
153
129
  const lowered = text
154
130
  .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
@@ -162,35 +138,20 @@ function normaliseForClaims(text: string): string {
162
138
  return folded;
163
139
  }
164
140
 
165
- /**
166
- * Wording attested bans that this project has already ruled is not a claim. An allowance
167
- * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
168
- * clearance for" — forgives whatever follows it. Exported so a test can hold the list
169
- * against attested's own.
170
- */
171
141
  export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
172
142
 
173
- /** Only a string with one of these in it can stand behind a numeral. */
174
143
  const DIGIT = /\p{Nd}/u;
175
144
 
176
- /** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
177
145
  const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
178
146
 
179
- /** Wide enough for every finite JavaScript number, and for anything a shop sells. */
180
147
  const MAX_EXPONENT = 1000;
181
148
 
182
- // `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
183
- // attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
184
149
  function canLayOut(fact: string): boolean {
185
150
  const match = BARE_EXPONENT.exec(fact.trim());
186
151
  if (match === null) return true;
187
152
  return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
188
153
  }
189
154
 
190
- /**
191
- * The numbers the shop published about one product: its category and tags, only the strings
192
- * carrying a digit. Exported so a test can pin what never reaches attested.
193
- */
194
155
  export function productFacts(product: Product): string[] {
195
156
  const facts: string[] = [];
196
157
  if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
@@ -205,19 +166,6 @@ export interface HostFacts {
205
166
  bySku: Map<string, string[]>;
206
167
  }
207
168
 
208
- /**
209
- * The shop's own numbers for this request, for attested to check numerals against.
210
- *
211
- * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
212
- * means a product the model never saw. A title and a rating it is shown but told never to
213
- * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
214
- * from the request rather than the catalogue, so a host passing a URL segment through would
215
- * hand this list to whoever types the URL.
216
- *
217
- * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
218
- * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
219
- * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
220
- */
221
169
  export function hostFacts(input: TrackingInput): HostFacts {
222
170
  const pooled = new Set<string>();
223
171
  const bySku = new Map<string, string[]>();
@@ -28,52 +28,14 @@ import {
28
28
  type TrackingInputDraft,
29
29
  } from './tracking-input.js';
30
30
 
31
- /**
32
- * Turns one tracking payload into one renderable component.
33
- *
34
- * Everything else in this package is a piece of that sentence; this module is
35
- * the order they go in. It is deliberately the only place that knows the whole
36
- * sequence, and it is written as a straight line so the sequence is readable:
37
- *
38
- * validate → digest → cache → generate → reconcile → render
39
- *
40
- * The single promise it makes to a caller is that `generate` always returns
41
- * something renderable. A model that is slow, refusing, erroring, rate-limited
42
- * or simply not configured produces the deterministic component instead. The
43
- * only way it rejects is a malformed payload, which is a caller bug and should
44
- * be loud.
45
- */
46
-
47
- /**
48
- * Reported exactly once per call to `generate`, whatever happened.
49
- *
50
- * One flat shape rather than a variant per outcome, because the numbers the
51
- * evaluation needs are ratios over all calls — hit rate, fallback share, model
52
- * calls and cost per thousand views. A variant that some callers do not emit
53
- * makes every one of those ratios wrong by however many it skipped, which is
54
- * what happened when requests that joined an in-flight generation reported
55
- * nothing at all.
56
- */
57
31
  export interface GenerationEvent {
58
- /** Null when no key was computed, which means no provider was configured. */
59
32
  key: string | null;
60
33
  source: SpecSource;
61
- /** Wall-clock milliseconds for the whole call. */
34
+
62
35
  elapsedMs: number;
63
- /**
64
- * True for the caller that sent the request, on every outcome — including a
65
- * call that timed out, errored or came back unparseable. Requests that joined
66
- * an in-flight generation share its answer and its usage figures, so cost
67
- * must be summed over this flag rather than over every event.
68
- *
69
- * It counts requests sent, which is an upper bound on requests billed: an
70
- * adapter that throws before it reaches the vendor looks the same from here
71
- * as one that throws after. An upper bound is the useful direction — the
72
- * calls that produce nothing are the ones worth seeing, and reporting them as
73
- * no call at all hides them completely.
74
- */
36
+
75
37
  calledModel: boolean;
76
- /** What reconciliation removed. Absent when no spec was reconciled. */
38
+
77
39
  violations?: string[];
78
40
  usage?: TokenUsage;
79
41
  degradedReason?: DegradedReason;
@@ -82,47 +44,24 @@ export interface GenerationEvent {
82
44
  }
83
45
 
84
46
  export interface ComponentGeneratorOptions {
85
- /**
86
- * Omit to run without a model. That is a supported configuration rather than
87
- * a stub: it is the control arm of the benchmark, and the right setting for
88
- * anyone who has not yet decided on a provider.
89
- */
90
47
  provider?: ComponentProvider | null;
91
- /** Defaults to an in-process cache. Pass `createNullSpecCache()` to disable. */
48
+
92
49
  cache?: SpecCache;
93
- /**
94
- * How long the model gets. Past this the deterministic component renders and
95
- * the request is aborted. Defaults to 1500ms.
96
- */
50
+
97
51
  modelTimeoutMs?: number;
98
- /**
99
- * How long the cache gets. The shipped caches cannot exceed it, but the store
100
- * is a port a host implements — a hung Redis read on the render path would
101
- * hold the page open, which is exactly what this module exists to prevent.
102
- */
52
+
103
53
  cacheTimeoutMs?: number;
104
- /**
105
- * 'cohort' shares one generated component between shoppers who look alike and
106
- * fills in each shopper's own products. 'per-shopper' generates for the
107
- * individual, which is what the benchmark compares against. Defaults to
108
- * 'cohort'.
109
- */
54
+
110
55
  generation?: 'cohort' | 'per-shopper';
111
- /**
112
- * How the products are ordered. 'signals' scores each candidate from this
113
- * shopper's signals. 'given' keeps the order you sent, for a shop whose own
114
- * ranking is better than four weights. Either way the exclusions and the
115
- * stock check still apply, and each product still carries a basis
116
- * reconciliation can verify. Defaults to 'signals'.
117
- */
56
+
118
57
  rank?: RankOrder;
119
- /** Observability. Never allowed to break a render. */
58
+
120
59
  onEvent?: (event: GenerationEvent) => void;
121
60
  }
122
61
 
123
62
  export interface ComponentGenerator {
124
63
  generate(input: TrackingInputDraft): Promise<ComponentSpec>;
125
- /** The deterministic component, without consulting a model or a cache. */
64
+
126
65
  generateDeterministic(input: TrackingInputDraft): ComponentSpec;
127
66
  }
128
67
 
@@ -133,12 +72,6 @@ export class TimeoutError extends Error {
133
72
  }
134
73
  }
135
74
 
136
- /**
137
- * Once the deadline has fired the caller is told so, whatever the race actually
138
- * settled with: a provider honouring its half of the contract rejects from
139
- * inside the `abort()` below, so its rejection reaches the race first and the
140
- * deadline's own never wins.
141
- */
142
75
  async function withinBudget<T>(
143
76
  label: string,
144
77
  milliseconds: number,
@@ -182,7 +115,6 @@ function createSingleFlight() {
182
115
  };
183
116
  }
184
117
 
185
- /** A cache entry is no more trustworthy than model output, so it is parsed too. */
186
118
  const cachedSpecSchema = z.object({
187
119
  spec: generatedSpecSchema,
188
120
  generatedAt: z.number(),
@@ -193,24 +125,11 @@ interface CacheRead {
193
125
  entry?: CachedSpec;
194
126
  }
195
127
 
196
- /** `spec` is null when the answer did not satisfy the schema. `usage` is carried either way: the call was still billed. */
197
128
  interface ModelCall {
198
129
  spec: GeneratedSpec | null;
199
130
  usage?: TokenUsage;
200
131
  }
201
132
 
202
- interface ModelAnswer extends ModelCall {
203
- spec: GeneratedSpec;
204
- }
205
-
206
- /**
207
- * The arithmetic has to hold whatever order the model put the blocks in, so it
208
- * is written as one sum over the whole spec rather than as a running budget:
209
- * the grid gets `maxItems` minus every distinct product the set and the heroes
210
- * will place. Reconciliation then reaches the same set this did — it can only
211
- * ever have more placed than the pre-choice assumed, and never one of the set's
212
- * own products.
213
- */
214
133
  function fitCohortSpec(
215
134
  spec: GeneratedSpec,
216
135
  input: TrackingInput,
@@ -231,8 +150,6 @@ function fitCohortSpec(
231
150
  }
232
151
  if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
233
152
 
234
- // Only the heroes above the bundle block are placed when it is reached, so
235
- // they are all the choice may account for.
236
153
  const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
237
154
  if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
238
155
 
@@ -240,7 +157,7 @@ function fitCohortSpec(
240
157
  for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
241
158
 
242
159
  const roomLeft = digest.maxItems - spokenFor.size;
243
- // A set is worth showing, but not at the cost of an empty grid.
160
+
244
161
  if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
245
162
 
246
163
  const forGrid: ProductPick[] = [];
@@ -251,13 +168,6 @@ function fitCohortSpec(
251
168
  return fitToShopper(spec, forGrid, roomLeft);
252
169
  }
253
170
 
254
- function withProvenance(
255
- spec: GeneratedSpec,
256
- provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
257
- ): ComponentSpec {
258
- return { ...spec, specVersion: SPEC_VERSION, ...provenance };
259
- }
260
-
261
171
  export function createComponentGenerator(
262
172
  options: ComponentGeneratorOptions = {},
263
173
  ): ComponentGenerator {
@@ -273,9 +183,7 @@ export function createComponentGenerator(
273
183
  if (!options.onEvent) return;
274
184
  try {
275
185
  options.onEvent(event);
276
- } catch {
277
- // A broken metrics hook must not take down a page.
278
- }
186
+ } catch {}
279
187
  };
280
188
 
281
189
  const buildDeterministic = (
@@ -297,7 +205,9 @@ export function createComponentGenerator(
297
205
  degradedReason,
298
206
  });
299
207
 
300
- return withProvenance(buildFallbackSpec(input, digest, { rank }), {
208
+ return {
209
+ ...buildFallbackSpec(input, digest, { rank }),
210
+ specVersion: SPEC_VERSION,
301
211
  slot: digest.slot,
302
212
  source: 'fallback',
303
213
  generatedAt: finishedAt,
@@ -305,7 +215,7 @@ export function createComponentGenerator(
305
215
  provider: null,
306
216
  model: null,
307
217
  degradedReason,
308
- });
218
+ };
309
219
  };
310
220
 
311
221
  const readCache = async (key: string): Promise<CacheRead> => {
@@ -314,26 +224,16 @@ export function createComponentGenerator(
314
224
  const parsed = cachedSpecSchema.safeParse(stored);
315
225
  return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
316
226
  } catch (error) {
317
- // A store that is down or slow degrades to generating, not to an error page.
318
227
  return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
319
228
  }
320
229
  };
321
230
 
322
- /** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
323
231
  const storeInBackground = (key: string, cached: CachedSpec): void => {
324
232
  void Promise.resolve()
325
233
  .then(() => cache.set(key, cached))
326
- .catch(() => {
327
- // A store that cannot be written is not a reason to fail a render.
328
- });
234
+ .catch(() => {});
329
235
  };
330
236
 
331
- /**
332
- * Deliberately does not decide whether the answer is usable. That depends on
333
- * the asking shopper's live facts — stock, dislikes, what is in their basket
334
- * — and none of those are in the cache key, so a verdict reached here would
335
- * be handed to every request that joined this one.
336
- */
337
237
  const askModel = async (
338
238
  active: ComponentProvider,
339
239
  input: TrackingInput,
@@ -345,7 +245,6 @@ export function createComponentGenerator(
345
245
  active.generate({ system, user, schema: generatedSpecSchema, signal }),
346
246
  );
347
247
 
348
- // Providers return parsed objects, but the shape is still model output.
349
248
  const parsed = generatedSpecSchema.safeParse(result.spec);
350
249
 
351
250
  return {
@@ -363,8 +262,7 @@ export function createComponentGenerator(
363
262
 
364
263
  async generate(draft) {
365
264
  const startedAt = Date.now();
366
- // Deliberately unguarded: an invalid payload is a caller bug, not a
367
- // degraded render.
265
+
368
266
  const input = parseTrackingInput(draft);
369
267
  const digest = buildDigest(input);
370
268
 
@@ -382,16 +280,14 @@ export function createComponentGenerator(
382
280
  const read = await readCache(key);
383
281
  const cached = read.entry;
384
282
  let calledModel = false;
385
- let answer: ModelAnswer;
386
- // When the model produced this, not when it was served.
283
+ let answer: { spec: GeneratedSpec; usage?: TokenUsage };
284
+
387
285
  let generatedAt: number;
388
286
 
389
287
  if (cached) {
390
288
  answer = { spec: cached.spec };
391
289
  generatedAt = cached.generatedAt;
392
290
  } else {
393
- // Asked before joining, because by the time the shared promise settles
394
- // the entry is gone and there is no way to tell a leader from a follower.
395
291
  calledModel = !singleFlight.isRunning(key);
396
292
 
397
293
  let call: ModelCall;
@@ -419,16 +315,9 @@ export function createComponentGenerator(
419
315
  answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
420
316
  generatedAt = Date.now();
421
317
 
422
- // Stored unreconciled on purpose, and stored even when it is unusable
423
- // for this shopper. Reconciliation narrows a spec to one shopper's live
424
- // facts, and those move independently of the key — a product can sell
425
- // out and come back without the candidate list changing.
426
318
  if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
427
319
  }
428
320
 
429
- // A cohort spec names products chosen for whoever asked first, and every
430
- // reason under one of them is written here rather than by the model. In
431
- // per-shopper mode this stays empty and every reason is screened.
432
321
  const { spec: served, ourReasons } =
433
322
  generation === 'cohort'
434
323
  ? fitCohortSpec(answer.spec, input, digest, rank)
@@ -456,14 +345,16 @@ export function createComponentGenerator(
456
345
  ...(answer.usage ? { usage: answer.usage } : {}),
457
346
  });
458
347
 
459
- return withProvenance(reconciled.spec, {
348
+ return {
349
+ ...reconciled.spec,
350
+ specVersion: SPEC_VERSION,
460
351
  slot: digest.slot,
461
352
  source,
462
353
  generatedAt,
463
354
  latencyMs: finishedAt - startedAt,
464
355
  provider: provider.name,
465
356
  model: provider.model,
466
- });
357
+ };
467
358
  },
468
359
  };
469
360
  }