@rudra-js/core 0.4.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 +139 -15
  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 +33 -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 +6 -4
  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 +13 -1
  25. package/dist/model-prompt.d.ts.map +1 -1
  26. package/dist/model-prompt.js +53 -21
  27. package/dist/model-prompt.js.map +1 -1
  28. package/dist/product-selection.d.ts +1 -9
  29. package/dist/product-selection.d.ts.map +1 -1
  30. package/dist/product-selection.js +1 -4
  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 +15 -12
  37. package/dist/reconciliation.d.ts.map +1 -1
  38. package/dist/reconciliation.js +150 -340
  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 -2
  52. package/src/claim-screening.ts +242 -0
  53. package/src/component-generator.ts +38 -123
  54. package/src/component-spec.ts +5 -18
  55. package/src/fallback-component.ts +3 -14
  56. package/src/fit-to-shopper.ts +11 -4
  57. package/src/index.ts +1 -0
  58. package/src/model-prompt.ts +58 -23
  59. package/src/product-selection.ts +2 -13
  60. package/src/provider.ts +1 -8
  61. package/src/reconciliation.ts +178 -359
  62. package/src/signal-digest.ts +41 -31
  63. package/src/spec-cache.ts +27 -24
  64. package/src/tracking-input.ts +16 -13
@@ -1,18 +1,23 @@
1
1
  import type { Block, GeneratedSpec, ProductReference } from './component-spec.js';
2
2
  import type { ProductPick } from './product-selection.js';
3
3
 
4
+ export interface FittedSpec {
5
+ spec: GeneratedSpec;
6
+ ourReasons: Map<string, string>;
7
+ }
8
+
4
9
  // The model picks the shape. Selection picks the grid and carousel products and
5
10
  // what may be said about them, so a shared component still fits one shopper.
