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