@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,37 +1,21 @@
1
1
  import { verify } from '@rudra-js/attested';
2
- import { offeredCandidates } from './model-prompt.js';
2
+ import { ALLOWED_PHRASES, claimIn, hostFacts, type HostFacts } from './claim-screening.js';
3
3
  import type {
4
4
  Block,
5
5
  GeneratedSpec,
6
6
  ProductReference,
7
7
  RecommendationBasis,
8
8
  } from './component-spec.js';
9
- import type { SignalDigest } from './signal-digest.js';
9
+ import { engagedCategories, type SignalDigest } from './signal-digest.js';
10
10
  import type { Bundle, Product, TrackingInput } from './tracking-input.js';
11
11
 
12
12
  /**
13
- * Reconciliation — the boundary between what the model said and what renders.
14
- *
15
- * Schema validation guarantees shape. It cannot guarantee truth: a well-formed
16
- * spec can still name a product that does not exist, one the shopper told us
17
- * they dislike, one that sold out since the candidate set was assembled, or
18
- * claim the shopper viewed something they never saw. This pass is where those
19
- * become impossible.
20
- *
21
- * Nothing here trusts the model. A generation that survives every rule and
22
- * still has nothing to show degrades to `isUsable: false`, and the caller renders
23
- * the deterministic component instead.
24
- *
25
- * Repair, not rejection, is the default. A slightly clipped headline is a better
26
- * outcome for the shopper than a discarded generation, so text is truncated and
27
- * unverifiable claims are downgraded. Only an empty result fails outright.
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.
28
16
  */
29
17
 
