@rudra-js/core 0.5.0 → 0.6.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 (64) hide show
  1. package/README.md +51 -4
  2. package/dist/claim-screening.d.ts +33 -0
  3. package/dist/claim-screening.d.ts.map +1 -0
  4. package/dist/claim-screening.js +226 -0
  5. package/dist/claim-screening.js.map +1 -0
  6. package/dist/component-generator.d.ts.map +1 -1
  7. package/dist/component-generator.js +32 -98
  8. package/dist/component-generator.js.map +1 -1
  9. package/dist/component-spec.d.ts +2 -49
  10. package/dist/component-spec.d.ts.map +1 -1
  11. package/dist/component-spec.js +3 -17
  12. package/dist/component-spec.js.map +1 -1
  13. package/dist/fallback-component.d.ts +3 -0
  14. package/dist/fallback-component.d.ts.map +1 -1
  15. package/dist/fallback-component.js +3 -13
  16. package/dist/fallback-component.js.map +1 -1
  17. package/dist/fit-to-shopper.d.ts +5 -1
  18. package/dist/fit-to-shopper.d.ts.map +1 -1
  19. package/dist/fit-to-shopper.js +4 -3
  20. package/dist/fit-to-shopper.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/model-prompt.d.ts +1 -1
  25. package/dist/model-prompt.d.ts.map +1 -1
  26. package/dist/model-prompt.js +38 -18
  27. package/dist/model-prompt.js.map +1 -1
  28. package/dist/product-selection.d.ts +0 -6
  29. package/dist/product-selection.d.ts.map +1 -1
  30. package/dist/product-selection.js +1 -3
  31. package/dist/product-selection.js.map +1 -1
  32. package/dist/provider.d.ts +1 -8
  33. package/dist/provider.d.ts.map +1 -1
  34. package/dist/provider.js +1 -8
  35. package/dist/provider.js.map +1 -1
  36. package/dist/reconciliation.d.ts +10 -22
  37. package/dist/reconciliation.d.ts.map +1 -1
  38. package/dist/reconciliation.js +116 -399
  39. package/dist/reconciliation.js.map +1 -1
  40. package/dist/signal-digest.d.ts +10 -0
  41. package/dist/signal-digest.d.ts.map +1 -1
  42. package/dist/signal-digest.js +38 -28
  43. package/dist/signal-digest.js.map +1 -1
  44. package/dist/spec-cache.d.ts +14 -2
  45. package/dist/spec-cache.d.ts.map +1 -1
  46. package/dist/spec-cache.js +14 -24
  47. package/dist/spec-cache.js.map +1 -1
  48. package/dist/tracking-input.d.ts.map +1 -1
  49. package/dist/tracking-input.js +15 -13
  50. package/dist/tracking-input.js.map +1 -1
  51. package/package.json +4 -4
  52. package/src/claim-screening.ts +242 -0
  53. package/src/component-generator.ts +37 -123
  54. package/src/component-spec.ts +5 -18
  55. package/src/fallback-component.ts +3 -14
  56. package/src/fit-to-shopper.ts +9 -4
  57. package/src/index.ts +1 -0
  58. package/src/model-prompt.ts +42 -20
  59. package/src/product-selection.ts +1 -9
  60. package/src/provider.ts +1 -8
  61. package/src/reconciliation.ts +135 -430
  62. package/src/signal-digest.ts +41 -31
  63. package/src/spec-cache.ts +27 -24
  64. package/src/tracking-input.ts +16 -13
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  Interaction,
3
3
  Product,
4
- PurchaseSignal,
5
4
  SkuSignal,
6
5
  TrackingInput,
7
6
  ViewSignal,
@@ -78,11 +77,7 @@ export const DIGEST_LIMITS = {
78
77
  interactionTypes: 8,
79
78
  } as const;
80
79
 
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
- */
80
+ /** How much intent each kind of signal implies. */
86
81
  const SIGNAL_WEIGHTS = {
87
82
  purchase: 5,
88
83
  like: 4,
@@ -107,7 +102,6 @@ function byMostRecent(
107
102
  return (right.at ?? 0) - (left.at ?? 0);
108
103
  }
109
104
 
110
- /** The most recent `limit` SKUs, each appearing once. */
111
105
  function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
112
106
  const skus: string[] = [];
113
107
  const alreadySeen = new Set<string>();
@@ -122,11 +116,6 @@ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
122
116
  return skus;
123
117
  }
124
118
 
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
119
  function categoryOf(
131
120
  signal: { sku: string; category?: string | undefined },
132
121
  candidatesBySku: Map<string, Product>,
@@ -173,22 +162,50 @@ function computeCategoryAffinity(
173
162
  );
174
163
  }
175
164
 
