@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.
- package/README.md +186 -135
- package/dist/component-generator.d.ts +9 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +15 -9
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.js +1 -1
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +2 -1
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +2 -2
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +3 -1
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/product-selection.d.ts +14 -1
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +10 -11
- package/dist/product-selection.js.map +1 -1
- package/dist/reconciliation.d.ts +7 -1
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +20 -8
- package/dist/reconciliation.js.map +1 -1
- package/dist/tracking-input.d.ts +3 -0
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +7 -0
- package/dist/tracking-input.js.map +1 -1
- package/package.json +1 -1
- package/src/component-generator.ts +25 -9
- package/src/component-spec.ts +1 -1
- package/src/fallback-component.ts +7 -3
- package/src/fit-to-shopper.ts +2 -0
- package/src/product-selection.ts +29 -5
- package/src/reconciliation.ts +35 -4
- package/src/tracking-input.ts +7 -0
|
@@ -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'
|
|
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,
|
package/src/component-spec.ts
CHANGED
|
@@ -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. "
|
|
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(
|
|
61
|
-
|
|
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
|
|
package/src/fit-to-shopper.ts
CHANGED
|
@@ -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,
|
package/src/product-selection.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
);
|
package/src/reconciliation.ts
CHANGED
|
@@ -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
|
-
?
|
|
388
|
+
? isHostReason
|
|
389
|
+
? clampNullable(item.reason, CLAMP.reason)
|
|
390
|
+
: screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
|
|
385
391
|
: null,
|
|
386
|
-
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(
|
|
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(
|
|
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
|
}
|
package/src/tracking-input.ts
CHANGED
|
@@ -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))
|