30
- /**
31
- * Length ceilings, applied by truncation. These live here rather than in the
32
- * schema because a provider's strict structured-output mode rejects string
33
- * length bounds — see the note in `component-spec.ts`.
34
- */
18
+ /** Truncation ceilings. Not in the schema — a provider's strict mode rejects length bounds. See `component-spec.ts`. */
35
19
  const CLAMP = {
36
20
  headline: 90,
37
21
  subheadline: 140,
@@ -46,14 +30,14 @@ const CLAMP = {
46
30
  violationSku: 32,
47
31
  } as const;
48
32
 
49
- /**
50
- * More than this and the component stops being a component.
51
- *
52
- * Exported because the fill pass has to read the same blocks this module will:
53
- * one past the cap never renders, so nothing is worth reserving for it.
54
- */
33
+ /** More than this and the component stops being a component. */
55
34
  export const MAX_BLOCKS = 4;
56
35
 
36
+ /** The fill pass calls this too: one past the cap never renders, so nothing is worth reserving for it. */
37
+ export function capBlocks(blocks: Block[]): Block[] {
38
+ return blocks.slice(0, MAX_BLOCKS);
39
+ }
40
+
57
41
  export interface ReconcileResult {
58
42
  spec: GeneratedSpec;
59
43
  /** True when something survived that is worth rendering. */
@@ -66,8 +50,7 @@ function clamp(value: string, limit: number): string {
66
50
  const collapsed = value.trim().replace(/\s+/g, ' ');
67
51
  if (collapsed.length <= limit) return collapsed;
68
52
 
69
- // The ellipsis counts against the limit, so leave room for it. Otherwise a
70
- // clamped string is one character longer than the cap it was clamped to.
53
+ // The ellipsis counts against the limit, so leave room for it.
71
54
  const cut = collapsed.slice(0, limit - 1);
72
55
  const lastSpace = cut.lastIndexOf(' ');
73
56
  const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
@@ -81,52 +64,70 @@ function clampNullable(value: string | null, limit: number): string | null {
81
64
  }
82
65
 
83
66
  interface Allowlist {
84
- /** SKUs the model may place. */
85
67
  allowed: Set<string>;
86
- /** SKUs that must never be placed, whatever the model decided. */
87
68
  blocked: Set<string>;
69
+ /** A bundle is placed whole, so it checks this narrower set. See `refusedEverywhere`. */
70
+ blockedInBundle: Set<string>;
71
+ }
72
+
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
+ function refusedEverywhere(input: TrackingInput): Set<string> {
83
+ const refused = new Set<string>();
84
+ for (const signal of input.signals.dislikes) refused.add(signal.sku);
85
+ if (input.context.currentSku) refused.add(input.context.currentSku);
86
+
87
+ return refused;
88
88
  }
89
89
 
90
90
  /**
91
91
  * SKUs that must never be recommended, whatever chose them.
92
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
+ *
93
96
  * Exported because the deterministic selector applies the same rule when it
94
97
  * picks. Two copies of "never recommend these" would drift, and the pair that
95
98
  * drifted would be the model path and the fallback path — the two whose
96
99
  * comparability the whole evaluation depends on.
97
100
  */
98
- export function neverRecommend(digest: SignalDigest): Set<string> {
99
- const blocked = new Set<string>([
100
- ...digest.dislikedSkus,
101
- ...digest.purchasedSkus,
102
- ...digest.cartSkus,
103
- ]);
104
- if (digest.currentSku) blocked.add(digest.currentSku);
101
+ export function neverRecommend(input: TrackingInput): Set<string> {
102
+ const { signals } = input;
103
+
104
+ const blocked = refusedEverywhere(input);
105
+ for (const signal of [...signals.lastPurchased, ...signals.cart]) {
106
+ blocked.add(signal.sku);
107
+ }
108
+
105
109
  return blocked;
106
110
  }
107
111
 
108
- function buildAllowlist(input: TrackingInput, digest: SignalDigest): Allowlist {
112
+ function buildAllowlist(input: TrackingInput): Allowlist {
109
113
  const allowed = new Set<string>();
110
114
  for (const product of input.candidates) {
111
- // An out-of-stock candidate is not a recommendation, it is a dead end.
112
115
  if (product.isInStock) allowed.add(product.sku);
113
116
  }
114
117
 
115
- // Blocked structurally rather than by asking the model nicely. The prompt
116
- // says not to place these; this is what makes it true when it ignores us.
117
- return { allowed, blocked: neverRecommend(digest) };
118
+ return {
119
+ allowed,
120
+ blocked: neverRecommend(input),
121
+ blockedInBundle: refusedEverywhere(input),
122
+ };
118
123
  }
119
124
 
120
- /**
121
- * Checks the model's stated reason for a pick against the shopper's actual
122
- * signals.
123
- *
124
- * `basis` is a factual claim — "you viewed this", "this goes with your cart" —
125
- * and the model has every incentive to reach for the most flattering one. A
126
- * claim we cannot support becomes `popular`, which asserts nothing, and the
127
- * prose that stated it is dropped along with it.
128
- */
129
- function verifyBasis(basis: RecommendationBasis, product: Product, digest: SignalDigest): boolean {
125
+ function verifyBasis(
126
+ basis: RecommendationBasis,
127
+ product: Product,
128
+ digest: SignalDigest,
129
+ engaged: ReadonlySet<string>,
130
+ ): boolean {
130
131
  switch (basis) {
131
132
  case 'most_viewed':
132
133
  return digest.topViewed.some((viewed) => viewed.sku === product.sku);
@@ -135,7 +136,13 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
135
136
  case 'complements_purchase':
136
137
  return digest.purchasedSkus.length > 0;
137
138
  case 'liked_category':
138
- return digest.categoryAffinity.some((affinity) => affinity.category === product.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
+ return (
143
+ engaged.has(product.category) &&
144
+ digest.categoryAffinity.some((affinity) => affinity.category === product.category)
145
+ );
139
146
  case 'similar_to_current':
140
147
  return digest.currentCategory === product.category;
141
148
  case 'popular':
@@ -144,299 +151,12 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
144
151
  }
145
152
  }
146
153
 
147
- /**
148
- * Free text that states something the renderer cannot check.
149
- *
150
- * The prompt bans prices, discounts, delivery dates, stock levels and ratings
151
- * because every one of them moves after the words are written, and a cohort
152
- * component is cached and served again later. `verifyBasis` checks the basis a
153
- * pick claims; nothing checks the sentences around it, so every string the
154
- * model writes is checked here — a heading is not a safer place for a claim
155
- * than the small print under it.
156
- *
157
- * These patterns are the first of three passes and the only one that names which
158
- * of the five kinds a sentence claimed, so they read the domain: "20% off", not
159
- * "20%"; "rated 4.8", not "rated". A specification walks past all of them and is
160
- * then weighed by @rudra-js/attested, which reads no domain at all and asks only
161
- * whether the shop supplied the number.
162
- *
163
- * One rule per line, because each line is a separate judgement about where the
164
- * boundary sits and each one wants its own reason written next to it.
165
- */
166
- const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
167
- {
168
- // Customers scoring the product. "rated for winter use", "rated to -10C"
169
- // and "an IPX7 water rating" are about what the product can take.
170
- kind: 'rating',
171
- patterns: [
172
- /\breviews?\b|\breviewed\b/,
173
- /\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
174
- /\bstars?[\s-]?ratings?\b/,
175
- // A rating somebody gave it, rather than one it was built to.
176
- /\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
177
- // A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
178
- /\bratings?\s+of\s+[0-5]\.\d\b/,
179
- // The same score with the word "rating" nowhere near it: "4.8 out of 5".
180
- /\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
181
- /\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
182
- // "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
183
- /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
184
- // How many other people bought or liked it is a customer claim too.
185
- /\bbest[\s-]?sell(?:er|ers|ing)\b/,
186
- // The words a model reaches for when "best" is banned. Each branch carries its own
187
- // \b — one in front of the group demands a word character before the #, never there.
188
- /(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
189
- /\bloved by (?:thousands|hundreds|millions|\d)/,
190
- ],
191
- },
192
- {
193
- kind: 'price',
194
- patterns: [
195
- // No digit beside it: asking for one let "$thirty-nine and it is yours" through.
196
- /[$£€¥₹]/,
197
- /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
198
- // The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
199
- /\bdollars?\b|\beuros?\b/,
200
- // The same codes on the other side of the number: "USD 20", "EUR 5.99".
201
- /\b(?:usd|eur|gbp)\s?\d/,
202
- // The krona is spelled out rather than drawn, so it needs a number beside it.
203
- /\bkr\s?\d|\d\s?kr\b/,
204
- /\bpric(?:e|es|ed|ing)\b/,
205
- // A before-and-after is a price claim even with no currency on it.
206
- /\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
207
- // "does not feel cheap" is about quality. Only the comparison is money.
208
- /\bcheap(?:er|est)\b/,
209
- /\baffordable\b|\bbargain\b/,
210
- // "at no cost to comfort" is not money. "costs less" is.
211
- /\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
212
- /\blow(?:er)?[\s-]cost\b/,
213
- // "saves weight" and "saves on weight" are both about grams.
214
- /\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
215
- ],
216
- },
217
- {
218
- // A percentage in outdoor copy is a specification far more often than it is
219
- // money off, so it only counts beside a money word: "20% off", not
220
- // "100% recycled".
221
- kind: 'discount',
222
- patterns: [
223
- /\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
224
- /\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
225
- /\bdiscount(?:s|ed)?\b/,
226
- /\bsale\b|\bmarked down\b|\bdeal of\b/,
227
- /\bhalf[\s-]?(?:price|off)\b/,
228
- // Money off with no percent sign anywhere on it: "save 20 off".
229
- /\bsaves?\s+\d[\d.,]*\s+off\b/,
230
- // "extra clearance for thick socks" is room inside the shoe.
231
- /\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
232
- // "reduced weight" and "reduced to 900g" are specifications. Only a
233
- // reduced price, or a reduction with a date on it, is money off.
234
- /\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
235
- ],
236
- },
237
- {
238
- // When it turns up, not what turns up. "ships in a recycled box" and
239
- // "arrives flat-packed" describe the thing, so the verb alone is not enough
240
- // — it needs a day or a date beside it.
241
- kind: 'delivery',
242
- patterns: [
243
- /\bdelivery\b|\bshipping\b/,
244
- /\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
245
- /\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/,
246
- /\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
247
- /\bin time for\b/,
248
- ],
249
- },
250
- {
251
- kind: 'stock',
252
- patterns: [
253
- /\b(?:in|out of|low on) stock\b/,
254
- /\blimited stock\b/,
255
- /\brestocked?\b|\bsold out\b/,
256
- // "the last few miles" is a distance, so a count needs "left" after it.
257
- /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
258
- /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
259
- ],
260
- },
261
- ];
262
-
263
- /**
264
- * Letters that render as a Latin letter but are not one. A Cyrillic о in
265
- * "in stоck" would otherwise carry a stock claim straight past the rule written
266
- * for it, and the shopper would read the claim anyway.
267
- */
268
- const CONFUSABLES: Record<string, string> = {
269
- а: 'a',
270
- в: 'b',
271
- е: 'e',
272
- к: 'k',
273
- м: 'm',
274
- н: 'h',
275
- о: 'o',
276
- р: 'p',
277
- с: 'c',
278
- т: 't',
279
- х: 'x',
280
- у: 'y',
281
- ѕ: 's',
282
- і: 'i',
283
- ј: 'j',
284
- ԁ: 'd',
285
- һ: 'h',
286
- ӏ: 'l',
287
- α: 'a',
288
- ε: 'e',
289
- ι: 'i',
290
- κ: 'k',
291
- ν: 'v',
292
- ο: 'o',
293
- ρ: 'p',
294
- τ: 't',
295
- υ: 'u',
296
- χ: 'x',
297
- ϲ: 'c',
298
- };
299
-
300
- /**
301
- * Characters that render as nothing: format characters, default-ignorable code
302
- * points and the controls. Cf and Default_Ignorable each hold characters the
303
- * other does not.
304
- *
305
- * The same class @rudra-js/attested strips in its own `hidden.ts`. The second copy stays
306
- * now that core calls attested, because it feeds core's own patterns and those reach two
307
- * things attested's phrase list does not hold: "20% οff" spelled with a Greek omicron,
308
- * and "PRİCED to move". Widening one and not the other is still the mistake to watch for.
309
- */
310
- const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
311
-
312
- /**
313
- * The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
314
- *
315
- * No test pins this line, and none can: clamping collapses every run of whitespace
316
- * before a claim is screened, so nothing here ever sees one. It is kept so the class
317
- * reads the same in both copies.
318
- */
319
- const SPACING = /[\t\n\v\f\r\u0085]/;
320
-
321
- /**
322
- * Marks that hang on the character before them instead of taking a column of their
323
- * own. Dropped after composition, so an accented e keeps its accent while an
324
- * overline, which composes with nothing, cannot hide a word from its own rule.
325
- */
326
- const MARKS = /[\p{Mn}\p{Me}]/gu;
327
-
328
- /**
329
- * One shape for the text before the patterns read it, so a word that renders as
330
- * "in stock" is read as "in stock" however it was spelled.
331
- *
332
- * Only the reading is normalised. What renders is what the model wrote.
333
- */
334
- function normaliseForClaims(text: string): string {
335
- const lowered = text
336
- .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
337
- .normalize('NFKC')
338
- .toLowerCase()
339
- .normalize('NFC')
340
- .replace(MARKS, '');
341
-
342
- let folded = '';
343
- for (const char of lowered) folded += CONFUSABLES[char] ?? char;
344
- return folded;
345
- }
346
-
347
- /**
348
- * Wording attested bans that this project has already ruled is not a claim. An allowance
349
- * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
350
- * clearance for" — forgives whatever follows it. Exported so a test can hold the list
351
- * against attested's own.
352
- */
353
- export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
354
-
355
- /** Only a string with one of these in it can stand behind a numeral. */
356
- const DIGIT = /\p{Nd}/u;
357
-
358
154
  /** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
359
155
  const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
360
156
 
361
157
  const NO_FACTS: readonly string[] = [];
362
158
 
363
- /** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
364
- const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
365
-
366
- /** Wide enough for every finite JavaScript number, and for anything a shop sells. */
367
- const MAX_EXPONENT = 1000;
368
-
369
- // `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
370
- // attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
371
- function canLayOut(fact: string): boolean {
372
- const match = BARE_EXPONENT.exec(fact.trim());
373
- if (match === null) return true;
374
- return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
375
- }
376
-
377
- /**
378
- * The numbers the shop published about one product: its category and tags, only the strings
379
- * carrying a digit. Exported so a test can pin what never reaches attested.
380
- */
381
- export function productFacts(product: Product): string[] {
382
- const facts: string[] = [];
383
- if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
384
- for (const tag of product.tags) {
385
- if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
386
- }
387
- return facts;
388
- }
389
-
390
- interface HostFacts {
391
- pooled: string[];
392
- bySku: Map<string, string[]>;
393
- }
394
-
395
- /**
396
- * The shop's own numbers for this request, for attested to check numerals against.
397
- *
398
- * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
399
- * means a product the model never saw. A title and a rating it is shown but told never to
400
- * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
401
- * from the request rather than the catalogue, so a host passing a URL segment through would
402
- * hand this list to whoever types the URL.
403
- *
404
- * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
405
- * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
406
- * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
407
- */
408
- function hostFacts(input: TrackingInput): HostFacts {
409
- const pooled = new Set<string>();
410
- const bySku = new Map<string, string[]>();
411
-
412
- for (const product of offeredCandidates(input)) {
413
- const own = productFacts(product);
414
- bySku.set(product.sku, own);
415
- for (const fact of own) pooled.add(fact);
416
- }
417
-
418
- return { pooled: [...pooled], bySku };
419
- }
420
-
421
- /** Names the first forbidden claim the text makes, or null when it makes none. */
422
- function claimIn(text: string): string | null {
423
- const normalised = normaliseForClaims(text);
424
- for (const claim of CLAIM_PATTERNS) {
425
- for (const pattern of claim.patterns) {
426
- if (pattern.test(normalised)) return claim.kind;
427
- }
428
- }
429
- return null;
430
- }
431
-
432
- /**
433
- * The running state of one reconciliation pass: what has been placed, how much
434
- * of the item budget is left, and what was changed along the way.
435
- *
436
- * This is deliberately one named thing rather than three parameters threaded
437
- * through every function. The budget and the de-duplication set are global to a
438
- * spec, not to a block, which is the part that is easy to get wrong.
439
- */
159
+ /** The budget and the de-duplication set are global to a spec, not to a block. */
440
160
  function createPlacementTracker(maxItems: number, facts: HostFacts) {
441
161
  const placedSkus = new Set<string>();
442
162
  const violations: string[] = [];
@@ -463,26 +183,27 @@ function createPlacementTracker(maxItems: number, facts: HostFacts) {
463
183
  type PlacementTracker = ReturnType<typeof createPlacementTracker>;
464
184
 
465
185
  /**
466
- * Drops text that makes a claim we cannot check, and names what it claimed.
467
- *
468
- * Runs after clamping, so what is screened is exactly what would have rendered.
469
- * Dropping means what it means everywhere else here: this field becomes null
470
- * and the rest of the block carries on.
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.
471
188
  *
472
189
  * Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
473
190
  * is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
474
- * is a second denylist and the weakest, so it answers last. `facts` defaults to the pooled
475
- * list, and a field that names one product passes that product's own.
191
+ * is a second denylist and the weakest, so it answers last.
476
192
  */
477
193
  function screenClaim(
478
194
  value: string | null,
195
+ limit: number,
479
196
  field: string,
480
197
  tracker: PlacementTracker,
481
198
  facts: readonly string[] = tracker.facts,
482
199
  ): string | null {
483
200
  if (value === null) return null;
484
201
 
485
- const kind = claimIn(value);
202
+ const clamped = clamp(value, limit);
203
+ if (clamped.length === 0) return null;
204
+
205
+ // Both ways round: clamping collapses whitespace, so "in stock" only reads as a claim after.
206
+ const kind = claimIn(value) ?? claimIn(clamped);
486
207
  if (kind !== null) {
487
208
  tracker.record(`unverifiable-claim:${kind}:${field}`);
488
209
  return null;
@@ -490,9 +211,9 @@ function screenClaim(
490
211
 
491
212
  // Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
492
213
  // and reading the fact list to prove that again per field is most of what this costs.
493
- const weighed = NUMERAL.test(value) ? facts : NO_FACTS;
214
+ const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
494
215
 
495
- const result = verify(value, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
216
+ const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
496
217
  if (!result.quantity.supported) {
497
218
  tracker.record(`unverifiable-claim:quantity:${field}`);
498
219
  return null;
@@ -502,28 +223,29 @@ function screenClaim(
502
223
  return null;
503
224
  }
504
225
 
505
- return value;
226
+ return clamped;
506
227
  }
507
228
 
508
229
  /**
509
- * The same screen for a field that cannot be null. Emptying it hands the field
510
- * to the rule that already drops a block, or a whole generation, whose text
511
- * clamps to nothing — so a banner reading "20% off" disappears rather than
512
- * rendering blank.
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.
513
233
  */
514
- function screenRequired(value: string, field: string, tracker: PlacementTracker): string {
515
- return screenClaim(value, field, tracker) ?? '';
234
+ function screenRequired(
235
+ value: string,
236
+ limit: number,
237
+ field: string,
238
+ tracker: PlacementTracker,
239
+ ): string {
240
+ return screenClaim(value, limit, field, tracker) ?? '';
516
241
  }
517
242
 
518
243
  /**
519
- * Decides whether one SKU may be placed, and names the reason when it may not.
520
244
  * Does not consume budget — the caller does that once it commits.
521
245
  *
522
- * Order matters. The budget is checked last because it is the least specific
523
- * cause: a hallucinated SKU that arrives after the budget is spent is still a
524
- * hallucination, and reporting it as `budget:dropped` would understate how
525
- * often the model invents products. These strings are the evaluation signal,
526
- * so each one has to name the fault that actually fired.
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.
527
249
  */
528
250
  function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
529
251
  // A rejected SKU is whatever the model wrote, and the schema cannot bound it.
@@ -542,6 +264,7 @@ function reconcileItems(
542
264
  allowlist: Allowlist,
543
265
  candidatesBySku: Map<string, Product>,
544
266
  digest: SignalDigest,
267
+ engaged: ReadonlySet<string>,
545
268
  tracker: PlacementTracker,
546
269
  ourReasons: ReadonlyMap<string, string>,
547
270
  ): ProductReference[] {
@@ -560,33 +283,33 @@ function reconcileItems(
560
283
 
561
284
  tracker.place(item.sku);
562
285
 
563
- const hasSupportedBasis = verifyBasis(item.basis, product, digest);
286
+ const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
564
287
  if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
565
288
 
566
289
  // Our sentence, not the model's. It has to match, so a lookalike is still screened.
567
290
  const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
568
291
 
569
292
  const own = tracker.factsFor(item.sku);
293
+ const clampedReason = clampNullable(item.reason, CLAMP.reason);
294
+
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
+ let reason: string | null = null;
299
+ let badge: string | null = null;
300
+ if (hasSupportedBasis) {
301
+ reason = isOurs
302
+ ? clampedReason
303
+ : 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.
305
+ badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
306
+ }
570
307
 
571
308
  kept.push({
572
309
  sku: item.sku,
573
310
  basis: hasSupportedBasis ? item.basis : 'popular',
574
- // The prose exists to state the basis. If the basis did not hold, the
575
- // prose is a claim we just decided is untrue.
576
- reason: hasSupportedBasis
577
- ? isOurs
578
- ? clampNullable(item.reason, CLAMP.reason)
579
- : screenClaim(
580
- clampNullable(item.reason, CLAMP.reason),
581
- `reason:${item.sku}`,
582
- tracker,
583
- own,
584
- )
585
- : null,
586
- // A badge is the shortest, loudest text on the card, and the schema's own
587
- // example for it was "Back in stock" — a stock claim. It renders, so it is
588
- // read for claims like every other sentence the model writes.
589
- badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
311
+ reason,
312
+ badge,
590
313
  emphasis: item.emphasis,
591
314
  });
592
315
  }
@@ -594,8 +317,7 @@ function reconcileItems(
594
317
  return kept;
595
318
  }
596
319
 
597
- // Three separate counts, not one score — cart beats views beats category
598
- // regardless of how the counts compare, so they can't be summed.
320
+ // Cart beats views beats category however the counts compare, so they can't be summed.
599
321
  interface BundleFit {
600
322
  cartHits: number;
601
323
  viewedHits: number;
@@ -618,7 +340,6 @@ function fitOf(
618
340
  return fit;
619
341
  }
620
342
 
621
- /** In the cart beats recently viewed, which beats the category being looked at. */
622
343
  function isBetterFit(fit: BundleFit, best: BundleFit | undefined): boolean {
623
344
  if (!best) return true;
624
345
  if (fit.cartHits !== best.cartHits) return fit.cartHits > best.cartHits;
@@ -637,14 +358,12 @@ function chooseBundle(
637
358
  let bestFit: BundleFit | undefined;
638
359
 
639
360
  for (const bundle of bundles) {
640
- // A bundle needs room for every product at once.
641
361
  if (bundle.skus.length > tracker.remaining) continue;
642
362
 
643
363
  let isPlaceable = true;
644
364
  for (const sku of bundle.skus) {
645
365
  if (!allowlist.allowed.has(sku)) isPlaceable = false;
646
- if (digest.dislikedSkus.includes(sku)) isPlaceable = false;
647
- // Already shown by an earlier block — twice on a page looks broken.
366
+ if (allowlist.blockedInBundle.has(sku)) isPlaceable = false;
648
367
  if (tracker.hasPlaced(sku)) isPlaceable = false;
649
368
  }
650
369
  if (!isPlaceable) continue;
@@ -675,7 +394,7 @@ export function bundleForShopper(
675
394
  digest: SignalDigest,
676
395
  spokenFor: readonly string[],
677
396
  ): Bundle | undefined {
678
- const allowlist = buildAllowlist(input, digest);
397
+ const allowlist = buildAllowlist(input);
679
398
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
680
399
  const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
681
400
 
@@ -692,19 +411,14 @@ export function bundleForShopper(
692
411
  * One this shopper cannot see is dropped below and spends nothing, so it is not
693
412
  * counted here either.
694
413
  */
695
- export function placeableHeroSkus(
696
- blocks: readonly Block[],
697
- input: TrackingInput,
698
- digest: SignalDigest,
699
- ): string[] {
700
- const allowlist = buildAllowlist(input, digest);
414
+ export function placeableHeroSkus(blocks: readonly Block[], input: TrackingInput): string[] {
415
+ const allowlist = buildAllowlist(input);
701
416
 
702
417
  const skus: string[] = [];
703
418
  for (const block of blocks) {
704
419
  if (block.kind !== 'hero') continue;
705
420
  if (block.sku === null) continue;
706
421
  if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku)) continue;
707
- // Two heroes naming the same product: only the first one gets to place it.
708
422
  if (skus.includes(block.sku)) continue;
709
423
  skus.push(block.sku);
710
424
  }
@@ -717,6 +431,7 @@ function reconcileBlock(
717
431
  allowlist: Allowlist,
718
432
  candidatesBySku: Map<string, Product>,
719
433
  digest: SignalDigest,
434
+ engaged: ReadonlySet<string>,
720
435
  bundles: readonly Bundle[],
721
436
  tracker: PlacementTracker,
722
437
  ourReasons: ReadonlyMap<string, string>,
@@ -734,14 +449,8 @@ function reconcileBlock(
734
449
  tracker.place(sku);
735
450
  }
736
451
  }
737
- // Every other block kind disappears when its content clamps to nothing.
738
- // A hero with no headline and no product is the same empty region, and
739
- // it would otherwise render above real content.
740
- const headline = screenRequired(
741
- clamp(block.headline, CLAMP.headline),
742
- 'hero-headline',
743
- tracker,
744
- );
452
+ // A hero with no headline and no product is the empty region every other kind drops for.
453
+ const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
745
454
  if (headline.length === 0 && sku === null) {
746
455
  tracker.record('empty-block:hero');
747
456
  return null;
@@ -749,9 +458,9 @@ function reconcileBlock(
749
458
  return {
750
459
  kind: 'hero',
751
460
  headline,
752
- body: screenClaim(clampNullable(block.body, CLAMP.subheadline), 'hero-body', tracker),
461
+ body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
753
462
  sku,
754
- ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'hero-cta', tracker),
463
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
755
464
  };
756
465
  }
757
466
 
@@ -761,6 +470,7 @@ function reconcileBlock(
761
470
  allowlist,
762
471
  candidatesBySku,
763
472
  digest,
473
+ engaged,
764
474
  tracker,
765
475
  ourReasons,
766
476
  );
@@ -770,7 +480,7 @@ function reconcileBlock(
770
480
  }
771
481
  return {
772
482
  kind: 'grid',
773
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'grid-title', tracker),
483
+ title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
774
484
  // Never leave a grid wider than it has items to fill.
775
485
  columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
776
486
  items,
@@ -783,6 +493,7 @@ function reconcileBlock(
783
493
  allowlist,
784
494
  candidatesBySku,
785
495
  digest,
496
+ engaged,
786
497
  tracker,
787
498
  ourReasons,
788
499
  );
@@ -792,13 +503,13 @@ function reconcileBlock(
792
503
  }
793
504
  return {
794
505
  kind: 'carousel',
795
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'carousel-title', tracker),
506
+ title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
796
507
  items,
797
508
  };
798
509
  }
799
510
 
800
511
  case 'banner': {
801
- const text = screenRequired(clamp(block.text, CLAMP.bannerText), 'banner-text', tracker);
512
+ const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
802
513
  if (text.length === 0) {
803
514
  tracker.record('empty-block:banner');
804
515
  return null;
@@ -807,19 +518,19 @@ function reconcileBlock(
807
518
  kind: 'banner',
808
519
  tone: block.tone,
809
520
  text,
810
- ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'banner-cta', tracker),
521
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
811
522
  };
812
523
  }
813
524
 
814
525
  case 'copy': {
815
- const body = screenRequired(clamp(block.body, CLAMP.copyBody), 'copy-body', tracker);
526
+ const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
816
527
  if (body.length === 0) {
817
528
  tracker.record('empty-block:copy');
818
529
  return null;
819
530
  }
820
531
  return {
821
532
  kind: 'copy',
822
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'copy-title', tracker),
533
+ title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
823
534
  body,
824
535
  };
825
536
  }
@@ -835,9 +546,9 @@ function reconcileBlock(
835
546
 
836
547
  return {
837
548
  kind: 'bundle',
838
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'bundle-title', tracker),
839
- body: screenClaim(clampNullable(block.body, CLAMP.subheadline), 'bundle-body', tracker),
840
- ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'bundle-cta', tracker),
549
+ title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
550
+ body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
551
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
841
552
  // The model's bundleId is ignored on purpose.
842
553
  bundleId: chosen.id,
843
554
  };
@@ -845,14 +556,12 @@ function reconcileBlock(
845
556
  }
846
557
  }
847
558
 
848
- /** True when a spec contains at least one block that actually shows a product. */
849
559
  function showsAnyProduct(blocks: Block[]): boolean {
850
560
  return blocks.some(
851
561
  (block) =>
852
562
  (block.kind === 'grid' && block.items.length > 0) ||
853
563
  (block.kind === 'carousel' && block.items.length > 0) ||
854
564
  (block.kind === 'hero' && block.sku !== null) ||
855
- // A bundle shows products too, so it counts the same as grid/carousel/hero.
856
565
  (block.kind === 'bundle' && block.bundleId !== null),
857
566
  );
858
567
  }
@@ -871,8 +580,9 @@ export function reconcileSpec(
871
580
  */
872
581
  ourReasons: ReadonlyMap<string, string> = new Map(),
873
582
  ): ReconcileResult {
874
- const allowlist = buildAllowlist(input, digest);
583
+ const allowlist = buildAllowlist(input);
875
584
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
585
+ const engaged = engagedCategories(input);
876
586
  const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
877
587
 
878
588
  if (generated.blocks.length > MAX_BLOCKS) {
@@ -880,12 +590,13 @@ export function reconcileSpec(
880
590
  }
881
591
 
882
592
  const blocks: Block[] = [];
883
- for (const block of generated.blocks.slice(0, MAX_BLOCKS)) {
593
+ for (const block of capBlocks(generated.blocks)) {
884
594
  const reconciled = reconcileBlock(
885
595
  block,
886
596
  allowlist,
887
597
  candidatesBySku,
888
598
  digest,
599
+ engaged,
889
600
  input.bundles,
890
601
  tracker,
891
602
  ourReasons,
@@ -895,19 +606,13 @@ export function reconcileSpec(
895
606
 
896
607
  const spec: GeneratedSpec = {
897
608
  tone: generated.tone,
898
- headline: screenRequired(clamp(generated.headline, CLAMP.headline), 'headline', tracker),
899
- subheadline: screenClaim(
900
- clampNullable(generated.subheadline, CLAMP.subheadline),
901
- 'subheadline',
902
- tracker,
903
- ),
609
+ headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
610
+ subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
904
611
  blocks,
905
- rationale: screenRequired(clamp(generated.rationale, CLAMP.rationale), 'rationale', tracker),
612
+ rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
906
613
  };
907
614
 
908
- // A component that recommends nothing is worse than no component at all.
909
- // Recorded separately: "no products survived" and "the model returned no
910
- // headline" are different failures and want different fixes.
615
+ // Recorded separately: no products and no headline are different failures.
911
616
  if (!showsAnyProduct(blocks)) tracker.record('unusable:no-products');
912
617
  if (spec.headline.length === 0) tracker.record('unusable:no-headline');
913
618
  const isUsable = showsAnyProduct(blocks) && spec.headline.length > 0;