@rudra-js/core 0.5.0 → 0.6.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 (64) hide show
  1. package/README.md +51 -4
  2. package/dist/claim-screening.d.ts +33 -0
  3. package/dist/claim-screening.d.ts.map +1 -0
  4. package/dist/claim-screening.js +226 -0
  5. package/dist/claim-screening.js.map +1 -0
  6. package/dist/component-generator.d.ts.map +1 -1
  7. package/dist/component-generator.js +32 -98
  8. package/dist/component-generator.js.map +1 -1
  9. package/dist/component-spec.d.ts +2 -49
  10. package/dist/component-spec.d.ts.map +1 -1
  11. package/dist/component-spec.js +3 -17
  12. package/dist/component-spec.js.map +1 -1
  13. package/dist/fallback-component.d.ts +3 -0
  14. package/dist/fallback-component.d.ts.map +1 -1
  15. package/dist/fallback-component.js +3 -13
  16. package/dist/fallback-component.js.map +1 -1
  17. package/dist/fit-to-shopper.d.ts +5 -1
  18. package/dist/fit-to-shopper.d.ts.map +1 -1
  19. package/dist/fit-to-shopper.js +4 -3
  20. package/dist/fit-to-shopper.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/model-prompt.d.ts +1 -1
  25. package/dist/model-prompt.d.ts.map +1 -1
  26. package/dist/model-prompt.js +38 -18
  27. package/dist/model-prompt.js.map +1 -1
  28. package/dist/product-selection.d.ts +0 -6
  29. package/dist/product-selection.d.ts.map +1 -1
  30. package/dist/product-selection.js +1 -3
  31. package/dist/product-selection.js.map +1 -1
  32. package/dist/provider.d.ts +1 -8
  33. package/dist/provider.d.ts.map +1 -1
  34. package/dist/provider.js +1 -8
  35. package/dist/provider.js.map +1 -1
  36. package/dist/reconciliation.d.ts +10 -22
  37. package/dist/reconciliation.d.ts.map +1 -1
  38. package/dist/reconciliation.js +116 -399
  39. package/dist/reconciliation.js.map +1 -1
  40. package/dist/signal-digest.d.ts +10 -0
  41. package/dist/signal-digest.d.ts.map +1 -1
  42. package/dist/signal-digest.js +38 -28
  43. package/dist/signal-digest.js.map +1 -1
  44. package/dist/spec-cache.d.ts +14 -2
  45. package/dist/spec-cache.d.ts.map +1 -1
  46. package/dist/spec-cache.js +14 -24
  47. package/dist/spec-cache.js.map +1 -1
  48. package/dist/tracking-input.d.ts.map +1 -1
  49. package/dist/tracking-input.js +15 -13
  50. package/dist/tracking-input.js.map +1 -1
  51. package/package.json +4 -4
  52. package/src/claim-screening.ts +242 -0
  53. package/src/component-generator.ts +37 -123
  54. package/src/component-spec.ts +5 -18
  55. package/src/fallback-component.ts +3 -14
  56. package/src/fit-to-shopper.ts +9 -4
  57. package/src/index.ts +1 -0
  58. package/src/model-prompt.ts +42 -20
  59. package/src/product-selection.ts +1 -9
  60. package/src/provider.ts +1 -8
  61. package/src/reconciliation.ts +135 -430
  62. package/src/signal-digest.ts +41 -31
  63. package/src/spec-cache.ts +27 -24
  64. package/src/tracking-input.ts +16 -13
package/README.md CHANGED
@@ -120,10 +120,23 @@ before anything renders. A product you left out doesn't reach the page.
120
120
  `bundles` is optional. These are the sets you sell together, each with your own
121
121
  price for the set, the currency that price is in, and, if you want one, your own
122
122
  name for it. Every product in a set has to be a candidate as well. That's what
123
- lets the same checks that pass a single product pass a whole set, and what lets
123
+ lets the checks that pass a single product run over a whole set, and what lets
124
124
  the renderer look the members up in the catalog it already has. Ids must be
