@rudra-js/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +64 -16
  2. package/dist/claim-screening.d.ts +10 -0
  3. package/dist/claim-screening.d.ts.map +1 -0
  4. package/dist/claim-screening.js +157 -0
  5. package/dist/claim-screening.js.map +1 -0
  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 +27 -135
  9. package/dist/component-generator.js.map +1 -1
  10. package/dist/component-spec.d.ts +4 -159
  11. package/dist/component-spec.d.ts.map +1 -1
  12. package/dist/component-spec.js +0 -92
  13. package/dist/component-spec.js.map +1 -1
  14. package/dist/fallback-component.d.ts +0 -6
  15. package/dist/fallback-component.d.ts.map +1 -1
  16. package/dist/fallback-component.js +0 -21
  17. package/dist/fallback-component.js.map +1 -1
  18. package/dist/fit-to-shopper.d.ts +5 -1
  19. package/dist/fit-to-shopper.d.ts.map +1 -1
  20. package/dist/fit-to-shopper.js +5 -14
  21. package/dist/fit-to-shopper.js.map +1 -1
  22. package/dist/index.d.ts +1 -8
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +0 -7
  25. package/dist/index.js.map +1 -1
  26. package/dist/model-prompt.d.ts +1 -30
  27. package/dist/model-prompt.d.ts.map +1 -1
  28. package/dist/model-prompt.js +19 -78
  29. package/dist/model-prompt.js.map +1 -1
  30. package/dist/product-selection.d.ts +0 -29
  31. package/dist/product-selection.d.ts.map +1 -1
  32. package/dist/product-selection.js +1 -22
  33. package/dist/product-selection.js.map +1 -1
  34. package/dist/provider.d.ts +0 -55
  35. package/dist/provider.d.ts.map +1 -1
  36. package/dist/provider.js +0 -12
  37. package/dist/provider.js.map +1 -1
  38. package/dist/reconciliation.d.ts +6 -61
  39. package/dist/reconciliation.d.ts.map +1 -1
  40. package/dist/reconciliation.js +76 -466
  41. package/dist/reconciliation.js.map +1 -1
  42. package/dist/signal-digest.d.ts +1 -18
  43. package/dist/signal-digest.d.ts.map +1 -1
  44. package/dist/signal-digest.js +27 -66
  45. package/dist/signal-digest.js.map +1 -1
  46. package/dist/spec-cache.d.ts +8 -70
  47. package/dist/spec-cache.d.ts.map +1 -1
  48. package/dist/spec-cache.js +5 -68
  49. package/dist/spec-cache.js.map +1 -1
  50. package/dist/tracking-input.d.ts +0 -49
  51. package/dist/tracking-input.d.ts.map +1 -1
  52. package/dist/tracking-input.js +5 -105
  53. package/dist/tracking-input.js.map +1 -1
  54. package/package.json +7 -6
  55. package/src/claim-screening.ts +190 -0
  56. package/src/component-generator.ts +45 -240
  57. package/src/component-spec.ts +17 -118
  58. package/src/fallback-component.ts +0 -22
  59. package/src/fit-to-shopper.ts +12 -16
  60. package/src/index.ts +1 -8
  61. package/src/model-prompt.ts +25 -84
  62. package/src/product-selection.ts +6 -52
  63. package/src/provider.ts +4 -60
  64. package/src/reconciliation.ts +104 -499
  65. package/src/signal-digest.ts +34 -83
  66. package/src/spec-cache.ts +10 -97
  67. package/src/tracking-input.ts +17 -111
@@ -1,28 +1,14 @@
1
1
  import type {
2
2
  Interaction,
3
3
  Product,
4
- PurchaseSignal,
5
4
  SkuSignal,
6
5
  TrackingInput,
7
6
  ViewSignal,
8
7
  } from './tracking-input.js';
9
8
 
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
9
  export interface CategoryAffinity {
24
10
  category: string;
25
- /** Unnormalised. Only the ordering is meaningful — do not show this to anyone. */
11
+
26
12
  score: number;
27
13
  }
28
14
 
@@ -59,14 +45,9 @@ export interface SignalDigest {
59
45
  categoryAffinity: CategoryAffinity[];
60
46
  interactionCounts: InteractionCount[];
61
47
 
62
- /** True when there is no behavioural evidence to personalise on. */
63
48
  isColdStart: boolean;
64
49
  }
