@rudra-js/core 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +306 -73
  2. package/dist/component-generator.d.ts +20 -0
  3. package/dist/component-generator.d.ts.map +1 -1
  4. package/dist/component-generator.js +29 -15
  5. package/dist/component-generator.js.map +1 -1
  6. package/dist/component-spec.d.ts +7 -7
  7. package/dist/component-spec.js +8 -8
  8. package/dist/component-spec.js.map +1 -1
  9. package/dist/fallback-component.d.ts +2 -1
  10. package/dist/fallback-component.d.ts.map +1 -1
  11. package/dist/fallback-component.js +2 -2
  12. package/dist/fallback-component.js.map +1 -1
  13. package/dist/fit-to-shopper.d.ts +1 -1
  14. package/dist/fit-to-shopper.d.ts.map +1 -1
  15. package/dist/fit-to-shopper.js +4 -2
  16. package/dist/fit-to-shopper.js.map +1 -1
  17. package/dist/index.d.ts +3 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/product-selection.d.ts +14 -1
  22. package/dist/product-selection.d.ts.map +1 -1
  23. package/dist/product-selection.js +10 -11
  24. package/dist/product-selection.js.map +1 -1
  25. package/dist/reconciliation.d.ts +7 -1
  26. package/dist/reconciliation.d.ts.map +1 -1
  27. package/dist/reconciliation.js +40 -16
  28. package/dist/reconciliation.js.map +1 -1
  29. package/dist/signal-digest.d.ts.map +1 -1
  30. package/dist/signal-digest.js +10 -7
  31. package/dist/signal-digest.js.map +1 -1
  32. package/dist/spec-cache.d.ts +6 -5
  33. package/dist/spec-cache.d.ts.map +1 -1
  34. package/dist/spec-cache.js +7 -0
  35. package/dist/spec-cache.js.map +1 -1
  36. package/dist/tracking-input.d.ts +5 -0
  37. package/dist/tracking-input.d.ts.map +1 -1
  38. package/dist/tracking-input.js +17 -2
  39. package/dist/tracking-input.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/component-generator.ts +54 -21
  42. package/src/component-spec.ts +8 -8
  43. package/src/fallback-component.ts +7 -3
  44. package/src/fit-to-shopper.ts +3 -1
  45. package/src/index.ts +2 -6
  46. package/src/product-selection.ts +29 -5
  47. package/src/reconciliation.ts +56 -12
  48. package/src/signal-digest.ts +10 -7
  49. package/src/spec-cache.ts +15 -5
  50. package/src/tracking-input.ts +17 -2
package/README.md CHANGED
@@ -9,42 +9,60 @@ component specification.
9
9
  npm install @rudra-js/core zod@^4
10
10
  ```
11
11
 
12
- `zod` is a peer dependency: the package's public API _is_ zod schemas, so your
13
- application and this package must resolve the same zod instance. **zod 4 is
14
- required** the schemas use zod 4 APIs, and installing into a zod 3 app fails
15
- with `ERESOLVE` rather than anything more helpful.
12
+ `zod` is a peer dependency. The public API of this package _is_ zod schemas, so
13
+ your app and the package have to resolve the same copy of zod. **zod 4.5 or later is
14
+ required**, which is what the peer range asks for. The schemas use zod 4 APIs,
15
+ and installing into a zod 3 app fails with `ERESOLVE`, which isn't the most
16
+ helpful error you'll ever read. The floor is 4.5 rather than 4.0 because 4.5
17
+ changed how a nullable field is written into the tool schema we send the model.
16
18
 
17
19
  ## Running without a model
18
20
 
19
21
  `createComponentGenerator` takes a `provider`. Leave it out, or pass `null`, and
20
- nothing calls a model and nothing is billed:
22
+ nothing calls a model and nothing gets billed:
21
23
 
22
24
  ```ts
23
25
  const generator = createComponentGenerator({ provider: null });
24
26
  const spec = await generator.generate(input);
