@rudra-js/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +64 -16
  2. package/dist/claim-screening.d.ts +10 -0
  3. package/dist/claim-screening.d.ts.map +1 -0
  4. package/dist/claim-screening.js +157 -0
  5. package/dist/claim-screening.js.map +1 -0
  6. package/dist/component-generator.d.ts +0 -70
  7. package/dist/component-generator.d.ts.map +1 -1
  8. package/dist/component-generator.js +27 -135
  9. package/dist/component-generator.js.map +1 -1
  10. package/dist/component-spec.d.ts +4 -159
  11. package/dist/component-spec.d.ts.map +1 -1
  12. package/dist/component-spec.js +0 -92
  13. package/dist/component-spec.js.map +1 -1
  14. package/dist/fallback-component.d.ts +0 -6
  15. package/dist/fallback-component.d.ts.map +1 -1
  16. package/dist/fallback-component.js +0 -21
  17. package/dist/fallback-component.js.map +1 -1
  18. package/dist/fit-to-shopper.d.ts +5 -1
  19. package/dist/fit-to-shopper.d.ts.map +1 -1
  20. package/dist/fit-to-shopper.js +5 -14
  21. package/dist/fit-to-shopper.js.map +1 -1
  22. package/dist/index.d.ts +1 -8
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +0 -7
  25. package/dist/index.js.map +1 -1
  26. package/dist/model-prompt.d.ts +1 -30
  27. package/dist/model-prompt.d.ts.map +1 -1
  28. package/dist/model-prompt.js +19 -78
  29. package/dist/model-prompt.js.map +1 -1
  30. package/dist/product-selection.d.ts +0 -29
  31. package/dist/product-selection.d.ts.map +1 -1
  32. package/dist/product-selection.js +1 -22
  33. package/dist/product-selection.js.map +1 -1
  34. package/dist/provider.d.ts +0 -55
  35. package/dist/provider.d.ts.map +1 -1
  36. package/dist/provider.js +0 -12
  37. package/dist/provider.js.map +1 -1
  38. package/dist/reconciliation.d.ts +6 -61
  39. package/dist/reconciliation.d.ts.map +1 -1
  40. package/dist/reconciliation.js +76 -466
  41. package/dist/reconciliation.js.map +1 -1
  42. package/dist/signal-digest.d.ts +1 -18
  43. package/dist/signal-digest.d.ts.map +1 -1
  44. package/dist/signal-digest.js +27 -66
  45. package/dist/signal-digest.js.map +1 -1
  46. package/dist/spec-cache.d.ts +8 -70
  47. package/dist/spec-cache.d.ts.map +1 -1
  48. package/dist/spec-cache.js +5 -68
  49. package/dist/spec-cache.js.map +1 -1
  50. package/dist/tracking-input.d.ts +0 -49
  51. package/dist/tracking-input.d.ts.map +1 -1
  52. package/dist/tracking-input.js +5 -105
  53. package/dist/tracking-input.js.map +1 -1
  54. package/package.json +7 -6
  55. package/src/claim-screening.ts +190 -0
  56. package/src/component-generator.ts +45 -240
  57. package/src/component-spec.ts +17 -118
  58. package/src/fallback-component.ts +0 -22
  59. package/src/fit-to-shopper.ts +12 -16
  60. package/src/index.ts +1 -8
  61. package/src/model-prompt.ts +25 -84
  62. package/src/product-selection.ts +6 -52
  63. package/src/provider.ts +4 -60
  64. package/src/reconciliation.ts +104 -499
  65. package/src/signal-digest.ts +34 -83
  66. package/src/spec-cache.ts +10 -97
  67. package/src/tracking-input.ts +17 -111
@@ -1,27 +1,6 @@
1
1
  import { verify } from '@rudra-js/attested';