65
50
 
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
51
  export const DIGEST_LIMITS = {
71
52
  liked: 12,
72
53
  disliked: 12,
@@ -78,11 +59,6 @@ export const DIGEST_LIMITS = {
78
59
  interactionTypes: 8,
79
60
  } as const;
80
61
 
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
62
  const SIGNAL_WEIGHTS = {
87
63
  purchase: 5,
88
64
  like: 4,
@@ -91,15 +67,8 @@ const SIGNAL_WEIGHTS = {
91
67
  dislike: -6,
92
68
  } as const;
93
69
 
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
70
  const effectiveWeight = (signal: { weight?: number | undefined }): number => signal.weight ?? 1;
101
71
 
102
- /** Most recent first. A signal with no timestamp sorts last. */
103
72
  function byMostRecent(
104
73
  left: { at?: number | undefined },
105
74
  right: { at?: number | undefined },
@@ -107,7 +76,6 @@ function byMostRecent(
107
76
  return (right.at ?? 0) - (left.at ?? 0);
108
77
  }
109
78
 
110
- /** The most recent `limit` SKUs, each appearing once. */
111
79
  function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
112
80
  const skus: string[] = [];
113
81
  const alreadySeen = new Set<string>();
@@ -122,11 +90,6 @@ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
122
90
  return skus;
123
91
  }
124
92
 
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
93
  function categoryOf(
131
94
  signal: { sku: string; category?: string | undefined },
132
95
  candidatesBySku: Map<string, Product>,
@@ -159,9 +122,7 @@ function computeCategoryAffinity(
159
122
  for (const inCart of signals.cart) {
160
123
  addScore(categoryOf(inCart, candidatesBySku), SIGNAL_WEIGHTS.cart * effectiveWeight(inCart));
161
124
  }
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.
125
+
165
126
  for (const view of mergeViewsBySku(signals.mostViewed)) {
166
127
  const scaledViews = Math.log2(1 + view.views);
167
128
  addScore(categoryOf(view, candidatesBySku), SIGNAL_WEIGHTS.view * scaledViews * view.weight);
@@ -173,37 +134,42 @@ function computeCategoryAffinity(
173
134
  );
174
135
  }
175
136
 
176
- // The category the shopper is standing in right now is itself evidence.
177
137
  addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
178
138
 
179
- return [...scoreByCategory.entries()]
180
- .map(([category, score]) => ({
181
- category,
182
- score: Math.round(score * 100) / 100,
183
- }))
184
- .filter((affinity) => affinity.score > 0)
139
+ const affinities: CategoryAffinity[] = [];
140
+ for (const [category, score] of scoreByCategory) {
141
+ const rounded = Math.round(score * 100) / 100;
142
+ if (rounded > 0) affinities.push({ category, score: rounded });
143
+ }
144
+
145
+ return affinities
185
146
  .toSorted((left, right) => right.score - left.score)
186
147
  .slice(0, DIGEST_LIMITS.affinity);
187
148
  }
188
149
 
150
+ export function engagedCategories(input: TrackingInput): Set<string> {
151
+ const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
152
+ const { signals } = input;
153
+ const categories = new Set<string>();
154
+
155
+ for (const signal of [
156
+ ...signals.lastPurchased,
157
+ ...signals.likes,
158
+ ...signals.cart,
159
+ ...signals.mostViewed,
160
+ ]) {
161
+ const category = categoryOf(signal, candidatesBySku);
162
+ if (category) categories.add(category);
163
+ }
164
+
165
+ return categories;
166
+ }
167
+
189
168
  interface MergedView extends ViewedProduct {
190
169
  category?: string;
191
- /** Always resolved, so no consumer has to re-apply the default. */
192
170
  weight: number;
193
171
  }
194
172
 
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
173
  function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
208
174
  const totalsBySku = new Map<string, MergedView>();
209
175
 
@@ -228,22 +194,16 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
228
194
  return [...totalsBySku.values()];
229
195
  }
230
196
 
231
- /** The most-viewed few, as the digest reports them. */
232
197
  function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
233
198
  const top = mergeViewsBySku(views)
234
199
  .toSorted((left, right) => right.views - left.views)
235
200
  .slice(0, DIGEST_LIMITS.viewed);
236
201
 
237
- const products: ViewedProduct[] = [];
238
- for (const merged of top) {
239
- const product: ViewedProduct = { sku: merged.sku, views: merged.views };
240
- if (merged.dwellMs !== undefined) product.dwellMs = merged.dwellMs;
241
- products.push(product);
242
- }
243
- return products;
202
+ return top.map(({ sku, views: count, dwellMs }) =>
203
+ dwellMs === undefined ? { sku, views: count } : { sku, views: count, dwellMs },
204
+ );
244
205
  }
245
206
 
246
- /** The open-vocabulary long tail, reduced to "which kinds, and how often". */
247
207
  function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
248
208
  const countByType = new Map<string, number>();
249
209
 
@@ -251,28 +211,23 @@ function countByInteractionType(interactions: Interaction[]): InteractionCount[]
251
211
  countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
252
212
  }