25
27
  ```
26
28
 
27
- That is a supported configuration rather than a stub. It is the control arm of
28
- the benchmark, and the right setting for anyone who has not yet decided on a
29
- provider. `generate` returns a promise either way, so the shape of your code
30
- does not change when you add one.
29
+ This is a supported setting, not a stub. It's the control arm of the benchmark,
30
+ and it's the right one until you've settled on a provider. `generate` returns a
31
+ promise either way, so your code keeps its shape when you add one.
32
+
33
+ The deterministic component emits exactly one **grid** block, with a headline
34
+ from a fixed set of four, or no blocks at all when there's nothing left to show.
35
+ That happens when no candidate is in stock, and equally when every one of them
36
+ is ruled out for this shopper.
37
+ Every other block kind in the vocabulary (hero, carousel, banner, copy, bundle)
38
+ only ever comes from a model. So if you're wiring up bundles and none of them
39
+ appear, that's why. It isn't your catalog.
40
+
41
+ Want to render a spec you wrote yourself, still without a model? Pass
42
+ `createFixedSpecProvider(spec)` as the provider and it answers every request with
43
+ that spec. That's how our tests exercise the blocks the deterministic component
44
+ never emits.
31
45
 
32
- The deterministic component emits exactly one **grid** block — or nothing, when
33
- no candidate is in stock — with a headline from a fixed set of four. Every other
34
- block kind in the vocabulary (hero, carousel, banner, copy, bundle) only ever
35
- comes from a model. If you are wiring bundles and none appear, that is why, and
36
- not your catalog.
46
+ ## Options
37
47
 
38
- To render a spec you wrote yourself, without a model, pass
39
- `createFixedSpecProvider(spec)` as the provider. It answers every request with
40
- that spec, which is how the tests exercise blocks the deterministic component
41
- never emits.
48
+ Everything `createComponentGenerator` takes, and what you get if you leave it
49
+ out.
50
+
51
+ | Option | What it does | Default |
52
+ | ---------------- | ----------------------------------------------------------------------------------------------- | ------------------------- |
53
+ | `provider` | The model adapter. `null` runs without a model and bills nothing. | `null` |
54
+ | `cache` | Where generated specs live between requests. Pass `createNullSpecCache()` to keep none. | `createMemorySpecCache()` |
55
+ | `generation` | `'cohort'` shares one component between shoppers who look alike; `'per-shopper'` does each one. | `'cohort'` |
56
+ | `rank` | `'signals'` orders products by the shopper's signals; `'given'` keeps the order you sent. | `'signals'` |
57
+ | `modelTimeoutMs` | How long the model gets. Past that, we abort the request and render the deterministic one. | `1500` |
58
+ | `cacheTimeoutMs` | How long a cache read gets. Past that, the request generates as if the store had nothing. | `50` |
59
+ | `onEvent` | Called once per `generate` with a `GenerationEvent`. If your hook throws, we swallow it. | none |
42
60
 
43
61
  ## `tracking-input`
44
62
 
45
- The boundary between a host application and rudra-js. rudra-js collects,
46
- stores and aggregates nothing the host owns its tracking pipeline and hands
47
- the framework one JSON object per render.
63
+ The boundary between your application and rudra-js. We collect nothing, store
64
+ nothing, aggregate nothing. You own your tracking pipeline and hand us one JSON
65
+ object per render.
48
66
 
49
67
  ```ts
50
68
  import { parseTrackingInput } from '@rudra-js/core';
@@ -68,51 +86,55 @@ const input = parseTrackingInput({
68
86
  });
