@rudra-js/core 0.4.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 +139 -15
  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 +33 -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 +6 -4
  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 +13 -1
  25. package/dist/model-prompt.d.ts.map +1 -1
  26. package/dist/model-prompt.js +53 -21
  27. package/dist/model-prompt.js.map +1 -1
  28. package/dist/product-selection.d.ts +1 -9
  29. package/dist/product-selection.d.ts.map +1 -1
  30. package/dist/product-selection.js +1 -4
  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 +15 -12
  37. package/dist/reconciliation.d.ts.map +1 -1
  38. package/dist/reconciliation.js +150 -340
  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 -2
  52. package/src/claim-screening.ts +242 -0
  53. package/src/component-generator.ts +38 -123
  54. package/src/component-spec.ts +5 -18
  55. package/src/fallback-component.ts +3 -14
  56. package/src/fit-to-shopper.ts +11 -4
  57. package/src/index.ts +1 -0
  58. package/src/model-prompt.ts +58 -23
  59. package/src/product-selection.ts +2 -13
  60. package/src/provider.ts +1 -8
  61. package/src/reconciliation.ts +178 -359
  62. package/src/signal-digest.ts +41 -31
  63. package/src/spec-cache.ts +27 -24
  64. package/src/tracking-input.ts +16 -13
@@ -0,0 +1,242 @@
1
+ import { offeredCandidates } from './model-prompt.js';
2
+ import type { Product, TrackingInput } from './tracking-input.js';
3
+
4
+ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
5
+ {
6
+ // Customers scoring the product. "rated for winter use", "rated to -10C"
7
+ // and "an IPX7 water rating" are about what the product can take.
8
+ kind: 'rating',
9
+ patterns: [
10
+ /\breviews?\b|\breviewed\b/,
11
+ /\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
12
+ /\bstars?[\s-]?ratings?\b/,
13
+ // A rating somebody gave it, rather than one it was built to.
14
+ /\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
15
+ // A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
16
+ /\bratings?\s+of\s+[0-5]\.\d\b/,
17
+ // The same score with the word "rating" nowhere near it: "4.8 out of 5".
18
+ /\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
19
+ /\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
20
+ // "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
21
+ /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
22
+ // How many other people bought or liked it is a customer claim too.
23
+ /\bbest[\s-]?sell(?:er|ers|ing)\b/,
24
+ // The words a model reaches for when "best" is banned. Each branch carries its own
25
+ // \b — one in front of the group demands a word character before the #, never there.
26
+ /(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
27
+ /\bloved by (?:thousands|hundreds|millions|\d)/,
28
+ ],
29
+ },
30
+ {
31
+ kind: 'price',
32
+ patterns: [
33
+ // No digit beside it: asking for one let "$thirty-nine and it is yours" through.
34
+ /[$£€¥₹]/,
35
+ /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
36
+ // The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
37
+ /\bdollars?\b|\beuros?\b/,
38
+ // The same codes on the other side of the number: "USD 20", "EUR 5.99".
39
+ /\b(?:usd|eur|gbp)\s?\d/,
40
+ // The krona is spelled out rather than drawn, so it needs a number beside it.
41
+ /\bkr\s?\d|\d\s?kr\b/,
42
+ /\bpric(?:e|es|ed|ing)\b/,
43
+ // A before-and-after is a price claim even with no currency on it.
44
+ /\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
45
+ // "does not feel cheap" is about quality. Only the comparison is money.
46
+ /\bcheap(?:er|est)\b/,
47
+ /\baffordable\b|\bbargain\b/,
48
+ // "at no cost to comfort" is not money. "costs less" is.
49
+ /\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
50
+ /\blow(?:er)?[\s-]cost\b/,
51
+ // "saves weight" and "saves on weight" are both about grams.
52
+ /\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
53
+ ],
54
+ },
55
+ {
56
+ // A percentage in outdoor copy is a specification far more often than it is
57
+ // money off, so it only counts beside a money word: "20% off", not
58
+ // "100% recycled".
59
+ kind: 'discount',
60
+ patterns: [
61
+ /\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
62
+ /\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
63
+ /\bdiscount(?:s|ed)?\b/,
64
+ /\bsale\b|\bmarked down\b|\bdeal of\b/,
65
+ /\bhalf[\s-]?(?:price|off)\b/,
66
+ // Money off with no percent sign anywhere on it: "save 20 off".
67
+ /\bsaves?\s+\d[\d.,]*\s+off\b/,
68
+ // "extra clearance for thick socks" is room inside the shoe.
69
+ /\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
70
+ // "reduced weight" and "reduced to 900g" are specifications. Only a
71
+ // reduced price, or a reduction with a date on it, is money off.
72
+ /\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
73
+ ],
74
+ },
75
+ {
76
+ // When it turns up, not what turns up. "ships in a recycled box" and
77
+ // "arrives flat-packed" describe the thing, so the verb alone is not enough
78
+ // — it needs a day or a date beside it.
79
+ kind: 'delivery',
80
+ patterns: [
81
+ /\bdelivery\b|\bshipping\b/,
82
+ /\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
83
+ /\b(?:ships?|arrives?|arriving|delivered|by|before|in time for)\s+(?:on\s+)?(?:today|tomorrow|tonight|this week|next week|the weekend|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
84
+ /\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
85
+ /\bin time for\b/,
86
+ ],
87
+ },
88
+ {
89
+ kind: 'stock',
90
+ patterns: [
91
+ /\b(?:in|out of|low on) stock\b/,
92
+ /\blimited stock\b/,
93
+ /\brestocked?\b|\bsold out\b/,
94
+ // "the last few miles" is a distance, so a count needs "left" after it.
95
+ /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
96
+ /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
97
+ ],
98
+ },
99
+ ];
100
+
101
+ /** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
102
+ const CONFUSABLES: Record<string, string> = {
103
+ а: 'a',
104
+ в: 'b',
105
+ е: 'e',
106
+ к: 'k',
107
+ м: 'm',
108
+ н: 'h',
109
+ о: 'o',
110
+ р: 'p',
111
+ с: 'c',
112
+ т: 't',
113
+ х: 'x',
114
+ у: 'y',
115
+ ѕ: 's',
116
+ і: 'i',
117
+ ј: 'j',
118
+ ԁ: 'd',
119
+ һ: 'h',
120
+ ӏ: 'l',
121
+ α: 'a',
122
+ ε: 'e',
123
+ ι: 'i',
124
+ κ: 'k',
125
+ ν: 'v',
126
+ ο: 'o',
127
+ ρ: 'p',
128
+ τ: 't',
129
+ υ: 'u',
130
+ χ: 'x',
131
+ ϲ: 'c',
132
+ };
133
+
134
+ /**
135
+ * Characters that render as nothing: format characters, default-ignorable code points and the
136
+ * controls. Cf and Default_Ignorable each hold characters the other does not.
137
+ * A second copy of the class in attested's `hidden.ts`, pinned to it by a test.
138
+ */
139
+ const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
140
+
141
+ /** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
142
+ const SPACING = /[\t\n\v\f\r\u0085]/;
143
+
144
+ /**
145
+ * Marks that hang on the character before them instead of taking a column of their own.
146
+ * Dropped after composition, so an accented e keeps its accent while an overline,
147
+ * which composes with nothing, cannot hide a word from its own rule.
148
+ */
149
+ const MARKS = /[\p{Mn}\p{Me}]/gu;
150
+
151
+ /** Only the reading is normalised. What renders is what the model wrote. */
152
+ function normaliseForClaims(text: string): string {
153
+ const lowered = text
154
+ .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
155
+ .normalize('NFKC')
156
+ .toLowerCase()
157
+ .normalize('NFC')
158
+ .replace(MARKS, '');
159
+
160
+ let folded = '';
161
+ for (const char of lowered) folded += CONFUSABLES[char] ?? char;
162
+ return folded;
163
+ }
164
+
165
+ /**
166
+ * Wording attested bans that this project has already ruled is not a claim. An allowance
167
+ * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
168
+ * clearance for" — forgives whatever follows it. Exported so a test can hold the list
169
+ * against attested's own.
170
+ */
171
+ export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
172
+
173
+ /** Only a string with one of these in it can stand behind a numeral. */
174
+ const DIGIT = /\p{Nd}/u;
175
+
176
+ /** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
177
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
178
+
179
+ /** Wide enough for every finite JavaScript number, and for anything a shop sells. */
180
+ const MAX_EXPONENT = 1000;
181
+
182
+ // `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
183
+ // attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
184
+ function canLayOut(fact: string): boolean {
185
+ const match = BARE_EXPONENT.exec(fact.trim());
186
+ if (match === null) return true;
187
+ return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
188
+ }
189
+
190
+ /**
191
+ * The numbers the shop published about one product: its category and tags, only the strings
192
+ * carrying a digit. Exported so a test can pin what never reaches attested.
193
+ */
194
+ export function productFacts(product: Product): string[] {
195
+ const facts: string[] = [];
196
+ if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
197
+ for (const tag of product.tags) {
198
+ if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
199
+ }
200
+ return facts;
201
+ }
202
+
203
+ export interface HostFacts {
204
+ pooled: string[];
205
+ bySku: Map<string, string[]>;
206
+ }
207
+
208
+ /**
209
+ * The shop's own numbers for this request, for attested to check numerals against.
210
+ *
211
+ * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
212
+ * means a product the model never saw. A title and a rating it is shown but told never to
213
+ * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
214
+ * from the request rather than the catalogue, so a host passing a URL segment through would
215
+ * hand this list to whoever types the URL.
216
+ *
217
+ * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
218
+ * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
219
+ * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
220
+ */
221
+ export function hostFacts(input: TrackingInput): HostFacts {
222
+ const pooled = new Set<string>();
223
+ const bySku = new Map<string, string[]>();
224
+
225
+ for (const product of offeredCandidates(input)) {
226
+ const own = productFacts(product);
227
+ bySku.set(product.sku, own);
228
+ for (const fact of own) pooled.add(fact);
229
+ }
230
+
231
+ return { pooled: [...pooled], bySku };
232
+ }
233
+
234
+ export function claimIn(text: string): string | null {
235
+ const normalised = normaliseForClaims(text);
236
+ for (const claim of CLAIM_PATTERNS) {
237
+ for (const pattern of claim.patterns) {
238
+ if (pattern.test(normalised)) return claim.kind;
239
+ }
240
+ }
241
+ return null;
242
+ }
@@ -11,14 +11,9 @@ import {
11
11
  import { buildFallbackSpec } from './fallback-component.js';
12
12
  import { buildPrompt } from './model-prompt.js';
13
13
  import type { ComponentProvider, TokenUsage } from './provider.js';
14
- import {
15
- MAX_BLOCKS,
16
- bundleForShopper,
17
- placeableHeroSkus,
18
- reconcileSpec,
19
- } from './reconciliation.js';
14
+ import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
20
15
  import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
21
- import { fitToShopper } from './fit-to-shopper.js';
16
+ import { fitToShopper, type FittedSpec } from './fit-to-shopper.js';
22
17
  import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
23
18
  import {
24
19
  createMemorySpecCache,
@@ -139,18 +134,10 @@ export class TimeoutError extends Error {
139
134
  }
140
135
 
141
136
  /**
142
- * Races a promise against a deadline.
143
- *
144
- * The deadline is enforced here rather than trusted to the thing being waited
145
- * on. A provider that ignores its abort signal, or a store that never settles,
146
- * must still not hold a page open.
147
- *
148
- * Once the deadline has fired the caller is told so, whatever the race
149
- * actually settled with. Aborting is what makes that necessary: a provider
150
- * honouring its half of the contract rejects from inside the `abort()` below,
151
- * so its rejection reaches the race first and the deadline's own never wins.
152
- * Reporting the error that happened to arrive would blame the vendor for the
153
- * caller's deadline — and blame it most often on the best-behaved adapters.
137
+ * Once the deadline has fired the caller is told so, whatever the race actually
138
+ * settled with: a provider honouring its half of the contract rejects from
139
+ * inside the `abort()` below, so its rejection reaches the race first and the
140
+ * deadline's own never wins.
154
141
  */
155
142
  async function withinBudget<T>(
156
143
  label: string,
@@ -178,21 +165,13 @@ async function withinBudget<T>(
178
165
  }
179
166
  }
180
167
 
181
- /**
182
- * Collapses concurrent work for the same key into one execution.
183
- *
184
- * Without this, a key that is not yet cached fans out into one model call per
185
- * concurrent request — the same answer, bought many times over. The entry is
186
- * removed as soon as it settles, so one failure does not poison the next
187
- * attempt.
188
- */
189
- function createSingleFlight<T>() {
190
- const inFlight = new Map<string, Promise<T>>();
168
+ function createSingleFlight() {
169
+ const inFlight = new Map<string, Promise<ModelCall>>();
191
170
 
192
171
  return {
193
172
  isRunning: (key: string) => inFlight.has(key),
194
173
 
195
- run(key: string, task: () => Promise<T>): Promise<T> {
174
+ run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
196
175
  const existing = inFlight.get(key);
197
176
  if (existing) return existing;
198
177
 
@@ -214,48 +193,32 @@ interface CacheRead {
214
193
  entry?: CachedSpec;
215
194
  }
216
195
 
217
- /**
218
- * What one call to the model produced.
219
- *
220
- * `spec` is null when the answer did not satisfy the schema. `usage` is carried
221
- * either way: the request went out and was paid for whether or not anything
222
- * usable came back, and those are the calls most worth seeing.
223
- */
196
+ /** `spec` is null when the answer did not satisfy the schema. `usage` is carried either way: the call was still billed. */
224
197
  interface ModelCall {
225
198
  spec: GeneratedSpec | null;
226
199
  usage?: TokenUsage;
227
200
  }
228
201
 
229
- /** A model call whose answer can be reconciled and served. */
230
202
  interface ModelAnswer extends ModelCall {
231
203
  spec: GeneratedSpec;
232
204
  }
233
205
 
234
206
  /**
235
- * Fills a cohort spec with this shopper's products, keeping room for a set.
236
- *
237
- * The grid used to take the whole item budget, so a bundle block later in the
238
- * spec found nothing left and was dropped. The set is chosen first, its
239
- * products are held back from the grid, and the grid's limit drops by what the
240
- * set and the heroes have already spoken for.
241
- *
242
207
  * The arithmetic has to hold whatever order the model put the blocks in, so it
243
208
  * is written as one sum over the whole spec rather than as a running budget:
244
209
  * the grid gets `maxItems` minus every distinct product the set and the heroes
245
- * will place. Nothing is then dropped for want of budget, and reconciliation
246
- * reaches the same set this did — it can only ever have more placed than the
247
- * pre-choice assumed, and never one of the set's own products.
210
+ * will place. Reconciliation then reaches the same set this did — it can only
211
+ * ever have more placed than the pre-choice assumed, and never one of the set's
212
+ * own products.
248
213
  */
249
214
  function fitCohortSpec(
250
215
  spec: GeneratedSpec,
251
216
  input: TrackingInput,
252
217
  digest: SignalDigest,
253
218
  rank: RankOrder,
254
- hostReasonSkus: Set<string>,
255
- ): GeneratedSpec {
219
+ ): FittedSpec {
256
220
  const picks = selectProducts(input, digest, { rank });
257
- // Blocks past the cap never render, so a set is not worth reserving for one.
258
- const blocks = spec.blocks.slice(0, MAX_BLOCKS);
221
+ const blocks = capBlocks(spec.blocks);
259
222
 
260
223
  let hasBundleBlock = false;
261
224
  const aboveBundle: Block[] = [];
@@ -266,29 +229,28 @@ function fitCohortSpec(
266
229
  }
267
230
  aboveBundle.push(block);
268
231
  }
269
- if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
232
+ if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
270
233
 
271
234
  // Only the heroes above the bundle block are placed when it is reached, so
272
235
  // they are all the choice may account for.
273
- const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
274
- if (!chosen) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
236
+ const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
237
+ if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
275
238
 
276
239
  const spokenFor = new Set<string>(chosen.skus);
277
- for (const sku of placeableHeroSkus(blocks, input, digest)) spokenFor.add(sku);
240
+ for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
278
241
 
279
242
  const roomLeft = digest.maxItems - spokenFor.size;
280
243
  // A set is worth showing, but not at the cost of an empty grid.
281
- if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
244
+ if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
282
245
 
283
246
  const forGrid: ProductPick[] = [];
284
247
  for (const pick of picks) {
285
248
  if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
286
249
  }
287
250
 
288
- return fitToShopper(spec, forGrid, roomLeft, hostReasonSkus);
251
+ return fitToShopper(spec, forGrid, roomLeft);
289
252
  }
290
253
 
291
- /** Attaches the provenance the server owns. The model never supplies any of it. */
292
254
  function withProvenance(
293
255
  spec: GeneratedSpec,
294
256
  provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
@@ -305,7 +267,7 @@ export function createComponentGenerator(
305
267
  const rank = options.rank ?? 'signals';
306
268
  const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
307
269
  const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
308
- const singleFlight = createSingleFlight<ModelCall>();
270
+ const singleFlight = createSingleFlight();
309
271
 
310
272
  const report = (event: GenerationEvent): void => {
311
273
  if (!options.onEvent) return;
@@ -322,12 +284,6 @@ export function createComponentGenerator(
322
284
  startedAt: number,
323
285
  key: string | null,
324
286
  degradedReason: DegradedReason,
325
- /**
326
- * What the model call cost, when there was one. A generation that is
327
- * unusable for this shopper was still asked for and still billed, so
328
- * omitting it here would hide the calls that produce nothing — exactly the
329
- * ones worth knowing about.
330
- */
331
287
  modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
332
288
  calledModel: false,
333
289
  },
@@ -352,36 +308,18 @@ export function createComponentGenerator(
352
308
  });
353
309
  };
354
310
 
355
- /**
356
- * Reads the cache, treating anything unexpected as a miss.
357
- *
358
- * The value is re-validated because a store is a port a host implements, and
359
- * what comes back is no more trustworthy than what a model returns — a shared
360
- * store outlives a deploy, so it can hold entries written by an older shape of
361
- * the spec. Generating again is always safe; handing an unvalidated object to
362
- * reconciliation is not.
363
- */
364
311
  const readCache = async (key: string): Promise<CacheRead> => {
365
312
  try {
366
313
  const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
367
314
  const parsed = cachedSpecSchema.safeParse(stored);
368
315
  return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
369
316
  } catch (error) {
370
- // A store that is down or slow degrades to generating, not to an error
371
- // page. Nothing here is worth failing a render over.
317
+ // A store that is down or slow degrades to generating, not to an error page.
372
318
  return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
373
319
  }
374
320
  };
375
321
 
376
- /**
377
- * Writes to the cache without the render waiting for it.
378
- *
379
- * The spec is already in hand; nothing downstream needs the write to finish.
380
- * Awaiting it put a second unbounded call to a host-implemented store on the
381
- * render path, which is the failure this module exists to prevent arriving
382
- * through the other door. The `Promise.resolve` wrapper is what catches a
383
- * store that throws synchronously rather than rejecting.
384
- */
322
+ /** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
385
323
  const storeInBackground = (key: string, cached: CachedSpec): void => {
386
324
  void Promise.resolve()
387
325
  .then(() => cache.set(key, cached))
@@ -391,15 +329,10 @@ export function createComponentGenerator(
391
329
  };
392
330
 
393
331
  /**
394
- * Asks the model.
395
- *
396
332
  * Deliberately does not decide whether the answer is usable. That depends on
397
333
  * the asking shopper's live facts — stock, dislikes, what is in their basket
398
334
  * — and none of those are in the cache key, so a verdict reached here would
399
- * be handed to every request that joined this one. A null `spec` in the
400
- * result means the answer did not satisfy the schema, which is a fault of the
401
- * adapter rather than a judgement about any shopper — and is still a call
402
- * that happened, so its usage comes back with it.
335
+ * be handed to every request that joined this one.
403
336
  */
404
337
  const askModel = async (
405
338
  active: ComponentProvider,
@@ -439,27 +372,18 @@ export function createComponentGenerator(
439
372
  return buildDeterministic(input, digest, startedAt, null, 'no-provider');
440
373
  }
441
374
 
442
- const providerId = `${provider.name}:${provider.model}`;
375
+ const identity = { name: provider.name, model: provider.model };
376
+ const candidateSkus = input.candidates.map((product) => product.sku);
443
377
  const key =
444
378
  generation === 'cohort'
445
- ? cohortCacheKey(
446
- digest,
447
- input.candidates.map((product) => product.sku),
448
- providerId,
449
- )
450
- : specCacheKey(
451
- digest,
452
- input.candidates.map((product) => product.sku),
453
- providerId,
454
- );
379
+ ? cohortCacheKey(digest, candidateSkus, identity)
380
+ : specCacheKey(digest, candidateSkus, identity);
455
381
 
456
382
  const read = await readCache(key);
457
383
  const cached = read.entry;
458
384
  let calledModel = false;
459
385
  let answer: ModelAnswer;
460
- // When the model produced this, not when it was served. A cached
461
- // component is not newly generated, and pretending otherwise makes any
462
- // measure of how stale a page is showing read as zero.
386
+ // When the model produced this, not when it was served.
463
387
  let generatedAt: number;
464
388
 
465
389
  if (cached) {
@@ -467,8 +391,7 @@ export function createComponentGenerator(
467
391
  generatedAt = cached.generatedAt;
468
392
  } else {
469
393
  // Asked before joining, because by the time the shared promise settles
470
- // the entry is gone and there is no way to tell a leader from a
471
- // follower — and they must not both be counted as a model call.
394
+ // the entry is gone and there is no way to tell a leader from a follower.
472
395
  calledModel = !singleFlight.isRunning(key);
473
396
 
474
397
  let call: ModelCall;
@@ -478,9 +401,6 @@ export function createComponentGenerator(
478
401
  );
479
402
  } catch (error) {
480
403
  const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
481
- // The request went out. Leaving `calledModel` to default here reported
482
- // every failed call as no call at all, so the calls that cost money
483
- // and produced nothing were the only ones missing from the count.
484
404
  return buildDeterministic(input, digest, startedAt, key, reason, {
485
405
  calledModel,
486
406
  cache: read.outcome,
@@ -502,24 +422,19 @@ export function createComponentGenerator(
502
422
  // Stored unreconciled on purpose, and stored even when it is unusable
503
423
  // for this shopper. Reconciliation narrows a spec to one shopper's live
504
424
  // facts, and those move independently of the key — a product can sell
505
- // out and come back without the candidate list changing. Keeping what
506
- // the model said means the restock is picked up from cache rather than
507
- // paid for again.
425
+ // out and come back without the candidate list changing.
508
426
  if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
509
427
  }
510
428
 
511
- // One place where anything is served, whichever side of the cache it came
512
- // from, and always against the facts of the shopper asking now.
513
- // A cohort spec names products chosen for whoever asked first.
514
- // Only the cohort path writes a host reason into a spec, so in
429
+ // A cohort spec names products chosen for whoever asked first, and every
430
+ // reason under one of them is written here rather than by the model. In
515
431
  // per-shopper mode this stays empty and every reason is screened.
516
- const hostReasonSkus = new Set<string>();
517
- const served =
432
+ const { spec: served, ourReasons } =
518
433
  generation === 'cohort'
519
- ? fitCohortSpec(answer.spec, input, digest, rank, hostReasonSkus)
520
- : answer.spec;
434
+ ? fitCohortSpec(answer.spec, input, digest, rank)
435
+ : { spec: answer.spec, ourReasons: new Map<string, string>() };
521
436
 
522
- const reconciled = reconcileSpec(served, input, digest, hostReasonSkus);
437
+ const reconciled = reconcileSpec(served, input, digest, ourReasons);
523
438
  if (!reconciled.isUsable) {
524
439
  return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
525
440
  calledModel,
@@ -77,20 +77,9 @@ export const RECOMMENDATION_BASES = [
77
77
  export const productReferenceSchema = z.object({
78
78
  /** Must be a SKU the host supplied in `TrackingInput.candidates`. */
79
79
  sku: z.string(),
80
- /**
81
- * The strategy behind this pick. Reconciliation checks it against the
82
- * shopper's signals, so the model cannot assert a relationship that is not
83
- * there.
84
- */
80
+ /** Checked against the shopper's signals, so the model cannot assert a pick it did not earn. */
85
81
  basis: z.enum(RECOMMENDATION_BASES),
86
- /**
87
- * How the basis is phrased for the shopper, e.g. "Pairs with the boots you
88
- * bought". Free text, but it has to be consistent with `basis`, which is not.
89
- *
90
- * Nullable because reconciliation clears it when it cannot verify the basis:
91
- * a pick may still be worth showing when the stated reason for it is not
92
- * true, but the prose asserting that reason must not render.
93
- */
82
+ /** How the basis is phrased, e.g. "Pairs with the boots you bought". Null once unverifiable. */
94
83
  reason: z.string().nullable(),
95
84
  /** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
96
85
  badge: z.string().nullable(),
@@ -177,13 +166,11 @@ export const generatedSpecSchema = z.object({
177
166
  headline: z.string(),
178
167
  subheadline: z.string().nullable(),
179
168
  blocks: z.array(blockSchema),
180
- /**
181
- * One sentence on why this arrangement was chosen. For engineers reading
182
- * generation logs, not for shoppers; it is not rendered by default.
183
- */
169
+ /** One sentence for generation logs, not for shoppers. Not rendered by default. */
184
170
  rationale: z.string(),
185
171
  });
186
172
  export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
173
+ export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;
187
174
 
188
175
  /** How a spec came to exist. Surfaced for benchmarking and observability. */
189
176
  export type SpecSource = 'llm' | 'cache' | 'fallback';
@@ -238,6 +225,6 @@ export function parseGeneratedSpec(value: unknown): GeneratedSpec {
238
225
  }
239
226
 
240
227
  /** Non-throwing variant, for validating untrusted model output. */
241
- export function safeParseGeneratedSpec(value: unknown) {
228
+ export function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult {
242
229
  return generatedSpecSchema.safeParse(value);
243
230
  }
@@ -3,20 +3,6 @@ import type { SignalDigest } from './signal-digest.js';
3
3
  import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
4
4
  import type { TrackingInput } from './tracking-input.js';
5
5
 
6
- /**
7
- * The deterministic component — what renders when no model does.
8
- *
9
- * The manuscript names data latency as the central risk of moving
10
- * personalisation onto the server path: any delay in the recommendation engine
11
- * blocks the page. This module is the answer. It is pure, synchronous, and
12
- * cannot fail, so the server always has something correct to render — whether
13
- * the model is slow, erroring, rate-limited, or simply not configured.
14
- *
15
- * It reads the same digest and uses the same selector the model path does, so a
16
- * degraded render is a weaker version of the same decision rather than an
17
- * unrelated one. Only the presentation is fixed.
18
- */
19
-
20
6
  /** A featured lead only reads as deliberate when something follows it. */
21
7
  const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
22
8
 
@@ -56,6 +42,9 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
56
42
  * no blocks only when there is genuinely nothing in stock left to show, which
57
43
  * the renderer treats as "render nothing" — an empty recommendation region is
58
44
  * worse than none.
45
+ *
46
+ * Reads the same digest and selector the model path does, so a degraded render is
47
+ * a weaker version of the same decision rather than an unrelated one.
59
48
  */
60
49
  export function buildFallbackSpec(
61
50
  input: TrackingInput,