@rudra-js/core 0.2.0 → 0.3.1

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.
@@ -17,7 +17,7 @@ import {
17
17
  placeableHeroSkus,
18
18
  reconcileSpec,
19
19
  } from './reconciliation.js';
20
- import { selectProducts, type ProductPick } from './product-selection.js';
20
+ import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
21
21
  import { fitToShopper } from './fit-to-shopper.js';
22
22
  import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
23
23
  import {
@@ -113,6 +113,14 @@ export interface ComponentGeneratorOptions {
113
113
  * 'cohort'.
114
114
  */
115
115
  generation?: 'cohort' | 'per-shopper';
116
+ /**
117
+ * How the products are ordered. 'signals' scores each candidate from this
118
+ * shopper's signals. 'given' keeps the order you sent, for a shop whose own
119
+ * ranking is better than four weights. Either way the exclusions and the
120
+ * stock check still apply, and each product still carries a basis
121
+ * reconciliation can verify. Defaults to 'signals'.
122
+ */
123
+ rank?: RankOrder;
116
124
  /** Observability. Never allowed to break a render. */
117
125
  onEvent?: (event: GenerationEvent) => void;
118
126
  }
@@ -242,8 +250,10 @@ function fitCohortSpec(
242
250
  spec: GeneratedSpec,
243
251
  input: TrackingInput,
244
252
  digest: SignalDigest,
253
+ rank: RankOrder,
254
+ hostReasonSkus: Set<string>,
245
255
  ): GeneratedSpec {
246
- const picks = selectProducts(input, digest);
256
+ const picks = selectProducts(input, digest, { rank });
247
257
  // Blocks past the cap never render, so a set is not worth reserving for one.
248
258
  const blocks = spec.blocks.slice(0, MAX_BLOCKS);
249
259
 
@@ -256,26 +266,26 @@ function fitCohortSpec(
256
266
  }
257
267
  aboveBundle.push(block);
258
268
  }
259
- if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
269
+ if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
260
270
 
261
271
  // Only the heroes above the bundle block are placed when it is reached, so
262
272
  // they are all the choice may account for.
263
273
  const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
264
- if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
274
+ if (!chosen) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
265
275
 
266
276
  const spokenFor = new Set<string>(chosen.skus);
267
277
  for (const sku of placeableHeroSkus(blocks, input, digest)) spokenFor.add(sku);
268
278
 
269
279
  const roomLeft = digest.maxItems - spokenFor.size;
270
280
  // A set is worth showing, but not at the cost of an empty grid.
271
- if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
281
+ if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
272
282
 
273
283
  const forGrid: ProductPick[] = [];
274
284
  for (const pick of picks) {
275
285
  if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
276
286
  }
277
287
 
278
- return fitToShopper(spec, forGrid, roomLeft);
288
+ return fitToShopper(spec, forGrid, roomLeft, hostReasonSkus);
279
289
  }
280
290
 
281
291
  /** Attaches the provenance the server owns. The model never supplies any of it. */
@@ -292,6 +302,7 @@ export function createComponentGenerator(
292
302
  const provider = options.provider ?? null;
293
303
  const cache = options.cache ?? createMemorySpecCache();
294
304
  const generation = options.generation ?? 'cohort';
305
+ const rank = options.rank ?? 'signals';
295
306
  const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
296
307
  const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
297
308
  const singleFlight = createSingleFlight<ModelCall>();
@@ -330,7 +341,7 @@ export function createComponentGenerator(
330
341
  degradedReason,
331
342
  });
332
343
 
