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