@rudra-js/core 0.2.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.
package/README.md CHANGED
@@ -9,55 +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.
31
-
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.
37
-
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
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
41
44
  never emits.
42
45
 
43
46
  ## Options
44
47
 
45
- Everything `createComponentGenerator` takes, and what it does without it.
48
+ Everything `createComponentGenerator` takes, and what you get if you leave it
49
+ out.
46
50
 
47
- | Option | What it does | Default |
48
- | ---------------- | ---------------------------------------------------------------------------------------------------- | ------------------------- |
49
- | `provider` | The model adapter. `null` runs without a model and bills nothing. | `null` |
50
- | `cache` | Where generated specs are kept between requests. Pass `createNullSpecCache()` to keep none. | `createMemorySpecCache()` |
51
- | `generation` | `'cohort'` shares one component between shoppers who look alike; `'per-shopper'` generates for each. | `'cohort'` |
52
- | `modelTimeoutMs` | How long the model gets. Past this the request is aborted and the deterministic component renders. | `1500` |
53
- | `cacheTimeoutMs` | How long a cache read gets. Past this the request generates as if the store had nothing. | `50` |
54
- | `onEvent` | Called once per `generate` with a `GenerationEvent`. A hook that throws is swallowed. | none |
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 |
55
60
 
56
61
  ## `tracking-input`
57
62
 
58
- The boundary between a host application and rudra-js. rudra-js collects,
59
- stores and aggregates nothing the host owns its tracking pipeline and hands
60
- 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.
61
66
 
62
67
  ```ts
63
68
  import { parseTrackingInput } from '@rudra-js/core';
@@ -81,52 +86,55 @@ const input = parseTrackingInput({
81
86
  });
82
87
  ```
83
88
 
84
- `parseTrackingInput` throws a `ZodError`; `safeParseTrackingInput` returns a
85
- `TrackingInputResult` instead, so a host can inspect `result.error.issues`
86
- 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.
87
92
 
88
93
  ### Cold start is not an error
89
94
 
90
95
  A payload with no `signals` block is a first-time visitor, not a malformed
91
- 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.
92
98
 
93
99
  ### What the host must supply
94
100
 
95
- `user.id`, `context.surface`, and at least one entry in `candidates`.
96
- `candidates` is the merchandising boundary. Every SKU the model writes is looked
97
- up in that list, and one that is not on it is dropped by reconciliation before
98
- anything renders — so a product you left out does not reach the page. SKUs must
101
+ `user.id`, `context.surface`, and at least one entry in `candidates`. SKUs must
99
102
  be unique.
100
103
 
101
- `bundles` is optional: the sets the shop sells together, each with the shop's
102
- own price for the set, the currency that price is in, and, if you want one,
103
- your own name for it. Every product in a set must also be a candidate — that is
104
- what lets the same checks that pass a single product pass a whole set, and what
105
- 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
106
113
  unique, and one set must not name the same product twice.
107
114
 
108
- The model never picks a set and is never told a price. It only asks for a
109
- bundle block and writes the words around it; the framework picks which set when
110
- the page is served, from what the shopper has in their basket, has looked at,
111
- or is browsing now.
112
-
113
- Every word the model writes is read for claims: the headline, the subheadline,
114
- a hero, a banner, a block title, the copy block, the reason under a product,
115
- and the words around the set. Text you supplied is never read this way a
116
- product title, a category and a bundle `label` are your words, not the model's.
117
-
118
- The framework drops text that makes a claim it cannot check. It looks for
119
- money, a customer score, a delivery date and a count of what is left, and it
120
- leaves a specification alone even when the specification has a number in it.
121
- Spotting one is not a guarantee, the way checking a price against your catalog
122
- is. A field that cannot be empty — a headline, a banner's text — is emptied
123
- 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
124
132
  an emptied page headline makes the whole generation unusable.
125
133
 
