@rudra-js/core 0.4.0 → 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.
@@ -297,9 +302,10 @@ const CONFUSABLES: Record<string, string> = {
297
302
  * points and the controls. Cf and Default_Ignorable each hold characters the
298
303
  * other does not.
299
304
  *
300
- * The same class @rudra-js/attested strips in its own `hidden.ts`. A second copy,
301
- * because core carries no dependencies and is not about to take one — which means
302
- * widening one and not the other is the mistake to watch for.
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.
303
309
  */
304
310
  const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
305
311
 
@@ -338,6 +344,80 @@ function normaliseForClaims(text: string): string {
338
344
  return folded;
339
345
  }
340
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
+
341
421
  /** Names the first forbidden claim the text makes, or null when it makes none. */
342
422
  function claimIn(text: string): string | null {
343
423
  const normalised = normaliseForClaims(text);
@@ -357,13 +437,15 @@ function claimIn(text: string): string | null {
357
437
  * through every function. The budget and the de-duplication set are global to a
358
438
  * spec, not to a block, which is the part that is easy to get wrong.
359
439
  */
360
- function createPlacementTracker(maxItems: number) {
440
+ function createPlacementTracker(maxItems: number, facts: HostFacts) {
361
441
  const placedSkus = new Set<string>();
362
442
  const violations: string[] = [];
363
443
  let remaining = maxItems;
364
444
 
365
445
  return {
366
446
  violations,
447
+ facts: facts.pooled,
448
+ factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
367
449
  get remaining() {
368
450
  return remaining;
369
451
  },
@@ -386,19 +468,41 @@ type PlacementTracker = ReturnType<typeof createPlacementTracker>;
386
468
  * Runs after clamping, so what is screened is exactly what would have rendered.
387
469
  * Dropping means what it means everywhere else here: this field becomes null
388
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.
389
476
  */
390
477
  function screenClaim(
391
478
  value: string | null,
392
479
  field: string,
393
480
  tracker: PlacementTracker,
481
+ facts: readonly string[] = tracker.facts,
394
482
  ): string | null {
395
483
  if (value === null) return null;
396
484
 
397
485
  const kind = claimIn(value);
398
- if (kind === null) return value;
486
+ if (kind !== null) {
487
+ tracker.record(`unverifiable-claim:${kind}:${field}`);
488
+ return null;
489
+ }
399
490
 
400
- tracker.record(`unverifiable-claim:${kind}:${field}`);
401
- 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;
402
506
  }
403
507
 
404
508
  /**
@@ -439,7 +543,7 @@ function reconcileItems(
439
543
  candidatesBySku: Map<string, Product>,
440
544
  digest: SignalDigest,
441
545
  tracker: PlacementTracker,
442
- hostReasonSkus: ReadonlySet<string>,
546
+ ourReasons: ReadonlyMap<string, string>,
443
547
  ): ProductReference[] {
444
548
  const kept: ProductReference[] = [];
445
549
 
@@ -459,8 +563,10 @@ function reconcileItems(
459
563
  const hasSupportedBasis = verifyBasis(item.basis, product, digest);
460
564
  if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
461
565
 
462
- const isHostReason =
463
- 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);
464
570
 
465
571
  kept.push({
466
572
  sku: item.sku,
@@ -468,14 +574,19 @@ function reconcileItems(
468
574
  // The prose exists to state the basis. If the basis did not hold, the
469
575
  // prose is a claim we just decided is untrue.
470
576
  reason: hasSupportedBasis
471
- ? isHostReason
577
+ ? isOurs
472
578
  ? clampNullable(item.reason, CLAMP.reason)
473
- : 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
+ )
474
585
  : null,
475
586
  // A badge is the shortest, loudest text on the card, and the schema's own
476
587
  // example for it was "Back in stock" — a stock claim. It renders, so it is
477
588
  // read for claims like every other sentence the model writes.
478
- badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
589
+ badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
479
590
  emphasis: item.emphasis,
480
591
  });
481
592
  }
@@ -566,7 +677,7 @@ export function bundleForShopper(
566
677
  ): Bundle | undefined {
567
678
  const allowlist = buildAllowlist(input, digest);
568
679
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
569
- const tracker = createPlacementTracker(digest.maxItems);
680
+ const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
570
681
 
571
682
  for (const sku of spokenFor) tracker.place(sku);
572
683
 
@@ -608,7 +719,7 @@ function reconcileBlock(
608
719
  digest: SignalDigest,
609
720
  bundles: readonly Bundle[],
610
721
  tracker: PlacementTracker,
611
- hostReasonSkus: ReadonlySet<string>,
722
+ ourReasons: ReadonlyMap<string, string>,
612
723
  ): Block | null {
613
724
  switch (block.kind) {
614
725
  case 'hero': {
@@ -651,7 +762,7 @@ function reconcileBlock(
651
762
  candidatesBySku,
652
763
  digest,
653
764
  tracker,
654
- hostReasonSkus,
765
+ ourReasons,
655
766
  );
656
767
  if (items.length === 0) {
657
768
  tracker.record('empty-block:grid');
@@ -673,7 +784,7 @@ function reconcileBlock(
673
784
  candidatesBySku,
674
785
  digest,
675
786
  tracker,
676
- hostReasonSkus,
787
+ ourReasons,
677
788
  );
678
789
  if (items.length === 0) {
679
790
  tracker.record('empty-block:carousel');
@@ -751,15 +862,18 @@ export function reconcileSpec(
751
862
  input: TrackingInput,
752
863
  digest: SignalDigest,
753
864
  /**
754
- * SKUs whose reason this request wrote from the host's own candidate. Only
755
- * `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
756
870
  * reason is the model's, including one that happens to read the same.
757
871
  */
758
- hostReasonSkus: ReadonlySet<string> = new Set(),
872
+ ourReasons: ReadonlyMap<string, string> = new Map(),
759
873
  ): ReconcileResult {
760
874
  const allowlist = buildAllowlist(input, digest);
761
875
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
762
- const tracker = createPlacementTracker(digest.maxItems);
876
+ const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
763
877
 
764
878
  if (generated.blocks.length > MAX_BLOCKS) {
765
879
  tracker.record(`too-many-blocks:${generated.blocks.length}`);
@@ -774,7 +888,7 @@ export function reconcileSpec(
774
888
  digest,
775
889
  input.bundles,
776
890
  tracker,
777
- hostReasonSkus,
891
+ ourReasons,
778
892
  );
779
893
  if (reconciled !== null) blocks.push(reconciled);
780
894
  }