176
- // The category the shopper is standing in right now is itself evidence.
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`.
177
167
  addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
178
168
 
179
- return [...scoreByCategory.entries()]
180
- .map(([category, score]) => ({
181
- category,
182
- score: Math.round(score * 100) / 100,
183
- }))
184
- .filter((affinity) => affinity.score > 0)
169
+ const affinities: CategoryAffinity[] = [];
170
+ for (const [category, score] of scoreByCategory) {
171
+ const rounded = Math.round(score * 100) / 100;
172
+ if (rounded > 0) affinities.push({ category, score: rounded });
173
+ }
174
+
175
+ return affinities
185
176
  .toSorted((left, right) => right.score - left.score)
186
177
  .slice(0, DIGEST_LIMITS.affinity);
187
178
  }
188
179
 
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
+ export function engagedCategories(input: TrackingInput): Set<string> {
190
+ const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
191
+ const { signals } = input;
192
+ const categories = new Set<string>();
193
+
194
+ for (const signal of [
195
+ ...signals.lastPurchased,
196
+ ...signals.likes,
197
+ ...signals.cart,
198
+ ...signals.mostViewed,
199
+ ]) {
200
+ const category = categoryOf(signal, candidatesBySku);
201
+ if (category) categories.add(category);
202
+ }
203
+
204
+ return categories;
205
+ }
206
+
189
207
  interface MergedView extends ViewedProduct {
190
208
  category?: string;
191
- /** Always resolved, so no consumer has to re-apply the default. */
192
209
  weight: number;
193
210
  }
194
211
 
@@ -201,8 +218,6 @@ interface MergedView extends ViewedProduct {
201
218
  * over, defeating the sub-linear scaling in `computeCategoryAffinity` entirely.
202
219
  * Merging first makes the score depend on how much someone looked, not on how
203
220
  * their tracking pipeline happens to batch.
204
- *
205
- * Where records disagree on `weight`, the strongest wins.
206
221
  */
207
222
  function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
208
223
  const totalsBySku = new Map<string, MergedView>();
@@ -228,7 +243,6 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
228
243
  return [...totalsBySku.values()];
229
244
  }
230
245
 
231
- /** The most-viewed few, as the digest reports them. */
232
246
  function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
233
247
  const top = mergeViewsBySku(views)
234
248
  .toSorted((left, right) => right.views - left.views)
@@ -243,7 +257,6 @@ function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
243
257
  return products;
244
258
  }
245
259
 
246
- /** The open-vocabulary long tail, reduced to "which kinds, and how often". */
247
260
  function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
248
261
  const countByType = new Map<string, number>();
249
262
 
@@ -251,23 +264,20 @@ function countByInteractionType(interactions: Interaction[]): InteractionCount[]
251
264
  countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
252
265
  }
253
266
 
254
- return [...countByType.entries()]
255
- .map(([type, count]) => ({ type, count }))
267
+ const counts = [...countByType.entries()].map(([type, count]) => ({ type, count }));
268
+
269
+ return counts
256
270
  .toSorted((left, right) => right.count - left.count)
257
271
  .slice(0, DIGEST_LIMITS.interactionTypes);
258
272
  }
259
273
 
260
- function recentPurchasedSkus(purchases: PurchaseSignal[]): string[] {
261
- return recentUniqueSkus(purchases, DIGEST_LIMITS.purchased);
262
- }
263
-
264
274
  export function buildDigest(input: TrackingInput): SignalDigest {
265
275
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
266
276
  const { signals, context, user } = input;
267
277
 
268
278
  const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
269
279
  const dislikedSkus = recentUniqueSkus(signals.dislikes, DIGEST_LIMITS.disliked);
270
- const purchasedSkus = recentPurchasedSkus(signals.lastPurchased);
280
+ const purchasedSkus = recentUniqueSkus(signals.lastPurchased, DIGEST_LIMITS.purchased);
271
281
  const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
272
282
  const topViewed = mostViewedProducts(signals.mostViewed);
273
283
 
package/src/spec-cache.ts CHANGED
@@ -75,12 +75,8 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
75
75
  const ttlMs = options.ttlMs ?? 60_000;
76
76
  const maxEntries = options.maxEntries ?? 10_000;
77
77
 
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.
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.
84
80
  assertFiniteAtLeastZero('ttlMs', ttlMs);
85
81
  assertFiniteAtLeastZero('maxEntries', maxEntries);
86
82
  if (!Number.isSafeInteger(maxEntries)) {
@@ -100,8 +96,8 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
100
96
  return undefined;
101
97
  }
102
98
 
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.
99
+ // Re-insert so insertion order tracks recency, which makes the eviction below
100
+ // least-recently-used rather than oldest-written.
105
101
  entries.delete(key);
106
102
  entries.set(key, entry);
107
103
  return entry.cached;
@@ -156,6 +152,19 @@ function canonicalise(value: unknown): string {
156
152
 
157
153
  const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
158
154
 
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
+
159
168
  /**
160
169
  * Derives the cache key.
161
170
  *
@@ -184,14 +193,14 @@ const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('he
184
193
  export function specCacheKey(
185
194
  digest: SignalDigest,
186
195
  candidateSkus: readonly string[],
187
- providerId: string,
196
+ provider: ProviderIdentity,
188
197
  ): string {
189
198
  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.
199
+ // So a shape change cannot read entries written by the previous shape out of
200
+ // a shared store that outlives a deploy.
192
201
  specVersion: SPEC_VERSION,
193
202
  prompt: PROMPT_FINGERPRINT,
194
- provider: providerId,
203
+ provider,
195
204
  digest,
196
205
  candidates: candidateSkus.toSorted(),
197
206
  });
@@ -199,33 +208,27 @@ export function specCacheKey(
199
208
  return createHash('sha256').update(material).digest('hex').slice(0, 32);
200
209
  }
201
210
 
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.
211
+ // Leaves out the personal fields: who the shopper is, what they liked, viewed or searched.
206
212
  export function cohortCacheKey(
207
213
  digest: SignalDigest,
208
214
  candidateSkus: readonly string[],
209
- providerId: string,
215
+ provider: ProviderIdentity,
210
216
  ): string {
211
217
  const material = canonicalise({
212
218
  specVersion: SPEC_VERSION,
213
219
  prompt: PROMPT_FINGERPRINT,
214
- provider: providerId,
220
+ provider,
215
221
  segment: digest.segment ?? null,
216
222
  surface: digest.surface,
217
223
  slot: digest.slot,
218
224
  locale: digest.locale,
219
225
  maxItems: digest.maxItems,
220
226
  isColdStart: digest.isColdStart,
221
- // The page being looked at, so copy written for a backpack page is not
222
- // served on a tent page.
227
+ // So copy written for a backpack page is not served on a tent page.
223
228
  currentCategory: digest.currentCategory ?? null,
224
229
  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.
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.
229
232
  candidates: candidateSkus.toSorted(),
230
233
  });
231
234
 
@@ -52,7 +52,6 @@ export const FIELD_LIMITS = {
52
52
  /** Assigning this as an object key mutates the prototype instead of the object. */
53
53
  const RESERVED_META_KEY = '__proto__';
54
54
 
55
- /** Upper bound on any timestamp: 2100-01-01. */
56
55
  const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
57
56
 
58
57
  const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
@@ -65,6 +64,20 @@ const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).
65
64
  */
66
65
  const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
67
66
 
67
+ /**
68
+ * Whether a path stays on the page's own origin.
69
+ *
70
+ * Read the way a browser reads it, not the way it looks. A browser drops tab,
71
+ * carriage return and newline from a URL before parsing it, and treats a
72
+ * backslash as a slash, so `/\evil.example/x.png` and `/<tab>//evil.example/x.png`
73
+ * both point at someone else's host while passing a plain "starts with one
74
+ * slash" test.
75
+ */
76
+ const isRootRelative = (value: string) => {
77
+ const asParsed = value.replace(/[\t\n\r]/g, '');
78
+ return value.startsWith('/') && /^\/(?![/\\])/.test(asParsed);
79
+ };
80
+
68
81
  /**
69
82
  * An absolute http(s) URL, or a root-relative path such as `/images/tr-102.png`.
70
83
  * Anything else — a bare word, a `javascript:` URI, a protocol-relative `//host`
@@ -76,9 +89,7 @@ const imageReference = () =>
76
89
  .max(FIELD_LIMITS.shortText)
77
90
  .refine(
78
91
  (value) =>
79
- value.startsWith('/') && !value.startsWith('//')
80
- ? true
81
- : z.url({ protocol: /^https?$/ }).safeParse(value).success,
92
+ isRootRelative(value) ? true : z.url({ protocol: /^https?$/ }).safeParse(value).success,
82
93
  { message: 'expected an http(s) URL or a root-relative path' },
83
94
  );
84
95
 
@@ -138,12 +149,6 @@ export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
138
149
  * each key, and assigning `__proto__` sets the prototype instead of adding a
139
150
  * key — so the entry would vanish from the parsed output with no error, which
140
151
  * 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
152
  */
148
153
  const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
149
154
  (value) =>
@@ -244,9 +249,7 @@ export const trackingInputSchema = z
244
249
  isReturning: z.boolean().optional(),
245
250
  }),
246
251
  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.
252
+ // A payload with no `signals` block at all is the cold-start case, not an error.
250
253
  signals: trackingSignalsSchema.prefault({}),
251
254
  /**
252
255
  * The only products the generated component may place. Merchandising rules