6
11
  export function fitToShopper(
7
12
  spec: GeneratedSpec,
8
13
  picks: readonly ProductPick[],
9
14
  maxItems: number,
10
- hostReasonSkus?: Set<string>,
11
- ): GeneratedSpec {
15
+ ): FittedSpec {
12
16
  // Picks are ordered best first. Every slot takes the next one.
13
17
  let next = 0;
14
18
  const limit = Math.min(picks.length, maxItems);
15
19
 
20
+ const ourReasons = new Map<string, string>();
16
21
  const blocks: Block[] = [];
17
22
  for (const block of spec.blocks) {
18
23
  // A hero is left alone. Its headline and body were written about the
@@ -25,7 +30,9 @@ export function fitToShopper(
25
30
  for (const item of block.items) {
26
31
  if (next >= limit) break; // shrink, never pad
27
32
  const chosen = picks[next]!;
28
- if (chosen.reasonFromHost) hostReasonSkus?.add(chosen.product.sku);
33
+ // Every reason here is written by this library or handed to it by the
34
+ // shop. None of them is the model's, whatever the model wrote in this slot.
35
+ ourReasons.set(chosen.product.sku, chosen.reason);
29
36
  items.push({
30
37
  sku: chosen.product.sku,
31
38
  basis: chosen.basis,
@@ -43,5 +50,5 @@ export function fitToShopper(
43
50
  blocks.push(block);
44
51
  }
45
52
 
46
- return { ...spec, blocks };
53
+ return { spec: { ...spec, blocks }, ourReasons };
47
54
  }
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export {
61
61
  type ComponentSpec,
62
62
  type CopyBlock,
63
63
  type GeneratedSpec,
64
+ type GeneratedSpecResult,
64
65
  type GridBlock,
65
66
  type HeroBlock,
66
67
  type ProductReference,
@@ -1,6 +1,6 @@
1
1
  import { BANNER_TONES, EMPHASIS, RECOMMENDATION_BASES, TONES } from './component-spec.js';
2
2
  import type { SignalDigest } from './signal-digest.js';
3
- import type { Product, TrackingInput } from './tracking-input.js';
3
+ import { FIELD_LIMITS, type Product, type TrackingInput } from './tracking-input.js';
4
4
 
5
5
  /**
6
6
  * What actually reaches the model.
@@ -140,34 +140,57 @@ on.`;
140
140
  * text exactly the same way, and it missed U+0085, U+061C and U+00AD as well.
141
141
  * Properties cover the ones nobody has thought of yet.
142
142
  *
143
- * - Cc, control. Includes U+0085, a mandatory line break that is not U+000A.
144
- * - Cf, format. Zero-width characters, the bidirectional overrides and
145
- * isolates, and the tag block, which mirrors all of ASCII invisibly.
146
- * - Zl and Zp, the line and paragraph separators.
147
- * - Cn and Co, unassigned and private use — undefined rendering by definition.
148
- * - The variation selectors supplement, which is assigned and therefore not
149
- * caught by Cn, and is invisible.
143
+ * The general categories alone do not, though. Half of what renders as nothing
144
+ * is a mark or a letter rather than a format character \u2014 the Mongolian free
145
+ * variation selectors, U+034F, the Hangul fillers, and the sixteen variation
146
+ * selectors at U+FE00 \u2014 so `Default_Ignorable_Code_Point` is in the class too.
147
+ * It is the property that means "takes up no room", which is the thing that
148
+ * makes a character able to carry text nobody sees.
150
149
  *
151
- * The zero-width joiner is the one exception. It is a format character, but it
152
- * is also how a family emoji is spelled, so escaping it mangles ordinary
153
- * product titles. Emoji presentation selectors (U+FE00-U+FE0F) are excluded for
154
- * the same reason.
150
+ * Three are left out. The zero-width joiner is a format character, but it is
151
+ * also how a family emoji is spelled, and U+FE0E and U+FE0F are what make a
152
+ * character render as an emoji rather than as text. Escaping those mangles
153
+ * ordinary product titles. The other thirteen selectors at U+FE00 spell
154
+ * nothing and are escaped like the rest.
155
155
  */
156
- const UNPRINTABLE = /(?!\u200D)[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\u{E0100}-\u{E01EF}]/gu;
156
+ const UNPRINTABLE =
157
+ /(?!\u{200D}|\u{FE0E}|\u{FE0F})[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
157
158
 
158
159
  /**
159
- * Host-supplied text, written so it cannot introduce structure of its own.
160
+ * How long one quoted value may be once escaped.
160
161
  *
161
- * Quoting handles the obvious half. Escaping the characters above handles the
162
- * half that looks identical to a reader: after this, a value occupies exactly
163
- * one line, reads in one direction, and contains nothing a log cannot show.
162
+ * The contract caps the string a host may send, and this caps what that string
163
+ * turns into. They are not the same number: an escape writes up to eight
164
+ * characters for one, so a field on its cap can still buy eight times the
165
+ * prompt \u2014 and the bill \u2014 that the cap implies. Ordinary text escapes nothing
166
+ * and never comes near this.
164
167
  */
165
- const quote = (value: string) =>
166
- JSON.stringify(value).replace(UNPRINTABLE, (character) => {
168
+ const MAX_QUOTED = FIELD_LIMITS.shortText * 2;
169
+
170
+ const escapeUnprintable = (text: string) =>
171
+ text.replace(UNPRINTABLE, (character) => {
167
172
  const codePoint = character.codePointAt(0)!;
168
173
  return `\\u{${codePoint.toString(16).toUpperCase()}}`;
169
174
  });
170
175
 
176
+ /** Host-supplied text, written so it cannot introduce structure of its own. */
177
+ const quote = (value: string) => {
178
+ const escaped = escapeUnprintable(JSON.stringify(value));
179
+ if (escaped.length <= MAX_QUOTED) return escaped;
180
+
181
+ // Cut whole characters, so the result cannot end inside an escape sequence.
182
+ let length = 2;
183
+ let kept = '';
184
+ for (const character of value) {
185
+ const cost = escapeUnprintable(JSON.stringify(character)).length - 2;
186
+ if (length + cost > MAX_QUOTED) break;
187
+ length += cost;
188
+ kept += character;
189
+ }
190
+
191
+ return escapeUnprintable(JSON.stringify(kept));
192
+ };
193
+
171
194
  function section(heading: string, body: string | undefined): string | null {
172
195
  if (!body || body.length === 0) return null;
173
196
  return `${heading}: ${body}`;
@@ -207,7 +230,6 @@ function describeShopper(digest: SignalDigest): string {
207
230
  return lines.filter((line): line is string => line !== null).join('\n');
208
231
  }
209
232
 
210
- /** One candidate per line. Facts the model must not restate are left out. */
211
233
  function describeCandidate(product: Product): string {
212
234
  const parts = [quote(product.sku), quote(product.title), quote(product.category)];
213
235
  if (product.rating !== undefined) parts.push(`rated ${product.rating}`);
@@ -227,10 +249,23 @@ function describeCandidate(product: Product): string {
227
249
  */
228
250
  const MAX_CANDIDATES = 60;
229
251
 
252
+ /**
253
+ * The candidates the model is actually shown.
254
+ *
255
+ * An out-of-stock product is dropped during reconciliation whatever the model
256
+ * does with it, so offering one only costs the shopper a slot.
257
+ *
258
+ * Exported because reconciliation checks the numerals the model writes against
259
+ * the strings on this list, and a candidate it was never shown cannot be where
260
+ * one came from. Two copies of "what the model saw" would drift, and the pair
261
+ * that drifted would be the prompt and the screen reading it back.
262
+ */
263
+ export function offeredCandidates(input: TrackingInput): Product[] {
264
+ return input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
265
+ }
266
+
230
267
  export function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair {
231
- // An out-of-stock product is dropped during reconciliation whatever the model
232
- // does with it, so offering one only costs the shopper a slot.
233
- const offered = input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
268
+ const offered = offeredCandidates(input);
234
269
 
235
270
  // The markers are OWASP's labelled-block recommendation. They are safe as
236
271
  // boundaries because every value between them is quoted and stripped of
@@ -21,10 +21,8 @@ export interface ProductPick {
21
21
  product: Product;
22
22
  /** Why this product, stated so reconciliation can check it. */
23
23
  basis: RecommendationBasis;
24
- /** How the basis reads to a shopper. */
24
+ /** How the basis reads to a shopper. The host's own sentence when it wrote one. */
25
25
  reason: string;
26
- /** True when `reason` is the host's own sentence from the candidate. */
27
- reasonFromHost: boolean;
28
26
  /** Unnormalised. Only the ordering is meaningful. */
29
27
  score: number;
30
28
  }
@@ -44,10 +42,8 @@ const SCORE_WEIGHTS = {
44
42
  /** Beyond this many shared tags, more overlap says nothing new. */
45
43
  const MAX_TAG_OVERLAP = 3;
46
44
 
47
- /** Assumed rating for a product the catalog does not rate. */
48
45
  const UNRATED = 3.5;
49
46
 
50
- /** Tags on the products this shopper has actually engaged with. */
51
47
  function engagedTags(input: TrackingInput, digest: SignalDigest): Set<string> {
52
48
  const engagedSkus = new Set([
53
49
  ...digest.likedSkus,
@@ -100,12 +96,6 @@ function basisFor(
100
96
  return { basis: 'popular', reason: `Popular in ${product.category}` };
101
97
  }
102
98
 
103
- /**
104
- * Scores every eligible candidate and returns them best first.
105
- *
106
- * Ties break on SKU so the order is total: two runs over the same payload
107
- * produce the same list, which is what makes the control arm reproducible.
108
- */
109
99
  /** How the picks are ordered once the unplaceable ones are gone. */
110
100
  export type RankOrder = 'signals' | 'given';
111
101
 
@@ -131,7 +121,7 @@ export function selectProducts(
131
121
  // same thing whether a shopper has two signals or two hundred.
132
122
  const strongestAffinity = Math.max(1, ...affinityByCategory.values());
133
123
  const tags = engagedTags(input, digest);
134
- const excluded = neverRecommend(digest);
124
+ const excluded = neverRecommend(input);
135
125
  const viewsBySku = new Map(digest.topViewed.map((viewed) => [viewed.sku, viewed.views]));
136
126
 
137
127
  const picks: ProductPick[] = [];
@@ -164,7 +154,6 @@ export function selectProducts(
164
154
  product,
165
155
  basis,
166
156
  reason: product.reason ?? reason,
167
- reasonFromHost: product.reason !== undefined,
168
157
  score,
169
158
  });
170
159
  }
package/src/provider.ts CHANGED
@@ -74,14 +74,7 @@ export interface ComponentProvider {
74
74
  generate(request: ProviderRequest): Promise<ProviderResult>;
75
75
  }
76
76
 
77
- /**
78
- * A provider that returns a fixed spec and never touches the network.
79
- *
80
- * This is the benchmark's control: it isolates the cost of the framework from
81
- * the latency of a model, which is the only way to report what server-side
82
- * rendering itself costs. It is also what the generator's tests run against,
83
- * since a test that needs an API key is a test nobody runs.
84
- */
77
+ /** A provider that returns a fixed spec and never touches the network. */
85
78
  export function createFixedSpecProvider(spec: GeneratedSpec): ComponentProvider {
86
79
  return {
87
80
  name: 'fixed',