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