@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.
Files changed (66) hide show
  1. package/README.md +13 -12
  2. package/dist/claim-screening.d.ts +0 -23
  3. package/dist/claim-screening.d.ts.map +1 -1
  4. package/dist/claim-screening.js +0 -69
  5. package/dist/claim-screening.js.map +1 -1
  6. package/dist/component-generator.d.ts +0 -70
  7. package/dist/component-generator.d.ts.map +1 -1
  8. package/dist/component-generator.js +10 -52
  9. package/dist/component-generator.js.map +1 -1
  10. package/dist/component-spec.d.ts +2 -110
  11. package/dist/component-spec.d.ts.map +1 -1
  12. package/dist/component-spec.js +0 -78
  13. package/dist/component-spec.js.map +1 -1
  14. package/dist/fallback-component.d.ts +0 -9
  15. package/dist/fallback-component.d.ts.map +1 -1
  16. package/dist/fallback-component.js +0 -11
  17. package/dist/fallback-component.js.map +1 -1
  18. package/dist/fit-to-shopper.d.ts.map +1 -1
  19. package/dist/fit-to-shopper.js +1 -11
  20. package/dist/fit-to-shopper.js.map +1 -1
  21. package/dist/index.d.ts +0 -7
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -7
  24. package/dist/index.js.map +1 -1
  25. package/dist/model-prompt.d.ts +0 -29
  26. package/dist/model-prompt.d.ts.map +1 -1
  27. package/dist/model-prompt.js +0 -79
  28. package/dist/model-prompt.js.map +1 -1
  29. package/dist/product-selection.d.ts +0 -23
  30. package/dist/product-selection.d.ts.map +1 -1
  31. package/dist/product-selection.js +0 -19
  32. package/dist/product-selection.js.map +1 -1
  33. package/dist/provider.d.ts +0 -48
  34. package/dist/provider.d.ts.map +1 -1
  35. package/dist/provider.js +0 -5
  36. package/dist/provider.js.map +1 -1
  37. package/dist/reconciliation.d.ts +1 -44
  38. package/dist/reconciliation.d.ts.map +1 -1
  39. package/dist/reconciliation.js +2 -109
  40. package/dist/reconciliation.js.map +1 -1
  41. package/dist/signal-digest.d.ts +0 -27
  42. package/dist/signal-digest.d.ts.map +1 -1
  43. package/dist/signal-digest.js +1 -50
  44. package/dist/signal-digest.js.map +1 -1
  45. package/dist/spec-cache.d.ts +6 -80
  46. package/dist/spec-cache.d.ts.map +1 -1
  47. package/dist/spec-cache.js +1 -54
  48. package/dist/spec-cache.js.map +1 -1
  49. package/dist/tracking-input.d.ts +0 -49
  50. package/dist/tracking-input.d.ts.map +1 -1
  51. package/dist/tracking-input.js +0 -102
  52. package/dist/tracking-input.js.map +1 -1
  53. package/package.json +8 -7
  54. package/src/claim-screening.ts +17 -69
  55. package/src/component-generator.ts +24 -133
  56. package/src/component-spec.ts +15 -103
  57. package/src/fallback-component.ts +0 -11
  58. package/src/fit-to-shopper.ts +3 -12
  59. package/src/index.ts +0 -8
  60. package/src/model-prompt.ts +1 -82
  61. package/src/product-selection.ts +5 -43
  62. package/src/provider.ts +4 -53
  63. package/src/reconciliation.ts +12 -112
  64. package/src/signal-digest.ts +5 -64
  65. package/src/spec-cache.ts +8 -98
  66. package/src/tracking-input.ts +11 -108
@@ -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
- // Not rendered anywhere — this is the SKU as it reads back in a violation.
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
- /** True when something survived that is worth rendering. */
34
+
44
35
  isUsable: boolean;
45
- /** Machine-readable notes on what was removed or changed, for evaluation. */
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
- /** A bundle is placed whole, so it checks this narrower set. See `refusedEverywhere`. */
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
- // rejectionFor already proved the SKU is an in-stock candidate.
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
- ? clampedReason
230
+ ? clamp(item.reason ?? '', CLAMP.reason) || null
303
231
  : screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
304
- // The schema's own example badge was "Back in stock" — a stock claim, and it renders.
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
- // A hero without its product is still a legitimate headline.
354
+
447
355
  sku = null;
448
356
  } else {
449
357
  tracker.place(sku);
450
358
  }
451
359
  }
452
- // A hero with no headline and no product is the empty region every other kind drops for.
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
- // Never leave a grid wider than it has items to fill.
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
- // The model's bundleId is ignored on purpose.
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;
@@ -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
- /** Unnormalised. Only the ordering is meaningful — do not show this to anyone. */
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
- // Merged first, deliberately — see `mergeViewsBySku`. Views are noisy and
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
- const products: ViewedProduct[] = [];
252
- for (const merged of top) {
253
- const product: ViewedProduct = { sku: merged.sku, views: merged.views };
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
- /** Epoch milliseconds at which the model produced this. */
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
- /** Hard ceiling on entries. Least recently read is evicted first. */
20
+
47
21
  maxEntries?: number;
48
- /** Injectable clock, so tests do not have to wait. */
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: ProviderIdentity,
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: ProviderIdentity,
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
- // So copy written for a backpack page is not served on a tent page.
138
+
228
139
  currentCategory: digest.currentCategory ?? null,
229
140
  topCategory: digest.categoryAffinity[0]?.category ?? null,
230
- // The model is shown products drawn from this list, so a different list must not reuse the
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