@rudra-js/core 0.8.2 → 0.9.0

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 (2) hide show
  1. package/README.md +454 -371
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -2,17 +2,25 @@
2
2
 
3
3
  This package is part of [rudra-js](https://rudrajs.com).
4
4
 
5
- rudra-js is an open-source TypeScript library (MIT) you install from npm. A language model designs one product-recommendation block for a page in your shop, rudra-js checks it against your catalog, and React renders it.
5
+ rudra-js is an open-source TypeScript library (MIT) you install from npm. A
6
+ language model designs one product-recommendation block for a page in your shop,
7
+ rudra-js checks it against your catalog, and React renders it.
6
8
 
7
- It is not a hosted platform, a visual or low-code app builder, or a workspace you sign up for.
9
+ It is not a hosted platform, a visual or low-code app builder, or a workspace
10
+ you sign up for.
8
11
 
9
- You give this package one page view: the shopper and what they've been doing,
10
- the page they're on, and the products you're willing to show. It gives back a
11
- specification for the recommendations on that page, written by your model or
12
- built without one, and checked against those products. There's no React in it.
13
- `@rudra-js/react` turns the specification into markup.
12
+ You give this package one page view:
14
13
 
15
- Every export of this package is documented at [rudrajs.com](https://rudrajs.com/docs).
14
+ - the shopper and what they've been doing
15
+ - the page they're on
16
+ - the products you're willing to show
17
+
18
+ It gives back a spec: data that describes the recommendations on that page. Your
19
+ model writes it, or core builds it without one. Either way, it's checked against
20
+ those products. There's no React in this package. `@rudra-js/react` turns the
21
+ spec into markup.
22
+
23
+ The guides and the API reference are at [rudrajs.com](https://rudrajs.com).
16
24
 
17
25
  ## Install
18
26
 
@@ -20,35 +28,41 @@ Every export of this package is documented at [rudrajs.com](https://rudrajs.com/
20
28
  npm install @rudra-js/core@latest @rudra-js/attested@latest
21
29
  ```
22
30
 
23
- Run the same command in a new app, in one that already uses rudra-js, and to
24
- upgrade. If your app has `@rudra-js/react` or `@rudra-js/anthropic`, add them
25
- to the command with `@latest` too: each one's peer range on core covers only
26
- its own minor release. Leave one behind and npm only warns, but the next
27
- `npm install` or `npm ci` fails with `ERESOLVE`. If it has `@rudra-js/a2ui`, use
28
- the install line in that package's README instead, since it also names the two
29
- A2UI packages. For a bare name npm stays inside the range your `package.json`
30
- already has, so without `@latest` an app on an older core never leaves the minor
31
+ Run the same command in a new app, in an app that already uses rudra-js, and to
32
+ upgrade.
33
+
34
+ If your app also has `@rudra-js/react` or `@rudra-js/anthropic`, add them to the
35
+ command with `@latest`. Each one's peer range on core covers only its own minor
36
+ release. Leave one behind and npm only warns, but the next `npm install` or
37
+ `npm ci` fails with `ERESOLVE`.
38
+
39
+ If your app has `@rudra-js/a2ui`, use the install line in that package's README
40
+ instead. It also names the two A2UI packages.
41
+
42
+ Keep `@latest` on every name. For a bare name, npm stays inside the range your
43
+ `package.json` already has, so an app on an older core never leaves the minor
31
44
  release it's on.
32
45
 
33
- `zod` is a dependency, so you don't need it yourself. The public API of this
34
- package _is_ zod schemas, and an app on any zod 4 shares one copy with it. An
35
- app on zod 3 gets its own copy alongside ours, and the two don't mix: build on
36
- the exported schemas with their own methods (`productSchema.array()`) rather
37
- than your zod's (`z.array(productSchema)`), and read `error.issues` rather than
38
- checking `instanceof ZodError`. `@rudra-js/anthropic`'s own zod writes the tool
39
- schema we send the model, so its wording shifts a little between releases:
40
- before 4.5, a nullable field is written as `anyOf` rather than a type array. It
41
- means the same thing either way.
42
-
43
- `@rudra-js/attested` is a peer dependency, even though none of its types cross
44
- this package's public surface and you never have to import it. It's a peer so
45
- there is exactly one denylist in your tree. As a real dependency, an app that
46
- pins its own copy gets two — we checked, and npm 10.9.4 installs the app's
47
- version at the top and quietly nests ours under `@rudra-js/core` — and the two
48
- would then disagree about what counts as a claim. As a peer that same pin is an
49
- `ERESOLVE` you can see and fix. Pin nothing and the tree is identical either
50
- way: one copy, at the top. One tag publishes every `@rudra-js` package on npm
51
- at one version, so the ranges here always move together.
46
+ `zod` is a dependency, so you don't need to install it yourself. The public API
47
+ of this package _is_ zod schemas. An app on any zod 4 shares one copy of zod
48
+ with it.
49
+
50
+ An app on zod 3 gets its own copy next to ours, and the two don't mix. Build on
51
+ the exported schemas with their own methods (`productSchema.array()`), not with
52
+ your zod's (`z.array(productSchema)`). Read `error.issues` instead of checking
53
+ `instanceof ZodError`.
54
+
55
+ `@rudra-js/anthropic` uses its own zod to write the tool schema sent to the
56
+ model, so the schema's wording shifts a little between zod releases. Before zod
57
+ 4.5, a nullable field is written as `anyOf` instead of a type array. Both mean
58
+ the same thing.
59
+
60
+ `@rudra-js/attested` is a peer dependency. None of its types appear in this
61
+ package's public API, and you never have to import it. It's a peer so your app
62
+ has exactly one copy of its phrase list. If your app pins its own version, npm
63
+ stops with an `ERESOLVE` you can see and fix. If you pin nothing, npm installs
64
+ one copy at the top of the tree. One tag publishes every `@rudra-js` package to
65
+ npm at the same version, so these ranges always move together.
52
66
 
53
67
  ## Running without a model
54
68
 
@@ -60,44 +74,46 @@ const generator = createComponentGenerator({ provider: null });
60
74
  const spec = await generator.generate(input);
61
75
  ```
62
76
 
63
- This is a supported setting, not a stub. It's the control arm of the benchmark,
64
- and it's the right one until you've settled on a provider. `generate` returns a
65
- promise either way, so your code keeps its shape when you add one.
77
+ This is a supported setting. The benchmark compares the models against it. It's
78
+ the right setting until you've picked a provider. `generate` returns a promise
79
+ either way, so your code keeps its shape when you add one.
66
80
 
67
- The deterministic component emits exactly one **grid** block, with a headline
68
- from a fixed set of four, or no blocks at all when there's nothing left to show.
69
- That happens when no candidate is in stock, and equally when every one of them
70
- is ruled out for this shopper.
71
- Every other block kind in the vocabulary (hero, carousel, banner, copy, bundle)
72
- only ever comes from a model. So if you're wiring up bundles and none of them
73
- appear, that's why. It isn't your catalog.
81
+ Without a model, core builds the spec itself. The rest of this README calls that
82
+ spec the fallback. It has exactly one **grid** block, with a headline from a
83
+ fixed set of four. It has no blocks at all when there's nothing left to show.
84
+ That happens when no candidate is in stock, and also when every candidate is
85
+ ruled out for this shopper.
74
86
 
75
- Want to render a spec you wrote yourself, still without a model? Pass
76
- `createFixedSpecProvider(spec)` as the provider and it answers every request with
77
- that spec. It still goes through reconciliation, so it needs a headline that
78
- survives screening and at least one product on show, or you get the
79
- deterministic component.
87
+ The other block kinds (hero, carousel, banner, copy, bundle) only ever come from
88
+ a model. So if you're wiring up bundles and none of them appear, that's why. It
89
+ isn't your catalog.
90
+
91
+ To render a spec you wrote yourself, still without a model, pass
92
+ `createFixedSpecProvider(spec)` as the provider. It answers every request with
93
+ that spec. Your spec goes through the same checks as a model's, so it needs a
94
+ headline that passes the [claim check](#what-the-host-must-supply) and at least
95
+ one product on show. Otherwise you get the fallback.
80
96
 
81
97
  ## Options
82
98
 
83
99
  Everything `createComponentGenerator` takes, and what you get if you leave it
84
100
  out.
85
101
 
86
- | Option | What it does | Default |
87
- | ---------------- | ----------------------------------------------------------------------------------------------- | ------------------------- |
88
- | `provider` | The model adapter. `null` runs without a model and bills nothing. | `null` |
89
- | `cache` | Where generated specs live between requests. Pass `createNullSpecCache()` to keep none. | `createMemorySpecCache()` |
90
- | `generation` | `'cohort'` shares one component between shoppers who look alike; `'per-shopper'` does each one. | `'cohort'` |
91
- | `rank` | `'signals'` orders products by the shopper's signals; `'given'` keeps the order you sent. | `'signals'` |
92
- | `modelTimeoutMs` | How long the model gets. Past that, we abort the request and render the deterministic one. | `1500` |
93
- | `cacheTimeoutMs` | How long a cache read gets. Past that, the request generates as if the store had nothing. | `50` |
94
- | `onEvent` | Called once per `generate` with a `GenerationEvent`. If your hook throws, we swallow it. | none |
102
+ | Option | What it does | Default |
103
+ | ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------- |
104
+ | `provider` | The model adapter. `null` runs without a model and bills nothing. | `null` |
105
+ | `cache` | Where generated specs live between requests. Pass `createNullSpecCache()` to keep none. | `createMemorySpecCache()` |
106
+ | `generation` | `'cohort'` shares one spec between shoppers who look alike. `'per-shopper'` generates for each shopper. | `'cohort'` |
107
+ | `rank` | `'signals'` orders products by the shopper's signals. `'given'` keeps the order you sent. | `'signals'` |
108
+ | `modelTimeoutMs` | How long the model gets. After that, core aborts the request and renders the fallback. | `1500` |
109
+ | `cacheTimeoutMs` | How long a cache read gets. After that, the request generates as if the cache had nothing. | `50` |
110
+ | `onEvent` | Called once per `generate` with a `GenerationEvent`. If your function throws, core ignores the error. | none |
95
111
 
96
112
  ## `tracking-input`
97
113
 
98
- The boundary between your application and rudra-js. We collect nothing, store
99
- nothing, aggregate nothing. You own your tracking pipeline and hand us one JSON
100
- object per render.
114
+ This is the boundary between your app and rudra-js. You own your tracking
115
+ pipeline and pass one JSON object per render. rudra-js collects nothing and
116
+ stores nothing. It doesn't aggregate anything either.
101
117
 
102
118
  ```ts
103
119
  import { parseTrackingInput } from '@rudra-js/core';
@@ -122,118 +138,128 @@ const input = parseTrackingInput({
122
138
  ```
123
139
 
124
140
  `parseTrackingInput` throws a `ZodError`. If you'd rather not catch,
125
- `safeParseTrackingInput` hands you a `TrackingInputResult` instead, so you can
141
+ `safeParseTrackingInput` returns a `TrackingInputResult` instead. Then you can
126
142
  read `result.error.issues` without importing zod yourself.
127
143
 
128
144
  ### Cold start is not an error
129
145
 
130
- A payload with no `signals` block is a first-time visitor, not a malformed
131
- request. Every category defaults to `[]`, so you don't need a special case for
132
- it.
146
+ A payload with no `signals` block is a first-time visitor. Every signal list
147
+ defaults to `[]`, so you don't need a special case for it.
133
148
 
134
149
  ### What the host must supply
135
150
 
136
- `user.id`, `context.surface`, and at least one entry in `candidates`. SKUs must
137
- be unique.
138
-
139
- `candidates` is the merchandising boundary. Every SKU the model writes gets
140
- looked up in that list, and one that isn't on it is dropped by reconciliation
141
- before anything renders. A product you left out doesn't reach the page.
142
-
143
- `bundles` is optional. These are the sets you sell together, each with your own
144
- price for the set, the currency that price is in, and, if you want one, your own
145
- name for it. Every product in a set has to be a candidate as well. That's what
146
- lets the checks that pass a single product run over a whole set, and what lets
147
- the renderer look the members up in the catalog it already has. Ids must be
148
- unique, and one set must not name the same product twice.
149
-
150
- A set is placed whole, so one check runs differently: a set may hold something
151
- the shopper already bought or has in the basket, where a grid or a carousel
152
- would drop it. A thumbs-down on a member still blocks the whole set, and so does
153
- a member that is out of stock, already on the page, or the product being looked
154
- at right now.
155
-
156
- That last one has a cost. A "frequently bought together, including this item"
157
- set names the product whose page it sits on, so it is refused on exactly the
158
- page you wrote it for. Leave the item out and sell the companions as the set —
159
- that rejection goes away, and the rest of the checks still apply. And when a
160
- bundle block is the only block the model asked for, refusing the set leaves
161
- nothing to render, so the deterministic component takes over.
162
-
163
- The model _never_ picks a set and is never told a price. All it does is ask for a
164
- bundle block and write the words around it. We pick which set when the page is
165
- served, going on what the shopper has in their basket, has looked at, or is
166
- browsing right now.
167
-
168
- Every word the model writes is read for claims: the headline, the subheadline, a
169
- hero, a banner, a block title, the copy block, the reason under a product, and
170
- the words around the set. Text you supplied is never read this way. A product
171
- title, a category and a bundle `label` are your words, not the model's.
172
-
173
- We drop text that makes a claim we can't check, in three passes. The first looks
174
- for money, a customer score, a delivery date and a count of what's left. The
175
- second asks whether every numeral in the sentence is one you supplied. The third
176
- is `@rudra-js/attested`'s phrase list, for claims with no number in them to
177
- check — "top pick", "customer favourite".
178
-
179
- That second pass changed what happens to a specification. "a comfort rating of
180
- -5C" used to be kept on the strength of the words around the number. It is kept
181
- now only when a `5` turns up in a `tag` or a `category` name you sent us — the
182
- number, not the string, so a tag reading `5-pocket` keeps it as surely as one
183
- reading `-5C comfort`. Those are the strings we hand the model and let it repeat.
184
- A `title` and a `rating` we also show it, and the prompt tells it never to
185
- restate either, so neither stands behind a number. Put your spec sheet in `tags`
186
- and the model can quote it; leave it out and a number in that field is one the
187
- model made up, and it goes.
188
-
189
- Which strings, exactly:
190
-
191
- - Only candidates we showed the model. Out of stock, or past the 60 we send,
192
- means a product the model never saw, and its tags stand behind nothing.
193
- - The `currentCategory` on the request is not one of them. It's a string from
194
- this request rather than a row of your catalog, and plenty of sites pass a URL
195
- segment straight into it.
151
+ Send `user.id`, `context.surface` and at least one entry in `candidates`. SKUs
152
+ must be unique.
153
+
154
+ `candidates` sets which products can reach the page. Core looks up every SKU the
155
+ model writes in that list. A SKU that isn't on it is dropped before anything
156
+ renders.
157
+
158
+ `bundles` is optional. These are the sets you sell together. Each one has your
159
+ own price for the set and the currency that price is in. It can also have your
160
+ own name for the set, its `label`. Every product in a set has to be a candidate
161
+ as well. Bundle ids must be unique, and one set must not name the same product
162
+ twice.
163
+
164
+ A set is placed whole, so one check works differently. A set may hold something
165
+ the shopper already bought or has in the basket. A grid or a carousel would drop
166
+ that product. The whole set is still blocked when any member is:
167
+
168
+ - disliked by the shopper
169
+ - out of stock
170
+ - already on the page
171
+ - the product being looked at right now
172
+
173
+ That last rule has a cost. A "frequently bought together, including this item"
174
+ set names the product whose page it sits on, so it's refused on exactly the page
175
+ you wrote it for. Leave that item out and sell its companions as the set. That
176
+ rejection goes away, and the other checks still apply.
177
+
178
+ When a bundle block is the only block the model asked for, refusing the set
179
+ leaves nothing to render. Then the fallback takes over.
180
+
181
+ The model _never_ picks a set and is never told a price. All it does is ask for
182
+ a bundle block and write the words around it. Core picks which set when the page
183
+ is served. It bases the choice on what the shopper has in their basket, has
184
+ looked at, or is browsing right now.
185
+
186
+ The prompt tells the model to write about the offer, not the products in it. It
187
+ also tells the model never to say the set saves money, or by how much. Your
188
+ `label`, when you pass one, renders ahead of the model's words.
189
+
190
+ Core reads every word the model writes for claims:
191
+
192
+ - the headline and the subheadline
193
+ - a hero
194
+ - a banner
195
+ - a block title
196
+ - the copy block
197
+ - the reason under a product
198
+ - the words around the set
199
+
200
+ Text you supplied is never read this way. A product title, a category and a
201
+ bundle `label` are your words.
202
+
203
+ Core drops text that makes a claim it can't check. This README calls that the
204
+ claim check. It runs in three passes:
205
+
206
+ 1. The first looks for money, a customer score, a delivery date and a count of
207
+ what's left.
208
+ 2. The second asks whether every numeral in the sentence is one you supplied.
209
+ 3. The third is `@rudra-js/attested`'s phrase list. It catches claims with no
210
+ number in them to check, like "top pick" and "customer favourite".
211
+
212
+ For example, the second pass keeps "a comfort rating of -5C" only when a `5`
213
+ turns up in a `tag` or a `category` name you sent. The pass compares the number,
214
+ not the string, so a tag reading `5-pocket` keeps it as surely as one reading
215
+ `-5C comfort`.
216
+
217
+ Tags and category names count because they're the strings core hands the model
218
+ and lets it repeat. The model is also shown each product's `title` and `rating`.
219
+ Neither one backs a number, because the prompt tells it never to restate either.
220
+ Put your product's figures in `tags` and the model can quote them. Leave them
221
+ out, and any figure the model writes about them is made up, so it's dropped.
222
+
223
+ Which strings count, exactly:
224
+
225
+ - Only candidates the model was shown. A product that's out of stock, or past
226
+ the 60 core sends, is one the model never saw. Its tags back nothing.
227
+ - Not the `currentCategory` on the request. It's a string from this request, not
228
+ a row of your catalog, and plenty of sites pass a URL segment straight into
229
+ it.
196
230
  - A `reason` or a `badge` sits under a named product, so it's read against that
197
- product's own tags and category. Every other field reads all the candidates'
198
- pooled.
199
-
200
- Two caveats worth saying out loud, because they're the shape of the check rather
201
- than bugs in it. Pooled means pooled: one product's `40 litre` tag stands behind
202
- "take 40 off" written in a headline about another. And nothing in the pass knows
203
- which quantity a tag was about, so a tag holding a weight stands behind a price
204
- with the same digits in it. It proves the digits came from you. It doesn't prove
205
- the sentence is true.
206
-
207
- The first and third passes are word lists, so spotting a claim isn't a
208
- guarantee, not the way checking a price against your catalog is. The second is a
209
- proof of something narrower than it sounds: every digit was one of yours. A
210
- number written as a word isn't a number to it, so "four and a half stars" is not
211
- a rating it can see — while a ½ or the K in "10K" is a numeral it can't read,
212
- which it drops rather than waves through.
231
+ product's own tags and category. Every other field is read against all the
232
+ candidates' tags and categories, pooled.
233
+
234
+ Two things to know about the second pass. Pooled means pooled: one product's
235
+ `40 litre` tag backs "take 40 off" written in a headline about another. And
236
+ nothing in the pass knows which quantity a tag was about, so a tag holding a
237
+ weight backs a price with the same digits in it. The pass proves the digits came
238
+ from you. It doesn't prove the sentence is true.
239
+
240
+ The first and third passes are word lists. A word list can miss a claim, so it
241
+ isn't a guarantee the way checking a price against your catalog is. The second
242
+ pass proves something narrower than it sounds: every digit was one of yours. A
243
+ number written as a word isn't a number to it, so it can't see "four and a half
244
+ stars" as a rating. A ½, or the K in "10K", is a numeral it can't read. It drops
245
+ those instead of letting them through.
213
246
 
214
247
  The model isn't told any of this. The prompt bans prices, ratings, discounts,
215
- delivery dates and stock levels, and says nothing about digits, so it can't tell
216
- `3-season` — a tag you sent, and fine — from `2 litres`, which is not. Whatever
217
- the over-rejection rate is on your catalog, nothing in the prompt is steering it
218
- down yet.
219
-
220
- Some fields can't be empty, like a headline or a banner's text. Those get emptied
221
- instead of nulled, so the block drops the way any block with no text drops. And
222
- an emptied page headline makes the whole generation unusable.
223
-
224
- That last one is the expensive edge of the digit check, so it's worth being
225
- concrete: if the model writes "Our 3 favourites for wet weather" and no candidate
226
- carries a 3, the headline empties, the generation is unusable, and the
227
- deterministic component renders — a model call paid for and thrown away. In
228
- cohort mode the spec is cached before reconciliation, so every cache hit for the
229
- rest of the TTL runs the same screen and reaches the same fallback. The reason a
230
- `reason` under a card is exempt is the same idea from the other side: in cohort
231
- mode we wrote that sentence, so reading it back would only ever cost us.
232
-
233
- For the set, the prompt also tells the model to write about the offer rather than
234
- the products in it, and never to say the set saves money or by how much. Pass a
235
- `label` on the bundle to put your own words on the set. A label is text you
236
- wrote, not text the model wrote, so it renders ahead of the model's words.
248
+ delivery dates and stock levels. It says nothing about digits. So the model
249
+ can't tell `3-season`, a tag you sent and fine to use, from `2 litres`, which
250
+ isn't. However much good text the check drops on your catalog, nothing in the
251
+ prompt brings that down.
252
+
253
+ Some fields can't be null, like a headline or a banner's text. When the check
254
+ drops text from one of those, it empties the field instead. The block then
255
+ drops, as any block with no text does. An empty page headline makes the whole
256
+ generation unusable.
257
+
258
+ That's the costly case. Say the model writes "Our 3 favourites for wet weather"
259
+ and no candidate carries a 3. The headline empties, so the generation is
260
+ unusable and the fallback renders. You paid for a model call and threw it away.
261
+ In cohort mode the spec is cached before it's checked, so every cache hit runs
262
+ the same check and gets the same fallback until the entry expires.
237
263
 
238
264
  ### Defaults
239
265
 
@@ -252,52 +278,70 @@ wrote, not text the model wrote, so it renders ahead of the model's words.
252
278
  | `mostViewed[].views` | `1` |
253
279
  | `lastPurchased[].quantity` | `1` |
254
280
 
255
- `context.locale` has to be a single language tag, such as `en-US`. One tag. Not a
256
- list, and not an `Accept-Language` header.
281
+ `context.locale` has to be one language tag, such as `en-US`. Not a list, and
282
+ not an `Accept-Language` header.
257
283
 
258
284
  ### Cohorts
259
285
 
260
- By default one generated component is shared between shoppers who look alike,
261
- and each shopper's own products are filled in per request. A cohort is the
262
- shopper's segment, the surface and slot, the locale, the item count, whether
263
- they have any history, the category being browsed, and the category they lean
264
- towards. Who they are, what they searched for and the products they liked or
265
- viewed stay out of it. For the shared cohort we work out only two things from
266
- their own data: the category they lean towards and whether they have any
267
- history. That's what lets many page views reuse one call.
286
+ By default, shoppers who look alike share one generated spec. Each shopper's own
287
+ products are filled in per request. A group of look-alike shoppers is a cohort.
288
+ A cohort is set by:
289
+
290
+ - the shopper's segment
291
+ - the surface and the slot
292
+ - the locale
293
+ - the item count
294
+ - whether they have any history
295
+ - the category being browsed
296
+ - the category they lean towards
297
+
298
+ Only two of these come from the shopper's own data: whether they have any
299
+ history, and the category they lean towards. Who they are, what they searched
300
+ for and the products they liked or viewed stay out. That's what lets many page
301
+ views reuse one model call.
268
302
 
269
- The candidate list is part of the cohort too, since the model is shown those
303
+ The candidate list is part of the cohort too, because the model is shown those
270
304
  products and writes about them. In most shops candidates come from the page, so
271
- everyone looking at it shares them. If you pick candidates per shopper you'll get
272
- smaller cohorts. That's the honest outcome, because your prompt really is
273
- personal.
274
-
275
- What goes in the key is the _set_ of candidate SKUs. Not the order you sent them
276
- in, not which ones were in stock, and not where the 60-product cut fell. So two
277
- requests carrying the same SKUs in a different order share one component, and the
278
- one generated first is the one both get. If your ranking is personal, order your
279
- candidates and the products in the copy won't be the ones you put at the top.
280
- Prices, titles and stock are always read from your catalog at render time, so
281
- nothing stale is ever shown — a product that sold out is dropped as the page is
282
- served, and any sentence written about it stays.
305
+ everyone looking at it shares them. If you pick candidates per shopper, you'll
306
+ get smaller cohorts, because each prompt really is personal.
307
+
308
+ The cache key for a cohort holds the _set_ of candidate SKUs. It ignores the
309
+ order you sent them in, which ones were in stock, and where the 60-product cut
310
+ fell. So two requests with the same SKUs in a different order share one spec,
311
+ and both get the one generated first. If your ranking is personal and you order
312
+ your candidates per shopper, the products the copy talks about won't be the ones
313
+ you put at the top.
314
+
315
+ Prices, titles and stock are always read from your catalog at render time, so a
316
+ stale one never shows. A product that sold out is dropped as the page is served.
317
+ Any sentence the model wrote about it stays.
283
318
 
284
319
  #### Who can create a cohort
285
320
 
286
- Every field above is one you supply, and each distinct value is a cohort of its
287
- own — a fresh model call, and a slot in the cache. If a visitor can choose one of
288
- them, a visitor can choose how many calls you pay for. A `locale` copied straight
289
- out of `Accept-Language`, a `currentCategory` read off a URL slug and a `segment`
290
- built from a query parameter are the three that go wrong. The shipped cache holds
291
- 10,000 entries and drops the oldest, so enough made-up values also evict the
292
- cohorts your real shoppers were being served from.
321
+ Every cohort field above is one you supply, and each distinct value makes a
322
+ cohort of its own. That's a fresh model call, and a slot in the cache. If a
323
+ visitor can choose one of these values, a visitor can choose how many calls you
324
+ pay for. These are the three that go wrong:
325
+
326
+ - a `locale` copied straight out of `Accept-Language`
327
+ - a `currentCategory` read off a URL slug
328
+ - a `segment` built from a query parameter
293
329
 
294
- Draw those fields from sets you control: your own locale list, your own category
295
- ids, your own segment labels. Nothing leaks either way — a cohort prompt carries
296
- no shopper text at all — the cost is the bill and the model round trip on a page
297
- that was being served from cache.
330
+ The built-in cache holds 10,000 entries, so once it's full, made-up values push
331
+ out the cohorts your real shoppers were being served from.
298
332
 
299
- Pass `generation: 'per-shopper'` to generate for the individual instead. The
300
- model then chooses the products too, and every shopper pays for their own call.
333
+ Draw those fields from sets you control:
334
+
335
+ - your own locale list
336
+ - your own category ids
337
+ - your own segment labels
338
+
339
+ Nothing leaks either way, because a cohort prompt carries no shopper text at
340
+ all. The cost is the bill, plus a model round trip on a page that was being
341
+ served from cache.
342
+
343
+ Pass `generation: 'per-shopper'` to generate for each shopper instead. The model
344
+ then picks the products too, and every shopper costs their own call.
301
345
 
302
346
  ```ts
303
347
  createComponentGenerator({ provider, generation: 'per-shopper' });
@@ -305,23 +349,30 @@ createComponentGenerator({ provider, generation: 'per-shopper' });
305
349
 
306
350
  #### What you put in `segment`
307
351
 
308
- `segment` is sent to the model exactly as you wrote it, in both modes, and the
309
- contract takes any string up to 128 characters. Stick to plain merchandising
310
- labels like `lapsed`, `high-value`, `trial`, `wholesale`. Keep out anything that
311
- says something protected about a person: health, race, ethnic origin, religion or
312
- belief, sex life or sexual orientation, politics, union membership, biometric or
313
- genetic data.
352
+ `segment` goes to the model exactly as you wrote it, in both modes. The schema
353
+ takes any string up to 128 characters. Stick to plain merchandising labels like
354
+ `lapsed`, `high-value`, `trial` or `wholesale`. Keep out anything that says
355
+ something protected about a person:
356
+
357
+ - health
358
+ - race
359
+ - ethnic origin
360
+ - religion or belief
361
+ - sex life or sexual orientation
362
+ - politics
363
+ - union membership
364
+ - biometric or genetic data
314
365
 
315
366
  The same goes for `recentSearches`, `context.searchQuery` and `interaction.type`
316
- in per-shopper mode. Those three are shopper text and they're sent as written.
317
- What a shopper types is theirs. What you label them with is your choice.
367
+ in per-shopper mode. Those three are shopper text, and they're sent as written.
368
+ What a shopper types belongs to them. The label you give them is your choice.
318
369
 
319
370
  ### Limits
320
371
 
321
- Every free-text field and every array is capped, because your strings end up
322
- inside a model prompt and a model is billed per token. The caps live in
323
- `FIELD_LIMITS` and we export them, so you can validate against the same numbers
324
- instead of finding them out from a rejection.
372
+ Every free-text field and every array has a cap, because your strings end up
373
+ inside a model prompt and you pay for a model per token. The caps live in
374
+ `FIELD_LIMITS`, which is exported. Validate against the same numbers instead of
375
+ finding them out from a rejection.
325
376
 
326
377
  | Limit | Value | Applies to |
327
378
  | -------------------- | ----- | ------------------------------------------------------------------- |
@@ -339,46 +390,64 @@ instead of finding them out from a rejection.
339
390
  | `maxItems` | 12 | `context.maxItems`, which also needs at least 1 |
340
391
  | `reason` | 120 | `candidates[].reason`, your own phrase for a product |
341
392
 
342
- These bound each field on its own. They aren't an aggregate prompt budget.
343
- Fitting a payload into a context window is `digest`'s job, and it trims rather
344
- than throws.
393
+ Each cap bounds one field on its own. Together they aren't a budget for the
394
+ whole prompt. Fitting a payload into a context window is the digest's job. The
395
+ digest is core's short summary of the shopper and the page, which goes into the
396
+ prompt. It trims instead of throwing.
345
397
 
346
398
  ### Unknown fields are rejected
347
399
 
348
- Every fixed-shape object is a `strictObject`. Misspell `recentSearches` and
349
- you'll get an error, not a shopper who quietly looks like a first-time visitor.
350
- `interaction.meta` is the one dynamic shape: an open record, minus the keys that
351
- would mutate a prototype instead of the object.
400
+ Every fixed-shape object is a `strictObject`. Misspell `recentSearches` and you
401
+ get an error, instead of a shopper who quietly looks like a first-time visitor.
402
+ `interaction.meta` is the one open shape. It takes any key, except the keys that
403
+ would change a prototype instead of the object.
352
404
 
353
405
  ## Bringing your own ranking
354
406
 
355
- By default we order the products for you, scoring each candidate against the
356
- shopper's signals. But you might already have a recommender you trust: bought
357
- together, an engine trained on your own orders, or a merchandiser's hand-picked
358
- row. Pass `rank: 'given'` and the order you sent is the order that renders.
407
+ By default core orders the products for you. It scores each candidate against
408
+ the shopper's signals. You might already have a recommender you trust:
409
+
410
+ - bought-together data
411
+ - an engine trained on your own orders
412
+ - a merchandiser's hand-picked row
413
+
414
+ Pass `rank: 'given'` and the order you sent is the order that renders.
359
415
 
360
416
  ```ts
361
417
  const generator = createComponentGenerator({ provider, rank: 'given' });
362
418
  ```
363
419
 
364
- You keep the rest either way. We still drop anything the shopper shouldn't be
365
- shown, whether it's out of stock, already bought, in the basket, disliked, or
366
- the product they're looking at right now — read from the payload you sent, not
367
- from the shortened history the model was shown, so a shopper with a long order
368
- book is covered too. Every product still carries a basis we check against their
369
- real signals, and everything the model writes is still screened.
370
-
371
- How much each basis proves is worth knowing, because two of the six prove less
372
- than they read. `most_viewed` is checked against the SKUs they viewed,
373
- `similar_to_current` against the category being browsed, `liked_category`
374
- against the categories they bought, liked, carted or viewed in — standing on a
375
- category page is not one of those — and `popular` claims nothing.
376
- `complements_cart` and `complements_purchase` check only that the basket, or the
377
- order history, isn't empty: nothing here can know that one product goes with
378
- another. A basis that fails takes the sentence and the badge stating it with it.
420
+ You keep the other checks either way. Core still drops anything the shopper
421
+ shouldn't be shown:
422
+
423
+ - out of stock
424
+ - already bought
425
+ - in the basket
426
+ - disliked
427
+ - the product they're looking at right now
428
+
429
+ Core reads these from the payload you sent, not from the shorter history the
430
+ model was shown. So a shopper with a long order history is covered too. Every
431
+ product still carries a basis, a code like `most_viewed` that says why it's
432
+ shown, and core checks it against the shopper's real signals. Everything the
433
+ model writes still goes through the claim check.
434
+
435
+ There are six bases, and two of them prove less than their names suggest:
436
+
437
+ - `most_viewed` is checked against the SKUs they viewed.
438
+ - `similar_to_current` is checked against the category being browsed.
439
+ - `liked_category` is checked against the categories they bought, liked, carted
440
+ or viewed in. Standing on a category page isn't one of those.
441
+ - `popular` claims nothing.
442
+ - `complements_cart` checks only that the basket isn't empty.
443
+ - `complements_purchase` checks only that the order history isn't empty.
444
+
445
+ The two `complements_` bases are the weak ones, because nothing here can know
446
+ that one product goes with another. A basis that fails takes the sentence and
447
+ the badge stating it with it.
379
448
 
380
449
  Each candidate can carry its own `reason`, the phrase shown under the product.
381
- Reach for it when your ranking knows something the signals don't:
450
+ Use it when your ranking knows something the signals don't:
382
451
 
383
452
  ```ts
384
453
  candidates: [
@@ -392,18 +461,16 @@ candidates: [
392
461
  ];
393
462
  ```
394
463
 
395
- A reason you supply is your own words, like the title, so it is rendered as
396
- written and not screened. That only applies where this request actually used it,
397
- which is the default `cohort` mode. In `per-shopper` mode the model writes the
398
- reasons itself, so every one of them is screened, including one that happens to
399
- read the same as yours.
464
+ A reason you supply is your own words, like the title, so it renders as written
465
+ and skips the claim check. That only applies where this request actually used
466
+ it, which is the default `cohort` mode. In `per-shopper` mode the model writes
467
+ the reasons itself. Every one of them goes through the claim check, even one
468
+ that reads the same as yours.
400
469
 
401
- Without a reason of your own, the basis is stated for you from the shopper's
402
- signals — "More in Backpacks", "Goes with what is in your cart". Those are our
403
- words, not the model's, so in `cohort` mode they aren't screened either. They
404
- used to be, and a shop with a category called Clearance or Last Chance would
405
- have watched the phrase list delete the reason under every card while the
406
- deterministic component printed the same sentence untouched.
470
+ Without a reason of your own, core states the basis for you from the shopper's
471
+ signals, like "More in Backpacks" or "Goes with what is in your cart". Those are
472
+ core's words, not the model's, so in `cohort` mode they skip the claim check
473
+ too.
407
474
 
408
475
  ## What the model sees
409
476
 
@@ -415,13 +482,12 @@ The two generation modes send different things. Cohort is the default.
415
482
  - the locale
416
483
  - the segment, when you set one
417
484
  - the category being browsed (`context.currentCategory`)
418
- - the name of the category the shopper leans towards most. Just the name, the
419
- score stays behind
485
+ - the name of the category the shopper leans towards most, without its score
420
486
  - whether this shopper has no history at all, meaning no likes, dislikes,
421
487
  purchases, basket items or views. Searches and other interactions don't count
422
- - how many products the component may place (`context.maxItems`)
423
- - the candidate list: one line per product, with its SKU, title, category, rating
424
- and tags
488
+ - how many products the spec may place (`context.maxItems`)
489
+ - the candidate list: one line per product, with its SKU, title, category,
490
+ rating and tags
425
491
 
426
492
  ### Per-shopper mode
427
493
 
@@ -440,25 +506,24 @@ Everything above, and:
440
506
  ### Left out of both
441
507
 
442
508
  - `user.id`
443
- - every timestamp (`at`), which we use to sort signals and then drop
444
- - dwell time (`dwellMs`), added up in the digest and left out of the prompt
509
+ - every timestamp (`at`). Core uses them to sort signals, then drops them
510
+ - dwell time (`dwellMs`). It's added up in the digest and left out of the prompt
445
511
  - every price, and every currency
446
512
  - `imageUrl`
447
513
  - `interaction.value` and `interaction.meta`. The model is told which kinds of
448
514
  interaction happened and how often, and no more
449
515
 
450
- The candidate list is trimmed on the way out. An out-of-stock product is dropped,
451
- and at most 60 products go, in the order you supplied them.
516
+ The candidate list is trimmed before it's sent. Out-of-stock products are
517
+ dropped, and at most 60 products go, in the order you supplied them.
452
518
 
453
- `spec-cache.test.ts` walks every field of the digest and checks that each one is
454
- either in the cohort key or scrubbed from the cohort prompt. A field the key
455
- leaves out that still changes the prompt fails that test. Adding a field to the
456
- digest fails it too, until someone says which side the field is on.
519
+ `spec-cache.test.ts` walks every field of the digest. It checks that each one is
520
+ either in the cache key or removed from the cohort prompt. A field the key
521
+ leaves out that still changes the prompt fails that test. A new field added to
522
+ the digest fails it too, until someone says which side the field is on.
457
523
 
458
524
  ## What the model decides, by mode
459
525
 
460
- What the model wrote, and what we replace before the page is served. Cohort is
461
- the default.
526
+ What the model wrote, and what core replaces before the page is served.
462
527
 
463
528
  | Decision | Cohort, the default | Per-shopper |
464
529
  | ---------------------------------- | --------------------------------------- | -------------------------------------- |
@@ -471,15 +536,16 @@ the default.
471
536
  | Which bundle, of the ones you pass | Chosen per request, not by the model | Chosen per request, not by the model |
472
537
 
473
538
  In cohort mode the grid and carousel items are filled in per request, best pick
474
- first, so a component written for one shopper still fits the next.
539
+ first, so a spec written for one shopper still fits the next.
475
540
 
476
541
  The hero is the exception. It keeps the product the model named, because its
477
- headline and body were written about that product, and swapping it would leave
478
- copy about something else. When that product can't be placed, reconciliation
479
- drops the link and keeps the words. It can't be placed if this
480
- shopper can't see it (out of stock, not a candidate, disliked, already bought, in
481
- the basket, or the one being looked at), an earlier block already placed it, or
482
- the item budget ran out before the hero was reached.
542
+ headline and body were written about that product. When that product can't be
543
+ placed, core drops the link and keeps the words. It can't be placed when:
544
+
545
+ - this shopper can't see it: it's out of stock, not a candidate, disliked,
546
+ already bought, in the basket, or the one being looked at
547
+ - an earlier block already placed it
548
+ - the item budget ran out before the hero was reached
483
549
 
484
550
  ## Any provider
485
551
 
@@ -495,33 +561,36 @@ export interface ComponentProvider {
495
561
  }
496
562
  ```
497
563
 
498
- `createComponentGenerator({ provider })` takes any object of that shape: a hosted
499
- API, a model you run yourself, a deployment inside your own tenancy, or a
500
- recorded fixture. `@rudra-js/core` depends on no vendor SDK.
501
- `@rudra-js/anthropic` is one adapter, not a requirement, and `provider: null` is
502
- the default that costs nothing.
564
+ `createComponentGenerator({ provider })` takes any object of that shape. That
565
+ can be a hosted API, a model you run yourself, a deployment inside your own
566
+ tenancy, or a recorded fixture. `@rudra-js/core` depends on no vendor SDK.
567
+ `@rudra-js/anthropic` is one adapter, and you don't need it.
503
568
 
504
569
  An adapter takes its API key as an option, so you choose where the key comes
505
- from. `ANTHROPIC_API_KEY` is just the name the example shop uses for its own
506
- convenience. No package here reads the environment.
570
+ from. `ANTHROPIC_API_KEY` is just the name the example shop uses. No package
571
+ here reads the environment.
507
572
 
508
573
  ## The cache
509
574
 
510
- `cache` defaults to an in-process store. `createMemorySpecCache()` keeps an entry
511
- for `ttlMs`, 60,000 milliseconds by default, so one minute. It holds up to
512
- `maxEntries`, 10,000 by default. Once it's full, the entry read longest ago is
513
- the first to go.
514
-
515
- An entry holds the generated spec, as the model returned it before screening,
516
- and `generatedAt`, the epoch milliseconds when the model produced it. No payload
517
- and no prompt. In cohort mode it holds no shopper id and everyone in the cohort
518
- shares it, unless its segment or candidates were picked for one shopper. In
519
- per-shopper mode the model wrote it from that shopper's signals, so its text can
520
- repeat them, a search term for instance, and the entry is personal data until it
521
- goes. Expiry is checked when an entry is read, so an expired one stays in memory
575
+ By default `cache` is an in-process store. `createMemorySpecCache()` keeps an
576
+ entry for `ttlMs`, which defaults to 60,000 milliseconds, so one minute. It
577
+ holds up to `maxEntries`, which defaults to 10,000. Once it's full, the entry
578
+ read longest ago is the first to go.
579
+
580
+ An entry holds the generated spec, as the model returned it before any checks.
581
+ It also holds `generatedAt`, the epoch milliseconds when the model produced it.
582
+ It holds no payload and no prompt.
583
+
584
+ In cohort mode an entry holds no shopper id, and everyone in the cohort shares
585
+ it. The exception is an entry whose segment or candidates were picked for one
586
+ shopper. In per-shopper mode the model wrote the entry from that shopper's
587
+ signals. So its text can repeat them, a search term for instance, and the entry
588
+ is personal data until it's gone.
589
+
590
+ Expiry is checked when an entry is read, so an expired entry stays in memory
522
591
  until it's read again or pushed out by a newer one.
523
592
 
524
- The port is two methods, and an optional third:
593
+ The cache interface is two methods, and an optional third:
525
594
 
526
595
  ```ts
527
596
  export interface SpecCache {
@@ -531,125 +600,139 @@ export interface SpecCache {
531
600
  }
532
601
  ```
533
602
 
534
- Pass your own store, whether that's Redis, Memcached or whatever you already run,
535
- and it keeps entries on its own terms. Just keep in mind that what that store
536
- holds, and for how long, is yours to declare to your users. This package doesn't
537
- set it. Pass `createNullSpecCache()` to store nothing at all.
603
+ Pass your own store, whether that's Redis, Memcached or whatever you already
604
+ run, and it keeps entries on its own terms. What that store holds, and for how
605
+ long, is yours to declare to your users. This package doesn't set it. Pass
606
+ `createNullSpecCache()` to store nothing at all.
538
607
 
539
608
  ### When a generation is wrong
540
609
 
541
- Pass `provider: null` and nothing new is generated, so every page renders the
542
- deterministic component. Shorten `ttlMs` and a bad entry ends sooner. A store
543
- with `delete` can drop one entry by the `key` on its `GenerationEvent`, and the
544
- next request generates again. Per-shopper entries only end by TTL, because
545
- nothing maps a shopper to their keys.
610
+ - Pass `provider: null` and nothing new is generated, so every page renders the
611
+ fallback.
612
+ - Shorten `ttlMs` and a bad entry ends sooner.
613
+ - If your store has `delete`, drop one entry by the `key` on its
614
+ `GenerationEvent`. The next request generates again.
615
+
616
+ Per-shopper entries only end when they expire, because nothing maps a shopper to
617
+ their keys.
546
618
 
547
619
  ## Watching it in production
548
620
 
549
621
  The generator never fails a render, so a provider that's been down for a week
550
- only shows as plainer pages. The way to know is `onEvent`: every call to
551
- `generate` that gets past input validation reports exactly one `GenerationEvent`.
552
- A payload that fails `parseTrackingInput` throws instead, and reports nothing.
622
+ only shows up as plainer pages. Watch `onEvent` to find out. Every call to
623
+ `generate` that gets past input validation reports exactly one
624
+ `GenerationEvent`. A payload that fails `parseTrackingInput` throws instead, and
625
+ reports nothing.
553
626
 
554
- These are the numbers worth keeping:
627
+ Keep these numbers:
555
628
 
556
- - **Fallback share** — the share of events with `source: 'fallback'`. Alert when
557
- it climbs. `degradedReason` tells you which way the call failed, and `error`
558
- carries what was thrown when the reason is `'provider-error'` or `'timeout'`.
559
- - **Cache hit rate** — `cache: 'hit'` over the events that have a `cache` field.
629
+ - **Fallback share.** The share of events with `source: 'fallback'`. Alert when
630
+ it climbs. `degradedReason` tells you which way the call failed. When the
631
+ reason is `'provider-error'` or `'timeout'`, `error` carries what was thrown.
632
+ - **Cache hit rate.** `cache: 'hit'` over the events that have a `cache` field.
560
633
  A store that's down shows as `cache: 'error'`, and a slow one as
561
- `cache: 'timeout'`, rather than as a rising bill.
562
- - **Spend** — sum `usage` over the events where `calledModel` is true. Requests
563
- that joined an in-flight generation carry the same `usage`, so summing over
564
- every event counts one call many times. A call the model answered but the
565
- adapter turned down (a `max_tokens` stop, a refusal, no tool call, or a reply
566
- that doesn't fit the schema) was billed too, and arrives as a
634
+ `cache: 'timeout'`. So a broken store shows up here, not only as a rising
635
+ bill.
636
+ - **Spend.** Sum `usage` over the events where `calledModel` is true. Requests
637
+ that joined a generation already in flight carry the same `usage`, so summing
638
+ over every event counts one call many times. Some calls the model answered are
639
+ turned down by the adapter: a `max_tokens` stop, a refusal, no tool call, or a
640
+ reply that doesn't fit the schema. Those were billed too. They arrive as a
567
641
  `'provider-error'` with no `usage`, so this sum is lower than your bill.
568
642
 
569
643
  ## Running it in a regulated shop
570
644
 
571
- This isn't legal advice, and which rules apply depends on where you sell. It's
572
- what the package does, so you can answer what a privacy or consumer-law review
573
- will ask.
645
+ This isn't legal advice, and which rules apply depends on where you sell. It
646
+ describes what the package does, so you can answer what a privacy or
647
+ consumer-law review will ask.
574
648
 
575
649
  **What leaves your server.** Nothing, unless you pass a `provider`, or a `cache`
576
650
  whose store lives somewhere else. With a provider, cohort mode sends the page
577
651
  and locale, the category, the segment you set, the shopper's strongest category,
578
- whether they have any history, and the candidate products. There's no shopper id
579
- in it, but it can still be personal data: the strongest category and the history
580
- flag come from that shopper's data, the segment goes exactly as you wrote it,
581
- and the candidate list is about them too if you pick or order it per shopper.
582
- Per-shopper mode adds the shopper's own signals. Both lists are under [What the
583
- model sees](#what-the-model-sees). `user.id` is never sent in either mode. The
584
- provider should be your processor, under a contract that says so; for Anthropic,
585
- see the `@rudra-js/anthropic` README.
652
+ whether they have any history, and the candidate products. That has no shopper
653
+ id, but it can still be personal data. The strongest category and the history
654
+ flag come from that shopper's data. The segment goes exactly as you wrote it.
655
+ The candidate list is about them too if you pick or order it per shopper.
656
+ Per-shopper mode adds the shopper's own signals. Both lists are under
657
+ [What the model sees](#what-the-model-sees). `user.id` is never sent in either
658
+ mode. The provider should be your processor, under a contract that says so. For
659
+ Anthropic, see the `@rudra-js/anthropic` README.
586
660
 
587
661
  **A shopper who objects to profiling.** Leave `signals` and `user.segment` out
588
- of their payload, and in per-shopper mode `context.searchQuery` and
589
- `user.isReturning` too. They get the cold-start component, built from the page
590
- and the catalog, not from them. With those left out, `generateDeterministic(input)`
591
- gives the same kind of component without calling the model or the cache. Called
592
- with signals, it still personalises.
662
+ of their payload. In per-shopper mode, leave out `context.searchQuery` and
663
+ `user.isReturning` too. They get what a first-time visitor gets, built from the
664
+ page and the catalog, not from them. With those left out,
665
+ `generateDeterministic(input)` gives the same kind of spec without calling the
666
+ model or the cache. Called with signals, it still personalises.
593
667
 
594
668
  **Deleting a shopper's data.** The package keeps nothing about a shopper except
595
669
  the cache. In cohort mode an entry holds no shopper id and everyone in the
596
670
  cohort shares it, so usually there's no one shopper's entry to delete. The
597
671
  exception is a cohort entry whose segment or candidate list was picked for one
598
- shopper: like every per-shopper entry, it's about that shopper. Nothing maps a
672
+ shopper. Like every per-shopper entry, it's about that shopper. Nothing maps a
599
673
  shopper to their entries, so keep `ttlMs` short or pass `createNullSpecCache()`.
600
- `onEvent` sends no shopper id, but in per-shopper mode its `key` is derived from
601
- one, and `violations` can name SKUs from a shopper's basket or purchases, so
674
+ `onEvent` sends no shopper id. But in per-shopper mode its `key` is derived from
675
+ one, and `violations` can name SKUs from a shopper's basket or purchases. So
602
676
  treat logs of it as personal data.
603
677
 
604
- **Caching pages.** A personalised page says things like "Goes with your cart" and
605
- "You looked at this recently" in its HTML. Serve those pages with
678
+ **Caching pages.** A personalised page says things like "Goes with your cart"
679
+ and "You looked at this recently" in its HTML. Serve those pages with
606
680
  `Cache-Control: private` or `no-store`, so a shared cache never hands one
607
681
  shopper's page to another.
608
682
 
609
683
  **Telling shoppers a model wrote the copy.** The wrapper's `data-rudra-source`
610
- says `llm` or `cache` when a model wrote the copy and `fallback` when it didn't,
611
- so it's a machine-readable marker you can build on, for example to show a note.
612
- Whether you must label AI-written copy, and how, depends on where you sell; in
613
- the EU, Article 50 of the AI Act is the rule to ask about.
684
+ says `llm` or `cache` when a model wrote the copy, and `fallback` when it
685
+ didn't. That makes it a machine-readable marker you can build on, for example
686
+ to show a note. Whether you must label AI-written copy, and how, depends on
687
+ where you sell. In the EU, Article 50 of the AI Act is the rule to ask about.
614
688
 
615
689
  **Claims.** The copy on your page is your own advertising, whoever wrote it. The
616
- screen catches prices, discounts, stock, ratings, delivery, urgency and generic
617
- environmental claims, but it's a list, and rewordings get through (SECURITY.md
618
- explains why, and the tests list the known ones). Anything it misses is still
619
- yours to stand behind.
690
+ claim check catches prices, discounts, stock, ratings, delivery, urgency and
691
+ generic environmental claims. But it's a list, and rewordings get through.
692
+ SECURITY.md explains why, and the tests list the known ones. Anything it misses
693
+ is still yours to stand behind.
620
694
 
621
695
  **Children.** In the UK, the ICO's Children's Code covers any shop that
622
696
  under-18s are more likely than not to use, not only one aimed at them. Its
623
697
  standard 12 asks for profiling to be off by default unless you can show a
624
- compelling reason, judged by the child's best interests, and for safeguards when
625
- it's on. For a shop like that, don't use per-shopper mode. For a shopper under
626
- 18, leave `signals` and `user.segment` out of their payload unless they've
627
- switched personalisation on, as for a shopper who objects to profiling. If you
628
- can't tell a shopper's age with confidence that fits the risk, treat every
629
- shopper as under 18. Cohort mode alone doesn't turn profiling off: the default
630
- `rank: 'signals'` still orders products by the shopper's signals in cohort mode
631
- and in the fallback component, even with no provider, and cohort mode still
632
- sends their strongest category, whether they have any history, and any segment
633
- to the model.
698
+ compelling reason, judged by the child's best interests. It also asks for
699
+ safeguards when profiling is on. For a shop like that, don't use per-shopper
700
+ mode. For a shopper under 18, leave `signals` and `user.segment` out of their
701
+ payload unless they've switched personalisation on, as for a shopper who objects
702
+ to profiling. If you can't tell a shopper's age with confidence that fits the
703
+ risk, treat every shopper as under 18. Cohort mode alone doesn't turn profiling
704
+ off. The default `rank: 'signals'` still orders products by the shopper's
705
+ signals in cohort mode and in the fallback, even with no provider. And cohort
706
+ mode still sends their strongest category, whether they have any history, and
707
+ any segment to the model.
634
708
 
635
709
  **Marketplaces.** If other sellers list products on your site, EU platform rules
636
- (Article 27 of the Digital Services Act) ask you, unless you're a micro or small
637
- business, to set out in your terms your recommender's main parameters, why they
638
- weigh as they do, and any options shoppers have to change them. If shoppers can
639
- pick between options, such as switching personalisation off, the control has to
640
- sit where the products are shown. Which parameters apply depends on your mode
641
- and your `rank` setting. `rank` sets the order in cohort mode and in the
642
- fallback component. With `rank: 'given'` that's the order you sent, so describe
643
- your own ranking. With `rank: 'signals'` it comes from the shopper's category
644
- interest (the category they're browsing counts too), how often they've viewed a
645
- product, its rating, and tags it shares with products they've engaged with.
646
- Category weighs most. In per-shopper mode the model picks which candidates to
647
- show and in what order, from what's under
648
- [What the model sees](#what-the-model-sees). In cohort mode the model picks the
649
- hero's product, from the cohort list there. In every mode, anything out of stock, disliked or
650
- being looked at right now is left out, and so is anything bought or in the
651
- basket, except as part of a bundle. A bundle is picked by what's in the basket,
652
- then what they viewed, then the category being browsed.
710
+ (Article 27 of the Digital Services Act) ask you to set out in your terms:
711
+
712
+ - your recommender's main parameters
713
+ - why they weigh as they do
714
+ - any options shoppers have to change them
715
+
716
+ This doesn't apply if you're a micro or small business. If shoppers can pick
717
+ between options, such as switching personalisation off, the control has to sit
718
+ where the products are shown.
719
+
720
+ Which parameters apply depends on your mode and your `rank` setting:
721
+
722
+ - `rank` sets the order in cohort mode and in the fallback. With `rank: 'given'`
723
+ that's the order you sent, so describe your own ranking.
724
+ - With `rank: 'signals'` the order comes from the shopper's category interest
725
+ (the category they're browsing counts too), how often they've viewed a
726
+ product, its rating, and tags it shares with products they've engaged with.
727
+ Category weighs most.
728
+ - In per-shopper mode the model picks which candidates to show and in what
729
+ order, from what's under [What the model sees](#what-the-model-sees).
730
+ - In cohort mode the model picks the hero's product, from the cohort list in
731
+ that section.
732
+ - In every mode, anything out of stock, disliked or being looked at right now is
733
+ left out. So is anything bought or in the basket, except as part of a bundle.
734
+ - A bundle is picked by what's in the basket, then what they viewed, then the
735
+ category being browsed.
653
736
 
654
737
  **Accessibility.** Every card is one link with a readable name, and headings
655
738
  only render when they have text. Contrast, focus styles and layout come from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rudra-js/core",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Core of rudra-js, a TypeScript library for LLM-designed product recommendations: turns one page view into a recommendation spec checked against your catalog",
5
5
  "keywords": [
6
6
  "rudra-js",
@@ -60,10 +60,10 @@
60
60
  "zod": "^4.6.7"
61
61
  },
62
62
  "peerDependencies": {
63
- "@rudra-js/attested": "^0.8.2"
63
+ "@rudra-js/attested": "^0.9.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@rudra-js/attested": "0.8.2",
66
+ "@rudra-js/attested": "0.9.0",
67
67
  "@types/node": "^26.4.1"
68
68
  }
69
69
  }