126
- For the set the prompt also tells the model to write about the offer, not the
127
- products in it, and never to say the set saves money or by how much. Pass a
128
- `label` on the bundle to put your own words on the set: a label is text you
129
- 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.
130
138
 
131
139
  ### Defaults
132
140
 
@@ -145,26 +153,26 @@ wrote, not text the model wrote, and it renders ahead of the model's words.
145
153
  | `mostViewed[].views` | `1` |
146
154
  | `lastPurchased[].quantity` | `1` |
147
155
 
148
- `context.locale` has to be a single language tag, such as `en-US`. One tag, not
149
- a list and not an `Accept-Language` header.
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.
150
158
 
151
159
  ### Cohorts
152
160
 
153
- By default one generated component is shared between shoppers who look alike,
154
- and each shopper's own products are filled in per request. A cohort is the
155
- shopper's segment, the surface and slot, the locale, the item count, whether
156
- they are a first-time visitor, and the category they lean towards. Everything
157
- that makes a person an individual who they are, what they liked, viewed or
158
- 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.
159
167
 
160
- 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
161
169
  products and writes about them. In most shops candidates come from the page, so
162
- everyone looking at it shares them. A shop that picks candidates per shopper
163
- 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
164
172
  personal.
165
173
 
166
- Pass `generation: 'per-shopper'` to generate for the individual instead. Then
167
- 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.
168
176
 
