@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
@@ -1,105 +1,38 @@
1
1
  import { z } from 'zod';
2
2
 
3
- /**
4
- * The component specification — the only thing a language model is ever allowed
5
- * to return.
6
- *
7
- * The model chooses layout, ordering, emphasis and copy. Which products to show
8
- * and why is its call only in per-shopper mode, apart from the product a hero
9
- * names. It never returns markup, code, URLs, prices, product titles, or
10
- * images. Every field is an enum, a bounded number, a SKU reference resolved
11
- * against the candidate set, or free text that is clamped and escaped before it
12
- * renders. That is what makes generated output safe to put in a server-rendered
13
- * response.
14
- *
15
- * Two constraints shape this file, and both are easy to undo by accident:
16
- *
17
- * 1. It doubles as a provider structured-output schema, so it avoids
18
- * recursion, string length bounds, and numeric ranges. Providers reject
19
- * schemas that use them in strict mode. Bounds are enforced afterwards, in
20
- * reconciliation, where a violation can be repaired rather than fatal.
21
- * 2. Optionality is `.nullable()` rather than `.optional()`, because strict
22
- * structured outputs require every declared property to be present. "No
23
- * badge" has to be expressible as `null`, not as a missing key.
24
- *
25
- * A test asserts both by converting this schema to JSON Schema, so neither is
26
- * left to a comment nobody reads.
27
- *
28
- * Note the asymmetry with `tracking-input`, which rejects unknown fields: these
29
- * schemas strip them. The inputs differ in kind. An unrecognised key in a host
30
- * payload is a typo, and silently dropping it costs the shopper their history,
31
- * so it must be loud. An unrecognised key from a model is drift, and discarding
32
- * it is exactly right — rejecting the whole spec over one stray key would cost
33
- * the shopper the component. Either way the renderer only ever sees the fields
34
- * declared here, which is the property that matters.
35
- */
36
-
37
- /** Overall voice of the component. */
38
3
  export const TONES = ['neutral', 'enthusiastic', 'urgent', 'editorial'] as const;
39
4
 
40
- /** What a banner is claiming. */
41
5
  export const BANNER_TONES = ['info', 'promo', 'urgency', 'restock'] as const;
42
6
 
43
- /** How prominently a single product is placed. */
44
7
  export const EMPHASIS = ['normal', 'featured'] as const;
45
8
 
46
- /**
47
- * Why a product was chosen — the recommendation strategy behind the pick.
48
- *
49
- * This is a closed set rather than free text because every value here is a
50
- * factual claim about the shopper, and a claim the server can check. "Because
51
- * you viewed this" written as prose is unverifiable: nothing downstream can
52
- * tell whether the shopper viewed anything. Declared as `most_viewed`,
53
- * reconciliation can look it up in the digest and drop the claim if it is
54
- * false.
55
- *
56
- * Every value is deliberately checkable against data already in hand — the
57
- * signal digest or the host's own catalog. A basis that needs data the
58
- * framework never receives (`new_arrival`, `back_in_stock`, `trending_today`)
59
- * is not in this list, because it could only ever be taken on trust.
60
- */
61
9
  export const RECOMMENDATION_BASES = [
62
- /** Same category as the product being viewed. */
63
10
  'similar_to_current',
64
- /** The shopper has viewed this product. */
11
+
65
12
  'most_viewed',
66
- /** Goes with something already in the cart. */
13
+
67
14
  'complements_cart',
68
- /** Goes with something the shopper has bought. */
15
+
69
16
  'complements_purchase',
70
- /** In a category the shopper's signals favour. */
17
+
71
18
  'liked_category',
72
- /** No claim about this shopper at all — the safe default. */
19
+
73
20
  'popular',
74
21
  ] as const;
75
22
 