125
125
  unique, and one set must not name the same product twice.
126
126
 
127
+ A set is placed whole, so one check runs differently: a set may hold something
128
+ the shopper already bought or has in the basket, where a grid or a carousel
129
+ would drop it. A thumbs-down on a member still blocks the whole set, and so does
130
+ a member that is out of stock, already on the page, or the product being looked
131
+ at right now.
132
+
133
+ That last one has a cost. A "frequently bought together, including this item"
134
+ set names the product whose page it sits on, so it is refused on exactly the
135
+ page you wrote it for. Leave the item out and sell the companions as the set —
136
+ that rejection goes away, and the rest of the checks still apply. And when a
137
+ bundle block is the only block the model asked for, refusing the set leaves
138
+ nothing to render, so the deterministic component takes over.
139
+
127
140
  The model _never_ picks a set and is never told a price. All it does is ask for a
128
141
  bundle block and write the words around it. We pick which set when the page is
129
142
  served, going on what the shopper has in their basket, has looked at, or is
@@ -244,6 +257,30 @@ everyone looking at it shares them. If you pick candidates per shopper you'll ge
244
257
  smaller cohorts. That's the honest outcome, because your prompt really is
245
258
  personal.
246
259
 
260
+ What goes in the key is the _set_ of candidate SKUs. Not the order you sent them
261
+ in, not which ones were in stock, and not where the 60-product cut fell. So two
262
+ requests carrying the same SKUs in a different order share one component, and the
263
+ one generated first is the one both get. If your ranking is personal, order your
264
+ candidates and the products in the copy won't be the ones you put at the top.
265
+ Prices, titles and stock are always read from your catalog at render time, so
266
+ nothing stale is ever shown — a product that sold out is dropped as the page is
267
+ served, and any sentence written about it stays.
268
+
269
+ #### Who can create a cohort
270
+
271
+ Every field above is one you supply, and each distinct value is a cohort of its
272
+ own — a fresh model call, and a slot in the cache. If a visitor can choose one of
273
+ them, a visitor can choose how many calls you pay for. A `locale` copied straight
274
+ out of `Accept-Language`, a `currentCategory` read off a URL slug and a `segment`
275
+ built from a query parameter are the three that go wrong. The shipped cache holds
276
+ 10,000 entries and drops the oldest, so enough made-up values also evict the
277
+ cohorts your real shoppers were being served from.
278
+
279
+ Draw those fields from sets you control: your own locale list, your own category
280
+ ids, your own segment labels. Nothing leaks either way — a cohort prompt carries
281
+ no shopper text at all — the cost is the bill and the model round trip on a page
282
+ that was being served from cache.
283
+
247
284
  Pass `generation: 'per-shopper'` to generate for the individual instead. The
248
285
  model then chooses the products too, and every shopper pays for their own call.
249
286
 
@@ -311,9 +348,19 @@ const generator = createComponentGenerator({ provider, rank: 'given' });
311
348
 
312
349
  You keep the rest either way. We still drop anything the shopper shouldn't be
313
350
  shown, whether it's out of stock, already bought, in the basket, disliked, or
314
- the product they're looking at right now. Every product still carries a basis
315
- we check against their real signals, and everything the model writes is still
316
- screened.
351
+ the product they're looking at right now — read from the payload you sent, not
352
+ from the shortened history the model was shown, so a shopper with a long order
353
+ book is covered too. Every product still carries a basis we check against their
354
+ real signals, and everything the model writes is still screened.
355
+
356
+ How much each basis proves is worth knowing, because two of the six prove less
357
+ than they read. `most_viewed` is checked against the SKUs they viewed,
358
+ `similar_to_current` against the category being browsed, `liked_category`
359
+ against the categories they bought, liked, carted or viewed in — standing on a
360
+ category page is not one of those — and `popular` claims nothing.
361
+ `complements_cart` and `complements_purchase` check only that the basket, or the
362
+ order history, isn't empty: nothing here can know that one product goes with
363
+ another. A basis that fails takes the sentence and the badge stating it with it.
317
364
 
