@rudra-js/core 0.1.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -0
  3. package/dist/component-generator.d.ts +84 -0
  4. package/dist/component-generator.d.ts.map +1 -0
  5. package/dist/component-generator.js +331 -0
  6. package/dist/component-generator.js.map +1 -0
  7. package/dist/component-spec.d.ts +426 -0
  8. package/dist/component-spec.d.ts.map +1 -0
  9. package/dist/component-spec.js +170 -0
  10. package/dist/component-spec.js.map +1 -0
  11. package/dist/fallback-component.d.ts +11 -0
  12. package/dist/fallback-component.d.ts.map +1 -0
  13. package/dist/fallback-component.js +69 -0
  14. package/dist/fallback-component.js.map +1 -0
  15. package/dist/fit-to-shopper.d.ts +4 -0
  16. package/dist/fit-to-shopper.d.ts.map +1 -0
  17. package/dist/fit-to-shopper.js +36 -0
  18. package/dist/fit-to-shopper.js.map +1 -0
  19. package/dist/index.d.ts +19 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +19 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/model-prompt.d.ts +29 -0
  24. package/dist/model-prompt.d.ts.map +1 -0
  25. package/dist/model-prompt.js +227 -0
  26. package/dist/model-prompt.js.map +1 -0
  27. package/dist/product-selection.d.ts +33 -0
  28. package/dist/product-selection.d.ts.map +1 -0
  29. package/dist/product-selection.js +102 -0
  30. package/dist/product-selection.js.map +1 -0
  31. package/dist/provider.d.ts +80 -0
  32. package/dist/provider.d.ts.map +1 -0
  33. package/dist/provider.js +23 -0
  34. package/dist/provider.js.map +1 -0
  35. package/dist/reconciliation.d.ts +49 -0
  36. package/dist/reconciliation.d.ts.map +1 -0
  37. package/dist/reconciliation.js +564 -0
  38. package/dist/reconciliation.js.map +1 -0
  39. package/dist/signal-digest.d.ts +66 -0
  40. package/dist/signal-digest.d.ts.map +1 -0
  41. package/dist/signal-digest.js +224 -0
  42. package/dist/signal-digest.js.map +1 -0
  43. package/dist/spec-cache.d.ts +88 -0
  44. package/dist/spec-cache.d.ts.map +1 -0
  45. package/dist/spec-cache.js +152 -0
  46. package/dist/spec-cache.js.map +1 -0
  47. package/dist/tracking-input.d.ts +258 -0
  48. package/dist/tracking-input.d.ts.map +1 -0
  49. package/dist/tracking-input.js +241 -0
  50. package/dist/tracking-input.js.map +1 -0
  51. package/package.json +60 -0
  52. package/src/component-generator.ts +521 -0
  53. package/src/component-spec.ts +243 -0
  54. package/src/fallback-component.ts +77 -0
  55. package/src/fit-to-shopper.ts +45 -0
  56. package/src/index.ts +102 -0
  57. package/src/model-prompt.ts +258 -0
  58. package/src/product-selection.ts +153 -0
  59. package/src/provider.ts +98 -0
  60. package/src/reconciliation.ts +675 -0
  61. package/src/signal-digest.ts +335 -0
  62. package/src/spec-cache.ts +223 -0
  63. package/src/tracking-input.ts +300 -0
