@rudra-js/core 0.4.0 → 0.6.0

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