@rudra-js/core 0.9.0 → 0.9.1

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