169
177
  ```ts
170
178
  createComponentGenerator({ provider, generation: 'per-shopper' });
@@ -173,23 +181,22 @@ createComponentGenerator({ provider, generation: 'per-shopper' });
173
181
  #### What you put in `segment`
174
182
 
175
183
  `segment` is sent to the model exactly as you wrote it, in both modes, and the
176
- contract takes any string up to 128 characters. Use plain merchandising labels —
177
- `lapsed`, `high-value`, `trial`, `wholesale`. Keep out anything that says
178
- something protected about a person: health, race, ethnic origin, religion or
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
179
187
  belief, sex life or sexual orientation, politics, union membership, biometric or
180
188
  genetic data.
181
189
 
182
- The same applies to `recentSearches`, `context.searchQuery` and
183
- `interaction.type` in per-shopper mode. Those three are shopper text, and they
184
- are sent as written. What a shopper types is theirs; what you label them with is
185
- your choice.
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.
186
193
 
187
194
  ### Limits
188
195
 
189
- 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
190
197
  inside a model prompt and a model is billed per token. The caps live in
191
- `FIELD_LIMITS` and are exported, so a host can validate against the same
192
- 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.
193
200
 
194
201
  | Limit | Value | Applies to |
195
202
  | -------------------- | ----- | ------------------------------------------------------------------- |
@@ -205,17 +212,59 @@ numbers rather than discovering them from a rejection.
205
212
  | `bundles` | 20 | `bundles` |
206
213
  | `localeTag` | 35 | `context.locale`, which also has to be one language tag |
207
214
  | `maxItems` | 12 | `context.maxItems`, which also needs at least 1 |
215
+ | `reason` | 120 | `candidates[].reason`, your own phrase for a product |
208
216
 
209
- 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.
210
218
  Fitting a payload into a context window is `digest`'s job, and it trims rather
211
219
  than throws.
212
220
 
213
221
  ### Unknown fields are rejected
214
222
 
215
- Every fixed-shape object is a `strictObject`. A host that misspells
216
- `recentSearches` gets an error, not a shopper who silently looks like a
217
- first-time visitor. `interaction.meta` is the one dynamic shape an open
218
- 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.
219
268
 
220
269
  ## What the model sees
221
270
 
@@ -227,12 +276,12 @@ The two generation modes send different things. Cohort is the default.
227
276
  - the locale
228
277
  - the segment, when you set one
229
278
  - the category being browsed (`context.currentCategory`)
230
- - the name of the category the shopper leans towards most the name only, the
279
+ - the name of the category the shopper leans towards most. Just the name, the
231
280
  score stays behind
232
281
  - whether this shopper has no history at all
233
282
  - how many products the component may place (`context.maxItems`)
234
- - the candidate list: one line per product, with its SKU, title, category,
235
- rating and tags
283
+ - the candidate list: one line per product, with its SKU, title, category, rating
284
+ and tags
236
285
 
237
286
  ### Per-shopper mode
238
287
 
@@ -251,24 +300,24 @@ Everything above, and:
251
300
  ### Left out of both
252
301
 
253
302
  - `user.id`
254
- - every timestamp (`at`) used to sort signals, then dropped
255
- - dwell time (`dwellMs`) added up in the digest, left out of the prompt
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
256
305
  - every price, and every currency
257
306
  - `imageUrl`
258
- - `interaction.value` and `interaction.meta` the model is told which kinds of
307
+ - `interaction.value` and `interaction.meta`. The model is told which kinds of
259
308
  interaction happened and how often, and no more
260
309
 
261
- The candidate list is trimmed on the way out: an out-of-stock product is dropped,
310
+ The candidate list is trimmed on the way out. An out-of-stock product is dropped,
262
311
  and at most 60 products go, in the order you supplied them.
263
312
 
264
- `spec-cache.test.ts` walks every field of the digest and checks each one is
313
+ `spec-cache.test.ts` walks every field of the digest and checks that each one is
265
314
  either in the cohort key or scrubbed from the cohort prompt. A field the key
266
315
  leaves out that still changes the prompt fails that test. Adding a field to the
267
316
  digest fails it too, until someone says which side the field is on.
268
317
 
269
318
  ## What the model decides, by mode
270
319
 
271
- What the model wrote and what is replaced before the page is served. Cohort is
320
+ What the model wrote, and what we replace before the page is served. Cohort is
272
321
  the default.
273
322
 
274
323
  | Decision | Cohort, the default | Per-shopper |
@@ -276,19 +325,21 @@ the default.
276
325
  | Layout and block order | The model | The model |
277
326
  | Headline, subheadline, copy | The model | The model |
278
327
  | Emphasis per item | The model | The model |
279
- | Badge text | Dropped written for another product | The model |
328
+ | Badge text | Dropped, written for another product | The model |
280
329
  | Which products, and in what order | Filled in per request, not by the model | The model, from your candidates |
281
330
  | The reason and basis per product | Filled in per request, not by the model | The model, checked against the signals |
282
331
  | Which bundle, of the ones you pass | Chosen per request, not by the model | Chosen per request, not by the model |
283
332
 
284
333
  In cohort mode the grid and carousel items are filled in per request, best pick
285
- first, so a component written for one shopper still fits the next. The hero is
286
- the exception: it keeps the product the model named, because its headline and
287
- body were written about that product and swapping it would leave copy about
288
- something else. Reconciliation drops the link and keeps the words if the product cannot be
289
- placed: this shopper cannot see it out of stock, not a candidate, disliked,
290
- already bought, in the basket, or the one being looked at or an earlier block
291
- already placed it, or the item budget ran out before the hero was reached.
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.
292
343
 
293
344
  ## Any provider
294
345
 
@@ -304,26 +355,25 @@ export interface ComponentProvider {
304
355
  }
305
356
  ```
306
357
 
307
- `createComponentGenerator({ provider })` takes any object of that shape a
308
- hosted API, a model you run yourself, a deployment inside your own tenancy, or a
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
309
360
  recorded fixture. `@rudra-js/core` depends on no vendor SDK.
310
361
  `@rudra-js/anthropic` is one adapter, not a requirement, and `provider: null` is
311
362
  the default that costs nothing.
312
363
 
313
364
  An adapter takes its API key as an option, so you choose where the key comes