76
- /** A reference to one product from the candidate set, and why it was chosen. */
77
23
  export const productReferenceSchema = z.object({
78
- /** Must be a SKU the host supplied in `TrackingInput.candidates`. */
79
24
  sku: z.string(),
80
- /**
81
- * The strategy behind this pick. Reconciliation checks it against the
82
- * shopper's signals, so the model cannot assert a relationship that is not
83
- * there.
84
- */
25
+
85
26
  basis: z.enum(RECOMMENDATION_BASES),
86
- /**
87
- * How the basis is phrased for the shopper, e.g. "Pairs with the boots you
88
- * bought". Free text, but it has to be consistent with `basis`, which is not.
89
- *
90
- * Nullable because reconciliation clears it when it cannot verify the basis:
91
- * a pick may still be worth showing when the stated reason for it is not
92
- * true, but the prose asserting that reason must not render.
93
- */
27
+
94
28
  reason: z.string().nullable(),
95
- /** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
29
+
96
30
  badge: z.string().nullable(),
97
31
  emphasis: z.enum(EMPHASIS),
98
32
  });
99
33
  export type ProductReference = z.infer<typeof productReferenceSchema>;
100
34
  export type RecommendationBasis = (typeof RECOMMENDATION_BASES)[number];
101
35
 
102
- /** One large featured statement, optionally anchored to a single product. */
103
36
  const heroBlockSchema = z.object({
104
37
  kind: z.literal('hero'),
105
38
  headline: z.string(),
@@ -108,7 +41,6 @@ const heroBlockSchema = z.object({
108
41
  ctaLabel: z.string().nullable(),
109
42
  });
110
43
 
111
- /** The general-purpose choice when several products are comparably relevant. */
112
44
  const gridBlockSchema = z.object({
113
45
  kind: z.literal('grid'),
114
46
  title: z.string().nullable(),
@@ -116,14 +48,12 @@ const gridBlockSchema = z.object({
116
48
  items: z.array(productReferenceSchema),
117
49
  });
118
50
 
119
- /** A horizontally scanned row, for when order implies a ranking. */
120
51
  const carouselBlockSchema = z.object({
121
52
  kind: z.literal('carousel'),
122
53
  title: z.string().nullable(),
123
54
  items: z.array(productReferenceSchema),
124
55
  });
125
56
 
126
- /** A single line of merchandising copy. */
127
57
  const bannerBlockSchema = z.object({
128
58
  kind: z.literal('banner'),
129
59
  tone: z.enum(BANNER_TONES),
@@ -131,24 +61,21 @@ const bannerBlockSchema = z.object({
131
61
  ctaLabel: z.string().nullable(),
132
62
  });
133
63
 
134
- /** Short editorial prose, for explaining the theme of a selection. */
135
64
  const copyBlockSchema = z.object({
136
65
  kind: z.literal('copy'),
137
66
  title: z.string().nullable(),
138
67
  body: z.string(),
139
68
  });
140
69
 
141
- /** A set the shop sells together. The shop picks which one; this only asks for it. */
142
70
  const bundleBlockSchema = z.object({
143
71
  kind: z.literal('bundle'),
144
72
  title: z.string().nullable(),
145
73
  body: z.string().nullable(),
146
74
  ctaLabel: z.string().nullable(),
147
- // Filled in per request. Whatever the model puts here is thrown away.
75
+
148
76
  bundleId: z.string().nullable(),
149
77
  });
150
78
 
151
- /** Blocks do not nest. The vocabulary is closed on purpose. */
152
79
  export const blockSchema = z.discriminatedUnion('kind', [
153
80
  heroBlockSchema,
154
81
  gridBlockSchema,
@@ -167,77 +94,49 @@ export type BannerBlock = z.infer<typeof bannerBlockSchema>;
167
94
  export type CopyBlock = z.infer<typeof copyBlockSchema>;
168
95
  export type BundleBlock = z.infer<typeof bundleBlockSchema>;
169
96
 
170
- /**
171
- * Exactly what the model must return. Provenance — version, slot, latency, which
172
- * provider answered — is added by the server afterwards, and is deliberately not
173
- * part of the model's burden.
174
- */
175
97
  export const generatedSpecSchema = z.object({
176
98
  tone: z.enum(TONES),
177
99
  headline: z.string(),
178
100
  subheadline: z.string().nullable(),
179
101
  blocks: z.array(blockSchema),
180
- /**
181
- * One sentence on why this arrangement was chosen. For engineers reading
182
- * generation logs, not for shoppers; it is not rendered by default.
183
- */
102
+
184
103
  rationale: z.string(),
185
104
  });
186
105
  export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
106
+ export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;
187
107
 
188
- /** How a spec came to exist. Surfaced for benchmarking and observability. */
189
108
  export type SpecSource = 'llm' | 'cache' | 'fallback';
190
109
 
191
- /**
192
- * Why a component is not what the model would have produced.
193
- *
194
- * A closed set, like `SpecSource`: it is rendered into pages as
195
- * `data-rudra-degraded` and counted in dashboards, so a host needs to know what
196
- * it can receive without reading the generator.
197
- */
198
110
  export type DegradedReason =
199
- /** No provider was configured, so nothing was ever asked. */
200
111
  | 'no-provider'
201
- /** The provider errored, or threw before it reached the vendor. */
202
112
  | 'provider-error'
203
- /** The deadline fired before an answer arrived. */
204
113
  | 'timeout'
205
- /** An answer came back that did not satisfy the schema. */
206
114
  | 'invalid-generation'
207
- /** A usable answer reconciled down to nothing for this shopper. */
208
115
  | 'unusable-on-serve'
209
- /** The caller asked for the deterministic component on purpose. */
210
116
  | 'requested';
211
117
 
212
118
  export const SPEC_VERSION = '1' as const;
213
119
 
214
- /** A generated spec plus the provenance the server owns. This is what renders. */
215
120
  export interface ComponentSpec extends GeneratedSpec {
216
121
  specVersion: typeof SPEC_VERSION;
217
122
  slot: string;
218
123
  source: SpecSource;
219
- /** Epoch milliseconds at which the underlying generation completed. */
124
+
220
125
  generatedAt: number;
221
- /** Wall-clock milliseconds spent producing it, including any cache lookup. */
126
+
222
127
  latencyMs: number;
223
- /** Provider name, or null when no model was involved. */
128
+
224
129
  provider: string | null;
225
- /** Model identifier, or null when no model was involved. */
130
+
226
131
  model: string | null;
227
- /**
228
- * Why the deterministic component is showing instead of a generated one.
229
- * Present on every fallback, including the ones where no model was involved
230
- * at all — no provider configured, or the caller asked for it directly.
231
- */
132
+
232
133
  degradedReason?: DegradedReason;
233
134
  }
234
135
 
235
- /** Throws a `ZodError` if the value is not a well-formed generated spec. */
236
136
  export function parseGeneratedSpec(value: unknown): GeneratedSpec {
237
137
  return generatedSpecSchema.parse(value);
238
138
  }
239
139
 
240
- /** Non-throwing variant, for validating untrusted model output. */
241
- export function safeParseGeneratedSpec(value: unknown) {
140
+ export function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult {
242
141
  return generatedSpecSchema.safeParse(value);
243
142
  }
@@ -3,21 +3,6 @@ import type { SignalDigest } from './signal-digest.js';
3
3
  import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
4
4
  import type { TrackingInput } from './tracking-input.js';
5
5
 
6
- /**
7
- * The deterministic component — what renders when no model does.
8
- *
9
- * The manuscript names data latency as the central risk of moving
10
- * personalisation onto the server path: any delay in the recommendation engine
11
- * blocks the page. This module is the answer. It is pure, synchronous, and
12
- * cannot fail, so the server always has something correct to render — whether
13
- * the model is slow, erroring, rate-limited, or simply not configured.
14
- *
15
- * It reads the same digest and uses the same selector the model path does, so a
16
- * degraded render is a weaker version of the same decision rather than an
17
- * unrelated one. Only the presentation is fixed.
18
- */
19
-
20
- /** A featured lead only reads as deliberate when something follows it. */
21
6
  const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
22
7
 
23
8
  function headlineFor(digest: SignalDigest): { headline: string; subheadline: string | null } {
@@ -34,7 +19,6 @@ function headlineFor(digest: SignalDigest): { headline: string; subheadline: str
34
19
  return { headline: 'You might also like', subheadline: null };
35
20
  }
36
21
 
37
- /** Wide enough to fill, never wider. */
38
22
  function columnsFor(itemCount: number): 2 | 3 | 4 {
39
23
  if (itemCount >= 4) return 4;
40
24
  if (itemCount === 3) return 3;
@@ -51,12 +35,6 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
51
35
  };
52
36
  }
53
37
 
54
- /**
55
- * Builds a renderable spec from signals alone. Never throws. Returns a spec with
56
- * no blocks only when there is genuinely nothing in stock left to show, which
57
- * the renderer treats as "render nothing" — an empty recommendation region is
58
- * worse than none.
59
- */
60
38
  export function buildFallbackSpec(
61
39
  input: TrackingInput,
62
40
  digest: SignalDigest,
@@ -1,38 +1,34 @@
1
1
  import type { Block, GeneratedSpec, ProductReference } from './component-spec.js';
2
2
  import type { ProductPick } from './product-selection.js';
3
3
 
4
- // The model picks the shape. Selection picks the grid and carousel products and
5
- // what may be said about them, so a shared component still fits one shopper.
4
+ export interface FittedSpec {
5
+ spec: GeneratedSpec;
6
+ ourReasons: Map<string, string>;
7
+ }
8
+
6
9
  export function fitToShopper(
7
10
  spec: GeneratedSpec,
8
11
  picks: readonly ProductPick[],
9
12
  maxItems: number,
10
- ourReasons?: Map<string, string>,
11
- ): GeneratedSpec {
12
- // Picks are ordered best first. Every slot takes the next one.
13
+ ): FittedSpec {
13
14
  let next = 0;
14
15
  const limit = Math.min(picks.length, maxItems);
15
16
 
17
+ const ourReasons = new Map<string, string>();
16
18
  const blocks: Block[] = [];
17
19
  for (const block of spec.blocks) {
18
- // A hero is left alone. Its headline and body were written about the
19
- // product it names, so swapping the product would leave copy that describes
20
- // something else. Reconciliation drops the link if this shopper cannot see
21
- // that product, and the words stay.
22
-
23
20
  if (block.kind === 'grid' || block.kind === 'carousel') {
24
21
  const items: ProductReference[] = [];
25
22
  for (const item of block.items) {
26
- if (next >= limit) break; // shrink, never pad
23
+ if (next >= limit) break;
27
24
  const chosen = picks[next]!;
28
- // Every reason here is written by this library or handed to it by the
29
- // shop. None of them is the model's, whatever the model wrote in this slot.
30
- ourReasons?.set(chosen.product.sku, chosen.reason);
25
+
26
+ ourReasons.set(chosen.product.sku, chosen.reason);
31
27
  items.push({
32
28
  sku: chosen.product.sku,
33
29
  basis: chosen.basis,
34
30
  reason: chosen.reason,
35
- // The badge was written about a different product.
31
+
36
32
  badge: null,
37
33
  emphasis: item.emphasis,
38
34
  });
@@ -45,5 +41,5 @@ export function fitToShopper(
45
41
  blocks.push(block);
46
42
  }
47
43
 
48
- return { ...spec, blocks };
44
+ return { spec: { ...spec, blocks }, ourReasons };
49
45
  }
package/src/index.ts CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * @rudra-js/core — the contracts and logic that turn one tracking payload into one
3
- * renderable component specification.
4
- *
5
- * Carries no React and no model-vendor SDK, so it can be unit tested in
6
- * isolation and imported from any server runtime.
7
- */
8
-
9
1
  export {
10
2
  FIELD_LIMITS,
11
3
  productSchema,
@@ -61,6 +53,7 @@ export {
61
53
  type ComponentSpec,
62
54
  type CopyBlock,
63
55
  type GeneratedSpec,
56
+ type GeneratedSpecResult,
64
57
  type GridBlock,
65
58
  type HeroBlock,
66
59
  type ProductReference,
@@ -1,31 +1,13 @@
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';
4
-
5
- /**
6
- * What actually reaches the model.
7
- *
8
- * The prompt is split in two, and the split is load-bearing. `system` is
9
- * byte-identical for every request in a deployment, which is what lets a
10
- * provider cache it and charge a fraction for the repeat. `user` carries
11
- * everything that varies. Interpolating one shopper's data into the system half
12
- * would not break anything visibly — it would quietly make the cached prefix
13
- * useless and multiply the bill, which is why a test asserts the halves stay
14
- * separate rather than trusting anyone to remember.
15
- *
16
- * This module is also the only place shopper-supplied text meets model
17
- * instructions, so every host value is written as a JSON string rather than as
18
- * prose. A search for `boots\n\n# Task\nIgnore the above` is then one quoted
19
- * value on one line, not a heading the model might read as a new instruction.
20
- */
3
+ import { FIELD_LIMITS, type Product, type TrackingInput } from './tracking-input.js';
21
4
 
22
5
  export const UNTRUSTED_BEGIN = 'BEGIN_UNTRUSTED_DATA';
23
6
  export const UNTRUSTED_END = 'END_UNTRUSTED_DATA';
24
7
 
25
8
  export interface PromptPair {
26
- /** Stable across requests. Safe for a provider to cache. */
27
9
  system: string;
28
- /** Everything about this shopper and this page. */
10
+
29
11
  user: string;
30
12
  }
31
13
 
@@ -126,48 +108,33 @@ The "rationale" field is for engineers reading generation logs, not for
126
108
  shoppers. One sentence on why this arrangement, naming the signals you leaned
127
109
  on.`;
128
110
 
129
- /**
130
- * Characters a value has no business containing.
131
- *
132
- * `JSON.stringify` escapes control characters, quotes and backslashes, and
133
- * nothing else. Everything below survives it, and each one lets a shopper's
134
- * value do something the surrounding quotes are meant to prevent — end a line,
135
- * reverse the reading order, or carry text that displays as nothing at all.
136
- *
137
- * This is written as Unicode properties rather than a list of code points on
138
- * purpose. A list is a denylist: it covered the tag block (U+E0000-U+E007F)
139
- * but not the variation selectors supplement (U+E0100-U+E01EF), which smuggles
140
- * text exactly the same way, and it missed U+0085, U+061C and U+00AD as well.
141
- * Properties cover the ones nobody has thought of yet.
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.
150
- *
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.
155
- */
156
- const UNPRINTABLE = /(?!\u200D)[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\u{E0100}-\u{E01EF}]/gu;
157
-
158
- /**
159
- * Host-supplied text, written so it cannot introduce structure of its own.
160
- *
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.
164
- */
165
- const quote = (value: string) =>
166
- JSON.stringify(value).replace(UNPRINTABLE, (character) => {
111
+ const UNPRINTABLE =
112
+ /(?!\u{200D}|\u{FE0E}|\u{FE0F})[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
113
+
114
+ const MAX_QUOTED = FIELD_LIMITS.shortText * 2;
115
+
116
+ const escapeUnprintable = (text: string) =>
117
+ text.replace(UNPRINTABLE, (character) => {
167
118
  const codePoint = character.codePointAt(0)!;
168
119
  return `\\u{${codePoint.toString(16).toUpperCase()}}`;
169
120
  });
170
121
 
122
+ const quote = (value: string) => {
123
+ const escaped = escapeUnprintable(JSON.stringify(value));
124
+ if (escaped.length <= MAX_QUOTED) return escaped;
125
+
126
+ let length = 2;
127
+ let kept = '';
128
+ for (const character of value) {
129
+ const cost = escapeUnprintable(JSON.stringify(character)).length - 2;
130
+ if (length + cost > MAX_QUOTED) break;
131
+ length += cost;
132
+ kept += character;
133
+ }
134
+
135
+ return escapeUnprintable(JSON.stringify(kept));
136
+ };
137
+
171
138
  function section(heading: string, body: string | undefined): string | null {
172
139
  if (!body || body.length === 0) return null;
173
140
  return `${heading}: ${body}`;
@@ -207,7 +174,6 @@ function describeShopper(digest: SignalDigest): string {
207
174
  return lines.filter((line): line is string => line !== null).join('\n');
208
175
  }
209
176
 
210
- /** One candidate per line. Facts the model must not restate are left out. */
211
177
  function describeCandidate(product: Product): string {
212
178
  const parts = [quote(product.sku), quote(product.title), quote(product.category)];
213
179
  if (product.rating !== undefined) parts.push(`rated ${product.rating}`);
@@ -215,29 +181,8 @@ function describeCandidate(product: Product): string {
215
181
  return `- ${parts.join(' | ')}`;
216
182
  }
217
183
 
218
- /**
219
- * How many candidates reach the prompt.
220
- *
221
- * The payload contract caps the candidate list at 200, and every field on a
222
- * product at its own length — which multiplies out to a prompt far larger than
223
- * is sensible to send or pay for. The contract deliberately does not impose an
224
- * aggregate budget, on the grounds that trimming to fit is this layer's job.
225
- * This is that trim. The host's ordering is its merchandising priority, so the
226
- * first ones through are the ones it put first.
227
- */
228
184
  const MAX_CANDIDATES = 60;
229
185
 
230
- /**
231
- * The candidates the model is actually shown.
232
- *
233
- * An out-of-stock product is dropped during reconciliation whatever the model
234
- * does with it, so offering one only costs the shopper a slot.
235
- *
236
- * Exported because reconciliation checks the numerals the model writes against
237
- * the strings on this list, and a candidate it was never shown cannot be where
238
- * one came from. Two copies of "what the model saw" would drift, and the pair
239
- * that drifted would be the prompt and the screen reading it back.
240
- */
241
186
  export function offeredCandidates(input: TrackingInput): Product[] {
242
187
  return input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
243
188
  }
@@ -245,10 +190,6 @@ export function offeredCandidates(input: TrackingInput): Product[] {
245
190
  export function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair {
246
191
  const offered = offeredCandidates(input);
247
192
 
248
- // The markers are OWASP's labelled-block recommendation. They are safe as
249
- // boundaries because every value between them is quoted and stripped of
250
- // anything that could end a line, so no shopper value can occupy a line by
251
- // itself — which is the only way one could impersonate a marker.
252
193
  const user = `${UNTRUSTED_BEGIN}
253
194
 
254
195
  ## Shopper
@@ -3,35 +3,16 @@ import type { SignalDigest } from './signal-digest.js';
3
3
  import { neverRecommend } from './reconciliation.js';
4
4
  import type { Product, TrackingInput } from './tracking-input.js';
5
5
 
6
- /**
7
- * The deterministic selector — which products to show, in what order, and why.
8
- *
9
- * This is the half of the problem a language model is not needed for. Given the
10
- * same digest, it always returns the same picks, it cannot fail, and it costs
11
- * nothing. It exists for three reasons, in ascending order of importance:
12
- *
13
- * 1. It is what renders when the model is slow, erroring, or not configured.
14
- * 2. It supplies the candidate ordering the model is asked to work from.
15
- * 3. It is the control arm. If a generated component cannot be told apart from
16
- * this, the model has not earned its place, and the evaluation has to be
17
- * able to ask that question honestly.
18
- */
19
-
20
6
  export interface ProductPick {
21
7
  product: Product;
22
- /** Why this product, stated so reconciliation can check it. */
8
+
23
9
  basis: RecommendationBasis;
24
- /** How the basis reads to a shopper. The host's own sentence when it wrote one. */
10
+
25
11
  reason: string;
26
- /** Unnormalised. Only the ordering is meaningful. */
12
+
27
13
  score: number;
28
14
  }
29
15
 
30
- /**
31
- * How much each factor moves a product up the list. Relative sizes are what
32
- * matter: category affinity dominates, a revisit is nearly as strong, and
33
- * rating only separates products the signals cannot.
34
- */
35
16
  const SCORE_WEIGHTS = {
36
17
  category: 3,
37
18
  revisit: 1.5,
@@ -39,13 +20,10 @@ const SCORE_WEIGHTS = {
39
20
  tagOverlap: 0.6,
40
21
  } as const;
41
22
 
42
- /** Beyond this many shared tags, more overlap says nothing new. */
43
23
  const MAX_TAG_OVERLAP = 3;
44
24
 
45
- /** Assumed rating for a product the catalog does not rate. */
46
25
  const UNRATED = 3.5;
47
26
 
48
- /** Tags on the products this shopper has actually engaged with. */
49
27
  function engagedTags(input: TrackingInput, digest: SignalDigest): Set<string> {
50
28
  const engagedSkus = new Set([
51
29
  ...digest.likedSkus,
@@ -69,15 +47,6 @@ interface Evidence {
69
47
  hasCart: boolean;
70
48
  }
71
49
 
72
- /**
73
- * States why a product was picked, choosing the most specific claim the signals
74
- * actually support.
75
- *
76
- * Every branch has to be one reconciliation can verify — the selector is held to
77
- * the same standard as the model, and a basis it cannot support would be
78
- * downgraded there just the same. `popular` asserts nothing and is the honest
79
- * answer when nothing else holds.
80
- */
81
50
  function basisFor(
82
51
  product: Product,
83
52
  digest: SignalDigest,
@@ -98,22 +67,9 @@ function basisFor(
98
67
  return { basis: 'popular', reason: `Popular in ${product.category}` };
99
68
  }
100
69
 
101
- /**
102
- * Scores every eligible candidate and returns them best first.
103
- *
104
- * Ties break on SKU so the order is total: two runs over the same payload
105
- * produce the same list, which is what makes the control arm reproducible.
106
- */
107
- /** How the picks are ordered once the unplaceable ones are gone. */
108
70
  export type RankOrder = 'signals' | 'given';
109
71
 
110
72
  export interface SelectOptions {
111
- /**
112
- * `signals` scores each candidate and orders by that score. `given` keeps the
113
- * order you sent, for a shop whose own ranking is better than four weights.
114
- * Either way the exclusions and the stock check still apply, and every pick
115
- * still carries a basis reconciliation can verify.
116
- */
117
73
  rank?: RankOrder;
118
74
  }
119
75
 
@@ -125,11 +81,10 @@ export function selectProducts(
125
81
  const affinityByCategory = new Map(
126
82
  digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
127
83
  );
128
- // Normalised against the strongest affinity so the weights below mean the
129
- // same thing whether a shopper has two signals or two hundred.
84
+
130
85
  const strongestAffinity = Math.max(1, ...affinityByCategory.values());
131
86
  const tags = engagedTags(input, digest);
132
- const excluded = neverRecommend(digest);
87
+ const excluded = neverRecommend(input);
133
88
  const viewsBySku = new Map(digest.topViewed.map((viewed) => [viewed.sku, viewed.views]));
134
89
 
135
90
  const picks: ProductPick[] = [];
@@ -140,8 +95,7 @@ export function selectProducts(
140
95
  const categoryScore = (affinityByCategory.get(product.category) ?? 0) / strongestAffinity;
141
96
  const tagOverlap = product.tags.filter((tag) => tags.has(tag)).length;
142
97
  const ratingScore = (product.rating ?? UNRATED) / 5;
143
- // Something viewed and not bought is a strong re-surface signal, but it
144
- // saturates: the twentieth view means little more than the fifth.
98
+
145
99
  const revisitScore = Math.min(1, Math.log2(1 + (viewsBySku.get(product.sku) ?? 0)) / 3);
146
100
 
147
101
  const score =