69
87
  ```
70
88
 
71
- `parseTrackingInput` throws a `ZodError`; `safeParseTrackingInput` returns a
72
- `TrackingInputResult` instead, so a host can inspect `result.error.issues`
73
- without importing zod itself.
89
+ `parseTrackingInput` throws a `ZodError`. If you'd rather not catch,
90
+ `safeParseTrackingInput` hands you a `TrackingInputResult` instead, so you can
91
+ read `result.error.issues` without importing zod yourself.
74
92
 
75
93
  ### Cold start is not an error
76
94
 
77
95
  A payload with no `signals` block is a first-time visitor, not a malformed
78
- request. Every category defaults to `[]`, so the host needs no special case.
96
+ request. Every category defaults to `[]`, so you don't need a special case for
97
+ it.
79
98
 
80
99
  ### What the host must supply
81
100
 
82
- `user.id`, `context.surface`, and at least one entry in `candidates`.
83
- `candidates` is the merchandising boundary: whatever the host leaves out cannot
84
- be recommended, which is what makes it impossible to surface a product that
85
- does not exist or is not merchandised for this shopper. SKUs must be unique.
101
+ `user.id`, `context.surface`, and at least one entry in `candidates`. SKUs must
102
+ be unique.
86
103
 
87
- `bundles` is optional: the sets the shop sells together, each with the shop's
88
- own price for the set, the currency that price is in, and, if you want one,
89
- your own name for it. Every product in a set must also be a candidate — that is
90
- what lets the same checks that pass a single product pass a whole set, and what
91
- lets the renderer look the members up in the catalog it already has. Ids must be
104
+ `candidates` is the merchandising boundary. Every SKU the model writes gets
105
+ looked up in that list, and one that isn't on it is dropped by reconciliation
106
+ before anything renders. A product you left out doesn't reach the page.
107
+
108
+ `bundles` is optional. These are the sets you sell together, each with your own
109
+ price for the set, the currency that price is in, and, if you want one, your own
110
+ name for it. Every product in a set has to be a candidate as well. That's what
111
+ lets the same checks that pass a single product pass a whole set, and what lets
112
+ the renderer look the members up in the catalog it already has. Ids must be
92
113
  unique, and one set must not name the same product twice.
93
114
 
94
- The model never picks a set and is never told a price. It only asks for a
95
- bundle block and writes the words around it; the framework picks which set when
96
- the page is served, from what the shopper has in their basket, has looked at,
97
- or is browsing now.
98
-
99
- Every word the model writes is read for claims: the headline, the subheadline,
100
- a hero, a banner, a block title, the copy block, the reason under a product,
101
- and the words around the set. Text you supplied is never read this way a
102
- product title, a category and a bundle `label` are your words, not the model's.
103
-
104
- The framework drops text that makes a claim it cannot check. It looks for
105
- money, a customer score, a delivery date and a count of what is left, and it
106
- leaves a specification alone even when the specification has a number in it.
107
- Spotting one is not a guarantee, the way checking a price against your catalog
108
- is. A field that cannot be empty — a headline, a banner's text — is emptied
109
- instead of nulled, so the block drops the way any block with no text drops, and
115
+ The model _never_ picks a set and is never told a price. All it does is ask for a
116
+ bundle block and write the words around it. We pick which set when the page is
117
+ served, going on what the shopper has in their basket, has looked at, or is
118
+ browsing right now.
119
+
120
+ Every word the model writes is read for claims: the headline, the subheadline, a
121
+ hero, a banner, a block title, the copy block, the reason under a product, and
122
+ the words around the set. Text you supplied is never read this way. A product
123
+ title, a category and a bundle `label` are your words, not the model's.
124
+
125
+ We drop text that makes a claim we can't check. The check looks for money, a
126
+ customer score, a delivery date and a count of what's left, and it leaves a
127
+ specification alone even when that specification has a number in it. Spotting one
128
+ isn't a guarantee, not the way checking a price against your catalog is.
129
+
130
+ Some fields can't be empty, like a headline or a banner's text. Those get emptied
131
+ instead of nulled, so the block drops the way any block with no text drops. And
110
132
  an emptied page headline makes the whole generation unusable.
111
133
 
112
- For the set the prompt also tells the model to write about the offer, not the
113
- products in it, and never to say the set saves money or by how much. Pass a
114
- `label` on the bundle to put your own words on the set: a label is text you
115
- wrote, not text the model wrote, and it renders ahead of the model's words.
134
+ For the set, the prompt also tells the model to write about the offer rather than
135
+ the products in it, and never to say the set saves money or by how much. Pass a
136
+ `label` on the bundle to put your own words on the set. A label is text you
137
+ wrote, not text the model wrote, so it renders ahead of the model's words.
116
138
 
117
139
  ### Defaults
118
140
 
@@ -131,34 +153,50 @@ wrote, not text the model wrote, and it renders ahead of the model's words.
131
153
  | `mostViewed[].views` | `1` |
132
154
  | `lastPurchased[].quantity` | `1` |
133
155
 
156
+ `context.locale` has to be a single language tag, such as `en-US`. One tag. Not a
157
+ list, and not an `Accept-Language` header.
158
+
134
159
  ### Cohorts
135
160
 
136
- By default one generated component is shared between shoppers who look alike,
137
- and each shopper's own products are filled in per request. A cohort is the
138
- shopper's segment, the surface and slot, the locale, the item count, whether
139
- they are a first-time visitor, and the category they lean towards. Everything
140
- that makes a person an individual who they are, what they liked, viewed or
141
- searched for is left out, which is what lets many page views reuse one call.
161
+ By default one generated component is shared between shoppers who look alike, and
162
+ each shopper's own products are filled in per request. A cohort is the shopper's
163
+ segment, the surface and slot, the locale, the item count, whether they're a
164
+ first-time visitor, and the category they lean towards. Everything that makes a
165
+ person an individual stays out of it: who they are, what they liked, viewed or
166
+ searched for. That's what lets many page views reuse one call.
142
167
 
143
- The candidate list is part of the cohort too, because the model is shown those
168
+ The candidate list is part of the cohort too, since the model is shown those
144
169
  products and writes about them. In most shops candidates come from the page, so
145
- everyone looking at it shares them. A shop that picks candidates per shopper
146
- gets smaller cohorts, which is the honest outcome: its prompt really is
170
+ everyone looking at it shares them. If you pick candidates per shopper you'll get
171
+ smaller cohorts. That's the honest outcome, because your prompt really is
147
172
  personal.
148
173
 
149
- Pass `generation: 'per-shopper'` to generate for the individual instead. Then
150
- the model chooses the products too, and every shopper pays for their own call.
174
+ Pass `generation: 'per-shopper'` to generate for the individual instead. The
175
+ model then chooses the products too, and every shopper pays for their own call.
151
176
 
152
177
  ```ts