333
- return withProvenance(buildFallbackSpec(input, digest), {
344
+ return withProvenance(buildFallbackSpec(input, digest, { rank }), {
334
345
  slot: digest.slot,
335
346
  source: 'fallback',
336
347
  generatedAt: finishedAt,
@@ -500,10 +511,15 @@ export function createComponentGenerator(
500
511
  // One place where anything is served, whichever side of the cache it came
501
512
  // from, and always against the facts of the shopper asking now.
502
513
  // A cohort spec names products chosen for whoever asked first.
514
+ // Only the cohort path writes a host reason into a spec, so in
515
+ // per-shopper mode this stays empty and every reason is screened.
516
+ const hostReasonSkus = new Set<string>();
503
517
  const served =
504
- generation === 'cohort' ? fitCohortSpec(answer.spec, input, digest) : answer.spec;
518
+ generation === 'cohort'
519
+ ? fitCohortSpec(answer.spec, input, digest, rank, hostReasonSkus)
520
+ : answer.spec;
505
521
 
506
- const reconciled = reconcileSpec(served, input, digest);
522
+ const reconciled = reconcileSpec(served, input, digest, hostReasonSkus);
507
523
  if (!reconciled.isUsable) {
508
524
  return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
509
525
  calledModel,
@@ -92,7 +92,7 @@ export const productReferenceSchema = z.object({
92
92
  * true, but the prose asserting that reason must not render.
93
93
  */
94
94
  reason: z.string().nullable(),
95
- /** Short accent label, e.g. "Back in stock". Null when nothing warrants one. */
95
+ /** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
96
96
  badge: z.string().nullable(),
97
97
  emphasis: z.enum(EMPHASIS),
98
98
  });
@@ -1,6 +1,6 @@
1
1
  import type { GeneratedSpec, ProductReference } from './component-spec.js';
2
2
  import type { SignalDigest } from './signal-digest.js';
3
- import { selectProducts, type ProductPick } from './product-selection.js';
3
+ import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
4
4
  import type { TrackingInput } from './tracking-input.js';
5
5
 
6
6
  /**
@@ -57,8 +57,12 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
57
57
  * the renderer treats as "render nothing" — an empty recommendation region is
58
58
  * worse than none.
59
59
  */
60
- export function buildFallbackSpec(input: TrackingInput, digest: SignalDigest): GeneratedSpec {
61
- const picks = selectProducts(input, digest).slice(0, digest.maxItems);
60
+ export function buildFallbackSpec(
61
+ input: TrackingInput,
62
+ digest: SignalDigest,
63
+ options: SelectOptions = {},
64
+ ): GeneratedSpec {
65
+ const picks = selectProducts(input, digest, options).slice(0, digest.maxItems);
62
66
  const { headline, subheadline } = headlineFor(digest);
63
67
  const items = picks.map((pick, index) => toProductReference(pick, index, picks.length));
64
68
 
@@ -7,6 +7,7 @@ export function fitToShopper(
7
7
  spec: GeneratedSpec,
8
8
  picks: readonly ProductPick[],
9
9
  maxItems: number,
10
+ hostReasonSkus?: Set<string>,
10
11
  ): GeneratedSpec {
11
12
  // Picks are ordered best first. Every slot takes the next one.
12
13
  let next = 0;
@@ -24,6 +25,7 @@ export function fitToShopper(
24
25
  for (const item of block.items) {
25
26
  if (next >= limit) break; // shrink, never pad
26
27
  const chosen = picks[next]!;
28
+ if (chosen.reasonFromHost) hostReasonSkus?.add(chosen.product.sku);
27
29
  items.push({
28
30
  sku: chosen.product.sku,
29
31
  basis: chosen.basis,
@@ -23,6 +23,8 @@ export interface ProductPick {
23
23
  basis: RecommendationBasis;
24
24
  /** How the basis reads to a shopper. */
25
25
  reason: string;
26
+ /** True when `reason` is the host's own sentence from the candidate. */
27
+ reasonFromHost: boolean;
26
28
  /** Unnormalised. Only the ordering is meaningful. */
27
29
  score: number;
28
30
  }
@@ -95,9 +97,6 @@ function basisFor(
95
97
  if (evidence.hasCart) {
96
98
  return { basis: 'complements_cart', reason: 'Goes with what is in your cart' };
97
99
  }
98
- if ((product.rating ?? 0) >= 4.5) {
99
- return { basis: 'popular', reason: 'Highly rated' };
100
- }
101
100
  return { basis: 'popular', reason: `Popular in ${product.category}` };
102
101
  }
103
102
 
@@ -107,7 +106,24 @@ function basisFor(
107
106
  * Ties break on SKU so the order is total: two runs over the same payload
108
107
  * produce the same list, which is what makes the control arm reproducible.
109
108
  */
110
- export function selectProducts(input: TrackingInput, digest: SignalDigest): ProductPick[] {
109
+ /** How the picks are ordered once the unplaceable ones are gone. */
110
+ export type RankOrder = 'signals' | 'given';
111
+
112
+ export interface SelectOptions {
113
+ /**
114
+ * `signals` scores each candidate and orders by that score. `given` keeps the
115
+ * order you sent, for a shop whose own ranking is better than four weights.
116
+ * Either way the exclusions and the stock check still apply, and every pick
117
+ * still carries a basis reconciliation can verify.
118
+ */
119
+ rank?: RankOrder;
120
+ }
121
+
122
+ export function selectProducts(
123
+ input: TrackingInput,
124
+ digest: SignalDigest,
125
+ options: SelectOptions = {},
126
+ ): ProductPick[] {
111
127
  const affinityByCategory = new Map(
112
128
  digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
113
129
  );
@@ -144,9 +160,17 @@ export function selectProducts(input: TrackingInput, digest: SignalDigest): Prod
144
160
  hasCart,
145
161
  });
146
162
 
147
- picks.push({ product, basis, reason, score });
163
+ picks.push({
164
+ product,
165
+ basis,
166
+ reason: product.reason ?? reason,
167
+ reasonFromHost: product.reason !== undefined,
168
+ score,
169
+ });
148
170
  }
149
171
 
172
+ if (options.rank === 'given') return picks;
173
+
150
174
  return picks.toSorted(
151
175
  (left, right) => right.score - left.score || left.product.sku.localeCompare(right.product.sku),
152
176
  );
@@ -356,6 +356,7 @@ function reconcileItems(
356
356
  candidatesBySku: Map<string, Product>,
357
357
  digest: SignalDigest,
358
358
  tracker: PlacementTracker,
359
+ hostReasonSkus: ReadonlySet<string>,
359
360
  ): ProductReference[] {
360
361
  const kept: ProductReference[] = [];
361
362
 
@@ -375,15 +376,23 @@ function reconcileItems(
375
376
  const hasSupportedBasis = verifyBasis(item.basis, product, digest);
376
377
  if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
377
378
 
379
+ const isHostReason =
380
+ item.reason !== null && hostReasonSkus.has(item.sku) && item.reason === product.reason;
381
+
378
382
  kept.push({
379
383
  sku: item.sku,
380
384
  basis: hasSupportedBasis ? item.basis : 'popular',
381
385
  // The prose exists to state the basis. If the basis did not hold, the
382
386
  // prose is a claim we just decided is untrue.
383
387
  reason: hasSupportedBasis
384
- ? screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
388
+ ? isHostReason
389
+ ? clampNullable(item.reason, CLAMP.reason)
390
+ : screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
385
391
  : null,
386
- badge: clampNullable(item.badge, CLAMP.badge),
392
+ // A badge is the shortest, loudest text on the card, and the schema's own
393
+ // example for it was "Back in stock" — a stock claim. It renders, so it is
394
+ // read for claims like every other sentence the model writes.
395
+ badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
387
396
  emphasis: item.emphasis,
388
397
  });
389
398
  }
@@ -516,6 +525,7 @@ function reconcileBlock(
516
525
  digest: SignalDigest,
517
526
  bundles: readonly Bundle[],
518
527
  tracker: PlacementTracker,
528
+ hostReasonSkus: ReadonlySet<string>,
519
529
  ): Block | null {
520
530
  switch (block.kind) {
521
531
  case 'hero': {
@@ -552,7 +562,14 @@ function reconcileBlock(
552
562
  }
553
563
 
554
564
  case 'grid': {
555
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
565
+ const items = reconcileItems(
566
+ block.items,
567
+ allowlist,
568
+ candidatesBySku,
569
+ digest,
570
+ tracker,
571
+ hostReasonSkus,
572
+ );
556
573
  if (items.length === 0) {
557
574
  tracker.record('empty-block:grid');
558
575
  return null;
@@ -567,7 +584,14 @@ function reconcileBlock(
567
584
  }
568
585
 
569
586
  case 'carousel': {
570
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
587
+ const items = reconcileItems(
588
+ block.items,
589
+ allowlist,
590
+ candidatesBySku,
591
+ digest,
592
+ tracker,
593
+ hostReasonSkus,
594
+ );
571
595
  if (items.length === 0) {
572
596
  tracker.record('empty-block:carousel');
573
597
  return null;
@@ -643,6 +667,12 @@ export function reconcileSpec(
643
667
  generated: GeneratedSpec,
644
668
  input: TrackingInput,
645
669
  digest: SignalDigest,
670
+ /**
671
+ * SKUs whose reason this request wrote from the host's own candidate. Only
672
+ * `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
673
+ * reason is the model's, including one that happens to read the same.
674
+ */
675
+ hostReasonSkus: ReadonlySet<string> = new Set(),
646
676
  ): ReconcileResult {
647
677
  const allowlist = buildAllowlist(input, digest);
648
678
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
@@ -661,6 +691,7 @@ export function reconcileSpec(
661
691
  digest,
662
692
  input.bundles,
663
693
  tracker,
694
+ hostReasonSkus,
664
695
  );
665
696
  if (reconciled !== null) blocks.push(reconciled);
666
697
  }
@@ -44,6 +44,9 @@ export const FIELD_LIMITS = {
44
44
  bundles: 20,
45
45
  localeTag: 35,
46
46
  maxItems: 12,
47
+ // Matches CLAMP.reason in reconciliation: a host reason is rendered in the
48
+ // same place a model's is, so the same length has to hold.
49
+ reason: 120,
47
50
  } as const;
48
51
 
49
52
  /** Assigning this as an object key mutates the prototype instead of the object. */
@@ -95,6 +98,10 @@ export const productSchema = z.strictObject({
95
98
  // scheme to abuse.
96
99
  imageUrl: imageReference().optional(),
97
100
  rating: z.number().min(0).max(5).optional(),
101
+ // Your phrase for why this product is here, when your own ranking already has
102
+ // one. It renders as the item's reason and is not screened: these are your
103
+ // words about your product, like the title, so you stand behind them.
104
+ reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
98
105
  isInStock: z.boolean().default(true),
99
106
  tags: z
100
107
  .array(z.string().min(1).max(FIELD_LIMITS.tag))