@rudra-js/core 0.6.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 (66) hide show
  1. package/README.md +13 -12
  2. package/dist/claim-screening.d.ts +0 -23
  3. package/dist/claim-screening.d.ts.map +1 -1
  4. package/dist/claim-screening.js +0 -69
  5. package/dist/claim-screening.js.map +1 -1
  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 +10 -52
  9. package/dist/component-generator.js.map +1 -1
  10. package/dist/component-spec.d.ts +2 -110
  11. package/dist/component-spec.d.ts.map +1 -1
  12. package/dist/component-spec.js +0 -78
  13. package/dist/component-spec.js.map +1 -1
  14. package/dist/fallback-component.d.ts +0 -9
  15. package/dist/fallback-component.d.ts.map +1 -1
  16. package/dist/fallback-component.js +0 -11
  17. package/dist/fallback-component.js.map +1 -1
  18. package/dist/fit-to-shopper.d.ts.map +1 -1
  19. package/dist/fit-to-shopper.js +1 -11
  20. package/dist/fit-to-shopper.js.map +1 -1
  21. package/dist/index.d.ts +0 -7
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -7
  24. package/dist/index.js.map +1 -1
  25. package/dist/model-prompt.d.ts +0 -29
  26. package/dist/model-prompt.d.ts.map +1 -1
  27. package/dist/model-prompt.js +0 -79
  28. package/dist/model-prompt.js.map +1 -1
  29. package/dist/product-selection.d.ts +0 -23
  30. package/dist/product-selection.d.ts.map +1 -1
  31. package/dist/product-selection.js +0 -19
  32. package/dist/product-selection.js.map +1 -1
  33. package/dist/provider.d.ts +0 -48
  34. package/dist/provider.d.ts.map +1 -1
  35. package/dist/provider.js +0 -5
  36. package/dist/provider.js.map +1 -1
  37. package/dist/reconciliation.d.ts +1 -44
  38. package/dist/reconciliation.d.ts.map +1 -1
  39. package/dist/reconciliation.js +2 -109
  40. package/dist/reconciliation.js.map +1 -1
  41. package/dist/signal-digest.d.ts +0 -27
  42. package/dist/signal-digest.d.ts.map +1 -1
  43. package/dist/signal-digest.js +1 -50
  44. package/dist/signal-digest.js.map +1 -1
  45. package/dist/spec-cache.d.ts +6 -80
  46. package/dist/spec-cache.d.ts.map +1 -1
  47. package/dist/spec-cache.js +1 -54
  48. package/dist/spec-cache.js.map +1 -1
  49. package/dist/tracking-input.d.ts +0 -49
  50. package/dist/tracking-input.d.ts.map +1 -1
  51. package/dist/tracking-input.js +0 -102
  52. package/dist/tracking-input.js.map +1 -1
  53. package/package.json +7 -6
  54. package/src/claim-screening.ts +17 -69
  55. package/src/component-generator.ts +24 -133
  56. package/src/component-spec.ts +15 -103
  57. package/src/fallback-component.ts +0 -11
  58. package/src/fit-to-shopper.ts +3 -12
  59. package/src/index.ts +0 -8
  60. package/src/model-prompt.ts +1 -82
  61. package/src/product-selection.ts +5 -43
  62. package/src/provider.ts +4 -53
  63. package/src/reconciliation.ts +12 -112
  64. package/src/signal-digest.ts +5 -64
  65. package/src/spec-cache.ts +8 -98
  66. package/src/tracking-input.ts +11 -108
@@ -1,94 +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
- /** Checked against the shopper's signals, so the model cannot assert a pick it did not earn. */
25
+
81
26
  basis: z.enum(RECOMMENDATION_BASES),
82
- /** How the basis is phrased, e.g. "Pairs with the boots you bought". Null once unverifiable. */
27
+
83
28
  reason: z.string().nullable(),
