@rudra-js/core 0.7.3 → 0.7.5

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 (55) hide show
  1. package/README.md +113 -22
  2. package/dist/claim-screening.d.ts +3 -3
  3. package/dist/claim-screening.d.ts.map +1 -1
  4. package/dist/claim-screening.js +37 -102
  5. package/dist/claim-screening.js.map +1 -1
  6. package/dist/component-generator.d.ts.map +1 -1
  7. package/dist/component-generator.js +161 -176
  8. package/dist/component-generator.js.map +1 -1
  9. package/dist/component-spec.d.ts +1 -0
  10. package/dist/component-spec.d.ts.map +1 -1
  11. package/dist/component-spec.js +3 -0
  12. package/dist/component-spec.js.map +1 -1
  13. package/dist/fallback-component.d.ts +1 -1
  14. package/dist/fallback-component.d.ts.map +1 -1
  15. package/dist/fallback-component.js +9 -17
  16. package/dist/fallback-component.js.map +1 -1
  17. package/dist/fit-to-shopper.d.ts.map +1 -1
  18. package/dist/fit-to-shopper.js +19 -19
  19. package/dist/fit-to-shopper.js.map +1 -1
  20. package/dist/model-prompt.d.ts.map +1 -1
  21. package/dist/model-prompt.js +56 -39
  22. package/dist/model-prompt.js.map +1 -1
  23. package/dist/product-selection.d.ts.map +1 -1
  24. package/dist/product-selection.js +15 -31
  25. package/dist/product-selection.js.map +1 -1
  26. package/dist/provider.d.ts.map +1 -1
  27. package/dist/provider.js.map +1 -1
  28. package/dist/reconciliation.d.ts +3 -3
  29. package/dist/reconciliation.d.ts.map +1 -1
  30. package/dist/reconciliation.js +133 -171
  31. package/dist/reconciliation.js.map +1 -1
  32. package/dist/signal-digest.d.ts +1 -1
  33. package/dist/signal-digest.d.ts.map +1 -1
  34. package/dist/signal-digest.js +51 -52
  35. package/dist/signal-digest.js.map +1 -1
  36. package/dist/spec-cache.d.ts +0 -4
  37. package/dist/spec-cache.d.ts.map +1 -1
  38. package/dist/spec-cache.js +15 -35
  39. package/dist/spec-cache.js.map +1 -1
  40. package/dist/tracking-input.d.ts.map +1 -1
  41. package/dist/tracking-input.js +16 -19
  42. package/dist/tracking-input.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/claim-screening.ts +41 -125
  45. package/src/component-generator.ts +223 -220
  46. package/src/component-spec.ts +4 -15
  47. package/src/fallback-component.ts +11 -20
  48. package/src/fit-to-shopper.ts +19 -21
  49. package/src/model-prompt.ts +55 -54
  50. package/src/product-selection.ts +21 -50
  51. package/src/provider.ts +0 -4
  52. package/src/reconciliation.ts +159 -268
  53. package/src/signal-digest.ts +50 -79
  54. package/src/spec-cache.ts +15 -48
  55. package/src/tracking-input.ts +21 -36
