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