@@ -0,0 +1,335 @@
1
+ import type {
2
+ Interaction,
3
+ Product,
4
+ PurchaseSignal,
5
+ SkuSignal,
6
+ TrackingInput,
7
+ ViewSignal,
8
+ } from './tracking-input.js';
9
+
10
+ /**
11
+ * Reduces a validated tracking payload to the compact, ordered, bounded view
12
+ * that everything downstream reads.
13
+ *
14
+ * Two jobs:
15
+ * 1. Keep the volatile part of a prompt small and stable. The contract lets a
16
+ * host send 500 signals per category; a prompt cannot afford them, and the
17
+ * long tail is noise anyway.
18
+ * 2. Give the deterministic path the same evidence the model gets, so a
19
+ * degraded render is a weaker version of the same decision rather than an
20
+ * unrelated one.
21
+ */
22
+
23
+ export interface CategoryAffinity {
24
+ category: string;
25
+ /** Unnormalised. Only the ordering is meaningful — do not show this to anyone. */
26
+ score: number;
27
+ }
28
+
29
+ export interface ViewedProduct {
30
+ sku: string;
31
+ views: number;
32
+ dwellMs?: number;
33
+ }
34
+
35
+ export interface InteractionCount {
36
+ type: string;
37
+ count: number;
38
+ }
39
+
40
+ export interface SignalDigest {
41
+ userId: string;
42
+ segment?: string;
43
+ isReturning: boolean;
44
+
45
+ surface: string;
46
+ slot: string;
47
+ locale: string;
48
+ maxItems: number;
49
+ currentSku?: string;
50
+ currentCategory?: string;
51
+ searchQuery?: string;
52
+
53
+ likedSkus: string[];
54
+ dislikedSkus: string[];
55
+ purchasedSkus: string[];
56
+ cartSkus: string[];
57
+ topViewed: ViewedProduct[];
58
+ recentSearches: string[];
59
+ categoryAffinity: CategoryAffinity[];
60
+ interactionCounts: InteractionCount[];
61
+
62
+ /** True when there is no behavioural evidence to personalise on. */
63
+ isColdStart: boolean;
64
+ }
65
+
66
+ /**
67
+ * How much of each signal category survives into the digest. Chosen to keep the
68
+ * per-shopper part of a prompt in the low hundreds of tokens.
69
+ */
70
+ export const DIGEST_LIMITS = {
71
+ liked: 12,
72
+ disliked: 12,
73
+ purchased: 8,
74
+ cart: 8,
75
+ viewed: 10,
76
+ searches: 5,
77
+ affinity: 6,
78
+ interactionTypes: 8,
79
+ } as const;
80
+
81
+ /**
82
+ * How much intent each kind of signal implies. A purchase says more about a
83
+ * shopper than a view; an explicit dislike says the most, and says it in the
84
+ * opposite direction.
85
+ */
86
+ const SIGNAL_WEIGHTS = {
87
+ purchase: 5,
88
+ like: 4,
89
+ cart: 3,
90
+ view: 1,
91
+ dislike: -6,
92
+ } as const;
93
+
94
+ /**
95
+ * A signal that does not state a weight counts at full strength. This lives in
96
+ * one place on purpose: when the default was written out at each call site, the
97
+ * merge step defaulted to 0 while scoring defaulted to 1, and an unweighted view
98
+ * merged with a `weight: 0.2` view scored as though both were 0.2.
99
+ */
100
+ const effectiveWeight = (signal: { weight?: number | undefined }): number => signal.weight ?? 1;
101
+
102
+ /** Most recent first. A signal with no timestamp sorts last. */
103
+ function byMostRecent(
104
+ left: { at?: number | undefined },
105
+ right: { at?: number | undefined },
106
+ ): number {
107
+ return (right.at ?? 0) - (left.at ?? 0);
108
+ }
109
+
110
+ /** The most recent `limit` SKUs, each appearing once. */
111
+ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
112
+ const skus: string[] = [];
113
+ const alreadySeen = new Set<string>();
114
+
115
+ for (const signal of signals.toSorted(byMostRecent)) {
116
+ if (alreadySeen.has(signal.sku)) continue;
117
+ alreadySeen.add(signal.sku);
118
+ skus.push(signal.sku);
119
+ if (skus.length >= limit) break;
120
+ }
121
+
122
+ return skus;
123
+ }
124
+
125
+ /**
126
+ * A signal may name its own category; otherwise we look the SKU up in the
127
+ * candidate set. A signal for a product that is not a candidate today and
128
+ * carries no category simply contributes nothing.
129
+ */
130
+ function categoryOf(
131
+ signal: { sku: string; category?: string | undefined },
132
+ candidatesBySku: Map<string, Product>,
133
+ ): string | undefined {
134
+ return signal.category ?? candidatesBySku.get(signal.sku)?.category;
135
+ }
136
+
137
+ function computeCategoryAffinity(
138
+ input: TrackingInput,
139
+ candidatesBySku: Map<string, Product>,
140
+ ): CategoryAffinity[] {
141
+ const scoreByCategory = new Map<string, number>();
142
+
143
+ const addScore = (category: string | undefined, score: number): void => {
144
+ if (!category) return;
145
+ scoreByCategory.set(category, (scoreByCategory.get(category) ?? 0) + score);
146
+ };
147
+
148
+ const { signals } = input;
149
+
150
+ for (const purchase of signals.lastPurchased) {
151
+ addScore(
152
+ categoryOf(purchase, candidatesBySku),
153
+ SIGNAL_WEIGHTS.purchase * effectiveWeight(purchase),
154
+ );
155
+ }
156
+ for (const like of signals.likes) {
157
+ addScore(categoryOf(like, candidatesBySku), SIGNAL_WEIGHTS.like * effectiveWeight(like));
158
+ }
159
+ for (const inCart of signals.cart) {
160
+ addScore(categoryOf(inCart, candidatesBySku), SIGNAL_WEIGHTS.cart * effectiveWeight(inCart));
161
+ }
162
+ // Merged first, deliberately — see `mergeViewsBySku`. Views are noisy and
163
+ // repeat cheaply, so the tenth view counts for far less than the second, and
164
+ // log scaling keeps a single obsessive session from drowning out a purchase.
165
+ for (const view of mergeViewsBySku(signals.mostViewed)) {
166
+ const scaledViews = Math.log2(1 + view.views);
167
+ addScore(categoryOf(view, candidatesBySku), SIGNAL_WEIGHTS.view * scaledViews * view.weight);
168
+ }
169
+ for (const dislike of signals.dislikes) {
170
+ addScore(
171
+ categoryOf(dislike, candidatesBySku),
172
+ SIGNAL_WEIGHTS.dislike * effectiveWeight(dislike),
173
+ );
174
+ }
175
+
176
+ // The category the shopper is standing in right now is itself evidence.
177
+ addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
178
+
179
+ return [...scoreByCategory.entries()]
180
+ .map(([category, score]) => ({
181
+ category,
182
+ score: Math.round(score * 100) / 100,
183
+ }))
184
+ .filter((affinity) => affinity.score > 0)
185
+ .toSorted((left, right) => right.score - left.score)
186
+ .slice(0, DIGEST_LIMITS.affinity);
187
+ }
188
+
189
+ interface MergedView extends ViewedProduct {
190
+ category?: string;
191
+ /** Always resolved, so no consumer has to re-apply the default. */
192
+ weight: number;
193
+ }
194
+
195
+ /**
196
+ * Collapses every view record for one SKU into a single running total.
197
+ *
198
+ * This has to happen before any scoring. A host is free to emit one record per
199
+ * page view rather than a running count, and scoring each record separately
200
+ * would let thirty `views: 1` records outweigh one `views: 30` record six times
201
+ * over, defeating the sub-linear scaling in `computeCategoryAffinity` entirely.
202
+ * Merging first makes the score depend on how much someone looked, not on how
203
+ * their tracking pipeline happens to batch.
204
+ *
205
+ * Where records disagree on `weight`, the strongest wins.
206
+ */
207
+ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
208
+ const totalsBySku = new Map<string, MergedView>();
209
+
210
+ for (const view of views) {
211
+ const running = totalsBySku.get(view.sku);
212
+ if (running) {
213
+ running.views += view.views;
214
+ if (view.dwellMs !== undefined) running.dwellMs = (running.dwellMs ?? 0) + view.dwellMs;
215
+ if (view.category !== undefined) running.category ??= view.category;
216
+ running.weight = Math.max(running.weight, effectiveWeight(view));
217
+ continue;
218
+ }
219
+ totalsBySku.set(view.sku, {
220
+ sku: view.sku,
221
+ views: view.views,
222
+ ...(view.dwellMs !== undefined ? { dwellMs: view.dwellMs } : {}),
223
+ ...(view.category !== undefined ? { category: view.category } : {}),
224
+ weight: effectiveWeight(view),
225
+ });
226
+ }
227
+
228
+ return [...totalsBySku.values()];
229
+ }
230
+
231
+ /** The most-viewed few, as the digest reports them. */
232
+ function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
233
+ return mergeViewsBySku(views)
234
+ .toSorted((left, right) => right.views - left.views)
235
+ .slice(0, DIGEST_LIMITS.viewed)
236
+ .map(({ sku, views: viewCount, dwellMs }) => ({
237
+ sku,
238
+ views: viewCount,
239
+ ...(dwellMs !== undefined ? { dwellMs } : {}),
240
+ }));
241
+ }
242
+
243
+ /** The open-vocabulary long tail, reduced to "which kinds, and how often". */
244
+ function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
245
+ const countByType = new Map<string, number>();
246
+
247
+ for (const interaction of interactions) {
248
+ countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
249
+ }
250
+
251
+ return [...countByType.entries()]
252
+ .map(([type, count]) => ({ type, count }))
253
+ .toSorted((left, right) => right.count - left.count)
254
+ .slice(0, DIGEST_LIMITS.interactionTypes);
255
+ }
256
+
257
+ function recentPurchasedSkus(purchases: PurchaseSignal[]): string[] {
258
+ return recentUniqueSkus(purchases, DIGEST_LIMITS.purchased);
259
+ }
260
+
261
+ export function buildDigest(input: TrackingInput): SignalDigest {
262
+ const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
263
+ const { signals, context, user } = input;
264
+
265
+ const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
266
+ const dislikedSkus = recentUniqueSkus(signals.dislikes, DIGEST_LIMITS.disliked);
267
+ const purchasedSkus = recentPurchasedSkus(signals.lastPurchased);
268
+ const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
269
+ const topViewed = mostViewedProducts(signals.mostViewed);
270
+
271
+ // Searches say what a shopper wants; they do not say they engaged with any
272
+ // product, so they do not lift a shopper out of cold start on their own.
273
+ const evidenceCount =
274
+ likedSkus.length +
275
+ dislikedSkus.length +
276
+ purchasedSkus.length +
277
+ cartSkus.length +
278
+ topViewed.length;
279
+
280
+ return {
281
+ userId: user.id,
282
+ ...(user.segment !== undefined ? { segment: user.segment } : {}),
283
+ isReturning: user.isReturning ?? purchasedSkus.length > 0,
284
+
285
+ surface: context.surface,
286
+ slot: context.slot,
287
+ locale: context.locale,
288
+ maxItems: context.maxItems,
289
+ ...(context.currentSku !== undefined ? { currentSku: context.currentSku } : {}),
290
+ ...(context.currentCategory !== undefined ? { currentCategory: context.currentCategory } : {}),
291
+ ...(context.searchQuery !== undefined ? { searchQuery: context.searchQuery } : {}),
292
+
293
+ likedSkus,
294
+ dislikedSkus,
295
+ purchasedSkus,
296
+ cartSkus,
297
+ topViewed,
298
+ recentSearches: signals.recentSearches.slice(0, DIGEST_LIMITS.searches),
299
+ categoryAffinity: computeCategoryAffinity(input, candidatesBySku),
300
+ interactionCounts: countByInteractionType(signals.interactions),
301
+
302
+ isColdStart: evidenceCount === 0,
303
+ };
304
+ }
305
+
306
+ // Everything the cohort key leaves out has to leave the prompt too, or the
307
+ // first shopper's searches and history end up shaping copy that is cached and
308
+ // served to everyone else in their cohort.
309
+ export function toCohortDigest(digest: SignalDigest): SignalDigest {
310
+ const top = digest.categoryAffinity[0];
311
+
312
+ // Listed rather than spread, so what survives is the thing you read.
313
+ const cohort: SignalDigest = {
314
+ userId: 'cohort',
315
+ isReturning: false,
316
+ surface: digest.surface,
317
+ slot: digest.slot,
318
+ locale: digest.locale,
319
+ maxItems: digest.maxItems,
320
+ likedSkus: [],
321
+ dislikedSkus: [],
322
+ purchasedSkus: [],
323
+ cartSkus: [],
324
+ topViewed: [],
325
+ recentSearches: [],
326
+ categoryAffinity: top ? [{ category: top.category, score: 0 }] : [],
327
+ interactionCounts: [],
328
+ isColdStart: digest.isColdStart,
329
+ };
330
+
331
+ if (digest.segment !== undefined) cohort.segment = digest.segment;
332
+ if (digest.currentCategory !== undefined) cohort.currentCategory = digest.currentCategory;
333
+
334
+ return cohort;
335
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SPEC_VERSION, type GeneratedSpec } from './component-spec.js';
3
+ import type { SignalDigest } from './signal-digest.js';
4
+
5
+ /**
6
+ * Where a generated component is kept between requests.
7
+ *
8
+ * A generation is stable for as long as the shopper's signals are, so asking a
9
+ * model again on the next page view buys nothing and costs a round trip on the
10
+ * render path — which is the latency this whole design exists to avoid.
11
+ *
12
+ * The store is a port rather than an implementation. A single process can use
13
+ * the in-memory one; anything running more than one instance needs a shared
14
+ * store, or each instance keeps its own copy and the hit rate divides by the
15
+ * instance count.
16
+ */
17
+
18
+ /**
19
+ * Asynchronous on purpose. A synchronous `get` would look simpler and would
20
+ * make the shared store the docs recommend impossible to write, since no Redis
21
+ * or Memcached client can return a value without awaiting.
22
+ */
23
+ /**
24
+ * A stored generation, with the moment it was produced.
25
+ *
26
+ * The timestamp travels with the spec because it cannot be recovered later: a
27
+ * component served from cache is not newly generated, and `generatedAt` is the
28
+ * only way anything downstream can tell how stale what it is showing has become.
29
+ */
30
+ export interface CachedSpec {
31
+ spec: GeneratedSpec;
32
+ /** Epoch milliseconds at which the model produced this. */
33
+ generatedAt: number;
34
+ }
35
+
36
+ export interface SpecCache {
37
+ get(key: string): Promise<CachedSpec | undefined>;
38
+ set(key: string, cached: CachedSpec): Promise<void>;
39
+ }
40
+
41
+ export interface MemorySpecCacheOptions {
42
+ /** How long an entry stays valid. Defaults to 60 seconds. */
43
+ ttlMs?: number;
44
+ /** Hard ceiling on entries. Least recently read is evicted first. */
45
+ maxEntries?: number;
46
+ /** Injectable clock, so tests do not have to wait. */
47
+ now?: () => number;
48
+ }
49
+
50
+ function assertFiniteAtLeastZero(name: string, value: number): void {
51
+ if (Number.isFinite(value) && value >= 0) return;
52
+
53
+ const hint = Number.isNaN(value)
54
+ ? ' (a common cause is Number() on an environment variable that is not set)'
55
+ : '';
56
+ throw new RangeError(`${name} must be a finite number of at least 0, received ${value}${hint}`);
57
+ }
58
+
59
+ interface CacheEntry {
60
+ cached: CachedSpec;
61
+ expiresAt: number;
62
+ }
63
+
64
+ /**
65
+ * A bounded in-process cache.
66
+ *
67
+ * `maxEntries` is generous relative to the default TTL for a reason: a ceiling
68
+ * low enough to evict entries before they expire silently turns a longer TTL
69
+ * into a setting that does nothing, and the person who raised it has no way to
70
+ * tell.
71
+ */
72
+ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): SpecCache {
73
+ const ttlMs = options.ttlMs ?? 60_000;
74
+ const maxEntries = options.maxEntries ?? 10_000;
75
+
76
+ // Checked rather than trusted, because the way these are usually supplied is
77
+ // `Number(process.env.SOMETHING)`, and an unset or misspelled variable makes
78
+ // that NaN. Every comparison against NaN is false, so the cache would then
79
+ // never expire an entry and never evict one — it would grow forever while
80
+ // serving a shopper the component they were given last week, and nothing
81
+ // would report it. Failing at construction is the only loud option.
82
+ assertFiniteAtLeastZero('ttlMs', ttlMs);
83
+ assertFiniteAtLeastZero('maxEntries', maxEntries);
84
+ if (!Number.isSafeInteger(maxEntries)) {
85
+ throw new RangeError(`maxEntries must be a whole number, received ${maxEntries}`);
86
+ }
87
+
88
+ const now = options.now ?? Date.now;
89
+ const entries = new Map<string, CacheEntry>();
90
+
91
+ return {
92
+ async get(key) {
93
+ const entry = entries.get(key);
94
+ if (!entry) return undefined;
95
+
96
+ if (entry.expiresAt <= now()) {
97
+ entries.delete(key);
98
+ return undefined;
99
+ }
100
+
101
+ // Re-insert so insertion order tracks recency of use, which is what makes
102
+ // the eviction below least-recently-used rather than oldest-written.
103
+ entries.delete(key);
104
+ entries.set(key, entry);
105
+ return entry.cached;
106
+ },
107
+
108
+ async set(key, cached) {
109
+ entries.delete(key);
110
+ entries.set(key, { cached, expiresAt: now() + ttlMs });
111
+
112
+ while (entries.size > maxEntries) {
113
+ const oldest = entries.keys().next();
114
+ if (oldest.done) break;
115
+ entries.delete(oldest.value);
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * A cache that stores nothing, so every request generates.
123
+ *
124
+ * This is not a stub. It is the control for any measurement of generation cost
125
+ * or latency: with a cache in front, a benchmark reports how often it hit, not
126
+ * what generating costs.
127
+ */
128
+ export function createNullSpecCache(): SpecCache {
129
+ return {
130
+ async get() {
131
+ return undefined;
132
+ },
133
+ async set() {
134
+ // Deliberately nothing.
135
+ },
136
+ };
137
+ }
138
+
139
+ /** Stable JSON: object keys sorted, so key order in a digest cannot matter. */
140
+ function canonicalise(value: unknown): string {
141
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
142
+ if (Array.isArray(value)) return `[${value.map(canonicalise).join(',')}]`;
143
+
144
+ const entries = Object.entries(value as Record<string, unknown>)
145
+ .filter(([, fieldValue]) => fieldValue !== undefined)
146
+ .toSorted(([left], [right]) => (left < right ? -1 : 1));
147
+
148
+ return `{${entries.map(([name, fieldValue]) => `${JSON.stringify(name)}:${canonicalise(fieldValue)}`).join(',')}}`;
149
+ }
150
+
151
+ /**
152
+ * Derives the cache key.
153
+ *
154
+ * The digest goes in **whole**, rather than as a hand-picked list of fields.
155
+ * That is the entire point of this function. The previous implementation listed
156
+ * the fields it thought mattered, and four of them drifted: they reached the
157
+ * model but not the key, so two shoppers with genuinely different histories
158
+ * collided and were served each other's component. Hashing the whole digest
159
+ * makes that class of bug unreachable — a field cannot be forgotten here,
160
+ * because nothing is named here.
161
+ *
162
+ * The cost is a lower hit rate than a hand-tuned key would give, since every
163
+ * signal now moves the key. That is the right way round: a key that is too
164
+ * specific wastes money, and a key that is too loose shows one shopper another
165
+ * shopper's page. Raising the hit rate means deliberately coarsening the digest
166
+ * itself, which is a decision to make against measurements rather than by
167
+ * guessing here.
168
+ *
169
+ * Candidates enter as SKUs only. Their titles, prices and ratings reach the
170
+ * model too, but including them would churn the key on every price change for
171
+ * copy that would almost always come back the same. The consequence is bounded
172
+ * and worth stating: within one TTL, a price change does not refresh the
173
+ * generated copy. It cannot show a stale price — prices are read from the live
174
+ * catalog at render time, never from the model.
175
+ */
176
+ export function specCacheKey(
177
+ digest: SignalDigest,
178
+ candidateSkus: readonly string[],
179
+ providerId: string,
180
+ ): string {
181
+ const material = canonicalise({
182
+ // The spec's own version, so a shape change cannot read entries written by
183
+ // the previous shape out of a shared store that outlives a deploy.
184
+ specVersion: SPEC_VERSION,
185
+ provider: providerId,
186
+ digest,
187
+ candidates: candidateSkus.toSorted(),
188
+ });
189
+
190
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
191
+ }
192
+
193
+ // Leaves out the fields that make a key personal: who the shopper is, what they
194
+ // liked, viewed or searched for. Candidates go too, because every shopper's list
195
+ // is different — that is safe because the SKUs in a cohort spec get replaced
196
+ // before the page is served.
197
+ export function cohortCacheKey(
198
+ digest: SignalDigest,
199
+ candidateSkus: readonly string[],
200
+ providerId: string,
201
+ ): string {
202
+ const material = canonicalise({
203
+ specVersion: SPEC_VERSION,
204
+ provider: providerId,
205
+ segment: digest.segment ?? null,
206
+ surface: digest.surface,
207
+ slot: digest.slot,
208
+ locale: digest.locale,
209
+ maxItems: digest.maxItems,
210
+ isColdStart: digest.isColdStart,
211
+ // The page being looked at, so copy written for a backpack page is not
212
+ // served on a tent page.
213
+ currentCategory: digest.currentCategory ?? null,
214
+ topCategory: digest.categoryAffinity[0]?.category ?? null,
215
+ // The model is shown these, so they belong in the key. Normally they come
216
+ // from the page and everyone on it shares them. A shop that picks
217
+ // candidates per shopper gets smaller cohorts, which is the honest result:
218
+ // its prompt really is personal.
219
+ candidates: candidateSkus.toSorted(),
220
+ });
221
+
222
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
223
+ }