153
178
  createComponentGenerator({ provider, generation: 'per-shopper' });
154
179
  ```
155
180
 
181
+ #### What you put in `segment`
182
+
183
+ `segment` is sent to the model exactly as you wrote it, in both modes, and the
184
+ contract takes any string up to 128 characters. Stick to plain merchandising
185
+ labels like `lapsed`, `high-value`, `trial`, `wholesale`. Keep out anything that
186
+ says something protected about a person: health, race, ethnic origin, religion or
187
+ belief, sex life or sexual orientation, politics, union membership, biometric or
188
+ genetic data.
189
+
190
+ The same goes for `recentSearches`, `context.searchQuery` and `interaction.type`
191
+ in per-shopper mode. Those three are shopper text and they're sent as written.
192
+ What a shopper types is theirs. What you label them with is your choice.
193
+
156
194
  ### Limits
157
195
 
158
- Every free-text field and every array is capped, because host strings end up
196
+ Every free-text field and every array is capped, because your strings end up
159
197
  inside a model prompt and a model is billed per token. The caps live in
160
- `FIELD_LIMITS` and are exported, so a host can validate against the same
161
- numbers rather than discovering them from a rejection.
198
+ `FIELD_LIMITS` and we export them, so you can validate against the same numbers
199
+ instead of finding them out from a rejection.
162
200
 
163
201
  | Limit | Value | Applies to |
164
202
  | -------------------- | ----- | ------------------------------------------------------------------- |
@@ -172,17 +210,212 @@ numbers rather than discovering them from a rejection.
172
210
  | `candidates` | 200 | `candidates` |
173
211
  | `productsPerBundle` | 5 | `bundles[].skus`, which also needs at least 2 |
174
212
  | `bundles` | 20 | `bundles` |
213
+ | `localeTag` | 35 | `context.locale`, which also has to be one language tag |
214
+ | `maxItems` | 12 | `context.maxItems`, which also needs at least 1 |
215
+ | `reason` | 120 | `candidates[].reason`, your own phrase for a product |
175
216
 
176
- These bound each field individually; they are not an aggregate prompt budget.
217
+ These bound each field on its own. They aren't an aggregate prompt budget.
177
218
  Fitting a payload into a context window is `digest`'s job, and it trims rather
178
219
  than throws.
179
220
 
180
221
  ### Unknown fields are rejected
181
222
 
182
- Every fixed-shape object is a `strictObject`. A host that misspells
183
- `recentSearches` gets an error, not a shopper who silently looks like a
184
- first-time visitor. `interaction.meta` is the one dynamic shape an open
185
- record, minus the keys that would mutate a prototype instead of the object.
223
+ Every fixed-shape object is a `strictObject`. Misspell `recentSearches` and
224
+ you'll get an error, not a shopper who quietly looks like a first-time visitor.
225
+ `interaction.meta` is the one dynamic shape: an open record, minus the keys that
226
+ would mutate a prototype instead of the object.
227
+
228
+ ## Bringing your own ranking
229
+
230
+ By default we order the products for you, scoring each candidate against the
231
+ shopper's signals. But you might already have a recommender you trust: bought
232
+ together, an engine trained on your own orders, or a merchandiser's hand-picked
233
+ row. Pass `rank: 'given'` and the order you sent is the order that renders.
234
+
235
+ ```ts
236
+ const generator = createComponentGenerator({ provider, rank: 'given' });
237
+ ```
238
+
239
+ You keep the rest either way. We still drop anything the shopper shouldn't be
240
+ shown, whether it's out of stock, already bought, in the basket, disliked, or
241
+ the product they're looking at right now. Every product still carries a basis
242
+ we check against their real signals, and everything the model writes is still
243
+ screened.
244
+
245
+ Each candidate can carry its own `reason`, the phrase shown under the product.
246
+ Reach for it when your ranking knows something the signals don't:
247
+
248
+ ```ts
249
+ candidates: [
250
+ {
251
+ sku: 'A-2',
252
+ title: 'Enamel dutch oven',
253
+ category: 'Cookware',
254
+ price: 89,
255
+ reason: 'Bought together with your skillet',
256
+ },
257
+ ];
258
+ ```
259
+
260
+ A reason you supply is your own words, like the title, so it is rendered as
261
+ written and not screened. That only applies where this request actually used it,
262
+ which is the default `cohort` mode. In `per-shopper` mode the model writes the
263
+ reasons itself, so every one of them is screened, including one that happens to
264
+ read the same as yours.
265
+
266
+ Without a reason of your own, the basis is stated for you from the shopper's
267
+ signals.
268
+
269
+ ## What the model sees
270
+
271
+ The two generation modes send different things. Cohort is the default.
272
+
273
+ ### Cohort mode
274
+
275
+ - the surface and the slot
276
+ - the locale
277
+ - the segment, when you set one
278
+ - the category being browsed (`context.currentCategory`)
279
+ - the name of the category the shopper leans towards most. Just the name, the
280
+ score stays behind
281
+ - whether this shopper has no history at all
282
+ - how many products the component may place (`context.maxItems`)
283
+ - the candidate list: one line per product, with its SKU, title, category, rating
284
+ and tags
285
+
286
+ ### Per-shopper mode
287
+
288
+ Everything above, and:
289
+
290
+ - the SKU being looked at right now
291
+ - the current search
292
+ - whether this is a returning shopper
293
+ - liked SKUs, and disliked SKUs
294
+ - purchased SKUs, and what is in the basket
295
+ - the most-viewed SKUs, each with its view count
296
+ - recent searches
297
+ - every category they lean towards, strongest first
298
+ - the other kinds of interaction, each with a count
299
+
300
+ ### Left out of both
301
+
302
+ - `user.id`
303
+ - every timestamp (`at`), which we use to sort signals and then drop
304
+ - dwell time (`dwellMs`), added up in the digest and left out of the prompt
305
+ - every price, and every currency
306
+ - `imageUrl`
307
+ - `interaction.value` and `interaction.meta`. The model is told which kinds of
308
+ interaction happened and how often, and no more
309
+
310
+ The candidate list is trimmed on the way out. An out-of-stock product is dropped,
311
+ and at most 60 products go, in the order you supplied them.
312
+
313
+ `spec-cache.test.ts` walks every field of the digest and checks that each one is
314
+ either in the cohort key or scrubbed from the cohort prompt. A field the key
315
+ leaves out that still changes the prompt fails that test. Adding a field to the
316
+ digest fails it too, until someone says which side the field is on.
317
+
318
+ ## What the model decides, by mode
319
+
320
+ What the model wrote, and what we replace before the page is served. Cohort is
321
+ the default.
322
+
323
+ | Decision | Cohort, the default | Per-shopper |
324
+ | ---------------------------------- | --------------------------------------- | -------------------------------------- |
325
+ | Layout and block order | The model | The model |
326
+ | Headline, subheadline, copy | The model | The model |
327
+ | Emphasis per item | The model | The model |
328
+ | Badge text | Dropped, written for another product | The model |
329
+ | Which products, and in what order | Filled in per request, not by the model | The model, from your candidates |
330
+ | The reason and basis per product | Filled in per request, not by the model | The model, checked against the signals |
331
+ | Which bundle, of the ones you pass | Chosen per request, not by the model | Chosen per request, not by the model |
332
+
333
+ In cohort mode the grid and carousel items are filled in per request, best pick
334
+ first, so a component written for one shopper still fits the next.
335
+
336
+ The hero is the exception. It keeps the product the model named, because its
337
+ headline and body were written about that product, and swapping it would leave
338
+ copy about something else. When that product can't be placed, reconciliation
339
+ drops the link and keeps the words. It can't be placed if this
340
+ shopper can't see it (out of stock, not a candidate, disliked, already bought, in
341
+ the basket, or the one being looked at), an earlier block already placed it, or
342
+ the item budget ran out before the hero was reached.
343
+
344
+ ## Any provider
345
+
346
+ A provider is three things:
347
+
348
+ ```ts
349
+ export interface ComponentProvider {
350
+ /** Short identifier recorded on every generated spec, e.g. 'anthropic'. */
351
+ readonly name: string;
352
+ /** Concrete model identifier, e.g. 'claude-opus-5'. */
353
+ readonly model: string;
354
+ generate(request: ProviderRequest): Promise<ProviderResult>;
355
+ }
356
+ ```
357
+
358
+ `createComponentGenerator({ provider })` takes any object of that shape: a hosted
359
+ API, a model you run yourself, a deployment inside your own tenancy, or a
360
+ recorded fixture. `@rudra-js/core` depends on no vendor SDK.
361
+ `@rudra-js/anthropic` is one adapter, not a requirement, and `provider: null` is
362
+ the default that costs nothing.
363
+
364
+ An adapter takes its API key as an option, so you choose where the key comes
365
+ from. `ANTHROPIC_API_KEY` is just the name the example shop uses for its own
366
+ convenience. No package here reads the environment.
367
+
368
+ ## The cache
369
+
370
+ `cache` defaults to an in-process store. `createMemorySpecCache()` keeps an entry
371
+ for `ttlMs`, 60,000 milliseconds by default, so one minute. It holds up to
372
+ `maxEntries`, 10,000 by default. Once it's full, the entry read longest ago is
373
+ the first to go.
374
+
375
+ An entry holds the generated spec and `generatedAt`, the epoch milliseconds when
376
+ the model produced it. That's the whole of it. No payload, no shopper, no prompt.
377
+
378
+ The port is two methods, and an optional third:
379
+
380
+ ```ts
381
+ export interface SpecCache {
382
+ get(key: string): Promise<CachedSpec | undefined>;
383
+ set(key: string, cached: CachedSpec): Promise<void>;
384
+ delete?(key: string): Promise<void>;
385
+ }
386
+ ```
387
+
388
+ Pass your own store, whether that's Redis, Memcached or whatever you already run,
389
+ and it keeps entries on its own terms. Just keep in mind that what that store
390
+ holds, and for how long, is yours to declare to your users. This package doesn't
391
+ set it. Pass `createNullSpecCache()` to store nothing at all.
392
+
393
+ ### When a generation is wrong
394
+
395
+ Pass `provider: null` and nothing new is generated, so every page renders the
396
+ deterministic component. Shorten `ttlMs` and a bad entry ends sooner. A store
397
+ with `delete` can drop one entry by the `key` on its `GenerationEvent`, and the
398
+ next request generates again. Per-shopper entries only end by TTL, because
399
+ nothing maps a shopper to their keys.
400
+
401
+ ## Watching it in production
402
+
403
+ The generator never fails a render, so a provider that's been down for a week
404
+ only shows as plainer pages. The way to know is `onEvent`: every call to
405
+ `generate` that gets past input validation reports exactly one `GenerationEvent`.
406
+ A payload that fails `parseTrackingInput` throws instead, and reports nothing.
407
+
408
+ These are the numbers worth keeping:
409
+
410
+ - **Fallback share** — the share of events with `source: 'fallback'`. Alert when
411
+ it climbs. `degradedReason` tells you which way the call failed, and `error`
412
+ carries what was thrown when the reason is `'provider-error'` or `'timeout'`.
413
+ - **Cache hit rate** — `cache: 'hit'` over the events that have a `cache` field.
414
+ A store that's down shows as `cache: 'error'`, and a slow one as
415
+ `cache: 'timeout'`, rather than as a rising bill.
416
+ - **Spend** — sum `usage` over the events where `calledModel` is true. Requests
417
+ that joined an in-flight generation carry the same `usage`, so summing over
418
+ every event counts one call many times.
186
419
 
187
420
  ## Licence
188
421
 
@@ -1,5 +1,6 @@
1
1
  import { type ComponentSpec, type DegradedReason, type SpecSource } from './component-spec.js';
2
2
  import type { ComponentProvider, TokenUsage } from './provider.js';
3
+ import { type RankOrder } from './product-selection.js';
3
4
  import { type SpecCache } from './spec-cache.js';
4
5
  import { type TrackingInputDraft } from './tracking-input.js';
5
6
  /**
@@ -50,6 +51,8 @@ export interface GenerationEvent {
50
51
  violations?: string[];
51
52
  usage?: TokenUsage;
52
53
  degradedReason?: DegradedReason;
54
+ error?: unknown;
55
+ cache?: 'hit' | 'miss' | 'error' | 'timeout';
53
56
  }
54
57
  export interface ComponentGeneratorOptions {
55
58
  /**
@@ -71,7 +74,21 @@ export interface ComponentGeneratorOptions {
71
74
  * hold the page open, which is exactly what this module exists to prevent.
72
75
  */
73
76
  cacheTimeoutMs?: number;
77
+ /**
78
+ * 'cohort' shares one generated component between shoppers who look alike and
79
+ * fills in each shopper's own products. 'per-shopper' generates for the
80
+ * individual, which is what the benchmark compares against. Defaults to
81
+ * 'cohort'.
82
+ */
74
83
  generation?: 'cohort' | 'per-shopper';
84
+ /**
85
+ * How the products are ordered. 'signals' scores each candidate from this
86
+ * shopper's signals. 'given' keeps the order you sent, for a shop whose own
87
+ * ranking is better than four weights. Either way the exclusions and the
88
+ * stock check still apply, and each product still carries a basis
89
+ * reconciliation can verify. Defaults to 'signals'.
90
+ */
91
+ rank?: RankOrder;
75
92
  /** Observability. Never allowed to break a render. */
76
93
  onEvent?: (event: GenerationEvent) => void;
77
94
  }
@@ -80,5 +97,8 @@ export interface ComponentGenerator {
80
97
  /** The deterministic component, without consulting a model or a cache. */
81
98
  generateDeterministic(input: TrackingInputDraft): ComponentSpec;
82
99
  }
100
+ export declare class TimeoutError extends Error {
101
+ constructor(label: string, milliseconds: number);
102
+ }
83
103
  export declare function createComponentGenerator(options?: ComponentGeneratorOptions): ComponentGenerator;
84
104
  //# sourceMappingURL=component-generator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAUnE,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAIxB,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAiKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA+OpB"}
1
+ {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOnE,OAAO,EAAkB,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAG1F,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAED,qBAAa,YAAa,SAAQ,KAAK;IACrC,YAAY,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAG9C;CACF;AAgKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA6PpB"}
@@ -8,7 +8,7 @@ import { fitToShopper } from './fit-to-shopper.js';
8
8
  import { buildDigest, toCohortDigest } from './signal-digest.js';
9
9
  import { createMemorySpecCache, cohortCacheKey, specCacheKey, } from './spec-cache.js';
10
10
  import { parseTrackingInput, } from './tracking-input.js';
11
- class TimeoutError extends Error {
11
+ export class TimeoutError extends Error {
12
12
  constructor(label, milliseconds) {
13
13
  super(`${label} exceeded ${milliseconds}ms`);
14
14
  this.name = 'TimeoutError';
@@ -91,8 +91,8 @@ const cachedSpecSchema = z.object({
91
91
  * reaches the same set this did — it can only ever have more placed than the
92
92
  * pre-choice assumed, and never one of the set's own products.
93
93
  */
94
- function fitCohortSpec(spec, input, digest) {
95
- const picks = selectProducts(input, digest);
94
+ function fitCohortSpec(spec, input, digest, rank, hostReasonSkus) {
95
+ const picks = selectProducts(input, digest, { rank });
96
96
  // Blocks past the cap never render, so a set is not worth reserving for one.
97
97
  const blocks = spec.blocks.slice(0, MAX_BLOCKS);
98
98
  let hasBundleBlock = false;
@@ -105,25 +105,25 @@ function fitCohortSpec(spec, input, digest) {
105
105
  aboveBundle.push(block);
106
106
  }
107
107
  if (!hasBundleBlock)
108
- return fitToShopper(spec, picks, digest.maxItems);
108
+ return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
109
109
  // Only the heroes above the bundle block are placed when it is reached, so
110
110
  // they are all the choice may account for.
111
111
  const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
112
112
  if (!chosen)
113
- return fitToShopper(spec, picks, digest.maxItems);
113
+ return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
114
114
  const spokenFor = new Set(chosen.skus);
115
115
  for (const sku of placeableHeroSkus(blocks, input, digest))
116
116
  spokenFor.add(sku);
117
117
  const roomLeft = digest.maxItems - spokenFor.size;
118
118
  // A set is worth showing, but not at the cost of an empty grid.
119
119
  if (roomLeft <= 0)
120
- return fitToShopper(spec, picks, digest.maxItems);
120
+ return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
121
121
  const forGrid = [];
122
122
  for (const pick of picks) {
123
123
  if (!spokenFor.has(pick.product.sku))
124
124
  forGrid.push(pick);
125
125
  }
126
- return fitToShopper(spec, forGrid, roomLeft);
126
+ return fitToShopper(spec, forGrid, roomLeft, hostReasonSkus);
127
127
  }
128
128
  /** Attaches the provenance the server owns. The model never supplies any of it. */
129
129
  function withProvenance(spec, provenance) {
@@ -133,6 +133,7 @@ export function createComponentGenerator(options = {}) {
133
133
  const provider = options.provider ?? null;
134
134
  const cache = options.cache ?? createMemorySpecCache();
135
135
  const generation = options.generation ?? 'cohort';
136
+ const rank = options.rank ?? 'signals';
136
137
  const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
137
138
  const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
138
139
  const singleFlight = createSingleFlight();
@@ -164,7 +165,7 @@ export function createComponentGenerator(options = {}) {
164
165
  ...modelCall,
165
166
  degradedReason,
166
167
  });
167
- return withProvenance(buildFallbackSpec(input, digest), {
168
+ return withProvenance(buildFallbackSpec(input, digest, { rank }), {
168
169
  slot: digest.slot,
169
170
  source: 'fallback',
170
171
  generatedAt: finishedAt,
@@ -187,12 +188,12 @@ export function createComponentGenerator(options = {}) {
187
188
  try {
188
189
  const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
189
190
  const parsed = cachedSpecSchema.safeParse(stored);
190
- return parsed.success ? parsed.data : undefined;
191
+ return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
191
192
  }
192
- catch {
193
+ catch (error) {
193
194
  // A store that is down or slow degrades to generating, not to an error
194
195
  // page. Nothing here is worth failing a render over.
195
- return undefined;
196
+ return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
196
197
  }
197
198
  };
198
199
  /**
@@ -251,7 +252,8 @@ export function createComponentGenerator(options = {}) {
251
252
  const key = generation === 'cohort'
252
253
  ? cohortCacheKey(digest, input.candidates.map((product) => product.sku), providerId)
253
254
  : specCacheKey(digest, input.candidates.map((product) => product.sku), providerId);
254
- const cached = await readCache(key);
255
+ const read = await readCache(key);
256
+ const cached = read.entry;
255
257
  let calledModel = false;
256
258
  let answer;
257
259
  // When the model produced this, not when it was served. A cached
@@ -276,11 +278,16 @@ export function createComponentGenerator(options = {}) {
276
278
  // The request went out. Leaving `calledModel` to default here reported
277
279
  // every failed call as no call at all, so the calls that cost money
278
280
  // and produced nothing were the only ones missing from the count.
279
- return buildDeterministic(input, digest, startedAt, key, reason, { calledModel });
281
+ return buildDeterministic(input, digest, startedAt, key, reason, {
282
+ calledModel,
283
+ cache: read.outcome,
284
+ error,
285
+ });
280
286
  }
281
287
  if (!call.spec) {
282
288
  return buildDeterministic(input, digest, startedAt, key, 'invalid-generation', {
283
289
  calledModel,
290
+ cache: read.outcome,
284
291
  ...(call.usage ? { usage: call.usage } : {}),
285
292
  });
286
293
  }
@@ -298,11 +305,17 @@ export function createComponentGenerator(options = {}) {
298
305
  // One place where anything is served, whichever side of the cache it came
299
306
  // from, and always against the facts of the shopper asking now.
300
307
  // A cohort spec names products chosen for whoever asked first.
301
- const served = generation === 'cohort' ? fitCohortSpec(answer.spec, input, digest) : answer.spec;
302
- const reconciled = reconcileSpec(served, input, digest);
308
+ // Only the cohort path writes a host reason into a spec, so in
309
+ // per-shopper mode this stays empty and every reason is screened.
310
+ const hostReasonSkus = new Set();
311
+ const served = generation === 'cohort'
312
+ ? fitCohortSpec(answer.spec, input, digest, rank, hostReasonSkus)
313
+ : answer.spec;
314
+ const reconciled = reconcileSpec(served, input, digest, hostReasonSkus);
303
315
  if (!reconciled.isUsable) {
304
316
  return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
305
317
  calledModel,
318
+ cache: read.outcome,
306
319
  violations: reconciled.violations,
307
320
  ...(answer.usage ? { usage: answer.usage } : {}),
308
321
  });
@@ -314,6 +327,7 @@ export function createComponentGenerator(options = {}) {
314
327
  source,
315
328
  elapsedMs: finishedAt - startedAt,
316
329
  calledModel,
330
+ cache: read.outcome,
317
331
  violations: reconciled.violations,
318
332
  ...(answer.usage ? { usage: answer.usage } : {}),
319
333
  });