2
- import { offeredCandidates } from './model-prompt.js';
3
- /**
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`.
24
- */
2
+ import { ALLOWED_PHRASES, claimIn, hostFacts } from './claim-screening.js';
3
+ import { engagedCategories } from './signal-digest.js';
25
4
  const CLAMP = {
26
5
  headline: 90,
27
6
  subheadline: 140,
@@ -32,72 +11,50 @@ const CLAMP = {
32
11
  bannerText: 160,
33
12
  copyBody: 420,
34
13
  rationale: 300,
35
- // Not rendered anywhere — this is the SKU as it reads back in a violation.
36
14
  violationSku: 32,
37
15
  };
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
- */
44
16
  export const MAX_BLOCKS = 4;
17
+ export function capBlocks(blocks) {
18
+ return blocks.slice(0, MAX_BLOCKS);
19
+ }
45
20
  function clamp(value, limit) {
46
21
  const collapsed = value.trim().replace(/\s+/g, ' ');
47
22
  if (collapsed.length <= limit)
48
23
  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.
51
24
  const cut = collapsed.slice(0, limit - 1);
52
25
  const lastSpace = cut.lastIndexOf(' ');
53
26
  const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
54
27
  return `${base.replace(/[.,;:!?-]+$/, '')}…`;
55
28
  }
56
- function clampNullable(value, limit) {
57
- if (value === null)
58
- return null;
59
- const clamped = clamp(value, limit);
60
- return clamped.length === 0 ? null : clamped;
29
+ function refusedEverywhere(input) {
30
+ const refused = new Set();
31
+ for (const signal of input.signals.dislikes)
32
+ refused.add(signal.sku);
33
+ if (input.context.currentSku)
34
+ refused.add(input.context.currentSku);
35
+ return refused;
61
36
  }
62
- /**
63
- * SKUs that must never be recommended, whatever chose them.
64
- *
65
- * Exported because the deterministic selector applies the same rule when it
66
- * picks. Two copies of "never recommend these" would drift, and the pair that
67
- * drifted would be the model path and the fallback path — the two whose
68
- * comparability the whole evaluation depends on.
69
- */
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);
37
+ export function neverRecommend(input) {
38
+ const { signals } = input;
39
+ const blocked = refusedEverywhere(input);
40
+ for (const signal of [...signals.lastPurchased, ...signals.cart]) {
41
+ blocked.add(signal.sku);
42
+ }
78
43
  return blocked;
79
44
  }
80
- function buildAllowlist(input, digest) {
45
+ function buildAllowlist(input) {
81
46
  const allowed = new Set();
82
47
  for (const product of input.candidates) {
83
- // An out-of-stock candidate is not a recommendation, it is a dead end.
84
48
  if (product.isInStock)
85
49
  allowed.add(product.sku);
86
50
  }
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) };
51
+ return {
52
+ allowed,
53
+ blocked: neverRecommend(input),
54
+ blockedInBundle: refusedEverywhere(input),
55
+ };
90
56
  }
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) {
57
+ function verifyBasis(basis, product, digest, engaged) {
101
58
  switch (basis) {
102
59
  case 'most_viewed':
103
60
  return digest.topViewed.some((viewed) => viewed.sku === product.sku);
@@ -106,289 +63,16 @@ function verifyBasis(basis, product, digest) {
106
63
  case 'complements_purchase':
107
64
  return digest.purchasedSkus.length > 0;
108
65
  case 'liked_category':
109
- return digest.categoryAffinity.some((affinity) => affinity.category === product.category);
66
+ return (engaged.has(product.category) &&
67
+ digest.categoryAffinity.some((affinity) => affinity.category === product.category));
110
68
  case 'similar_to_current':
111
69
  return digest.currentCategory === product.category;
112
70
  case 'popular':
113
- // Makes no claim about this shopper, so there is nothing to check.
114
71
  return true;
115
72
  }
116
73
  }
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
- /** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
321
74
  const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
322
75
  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
- */
392
76
  function createPlacementTracker(maxItems, facts) {
393
77
  const placedSkus = new Set();
394
78
  const violations = [];
@@ -410,30 +94,19 @@ function createPlacementTracker(maxItems, facts) {
410
94
  },
411
95
  };
412
96
  }
413
- /**
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.
419
- *
420
- * Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
421
- * 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.
424
- */
425
- function screenClaim(value, field, tracker, facts = tracker.facts) {
97
+ function screenClaim(value, limit, field, tracker, facts = tracker.facts) {
426
98
  if (value === null)
427
99
  return null;
428
- const kind = claimIn(value);
100
+ const clamped = clamp(value, limit);
101
+ if (clamped.length === 0)
102
+ return null;
103
+ const kind = claimIn(value) ?? claimIn(clamped);
429
104
  if (kind !== null) {
430
105
  tracker.record(`unverifiable-claim:${kind}:${field}`);
431
106
  return null;
432
107
  }
433
- // Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
434
- // 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 });
108
+ const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
109
+ const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
437
110
  if (!result.quantity.supported) {
438
111
  tracker.record(`unverifiable-claim:quantity:${field}`);
439
112
  return null;
@@ -442,31 +115,13 @@ function screenClaim(value, field, tracker, facts = tracker.facts) {
442
115
  tracker.record(`unverifiable-claim:wording:${field}`);
443
116
  return null;
444
117
  }
445
- return value;
118
+ return clamped;
446
119
  }
447
- /**
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.
452
- */
453
- function screenRequired(value, field, tracker) {
454
- return screenClaim(value, field, tracker) ?? '';
120
+ function screenRequired(value, limit, field, tracker) {
121
+ return screenClaim(value, limit, field, tracker) ?? '';
455
122
  }
456
- /**
457
- * Decides whether one SKU may be placed, and names the reason when it may not.
458
- * Does not consume budget — the caller does that once it commits.
459
- *
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.
465
- */
466
123
  function rejectionFor(sku, allowlist, tracker) {
467
- // A rejected SKU is whatever the model wrote, and the schema cannot bound it.
468
124
  const named = clamp(sku, CLAMP.violationSku);
469
- // Either hallucinated or out of stock. Either way it cannot render.
470
125
  if (!allowlist.allowed.has(sku))
471
126
  return `unknown-sku:${named}`;
472
127
  if (allowlist.blocked.has(sku))
@@ -477,7 +132,7 @@ function rejectionFor(sku, allowlist, tracker) {
477
132
  return `budget:dropped:${named}`;
478
133
  return null;
479
134
  }
480
- function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourReasons) {
135
+ function reconcileItems(items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons) {
481
136
  const kept = [];
482
137
  for (const item of items) {
483
138
  const rejection = rejectionFor(item.sku, allowlist, tracker);
@@ -486,30 +141,27 @@ function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourR
486
141
  continue;
487
142
  }
488
143
  const product = candidatesBySku.get(item.sku);
489
- // rejectionFor already proved the SKU is an in-stock candidate.
490
144
  if (!product)
491
145
  continue;
492
146
  tracker.place(item.sku);
493
- const hasSupportedBasis = verifyBasis(item.basis, product, digest);
147
+ const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
494
148
  if (!hasSupportedBasis)
495
149
  tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
496
- // Our sentence, not the model's. It has to match, so a lookalike is still screened.
497
150
  const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
498
151
  const own = tracker.factsFor(item.sku);
152
+ let reason = null;
153
+ let badge = null;
154
+ if (hasSupportedBasis) {
155
+ reason = isOurs
156
+ ? clamp(item.reason ?? '', CLAMP.reason) || null
157
+ : screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
158
+ badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
159
+ }
499
160
  kept.push({
500
161
  sku: item.sku,
501
162
  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),
163
+ reason,
164
+ badge,
513
165
  emphasis: item.emphasis,
514
166
  });
515
167
  }
@@ -527,7 +179,6 @@ function fitOf(bundle, digest, candidatesBySku) {
527
179
  }
528
180
  return fit;
529
181
  }
530
- /** In the cart beats recently viewed, which beats the category being looked at. */
531
182
  function isBetterFit(fit, best) {
532
183
  if (!best)
533
184
  return true;
@@ -541,16 +192,14 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
541
192
  let best;
542
193
  let bestFit;
543
194
  for (const bundle of bundles) {
544
- // A bundle needs room for every product at once.
545
195
  if (bundle.skus.length > tracker.remaining)
546
196
  continue;
547
197
  let isPlaceable = true;
548
198
  for (const sku of bundle.skus) {
549
199
  if (!allowlist.allowed.has(sku))
550
200
  isPlaceable = false;
551
- if (digest.dislikedSkus.includes(sku))
201
+ if (allowlist.blockedInBundle.has(sku))
552
202
  isPlaceable = false;
553
- // Already shown by an earlier block — twice on a page looks broken.
554
203
  if (tracker.hasPlaced(sku))
555
204
  isPlaceable = false;
556
205
  }
@@ -564,35 +213,16 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
564
213
  }
565
214
  return best;
566
215
  }
567
- /**
568
- * The set this shopper should get, decided before anything is placed.
569
- *
570
- * The generator needs the answer early, so it can keep the set's products out
571
- * of the grid and keep room for them. `chooseBundle` stays private: this hands
572
- * out the choice, not the machinery behind it.
573
- *
574
- * `spokenFor` is what the blocks above the bundle block will have placed by the
575
- * time it is reached. Placing it here first is what makes the two choices agree:
576
- * a set is only pre-chosen if reconciliation could still reach for it.
577
- */
578
216
  export function bundleForShopper(input, digest, spokenFor) {
579
- const allowlist = buildAllowlist(input, digest);
217
+ const allowlist = buildAllowlist(input);
580
218
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
581
219
  const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
582
220
  for (const sku of spokenFor)
583
221
  tracker.place(sku);
584
222
  return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
585
223
  }
586
- /**
587
- * The hero products these blocks will really place.
588
- *
589
- * A hero keeps the product the model named — its headline was written about
590
- * that product — so it spends a slot of the item budget the grid cannot have.
591
- * One this shopper cannot see is dropped below and spends nothing, so it is not
592
- * counted here either.
593
- */
594
- export function placeableHeroSkus(blocks, input, digest) {
595
- const allowlist = buildAllowlist(input, digest);
224
+ export function placeableHeroSkus(blocks, input) {
225
+ const allowlist = buildAllowlist(input);
596
226
  const skus = [];
597
227
  for (const block of blocks) {
598
228
  if (block.kind !== 'hero')
@@ -601,14 +231,13 @@ export function placeableHeroSkus(blocks, input, digest) {
601
231
  continue;
602
232
  if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku))
603
233
  continue;
604
- // Two heroes naming the same product: only the first one gets to place it.
605
234
  if (skus.includes(block.sku))
606
235
  continue;
607
236
  skus.push(block.sku);
608
237
  }
609
238
  return skus;
610
239
  }
611
- function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, tracker, ourReasons) {
240
+ function reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, bundles, tracker, ourReasons) {
612
241
  switch (block.kind) {
613
242
  case 'hero': {
614
243
  let sku = block.sku;
@@ -616,17 +245,13 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
616
245
  const rejection = rejectionFor(sku, allowlist, tracker);
617
246
  if (rejection) {
618
247
  tracker.record(rejection);
619
- // A hero without its product is still a legitimate headline.
620
248
  sku = null;
621
249
  }
622
250
  else {
623
251
  tracker.place(sku);
624
252
  }
625
253
  }
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);
254
+ const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
630
255
  if (headline.length === 0 && sku === null) {
631
256
  tracker.record('empty-block:hero');
632
257
  return null;
@@ -634,39 +259,38 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
634
259
  return {
635
260
  kind: 'hero',
636
261
  headline,
637
- body: screenClaim(clampNullable(block.body, CLAMP.subheadline), 'hero-body', tracker),
262
+ body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
638
263
  sku,
639
- ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'hero-cta', tracker),
264
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
640
265
  };
641
266
  }
642
267
  case 'grid': {
643
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
268
+ const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
644
269
  if (items.length === 0) {
645
270
  tracker.record('empty-block:grid');
646
271
  return null;
647
272
  }
648
273
  return {
649
274
  kind: 'grid',
650
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'grid-title', tracker),
651
- // Never leave a grid wider than it has items to fill.
275
+ title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
652
276
  columns: Math.min(block.columns, Math.max(2, items.length)),
653
277
  items,
654
278
  };
655
279
  }
656
280
  case 'carousel': {
657
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
281
+ const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
658
282
  if (items.length === 0) {
659
283
  tracker.record('empty-block:carousel');
660
284
  return null;
661
285
  }
662
286
  return {
663
287
  kind: 'carousel',
664
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'carousel-title', tracker),
288
+ title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
665
289
  items,
666
290
  };
667
291
  }