253
213
 
254
- return [...countByType.entries()]
255
- .map(([type, count]) => ({ type, count }))
214
+ const counts = [...countByType.entries()].map(([type, count]) => ({ type, count }));
215
+
216
+ return counts
256
217
  .toSorted((left, right) => right.count - left.count)
257
218
  .slice(0, DIGEST_LIMITS.interactionTypes);
258
219
  }
259
220
 
260
- function recentPurchasedSkus(purchases: PurchaseSignal[]): string[] {
261
- return recentUniqueSkus(purchases, DIGEST_LIMITS.purchased);
262
- }
263
-
264
221
  export function buildDigest(input: TrackingInput): SignalDigest {
265
222
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
266
223
  const { signals, context, user } = input;
267
224
 
268
225
  const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
269
226
  const dislikedSkus = recentUniqueSkus(signals.dislikes, DIGEST_LIMITS.disliked);
270
- const purchasedSkus = recentPurchasedSkus(signals.lastPurchased);
227
+ const purchasedSkus = recentUniqueSkus(signals.lastPurchased, DIGEST_LIMITS.purchased);
271
228
  const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
272
229
  const topViewed = mostViewedProducts(signals.mostViewed);
273
230
 
274
- // Searches say what a shopper wants; they do not say they engaged with any
275
- // product, so they do not lift a shopper out of cold start on their own.
276
231
  const evidenceCount =
277
232
  likedSkus.length +
278
233
  dislikedSkus.length +
@@ -306,13 +261,9 @@ export function buildDigest(input: TrackingInput): SignalDigest {
306
261
  };
307
262
  }
308
263
 
309
- // Everything the cohort key leaves out has to leave the prompt too, or the
310
- // first shopper's searches and history end up shaping copy that is cached and
311
- // served to everyone else in their cohort.
312
264
  export function toCohortDigest(digest: SignalDigest): SignalDigest {
313
265
  const top = digest.categoryAffinity[0];
314
266
 
315
- // Listed rather than spread, so what survives is the thing you read.
316
267
  const cohort: SignalDigest = {
317
268
  userId: 'cohort',
318
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,24 +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
- // Checked rather than trusted, because the way these are usually supplied is
79
- // `Number(process.env.SOMETHING)`, and an unset or misspelled variable makes
80
- // that NaN. Every comparison against NaN is false, so the cache would then
81
- // never expire an entry and never evict one — it would grow forever while
82
- // serving a shopper the component they were given last week, and nothing
83
- // would report it. Failing at construction is the only loud option.
84
44
  assertFiniteAtLeastZero('ttlMs', ttlMs);
85
45
  assertFiniteAtLeastZero('maxEntries', maxEntries);
86
46
  if (!Number.isSafeInteger(maxEntries)) {
@@ -100,8 +60,6 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
100
60
  return undefined;
101
61
  }
102
62
 
103
- // Re-insert so insertion order tracks recency of use, which is what makes
104
- // the eviction below least-recently-used rather than oldest-written.
105
63
  entries.delete(key);
106
64
  entries.set(key, entry);
107
65
  return entry.cached;
@@ -124,25 +82,15 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
124
82
  };
125
83
  }
126
84
 
