@rudra-js/attested 0.0.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.
package/dist/verify.js ADDED
@@ -0,0 +1,153 @@
1
+ import { numeralsIn, supportedValues } from './numerals.js';
2
+ import { BANNED_PHRASES, indexPhrasing, normalisePhrasing, spansIn } from './phrases.js';
3
+ /**
4
+ * One reading per fact list, keyed on the list itself, because core hands `verify` the
5
+ * same array again for every field it screens. Held against a copy of what the list
6
+ * carried: `readonly` on `Facts.values` is a compile-time view of one reference, and a
7
+ * host holding the array can write to it between two calls.
8
+ */
9
+ const readings = new WeakMap();
10
+ function unchanged(held, values) {
11
+ if (held.length !== values.length)
12
+ return false;
13
+ for (let i = 0; i < held.length; i += 1) {
14
+ if (held[i] !== values[i])
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+ function valuesOf(facts) {
20
+ const values = facts.values;
21
+ // A caller outside TypeScript can pass something with no identity to key on.
22
+ if (!Array.isArray(values))
23
+ return supportedValues(values);
24
+ const read = readings.get(values);
25
+ if (read !== undefined && unchanged(read.held, values))
26
+ return read.supported;
27
+ const supported = supportedValues(values);
28
+ readings.set(values, { held: [...values], supported });
29
+ return supported;
30
+ }
31
+ function phrasesOf(facts) {
32
+ const phrases = new Set(BANNED_PHRASES);
33
+ for (const phrase of facts.bannedPhrases ?? []) {
34
+ const normalised = normalisePhrasing(phrase);
35
+ if (normalised.length > 0)
36
+ phrases.add(normalised);
37
+ }
38
+ return [...phrases];
39
+ }
40
+ function allowedOf(facts) {
41
+ const allowed = [];
42
+ for (const phrase of facts.allowedPhrases ?? []) {
43
+ const normalised = normalisePhrasing(phrase);
44
+ if (normalised.length > 0)
45
+ allowed.push(normalised);
46
+ }
47
+ return allowed;
48
+ }
49
+ function reasonFor(numeral) {
50
+ if (numeral.kind === 'other-numeral') {
51
+ return `quantity: "${numeral.token}" is a numeric character this layer cannot read, so no fact can back it`;
52
+ }
53
+ if (numeral.kind === 'magnitude') {
54
+ return `quantity: "${numeral.token}" ends in a magnitude mark this layer cannot read, so the value it shows is unchecked`;
55
+ }
56
+ return `quantity: "${numeral.token}" is not a value the supplied facts carry`;
57
+ }
58
+ function checkQuantities(text, supported) {
59
+ const numerals = numeralsIn(text);
60
+ const findings = [];
61
+ const reported = new Set();
62
+ for (const numeral of numerals) {
63
+ let backed = false;
64
+ for (const form of numeral.forms) {
65
+ if (supported.has(form))
66
+ backed = true;
67
+ }
68
+ if (backed || reported.has(numeral.token))
69
+ continue;
70
+ reported.add(numeral.token);
71
+ findings.push({ layer: 'quantity', token: numeral.token, reason: reasonFor(numeral) });
72
+ }
73
+ return {
74
+ supported: findings.length === 0,
75
+ strength: 'proof',
76
+ checked: numerals.length,
77
+ findings,
78
+ };
79
+ }
80
+ function standsBehind(allowed, hit) {
81
+ for (const span of allowed) {
82
+ if (span.start <= hit.start && hit.end <= span.end)
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ function checkWording(text, phrases, allowed) {
88
+ const normalised = normalisePhrasing(text);
89
+ const indexed = indexPhrasing(normalised);
90
+ const spans = [];
91
+ for (const phrase of allowed) {
92
+ for (const span of spansIn(indexed, phrase)) {
93
+ // The denylist reads through a break and the allowance does not, so both rules
94
+ // err toward reporting. A model that puts the negation in one block and the
95
+ // claim in another has written two things the shopper reads apart.
96
+ if (!normalised.slice(span.start, span.end + 1).includes('\n'))
97
+ spans.push(span);
98
+ }
99
+ }
100
+ const findings = [];
101
+ for (const phrase of phrases) {
102
+ // One hit inside allowed wording is forgiven; the same phrase elsewhere is not.
103
+ let caught = false;
104
+ for (const hit of spansIn(indexed, phrase)) {
105
+ if (!standsBehind(spans, hit))
106
+ caught = true;
107
+ }
108
+ if (!caught)
109
+ continue;
110
+ findings.push({
111
+ layer: 'wording',
112
+ token: phrase,
113
+ reason: `wording: "${phrase}" is a claim with no value to check, caught by a best-effort denylist`,
114
+ });
115
+ }
116
+ return {
117
+ supported: findings.length === 0,
118
+ strength: 'best-effort',
119
+ checked: phrases.length,
120
+ findings,
121
+ };
122
+ }
123
+ function check(text, supported, phrases, allowed) {
124
+ const quantity = checkQuantities(text, supported);
125
+ const wording = checkWording(text, phrases, allowed);
126
+ return { supported: quantity.supported && wording.supported, quantity, wording };
127
+ }
128
+ /** Whether one piece of model-written text stands on the facts the host supplied. */
129
+ export function verify(text, facts) {
130
+ return check(text, valuesOf(facts), phrasesOf(facts), allowedOf(facts));
131
+ }
132
+ /** The same check over every field of one product, without repeating the facts. */
133
+ export function verifyFields(fields, facts) {
134
+ const supported = valuesOf(facts);
135
+ const phrases = phrasesOf(facts);
136
+ const allowed = allowedOf(facts);
137
+ const results = [];
138
+ const written = [];
139
+ let allSupported = true;
140
+ for (const field of Object.keys(fields)) {
141
+ const text = fields[field] ?? '';
142
+ const result = check(text, supported, phrases, allowed);
143
+ if (!result.supported)
144
+ allSupported = false;
145
+ results.push({ field, result });
146
+ written.push(text);
147
+ }
148
+ // A card renders its fields next to each other, so `Free` and `delivery on every
149
+ // order` are one sentence to a shopper even though neither field carries one.
150
+ const acrossFields = checkWording(written.join(' '), phrases, allowed);
151
+ return { supported: allSupported && acrossFields.supported, fields: results, acrossFields };
152
+ }
153
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,14 +1,50 @@
1
1
  {
2
2
  "name": "@rudra-js/attested",
3
- "version": "0.0.0",
4
- "description": "Placeholder so a trusted publisher can be configured. Use 0.4.0 or later.",
3
+ "version": "0.5.0",
4
+ "description": "Checks model-written product copy against the facts a shop stands behind",
5
+ "keywords": [
6
+ "llm",
7
+ "hallucination",
8
+ "grounding",
9
+ "verification",
10
+ "ecommerce",
11
+ "guardrails"
12
+ ],
5
13
  "license": "MIT",
6
14
  "author": "Clive Dsouza",
7
15
  "homepage": "https://github.com/clivedsouza1010/rudra-js/tree/main/packages/attested#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/clivedsouza1010/rudra-js/issues"
18
+ },
8
19
  "repository": {
9
20
  "type": "git",
10
21
  "url": "git+https://github.com/clivedsouza1010/rudra-js.git",
11
22
  "directory": "packages/attested"
12
23
  },
13
- "private": false
24
+ "engines": {
25
+ "node": ">=22.12.0"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src/**/*.ts",
40
+ "!src/**/*.test.ts"
41
+ ],
42
+ "sideEffects": false,
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "prepack": "npm run build"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
14
50
  }
package/src/hidden.ts ADDED
@@ -0,0 +1,26 @@
1
+ // One definition for both layers. It lived in two files, and that is why widening it
2
+ // for the quantity layer left the wording layer open twice running.
3
+
4
+ /**
5
+ * Characters that render as nothing: format characters, default-ignorable code points
6
+ * and the controls. Cf and Default_Ignorable each hold characters the other does not.
7
+ */
8
+ const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
9
+
10
+ /** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
11
+ const SPACING = /[\t\n\v\f\r\u0085]/;
12
+
13
+ /**
14
+ * Marks that hang on the character before them instead of taking a column of their own,
15
+ * so `4<U+0305>9` reads as 49. Spacing marks (Mc) are not here: a Devanagari matra is
16
+ * a visible gap between two digits and separates them for a reader.
17
+ */
18
+ const MARKS = /[\p{Mn}\p{Me}]/gu;
19
+
20
+ export function stripInvisible(text: string): string {
21
+ return text.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''));
22
+ }
23
+
24
+ export function stripMarks(text: string): string {
25
+ return text.replace(MARKS, '');
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // @rudra-js/attested — the guarantee that a model cannot lie about a product.
2
+
3
+ export {
4
+ verify,
5
+ verifyFields,
6
+ type BatchResult,
7
+ type Facts,
8
+ type FieldResult,
9
+ type Finding,
10
+ type Layer,
11
+ type LayerReport,
12
+ type VerifyResult,
13
+ } from './verify.js';
14
+
15
+ export { BANNED_PHRASES } from './phrases.js';
@@ -0,0 +1,247 @@
1
+ // Everything here is string work. No float ever holds a value, so a twenty-digit
2
+ // order number compares exactly.
3
+
4
+ import { stripInvisible, stripMarks } from './hidden.js';
5
+
6
+ /** A dot or a comma. Either mark is a decimal point in one locale and grouping in another. */
7
+ const AMBIGUOUS = '.,';
8
+ /** The Arabic decimal separator, which is never grouping. */
9
+ const DECIMAL_ONLY = '\u066b';
10
+ /** Marks that are only ever grouping: Arabic thousands, the space family, the apostrophe. */
11
+ const GROUPING_ONLY = '\u066c\u0020\u00a0\u2007\u2008\u2009\u202f\u0027\u2019';
12
+
13
+ const SEPARATORS = AMBIGUOUS + DECIMAL_ONLY + GROUPING_ONLY;
14
+
15
+ const RUN = `\\p{Nd}+(?:[${SEPARATORS}]\\p{Nd}+)*`;
16
+ /** Marks that multiply the digits in front of them: 万 億 兆, and the k/M/B of English copy. */
17
+ const MAGNITUDE = '(?:[十百千万萬億亿兆]|[kKMB](?!\\p{L}))';
18
+ /** Numeric characters that are not decimal digits: ½ ² ② Ⅲ. */
19
+ const OTHER_NUMERAL = '[\\p{No}\\p{Nl}]+';
20
+
21
+ const SCAN = new RegExp(`${RUN}${MAGNITUDE}|${RUN}|${OTHER_NUMERAL}`, 'gu');
22
+ const LOOSE = new RegExp(`[${GROUPING_ONLY}]`, 'u');
23
+
24
+ const IS_DIGIT = /^\p{Nd}$/u;
25
+ const STARTS_DIGIT = /^\p{Nd}/u;
26
+ const ENDS_DIGIT = /\p{Nd}$/u;
27
+
28
+ export interface Numeral {
29
+ /** The run exactly as it appears in the text. */
30
+ token: string;
31
+ /** Every reading of that run, canonical. Empty when the layer cannot read the run. */
32
+ forms: string[];
33
+ /** `digits` is a run this layer reads. The other two it cannot read at all. */
34
+ kind: 'digits' | 'magnitude' | 'other-numeral';
35
+ }
36
+
37
+ const zeros = new Map<number, number>();
38
+
39
+ /**
40
+ * Unicode writes decimal digits in complete sets of ten, and adjacent sets abut, so
41
+ * the start of a maximal run of digit code points is a zero and the offset into that
42
+ * run, modulo ten, is the value. Walking back without that modulo reads every digit
43
+ * of the second and later sets in the maths block as a nine.
44
+ */
45
+ function digitValue(char: string): number {
46
+ const code = char.codePointAt(0) ?? 0;
47
+
48
+ let zero = zeros.get(code);
49
+ if (zero === undefined) {
50
+ zero = code;
51
+ while (zero > 0 && IS_DIGIT.test(String.fromCodePoint(zero - 1))) zero -= 1;
52
+ zeros.set(code, zero);
53
+ }
54
+
55
+ return (code - zero) % 10;
56
+ }
57
+
58
+ function toAscii(token: string): string {
59
+ let out = '';
60
+ for (const char of token) {
61
+ out += SEPARATORS.includes(char) ? char : String(digitValue(char));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Strips the zeros that carry no value, so 39.00 and 39 are one string. */
67
+ function canonical(whole: string, fraction: string): string {
68
+ let start = 0;
69
+ while (start < whole.length - 1 && whole[start] === '0') start += 1;
70
+
71
+ let end = fraction.length;
72
+ while (end > 0 && fraction[end - 1] === '0') end -= 1;
73
+
74
+ const head = whole.slice(start);
75
+ const tail = fraction.slice(0, end);
76
+ return tail.length === 0 ? head : `${head}.${tail}`;
77
+ }
78
+
79
+ /** Whether these runs read as one grouped whole number: 12,345,678 or 1,29,999. */
80
+ function isGrouping(groups: string[]): boolean {
81
+ if (groups.length === 1) return true;
82
+ if ((groups[groups.length - 1] ?? '').length !== 3) return false;
83
+
84
+ let three = true;
85
+ let two = true;
86
+ for (let index = 1; index < groups.length - 1; index += 1) {
87
+ const size = (groups[index] ?? '').length;
88
+ if (size !== 3) three = false;
89
+ if (size !== 2) two = false;
90
+ }
91
+
92
+ const lead = (groups[0] ?? '').length;
93
+ if (three) return lead >= 1 && lead <= 3;
94
+ // 1,29,999 — the Indian grouping, two digits at a time above the last three.
95
+ if (two) return lead >= 1 && lead <= 2;
96
+ return false;
97
+ }
98
+
99
+ /** Whether every mark is the same one, and every one of them can be grouping. */
100
+ function allGrouping(separators: string[]): boolean {
101
+ for (const separator of separators) {
102
+ if (separator !== separators[0]) return false;
103
+ if (DECIMAL_ONLY.includes(separator)) return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /** `1,299` is 1299 in London and in Berlin. `1234,567` is a decimal in both. */
109
+ function formsOf(token: string): string[] {
110
+ const ascii = toAscii(token);
111
+
112
+ const groups: string[] = [];
113
+ const separators: string[] = [];
114
+ let current = '';
115
+ for (const char of ascii) {
116
+ if (SEPARATORS.includes(char)) {
117
+ groups.push(current);
118
+ separators.push(char);
119
+ current = '';
120
+ } else {
121
+ current += char;
122
+ }
123
+ }
124
+ groups.push(current);
125
+
126
+ if (separators.length === 0) return [canonical(groups[0] ?? '', '')];
127
+
128
+ const forms: string[] = [];
129
+ if (allGrouping(separators) && isGrouping(groups)) forms.push(canonical(groups.join(''), ''));
130
+
131
+ const whole = groups.slice(0, -1);
132
+ const leading = separators.slice(0, -1);
133
+ const point = separators[separators.length - 1] ?? '';
134
+ const fraction = groups[groups.length - 1] ?? '';
135
+
136
+ // One dot or comma with exactly three digits behind it is grouping wherever it is
137
+ // written, so `4.800` is four thousand eight hundred and never the rating 4.8.
138
+ const groupingWins = forms.length > 0 && leading.length === 0 && fraction.length === 3;
139
+
140
+ if (
141
+ !groupingWins &&
142
+ !GROUPING_ONLY.includes(point) &&
143
+ allGrouping(leading) &&
144
+ leading[0] !== point &&
145
+ isGrouping(whole)
146
+ ) {
147
+ forms.push(canonical(whole.join(''), fraction));
148
+ }
149
+
150
+ return forms;
151
+ }
152
+
153
+ function collect(found: Numeral[], token: string): void {
154
+ if (!STARTS_DIGIT.test(token)) {
155
+ found.push({ token, forms: [], kind: 'other-numeral' });
156
+ return;
157
+ }
158
+ if (!ENDS_DIGIT.test(token)) {
159
+ found.push({ token, forms: [], kind: 'magnitude' });
160
+ return;
161
+ }
162
+
163
+ const forms = formsOf(token);
164
+ if (forms.length > 0) {
165
+ found.push({ token, forms, kind: 'digits' });
166
+ return;
167
+ }
168
+
169
+ // `8 10 12` is three numbers, not one badly grouped one.
170
+ if (LOOSE.test(token)) {
171
+ for (const part of token.split(LOOSE)) {
172
+ if (part.length > 0) collect(found, part);
173
+ }
174
+ return;
175
+ }
176
+
177
+ // No locale reads `24.12.2026` as a number, so only a fact written the same way
178
+ // stands behind it. The raw run carries a mark, and a reading never does, so it
179
+ // can never be mistaken for one.
180
+ found.push({ token, forms: [toAscii(token)], kind: 'digits' });
181
+ }
182
+
183
+ export function numeralsIn(text: string): Numeral[] {
184
+ const found: Numeral[] = [];
185
+ for (const match of stripMarks(stripInvisible(text)).matchAll(SCAN)) collect(found, match[0]);
186
+ return found;
187
+ }
188
+
189
+ /** A whole fact in exponent notation and nothing else, so expanding it swallows no prose. */
190
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/;
191
+
192
+ /**
193
+ * The widest digit run a fact may lay out to. Every finite JS number fits — 5e-324 is
194
+ * the longest, at 326 characters — and nothing a shop sells is wider than that.
195
+ */
196
+ const DIGIT_LIMIT = 1000;
197
+
198
+ /**
199
+ * `String(1e21)` is `1e+21`, which reads as the two numerals 1 and 21. Lay the same digits
200
+ * out in place. Empty past the limit: `1e2000000000` is twelve characters and no product
201
+ * fact, and laying it out builds a run long enough to take the process down with it.
202
+ */
203
+ function positional(written: string): string {
204
+ const marker = written.search(/[eE]/);
205
+ if (marker < 0) return written;
206
+
207
+ const power = Number(written.slice(marker + 1));
208
+ // The sign never reaches a numeral, so dropping it here mints no digit the host lacks.
209
+ const body = written.slice(0, marker).replace(/^[+-]/, '');
210
+ const point = body.indexOf('.');
211
+ const digits = point < 0 ? body : body.slice(0, point) + body.slice(point + 1);
212
+ const place = (point < 0 ? body.length : point) + power;
213
+
214
+ if (place <= 0) {
215
+ if (digits.length - place > DIGIT_LIMIT) return '';
216
+ return `0.${'0'.repeat(-place)}${digits}`;
217
+ }
218
+ if (place >= digits.length) {
219
+ if (place > DIGIT_LIMIT) return '';
220
+ return `${digits}${'0'.repeat(place - digits.length)}`;
221
+ }
222
+ return `${digits.slice(0, place)}.${digits.slice(place)}`;
223
+ }
224
+
225
+ /**
226
+ * A string fact is read as the host typed it, except when the whole of it is a number
227
+ * in exponent notation: `String`, `JSON.stringify`, a CSV export and an API that writes
228
+ * 64-bit values as strings all produce that, and none of them is the host choosing digits.
229
+ */
230
+ function digitsOf(value: string | number | bigint): string {
231
+ if (typeof value === 'number') return positional(String(value));
232
+ if (typeof value !== 'string') return String(value);
233
+
234
+ const trimmed = value.trim();
235
+ return BARE_EXPONENT.test(trimmed) ? positional(trimmed) : value;
236
+ }
237
+
238
+ /** Every reading of every numeral the host stands behind. */
239
+ export function supportedValues(values: readonly (string | number | bigint)[]): Set<string> {
240
+ const supported = new Set<string>();
241
+ for (const value of values) {
242
+ for (const numeral of numeralsIn(digitsOf(value))) {
243
+ for (const form of numeral.forms) supported.add(form);
244
+ }
245
+ }
246
+ return supported;
247
+ }