668
292
  case 'banner': {
669
- const text = screenRequired(clamp(block.text, CLAMP.bannerText), 'banner-text', tracker);
293
+ const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
670
294
  if (text.length === 0) {
671
295
  tracker.record('empty-block:banner');
672
296
  return null;
@@ -675,18 +299,18 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
675
299
  kind: 'banner',
676
300
  tone: block.tone,
677
301
  text,
678
- ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'banner-cta', tracker),
302
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
679
303
  };
680
304
  }
681
305
  case 'copy': {
682
- const body = screenRequired(clamp(block.body, CLAMP.copyBody), 'copy-body', tracker);
306
+ const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
683
307
  if (body.length === 0) {
684
308
  tracker.record('empty-block:copy');
685
309
  return null;
686
310
  }
687
311
  return {
688
312
  kind: 'copy',
689
- title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'copy-title', tracker),
313
+ title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
690
314
  body,
691
315
  };
692
316
  }
@@ -700,55 +324,41 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
700
324
  tracker.place(sku);
701
325
  return {
702
326
  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),
706
- // The model's bundleId is ignored on purpose.
327
+ title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
328
+ body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
329
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
707
330
  bundleId: chosen.id,
708
331
  };
709
332
  }
710
333
  }
711
334
  }
712
- /** True when a spec contains at least one block that actually shows a product. */
713
335
  function showsAnyProduct(blocks) {
714
336
  return blocks.some((block) => (block.kind === 'grid' && block.items.length > 0) ||
715
337
  (block.kind === 'carousel' && block.items.length > 0) ||
716
338
  (block.kind === 'hero' && block.sku !== null) ||
717
- // A bundle shows products too, so it counts the same as grid/carousel/hero.
718
339
  (block.kind === 'bundle' && block.bundleId !== null));
719
340
  }
