@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
@@ -11,14 +11,9 @@ import {
11
11
  import { buildFallbackSpec } from './fallback-component.js';
12
12
  import { buildPrompt } from './model-prompt.js';
13
13
  import type { ComponentProvider, TokenUsage } from './provider.js';
14
- import {
15
- MAX_BLOCKS,
16
- bundleForShopper,
17
- placeableHeroSkus,
18
- reconcileSpec,
19
- } from './reconciliation.js';
14
+ import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
20
15
  import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
21
- import { fitToShopper } from './fit-to-shopper.js';
16
+ import { fitToShopper, type FittedSpec } from './fit-to-shopper.js';
22
17
  import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
23
18
  import {
24
19
  createMemorySpecCache,
@@ -33,52 +28,14 @@ import {
33
28
  type TrackingInputDraft,
34
29
  } from './tracking-input.js';
35
30
 
36
- /**
37
- * Turns one tracking payload into one renderable component.
38
- *
39
- * Everything else in this package is a piece of that sentence; this module is
40
- * the order they go in. It is deliberately the only place that knows the whole
41
- * sequence, and it is written as a straight line so the sequence is readable:
42
- *
43
- * validate → digest → cache → generate → reconcile → render
44
- *
45
- * The single promise it makes to a caller is that `generate` always returns
46
- * something renderable. A model that is slow, refusing, erroring, rate-limited
47
- * or simply not configured produces the deterministic component instead. The
48
- * only way it rejects is a malformed payload, which is a caller bug and should
49
- * be loud.
50
- */
51
-
52
- /**
53
- * Reported exactly once per call to `generate`, whatever happened.
54
- *
55
- * One flat shape rather than a variant per outcome, because the numbers the
56
- * evaluation needs are ratios over all calls — hit rate, fallback share, model
57
- * calls and cost per thousand views. A variant that some callers do not emit
58
- * makes every one of those ratios wrong by however many it skipped, which is
59
- * what happened when requests that joined an in-flight generation reported
60
- * nothing at all.
61
- */
62
31
  export interface GenerationEvent {
63
- /** Null when no key was computed, which means no provider was configured. */
64
32
  key: string | null;
65
33
  source: SpecSource;
66
- /** Wall-clock milliseconds for the whole call. */
34
+
67
35
  elapsedMs: number;
68
- /**
69
- * True for the caller that sent the request, on every outcome — including a
70
- * call that timed out, errored or came back unparseable. Requests that joined
71
- * an in-flight generation share its answer and its usage figures, so cost
72
- * must be summed over this flag rather than over every event.
73
- *
74
- * It counts requests sent, which is an upper bound on requests billed: an
75
- * adapter that throws before it reaches the vendor looks the same from here
76
- * as one that throws after. An upper bound is the useful direction — the
77
- * calls that produce nothing are the ones worth seeing, and reporting them as
78
- * no call at all hides them completely.
79
- */
36
+
80
37
  calledModel: boolean;
81
- /** What reconciliation removed. Absent when no spec was reconciled. */
38
+
82
39
  violations?: string[];
83
40
  usage?: TokenUsage;
84
41
  degradedReason?: DegradedReason;
@@ -87,47 +44,24 @@ export interface GenerationEvent {
87
44
  }
88
45
 
89
46
  export interface ComponentGeneratorOptions {
90
- /**
91
- * Omit to run without a model. That is a supported configuration rather than
92
- * a stub: it is the control arm of the benchmark, and the right setting for
93
- * anyone who has not yet decided on a provider.
94
- */
95
47
  provider?: ComponentProvider | null;
96
- /** Defaults to an in-process cache. Pass `createNullSpecCache()` to disable. */
48
+
97
49
  cache?: SpecCache;
98
- /**
99
- * How long the model gets. Past this the deterministic component renders and
100
- * the request is aborted. Defaults to 1500ms.
101
- */
50
+
102
51
  modelTimeoutMs?: number;
103
- /**
104
- * How long the cache gets. The shipped caches cannot exceed it, but the store
105
- * is a port a host implements — a hung Redis read on the render path would
106
- * hold the page open, which is exactly what this module exists to prevent.
107
- */
52
+
108
53
  cacheTimeoutMs?: number;
109
- /**
110
- * 'cohort' shares one generated component between shoppers who look alike and
111
- * fills in each shopper's own products. 'per-shopper' generates for the
112
- * individual, which is what the benchmark compares against. Defaults to
113
- * 'cohort'.
114
- */
54
+
115
55
  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
- */
56
+
123
57
  rank?: RankOrder;
124
- /** Observability. Never allowed to break a render. */
58
+
125
59
  onEvent?: (event: GenerationEvent) => void;
126
60
  }
127
61
 
128
62
  export interface ComponentGenerator {
129
63
  generate(input: TrackingInputDraft): Promise<ComponentSpec>;
130
- /** The deterministic component, without consulting a model or a cache. */
64
+
131
65
  generateDeterministic(input: TrackingInputDraft): ComponentSpec;
132
66
  }
133
67
 
@@ -138,20 +72,6 @@ export class TimeoutError extends Error {
138
72
  }
139
73
  }
140
74
 
141
- /**
142
- * Races a promise against a deadline.
143
- *
144
- * The deadline is enforced here rather than trusted to the thing being waited
145
- * on. A provider that ignores its abort signal, or a store that never settles,
146
- * must still not hold a page open.
147
- *
148
- * Once the deadline has fired the caller is told so, whatever the race
149
- * actually settled with. Aborting is what makes that necessary: a provider
150
- * honouring its half of the contract rejects from inside the `abort()` below,
151
- * so its rejection reaches the race first and the deadline's own never wins.
152
- * Reporting the error that happened to arrive would blame the vendor for the
153
- * caller's deadline — and blame it most often on the best-behaved adapters.
154
- */
155
75
  async function withinBudget<T>(
156
76
  label: string,
157
77
  milliseconds: number,
@@ -178,21 +98,13 @@ async function withinBudget<T>(
178
98
  }
179
99
  }
180
100
 
181
- /**
182
- * Collapses concurrent work for the same key into one execution.
183
- *
184
- * Without this, a key that is not yet cached fans out into one model call per
185
- * concurrent request — the same answer, bought many times over. The entry is
186
- * removed as soon as it settles, so one failure does not poison the next
187
- * attempt.
188
- */
189
- function createSingleFlight<T>() {
190
- const inFlight = new Map<string, Promise<T>>();
101
+ function createSingleFlight() {
102
+ const inFlight = new Map<string, Promise<ModelCall>>();
191
103
 
192
104
  return {
193
105
  isRunning: (key: string) => inFlight.has(key),
194
106
 
195
- run(key: string, task: () => Promise<T>): Promise<T> {
107
+ run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
196
108
  const existing = inFlight.get(key);
197
109
  if (existing) return existing;
198
110
 
@@ -203,7 +115,6 @@ function createSingleFlight<T>() {
203
115
  };
204
116
  }
205
117
 
206
- /** A cache entry is no more trustworthy than model output, so it is parsed too. */
207
118
  const cachedSpecSchema = z.object({
208
119
  spec: generatedSpecSchema,
209
120
  generatedAt: z.number(),
@@ -214,48 +125,19 @@ interface CacheRead {
214
125
  entry?: CachedSpec;
215
126
  }
216
127
 
217
- /**
218
- * What one call to the model produced.
219
- *
220
- * `spec` is null when the answer did not satisfy the schema. `usage` is carried
221
- * either way: the request went out and was paid for whether or not anything
222
- * usable came back, and those are the calls most worth seeing.
223
- */
224
128
  interface ModelCall {
225
129
  spec: GeneratedSpec | null;
226
130
  usage?: TokenUsage;
227
131
  }
228
132
 
229
- /** A model call whose answer can be reconciled and served. */
230
- interface ModelAnswer extends ModelCall {
231
- spec: GeneratedSpec;
232
- }
233
-
234
- /**
235
- * Fills a cohort spec with this shopper's products, keeping room for a set.
236
- *
237
- * The grid used to take the whole item budget, so a bundle block later in the
238
- * spec found nothing left and was dropped. The set is chosen first, its
239
- * products are held back from the grid, and the grid's limit drops by what the
240
- * set and the heroes have already spoken for.
241
- *
242
- * The arithmetic has to hold whatever order the model put the blocks in, so it
243
- * is written as one sum over the whole spec rather than as a running budget:
244
- * the grid gets `maxItems` minus every distinct product the set and the heroes
245
- * will place. Nothing is then dropped for want of budget, and reconciliation
246
- * reaches the same set this did — it can only ever have more placed than the
247
- * pre-choice assumed, and never one of the set's own products.
248
- */
249
133
  function fitCohortSpec(
250
134
  spec: GeneratedSpec,
251
135
  input: TrackingInput,
252
136
  digest: SignalDigest,
253
137
  rank: RankOrder,
254
- ourReasons: Map<string, string>,
255
- ): GeneratedSpec {
138
+ ): FittedSpec {
256
139
  const picks = selectProducts(input, digest, { rank });
257
- // Blocks past the cap never render, so a set is not worth reserving for one.
258
- const blocks = spec.blocks.slice(0, MAX_BLOCKS);
140
+ const blocks = capBlocks(spec.blocks);
259
141
 
260
142
  let hasBundleBlock = false;
261
143
  const aboveBundle: Block[] = [];
@@ -266,34 +148,24 @@ function fitCohortSpec(
266
148
  }
267
149
  aboveBundle.push(block);
268
150
  }
269
- if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems, ourReasons);
151
+ if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
270
152
 
271
- // Only the heroes above the bundle block are placed when it is reached, so
272
- // they are all the choice may account for.
273
- const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
274
- if (!chosen) return fitToShopper(spec, picks, digest.maxItems, ourReasons);
153
+ const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
154
+ if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
275
155
 
276
156
  const spokenFor = new Set<string>(chosen.skus);
277
- for (const sku of placeableHeroSkus(blocks, input, digest)) spokenFor.add(sku);
157
+ for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
278
158
 
279
159
  const roomLeft = digest.maxItems - spokenFor.size;
280
- // A set is worth showing, but not at the cost of an empty grid.
281
- if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems, ourReasons);
160
+
161
+ if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
282
162
 
283
163
  const forGrid: ProductPick[] = [];
284
164
  for (const pick of picks) {
285
165
  if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
286
166
  }
287
167
 
288
- return fitToShopper(spec, forGrid, roomLeft, ourReasons);
289
- }
290
-
291
- /** Attaches the provenance the server owns. The model never supplies any of it. */
292
- function withProvenance(
293
- spec: GeneratedSpec,
294
- provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
295
- ): ComponentSpec {
296
- return { ...spec, specVersion: SPEC_VERSION, ...provenance };
168
+ return fitToShopper(spec, forGrid, roomLeft);
297
169
  }
298
170
 
299
171
  export function createComponentGenerator(
@@ -305,15 +177,13 @@ export function createComponentGenerator(
305
177
  const rank = options.rank ?? 'signals';
306
178
  const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
307
179
  const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
308
- const singleFlight = createSingleFlight<ModelCall>();
180
+ const singleFlight = createSingleFlight();
309
181
 
310
182
  const report = (event: GenerationEvent): void => {
311
183
  if (!options.onEvent) return;
312
184
  try {
313
185
  options.onEvent(event);
314
- } catch {
315
- // A broken metrics hook must not take down a page.
316
- }
186
+ } catch {}
317
187
  };
318
188
 
319
189
  const buildDeterministic = (
@@ -322,12 +192,6 @@ export function createComponentGenerator(
322
192
  startedAt: number,
323
193
  key: string | null,
324
194
  degradedReason: DegradedReason,
325
- /**
326
- * What the model call cost, when there was one. A generation that is
327
- * unusable for this shopper was still asked for and still billed, so
328
- * omitting it here would hide the calls that produce nothing — exactly the
329
- * ones worth knowing about.
330
- */
331
195
  modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
332
196
  calledModel: false,
333
197
  },
@@ -341,7 +205,9 @@ export function createComponentGenerator(
341
205
  degradedReason,
342
206
  });
343
207
 
344
- return withProvenance(buildFallbackSpec(input, digest, { rank }), {
208
+ return {
209
+ ...buildFallbackSpec(input, digest, { rank }),
210
+ specVersion: SPEC_VERSION,
345
211
  slot: digest.slot,
346
212
  source: 'fallback',
347
213
  generatedAt: finishedAt,
@@ -349,58 +215,25 @@ export function createComponentGenerator(
349
215
  provider: null,
350
216
  model: null,
351
217
  degradedReason,
352
- });
218
+ };
353
219
  };
354
220
 
355
- /**
356
- * Reads the cache, treating anything unexpected as a miss.
357
- *
358
- * The value is re-validated because a store is a port a host implements, and
359
- * what comes back is no more trustworthy than what a model returns — a shared
360
- * store outlives a deploy, so it can hold entries written by an older shape of
361
- * the spec. Generating again is always safe; handing an unvalidated object to
362
- * reconciliation is not.
363
- */
364
221
  const readCache = async (key: string): Promise<CacheRead> => {
365
222
  try {
366
223
  const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
367
224
  const parsed = cachedSpecSchema.safeParse(stored);
368
225
  return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
369
226
  } catch (error) {
370
- // A store that is down or slow degrades to generating, not to an error
371
- // page. Nothing here is worth failing a render over.
372
227
  return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
373
228
  }
374
229
  };
375
230
 
376
- /**
377
- * Writes to the cache without the render waiting for it.
378
- *
379
- * The spec is already in hand; nothing downstream needs the write to finish.
380
- * Awaiting it put a second unbounded call to a host-implemented store on the
381
- * render path, which is the failure this module exists to prevent arriving
382
- * through the other door. The `Promise.resolve` wrapper is what catches a
383
- * store that throws synchronously rather than rejecting.
384
- */
385
231
  const storeInBackground = (key: string, cached: CachedSpec): void => {
386
232
  void Promise.resolve()
387
233
  .then(() => cache.set(key, cached))
388
- .catch(() => {
389
- // A store that cannot be written is not a reason to fail a render.
390
- });
234
+ .catch(() => {});
391
235
  };
392
236
 
393
- /**
394
- * Asks the model.
395
- *
396
- * Deliberately does not decide whether the answer is usable. That depends on
397
- * the asking shopper's live facts — stock, dislikes, what is in their basket
398
- * — and none of those are in the cache key, so a verdict reached here would
399
- * be handed to every request that joined this one. A null `spec` in the
400
- * result means the answer did not satisfy the schema, which is a fault of the
401
- * adapter rather than a judgement about any shopper — and is still a call
402
- * that happened, so its usage comes back with it.
403
- */
404
237
  const askModel = async (
405
238
  active: ComponentProvider,
406
239
  input: TrackingInput,
@@ -412,7 +245,6 @@ export function createComponentGenerator(
412
245
  active.generate({ system, user, schema: generatedSpecSchema, signal }),
413
246
  );
414
247
 
415
- // Providers return parsed objects, but the shape is still model output.
416
248
  const parsed = generatedSpecSchema.safeParse(result.spec);
417
249
 
418
250
  return {
@@ -430,8 +262,7 @@ export function createComponentGenerator(
430
262
 
431
263
  async generate(draft) {
432
264
  const startedAt = Date.now();
433
- // Deliberately unguarded: an invalid payload is a caller bug, not a
434
- // degraded render.
265
+
435
266
  const input = parseTrackingInput(draft);
436
267
  const digest = buildDigest(input);
437
268
 
@@ -439,36 +270,24 @@ export function createComponentGenerator(
439
270
  return buildDeterministic(input, digest, startedAt, null, 'no-provider');
440
271
  }
441
272
 
442
- const providerId = `${provider.name}:${provider.model}`;
273
+ const identity = { name: provider.name, model: provider.model };
274
+ const candidateSkus = input.candidates.map((product) => product.sku);
443
275
  const key =
444
276
  generation === 'cohort'
445
- ? cohortCacheKey(
446
- digest,
447
- input.candidates.map((product) => product.sku),
448
- providerId,
449
- )
450
- : specCacheKey(
451
- digest,
452
- input.candidates.map((product) => product.sku),
453
- providerId,
454
- );
277
+ ? cohortCacheKey(digest, candidateSkus, identity)
278
+ : specCacheKey(digest, candidateSkus, identity);
455
279
 
456
280
  const read = await readCache(key);
457
281
  const cached = read.entry;
458
282
  let calledModel = false;
459
- let answer: ModelAnswer;
460
- // When the model produced this, not when it was served. A cached
461
- // component is not newly generated, and pretending otherwise makes any
462
- // measure of how stale a page is showing read as zero.
283
+ let answer: { spec: GeneratedSpec; usage?: TokenUsage };
284
+
463
285
  let generatedAt: number;
464
286
 
465
287
  if (cached) {
466
288
  answer = { spec: cached.spec };
467
289
  generatedAt = cached.generatedAt;
468
290
  } else {
469
- // Asked before joining, because by the time the shared promise settles
470
- // the entry is gone and there is no way to tell a leader from a
471
- // follower — and they must not both be counted as a model call.
472
291
  calledModel = !singleFlight.isRunning(key);
473
292
 
474
293
  let call: ModelCall;
@@ -478,9 +297,6 @@ export function createComponentGenerator(
478
297
  );
479
298
  } catch (error) {
480
299
  const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
481
- // The request went out. Leaving `calledModel` to default here reported
482
- // every failed call as no call at all, so the calls that cost money
483
- // and produced nothing were the only ones missing from the count.
484
300
  return buildDeterministic(input, digest, startedAt, key, reason, {
485
301
  calledModel,
486
302
  cache: read.outcome,
@@ -499,26 +315,13 @@ export function createComponentGenerator(
499
315
  answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
500
316
  generatedAt = Date.now();
501
317
 
502
- // Stored unreconciled on purpose, and stored even when it is unusable
503
- // for this shopper. Reconciliation narrows a spec to one shopper's live
504
- // facts, and those move independently of the key — a product can sell
505
- // out and come back without the candidate list changing. Keeping what
506
- // the model said means the restock is picked up from cache rather than
507
- // paid for again.
508
318
  if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
509
319
  }
510
320
 
511
- // One place where anything is served, whichever side of the cache it came
512
- // from, and always against the facts of the shopper asking now.
513
- // A cohort spec names products chosen for whoever asked first, and every
514
- // reason under one of them is written here rather than by the model. Only
515
- // the cohort path does that, so in per-shopper mode this stays empty and
516
- // every reason is screened.
517
- const ourReasons = new Map<string, string>();
518
- const served =
321
+ const { spec: served, ourReasons } =
519
322
  generation === 'cohort'
520
- ? fitCohortSpec(answer.spec, input, digest, rank, ourReasons)
521
- : answer.spec;
323
+ ? fitCohortSpec(answer.spec, input, digest, rank)
324
+ : { spec: answer.spec, ourReasons: new Map<string, string>() };
522
325
 
523
326
  const reconciled = reconcileSpec(served, input, digest, ourReasons);
524
327
  if (!reconciled.isUsable) {
@@ -542,14 +345,16 @@ export function createComponentGenerator(
542
345
  ...(answer.usage ? { usage: answer.usage } : {}),
543
346
  });
544
347
 
545
- return withProvenance(reconciled.spec, {
348
+ return {
349
+ ...reconciled.spec,
350
+ specVersion: SPEC_VERSION,
546
351
  slot: digest.slot,
547
352
  source,
548
353
  generatedAt,
549
354
  latencyMs: finishedAt - startedAt,
550
355
  provider: provider.name,
551
356
  model: provider.model,
552
- });
357
+ };
553
358
  },
554
359
  };
555
360
  }