@@ -1,25 +1,24 @@
1
- import { verify } from '@rudra-js/attested';
2
- import { ALLOWED_PHRASES, claimIn, hostFacts, type HostFacts } from './claim-screening.js';
3
- import type {
4
- Block,
5
- GeneratedSpec,
6
- ProductReference,
7
- RecommendationBasis,
1
+ import { hostFacts, NO_FACTS, unbackedClaim, type HostFacts } from './claim-screening.js';
2
+ import {
3
+ columnsFor,
4
+ type Block,
5
+ type GeneratedSpec,
6
+ type ProductReference,
7
+ type RecommendationBasis,
8
8
  } from './component-spec.js';
9
- import { engagedCategories, type SignalDigest } from './signal-digest.js';
10
- import type { Bundle, Product, TrackingInput } from './tracking-input.js';
9
+ import { categoriesTouchedBySignals, type SignalDigest } from './signal-digest.js';
10
+ import { FIELD_LIMITS, type Bundle, type Product, type TrackingInput } from './tracking-input.js';
11
11
 
12
12
  const CLAMP = {
13
13
  headline: 90,
14
14
  subheadline: 140,
15
15
  blockTitle: 80,
16
- reason: 120,
16
+ reason: FIELD_LIMITS.reason,
17
17
  badge: 24,
18
18
  ctaLabel: 32,
19
19
  bannerText: 160,
20
20
  copyBody: 420,
21
21
  rationale: 300,
22
-
23
22
  violationSku: 32,
24
23
  } as const;
25
24
 
@@ -31,9 +30,7 @@ export function capBlocks(blocks: Block[]): Block[] {
31
30
 
32
31
  export interface ReconcileResult {
33
32
  spec: GeneratedSpec;
34
-
35
33
  isUsable: boolean;
36
-
37
34
  violations: string[];
38
35
  }
39
36
 
@@ -47,51 +44,66 @@ function clamp(value: string, limit: number): string {
47
44
  return `${base.replace(/[.,;:!?-]+$/, '')}…`;
48
45
  }
49
46
 
50
- interface Allowlist {
51
- allowed: Set<string>;
52
- blocked: Set<string>;
53
-
54
- blockedInBundle: Set<string>;
55
- }
56
-
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;
47
+ function blockedForBundles(input: TrackingInput): Set<string> {
48
+ const blocked = new Set<string>();
49
+ for (const signal of input.signals.dislikes) blocked.add(signal.sku);
50
+ if (input.context.currentSku) blocked.add(input.context.currentSku);
51
+ return blocked;
63
52
  }
64
53
 
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
-
54
+ export function blockedForPicks(input: TrackingInput): Set<string> {
55
+ const blocked = blockedForBundles(input);
56
+ for (const signal of input.signals.lastPurchased) blocked.add(signal.sku);
57
+ for (const signal of input.signals.cart) blocked.add(signal.sku);
73
58
  return blocked;
74
59
  }
75
60
 
76
- function buildAllowlist(input: TrackingInput): Allowlist {
77
- const allowed = new Set<string>();
61
+ function inStockSkus(input: TrackingInput): Set<string> {
62
+ const skus = new Set<string>();
78
63
  for (const product of input.candidates) {
79
- if (product.isInStock) allowed.add(product.sku);
64
+ if (product.isInStock) skus.add(product.sku);
80
65
  }
66
+ return skus;
67
+ }
81
68
 
69
+ interface Run {
70
+ readonly allowed: ReadonlySet<string>;
71
+ readonly blockedForPicks: ReadonlySet<string>;
72
+ readonly blockedForBundles: ReadonlySet<string>;
73
+ readonly products: ReadonlyMap<string, Product>;
74
+ readonly digest: SignalDigest;
75
+ readonly engaged: ReadonlySet<string>;
76
+ readonly bundles: readonly Bundle[];
77
+ readonly ourReasons: ReadonlyMap<string, string>;
78
+ readonly facts: HostFacts;
79
+ placed: Set<string>;
80
+ remaining: number;
81
+ violations: string[];
82
+ }
83
+
84
+ function startRun(
85
+ input: TrackingInput,
86
+ digest: SignalDigest,
87
+ ourReasons: ReadonlyMap<string, string>,
88
+ ): Run {
82
89
  return {
83
- allowed,
84
- blocked: neverRecommend(input),
85
- blockedInBundle: refusedEverywhere(input),
90
+ allowed: inStockSkus(input),
91
+ blockedForPicks: blockedForPicks(input),
92
+ blockedForBundles: blockedForBundles(input),
93
+ products: new Map(input.candidates.map((product) => [product.sku, product])),
94
+ digest,
95
+ engaged: categoriesTouchedBySignals(input),
96
+ bundles: input.bundles,
97
+ ourReasons,
98
+ facts: hostFacts(input),
99
+ placed: new Set(),
100
+ remaining: digest.maxItems,
101
+ violations: [],
86
102
  };
87
103
  }
88
104
 
89
- function verifyBasis(
90
- basis: RecommendationBasis,
91
- product: Product,
92
- digest: SignalDigest,
93
- engaged: ReadonlySet<string>,
94
- ): boolean {
105
+ function verifyBasis(basis: RecommendationBasis, product: Product, run: Run): boolean {
106
+ const { digest } = run;
95
107
  switch (basis) {
96
108
  case 'most_viewed':
97
109
  return digest.topViewed.some((viewed) => viewed.sku === product.sku);
@@ -101,7 +113,7 @@ function verifyBasis(
101
113
  return digest.purchasedSkus.length > 0;
102
114
  case 'liked_category':
103
115
  return (
104
- engaged.has(product.category) &&
116
+ run.engaged.has(product.category) &&
105
117
  digest.categoryAffinity.some((affinity) => affinity.category === product.category)
106
118
  );
107
119
  case 'similar_to_current':
@@ -111,135 +123,82 @@ function verifyBasis(
111
123
  }
112
124
  }
113
125
 
114
- const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
115
-
116
- const NO_FACTS: readonly string[] = [];
117
-
118
- function createPlacementTracker(maxItems: number, facts: HostFacts) {
119
- const placedSkus = new Set<string>();
120
- const violations: string[] = [];
121
- let remaining = maxItems;
122
-
123
- return {
124
- violations,
125
- facts: facts.pooled,
126
- factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
127
- get remaining() {
128
- return remaining;
129
- },
130
- hasPlaced: (sku: string) => placedSkus.has(sku),
131
- record(violation: string) {
132
- violations.push(violation);
133
- },
134
- place(sku: string) {
135
- placedSkus.add(sku);
136
- remaining -= 1;
137
- },
138
- };
126
+ function place(run: Run, sku: string): void {
127
+ run.placed.add(sku);
128
+ run.remaining -= 1;
139
129
  }
140
130
 
141
- type PlacementTracker = ReturnType<typeof createPlacementTracker>;
142
-
143
131
  function screenClaim(
144
132
  value: string | null,
145
133
  limit: number,
146
134
  field: string,
147
- tracker: PlacementTracker,
148
- facts: readonly string[] = tracker.facts,
135
+ run: Run,
136
+ facts: readonly string[] = run.facts.pooled,
149
137
  ): string | null {
150
138
  if (value === null) return null;
151
139
 
152
140
  const clamped = clamp(value, limit);
153
141
  if (clamped.length === 0) return null;
154
142
 
155
- const kind = claimIn(value) ?? claimIn(clamped);
156
- if (kind !== null) {
157
- tracker.record(`unverifiable-claim:${kind}:${field}`);
158
- return null;
159
- }
160
-
161
- const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
143
+ const kind = unbackedClaim(value, clamped, facts);
144
+ if (kind === null) return clamped;
162
145
 
163
- const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
164
- if (!result.quantity.supported) {
165
- tracker.record(`unverifiable-claim:quantity:${field}`);
166
- return null;
167
- }
168
- if (!result.wording.supported) {
169
- tracker.record(`unverifiable-claim:wording:${field}`);
170
- return null;
171
- }
172
-
173
- return clamped;
146
+ run.violations.push(`unverifiable-claim:${kind}:${field}`);
147
+ return null;
174
148
  }
175
149
 
176
- function screenRequired(
177
- value: string,
178
- limit: number,
179
- field: string,
180
- tracker: PlacementTracker,
181
- ): string {
182
- return screenClaim(value, limit, field, tracker) ?? '';
150
+ function screenRequired(value: string, limit: number, field: string, run: Run): string {
151
+ return screenClaim(value, limit, field, run) ?? '';
183
152
  }
184
153
 
185
- function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
154
+ function rejectionFor(sku: string, run: Run): string | null {
186
155
  const named = clamp(sku, CLAMP.violationSku);
187
-
188
- if (!allowlist.allowed.has(sku)) return `unknown-sku:${named}`;
189
- if (allowlist.blocked.has(sku)) return `blocked-sku:${named}`;
190
- if (tracker.hasPlaced(sku)) return `duplicate-sku:${named}`;
191
- if (tracker.remaining <= 0) return `budget:dropped:${named}`;
156
+ if (!run.allowed.has(sku)) return `unknown-sku:${named}`;
157
+ if (run.blockedForPicks.has(sku)) return `blocked-sku:${named}`;
158
+ if (run.placed.has(sku)) return `duplicate-sku:${named}`;
159
+ if (run.remaining <= 0) return `budget:dropped:${named}`;
192
160
  return null;
193
161
  }
194
162
 
195
- function reconcileItems(
196
- items: ProductReference[],
197
- allowlist: Allowlist,
198
- candidatesBySku: Map<string, Product>,
199
- digest: SignalDigest,
200
- engaged: ReadonlySet<string>,
201
- tracker: PlacementTracker,
202
- ourReasons: ReadonlyMap<string, string>,
203
- ): ProductReference[] {
163
+ function takeSlot(sku: string, run: Run): string | null {
164
+ const rejection = rejectionFor(sku, run);
165
+ if (rejection !== null) {
166
+ run.violations.push(rejection);
167
+ return null;
168
+ }
169
+ place(run, sku);
170
+ return sku;
171
+ }
172
+
173
+ function reconcileItems(items: ProductReference[], run: Run): ProductReference[] {
204
174
  const kept: ProductReference[] = [];
205
175
 
206
176
  for (const item of items) {
207
- const rejection = rejectionFor(item.sku, allowlist, tracker);
208
- if (rejection) {
209
- tracker.record(rejection);
210
- continue;
211
- }
212
-
213
- const product = candidatesBySku.get(item.sku);
177
+ if (takeSlot(item.sku, run) === null) continue;
214
178
 
179
+ const product = run.products.get(item.sku);
215
180
  if (!product) continue;
216
181
 
217
- tracker.place(item.sku);
218
-
219
- const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
220
- if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
221
-
222
- const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
223
-
224
- const own = tracker.factsFor(item.sku);
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);
182
+ if (!verifyBasis(item.basis, product, run)) {
183
+ run.violations.push(`unsupported-basis:${item.basis}:${item.sku}`);
184
+ kept.push({
185
+ sku: item.sku,
186
+ basis: 'popular',
187
+ reason: null,
188
+ badge: null,
189
+ emphasis: item.emphasis,
190
+ });
191
+ continue;
234
192
  }
235
193
 
236
- kept.push({
237
- sku: item.sku,
238
- basis: hasSupportedBasis ? item.basis : 'popular',
239
- reason,
240
- badge,
241
- emphasis: item.emphasis,
242
- });
194
+ const own = run.facts.bySku.get(item.sku) ?? NO_FACTS;
195
+ const reason =
196
+ item.reason !== null && run.ourReasons.get(item.sku) === item.reason
197
+ ? clamp(item.reason, CLAMP.reason) || null
198
+ : screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, run, own);
199
+ const badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, run, own);
200
+
201
+ kept.push({ sku: item.sku, basis: item.basis, reason, badge, emphasis: item.emphasis });
243
202
  }
244
203
 
245
204
  return kept;
@@ -254,16 +213,14 @@ interface BundleFit {
254
213
  function fitOf(
255
214
  bundle: Bundle,
256
215
  digest: SignalDigest,
257
- candidatesBySku: Map<string, Product>,
216
+ products: ReadonlyMap<string, Product>,
258
217
  ): BundleFit {
259
218
  const fit: BundleFit = { cartHits: 0, viewedHits: 0, categoryHits: 0 };
260
-
261
219
  for (const sku of bundle.skus) {
262
220
  if (digest.cartSkus.includes(sku)) fit.cartHits += 1;
263
221
  else if (digest.topViewed.some((viewed) => viewed.sku === sku)) fit.viewedHits += 1;
264
- else if (candidatesBySku.get(sku)?.category === digest.currentCategory) fit.categoryHits += 1;
222
+ else if (products.get(sku)?.category === digest.currentCategory) fit.categoryHits += 1;
265
223
  }
266
-
267
224
  return fit;
268
225
  }
269
226
 
@@ -274,28 +231,19 @@ function isBetterFit(fit: BundleFit, best: BundleFit | undefined): boolean {
274
231
  return fit.categoryHits > best.categoryHits;
275
232
  }
276
233
 
277
- function chooseBundle(
278
- bundles: readonly Bundle[],
279
- allowlist: Allowlist,
280
- digest: SignalDigest,
281
- candidatesBySku: Map<string, Product>,
282
- tracker: PlacementTracker,
283
- ): Bundle | undefined {
234
+ function chooseBundle(run: Run): Bundle | undefined {
284
235
  let best: Bundle | undefined;
285
236
  let bestFit: BundleFit | undefined;
286
237
 
287
- for (const bundle of bundles) {
288
- if (bundle.skus.length > tracker.remaining) continue;
238
+ for (const bundle of run.bundles) {
239
+ if (bundle.skus.length > run.remaining) continue;
289
240
 
290
- let isPlaceable = true;
291
- for (const sku of bundle.skus) {
292
- if (!allowlist.allowed.has(sku)) isPlaceable = false;
293
- if (allowlist.blockedInBundle.has(sku)) isPlaceable = false;
294
- if (tracker.hasPlaced(sku)) isPlaceable = false;
295
- }
241
+ const isPlaceable = bundle.skus.every(
242
+ (sku) => run.allowed.has(sku) && !run.blockedForBundles.has(sku) && !run.placed.has(sku),
243
+ );
296
244
  if (!isPlaceable) continue;
297
245
 
298
- const fit = fitOf(bundle, digest, candidatesBySku);
246
+ const fit = fitOf(bundle, run.digest, run.products);
299
247
  if (isBetterFit(fit, bestFit)) {
300
248
  best = bundle;
301
249
  bestFit = fit;
@@ -310,154 +258,109 @@ export function bundleForShopper(
310
258
  digest: SignalDigest,
311
259
  spokenFor: readonly string[],
312
260
  ): Bundle | undefined {
313
- const allowlist = buildAllowlist(input);
314
- const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
315
- const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
316
-
317
- for (const sku of spokenFor) tracker.place(sku);
261
+ const run = startRun(input, digest, new Map());
262
+ for (const sku of spokenFor) place(run, sku);
318
263
 
319
- return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
264
+ return chooseBundle(run);
320
265
  }
321
266
 
322
267
  export function placeableHeroSkus(blocks: readonly Block[], input: TrackingInput): string[] {
323
- const allowlist = buildAllowlist(input);
324
-
268
+ const allowed = inStockSkus(input);
269
+ const blocked = blockedForPicks(input);
325
270
  const skus: string[] = [];
326
271
  for (const block of blocks) {
327
- if (block.kind !== 'hero') continue;
328
- if (block.sku === null) continue;
329
- if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku)) continue;
330
- if (skus.includes(block.sku)) continue;
331
- skus.push(block.sku);
272
+ if (block.kind !== 'hero' || block.sku === null) continue;
273
+ if (!allowed.has(block.sku) || blocked.has(block.sku)) continue;
274
+ if (!skus.includes(block.sku)) skus.push(block.sku);
332
275
  }
333
-
334
276
  return skus;
335
277
  }
336
278
 
337
- function reconcileBlock(
338
- block: Block,
339
- allowlist: Allowlist,
340
- candidatesBySku: Map<string, Product>,
341
- digest: SignalDigest,
342
- engaged: ReadonlySet<string>,
343
- bundles: readonly Bundle[],
344
- tracker: PlacementTracker,
345
- ourReasons: ReadonlyMap<string, string>,
346
- ): Block | null {
279
+ function reconcileBlock(block: Block, run: Run): Block | null {
347
280
  switch (block.kind) {
348
281
  case 'hero': {
349
- let sku = block.sku;
350
- if (sku !== null) {
351
- const rejection = rejectionFor(sku, allowlist, tracker);
352
- if (rejection) {
353
- tracker.record(rejection);
354
-
355
- sku = null;
356
- } else {
357
- tracker.place(sku);
358
- }
359
- }
360
-
361
- const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
282
+ const sku = block.sku === null ? null : takeSlot(block.sku, run);
283
+ const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', run);
362
284
  if (headline.length === 0 && sku === null) {
363
- tracker.record('empty-block:hero');
285
+ run.violations.push('empty-block:hero');
364
286
  return null;
365
287
  }
366
288
  return {
367
289
  kind: 'hero',
368
290
  headline,
369
- body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
291
+ body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', run),
370
292
  sku,
371
- ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
293
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', run),
372
294
  };
373
295
  }
374
296
 
375
297
  case 'grid': {
376
- const items = reconcileItems(
377
- block.items,
378
- allowlist,
379
- candidatesBySku,
380
- digest,
381
- engaged,
382
- tracker,
383
- ourReasons,
384
- );
298
+ const items = reconcileItems(block.items, run);
385
299
  if (items.length === 0) {
386
- tracker.record('empty-block:grid');
300
+ run.violations.push('empty-block:grid');
387
301
  return null;
388
302
  }
389
303
  return {
390
304
  kind: 'grid',
391
- title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
392
-
393
- columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
305
+ title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', run),
306
+ columns: columnsFor(items.length, block.columns),
394
307
  items,
395
308
  };
396
309
  }
397
310
 
398
311
  case 'carousel': {
399
- const items = reconcileItems(
400
- block.items,
401
- allowlist,
402
- candidatesBySku,
403
- digest,
404
- engaged,
405
- tracker,
406
- ourReasons,
407
- );
312
+ const items = reconcileItems(block.items, run);
408
313
  if (items.length === 0) {
409
- tracker.record('empty-block:carousel');
314
+ run.violations.push('empty-block:carousel');
410
315
  return null;
411
316
  }
412
317
  return {
413
318
  kind: 'carousel',
414
- title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
319
+ title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', run),
415
320
  items,
416
321
  };
417
322
  }
418
323
 
419
324
  case 'banner': {
420
- const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
325
+ const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', run);
421
326
  if (text.length === 0) {
422
- tracker.record('empty-block:banner');
327
+ run.violations.push('empty-block:banner');
423
328
  return null;
424
329
  }
425
330
  return {
426
331
  kind: 'banner',
427
332
  tone: block.tone,
428
333
  text,
429
- ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
334
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', run),
430
335
  };
431
336
  }
432
337
 
433
338
  case 'copy': {
434
- const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
339
+ const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', run);
435
340
  if (body.length === 0) {
436
- tracker.record('empty-block:copy');
341
+ run.violations.push('empty-block:copy');
437
342
  return null;
438
343
  }
439
344
  return {
440
345
  kind: 'copy',
441
- title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
346
+ title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', run),
442
347
  body,
443
348
  };
444
349
  }
445
350
 
446
351
  case 'bundle': {
447
- const chosen = chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker);
352
+ const chosen = chooseBundle(run);
448
353
  if (!chosen) {
449
- tracker.record('no-bundle');
354
+ run.violations.push('no-bundle');
450
355
  return null;
451
356
  }
452
357
 
453
- for (const sku of chosen.skus) tracker.place(sku);
454
-
358
+ for (const sku of chosen.skus) place(run, sku);
455
359
  return {
456
360
  kind: 'bundle',
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
-
361
+ title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', run),
362
+ body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', run),
363
+ ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', run),
461
364
  bundleId: chosen.id,
462
365
  };
463
366
  }
@@ -478,44 +381,32 @@ export function reconcileSpec(
478
381
  generated: GeneratedSpec,
479
382
  input: TrackingInput,
480
383
  digest: SignalDigest,
481
-
482
384
  ourReasons: ReadonlyMap<string, string> = new Map(),
483
385
  ): ReconcileResult {
484
- const allowlist = buildAllowlist(input);
485
- const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
486
- const engaged = engagedCategories(input);
487
- const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
386
+ const run = startRun(input, digest, ourReasons);
488
387
 
489
388
  if (generated.blocks.length > MAX_BLOCKS) {
490
- tracker.record(`too-many-blocks:${generated.blocks.length}`);
389
+ run.violations.push(`too-many-blocks:${generated.blocks.length}`);
491
390
  }
492
391
 
493
392
  const blocks: Block[] = [];
494
393
  for (const block of capBlocks(generated.blocks)) {
495
- const reconciled = reconcileBlock(
496
- block,
497
- allowlist,
498
- candidatesBySku,
499
- digest,
500
- engaged,
501
- input.bundles,
502
- tracker,
503
- ourReasons,
504
- );
394
+ const reconciled = reconcileBlock(block, run);
505
395
  if (reconciled !== null) blocks.push(reconciled);
506
396
  }
507
397
 
508
398
  const spec: GeneratedSpec = {
509
399
  tone: generated.tone,
510
- headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
511
- subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
400
+ headline: screenRequired(generated.headline, CLAMP.headline, 'headline', run),
401
+ subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', run),
512
402
  blocks,
513
- rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
403
+ rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', run),
514
404
  };
515
405
 
516
- if (!showsAnyProduct(blocks)) tracker.record('unusable:no-products');
517
- if (spec.headline.length === 0) tracker.record('unusable:no-headline');
518
- const isUsable = showsAnyProduct(blocks) && spec.headline.length > 0;
406
+ const hasProducts = showsAnyProduct(blocks);
407
+ if (!hasProducts) run.violations.push('unusable:no-products');
408
+ if (spec.headline.length === 0) run.violations.push('unusable:no-headline');
409
+ const isUsable = hasProducts && spec.headline.length > 0;
519
410
 
520
- return { spec, isUsable, violations: tracker.violations };
411
+ return { spec, isUsable, violations: run.violations };
521
412
  }