720
- export function reconcileSpec(generated, input, digest,
721
- /**
722
- * The reason this request wrote itself, by SKU: the host's own `reason` on a
723
- * candidate, or the sentence the selector wrote when there was none. Neither is the
724
- * model's words, and the deterministic component renders the same sentence unscreened.
725
- *
726
- * Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
727
- * reason is the model's, including one that happens to read the same.
728
- */
729
- ourReasons = new Map()) {
730
- const allowlist = buildAllowlist(input, digest);
341
+ export function reconcileSpec(generated, input, digest, ourReasons = new Map()) {
342
+ const allowlist = buildAllowlist(input);
731
343
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
344
+ const engaged = engagedCategories(input);
732
345
  const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
733
346
  if (generated.blocks.length > MAX_BLOCKS) {
734
347
  tracker.record(`too-many-blocks:${generated.blocks.length}`);
735
348
  }
736
349
  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);
350
+ for (const block of capBlocks(generated.blocks)) {
351
+ const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, input.bundles, tracker, ourReasons);
739
352
  if (reconciled !== null)
740
353
  blocks.push(reconciled);
741
354
  }
742
355
  const spec = {
743
356
  tone: generated.tone,
744
- headline: screenRequired(clamp(generated.headline, CLAMP.headline), 'headline', tracker),
745
- subheadline: screenClaim(clampNullable(generated.subheadline, CLAMP.subheadline), 'subheadline', tracker),
357
+ headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
358
+ subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
746
359
  blocks,
747
- rationale: screenRequired(clamp(generated.rationale, CLAMP.rationale), 'rationale', tracker),
360
+ rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
748
361
  };
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.
752
362
  if (!showsAnyProduct(blocks))
753
363
  tracker.record('unusable:no-products');
754
364
  if (spec.headline.length === 0)