84
- /** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
29
+
85
30
  badge: z.string().nullable(),
86
31
  emphasis: z.enum(EMPHASIS),
87
32
  });
88
33
  export type ProductReference = z.infer<typeof productReferenceSchema>;
89
34
  export type RecommendationBasis = (typeof RECOMMENDATION_BASES)[number];
90
35
 
91
- /** One large featured statement, optionally anchored to a single product. */
92
36
  const heroBlockSchema = z.object({
93
37
  kind: z.literal('hero'),
94
38
  headline: z.string(),
@@ -97,7 +41,6 @@ const heroBlockSchema = z.object({
97
41
  ctaLabel: z.string().nullable(),
98
42
  });
99
43
 
100
- /** The general-purpose choice when several products are comparably relevant. */
101
44
  const gridBlockSchema = z.object({
102
45
  kind: z.literal('grid'),
103
46
  title: z.string().nullable(),
@@ -105,14 +48,12 @@ const gridBlockSchema = z.object({
105
48
  items: z.array(productReferenceSchema),
106
49
  });
107
50
 
108
- /** A horizontally scanned row, for when order implies a ranking. */
109
51
  const carouselBlockSchema = z.object({
110
52
  kind: z.literal('carousel'),
111
53
  title: z.string().nullable(),
112
54
  items: z.array(productReferenceSchema),
113
55
  });
114
56
 
115
- /** A single line of merchandising copy. */
116
57
  const bannerBlockSchema = z.object({
117
58
  kind: z.literal('banner'),
118
59
  tone: z.enum(BANNER_TONES),
@@ -120,24 +61,21 @@ const bannerBlockSchema = z.object({
120
61
  ctaLabel: z.string().nullable(),
121
62
  });
122
63
 
123
- /** Short editorial prose, for explaining the theme of a selection. */
124
64
  const copyBlockSchema = z.object({
125
65
  kind: z.literal('copy'),
126
66
  title: z.string().nullable(),
127
67
  body: z.string(),
128
68
  });
129
69
 
130
- /** A set the shop sells together. The shop picks which one; this only asks for it. */
131
70
  const bundleBlockSchema = z.object({
132
71
  kind: z.literal('bundle'),
133
72
  title: z.string().nullable(),
134
73
  body: z.string().nullable(),
135
74
  ctaLabel: z.string().nullable(),
136
- // Filled in per request. Whatever the model puts here is thrown away.
75
+
137
76
  bundleId: z.string().nullable(),
138
77
  });
139
78
 
140
- /** Blocks do not nest. The vocabulary is closed on purpose. */
141
79
  export const blockSchema = z.discriminatedUnion('kind', [
142
80
  heroBlockSchema,
143
81
  gridBlockSchema,
@@ -156,75 +94,49 @@ export type BannerBlock = z.infer<typeof bannerBlockSchema>;
156
94
  export type CopyBlock = z.infer<typeof copyBlockSchema>;
157
95
  export type BundleBlock = z.infer<typeof bundleBlockSchema>;
158
96
 
159
- /**
160
- * Exactly what the model must return. Provenance — version, slot, latency, which
161
- * provider answered — is added by the server afterwards, and is deliberately not
162
- * part of the model's burden.
163
- */
164
97
  export const generatedSpecSchema = z.object({
165
98
  tone: z.enum(TONES),
166
99
  headline: z.string(),
167
100
  subheadline: z.string().nullable(),
168
101
  blocks: z.array(blockSchema),
169
- /** One sentence for generation logs, not for shoppers. Not rendered by default. */
102
+
170
103
  rationale: z.string(),
171
104
  });
172
105
  export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
173
106
  export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;
174
107
 
175
- /** How a spec came to exist. Surfaced for benchmarking and observability. */
176
108
  export type SpecSource = 'llm' | 'cache' | 'fallback';
177
109
 
178
- /**
179
- * Why a component is not what the model would have produced.
180
- *
181
- * A closed set, like `SpecSource`: it is rendered into pages as
182
- * `data-rudra-degraded` and counted in dashboards, so a host needs to know what
183
- * it can receive without reading the generator.
184
- */
185
110
  export type DegradedReason =
186
- /** No provider was configured, so nothing was ever asked. */
187
111
  | 'no-provider'
188
- /** The provider errored, or threw before it reached the vendor. */
189
112
  | 'provider-error'
190
- /** The deadline fired before an answer arrived. */
191
113
  | 'timeout'
192
- /** An answer came back that did not satisfy the schema. */
193
114
  | 'invalid-generation'
194
- /** A usable answer reconciled down to nothing for this shopper. */
195
115
  | 'unusable-on-serve'
196
- /** The caller asked for the deterministic component on purpose. */
197
116
  | 'requested';
198
117
 
199
118
  export const SPEC_VERSION = '1' as const;
200
119
 
201
- /** A generated spec plus the provenance the server owns. This is what renders. */
202
120
  export interface ComponentSpec extends GeneratedSpec {
203
121
  specVersion: typeof SPEC_VERSION;
204
122
  slot: string;
205
123
  source: SpecSource;
206
- /** Epoch milliseconds at which the underlying generation completed. */
124
+
207
125
  generatedAt: number;
208
- /** Wall-clock milliseconds spent producing it, including any cache lookup. */
126
+
209
127
  latencyMs: number;
210
- /** Provider name, or null when no model was involved. */
128
+
211
129
  provider: string | null;
212
- /** Model identifier, or null when no model was involved. */
130
+
213
131
  model: string | null;
214
- /**
215
- * Why the deterministic component is showing instead of a generated one.
216
- * Present on every fallback, including the ones where no model was involved
217
- * at all — no provider configured, or the caller asked for it directly.
218
- */
132
+
219
133
  degradedReason?: DegradedReason;
220
134
  }
221
135
 
222
- /** Throws a `ZodError` if the value is not a well-formed generated spec. */
223
136
  export function parseGeneratedSpec(value: unknown): GeneratedSpec {
224
137
  return generatedSpecSchema.parse(value);
225
138
  }
226
139
 
227
- /** Non-throwing variant, for validating untrusted model output. */
228
140
  export function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult {
229
141
  return generatedSpecSchema.safeParse(value);
230
142
  }
@@ -3,7 +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
- /** A featured lead only reads as deliberate when something follows it. */
7
6
  const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
8
7
 
9
8
  function headlineFor(digest: SignalDigest): { headline: string; subheadline: string | null } {
@@ -20,7 +19,6 @@ function headlineFor(digest: SignalDigest): { headline: string; subheadline: str
20
19
  return { headline: 'You might also like', subheadline: null };
21
20
  }
22
21
 
23
- /** Wide enough to fill, never wider. */
24
22
  function columnsFor(itemCount: number): 2 | 3 | 4 {
25
23
  if (itemCount >= 4) return 4;
26
24
  if (itemCount === 3) return 3;
@@ -37,15 +35,6 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
37
35
  };
38
36
  }
39
37
 
40
- /**
41
- * Builds a renderable spec from signals alone. Never throws. Returns a spec with
42
- * no blocks only when there is genuinely nothing in stock left to show, which
43
- * the renderer treats as "render nothing" — an empty recommendation region is
44
- * worse than none.
45
- *
46
- * Reads the same digest and selector the model path does, so a degraded render is
47
- * a weaker version of the same decision rather than an unrelated one.
48
- */
49
38
  export function buildFallbackSpec(
50
39
  input: TrackingInput,
51
40
  digest: SignalDigest,
@@ -6,38 +6,29 @@ export interface FittedSpec {
6
6
  ourReasons: Map<string, string>;
7
7
  }
8
8
 
9
- // The model picks the shape. Selection picks the grid and carousel products and
10
- // what may be said about them, so a shared component still fits one shopper.
11
9
  export function fitToShopper(
12
10
  spec: GeneratedSpec,
13
11
  picks: readonly ProductPick[],
14
12
  maxItems: number,
15
13
  ): FittedSpec {
16
- // Picks are ordered best first. Every slot takes the next one.
17
14
  let next = 0;
18
15
  const limit = Math.min(picks.length, maxItems);
19
16
 
20
17
  const ourReasons = new Map<string, string>();
21
18
  const blocks: Block[] = [];
22
19
  for (const block of spec.blocks) {
23
- // A hero is left alone. Its headline and body were written about the
24
- // product it names, so swapping the product would leave copy that describes
25
- // something else. Reconciliation drops the link if this shopper cannot see
26
- // that product, and the words stay.
27
-
28
20
  if (block.kind === 'grid' || block.kind === 'carousel') {
29
21
  const items: ProductReference[] = [];
30
22
  for (const item of block.items) {
31
- if (next >= limit) break; // shrink, never pad
23
+ if (next >= limit) break;
32
24
  const chosen = picks[next]!;
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.
25
+
35
26
  ourReasons.set(chosen.product.sku, chosen.reason);
36
27
  items.push({
37
28
  sku: chosen.product.sku,
38
29
  basis: chosen.basis,
39
30
  reason: chosen.reason,
40
- // The badge was written about a different product.
31
+
41
32
  badge: null,
42
33
  emphasis: item.emphasis,
43
34
  });
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,
@@ -2,30 +2,12 @@ import { BANNER_TONES, EMPHASIS, RECOMMENDATION_BASES, TONES } from './component
2
2
  import type { SignalDigest } from './signal-digest.js';
3
3
  import { FIELD_LIMITS, type Product, type TrackingInput } from './tracking-input.js';
4
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
- */
21
-
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,45 +108,9 @@ 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
- * 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.
149
- *
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
- */
156
111
  const UNPRINTABLE =
157
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;
158
113
 
159
- /**
160
- * How long one quoted value may be once escaped.
161
- *
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.
167
- */
168
114
  const MAX_QUOTED = FIELD_LIMITS.shortText * 2;
169
115
 
170
116
  const escapeUnprintable = (text: string) =>
@@ -173,12 +119,10 @@ const escapeUnprintable = (text: string) =>
173
119
  return `\\u{${codePoint.toString(16).toUpperCase()}}`;
174
120
  });
175
121
 
176
- /** Host-supplied text, written so it cannot introduce structure of its own. */
177
122
  const quote = (value: string) => {
178
123
  const escaped = escapeUnprintable(JSON.stringify(value));
179
124
  if (escaped.length <= MAX_QUOTED) return escaped;
180
125
 
181
- // Cut whole characters, so the result cannot end inside an escape sequence.
182
126
  let length = 2;
183
127
  let kept = '';
184
128
  for (const character of value) {
@@ -237,29 +181,8 @@ function describeCandidate(product: Product): string {
237
181
  return `- ${parts.join(' | ')}`;
238
182
  }
239
183
 
240
- /**
241
- * How many candidates reach the prompt.
242
- *
243
- * The payload contract caps the candidate list at 200, and every field on a
244
- * product at its own length — which multiplies out to a prompt far larger than
245
- * is sensible to send or pay for. The contract deliberately does not impose an
246
- * aggregate budget, on the grounds that trimming to fit is this layer's job.
247
- * This is that trim. The host's ordering is its merchandising priority, so the
248
- * first ones through are the ones it put first.
249
- */
250
184
  const MAX_CANDIDATES = 60;
251
185
 
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
186
  export function offeredCandidates(input: TrackingInput): Product[] {
264
187
  return input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
265
188
  }
@@ -267,10 +190,6 @@ export function offeredCandidates(input: TrackingInput): Product[] {
267
190
  export function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair {
268
191
  const offered = offeredCandidates(input);
269
192
 
270
- // The markers are OWASP's labelled-block recommendation. They are safe as
271
- // boundaries because every value between them is quoted and stripped of
272
- // anything that could end a line, so no shopper value can occupy a line by
273
- // itself — which is the only way one could impersonate a marker.
274
193
  const user = `${UNTRUSTED_BEGIN}
275
194
 
276
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,7 +20,6 @@ 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
25
  const UNRATED = 3.5;
@@ -67,15 +47,6 @@ interface Evidence {
67
47
  hasCart: boolean;
68
48
  }
69
49
 
70
- /**
71
- * States why a product was picked, choosing the most specific claim the signals
72
- * actually support.
73
- *
74
- * Every branch has to be one reconciliation can verify — the selector is held to
75
- * the same standard as the model, and a basis it cannot support would be
76
- * downgraded there just the same. `popular` asserts nothing and is the honest
77
- * answer when nothing else holds.
78
- */
79
50
  function basisFor(
80
51
  product: Product,
81
52
  digest: SignalDigest,
@@ -96,16 +67,9 @@ function basisFor(
96
67
  return { basis: 'popular', reason: `Popular in ${product.category}` };
97
68
  }
98
69
 
99
- /** How the picks are ordered once the unplaceable ones are gone. */
100
70
  export type RankOrder = 'signals' | 'given';
101
71
 
102
72
  export interface SelectOptions {
103
- /**
104
- * `signals` scores each candidate and orders by that score. `given` keeps the
105
- * order you sent, for a shop whose own ranking is better than four weights.
106
- * Either way the exclusions and the stock check still apply, and every pick
107
- * still carries a basis reconciliation can verify.
108
- */
109
73
  rank?: RankOrder;
110
74
  }
111
75
 
@@ -117,8 +81,7 @@ export function selectProducts(
117
81
  const affinityByCategory = new Map(
118
82
  digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
119
83
  );
120
- // Normalised against the strongest affinity so the weights below mean the
121
- // same thing whether a shopper has two signals or two hundred.
84
+
122
85
  const strongestAffinity = Math.max(1, ...affinityByCategory.values());
123
86
  const tags = engagedTags(input, digest);
124
87
  const excluded = neverRecommend(input);
@@ -132,8 +95,7 @@ export function selectProducts(
132
95
  const categoryScore = (affinityByCategory.get(product.category) ?? 0) / strongestAffinity;
133
96
  const tagOverlap = product.tags.filter((tag) => tags.has(tag)).length;
134
97
  const ratingScore = (product.rating ?? UNRATED) / 5;
135
- // Something viewed and not bought is a strong re-surface signal, but it
136
- // saturates: the twentieth view means little more than the fifth.
98
+
137
99
  const revisitScore = Math.min(1, Math.log2(1 + (viewsBySku.get(product.sku) ?? 0)) / 3);
138
100
 
139
101
  const score =
package/src/provider.ts CHANGED
@@ -1,16 +1,6 @@
1
1
  import type { z } from 'zod';
2
2
  import type { GeneratedSpec } from './component-spec.js';
3
3
 
4
- /**
5
- * The language-model port.
6
- *
7
- * `@rudra-js/core` depends on no vendor SDK. Adapters live in their own packages,
8
- * so a host installs exactly one and the rest never reach its dependency tree.
9
- * Anything satisfying this interface works — a hosted API, a self-hosted model,
10
- * an in-tenancy deployment, or a recorded fixture.
11
- */
12
-
13
- /** Reported by an adapter for cost accounting. Never used for control flow. */
14
4
  export interface TokenUsage {
15
5
  inputTokens?: number;
16
6
  outputTokens?: number;
@@ -19,21 +9,12 @@ export interface TokenUsage {
19
9
  }
20
10
 
21
11
  export interface ProviderRequest {
22
- /**
23
- * Stable across every request in a deployment. Adapters that support prompt
24
- * caching should mark this as the cached prefix; interpolating anything
25
- * per-shopper into it would silently destroy the cache hit rate.
26
- */
27
12
  system: string;
28
- /** Per-request content. Must not be merged into the cached prefix. */
13
+
29
14
  user: string;
30
- /**
31
- * The schema the response must satisfy. Adapters convert it with their own
32
- * SDK helper, so there is no hand-maintained JSON Schema to drift out of
33
- * sync with the one `component-spec` defines.
34
- */
15
+
35
16
  schema: z.ZodType<GeneratedSpec>;
36
- /** Fires when the caller's budget elapses. An adapter must stop work. */
17
+
37
18
  signal: AbortSignal;
38
19
  }
39
20
 
@@ -42,48 +23,18 @@ export interface ProviderResult {
42
23
  usage?: TokenUsage;
43
24
  }
44
25
 
45
- /**
46
- * What an adapter promises.
47
- *
48
- * Three obligations, none of which the caller can verify from the outside, so
49
- * they are stated here rather than assumed:
50
- *
51
- * 1. Return a parsed object, not a string. Turning model output into a
52
- * `GeneratedSpec` is the adapter's job, because only it knows what its
53
- * structured-output mode returns.
54
- * 2. Throw on failure — a refusal, a transport error, an unparseable
55
- * response. Never return a partial or invented spec. The caller treats a
56
- * throw as "use the deterministic component", which is always safe; a
57
- * fabricated spec is not.
58
- * 3. Respect `signal`, in both directions: do not start work when it is
59
- * already aborted, and stop when it aborts mid-flight. The caller races
60
- * the call against its own timeout regardless, so ignoring it does not
61
- * hold a page open — but it does leave work running and billing after
62
- * nobody is waiting, and it answers a caller that has already given up.
63
- *
64
- * `AbortSignal.throwIfAborted()` is the whole of the first half.
65
- *
66
- * The caller re-validates whatever comes back. An adapter that returns
67
- * something malformed is a bug, not a security hole.
68
- */
69
26
  export interface ComponentProvider {
70
- /** Short identifier recorded on every generated spec, e.g. 'anthropic'. */
71
27
  readonly name: string;
72
- /** Concrete model identifier, e.g. 'claude-opus-5'. */
28
+
73
29
  readonly model: string;
74
30
  generate(request: ProviderRequest): Promise<ProviderResult>;
75
31
  }
76
32
 
77
- /** A provider that returns a fixed spec and never touches the network. */
78
33
  export function createFixedSpecProvider(spec: GeneratedSpec): ComponentProvider {
79
34
  return {
80
35
  name: 'fixed',
81
36
  model: 'none',
82
37
  generate: async ({ signal }) => {
83
- // Nothing here is slow enough to need cancelling, which is exactly why it
84
- // is worth checking: the obligation is not "cancel your work", it is "do
85
- // not answer a caller that has already given up". A reference
86
- // implementation that skips the cheap half teaches the wrong lesson.
87
38
  signal.throwIfAborted();
88
39
  return { spec };
89
40
  },