314
- from. `ANTHROPIC_API_KEY` is the name the example shop uses for its own
365
+ from. `ANTHROPIC_API_KEY` is just the name the example shop uses for its own
315
366
  convenience. No package here reads the environment.
316
367
 
317
368
  ## The cache
318
369
 
319
370
  `cache` defaults to an in-process store. `createMemorySpecCache()` keeps an entry
320
- for `ttlMs` 60,000 milliseconds by default, so one minute and holds up to
321
- `maxEntries`, 10,000 by default. Once it is full, the entry read longest ago goes
322
- first.
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.
323
374
 
324
375
  An entry holds the generated spec and `generatedAt`, the epoch milliseconds when
325
- the model produced it. That is the whole of it no payload, no shopper, no
326
- prompt.
376
+ the model produced it. That's the whole of it. No payload, no shopper, no prompt.
327
377
 
328
378
  The port is two methods, and an optional third:
329
379
 
@@ -335,36 +385,37 @@ export interface SpecCache {
335
385
  }
336
386
  ```
337
387
 
338
- Pass your own store Redis, Memcached, whatever you already run — and it keeps
339
- entries on its own terms. What that store holds, and for how long, is yours to
340
- declare to your users, because this package does not set it. Pass
341
- `createNullSpecCache()` to store nothing at all.
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.
342
392
 
343
393
  ### When a generation is wrong
344
394
 
345
- Pass `provider: null` and nothing new is generated; every page renders the
395
+ Pass `provider: null` and nothing new is generated, so every page renders the
346
396
  deterministic component. Shorten `ttlMs` and a bad entry ends sooner. A store
347
397
  with `delete` can drop one entry by the `key` on its `GenerationEvent`, and the
348
- next request generates again. Per-shopper entries end only by TTL, because
398
+ next request generates again. Per-shopper entries only end by TTL, because
349
399
  nothing maps a shopper to their keys.
350
400
 
351
401
  ## Watching it in production
352
402
 
353
- The generator never fails a render, so a provider that has been down for a
354
- week only shows as plainer pages. The way to know is `onEvent`: every call to
355
- `generate` that gets past input validation reports exactly one
356
- `GenerationEvent`, and these are the numbers to keep from it. A payload that
357
- fails `parseTrackingInput` throws instead, and reports nothing.
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:
358
409
 
359
- - **Fallback share** — the share of events with `source: 'fallback'`. Alert
360
- when it climbs. `degradedReason` says which way the call failed, and `error`
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`
361
412
  carries what was thrown when the reason is `'provider-error'` or `'timeout'`.
362
- - **Cache hit rate** — `cache: 'hit'` over the events that have a `cache`
363
- field. A store that is down now shows as `cache: 'error'`, and a slow one as
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
364
415
  `cache: 'timeout'`, rather than as a rising bill.
365
- - **Spend** — sum `usage` over the events where `calledModel` is true.
366
- Requests that joined an in-flight generation carry the same `usage`, so
367
- summing over every event counts one call many times.
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.
368
419
 
369
420
  ## Licence
370
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
  /**
@@ -80,6 +81,14 @@ export interface ComponentGeneratorOptions {
80
81
  * 'cohort'.
81
82
  */
82
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;
83
92
  /** Observability. Never allowed to break a render. */
84
93
  onEvent?: (event: GenerationEvent) => void;
85
94
  }
@@ -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;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,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;AA8JD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CAuPpB"}
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"}
@@ -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,
@@ -304,8 +305,13 @@ export function createComponentGenerator(options = {}) {
304
305
  // One place where anything is served, whichever side of the cache it came
305
306
  // from, and always against the facts of the shopper asking now.
306
307
  // A cohort spec names products chosen for whoever asked first.
307
- const served = generation === 'cohort' ? fitCohortSpec(answer.spec, input, digest) : answer.spec;
308
- 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);
309
315
  if (!reconciled.isUsable) {
310
316
  return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
311
317
  calledModel,