@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.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/component-generator.d.ts +84 -0
- package/dist/component-generator.d.ts.map +1 -0
- package/dist/component-generator.js +331 -0
- package/dist/component-generator.js.map +1 -0
- package/dist/component-spec.d.ts +426 -0
- package/dist/component-spec.d.ts.map +1 -0
- package/dist/component-spec.js +170 -0
- package/dist/component-spec.js.map +1 -0
- package/dist/fallback-component.d.ts +11 -0
- package/dist/fallback-component.d.ts.map +1 -0
- package/dist/fallback-component.js +69 -0
- package/dist/fallback-component.js.map +1 -0
- package/dist/fit-to-shopper.d.ts +4 -0
- package/dist/fit-to-shopper.d.ts.map +1 -0
- package/dist/fit-to-shopper.js +36 -0
- package/dist/fit-to-shopper.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/model-prompt.d.ts +29 -0
- package/dist/model-prompt.d.ts.map +1 -0
- package/dist/model-prompt.js +227 -0
- package/dist/model-prompt.js.map +1 -0
- package/dist/product-selection.d.ts +33 -0
- package/dist/product-selection.d.ts.map +1 -0
- package/dist/product-selection.js +102 -0
- package/dist/product-selection.js.map +1 -0
- package/dist/provider.d.ts +80 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +23 -0
- package/dist/provider.js.map +1 -0
- package/dist/reconciliation.d.ts +49 -0
- package/dist/reconciliation.d.ts.map +1 -0
- package/dist/reconciliation.js +564 -0
- package/dist/reconciliation.js.map +1 -0
- package/dist/signal-digest.d.ts +66 -0
- package/dist/signal-digest.d.ts.map +1 -0
- package/dist/signal-digest.js +224 -0
- package/dist/signal-digest.js.map +1 -0
- package/dist/spec-cache.d.ts +88 -0
- package/dist/spec-cache.d.ts.map +1 -0
- package/dist/spec-cache.js +152 -0
- package/dist/spec-cache.js.map +1 -0
- package/dist/tracking-input.d.ts +258 -0
- package/dist/tracking-input.d.ts.map +1 -0
- package/dist/tracking-input.js +241 -0
- package/dist/tracking-input.js.map +1 -0
- package/package.json +60 -0
- package/src/component-generator.ts +521 -0
- package/src/component-spec.ts +243 -0
- package/src/fallback-component.ts +77 -0
- package/src/fit-to-shopper.ts +45 -0
- package/src/index.ts +102 -0
- package/src/model-prompt.ts +258 -0
- package/src/product-selection.ts +153 -0
- package/src/provider.ts +98 -0
- package/src/reconciliation.ts +675 -0
- package/src/signal-digest.ts +335 -0
- package/src/spec-cache.ts +223 -0
- 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
|
+
}
|