318
365
  Each candidate can carry its own `reason`, the phrase shown under the product.
319
366
  Reach for it when your ranking knows something the signals don't:
@@ -0,0 +1,33 @@
1
+ import type { Product, TrackingInput } from './tracking-input.js';
2
+ /**
3
+ * Wording attested bans that this project has already ruled is not a claim. An allowance
4
+ * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
5
+ * clearance for" — forgives whatever follows it. Exported so a test can hold the list
6
+ * against attested's own.
7
+ */
8
+ export declare const ALLOWED_PHRASES: string[];
9
+ /**
10
+ * The numbers the shop published about one product: its category and tags, only the strings
11
+ * carrying a digit. Exported so a test can pin what never reaches attested.
12
+ */
13
+ export declare function productFacts(product: Product): string[];
14
+ export interface HostFacts {
15
+ pooled: string[];
16
+ bySku: Map<string, string[]>;
17
+ }
18
+ /**
19
+ * The shop's own numbers for this request, for attested to check numerals against.
20
+ *
21
+ * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
22
+ * means a product the model never saw. A title and a rating it is shown but told never to
23
+ * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
24
+ * from the request rather than the catalogue, so a host passing a URL segment through would
25
+ * hand this list to whoever types the URL.
26
+ *
27
+ * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
28
+ * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
29
+ * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
30
+ */
31
+ export declare function hostFacts(input: TrackingInput): HostFacts;
32
+ export declare function claimIn(text: string): string | null;
33
+ //# sourceMappingURL=claim-screening.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claim-screening.d.ts","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAmKlE;;;;;GAKG;AACH,eAAO,MAAM,eAAe,UAAkE,CAAC;AAmB/F;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAOvD;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CAC9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAWzD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQnD"}
@@ -0,0 +1,226 @@
1
+ import { offeredCandidates } from './model-prompt.js';
2
+ const CLAIM_PATTERNS = [
3
+ {
4
+ // Customers scoring the product. "rated for winter use", "rated to -10C"
5
+ // and "an IPX7 water rating" are about what the product can take.
6
+ kind: 'rating',
7
+ patterns: [
8
+ /\breviews?\b|\breviewed\b/,
9
+ /\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
10
+ /\bstars?[\s-]?ratings?\b/,
11
+ // A rating somebody gave it, rather than one it was built to.
12
+ /\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
13
+ // A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
14
+ /\bratings?\s+of\s+[0-5]\.\d\b/,
15
+ // The same score with the word "rating" nowhere near it: "4.8 out of 5".
16
+ /\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
17
+ /\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
18
+ // "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
19
+ /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
20
+ // How many other people bought or liked it is a customer claim too.
21
+ /\bbest[\s-]?sell(?:er|ers|ing)\b/,
22
+ // The words a model reaches for when "best" is banned. Each branch carries its own
23
+ // \b — one in front of the group demands a word character before the #, never there.
24
+ /(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
25
+ /\bloved by (?:thousands|hundreds|millions|\d)/,
26
+ ],
27
+ },
28
+ {
29
+ kind: 'price',
30
+ patterns: [
31
+ // No digit beside it: asking for one let "$thirty-nine and it is yours" through.
32
+ /[$£€¥₹]/,
33
+ /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
34
+ // The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
35
+ /\bdollars?\b|\beuros?\b/,
36
+ // The same codes on the other side of the number: "USD 20", "EUR 5.99".
37
+ /\b(?:usd|eur|gbp)\s?\d/,
38
+ // The krona is spelled out rather than drawn, so it needs a number beside it.
39
+ /\bkr\s?\d|\d\s?kr\b/,
40
+ /\bpric(?:e|es|ed|ing)\b/,
41
+ // A before-and-after is a price claim even with no currency on it.
42
+ /\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
43
+ // "does not feel cheap" is about quality. Only the comparison is money.
44
+ /\bcheap(?:er|est)\b/,
45
+ /\baffordable\b|\bbargain\b/,
46
+ // "at no cost to comfort" is not money. "costs less" is.
47
+ /\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
48
+ /\blow(?:er)?[\s-]cost\b/,
49
+ // "saves weight" and "saves on weight" are both about grams.
50
+ /\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
51
+ ],
52
+ },
53
+ {
54
+ // A percentage in outdoor copy is a specification far more often than it is
55
+ // money off, so it only counts beside a money word: "20% off", not
56
+ // "100% recycled".
57
+ kind: 'discount',
58
+ patterns: [
59
+ /\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
60
+ /\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
61
+ /\bdiscount(?:s|ed)?\b/,
62
+ /\bsale\b|\bmarked down\b|\bdeal of\b/,
63
+ /\bhalf[\s-]?(?:price|off)\b/,
64
+ // Money off with no percent sign anywhere on it: "save 20 off".
65
+ /\bsaves?\s+\d[\d.,]*\s+off\b/,
66
+ // "extra clearance for thick socks" is room inside the shoe.
67
+ /\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
68
+ // "reduced weight" and "reduced to 900g" are specifications. Only a
69
+ // reduced price, or a reduction with a date on it, is money off.
70
+ /\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
71
+ ],
72
+ },
73
+ {
74
+ // When it turns up, not what turns up. "ships in a recycled box" and
75
+ // "arrives flat-packed" describe the thing, so the verb alone is not enough
76
+ // — it needs a day or a date beside it.
77
+ kind: 'delivery',
78
+ patterns: [
79
+ /\bdelivery\b|\bshipping\b/,
80
+ /\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
81
+ /\b(?:ships?|arrives?|arriving|delivered|by|before|in time for)\s+(?:on\s+)?(?:today|tomorrow|tonight|this week|next week|the weekend|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
82
+ /\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
83
+ /\bin time for\b/,
84
+ ],
85
+ },
86
+ {
87
+ kind: 'stock',
88
+ patterns: [
89
+ /\b(?:in|out of|low on) stock\b/,
90
+ /\blimited stock\b/,
91
+ /\brestocked?\b|\bsold out\b/,
92
+ // "the last few miles" is a distance, so a count needs "left" after it.
93
+ /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
94
+ /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
95
+ ],
96
+ },
97
+ ];
98
+ /** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
99
+ const CONFUSABLES = {
100
+ а: 'a',
101
+ в: 'b',
102
+ е: 'e',
103
+ к: 'k',
104
+ м: 'm',
105
+ н: 'h',
106
+ о: 'o',
107
+ р: 'p',
108
+ с: 'c',
109
+ т: 't',
110
+ х: 'x',
111
+ у: 'y',
112
+ ѕ: 's',
113
+ і: 'i',
114
+ ј: 'j',
115
+ ԁ: 'd',
116
+ һ: 'h',
117
+ ӏ: 'l',
118
+ α: 'a',
119
+ ε: 'e',
120
+ ι: 'i',
121
+ κ: 'k',
122
+ ν: 'v',
123
+ ο: 'o',
124
+ ρ: 'p',
125
+ τ: 't',
126
+ υ: 'u',
127
+ χ: 'x',
128
+ ϲ: 'c',
129
+ };
130
+ /**
131
+ * Characters that render as nothing: format characters, default-ignorable code points and the
132
+ * controls. Cf and Default_Ignorable each hold characters the other does not.
133
+ * A second copy of the class in attested's `hidden.ts`, pinned to it by a test.
134
+ */
135
+ const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
136
+ /** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
137
+ const SPACING = /[\t\n\v\f\r\u0085]/;
138
+ /**
139
+ * Marks that hang on the character before them instead of taking a column of their own.
140
+ * Dropped after composition, so an accented e keeps its accent while an overline,
141
+ * which composes with nothing, cannot hide a word from its own rule.
142
+ */
143
+ const MARKS = /[\p{Mn}\p{Me}]/gu;
144
+ /** Only the reading is normalised. What renders is what the model wrote. */
145
+ function normaliseForClaims(text) {
146
+ const lowered = text
147
+ .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
148
+ .normalize('NFKC')
149
+ .toLowerCase()
150
+ .normalize('NFC')
151
+ .replace(MARKS, '');
152
+ let folded = '';
153
+ for (const char of lowered)
154
+ folded += CONFUSABLES[char] ?? char;
155
+ return folded;
156
+ }
157
+ /**
158
+ * Wording attested bans that this project has already ruled is not a claim. An allowance
159
+ * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
160
+ * clearance for" — forgives whatever follows it. Exported so a test can hold the list
161
+ * against attested's own.
162
+ */
163
+ export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
164
+ /** Only a string with one of these in it can stand behind a numeral. */
165
+ const DIGIT = /\p{Nd}/u;
166
+ /** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
167
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
168
+ /** Wide enough for every finite JavaScript number, and for anything a shop sells. */
169
+ const MAX_EXPONENT = 1000;
170
+ // `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
171
+ // attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
172
+ function canLayOut(fact) {
173
+ const match = BARE_EXPONENT.exec(fact.trim());
174
+ if (match === null)
175
+ return true;
176
+ return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
177
+ }
178
+ /**
179
+ * The numbers the shop published about one product: its category and tags, only the strings
180
+ * carrying a digit. Exported so a test can pin what never reaches attested.
181
+ */
182
+ export function productFacts(product) {
183
+ const facts = [];
184
+ if (DIGIT.test(product.category) && canLayOut(product.category))
185
+ facts.push(product.category);
186
+ for (const tag of product.tags) {
187
+ if (DIGIT.test(tag) && canLayOut(tag))
188
+ facts.push(tag);
189
+ }
190
+ return facts;
191
+ }
192
+ /**
193
+ * The shop's own numbers for this request, for attested to check numerals against.
194
+ *
195
+ * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
196
+ * means a product the model never saw. A title and a rating it is shown but told never to
197
+ * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
198
+ * from the request rather than the catalogue, so a host passing a URL segment through would
199
+ * hand this list to whoever types the URL.
200
+ *
201
+ * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
202
+ * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
203
+ * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
204
+ */
205
+ export function hostFacts(input) {
206
+ const pooled = new Set();
207
+ const bySku = new Map();
208
+ for (const product of offeredCandidates(input)) {
209
+ const own = productFacts(product);
210
+ bySku.set(product.sku, own);
211
+ for (const fact of own)
212
+ pooled.add(fact);
213
+ }
214
+ return { pooled: [...pooled], bySku };
215
+ }
216
+ export function claimIn(text) {
217
+ const normalised = normaliseForClaims(text);
218
+ for (const claim of CLAIM_PATTERNS) {
219
+ for (const pattern of claim.patterns) {
220
+ if (pattern.test(normalised))
221
+ return claim.kind;
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+ //# sourceMappingURL=claim-screening.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claim-screening.js","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOnE,OAAO,EAAkB,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAG1F,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAED,qBAAa,YAAa,SAAQ,KAAK;IACrC,YAAY,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAG9C;CACF;AAgKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA8PpB"}
1
+ {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEnE,OAAO,EAAkB,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAG1F,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAED,qBAAa,YAAa,SAAQ,KAAK;IACrC,YAAY,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAG9C;CACF;AA+HD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA8MpB"}
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { SPEC_VERSION, generatedSpecSchema, } from './component-spec.js';
3
3
  import { buildFallbackSpec } from './fallback-component.js';
4
4
  import { buildPrompt } from './model-prompt.js';
5
- import { MAX_BLOCKS, bundleForShopper, placeableHeroSkus, reconcileSpec, } from './reconciliation.js';
5
+ import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
6
6
  import { selectProducts } from './product-selection.js';
7
7
  import { fitToShopper } from './fit-to-shopper.js';
8
8
  import { buildDigest, toCohortDigest } from './signal-digest.js';
@@ -15,18 +15,10 @@ export class TimeoutError extends Error {
15
15
  }
16
16
  }
17
17
  /**
18
- * Races a promise against a deadline.
19
- *
20
- * The deadline is enforced here rather than trusted to the thing being waited
21
- * on. A provider that ignores its abort signal, or a store that never settles,
22
- * must still not hold a page open.
23
- *
24
- * Once the deadline has fired the caller is told so, whatever the race
25
- * actually settled with. Aborting is what makes that necessary: a provider
26
- * honouring its half of the contract rejects from inside the `abort()` below,
27
- * so its rejection reaches the race first and the deadline's own never wins.
28
- * Reporting the error that happened to arrive would blame the vendor for the
29
- * caller's deadline — and blame it most often on the best-behaved adapters.
18
+ * Once the deadline has fired the caller is told so, whatever the race actually
19
+ * settled with: a provider honouring its half of the contract rejects from
20
+ * inside the `abort()` below, so its rejection reaches the race first and the
21
+ * deadline's own never wins.
30
22
  */
31
23
  async function withinBudget(label, milliseconds, start) {
32
24
  const controller = new AbortController();
@@ -49,14 +41,6 @@ async function withinBudget(label, milliseconds, start) {
49
41
  clearTimeout(timer);
50
42
  }
51
43
  }
52
- /**
53
- * Collapses concurrent work for the same key into one execution.
54
- *
55
- * Without this, a key that is not yet cached fans out into one model call per
56
- * concurrent request — the same answer, bought many times over. The entry is
57
- * removed as soon as it settles, so one failure does not poison the next
58
- * attempt.
59
- */
60
44
  function createSingleFlight() {
61
45
  const inFlight = new Map();
62
46
  return {
@@ -77,24 +61,16 @@ const cachedSpecSchema = z.object({
77
61
  generatedAt: z.number(),
78
62
  });
79
63
  /**
80
- * Fills a cohort spec with this shopper's products, keeping room for a set.
81
- *
82
- * The grid used to take the whole item budget, so a bundle block later in the
83
- * spec found nothing left and was dropped. The set is chosen first, its
84
- * products are held back from the grid, and the grid's limit drops by what the
85
- * set and the heroes have already spoken for.
86
- *
87
64
  * The arithmetic has to hold whatever order the model put the blocks in, so it
88
65
  * is written as one sum over the whole spec rather than as a running budget:
89
66
  * the grid gets `maxItems` minus every distinct product the set and the heroes
90
- * will place. Nothing is then dropped for want of budget, and reconciliation
91
- * reaches the same set this did — it can only ever have more placed than the
92
- * pre-choice assumed, and never one of the set's own products.
67
+ * will place. Reconciliation then reaches the same set this did — it can only
68
+ * ever have more placed than the pre-choice assumed, and never one of the set's
69
+ * own products.
93
70
  */
94
- function fitCohortSpec(spec, input, digest, rank, ourReasons) {
71
+ function fitCohortSpec(spec, input, digest, rank) {
95
72
  const picks = selectProducts(input, digest, { rank });
96
- // Blocks past the cap never render, so a set is not worth reserving for one.
97
- const blocks = spec.blocks.slice(0, MAX_BLOCKS);
73
+ const blocks = capBlocks(spec.blocks);
98
74
  let hasBundleBlock = false;
99
75
  const aboveBundle = [];
100
76
  for (const block of blocks) {
@@ -105,27 +81,26 @@ function fitCohortSpec(spec, input, digest, rank, ourReasons) {
105
81
  aboveBundle.push(block);
106
82
  }
107
83
  if (!hasBundleBlock)
108
- return fitToShopper(spec, picks, digest.maxItems, ourReasons);
84
+ return fitToShopper(spec, picks, digest.maxItems);
109
85
  // Only the heroes above the bundle block are placed when it is reached, so
110
86
  // they are all the choice may account for.
111
- const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
87
+ const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
112
88
  if (!chosen)
113
- return fitToShopper(spec, picks, digest.maxItems, ourReasons);
89
+ return fitToShopper(spec, picks, digest.maxItems);
114
90
  const spokenFor = new Set(chosen.skus);
115
- for (const sku of placeableHeroSkus(blocks, input, digest))
91
+ for (const sku of placeableHeroSkus(blocks, input))
116
92
  spokenFor.add(sku);
117
93
  const roomLeft = digest.maxItems - spokenFor.size;
118
94
  // A set is worth showing, but not at the cost of an empty grid.
119
95
  if (roomLeft <= 0)
120
- return fitToShopper(spec, picks, digest.maxItems, ourReasons);
96
+ return fitToShopper(spec, picks, digest.maxItems);
121
97
  const forGrid = [];
122
98
  for (const pick of picks) {
123
99
  if (!spokenFor.has(pick.product.sku))
124
100
  forGrid.push(pick);
125
101
  }
126
- return fitToShopper(spec, forGrid, roomLeft, ourReasons);
102
+ return fitToShopper(spec, forGrid, roomLeft);
127
103
  }
128
- /** Attaches the provenance the server owns. The model never supplies any of it. */
129
104
  function withProvenance(spec, provenance) {
130
105
  return { ...spec, specVersion: SPEC_VERSION, ...provenance };
131
106
  }
@@ -147,14 +122,7 @@ export function createComponentGenerator(options = {}) {
147
122
  // A broken metrics hook must not take down a page.
148
123
  }
149
124
  };
150
- const buildDeterministic = (input, digest, startedAt, key, degradedReason,
151
- /**
152
- * What the model call cost, when there was one. A generation that is
153
- * unusable for this shopper was still asked for and still billed, so
154
- * omitting it here would hide the calls that produce nothing — exactly the
155
- * ones worth knowing about.
156
- */
157
- modelCall = {
125
+ const buildDeterministic = (input, digest, startedAt, key, degradedReason, modelCall = {
158
126
  calledModel: false,
159
127
  }) => {
160
128
  const finishedAt = Date.now();
@@ -175,15 +143,6 @@ export function createComponentGenerator(options = {}) {
175
143
  degradedReason,
176
144
  });
177
145
  };
178
- /**
179
- * Reads the cache, treating anything unexpected as a miss.
180
- *
181
- * The value is re-validated because a store is a port a host implements, and
182
- * what comes back is no more trustworthy than what a model returns — a shared
183
- * store outlives a deploy, so it can hold entries written by an older shape of
184
- * the spec. Generating again is always safe; handing an unvalidated object to
185
- * reconciliation is not.
186
- */
187
146
  const readCache = async (key) => {
188
147
  try {
189
148
  const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
@@ -191,20 +150,11 @@ export function createComponentGenerator(options = {}) {
191
150
  return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
192
151
  }
193
152
  catch (error) {
194
- // A store that is down or slow degrades to generating, not to an error
195
- // page. Nothing here is worth failing a render over.
153
+ // A store that is down or slow degrades to generating, not to an error page.
196
154
  return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
197
155
  }
198
156
  };
199
- /**
200
- * Writes to the cache without the render waiting for it.
201
- *
202
- * The spec is already in hand; nothing downstream needs the write to finish.
203
- * Awaiting it put a second unbounded call to a host-implemented store on the
204
- * render path, which is the failure this module exists to prevent arriving
205
- * through the other door. The `Promise.resolve` wrapper is what catches a
206
- * store that throws synchronously rather than rejecting.
207
- */
157
+ /** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
208
158
  const storeInBackground = (key, cached) => {
209
159
  void Promise.resolve()
210
160
  .then(() => cache.set(key, cached))
@@ -213,15 +163,10 @@ export function createComponentGenerator(options = {}) {
213
163
  });
214
164
  };
215
165
  /**
216
- * Asks the model.
217
- *
218
166
  * Deliberately does not decide whether the answer is usable. That depends on
219
167
  * the asking shopper's live facts — stock, dislikes, what is in their basket
220
168
  * — and none of those are in the cache key, so a verdict reached here would
221
- * be handed to every request that joined this one. A null `spec` in the
222
- * result means the answer did not satisfy the schema, which is a fault of the
223
- * adapter rather than a judgement about any shopper — and is still a call
224
- * that happened, so its usage comes back with it.
169
+ * be handed to every request that joined this one.
225
170
  */
226
171
  const askModel = async (active, input, promptDigest) => {
227
172
  const { system, user } = buildPrompt(input, promptDigest);
@@ -248,17 +193,16 @@ export function createComponentGenerator(options = {}) {
248
193
  if (!provider) {
249
194
  return buildDeterministic(input, digest, startedAt, null, 'no-provider');
250
195
  }
251
- const providerId = `${provider.name}:${provider.model}`;
196
+ const identity = { name: provider.name, model: provider.model };
197
+ const candidateSkus = input.candidates.map((product) => product.sku);
252
198
  const key = generation === 'cohort'
253
- ? cohortCacheKey(digest, input.candidates.map((product) => product.sku), providerId)
254
- : specCacheKey(digest, input.candidates.map((product) => product.sku), providerId);
199
+ ? cohortCacheKey(digest, candidateSkus, identity)
200
+ : specCacheKey(digest, candidateSkus, identity);
255
201
  const read = await readCache(key);
256
202
  const cached = read.entry;
257
203
  let calledModel = false;
258
204
  let answer;
259
- // When the model produced this, not when it was served. A cached
260
- // component is not newly generated, and pretending otherwise makes any
261
- // measure of how stale a page is showing read as zero.
205
+ // When the model produced this, not when it was served.
262
206
  let generatedAt;
263
207
  if (cached) {
264
208
  answer = { spec: cached.spec };
@@ -266,8 +210,7 @@ export function createComponentGenerator(options = {}) {
266
210
  }
267
211
  else {
268
212
  // Asked before joining, because by the time the shared promise settles
269
- // the entry is gone and there is no way to tell a leader from a
270
- // follower — and they must not both be counted as a model call.
213
+ // the entry is gone and there is no way to tell a leader from a follower.
271
214
  calledModel = !singleFlight.isRunning(key);
272
215
  let call;
273
216
  try {
@@ -275,9 +218,6 @@ export function createComponentGenerator(options = {}) {
275
218
  }
276
219
  catch (error) {
277
220
  const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
278
- // The request went out. Leaving `calledModel` to default here reported
279
- // every failed call as no call at all, so the calls that cost money
280
- // and produced nothing were the only ones missing from the count.
281
221
  return buildDeterministic(input, digest, startedAt, key, reason, {
282
222
  calledModel,
283
223
  cache: read.outcome,
@@ -296,22 +236,16 @@ export function createComponentGenerator(options = {}) {
296
236
  // Stored unreconciled on purpose, and stored even when it is unusable
297
237
  // for this shopper. Reconciliation narrows a spec to one shopper's live
298
238
  // facts, and those move independently of the key — a product can sell
299
- // out and come back without the candidate list changing. Keeping what
300
- // the model said means the restock is picked up from cache rather than
301
- // paid for again.
239
+ // out and come back without the candidate list changing.
302
240
  if (calledModel)
303
241
  storeInBackground(key, { spec: answer.spec, generatedAt });
304
242
  }
305
- // One place where anything is served, whichever side of the cache it came
306
- // from, and always against the facts of the shopper asking now.
307
243
  // A cohort spec names products chosen for whoever asked first, and every
308
- // reason under one of them is written here rather than by the model. Only
309
- // the cohort path does that, so in per-shopper mode this stays empty and
310
- // every reason is screened.
311
- const ourReasons = new Map();
312
- const served = generation === 'cohort'
313
- ? fitCohortSpec(answer.spec, input, digest, rank, ourReasons)
314
- : answer.spec;
244
+ // reason under one of them is written here rather than by the model. In
245
+ // per-shopper mode this stays empty and every reason is screened.
246
+ const { spec: served, ourReasons } = generation === 'cohort'
247
+ ? fitCohortSpec(answer.spec, input, digest, rank)
248
+ : { spec: answer.spec, ourReasons: new Map() };
315
249
  const reconciled = reconcileSpec(served, input, digest, ourReasons);
316
250
  if (!reconciled.isUsable) {
317
251
  return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {