@rudra-js/core 0.3.1 → 0.5.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.
@@ -1,3 +1,5 @@
1
+ import { verify } from '@rudra-js/attested';
2
+ import { offeredCandidates } from './model-prompt.js';
1
3
  import type {
2
4
  Block,
3
5
  GeneratedSpec,
@@ -152,14 +154,11 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
152
154
  * model writes is checked here — a heading is not a safer place for a claim
153
155
  * than the small print under it.
154
156
  *
155
- * A claim is about money, a customer score, when it arrives, or how many are
156
- * left. A specification is not a claim, even when it has a number or a
157
- * percentage in it: "100% recycled nylon", "a comfort rating of -5C" and
158
- * "arrives flat-packed" are all things a shop can say about the product itself,
159
- * and they stay. That is why almost every rule below needs a second word beside
160
- * the first — "20% off", not "20%"; "rated 4.8", not "rated". A careful
161
- * rewording will get past this, and that is the trade we want: missing one
162
- * claim is better than deleting honest copy on every page.
157
+ * These patterns are the first of three passes and the only one that names which
158
+ * of the five kinds a sentence claimed, so they read the domain: "20% off", not
159
+ * "20%"; "rated 4.8", not "rated". A specification walks past all of them and is
160
+ * then weighed by @rudra-js/attested, which reads no domain at all and asks only
161
+ * whether the shop supplied the number.
163
162
  *
164
163
  * One rule per line, because each line is a separate judgement about where the
165
164
  * boundary sits and each one wants its own reason written next to it.
@@ -184,14 +183,20 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
184
183
  /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
185
184
  // How many other people bought or liked it is a customer claim too.
186
185
  /\bbest[\s-]?sell(?:er|ers|ing)\b/,
186
+ // The words a model reaches for when "best" is banned. Each branch carries its own
187
+ // \b — one in front of the group demands a word character before the #, never there.
188
+ /(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
187
189
  /\bloved by (?:thousands|hundreds|millions|\d)/,
188
190
  ],
189
191
  },
190
192
  {
191
193
  kind: 'price',
192
194
  patterns: [
193
- /[$£€¥₹]\s?\d/,
195
+ // No digit beside it: asking for one let "$thirty-nine and it is yours" through.
196
+ /[$£€¥₹]/,
194
197
  /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
198
+ // The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
199
+ /\bdollars?\b|\beuros?\b/,
195
200
  // The same codes on the other side of the number: "USD 20", "EUR 5.99".
196
201
  /\b(?:usd|eur|gbp)\s?\d/,
197
202
  // The krona is spelled out rather than drawn, so it needs a number beside it.
@@ -255,12 +260,170 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
255
260
  },
256
261
  ];
257
262
 
