@rudra-js/core 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/claim-screening.d.ts +0 -23
- package/dist/claim-screening.d.ts.map +1 -1
- package/dist/claim-screening.js +0 -69
- package/dist/claim-screening.js.map +1 -1
- package/dist/component-generator.d.ts +0 -70
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +10 -52
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -110
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +0 -78
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +0 -9
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +0 -11
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +1 -11
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +0 -29
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +0 -79
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -23
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +0 -19
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +0 -48
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +0 -5
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +1 -44
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +2 -109
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +0 -27
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +1 -50
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +6 -80
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +1 -54
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +0 -49
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +0 -102
- package/dist/tracking-input.js.map +1 -1
- package/package.json +8 -7
- package/src/claim-screening.ts +17 -69
- package/src/component-generator.ts +24 -133
- package/src/component-spec.ts +15 -103
- package/src/fallback-component.ts +0 -11
- package/src/fit-to-shopper.ts +3 -12
- package/src/index.ts +0 -8
- package/src/model-prompt.ts +1 -82
- package/src/product-selection.ts +5 -43
- package/src/provider.ts +4 -53
- package/src/reconciliation.ts +12 -112
- package/src/signal-digest.ts +5 -64
- package/src/spec-cache.ts +8 -98
- package/src/tracking-input.ts +11 -108
package/src/reconciliation.ts
CHANGED
|
@@ -9,13 +9,6 @@ import type {
|
|
|
9
9
|
import { engagedCategories, type SignalDigest } from './signal-digest.js';
|
|
10
10
|
import type { Bundle, Product, TrackingInput } from './tracking-input.js';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Nothing here trusts the model. Repair, not rejection, is the default: text is
|
|
14
|
-
* truncated and unverifiable claims are downgraded, and only an empty result
|
|
15
|
-
* fails outright.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** Truncation ceilings. Not in the schema — a provider's strict mode rejects length bounds. See `component-spec.ts`. */
|
|
19
12
|
const CLAMP = {
|
|
20
13
|
headline: 90,
|
|
21
14
|
subheadline: 140,
|
|
@@ -26,23 +19,21 @@ const CLAMP = {
|
|
|
26
19
|
bannerText: 160,
|
|
27
20
|
copyBody: 420,
|
|
28
21
|
rationale: 300,
|
|
29
|
-
|
|
22
|
+
|
|
30
23
|
violationSku: 32,
|
|
31
24
|
} as const;
|
|
32
25
|
|
|
33
|
-
/** More than this and the component stops being a component. */
|
|
34
26
|
export const MAX_BLOCKS = 4;
|
|
35
27
|
|
|
36
|
-
/** The fill pass calls this too: one past the cap never renders, so nothing is worth reserving for it. */
|
|
37
28
|
export function capBlocks(blocks: Block[]): Block[] {
|
|
38
29
|
return blocks.slice(0, MAX_BLOCKS);
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
export interface ReconcileResult {
|
|
42
33
|
spec: GeneratedSpec;
|
|
43
|
-
|
|
34
|
+
|
|
44
35
|
isUsable: boolean;
|
|
45
|
-
|
|
36
|
+
|
|
46
37
|
violations: string[];
|
|
47
38
|
}
|
|
48
39
|
|
|
@@ -50,35 +41,19 @@ function clamp(value: string, limit: number): string {
|
|
|
50
41
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
51
42
|
if (collapsed.length <= limit) return collapsed;
|
|
52
43
|
|
|
53
|
-
// The ellipsis counts against the limit, so leave room for it.
|
|
54
44
|
const cut = collapsed.slice(0, limit - 1);
|
|
55
45
|
const lastSpace = cut.lastIndexOf(' ');
|
|
56
46
|
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
57
47
|
return `${base.replace(/[.,;:!?-]+$/, '')}…`;
|
|
58
48
|
}
|
|
59
49
|
|
|
60
|
-
function clampNullable(value: string | null, limit: number): string | null {
|
|
61
|
-
if (value === null) return null;
|
|
62
|
-
const clamped = clamp(value, limit);
|
|
63
|
-
return clamped.length === 0 ? null : clamped;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
50
|
interface Allowlist {
|
|
67
51
|
allowed: Set<string>;
|
|
68
52
|
blocked: Set<string>;
|
|
69
|
-
|
|
53
|
+
|
|
70
54
|
blockedInBundle: Set<string>;
|
|
71
55
|
}
|
|
72
56
|
|
|
73
|
-
/**
|
|
74
|
-
* A thumbs-down, and the product on the screen right now. No block may hold one
|
|
75
|
-
* of these, a bundle included.
|
|
76
|
-
*
|
|
77
|
-
* Read from the payload rather than the digest on purpose. `DIGEST_LIMITS`
|
|
78
|
-
* trims each history to what fits in a prompt, and a shopper with thirteen
|
|
79
|
-
* dislikes would otherwise be shown the thirteenth. The cap exists to bound
|
|
80
|
-
* what the model is told, not what the shopper may be shown.
|
|
81
|
-
*/
|
|
82
57
|
function refusedEverywhere(input: TrackingInput): Set<string> {
|
|
83
58
|
const refused = new Set<string>();
|
|
84
59
|
for (const signal of input.signals.dislikes) refused.add(signal.sku);
|
|
@@ -87,17 +62,6 @@ function refusedEverywhere(input: TrackingInput): Set<string> {
|
|
|
87
62
|
return refused;
|
|
88
63
|
}
|
|
89
64
|
|
|
90
|
-
/**
|
|
91
|
-
* SKUs that must never be recommended, whatever chose them.
|
|
92
|
-
*
|
|
93
|
-
* Everything above plus what the shopper already has, read from the payload for
|
|
94
|
-
* the same reason: a shopper with nine purchases must not be sold the ninth back.
|
|
95
|
-
*
|
|
96
|
-
* Exported because the deterministic selector applies the same rule when it
|
|
97
|
-
* picks. Two copies of "never recommend these" would drift, and the pair that
|
|
98
|
-
* drifted would be the model path and the fallback path — the two whose
|
|
99
|
-
* comparability the whole evaluation depends on.
|
|
100
|
-
*/
|
|
101
65
|
export function neverRecommend(input: TrackingInput): Set<string> {
|
|
102
66
|
const { signals } = input;
|
|
103
67
|
|
|
@@ -136,9 +100,6 @@ function verifyBasis(
|
|
|
136
100
|
case 'complements_purchase':
|
|
137
101
|
return digest.purchasedSkus.length > 0;
|
|
138
102
|
case 'liked_category':
|
|
139
|
-
// Affinity alone is not enough: it scores the category the shopper is
|
|
140
|
-
// standing in, and standing somewhere is not liking it. That claim is
|
|
141
|
-
// `similar_to_current`, which says so honestly.
|
|
142
103
|
return (
|
|
143
104
|
engaged.has(product.category) &&
|
|
144
105
|
digest.categoryAffinity.some((affinity) => affinity.category === product.category)
|
|
@@ -146,17 +107,14 @@ function verifyBasis(
|
|
|
146
107
|
case 'similar_to_current':
|
|
147
108
|
return digest.currentCategory === product.category;
|
|
148
109
|
case 'popular':
|
|
149
|
-
// Makes no claim about this shopper, so there is nothing to check.
|
|
150
110
|
return true;
|
|
151
111
|
}
|
|
152
112
|
}
|
|
153
113
|
|
|
154
|
-
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
155
114
|
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
156
115
|
|
|
157
116
|
const NO_FACTS: readonly string[] = [];
|
|
158
117
|
|
|
159
|
-
/** The budget and the de-duplication set are global to a spec, not to a block. */
|
|
160
118
|
function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
161
119
|
const placedSkus = new Set<string>();
|
|
162
120
|
const violations: string[] = [];
|
|
@@ -182,14 +140,6 @@ function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
|
182
140
|
|
|
183
141
|
type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
184
142
|
|
|
185
|
-
/**
|
|
186
|
-
* Clamps, then screens. The cap has to happen here: clamping first can cut the word that
|
|
187
|
-
* makes a claim a claim, so "Ridge picks, only 2 left!" renders past the stock pattern.
|
|
188
|
-
*
|
|
189
|
-
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
190
|
-
* is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
|
|
191
|
-
* is a second denylist and the weakest, so it answers last.
|
|
192
|
-
*/
|
|
193
143
|
function screenClaim(
|
|
194
144
|
value: string | null,
|
|
195
145
|
limit: number,
|
|
@@ -202,15 +152,12 @@ function screenClaim(
|
|
|
202
152
|
const clamped = clamp(value, limit);
|
|
203
153
|
if (clamped.length === 0) return null;
|
|
204
154
|
|
|
205
|
-
// Both ways round: clamping collapses whitespace, so "in stock" only reads as a claim after.
|
|
206
155
|
const kind = claimIn(value) ?? claimIn(clamped);
|
|
207
156
|
if (kind !== null) {
|
|
208
157
|
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
209
158
|
return null;
|
|
210
159
|
}
|
|
211
160
|
|
|
212
|
-
// Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
|
|
213
|
-
// and reading the fact list to prove that again per field is most of what this costs.
|
|
214
161
|
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
215
162
|
|
|
216
163
|
const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
@@ -226,11 +173,6 @@ function screenClaim(
|
|
|
226
173
|
return clamped;
|
|
227
174
|
}
|
|
228
175
|
|
|
229
|
-
/**
|
|
230
|
-
* Emptying a field that cannot be null hands it to the rule that already drops a block,
|
|
231
|
-
* or a whole generation, whose text clamps to nothing — so a banner reading "20% off"
|
|
232
|
-
* disappears rather than rendering blank.
|
|
233
|
-
*/
|
|
234
176
|
function screenRequired(
|
|
235
177
|
value: string,
|
|
236
178
|
limit: number,
|
|
@@ -240,18 +182,9 @@ function screenRequired(
|
|
|
240
182
|
return screenClaim(value, limit, field, tracker) ?? '';
|
|
241
183
|
}
|
|
242
184
|
|
|
243
|
-
/**
|
|
244
|
-
* Does not consume budget — the caller does that once it commits.
|
|
245
|
-
*
|
|
246
|
-
* Order matters. The budget is checked last because it is the least specific cause:
|
|
247
|
-
* these strings are the evaluation signal, and reporting a hallucinated SKU as
|
|
248
|
-
* `budget:dropped` would understate how often the model invents products.
|
|
249
|
-
*/
|
|
250
185
|
function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
|
|
251
|
-
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
252
186
|
const named = clamp(sku, CLAMP.violationSku);
|
|
253
187
|
|
|
254
|
-
// Either hallucinated or out of stock. Either way it cannot render.
|
|
255
188
|
if (!allowlist.allowed.has(sku)) return `unknown-sku:${named}`;
|
|
256
189
|
if (allowlist.blocked.has(sku)) return `blocked-sku:${named}`;
|
|
257
190
|
if (tracker.hasPlaced(sku)) return `duplicate-sku:${named}`;
|
|
@@ -278,7 +211,7 @@ function reconcileItems(
|
|
|
278
211
|
}
|
|
279
212
|
|
|
280
213
|
const product = candidatesBySku.get(item.sku);
|
|
281
|
-
|
|
214
|
+
|
|
282
215
|
if (!product) continue;
|
|
283
216
|
|
|
284
217
|
tracker.place(item.sku);
|
|
@@ -286,22 +219,17 @@ function reconcileItems(
|
|
|
286
219
|
const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
|
|
287
220
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
288
221
|
|
|
289
|
-
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
290
222
|
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
291
223
|
|
|
292
224
|
const own = tracker.factsFor(item.sku);
|
|
293
|
-
const clampedReason = clampNullable(item.reason, CLAMP.reason);
|
|
294
225
|
|
|
295
|
-
// The prose exists to state the basis. If the basis did not hold, the prose
|
|
296
|
-
// is untrue — and the badge is prose too. "You viewed this" beside a
|
|
297
|
-
// downgraded pick says the same thing the dropped sentence did.
|
|
298
226
|
let reason: string | null = null;
|
|
299
227
|
let badge: string | null = null;
|
|
300
228
|
if (hasSupportedBasis) {
|
|
301
229
|
reason = isOurs
|
|
302
|
-
?
|
|
230
|
+
? clamp(item.reason ?? '', CLAMP.reason) || null
|
|
303
231
|
: screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
|
|
304
|
-
|
|
232
|
+
|
|
305
233
|
badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
|
|
306
234
|
}
|
|
307
235
|
|
|
@@ -317,7 +245,6 @@ function reconcileItems(
|
|
|
317
245
|
return kept;
|
|
318
246
|
}
|
|
319
247
|
|
|
320
|
-
// Cart beats views beats category however the counts compare, so they can't be summed.
|
|
321
248
|
interface BundleFit {
|
|
322
249
|
cartHits: number;
|
|
323
250
|
viewedHits: number;
|
|
@@ -378,17 +305,6 @@ function chooseBundle(
|
|
|
378
305
|
return best;
|
|
379
306
|
}
|
|
380
307
|
|
|
381
|
-
/**
|
|
382
|
-
* The set this shopper should get, decided before anything is placed.
|
|
383
|
-
*
|
|
384
|
-
* The generator needs the answer early, so it can keep the set's products out
|
|
385
|
-
* of the grid and keep room for them. `chooseBundle` stays private: this hands
|
|
386
|
-
* out the choice, not the machinery behind it.
|
|
387
|
-
*
|
|
388
|
-
* `spokenFor` is what the blocks above the bundle block will have placed by the
|
|
389
|
-
* time it is reached. Placing it here first is what makes the two choices agree:
|
|
390
|
-
* a set is only pre-chosen if reconciliation could still reach for it.
|
|
391
|
-
*/
|
|
392
308
|
export function bundleForShopper(
|
|
393
309
|
input: TrackingInput,
|
|
394
310
|
digest: SignalDigest,
|
|
@@ -403,14 +319,6 @@ export function bundleForShopper(
|
|
|
403
319
|
return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
|
|
404
320
|
}
|
|
405
321
|
|
|
406
|
-
/**
|
|
407
|
-
* The hero products these blocks will really place.
|
|
408
|
-
*
|
|
409
|
-
* A hero keeps the product the model named — its headline was written about
|
|
410
|
-
* that product — so it spends a slot of the item budget the grid cannot have.
|
|
411
|
-
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
412
|
-
* counted here either.
|
|
413
|
-
*/
|
|
414
322
|
export function placeableHeroSkus(blocks: readonly Block[], input: TrackingInput): string[] {
|
|
415
323
|
const allowlist = buildAllowlist(input);
|
|
416
324
|
|
|
@@ -443,13 +351,13 @@ function reconcileBlock(
|
|
|
443
351
|
const rejection = rejectionFor(sku, allowlist, tracker);
|
|
444
352
|
if (rejection) {
|
|
445
353
|
tracker.record(rejection);
|
|
446
|
-
|
|
354
|
+
|
|
447
355
|
sku = null;
|
|
448
356
|
} else {
|
|
449
357
|
tracker.place(sku);
|
|
450
358
|
}
|
|
451
359
|
}
|
|
452
|
-
|
|
360
|
+
|
|
453
361
|
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
|
|
454
362
|
if (headline.length === 0 && sku === null) {
|
|
455
363
|
tracker.record('empty-block:hero');
|
|
@@ -481,7 +389,7 @@ function reconcileBlock(
|
|
|
481
389
|
return {
|
|
482
390
|
kind: 'grid',
|
|
483
391
|
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
|
|
484
|
-
|
|
392
|
+
|
|
485
393
|
columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
|
|
486
394
|
items,
|
|
487
395
|
};
|
|
@@ -549,7 +457,7 @@ function reconcileBlock(
|
|
|
549
457
|
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
|
|
550
458
|
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
|
|
551
459
|
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
|
|
552
|
-
|
|
460
|
+
|
|
553
461
|
bundleId: chosen.id,
|
|
554
462
|
};
|
|
555
463
|
}
|
|
@@ -570,14 +478,7 @@ export function reconcileSpec(
|
|
|
570
478
|
generated: GeneratedSpec,
|
|
571
479
|
input: TrackingInput,
|
|
572
480
|
digest: SignalDigest,
|
|
573
|
-
|
|
574
|
-
* The reason this request wrote itself, by SKU: the host's own `reason` on a
|
|
575
|
-
* candidate, or the sentence the selector wrote when there was none. Neither is the
|
|
576
|
-
* model's words, and the deterministic component renders the same sentence unscreened.
|
|
577
|
-
*
|
|
578
|
-
* Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
579
|
-
* reason is the model's, including one that happens to read the same.
|
|
580
|
-
*/
|
|
481
|
+
|
|
581
482
|
ourReasons: ReadonlyMap<string, string> = new Map(),
|
|
582
483
|
): ReconcileResult {
|
|
583
484
|
const allowlist = buildAllowlist(input);
|
|
@@ -612,7 +513,6 @@ export function reconcileSpec(
|
|
|
612
513
|
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
|
|
613
514
|
};
|
|
614
515
|
|
|
615
|
-
// Recorded separately: no products and no headline are different failures.
|
|
616
516
|
if (!showsAnyProduct(blocks)) tracker.record('unusable:no-products');
|
|
617
517
|
if (spec.headline.length === 0) tracker.record('unusable:no-headline');
|
|
618
518
|
const isUsable = showsAnyProduct(blocks) && spec.headline.length > 0;
|
package/src/signal-digest.ts
CHANGED
|
@@ -6,22 +6,9 @@ import type {
|
|
|
6
6
|
ViewSignal,
|
|
7
7
|
} from './tracking-input.js';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Reduces a validated tracking payload to the compact, ordered, bounded view
|
|
11
|
-
* that everything downstream reads.
|
|
12
|
-
*
|
|
13
|
-
* Two jobs:
|
|
14
|
-
* 1. Keep the volatile part of a prompt small and stable. The contract lets a
|
|
15
|
-
* host send 500 signals per category; a prompt cannot afford them, and the
|
|
16
|
-
* long tail is noise anyway.
|
|
17
|
-
* 2. Give the deterministic path the same evidence the model gets, so a
|
|
18
|
-
* degraded render is a weaker version of the same decision rather than an
|
|
19
|
-
* unrelated one.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
9
|
export interface CategoryAffinity {
|
|
23
10
|
category: string;
|
|
24
|
-
|
|
11
|
+
|
|
25
12
|
score: number;
|
|
26
13
|
}
|
|
27
14
|
|
|
@@ -58,14 +45,9 @@ export interface SignalDigest {
|
|
|
58
45
|
categoryAffinity: CategoryAffinity[];
|
|
59
46
|
interactionCounts: InteractionCount[];
|
|
60
47
|
|
|
61
|
-
/** True when there is no behavioural evidence to personalise on. */
|
|
62
48
|
isColdStart: boolean;
|
|
63
49
|
}
|
|
64
50
|
|
|
65
|
-
/**
|
|
66
|
-
* How much of each signal category survives into the digest. Chosen to keep the
|
|
67
|
-
* per-shopper part of a prompt in the low hundreds of tokens.
|
|
68
|
-
*/
|
|
69
51
|
export const DIGEST_LIMITS = {
|
|
70
52
|
liked: 12,
|
|
71
53
|
disliked: 12,
|
|
@@ -77,7 +59,6 @@ export const DIGEST_LIMITS = {
|
|
|
77
59
|
interactionTypes: 8,
|
|
78
60
|
} as const;
|
|
79
61
|
|
|
80
|
-
/** How much intent each kind of signal implies. */
|
|
81
62
|
const SIGNAL_WEIGHTS = {
|
|
82
63
|
purchase: 5,
|
|
83
64
|
like: 4,
|
|
@@ -86,15 +67,8 @@ const SIGNAL_WEIGHTS = {
|
|
|
86
67
|
dislike: -6,
|
|
87
68
|
} as const;
|
|
88
69
|
|
|
89
|
-
/**
|
|
90
|
-
* A signal that does not state a weight counts at full strength. This lives in
|
|
91
|
-
* one place on purpose: when the default was written out at each call site, the
|
|
92
|
-
* merge step defaulted to 0 while scoring defaulted to 1, and an unweighted view
|
|
93
|
-
* merged with a `weight: 0.2` view scored as though both were 0.2.
|
|
94
|
-
*/
|
|
95
70
|
const effectiveWeight = (signal: { weight?: number | undefined }): number => signal.weight ?? 1;
|
|
96
71
|
|
|
97
|
-
/** Most recent first. A signal with no timestamp sorts last. */
|
|
98
72
|
function byMostRecent(
|
|
99
73
|
left: { at?: number | undefined },
|
|
100
74
|
right: { at?: number | undefined },
|
|
@@ -148,9 +122,7 @@ function computeCategoryAffinity(
|
|
|
148
122
|
for (const inCart of signals.cart) {
|
|
149
123
|
addScore(categoryOf(inCart, candidatesBySku), SIGNAL_WEIGHTS.cart * effectiveWeight(inCart));
|
|
150
124
|
}
|
|
151
|
-
|
|
152
|
-
// repeat cheaply, so the tenth view counts for far less than the second, and
|
|
153
|
-
// log scaling keeps a single obsessive session from drowning out a purchase.
|
|
125
|
+
|
|
154
126
|
for (const view of mergeViewsBySku(signals.mostViewed)) {
|
|
155
127
|
const scaledViews = Math.log2(1 + view.views);
|
|
156
128
|
addScore(categoryOf(view, candidatesBySku), SIGNAL_WEIGHTS.view * scaledViews * view.weight);
|
|
@@ -162,8 +134,6 @@ function computeCategoryAffinity(
|
|
|
162
134
|
);
|
|
163
135
|
}
|
|
164
136
|
|
|
165
|
-
// The category the shopper is standing in right now is itself evidence — for
|
|
166
|
-
// ranking. It is not evidence that they like it: see `engagedCategories`.
|
|
167
137
|
addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
|
|
168
138
|
|
|
169
139
|
const affinities: CategoryAffinity[] = [];
|
|
@@ -177,15 +147,6 @@ function computeCategoryAffinity(
|
|
|
177
147
|
.slice(0, DIGEST_LIMITS.affinity);
|
|
178
148
|
}
|
|
179
149
|
|
|
180
|
-
/**
|
|
181
|
-
* Categories this shopper did something in — bought, liked, carted or viewed.
|
|
182
|
-
*
|
|
183
|
-
* Not the same thing as `categoryAffinity`, which also scores the category the
|
|
184
|
-
* page itself is in. That scoring is right for ranking and wrong as evidence:
|
|
185
|
-
* a first-time visitor standing on a tent page has a tent affinity and has
|
|
186
|
-
* never touched a tent, so a sentence claiming they keep coming back to tents
|
|
187
|
-
* is checked against this instead.
|
|
188
|
-
*/
|
|
189
150
|
export function engagedCategories(input: TrackingInput): Set<string> {
|
|
190
151
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
191
152
|
const { signals } = input;
|
|
@@ -209,16 +170,6 @@ interface MergedView extends ViewedProduct {
|
|
|
209
170
|
weight: number;
|
|
210
171
|
}
|
|
211
172
|
|
|
212
|
-
/**
|
|
213
|
-
* Collapses every view record for one SKU into a single running total.
|
|
214
|
-
*
|
|
215
|
-
* This has to happen before any scoring. A host is free to emit one record per
|
|
216
|
-
* page view rather than a running count, and scoring each record separately
|
|
217
|
-
* would let thirty `views: 1` records outweigh one `views: 30` record six times
|
|
218
|
-
* over, defeating the sub-linear scaling in `computeCategoryAffinity` entirely.
|
|
219
|
-
* Merging first makes the score depend on how much someone looked, not on how
|
|
220
|
-
* their tracking pipeline happens to batch.
|
|
221
|
-
*/
|
|
222
173
|
function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
223
174
|
const totalsBySku = new Map<string, MergedView>();
|
|
224
175
|
|
|
@@ -248,13 +199,9 @@ function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
|
|
|
248
199
|
.toSorted((left, right) => right.views - left.views)
|
|
249
200
|
.slice(0, DIGEST_LIMITS.viewed);
|
|
250
201
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (merged.dwellMs !== undefined) product.dwellMs = merged.dwellMs;
|
|
255
|
-
products.push(product);
|
|
256
|
-
}
|
|
257
|
-
return products;
|
|
202
|
+
return top.map(({ sku, views: count, dwellMs }) =>
|
|
203
|
+
dwellMs === undefined ? { sku, views: count } : { sku, views: count, dwellMs },
|
|
204
|
+
);
|
|
258
205
|
}
|
|
259
206
|
|
|
260
207
|
function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
|
|
@@ -281,8 +228,6 @@ export function buildDigest(input: TrackingInput): SignalDigest {
|
|
|
281
228
|
const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
|
|
282
229
|
const topViewed = mostViewedProducts(signals.mostViewed);
|
|
283
230
|
|
|
284
|
-
// Searches say what a shopper wants; they do not say they engaged with any
|
|
285
|
-
// product, so they do not lift a shopper out of cold start on their own.
|
|
286
231
|
const evidenceCount =
|
|
287
232
|
likedSkus.length +
|
|
288
233
|
dislikedSkus.length +
|
|
@@ -316,13 +261,9 @@ export function buildDigest(input: TrackingInput): SignalDigest {
|
|
|
316
261
|
};
|
|
317
262
|
}
|
|
318
263
|
|
|
319
|
-
// Everything the cohort key leaves out has to leave the prompt too, or the
|
|
320
|
-
// first shopper's searches and history end up shaping copy that is cached and
|
|
321
|
-
// served to everyone else in their cohort.
|
|
322
264
|
export function toCohortDigest(digest: SignalDigest): SignalDigest {
|
|
323
265
|
const top = digest.categoryAffinity[0];
|
|
324
266
|
|
|
325
|
-
// Listed rather than spread, so what survives is the thing you read.
|
|
326
267
|
const cohort: SignalDigest = {
|
|
327
268
|
userId: 'cohort',
|
|
328
269
|
isReturning: false,
|
package/src/spec-cache.ts
CHANGED
|
@@ -3,37 +3,12 @@ import { SPEC_VERSION, type GeneratedSpec } from './component-spec.js';
|
|
|
3
3
|
import { SYSTEM_PROMPT } from './model-prompt.js';
|
|
4
4
|
import type { SignalDigest } from './signal-digest.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Where a generated component is kept between requests.
|
|
8
|
-
*
|
|
9
|
-
* A generation is stable for as long as the shopper's signals are, so asking a
|
|
10
|
-
* model again on the next page view buys nothing and costs a round trip on the
|
|
11
|
-
* render path — which is the latency this whole design exists to avoid.
|
|
12
|
-
*
|
|
13
|
-
* The store is a port rather than an implementation. A single process can use
|
|
14
|
-
* the in-memory one; anything running more than one instance needs a shared
|
|
15
|
-
* store, or each instance keeps its own copy and the hit rate divides by the
|
|
16
|
-
* instance count.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A stored generation, with the moment it was produced.
|
|
21
|
-
*
|
|
22
|
-
* The timestamp travels with the spec because it cannot be recovered later: a
|
|
23
|
-
* component served from cache is not newly generated, and `generatedAt` is the
|
|
24
|
-
* only way anything downstream can tell how stale what it is showing has become.
|
|
25
|
-
*/
|
|
26
6
|
export interface CachedSpec {
|
|
27
7
|
spec: GeneratedSpec;
|
|
28
|
-
|
|
8
|
+
|
|
29
9
|
generatedAt: number;
|
|
30
10
|
}
|
|
31
11
|
|
|
32
|
-
/**
|
|
33
|
-
* Asynchronous on purpose. A synchronous `get` would look simpler and would
|
|
34
|
-
* make the shared store the docs recommend impossible to write, since no Redis
|
|
35
|
-
* or Memcached client can return a value without awaiting.
|
|
36
|
-
*/
|
|
37
12
|
export interface SpecCache {
|
|
38
13
|
get(key: string): Promise<CachedSpec | undefined>;
|
|
39
14
|
set(key: string, cached: CachedSpec): Promise<void>;
|
|
@@ -41,11 +16,10 @@ export interface SpecCache {
|
|
|
41
16
|
}
|
|
42
17
|
|
|
43
18
|
export interface MemorySpecCacheOptions {
|
|
44
|
-
/** How long an entry stays valid. Defaults to 60 seconds. */
|
|
45
19
|
ttlMs?: number;
|
|
46
|
-
|
|
20
|
+
|
|
47
21
|
maxEntries?: number;
|
|
48
|
-
|
|
22
|
+
|
|
49
23
|
now?: () => number;
|
|
50
24
|
}
|
|
51
25
|
|
|
@@ -63,20 +37,10 @@ interface CacheEntry {
|
|
|
63
37
|
expiresAt: number;
|
|
64
38
|
}
|
|
65
39
|
|
|
66
|
-
/**
|
|
67
|
-
* A bounded in-process cache.
|
|
68
|
-
*
|
|
69
|
-
* `maxEntries` is generous relative to the default TTL for a reason: a ceiling
|
|
70
|
-
* low enough to evict entries before they expire silently turns a longer TTL
|
|
71
|
-
* into a setting that does nothing, and the person who raised it has no way to
|
|
72
|
-
* tell.
|
|
73
|
-
*/
|
|
74
40
|
export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): SpecCache {
|
|
75
41
|
const ttlMs = options.ttlMs ?? 60_000;
|
|
76
42
|
const maxEntries = options.maxEntries ?? 10_000;
|
|
77
43
|
|
|
78
|
-
// Usually `Number(process.env.X)`: an unset variable makes these NaN, and every
|
|
79
|
-
// comparison against NaN is false, so nothing would ever expire or be evicted.
|
|
80
44
|
assertFiniteAtLeastZero('ttlMs', ttlMs);
|
|
81
45
|
assertFiniteAtLeastZero('maxEntries', maxEntries);
|
|
82
46
|
if (!Number.isSafeInteger(maxEntries)) {
|
|
@@ -96,8 +60,6 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
96
60
|
return undefined;
|
|
97
61
|
}
|
|
98
62
|
|
|
99
|
-
// Re-insert so insertion order tracks recency, which makes the eviction below
|
|
100
|
-
// least-recently-used rather than oldest-written.
|
|
101
63
|
entries.delete(key);
|
|
102
64
|
entries.set(key, entry);
|
|
103
65
|
return entry.cached;
|
|
@@ -120,25 +82,15 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
120
82
|
};
|
|
121
83
|
}
|
|
122
84
|
|
|
123
|
-
/**
|
|
124
|
-
* A cache that stores nothing, so every request generates.
|
|
125
|
-
*
|
|
126
|
-
* This is not a stub. It is the control for any measurement of generation cost
|
|
127
|
-
* or latency: with a cache in front, a benchmark reports how often it hit, not
|
|
128
|
-
* what generating costs.
|
|
129
|
-
*/
|
|
130
85
|
export function createNullSpecCache(): SpecCache {
|
|
131
86
|
return {
|
|
132
87
|
async get() {
|
|
133
88
|
return undefined;
|
|
134
89
|
},
|
|
135
|
-
async set() {
|
|
136
|
-
// Deliberately nothing.
|
|
137
|
-
},
|
|
90
|
+
async set() {},
|
|
138
91
|
};
|
|
139
92
|
}
|
|
140
93
|
|
|
141
|
-
/** Stable JSON: object keys sorted, so key order in a digest cannot matter. */
|
|
142
94
|
function canonicalise(value: unknown): string {
|
|
143
95
|
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
144
96
|
if (Array.isArray(value)) return `[${value.map(canonicalise).join(',')}]`;
|
|
@@ -152,52 +104,12 @@ function canonicalise(value: unknown): string {
|
|
|
152
104
|
|
|
153
105
|
const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
|
|
154
106
|
|
|
155
|
-
/**
|
|
156
|
-
* Who answered, as two named fields rather than one joined string.
|
|
157
|
-
*
|
|
158
|
-
* Joining them with a colon is ambiguous, and model ids carry colons — an
|
|
159
|
-
* `anthropic` / `claude:1` pair and an `anthropic:claude` / `1` pair flatten to
|
|
160
|
-
* the same text and read each other's entries out of a shared store. The
|
|
161
|
-
* provider is in the key precisely so that cannot happen.
|
|
162
|
-
*/
|
|
163
|
-
export interface ProviderIdentity {
|
|
164
|
-
name: string;
|
|
165
|
-
model: string;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Derives the cache key.
|
|
170
|
-
*
|
|
171
|
-
* The digest goes in **whole**, rather than as a hand-picked list of fields.
|
|
172
|
-
* That is the entire point of this function. The previous implementation listed
|
|
173
|
-
* the fields it thought mattered, and four of them drifted: they reached the
|
|
174
|
-
* model but not the key, so two shoppers with genuinely different histories
|
|
175
|
-
* collided and were served each other's component. Hashing the whole digest
|
|
176
|
-
* makes that class of bug unreachable — a field cannot be forgotten here,
|
|
177
|
-
* because nothing is named here.
|
|
178
|
-
*
|
|
179
|
-
* The cost is a lower hit rate than a hand-tuned key would give, since every
|
|
180
|
-
* signal now moves the key. That is the right way round: a key that is too
|
|
181
|
-
* specific wastes money, and a key that is too loose shows one shopper another
|
|
182
|
-
* shopper's page. Raising the hit rate means deliberately coarsening the digest
|
|
183
|
-
* itself, which is a decision to make against measurements rather than by
|
|
184
|
-
* guessing here.
|
|
185
|
-
*
|
|
186
|
-
* Candidates enter as SKUs only. Their titles, prices and ratings reach the
|
|
187
|
-
* model too, but including them would churn the key on every price change for
|
|
188
|
-
* copy that would almost always come back the same. The consequence is bounded
|
|
189
|
-
* and worth stating: within one TTL, a price change does not refresh the
|
|
190
|
-
* generated copy. It cannot show a stale price — prices are read from the live
|
|
191
|
-
* catalog at render time, never from the model.
|
|
192
|
-
*/
|
|
193
107
|
export function specCacheKey(
|
|
194
108
|
digest: SignalDigest,
|
|
195
109
|
candidateSkus: readonly string[],
|
|
196
|
-
provider:
|
|
110
|
+
provider: { name: string; model: string },
|
|
197
111
|
): string {
|
|
198
112
|
const material = canonicalise({
|
|
199
|
-
// So a shape change cannot read entries written by the previous shape out of
|
|
200
|
-
// a shared store that outlives a deploy.
|
|
201
113
|
specVersion: SPEC_VERSION,
|
|
202
114
|
prompt: PROMPT_FINGERPRINT,
|
|
203
115
|
provider,
|
|
@@ -208,11 +120,10 @@ export function specCacheKey(
|
|
|
208
120
|
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
209
121
|
}
|
|
210
122
|
|
|
211
|
-
// Leaves out the personal fields: who the shopper is, what they liked, viewed or searched.
|
|
212
123
|
export function cohortCacheKey(
|
|
213
124
|
digest: SignalDigest,
|
|
214
125
|
candidateSkus: readonly string[],
|
|
215
|
-
provider:
|
|
126
|
+
provider: { name: string; model: string },
|
|
216
127
|
): string {
|
|
217
128
|
const material = canonicalise({
|
|
218
129
|
specVersion: SPEC_VERSION,
|
|
@@ -224,11 +135,10 @@ export function cohortCacheKey(
|
|
|
224
135
|
locale: digest.locale,
|
|
225
136
|
maxItems: digest.maxItems,
|
|
226
137
|
isColdStart: digest.isColdStart,
|
|
227
|
-
|
|
138
|
+
|
|
228
139
|
currentCategory: digest.currentCategory ?? null,
|
|
229
140
|
topCategory: digest.categoryAffinity[0]?.category ?? null,
|
|
230
|
-
|
|
231
|
-
// copy. A shop that picks candidates per shopper gets smaller cohorts, not a wrong answer.
|
|
141
|
+
|
|
232
142
|
candidates: candidateSkus.toSorted(),
|
|
233
143
|
});
|
|
234
144
|
|