127
- /**
128
- * A cache that stores nothing, so every request generates.
129
- *
130
- * This is not a stub. It is the control for any measurement of generation cost
131
- * or latency: with a cache in front, a benchmark reports how often it hit, not
132
- * what generating costs.
133
- */
134
85
  export function createNullSpecCache(): SpecCache {
135
86
  return {
136
87
  async get() {
137
88
  return undefined;
138
89
  },
139
- async set() {
140
- // Deliberately nothing.
141
- },
90
+ async set() {},
142
91
  };
143
92
  }
144
93
 
145
- /** Stable JSON: object keys sorted, so key order in a digest cannot matter. */
146
94
  function canonicalise(value: unknown): string {
147
95
  if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
148
96
  if (Array.isArray(value)) return `[${value.map(canonicalise).join(',')}]`;
@@ -156,42 +104,15 @@ function canonicalise(value: unknown): string {
156
104
 
157
105
  const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
158
106
 
159
- /**
160
- * Derives the cache key.
161
- *
162
- * The digest goes in **whole**, rather than as a hand-picked list of fields.
163
- * That is the entire point of this function. The previous implementation listed
164
- * the fields it thought mattered, and four of them drifted: they reached the
165
- * model but not the key, so two shoppers with genuinely different histories
166
- * collided and were served each other's component. Hashing the whole digest
167
- * makes that class of bug unreachable — a field cannot be forgotten here,
168
- * because nothing is named here.
169
- *
170
- * The cost is a lower hit rate than a hand-tuned key would give, since every
171
- * signal now moves the key. That is the right way round: a key that is too
172
- * specific wastes money, and a key that is too loose shows one shopper another
173
- * shopper's page. Raising the hit rate means deliberately coarsening the digest
174
- * itself, which is a decision to make against measurements rather than by
175
- * guessing here.
176
- *
177
- * Candidates enter as SKUs only. Their titles, prices and ratings reach the
178
- * model too, but including them would churn the key on every price change for
179
- * copy that would almost always come back the same. The consequence is bounded
180
- * and worth stating: within one TTL, a price change does not refresh the
181
- * generated copy. It cannot show a stale price — prices are read from the live
182
- * catalog at render time, never from the model.
183
- */
184
107
  export function specCacheKey(
185
108
  digest: SignalDigest,
186
109
  candidateSkus: readonly string[],
187
- providerId: string,
110
+ provider: { name: string; model: string },
188
111
  ): string {
189
112
  const material = canonicalise({
190
- // The spec's own version, so a shape change cannot read entries written by
191
- // the previous shape out of a shared store that outlives a deploy.
192
113
  specVersion: SPEC_VERSION,
193
114
  prompt: PROMPT_FINGERPRINT,
194
- provider: providerId,
115
+ provider,
195
116
  digest,
196
117
  candidates: candidateSkus.toSorted(),
197
118
  });
@@ -199,33 +120,25 @@ export function specCacheKey(
199
120
  return createHash('sha256').update(material).digest('hex').slice(0, 32);
200
121
  }
201
122
 
202
- // Leaves out the fields that make a key personal: who the shopper is, what they
203
- // liked, viewed or searched for. Candidates go too, because every shopper's list
204
- // is different — that is safe because the SKUs in a cohort spec get replaced
205
- // before the page is served.
206
123
  export function cohortCacheKey(
207
124
  digest: SignalDigest,
208
125
  candidateSkus: readonly string[],
209
- providerId: string,
126
+ provider: { name: string; model: string },
210
127
  ): string {
211
128
  const material = canonicalise({
212
129
  specVersion: SPEC_VERSION,
213
130
  prompt: PROMPT_FINGERPRINT,
214
- provider: providerId,
131
+ provider,
215
132
  segment: digest.segment ?? null,
216
133
  surface: digest.surface,
217
134
  slot: digest.slot,
218
135
  locale: digest.locale,
219
136
  maxItems: digest.maxItems,
220
137
  isColdStart: digest.isColdStart,
221
- // The page being looked at, so copy written for a backpack page is not
222
- // served on a tent page.
138
+
223
139
  currentCategory: digest.currentCategory ?? null,
224
140
  topCategory: digest.categoryAffinity[0]?.category ?? null,
225
- // The model is shown these, so they belong in the key. Normally they come
226
- // from the page and everyone on it shares them. A shop that picks
227
- // candidates per shopper gets smaller cohorts, which is the honest result:
228
- // its prompt really is personal.
141
+
229
142
  candidates: candidateSkus.toSorted(),
230
143
  });
231
144
 
@@ -1,36 +1,5 @@
1
1
  import { z } from 'zod';
2
2
 
3
- /**
4
- * The tracking-input contract — the boundary between the host application and
5
- * rudra-js.
6
- *
7
- * rudra-js does not collect, store, or aggregate anything. The host owns its
8
- * tracking pipeline (an event stream, a CDP, a warehouse) and hands the
9
- * framework one JSON object per render. This module is that contract: one
10
- * schema, validated at the edge, so a malformed payload fails loudly here
11
- * rather than quietly producing a bad prompt several layers later.
12
- *
13
- * Every fixed-shape object below is a `strictObject`, so an unrecognised field
14
- * is an error rather than being dropped. That matters more than it looks: with
15
- * a lenient object, a host that misspells `recentSearches` gets a shopper who
16
- * silently looks like a first-time visitor, and nothing anywhere reports it.
17
- * The only dynamic shape is `interaction.meta`, which is a record by design.
18
- */
19
-
20
- /**
21
- * Every free-text field and every array is length-capped.
22
- *
23
- * These caps are not cosmetic. Host-supplied strings end up inside the prompt
24
- * we send to a language model, and a model is billed per token — so an
25
- * unbounded string is an unbounded bill, and an unbounded array of candidates
26
- * is the same problem multiplied.
27
- *
28
- * What this buys is that no single field is unbounded. It is deliberately not
29
- * an aggregate budget: the caps multiply out to far more than any context
30
- * window, because rejecting a large-but-legitimate payload is the wrong
31
- * response to one. Fitting a payload into a prompt is `digest`'s job, and it
32
- * trims rather than throws.
33
- */
34
3
  export const FIELD_LIMITS = {
35
4
  identifier: 128,
36
5
  shortText: 200,
@@ -44,45 +13,34 @@ export const FIELD_LIMITS = {
44
13
  bundles: 20,
45
14
  localeTag: 35,
46
15
  maxItems: 12,
47
- // Matches CLAMP.reason in reconciliation: a host reason is rendered in the
48
- // same place a model's is, so the same length has to hold.
16
+
49
17
  reason: 120,
50
18
  } as const;
51
19
 
52
- /** Assigning this as an object key mutates the prototype instead of the object. */
53
20
  const RESERVED_META_KEY = '__proto__';
54
21
 
55
- /** Upper bound on any timestamp: 2100-01-01. */
56
22
  const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
57
23
 
58
24
  const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
59
25
  const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).optional();
60
26
 
61
- /**
62
- * Epoch milliseconds, used only for recency ordering. Bounded because a
63
- * negative or year-3000 timestamp does not fail anywhere downstream — it just
64
- * sorts to one end and silently reorders the shopper's history.
65
- */
66
27
  const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
67
28
 
68
- /**
69
- * An absolute http(s) URL, or a root-relative path such as `/images/tr-102.png`.
70
- * Anything else — a bare word, a `javascript:` URI, a protocol-relative `//host`
71
- * — is rejected before it can reach an `<img src>`.
72
- */
29
+ const isRootRelative = (value: string) => {
30
+ const asParsed = value.replace(/[\t\n\r]/g, '');
31
+ return value.startsWith('/') && /^\/(?![/\\])/.test(asParsed);
32
+ };
33
+
73
34
  const imageReference = () =>
74
35
  z
75
36
  .string()
76
37
  .max(FIELD_LIMITS.shortText)
77
38
  .refine(
78
39
  (value) =>
79
- value.startsWith('/') && !value.startsWith('//')
80
- ? true
81
- : z.url({ protocol: /^https?$/ }).safeParse(value).success,
40
+ isRootRelative(value) ? true : z.url({ protocol: /^https?$/ }).safeParse(value).success,
82
41
  { message: 'expected an http(s) URL or a root-relative path' },
83
42
  );
84
43
 
85
- /** A product the generated component is permitted to place. */
86
44
  export const productSchema = z.strictObject({
87
45
  sku: identifier(),
88
46
  title: z.string().min(1).max(FIELD_LIMITS.shortText),
@@ -92,15 +50,10 @@ export const productSchema = z.strictObject({
92
50
  .string()
93
51
  .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
94
52
  .default('USD'),
95
- // This lands in an `<img src>` the host did not write, so the scheme matters:
96
- // a bare capped string would accept '' and 'javascript:'. Root-relative paths
97
- // are allowed because most catalogs store images that way, and they carry no
98
- // scheme to abuse.
53
+
99
54
  imageUrl: imageReference().optional(),
100
55
  rating: z.number().min(0).max(5).optional(),
101
- // Your phrase for why this product is here, when your own ranking already has
102
- // one. It renders as the item's reason and is not screened: these are your
103
- // words about your product, like the title, so you stand behind them.
56
+
104
57
  reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
105
58
  isInStock: z.boolean().default(true),
106
59
  tags: z
@@ -110,12 +63,11 @@ export const productSchema = z.strictObject({
110
63
  });
111
64
  export type Product = z.infer<typeof productSchema>;
112
65
 
113
- /** Base shape for any signal that points at a single SKU. */
114
66
  export const skuSignalSchema = z.strictObject({
115
67
  sku: identifier(),
116
68
  category: optionalIdentifier(),
117
69
  at: epochMs(),
118
- /** Caller-supplied strength, 0..1. Defaults to 1 when absent. */
70
+
119
71
  weight: z.number().min(0).max(1).optional(),
120
72
  });
121
73
  export type SkuSignal = z.infer<typeof skuSignalSchema>;
@@ -132,27 +84,12 @@ export const purchaseSignalSchema = skuSignalSchema.extend({
132
84
  });
133
85
  export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
134
86
 
135
- /**
136
- * `meta` is the one dynamic shape in the contract, so its key is checked before
137
- * the record is parsed rather than after. Zod builds its result by assigning
138
- * each key, and assigning `__proto__` sets the prototype instead of adding a
139
- * key — so the entry would vanish from the parsed output with no error, which
140
- * is the silent drop this module exists to prevent.
141
- *
142
- * `__proto__` is the only key that behaves this way. `constructor` and
143
- * `prototype` are ordinary own properties: assigning either shadows it on that
144
- * one object and leaves the prototype alone, so both survive a parse intact and
145
- * are accepted. `meta` is a host-defined vocabulary, and a shop with a facet,
146
- * filter, or CMS field by either name should get a component, not a throw.
147
- */
148
87
  const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
149
88
  (value) =>
150
89
  typeof value === 'object' && value !== null && !Object.hasOwn(value, RESERVED_META_KEY),
151
90
  { message: `meta may not use the reserved key ${RESERVED_META_KEY}` },
152
91
  );
153
92
 
154
- // `z.record` bounds key and value shape but not how many entries a record may
155
- // carry, so the count is checked separately.
156
93
  const metaSchema = metaKeysAreSafe.pipe(
157
94
  z
158
95
  .record(
@@ -164,11 +101,6 @@ const metaSchema = metaKeysAreSafe.pipe(
164
101
  }),
165
102
  );
166
103
 
167
- /**
168
- * Catch-all for everything else the shopper did. `type` is an open vocabulary
169
- * on purpose — 'scroll_depth', 'wishlist', 'filter_applied', whatever the host
170
- * already emits — so hosts do not have to map their events onto ours.
171
- */
172
104
  export const interactionSchema = z.strictObject({
173
105
  type: identifier(),
174
106
  sku: optionalIdentifier(),
@@ -179,23 +111,20 @@ export const interactionSchema = z.strictObject({
179
111
  });
180
112
  export type Interaction = z.infer<typeof interactionSchema>;
181
113
 
182
- /** Where on the site this component is being rendered. */
183
114
  export const renderContextSchema = z.strictObject({
184
- /** 'pdp', 'home', 'cart', 'search', or any host-defined surface. */
185
115
  surface: identifier(),
186
- /** Named placement, e.g. 'below-fold-recommendations'. */
116
+
187
117
  slot: z.string().min(1).max(FIELD_LIMITS.identifier).default('recommendations'),
188
118
  currentSku: optionalIdentifier(),
189
119
  currentCategory: optionalIdentifier(),
190
120
  searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
191
- // One language tag, not the Accept-Language header it is often copied from:
192
- // the locale is part of the cohort cache key, so a list makes its own cohort.
121
+
193
122
  locale: z
194
123
  .string()
195
124
  .max(FIELD_LIMITS.localeTag)
196
125
  .regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, 'expected one language tag, such as en-US')
197
126
  .default('en-US'),
198
- /** Upper bound on products across the whole generated component. */
127
+
199
128
  maxItems: z.number().int().min(1).max(FIELD_LIMITS.maxItems).default(4),
200
129
  });
201
130
  export type RenderContext = z.infer<typeof renderContextSchema>;
@@ -214,7 +143,6 @@ export const trackingSignalsSchema = z.strictObject({
214
143
  });
215
144
  export type TrackingSignals = z.infer<typeof trackingSignalsSchema>;
216
145
 
217
- /** A set the shop sells together. The model never picks or invents one. */
218
146
  export const bundleSchema = z.strictObject({
219
147
  id: identifier(),
220
148
  skus: z
@@ -224,8 +152,7 @@ export const bundleSchema = z.strictObject({
224
152
  .refine((skus) => new Set(skus).size === skus.length, {
225
153
  message: 'a bundle must not list the same product twice',
226
154
  }),
227
- // The shop's price for the set — not derived from the parts, so the set says
228
- // which money it is in rather than borrowing it from a member.
155
+
229
156
  price: z.number().nonnegative(),
230
157
  currency: z
231
158
  .string()
@@ -244,19 +171,9 @@ export const trackingInputSchema = z
244
171
  isReturning: z.boolean().optional(),
245
172
  }),
246
173
  context: renderContextSchema,
247
- // A payload with no `signals` block at all is the cold-start case, not an
248
- // error. Every category defaults to empty, so a first-time visitor needs no
249
- // special handling from the host.
174
+
250
175
  signals: trackingSignalsSchema.prefault({}),
251
- /**
252
- * The only products the generated component may place. Merchandising rules
253
- * belong here: whatever the host leaves out cannot be recommended, which is
254
- * what makes it impossible to surface a product that does not exist or is not
255
- * merchandised for this shopper.
256
- *
257
- * SKUs must be unique — a duplicate is a host bug that spends prompt budget
258
- * twice and invites the same product in two slots.
259
- */
176
+
260
177
  candidates: z
261
178
  .array(productSchema)
262
179
  .min(1)
@@ -267,8 +184,7 @@ export const trackingInputSchema = z
267
184
  message: 'candidates must have unique SKUs',
268
185
  },
269
186
  ),
270
- // Ids must be unique — the renderer looks a bundle up by id alone, so a
271
- // duplicate would let it draw the wrong set at the wrong price.
187
+
272
188
  bundles: z
273
189
  .array(bundleSchema)
274
190
  .max(FIELD_LIMITS.bundles)
@@ -292,24 +208,14 @@ export const trackingInputSchema = z
292
208
 
293
209
  export type TrackingInput = z.infer<typeof trackingInputSchema>;
294
210
 
295
- /** The shape a caller passes in, before defaults are applied. */
296
211
  export type TrackingInputDraft = z.input<typeof trackingInputSchema>;
297
212
 
298
- /**
299
- * The result of a non-throwing parse. Exported so a consumer can type a
300
- * validation failure without depending on zod directly.
301
- */
302
213
  export type TrackingInputResult = z.ZodSafeParseResult<TrackingInput>;
303
214
 
304
- /**
305
- * Validates a payload, throwing a `ZodError` if it does not satisfy the
306
- * contract. An invalid payload is a caller bug, and it should be loud.
307
- */
308
215
  export function parseTrackingInput(value: unknown): TrackingInput {
309
216
  return trackingInputSchema.parse(value);
310
217
  }
311
218
 
312
- /** Non-throwing variant, for callers that want to inspect the failure. */
313
219
  export function safeParseTrackingInput(value: unknown): TrackingInputResult {
314
220
  return trackingInputSchema.safeParse(value);
315
221
  }