263
+ /**
264
+ * Letters that render as a Latin letter but are not one. A Cyrillic о in
265
+ * "in stоck" would otherwise carry a stock claim straight past the rule written
266
+ * for it, and the shopper would read the claim anyway.
267
+ */
268
+ const CONFUSABLES: Record<string, string> = {
269
+ а: 'a',
270
+ в: 'b',
271
+ е: 'e',
272
+ к: 'k',
273
+ м: 'm',
274
+ н: 'h',
275
+ о: 'o',
276
+ р: 'p',
277
+ с: 'c',
278
+ т: 't',
279
+ х: 'x',
280
+ у: 'y',
281
+ ѕ: 's',
282
+ і: 'i',
283
+ ј: 'j',
284
+ ԁ: 'd',
285
+ һ: 'h',
286
+ ӏ: 'l',
287
+ α: 'a',
288
+ ε: 'e',
289
+ ι: 'i',
290
+ κ: 'k',
291
+ ν: 'v',
292
+ ο: 'o',
293
+ ρ: 'p',
294
+ τ: 't',
295
+ υ: 'u',
296
+ χ: 'x',
297
+ ϲ: 'c',
298
+ };
299
+
300
+ /**
301
+ * Characters that render as nothing: format characters, default-ignorable code
302
+ * points and the controls. Cf and Default_Ignorable each hold characters the
303
+ * other does not.
304
+ *
305
+ * The same class @rudra-js/attested strips in its own `hidden.ts`. The second copy stays
306
+ * now that core calls attested, because it feeds core's own patterns and those reach two
307
+ * things attested's phrase list does not hold: "20% οff" spelled with a Greek omicron,
308
+ * and "PRİCED to move". Widening one and not the other is still the mistake to watch for.
309
+ */
310
+ const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
311
+
312
+ /**
313
+ * The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
314
+ *
315
+ * No test pins this line, and none can: clamping collapses every run of whitespace
316
+ * before a claim is screened, so nothing here ever sees one. It is kept so the class
317
+ * reads the same in both copies.
318
+ */
319
+ const SPACING = /[\t\n\v\f\r\u0085]/;
320
+
321
+ /**
322
+ * Marks that hang on the character before them instead of taking a column of their
323
+ * own. Dropped after composition, so an accented e keeps its accent while an
324
+ * overline, which composes with nothing, cannot hide a word from its own rule.
325
+ */
326
+ const MARKS = /[\p{Mn}\p{Me}]/gu;
327
+
328
+ /**
329
+ * One shape for the text before the patterns read it, so a word that renders as
330
+ * "in stock" is read as "in stock" however it was spelled.
331
+ *
332
+ * Only the reading is normalised. What renders is what the model wrote.
333
+ */
334
+ function normaliseForClaims(text: string): string {
335
+ const lowered = text
336
+ .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
337
+ .normalize('NFKC')
338
+ .toLowerCase()
339
+ .normalize('NFC')
340
+ .replace(MARKS, '');
341
+
342
+ let folded = '';
343
+ for (const char of lowered) folded += CONFUSABLES[char] ?? char;
344
+ return folded;
345
+ }
346
+
347
+ /**
348
+ * Wording attested bans that this project has already ruled is not a claim. An allowance
349
+ * forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
350
+ * clearance for" — forgives whatever follows it. Exported so a test can hold the list
351
+ * against attested's own.
352
+ */
353
+ export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
354
+
355
+ /** Only a string with one of these in it can stand behind a numeral. */
356
+ const DIGIT = /\p{Nd}/u;
357
+
358
+ /** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
359
+ const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
360
+
361
+ const NO_FACTS: readonly string[] = [];
362
+
363
+ /** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
364
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
365
+
366
+ /** Wide enough for every finite JavaScript number, and for anything a shop sells. */
367
+ const MAX_EXPONENT = 1000;
368
+
369
+ // `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
370
+ // attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
371
+ function canLayOut(fact: string): boolean {
372
+ const match = BARE_EXPONENT.exec(fact.trim());
373
+ if (match === null) return true;
374
+ return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
375
+ }
376
+
377
+ /**
378
+ * The numbers the shop published about one product: its category and tags, only the strings
379
+ * carrying a digit. Exported so a test can pin what never reaches attested.
380
+ */
381
+ export function productFacts(product: Product): string[] {
382
+ const facts: string[] = [];
383
+ if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
384
+ for (const tag of product.tags) {
385
+ if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
386
+ }
387
+ return facts;
388
+ }
389
+
390
+ interface HostFacts {
391
+ pooled: string[];
392
+ bySku: Map<string, string[]>;
393
+ }
394
+
395
+ /**
396
+ * The shop's own numbers for this request, for attested to check numerals against.
397
+ *
398
+ * `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
399
+ * means a product the model never saw. A title and a rating it is shown but told never to
400
+ * repeat, so a numeral out of one is one it was told not to write. The browsed category comes
401
+ * from the request rather than the catalogue, so a host passing a URL segment through would
402
+ * hand this list to whoever types the URL.
403
+ *
404
+ * `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
405
+ * written about another. A `reason` and a `badge` read `bySku`, because they sit under a
406
+ * named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
407
+ */
408
+ function hostFacts(input: TrackingInput): HostFacts {
409
+ const pooled = new Set<string>();
410
+ const bySku = new Map<string, string[]>();
411
+
412
+ for (const product of offeredCandidates(input)) {
413
+ const own = productFacts(product);
414
+ bySku.set(product.sku, own);
415
+ for (const fact of own) pooled.add(fact);
416
+ }
417
+
418
+ return { pooled: [...pooled], bySku };
419
+ }
420
+
258
421
  /** Names the first forbidden claim the text makes, or null when it makes none. */
