@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
@@ -2,7 +2,6 @@ import { z } from 'zod';
2
2
  import {
3
3
  SPEC_VERSION,
4
4
  generatedSpecSchema,
5
- type Block,
6
5
  type ComponentSpec,
7
6
  type DegradedReason,
8
7
  type GeneratedSpec,
@@ -12,12 +11,11 @@ import { buildFallbackSpec } from './fallback-component.js';
12
11
  import { buildPrompt } from './model-prompt.js';
13
12
  import type { ComponentProvider, TokenUsage } from './provider.js';
14
13
  import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
15
- import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
14
+ import { selectProducts, type RankOrder } from './product-selection.js';
16
15
  import { fitToShopper, type FittedSpec } from './fit-to-shopper.js';
17
16
  import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
18
17
  import {
19
18
  createMemorySpecCache,
20
- cohortCacheKey,
21
19
  specCacheKey,
22
20
  type CachedSpec,
23
21
  type SpecCache,
@@ -31,11 +29,8 @@ import {
31
29
  export interface GenerationEvent {
32
30
  key: string | null;
33
31
  source: SpecSource;
34
-
35
32
  elapsedMs: number;
36
-
37
33
  calledModel: boolean;
38
-
39
34
  violations?: string[];
40
35
  usage?: TokenUsage;
41
36
  degradedReason?: DegradedReason;
@@ -45,23 +40,16 @@ export interface GenerationEvent {
45
40
 
46
41
  export interface ComponentGeneratorOptions {
47
42
  provider?: ComponentProvider | null;
48
-
49
43
  cache?: SpecCache;
50
-
51
44
  modelTimeoutMs?: number;
52
-
53
45
  cacheTimeoutMs?: number;
54
-
55
46
  generation?: 'cohort' | 'per-shopper';
56
-
57
47
  rank?: RankOrder;
58
-
59
48
  onEvent?: (event: GenerationEvent) => void;
60
49
  }
61
50
 
62
51
  export interface ComponentGenerator {
63
52
  generate(input: TrackingInputDraft): Promise<ComponentSpec>;
64
-
65
53
  generateDeterministic(input: TrackingInputDraft): ComponentSpec;
66
54
  }
67
55
 
@@ -98,23 +86,6 @@ async function withinBudget<T>(
98
86
  }
99
87
  }
100
88
 
101
- function createSingleFlight() {
102
- const inFlight = new Map<string, Promise<ModelCall>>();
103
-
104
- return {
105
- isRunning: (key: string) => inFlight.has(key),
106
-
107
- run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
108
- const existing = inFlight.get(key);
109
- if (existing) return existing;
110
-
111
- const started = task().finally(() => inFlight.delete(key));
112
- inFlight.set(key, started);
113
- return started;
114
- },
115
- };
116
- }
117
-
118
89
  const cachedSpecSchema = z.object({
119
90
  spec: generatedSpecSchema,
120
91
  generatedAt: z.number(),
@@ -130,6 +101,40 @@ interface ModelCall {
130
101
  usage?: TokenUsage;
131
102
  }
132
103
 
104
+ interface Settings {
105
+ options: ComponentGeneratorOptions;
106
+ provider: ComponentProvider | null;
107
+ cache: SpecCache;
108
+ generation: 'cohort' | 'per-shopper';
109
+ rank: RankOrder;
110
+ modelTimeoutMs: number;
111
+ cacheTimeoutMs: number;
112
+ inFlight: Map<string, Promise<ModelCall>>;
113
+ }
114
+
115
+ interface Run {
116
+ input: TrackingInput;
117
+ digest: SignalDigest;
118
+ promptDigest: SignalDigest;
119
+ startedAt: number;
120
+ }
121
+
122
+ interface Trace {
123
+ calledModel: boolean;
124
+ cache: CacheRead['outcome'];
125
+ usage: TokenUsage | undefined;
126
+ error?: unknown;
127
+ }
128
+
129
+ type Found =
130
+ | { spec: GeneratedSpec; generatedAt: number; source: 'cache' | 'llm'; trace: Trace }
131
+ | { failed: DegradedReason; trace: Trace };
132
+
133
+ type EventDetails = Pick<
134
+ GenerationEvent,
135
+ 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'
136
+ >;
137
+
133
138
  function fitCohortSpec(
134
139
  spec: GeneratedSpec,
135
140
  input: TrackingInput,
@@ -139,222 +144,220 @@ function fitCohortSpec(
139
144
  const picks = selectProducts(input, digest, { rank });
140
145
  const blocks = capBlocks(spec.blocks);
141
146
 
142
- let hasBundleBlock = false;
143
- const aboveBundle: Block[] = [];
144
- for (const block of blocks) {
145
- if (block.kind === 'bundle') {
146
- hasBundleBlock = true;
147
- break;
148
- }
149
- aboveBundle.push(block);
150
- }
151
- if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
147
+ const bundleAt = blocks.findIndex((block) => block.kind === 'bundle');
148
+ if (bundleAt === -1) return fitToShopper(spec, picks, digest.maxItems);
152
149
 
153
- const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
154
- if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
150
+ const heroesAbove = placeableHeroSkus(blocks.slice(0, bundleAt), input);
151
+ const bundle = bundleForShopper(input, digest, heroesAbove);
152
+ if (!bundle) return fitToShopper(spec, picks, digest.maxItems);
155
153
 
156
- const spokenFor = new Set<string>(chosen.skus);
154
+ const spokenFor = new Set<string>(bundle.skus);
157
155
  for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
158
156
 
159
157
  const roomLeft = digest.maxItems - spokenFor.size;
160
-
161
158
  if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
162
159
 
163
- const forGrid: ProductPick[] = [];
164
- for (const pick of picks) {
165
- if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
166
- }
167
-
160
+ const forGrid = picks.filter((pick) => !spokenFor.has(pick.product.sku));
168
161
  return fitToShopper(spec, forGrid, roomLeft);
169
162
  }
170
163
 
171
164
  export function createComponentGenerator(
172
165
  options: ComponentGeneratorOptions = {},
173
166
  ): ComponentGenerator {
174
- const provider = options.provider ?? null;
175
- const cache = options.cache ?? createMemorySpecCache();
176
- const generation = options.generation ?? 'cohort';
177
- const rank = options.rank ?? 'signals';
178
- const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
179
- const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
180
- const singleFlight = createSingleFlight();
181
-
182
- const report = (event: GenerationEvent): void => {
183
- if (!options.onEvent) return;
184
- try {
185
- options.onEvent(event);
186
- } catch {}
167
+ const settings: Settings = {
168
+ options,
169
+ provider: options.provider ?? null,
170
+ cache: options.cache ?? createMemorySpecCache(),
171
+ generation: options.generation ?? 'cohort',
172
+ rank: options.rank ?? 'signals',
173
+ modelTimeoutMs: options.modelTimeoutMs ?? 1_500,
174
+ cacheTimeoutMs: options.cacheTimeoutMs ?? 50,
175
+ inFlight: new Map(),
187
176
  };
188
177
 
189
- const buildDeterministic = (
190
- input: TrackingInput,
191
- digest: SignalDigest,
192
- startedAt: number,
193
- key: string | null,
194
- degradedReason: DegradedReason,
195
- modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
196
- calledModel: false,
197
- },
198
- ): ComponentSpec => {
199
- const finishedAt = Date.now();
200
- report({
201
- key,
202
- source: 'fallback',
203
- elapsedMs: finishedAt - startedAt,
204
- ...modelCall,
205
- degradedReason,
206
- });
207
-
208
- return {
209
- ...buildFallbackSpec(input, digest, { rank }),
210
- specVersion: SPEC_VERSION,
211
- slot: digest.slot,
212
- source: 'fallback',
213
- generatedAt: finishedAt,
214
- latencyMs: finishedAt - startedAt,
215
- provider: null,
216
- model: null,
217
- degradedReason,
218
- };
178
+ return {
179
+ generate: (draft) => generate(settings, draft),
180
+ generateDeterministic: (draft) => generateDeterministic(settings, draft),
219
181
  };
182
+ }
220
183
 
221
- const readCache = async (key: string): Promise<CacheRead> => {
222
- try {
223
- const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
224
- const parsed = cachedSpecSchema.safeParse(stored);
225
- return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
226
- } catch (error) {
227
- return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
228
- }
229
- };
184
+ async function generate(settings: Settings, draft: TrackingInputDraft): Promise<ComponentSpec> {
185
+ const run = startRun(settings, draft);
186
+ const provider = settings.provider;
187
+ if (!provider) return fallback(settings, run, null, 'no-provider');
188
+
189
+ const key = cacheKeyFor(provider, run);
190
+ const found = await findSpec(settings, provider, run, key);
191
+ if ('failed' in found)
192
+ return fallback(settings, run, key, found.failed, eventDetails(found.trace));
193
+
194
+ const reconciled = shapeForShopper(settings, run, found.spec);
195
+ const details = eventDetails(found.trace, reconciled.violations);
196
+ if (!reconciled.isUsable) return fallback(settings, run, key, 'unusable-on-serve', details);
197
+
198
+ const finishedAt = Date.now();
199
+ report(settings, {
200
+ key,
201
+ source: found.source,
202
+ elapsedMs: finishedAt - run.startedAt,
203
+ ...details,
204
+ });
230
205
 
231
- const storeInBackground = (key: string, cached: CachedSpec): void => {
232
- void Promise.resolve()
233
- .then(() => cache.set(key, cached))
234
- .catch(() => {});
206
+ return {
207
+ ...reconciled.spec,
208
+ specVersion: SPEC_VERSION,
209
+ slot: run.digest.slot,
210
+ source: found.source,
211
+ generatedAt: found.generatedAt,
212
+ latencyMs: finishedAt - run.startedAt,
213
+ provider: provider.name,
214
+ model: provider.model,
235
215
  };
216
+ }
217
+
218
+ function generateDeterministic(settings: Settings, draft: TrackingInputDraft): ComponentSpec {
219
+ return fallback(settings, startRun(settings, draft), null, 'requested');
220
+ }
221
+
222
+ function startRun(settings: Settings, draft: TrackingInputDraft): Run {
223
+ const startedAt = Date.now();
224
+ const input = parseTrackingInput(draft);
225
+ const digest = buildDigest(input);
226
+ const promptDigest = settings.generation === 'cohort' ? toCohortDigest(digest) : digest;
227
+ return { input, digest, promptDigest, startedAt };
228
+ }
229
+
230
+ function cacheKeyFor(provider: ComponentProvider, run: Run): string {
231
+ const identity = { name: provider.name, model: provider.model };
232
+ const skus = run.input.candidates.map((product) => product.sku);
233
+ return specCacheKey(run.promptDigest, skus, identity);
234
+ }
235
+
236
+ async function findSpec(
237
+ settings: Settings,
238
+ provider: ComponentProvider,
239
+ run: Run,
240
+ key: string,
241
+ ): Promise<Found> {
242
+ const read = await readCache(settings, key);
243
+ if (read.entry) {
244
+ const trace = { calledModel: false, cache: read.outcome, usage: undefined };
245
+ return { spec: read.entry.spec, generatedAt: read.entry.generatedAt, source: 'cache', trace };
246
+ }
236
247
 
237
- const askModel = async (
238
- active: ComponentProvider,
239
- input: TrackingInput,
240
- promptDigest: SignalDigest,
241
- ): Promise<ModelCall> => {
242
- const { system, user } = buildPrompt(input, promptDigest);
248
+ const calledModel = !settings.inFlight.has(key);
249
+ let call: ModelCall;
250
+ try {
251
+ call = await sharedCall(settings, key, () => askModel(settings, provider, run));
252
+ } catch (error) {
253
+ const failed = error instanceof TimeoutError ? 'timeout' : 'provider-error';
254
+ return { failed, trace: { calledModel, cache: read.outcome, usage: undefined, error } };
255
+ }
243
256
 
244
- const result = await withinBudget('generation', modelTimeoutMs, (signal) =>
245
- active.generate({ system, user, schema: generatedSpecSchema, signal }),
257
+ const trace = { calledModel, cache: read.outcome, usage: call.usage };
258
+ if (!call.spec) return { failed: 'invalid-generation', trace };
259
+
260
+ const generatedAt = Date.now();
261
+ if (calledModel) storeInBackground(settings, key, { spec: call.spec, generatedAt });
262
+ return { spec: call.spec, generatedAt, source: 'llm', trace };
263
+ }
264
+
265
+ async function readCache(settings: Settings, key: string): Promise<CacheRead> {
266
+ try {
267
+ const stored = await withinBudget('cache read', settings.cacheTimeoutMs, () =>
268
+ settings.cache.get(key),
246
269
  );
270
+ const parsed = cachedSpecSchema.safeParse(stored);
271
+ return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
272
+ } catch (error) {
273
+ return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
274
+ }
275
+ }
276
+
277
+ async function askModel(
278
+ settings: Settings,
279
+ provider: ComponentProvider,
280
+ run: Run,
281
+ ): Promise<ModelCall> {
282
+ const { system, user } = buildPrompt(run.input, run.promptDigest);
283
+ const result = await withinBudget('generation', settings.modelTimeoutMs, (signal) =>
284
+ provider.generate({ system, user, schema: generatedSpecSchema, signal }),
285
+ );
286
+ const parsed = generatedSpecSchema.safeParse(result.spec);
287
+ return {
288
+ spec: parsed.success ? parsed.data : null,
289
+ ...(result.usage ? { usage: result.usage } : {}),
290
+ };
291
+ }
292
+
293
+ function sharedCall(
294
+ settings: Settings,
295
+ key: string,
296
+ ask: () => Promise<ModelCall>,
297
+ ): Promise<ModelCall> {
298
+ const running = settings.inFlight.get(key);
299
+ if (running) return running;
300
+ const started = ask().finally(() => settings.inFlight.delete(key));
301
+ settings.inFlight.set(key, started);
302
+ return started;
303
+ }
304
+
305
+ function storeInBackground(settings: Settings, key: string, cached: CachedSpec): void {
306
+ void Promise.resolve()
307
+ .then(() => settings.cache.set(key, cached))
308
+ .catch(() => {});
309
+ }
247
310
 
248
- const parsed = generatedSpecSchema.safeParse(result.spec);
311
+ function shapeForShopper(settings: Settings, run: Run, spec: GeneratedSpec) {
312
+ if (settings.generation !== 'cohort') {
313
+ return reconcileSpec(spec, run.input, run.digest, new Map<string, string>());
314
+ }
315
+ const fitted = fitCohortSpec(spec, run.input, run.digest, settings.rank);
316
+ return reconcileSpec(fitted.spec, run.input, run.digest, fitted.ourReasons);
317
+ }
249
318
 
250
- return {
251
- spec: parsed.success ? parsed.data : null,
252
- ...(result.usage ? { usage: result.usage } : {}),
253
- };
319
+ function fallback(
320
+ settings: Settings,
321
+ run: Run,
322
+ key: string | null,
323
+ degradedReason: DegradedReason,
324
+ details: EventDetails = { calledModel: false },
325
+ ): ComponentSpec {
326
+ const finishedAt = Date.now();
327
+ report(settings, {
328
+ key,
329
+ source: 'fallback',
330
+ elapsedMs: finishedAt - run.startedAt,
331
+ ...details,
332
+ degradedReason,
333
+ });
334
+
335
+ return {
336
+ ...buildFallbackSpec(run.input, run.digest, { rank: settings.rank }),
337
+ specVersion: SPEC_VERSION,
338
+ slot: run.digest.slot,
339
+ source: 'fallback',
340
+ generatedAt: finishedAt,
341
+ latencyMs: finishedAt - run.startedAt,
342
+ provider: null,
343
+ model: null,
344
+ degradedReason,
254
345
  };
346
+ }
255
347
 
348
+ function eventDetails(trace: Trace, violations?: string[]): EventDetails {
256
349
  return {
257
- generateDeterministic(draft) {
258
- const startedAt = Date.now();
259
- const input = parseTrackingInput(draft);
260
- return buildDeterministic(input, buildDigest(input), startedAt, null, 'requested');
261
- },
262
-
263
- async generate(draft) {
264
- const startedAt = Date.now();
265
-
266
- const input = parseTrackingInput(draft);
267
- const digest = buildDigest(input);
268
-
269
- if (!provider) {
270
- return buildDeterministic(input, digest, startedAt, null, 'no-provider');
271
- }
272
-
273
- const identity = { name: provider.name, model: provider.model };
274
- const candidateSkus = input.candidates.map((product) => product.sku);
275
- const key =
276
- generation === 'cohort'
277
- ? cohortCacheKey(digest, candidateSkus, identity)
278
- : specCacheKey(digest, candidateSkus, identity);
279
-
280
- const read = await readCache(key);
281
- const cached = read.entry;
282
- let calledModel = false;
283
- let answer: { spec: GeneratedSpec; usage?: TokenUsage };
284
-
285
- let generatedAt: number;
286
-
287
- if (cached) {
288
- answer = { spec: cached.spec };
289
- generatedAt = cached.generatedAt;
290
- } else {
291
- calledModel = !singleFlight.isRunning(key);
292
-
293
- let call: ModelCall;
294
- try {
295
- call = await singleFlight.run(key, () =>
296
- askModel(provider, input, generation === 'cohort' ? toCohortDigest(digest) : digest),
297
- );
298
- } catch (error) {
299
- const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
300
- return buildDeterministic(input, digest, startedAt, key, reason, {
301
- calledModel,
302
- cache: read.outcome,
303
- error,
304
- });
305
- }
306
-
307
- if (!call.spec) {
308
- return buildDeterministic(input, digest, startedAt, key, 'invalid-generation', {
309
- calledModel,
310
- cache: read.outcome,
311
- ...(call.usage ? { usage: call.usage } : {}),
312
- });
313
- }
314
-
315
- answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
316
- generatedAt = Date.now();
317
-
318
- if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
319
- }
320
-
321
- const { spec: served, ourReasons } =
322
- generation === 'cohort'
323
- ? fitCohortSpec(answer.spec, input, digest, rank)
324
- : { spec: answer.spec, ourReasons: new Map<string, string>() };
325
-
326
- const reconciled = reconcileSpec(served, input, digest, ourReasons);
327
- if (!reconciled.isUsable) {
328
- return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
329
- calledModel,
330
- cache: read.outcome,
331
- violations: reconciled.violations,
332
- ...(answer.usage ? { usage: answer.usage } : {}),
333
- });
334
- }
335
-
336
- const finishedAt = Date.now();
337
- const source: SpecSource = cached ? 'cache' : 'llm';
338
- report({
339
- key,
340
- source,
341
- elapsedMs: finishedAt - startedAt,
342
- calledModel,
343
- cache: read.outcome,
344
- violations: reconciled.violations,
345
- ...(answer.usage ? { usage: answer.usage } : {}),
346
- });
347
-
348
- return {
349
- ...reconciled.spec,
350
- specVersion: SPEC_VERSION,
351
- slot: digest.slot,
352
- source,
353
- generatedAt,
354
- latencyMs: finishedAt - startedAt,
355
- provider: provider.name,
356
- model: provider.model,
357
- };
358
- },
350
+ calledModel: trace.calledModel,
351
+ cache: trace.cache,
352
+ ...(violations ? { violations } : {}),
353
+ ...(trace.usage ? { usage: trace.usage } : {}),
354
+ ...('error' in trace ? { error: trace.error } : {}),
359
355
  };
360
356
  }
357
+
358
+ function report(settings: Settings, event: GenerationEvent): void {
359
+ if (!settings.options.onEvent) return;
360
+ try {
361
+ settings.options.onEvent(event);
362
+ } catch {}
363
+ }
@@ -8,25 +8,17 @@ export const EMPHASIS = ['normal', 'featured'] as const;
8
8
 
9
9
  export const RECOMMENDATION_BASES = [
10
10
  'similar_to_current',
11
-
12
11
  'most_viewed',
13
-
14
12
  'complements_cart',
15
-
16
13
  'complements_purchase',
17
-
18
14
  'liked_category',
19
-
20
15
  'popular',
21
16
  ] as const;
22
17
 
23
18
  export const productReferenceSchema = z.object({
24
19
  sku: z.string(),
25
-
26
20
  basis: z.enum(RECOMMENDATION_BASES),
27
-
28
21
  reason: z.string().nullable(),
29
-
30
22
  badge: z.string().nullable(),
31
23
  emphasis: z.enum(EMPHASIS),
32
24
  });
@@ -48,6 +40,10 @@ const gridBlockSchema = z.object({
48
40
  items: z.array(productReferenceSchema),
49
41
  });
50
42
 
43
+ export function columnsFor(count: number, widest: 2 | 3 | 4): 2 | 3 | 4 {
44
+ return Math.min(widest, Math.max(2, count)) as 2 | 3 | 4;
45
+ }
46
+
51
47
  const carouselBlockSchema = z.object({
52
48
  kind: z.literal('carousel'),
53
49
  title: z.string().nullable(),
@@ -72,7 +68,6 @@ const bundleBlockSchema = z.object({
72
68
  title: z.string().nullable(),
73
69
  body: z.string().nullable(),
74
70
  ctaLabel: z.string().nullable(),
75
-
76
71
  bundleId: z.string().nullable(),
77
72
  });
78
73
 
@@ -99,7 +94,6 @@ export const generatedSpecSchema = z.object({
99
94
  headline: z.string(),
100
95
  subheadline: z.string().nullable(),
101
96
  blocks: z.array(blockSchema),
102
-
103
97
  rationale: z.string(),
104
98
  });
105
99
  export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
@@ -121,15 +115,10 @@ export interface ComponentSpec extends GeneratedSpec {
121
115
  specVersion: typeof SPEC_VERSION;
122
116
  slot: string;
123
117
  source: SpecSource;
124
-
125
118
  generatedAt: number;
126
-
127
119
  latencyMs: number;
128
-
129
120
  provider: string | null;
130
-
131
121
  model: string | null;
132
-
133
122
  degradedReason?: DegradedReason;
134
123
  }
135
124
 
@@ -1,6 +1,6 @@
1
- import type { GeneratedSpec, ProductReference } from './component-spec.js';
1
+ import { columnsFor, type GeneratedSpec, type ProductReference } from './component-spec.js';
2
2
  import type { SignalDigest } from './signal-digest.js';
3
- import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
3
+ import { selectProducts, type SelectOptions } from './product-selection.js';
4
4
  import type { TrackingInput } from './tracking-input.js';
5
5
 
6
6
  const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
@@ -19,22 +19,6 @@ function headlineFor(digest: SignalDigest): { headline: string; subheadline: str
19
19
  return { headline: 'You might also like', subheadline: null };
20
20
  }
21
21
 
22
- function columnsFor(itemCount: number): 2 | 3 | 4 {
23
- if (itemCount >= 4) return 4;
24
- if (itemCount === 3) return 3;
25
- return 2;
26
- }
27
-
28
- function toProductReference(pick: ProductPick, index: number, total: number): ProductReference {
29
- return {
30
- sku: pick.product.sku,
31
- basis: pick.basis,
32
- reason: pick.reason,
33
- badge: null,
34
- emphasis: index === 0 && total >= MIN_PICKS_FOR_A_FEATURED_LEAD ? 'featured' : 'normal',
35
- };
36
- }
37
-
38
22
  export function buildFallbackSpec(
39
23
  input: TrackingInput,
40
24
  digest: SignalDigest,
@@ -42,7 +26,14 @@ export function buildFallbackSpec(
42
26
  ): GeneratedSpec {
43
27
  const picks = selectProducts(input, digest, options).slice(0, digest.maxItems);
44
28
  const { headline, subheadline } = headlineFor(digest);
45
- const items = picks.map((pick, index) => toProductReference(pick, index, picks.length));
29
+ const isLeadFeatured = picks.length >= MIN_PICKS_FOR_A_FEATURED_LEAD;
30
+ const items: ProductReference[] = picks.map((pick, index) => ({
31
+ sku: pick.product.sku,
32
+ basis: pick.basis,
33
+ reason: pick.reason,
34
+ badge: null,
35
+ emphasis: index === 0 && isLeadFeatured ? 'featured' : 'normal',
36
+ }));
46
37
 
47
38
  return {
48
39
  tone: 'neutral',
@@ -51,7 +42,7 @@ export function buildFallbackSpec(
51
42
  blocks:
52
43
  items.length === 0
53
44
  ? []
54
- : [{ kind: 'grid', title: null, columns: columnsFor(items.length), items }],
45
+ : [{ kind: 'grid', title: null, columns: columnsFor(items.length, 4), items }],
55
46
  rationale: digest.isColdStart
56
47
  ? 'Deterministic: no behavioural signals, ranked by rating and stock.'
57
48
  : 'Deterministic: ranked by category affinity, revisit, rating and tag overlap.',