259
422
  function claimIn(text: string): string | null {
260
- const lower = text.toLowerCase();
423
+ const normalised = normaliseForClaims(text);
261
424
  for (const claim of CLAIM_PATTERNS) {
262
425
  for (const pattern of claim.patterns) {
263
- if (pattern.test(lower)) return claim.kind;
426
+ if (pattern.test(normalised)) return claim.kind;
264
427
  }
265
428
  }
266
429
  return null;
@@ -274,13 +437,15 @@ function claimIn(text: string): string | null {
274
437
  * through every function. The budget and the de-duplication set are global to a
275
438
  * spec, not to a block, which is the part that is easy to get wrong.
276
439
  */
277
- function createPlacementTracker(maxItems: number) {
440
+ function createPlacementTracker(maxItems: number, facts: HostFacts) {
278
441
  const placedSkus = new Set<string>();
279
442
  const violations: string[] = [];
280
443
  let remaining = maxItems;
281
444
 
282
445
  return {
283
446
  violations,
447
+ facts: facts.pooled,
448
+ factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
284
449
  get remaining() {
285
450
  return remaining;
286
451
  },
@@ -303,19 +468,41 @@ type PlacementTracker = ReturnType<typeof createPlacementTracker>;
303
468
  * Runs after clamping, so what is screened is exactly what would have rendered.
304
469
  * Dropping means what it means everywhere else here: this field becomes null
305
470
  * and the rest of the block carries on.
471
+ *
472
+ * Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
473
+ * is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
474
+ * is a second denylist and the weakest, so it answers last. `facts` defaults to the pooled
475
+ * list, and a field that names one product passes that product's own.
306
476
  */
307
477
  function screenClaim(
308
478
  value: string | null,
309
479
  field: string,
310
480
  tracker: PlacementTracker,
481
+ facts: readonly string[] = tracker.facts,
311
482
  ): string | null {
312
483
  if (value === null) return null;
313
484
 
314
485
  const kind = claimIn(value);
315
- if (kind === null) return value;
486
+ if (kind !== null) {
487
+ tracker.record(`unverifiable-claim:${kind}:${field}`);
488
+ return null;
489
+ }
316
490
 
317
- tracker.record(`unverifiable-claim:${kind}:${field}`);
318
- return null;
491
+ // Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
492
+ // and reading the fact list to prove that again per field is most of what this costs.
493
+ const weighed = NUMERAL.test(value) ? facts : NO_FACTS;
494
+
495
+ const result = verify(value, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
496
+ if (!result.quantity.supported) {
497
+ tracker.record(`unverifiable-claim:quantity:${field}`);
498
+ return null;
499
+ }
500
+ if (!result.wording.supported) {
501
+ tracker.record(`unverifiable-claim:wording:${field}`);
502
+ return null;
503
+ }
504
+
505
+ return value;
319
506
  }
320
507
 
321
508
  /**
@@ -356,7 +543,7 @@ function reconcileItems(
356
543
  candidatesBySku: Map<string, Product>,
357
544
  digest: SignalDigest,
358
545
  tracker: PlacementTracker,
359
- hostReasonSkus: ReadonlySet<string>,
546
+ ourReasons: ReadonlyMap<string, string>,
360
547
  ): ProductReference[] {
361
548
  const kept: ProductReference[] = [];
362
549
 
@@ -376,8 +563,10 @@ function reconcileItems(
376
563
  const hasSupportedBasis = verifyBasis(item.basis, product, digest);
377
564
  if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
378
565
 
379
- const isHostReason =
380
- item.reason !== null && hostReasonSkus.has(item.sku) && item.reason === product.reason;
566
+ // Our sentence, not the model's. It has to match, so a lookalike is still screened.
567
+ const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
568
+
569
+ const own = tracker.factsFor(item.sku);
381
570
 
382
571
  kept.push({
383
572
  sku: item.sku,
@@ -385,14 +574,19 @@ function reconcileItems(
385
574
  // The prose exists to state the basis. If the basis did not hold, the
386
575
  // prose is a claim we just decided is untrue.
387
576
  reason: hasSupportedBasis
388
- ? isHostReason
577
+ ? isOurs
389
578
  ? clampNullable(item.reason, CLAMP.reason)
390
- : screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
579
+ : screenClaim(
580
+ clampNullable(item.reason, CLAMP.reason),
581
+ `reason:${item.sku}`,
582
+ tracker,
583
+ own,
584
+ )
391
585
  : null,
392
586
  // A badge is the shortest, loudest text on the card, and the schema's own
393
587
  // example for it was "Back in stock" — a stock claim. It renders, so it is
394
588
  // read for claims like every other sentence the model writes.
395
- badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
589
+ badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
396
590
  emphasis: item.emphasis,
397
591
  });
398
592
  }
@@ -483,7 +677,7 @@ export function bundleForShopper(
483
677
  ): Bundle | undefined {
484
678
  const allowlist = buildAllowlist(input, digest);
485
679
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
486
- const tracker = createPlacementTracker(digest.maxItems);
680
+ const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
487
681
 
488
682
  for (const sku of spokenFor) tracker.place(sku);
489
683
 
@@ -525,7 +719,7 @@ function reconcileBlock(
525
719
  digest: SignalDigest,
526
720
  bundles: readonly Bundle[],
527
721
  tracker: PlacementTracker,
528
- hostReasonSkus: ReadonlySet<string>,
722
+ ourReasons: ReadonlyMap<string, string>,
529
723
  ): Block | null {
530
724
  switch (block.kind) {
531
725
  case 'hero': {
@@ -568,7 +762,7 @@ function reconcileBlock(
568
762
  candidatesBySku,
569
763
  digest,
570
764
  tracker,
571
- hostReasonSkus,
765
+ ourReasons,
572
766
  );
573
767
  if (items.length === 0) {
574
768
  tracker.record('empty-block:grid');
@@ -590,7 +784,7 @@ function reconcileBlock(
590
784
  candidatesBySku,
591
785
  digest,
592
786
  tracker,
593
- hostReasonSkus,
787
+ ourReasons,
594
788
  );
595
789
  if (items.length === 0) {
596
790
  tracker.record('empty-block:carousel');
@@ -668,15 +862,18 @@ export function reconcileSpec(
668
862
  input: TrackingInput,
669
863
  digest: SignalDigest,
670
864
  /**
671
- * SKUs whose reason this request wrote from the host's own candidate. Only
672
- * `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
865
+ * The reason this request wrote itself, by SKU: the host's own `reason` on a
866
+ * candidate, or the sentence the selector wrote when there was none. Neither is the
867
+ * model's words, and the deterministic component renders the same sentence unscreened.
868
+ *
869
+ * Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
673
870
  * reason is the model's, including one that happens to read the same.
674
871
  */
675
- hostReasonSkus: ReadonlySet<string> = new Set(),
872
+ ourReasons: ReadonlyMap<string, string> = new Map(),
676
873
  ): ReconcileResult {
677
874
  const allowlist = buildAllowlist(input, digest);
678
875
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
679
- const tracker = createPlacementTracker(digest.maxItems);
876
+ const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
680
877
 
681
878
  if (generated.blocks.length > MAX_BLOCKS) {
682
879
  tracker.record(`too-many-blocks:${generated.blocks.length}`);
@@ -691,7 +888,7 @@ export function reconcileSpec(
691
888
  digest,
692
889
  input.bundles,
693
890
  tracker,
694
- hostReasonSkus,
891
+ ourReasons,
695
892
  );
696
893
  if (reconciled !== null) blocks.push(reconciled);
697
894
  }