@rudra-js/attested 0.0.0 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clive Dsouza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,355 @@
1
1
  # @rudra-js/attested
2
2
 
3
- This `0.0.0` is a placeholder. npm can only attach a trusted publisher to a
4
- package that already exists, so this version exists to create the name.
3
+ Checks model-written product copy against the facts a shop stands behind.
5
4
 
6
- Install `0.4.0` or later, which is published from CI with provenance.
5
+ Zero dependencies, no node builtins, no I/O. It is one pure function over text
6
+ and facts, so it runs in Node, in an edge runtime and in a browser, and it drops
7
+ in after anything that writes copy — json-render, the Vercel AI SDK, a raw
8
+ `fetch` to a model, your own template.
7
9
 
8
- See [the repository](https://github.com/clivedsouza1010/rudra-js/tree/main/packages/attested#readme).
10
+ ## The guarantee
11
+
12
+ **When `quantity.supported` is true, every numeral in the text is one you
13
+ supplied — and a numeral the reader cannot read counts as a failure, not a
14
+ pass.**
15
+
16
+ That is the whole promise, and it is a promise about numerals, not about
17
+ sentences. It does not say the sentence around the number is true: a price you
18
+ supplied can still be written as a sales figure. It says nothing at all about
19
+ `wording.supported`, which is a denylist over an open set and carries
20
+ `strength: 'best-effort'` on every result for that reason.
21
+
22
+ Everything the guarantee leaves open is listed under
23
+ [What it cannot catch](#what-it-cannot-catch). Nothing there is a bug queued for
24
+ later; it is the shape of what a closed check over digits can promise.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install @rudra-js/attested
30
+ ```
31
+
32
+ ```ts
33
+ import { verify } from '@rudra-js/attested';
34
+
35
+ const result = verify('Only 2 left at $39', { values: [39, 'SKU TR-101'] });
36
+
37
+ result.supported; // false
38
+ result.quantity.findings;
39
+ // [{ layer: 'quantity', token: '2', reason: 'quantity: "2" is not a value the supplied facts carry' }]
40
+ ```
41
+
42
+ ## The two layers
43
+
44
+ They are checked separately and reported separately, because only one of them is
45
+ a proof.
46
+
47
+ ### Layer one — quantities. This is the provable half.
48
+
49
+ Every run of digits in the text has to be a value you supplied. Not "does this
50
+ read like a bad phrase" but "is this number one the shop stands behind". The set
51
+ of digits in a string is closed and checkable, and it does not care what
52
+ language the sentence is in.
53
+
54
+ ```ts
55
+ verify('Nur noch 2 übrig', { values: [39] }).quantity.supported; // false
56
+ verify('4,8 von 5', { values: [4.8, 5] }).quantity.supported; // true
57
+ ```
58
+
59
+ It is crude on purpose and it errs toward rejection. `"Only 2 left"` is
60
+ unsupported unless you supplied a fact carrying 2, even though 2 is also the
61
+ number of colourways. A false rejection costs a duller sentence; a false
62
+ acceptance costs a fabricated claim.
63
+
64
+ **What it reads.** Bare integers and decimals, both decimal separators (`4.8`
65
+ and `4,8`), currency symbols attached or detached, percentages, and any Unicode
66
+ decimal digit — Eastern Arabic `٤٫٨`, Devanagari `४.८` and mathematical `𝟰.𝟴`
67
+ all read as 4.8. Grouping is read in the Western (`12,345,678`), Indian
68
+ (`1,29,999`), space (`1 299`, including the no-break and narrow no-break spaces)
69
+ and Swiss (`1'299`) shapes.
70
+
71
+ **How an ambiguous mark is decided.** One dot or comma with exactly three digits
72
+ behind it is grouping, never a decimal point. `1,299` and `1.299` both read as
73
+ 1299 and nothing else, which is why a rating of 4.8 cannot stand behind a
74
+ written `4.800` and a stock count of 12 cannot stand behind `12.000`. Where
75
+ grouping is impossible the decimal reading survives, so `1234,567` is still
76
+ 1234.567. Where the two marks differ the last one is the decimal point, so
77
+ `1.299,50` is 1299.5.
78
+
79
+ **What it refuses to read.** Three things become findings rather than passes:
80
+
81
+ - A numeric character that is not a decimal digit — `½`, `²`, `②`, `Ⅲ`. These
82
+ render as numbers and carry a value the reader has no way to check.
83
+ - A magnitude mark glued to a run — `4.8万`, `12M`, `4.8k`. The digits may be
84
+ yours; the multiplier is not.
85
+ - A run no locale reads as a number — `24.12.2026`, `3.14.15`. These are
86
+ supported only by a fact written exactly the same way.
87
+
88
+ Characters that take no room on the page are dropped before any of this, so a
89
+ zero-width space, a soft hyphen, a variation selector, a backspace or a
90
+ combining overline cannot split `213` into a `2` and a `13` that happen to be
91
+ supported separately. That class is every format character, every
92
+ default-ignorable code point, every control character and every mark that hangs
93
+ on the character before it — a rule over the whole of Unicode rather than a list
94
+ someone kept up to date.
95
+
96
+ A character that does take room still separates two numbers. A tab, a line break
97
+ and a Devanagari matra all leave a gap the shopper can see, so `"Only 2\n3 left"`
98
+ is two numbers and a fact of 23 does not stand behind it.
99
+
100
+ ### Layer two — wording. This is the best-effort half.
101
+
102
+ Claims with no number in them: "cheap", "bargain", "on sale", "best-selling",
103
+ "selling fast", "in stock", "free delivery". There is no value to check, so
104
+ there is nothing to invert. It stays a denylist, it stays open and
105
+ English-first, and every result it produces says so:
106
+
107
+ ```ts
108
+ verify('Selling fast', { values: [] }).wording;
109
+ // { supported: false, strength: 'best-effort', checked: 81, findings: [...] }
110
+ ```
111
+
112
+ Add your own for your own language, and name the wording you stand behind:
113
+
114
+ ```ts
115
+ verify('Nur noch wenige', { values: [], bannedPhrases: ['nur noch'] });
116
+ verify('In stock, ships today', { values: [], allowedPhrases: ['in stock'] });
117
+ ```
118
+
119
+ `bannedPhrases` is added to the built-in list. `allowedPhrases` works on the
120
+ text, not on the list: **where one of your phrases appears, a banned claim
121
+ sitting wholly inside it is not reported, and the same words elsewhere in the
122
+ copy still are.** So `['back in stock']` clears `"Back in stock"` and still
123
+ catches the `in stock` in `"Only 2 in stock, selling fast"`. `checked` is the
124
+ whole list either way, because nothing leaves it.
125
+
126
+ Without an allowance a denylist bans the truthful disclosure as hard as the
127
+ fabricated one, and a shop that genuinely offers free shipping cannot say so.
128
+ Three things are worth knowing before you write one:
129
+
130
+ - **Containment runs one way.** Allowing `in stock` does not clear
131
+ `back in stock`, which claims a restock on top of it.
132
+ - **Allowances do not compose, and they do not join.**
133
+ `['sold', 'out']` leaves `sold out` caught, and `['back in', 'stock']` leaves
134
+ both `in stock` and `back in stock` caught.
135
+ - **An allowance forgives whatever nests inside it, including entries added in a
136
+ later version.** `['in stock and ready to ship']` forgives nothing extra
137
+ today; the day `ready to ship` joins the list, that allowance silences it
138
+ wherever your sentence appears. Keep them tight.
139
+ - **An allowance does not cross a line break.** The denylist reads straight
140
+ through one, so `"Free\n\nshipping"` is still caught, but an allowance will
141
+ not bridge it. The model picks where the paragraph breaks fall, and
142
+ `"We do not offer"` above a horizontal rule, with the free shipping claim
143
+ below it, is two things a shopper reads apart. The two rules pull opposite
144
+ ways on purpose: the denylist catches more, the allowance forgives less, and
145
+ both err toward reporting.
146
+
147
+ It is also the way around negation. The list matches substrings, so
148
+ `"there is no sale on this product"` reads as a sale claim; allow `no sale` and
149
+ that sentence passes while a `sale` later in the same text still fails.
150
+
151
+ Matching is case-insensitive. Hyphens become spaces, accents are composed,
152
+ fullwidth forms are folded, characters that take no room are dropped, and a
153
+ handful of Cyrillic and Greek letters that render as Latin ones are folded to
154
+ Latin. Accents are composed before anything is dropped, so `café` keeps its `é`
155
+ while an overline, which composes with nothing, cannot hide `free` from its own
156
+ entry. A line break stays a line break: matching reads through it, and only an
157
+ allowance refuses to cross it. Spaces are dropped from both sides before
158
+ matching, so `送料 無料` still
159
+ reads as `送料無料`. A phrase whose first or last character is an ASCII letter or
160
+ digit will not match glued inside a longer word, so `cheap` misses `cheapskate`,
161
+ but a trailing `s` is allowed, so `discount` catches `discounts`.
162
+
163
+ ## The result
164
+
165
+ ```ts
166
+ interface VerifyResult {
167
+ supported: boolean; // both layers
168
+ quantity: LayerReport;
169
+ wording: LayerReport;
170
+ }
171
+
172
+ interface LayerReport {
173
+ supported: boolean;
174
+ strength: 'proof' | 'best-effort';
175
+ checked: number; // numerals found, or phrases screened against
176
+ findings: Finding[]; // one per distinct token that failed
177
+ }
178
+
179
+ interface Finding {
180
+ layer: 'quantity' | 'wording';
181
+ token: string; // the numeral as the text wrote it, or the phrase
182
+ reason: string; // audit evidence, naming the layer and the token
183
+ }
184
+ ```
185
+
186
+ `checked` says how much the layer actually looked at. `quantity.checked === 0`
187
+ means the text held no numeral and layer one proved nothing about it — which is
188
+ the honest reading of `"Only two left"`, and no assertion you can write on the
189
+ result will turn that into a catch. Layer one counts the numerals it refuses to
190
+ read, so `"Only ② left"` is a finding rather than a silent zero.
191
+
192
+ ## Facts
193
+
194
+ ```ts
195
+ interface Facts {
196
+ values: readonly (string | number | bigint)[];
197
+ bannedPhrases?: readonly string[];
198
+ allowedPhrases?: readonly string[];
199
+ }
200
+ ```
201
+
202
+ `values` is a flat list, not a typed one. Write them however they are written in
203
+ your own system — `39`, `'$1,299.00'`, `'4,8'`, `'ships 13 March'` — and every
204
+ numeral in each one becomes a supported value.
205
+
206
+ A number is laid out in positional notation first, whatever `String` would have
207
+ made of it, so a fact of `1e21` stands behind
208
+ `'1,000,000,000,000,000,000,000'` and behind neither `1` nor `21`. A string
209
+ whose whole content is a number in exponent notation is laid out the same way:
210
+ `String`, `JSON.stringify`, a spreadsheet export and an API that writes 64-bit
211
+ values as strings all produce one, and none of those is you choosing the digits.
212
+ Any other string is read exactly as you typed it, so `'SKU AX-220e5'` still
213
+ mints 220 and 5.
214
+
215
+ Past 2^53 a number has lost its exact digits before it ever reaches here —
216
+ `JSON.parse` turns `9007199254740993` into `...92` — so hand a long id over as a
217
+ bigint or as a string. A value that is none of the three stands behind no
218
+ numeral rather than throwing, so one null field does not take the whole call
219
+ down.
220
+
221
+ Flat rather than typed (money, count, rating, date) because typing only helps if
222
+ the extractor can classify a token in the text, and it cannot. The `2` in
223
+ `"Only 2 left"` and the `2` in `"2 year warranty"` are the same two characters;
224
+ telling them apart means understanding the sentence, in whatever language it was
225
+ written, which is the problem this package exists to stop relying on. Typed
226
+ facts would also need an "unclassified, so skip it" branch, and that branch is a
227
+ hole. A flat set has no hole: every numeral is checked against every value.
228
+
229
+ The cost is real and it is stated under **What it cannot catch**. Two things
230
+ follow from it that are worth doing:
231
+
232
+ - **Supply only numbers you are happy to see anywhere.** A SKU of `AT-2199` in
233
+ `values` makes `"Was $2,199"` a supported sentence. A listing date makes its
234
+ day number a supported sales figure. If a number in your record is not a fact
235
+ about the product a shopper would read, leave it out.
236
+ - **`values` is a trust boundary.** On a marketplace the product title is
237
+ written by the seller, not by you. Putting it in `values` hands the model
238
+ every number the seller chose to type. Supply fields you author.
239
+
240
+ ## Many fields, one product
241
+
242
+ ```ts
243
+ import { verifyFields } from '@rudra-js/attested';
244
+
245
+ const result = verifyFields(
246
+ { headline: 'Yours for $39', badge: 'Only 2 left', body: 'Built for long days' },
247
+ { values: [39] },
248
+ );
249
+
250
+ result.supported; // false
251
+ result.fields; // [{ field, result }, ...] in the order the fields were given
252
+ result.acrossFields; // the wording layer over every field read as one
253
+ ```
254
+
255
+ The facts are read once and reused. A card renders its fields next to each
256
+ other, and the model picks the field boundaries, so
257
+ `{ badge: 'Free', delivery: 'delivery on every order' }` is one sentence to a
258
+ shopper while neither field carries it alone. `acrossFields` reads the joined
259
+ text and counts toward `supported`.
260
+
261
+ ## What it cannot catch
262
+
263
+ This is the honest list.
264
+
265
+ ### Claims with a number in them
266
+
267
+ - **A number that is real but means something else.** This is the largest hole
268
+ in the provable half. A flat fact set proves the number came from you, not
269
+ that the sentence uses it correctly. Supply a price of 39 and `"39 sold in the
270
+ last hour"`, `"Save $39 today"` and `"Under $39"` all pass. Supply a rating
271
+ scale of 5 and `"Only 5 left"` passes. Supply a rating of 4.8 and a German
272
+ `"nur 4,80 €"` passes as a price, because 4.80 and 4.8 are one number.
273
+ - **Numbers written as words, in any script.** `"Only two left"` has no digit in
274
+ it, so layer one never sees it. Neither does `残り二点`, where 二 is a
275
+ character rather than a digit, nor Arabic `قطعتين`, where "two" is the noun's
276
+ dual ending and there is no numeral at all. `quantity.checked` is 0 for all
277
+ three, and that is the only signal you get.
278
+ - **Magnitudes written as words.** `4.8万` and `12M` are caught. `4,8 Millionen`,
279
+ `4.8 millions`, `4,8 mil`, `4.8 लाख` and `٤٫٨ مليون` are not — a word list is
280
+ the denylist this package exists to get away from.
281
+ - **The unit on the number.** `¥39.99` against a price of $39.99 passes: the
282
+ digits are yours and the currency symbol is not a digit. So does
283
+ `"39,99 Cent"`. Layer one checks the number, never what it is a number of.
284
+ - **A number your facts happen to contain.** A fact of `'2026-09-13'` makes
285
+ 2026, 9 and 13 supported everywhere in the text, including as a stock count.
286
+ - **Where in the text a token was.** A finding names the token, not its offset.
287
+
288
+ ### Claims with no number in them
289
+
290
+ - **Wording, in general.** Layer two is a denylist over an infinite set. A
291
+ careful rewording gets past it — `"Half of what it was"` for half price,
292
+ `"送料は無料"` with a particle dropped in. Entries are added as they are found,
293
+ and the list will never be finished.
294
+ - **Your language, until you give it one.** Every built-in phrase is English.
295
+ `"Gratis Versand"`, `"Livraison offerte"` and `"شحن مجاني"` all pass by
296
+ default. `bannedPhrases` is the answer, and it is your list to keep.
297
+ - **Negation.** The list matches substrings, so `"there is no sale on this
298
+ product"` is caught as a sale claim on its own. There is no rule that reads the
299
+ `no`; `allowedPhrases: ['no sale']` is the way out, and it is narrow by
300
+ design — a `sale` elsewhere in the same text still fails.
301
+ - **A character that folds into your allowance.** An allowance is matched
302
+ against normalised copy, so anything that folds to those words counts as those
303
+ words. Allow `no sale` and `"№ SALE"` passes, because U+2116 folds to `no`;
304
+ a superscript `ⁿᵒ` does the same. The shopper reads the numero sign as
305
+ decoration and the verifier reads it as a word. Keep allowances tight and
306
+ written the way a shop really writes them.
307
+ - **A letter glued to the end of a phrase.** `"FREE SHIPPINGᵃ see terms"` is not
308
+ reported. The superscript folds to an `a`, which glues to `shipping`, and the
309
+ word-gap rule that stops `cheap` matching `cheapskate` then treats the whole
310
+ thing as a longer word. It holds for every entry on the list, and a footnote
311
+ marker is exactly how a real shop writes one.
312
+
313
+ ### Preconditions the package cannot check for you
314
+
315
+ - **The text you check has to be the text you render.** `verify` sees a string.
316
+ If anything downstream decodes it — an HTML sink turning `½` into ½, or
317
+ `2` into a 2 — then the string that was checked and the string on the page
318
+ are different documents, and the guarantee covers the first one.
319
+ - **The font has to agree with Unicode about what renders as nothing.** The
320
+ layer drops what Unicode marks as taking no room. Where a font gives one of
321
+ those a visible glyph the two disagree, and U+3164 HANGUL FILLER is the one
322
+ people really paste as a blank: the layer reads `"1<U+3164>3"` as 13, and a
323
+ font that widens it shows the shopper `1 3`.
324
+ - **Everything that is not a number or a phrase.** A claim about materials,
325
+ compatibility, a certification, a warranty condition or a person is entirely
326
+ out of scope.
327
+
328
+ ## What it over-rejects
329
+
330
+ The guarantee errs toward rejection, and that has a bill:
331
+
332
+ - **Every incidental number has to be in `values`.** A VAT rate, a size, a care
333
+ temperature, a spec number, a support phone number, a founding year, the
334
+ denominator of a rating scale. None of them are claims, all of them are
335
+ digits, and copy mentioning one you did not supply is rejected.
336
+ - **A three-digit tail is always grouping.** `1.299 kg` reads as 1299, so a
337
+ genuine three-decimal number — a weight, or a price in a currency with three
338
+ decimal places — cannot be written with a dot or a comma. Supply it, and write
339
+ it, the same way.
340
+ - **Numeric characters that are not digits are refused outright.** `2 m²` and
341
+ `½ inch` fail, along with the `②` and `Ⅲ` the rule is there to stop.
342
+ - **A magnitude letter glued to a digit is refused.** `4K display` fails.
343
+ `4 K display` and `1kg` do not, because a letter followed by another letter is
344
+ a unit, not a multiplier.
345
+ - **A run no locale reads as a number needs its exact spelling.** `24.12.2026`
346
+ is supported by a fact of `'24.12.2026'` and by nothing else.
347
+ - **Scientific notation in the copy is unsupported by construction.** A fact of
348
+ `1e-7` is laid out as `0.0000001`, and the text `"1e-7 g"` reads as the two
349
+ numerals 1 and 7, so the two never meet. `"0.0000001 g"` passes. This is the
350
+ bill for laying facts out positionally, and a lab or bulk-goods shop that
351
+ prints the value the way its own data prints it pays it.
352
+
353
+ ## Licence
354
+
355
+ MIT
@@ -0,0 +1,3 @@
1
+ export declare function stripInvisible(text: string): string;
2
+ export declare function stripMarks(text: string): string;
3
+ //# sourceMappingURL=hidden.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden.d.ts","sourceRoot":"","sources":["../src/hidden.ts"],"names":[],"mappings":"AAmBA,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C"}
package/dist/hidden.js ADDED
@@ -0,0 +1,22 @@
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
+ * Characters that render as nothing: format characters, default-ignorable code points
5
+ * and the controls. Cf and Default_Ignorable each hold characters the other does not.
6
+ */
7
+ const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
8
+ /** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
9
+ const SPACING = /[\t\n\v\f\r\u0085]/;
10
+ /**
11
+ * Marks that hang on the character before them instead of taking a column of their own,
12
+ * so `4<U+0305>9` reads as 49. Spacing marks (Mc) are not here: a Devanagari matra is
13
+ * a visible gap between two digits and separates them for a reader.
14
+ */
15
+ const MARKS = /[\p{Mn}\p{Me}]/gu;
16
+ export function stripInvisible(text) {
17
+ return text.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''));
18
+ }
19
+ export function stripMarks(text) {
20
+ return text.replace(MARKS, '');
21
+ }
22
+ //# sourceMappingURL=hidden.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden.js","sourceRoot":"","sources":["../src/hidden.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,oEAAoE;AAEpE;;;GAGG;AACH,MAAM,SAAS,GAAG,kDAAkD,CAAC;AAErE,wFAAwF;AACxF,MAAM,OAAO,GAAG,oBAAoB,CAAC;AAErC;;;;GAIG;AACH,MAAM,KAAK,GAAG,kBAAkB,CAAC;AAEjC,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { verify, verifyFields, type BatchResult, type Facts, type FieldResult, type Finding, type Layer, type LayerReport, type VerifyResult, } from './verify.js';
2
+ export { BANNED_PHRASES } from './phrases.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,MAAM,EACN,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @rudra-js/attested — the guarantee that a model cannot lie about a product.
2
+ export { verify, verifyFields, } from './verify.js';
3
+ export { BANNED_PHRASES } from './phrases.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAE9E,OAAO,EACL,MAAM,EACN,YAAY,GAQb,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,12 @@
1
+ export interface Numeral {
2
+ /** The run exactly as it appears in the text. */
3
+ token: string;
4
+ /** Every reading of that run, canonical. Empty when the layer cannot read the run. */
5
+ forms: string[];
6
+ /** `digits` is a run this layer reads. The other two it cannot read at all. */
7
+ kind: 'digits' | 'magnitude' | 'other-numeral';
8
+ }
9
+ export declare function numeralsIn(text: string): Numeral[];
10
+ /** Every reading of every numeral the host stands behind. */
11
+ export declare function supportedValues(values: readonly (string | number | bigint)[]): Set<string>;
12
+ //# sourceMappingURL=numerals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"numerals.d.ts","sourceRoot":"","sources":["../src/numerals.ts"],"names":[],"mappings":"AA2BA,MAAM,WAAW,OAAO;IACtB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,+EAA+E;IAC/E,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;CAChD;AAoJD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,CAIlD;AAmCD,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAQ1F"}
@@ -0,0 +1,205 @@
1
+ // Everything here is string work. No float ever holds a value, so a twenty-digit
2
+ // order number compares exactly.
3
+ import { stripInvisible, stripMarks } from './hidden.js';
4
+ /** A dot or a comma. Either mark is a decimal point in one locale and grouping in another. */
5
+ const AMBIGUOUS = '.,';
6
+ /** The Arabic decimal separator, which is never grouping. */
7
+ const DECIMAL_ONLY = '\u066b';
8
+ /** Marks that are only ever grouping: Arabic thousands, the space family, the apostrophe. */
9
+ const GROUPING_ONLY = '\u066c\u0020\u00a0\u2007\u2008\u2009\u202f\u0027\u2019';
10
+ const SEPARATORS = AMBIGUOUS + DECIMAL_ONLY + GROUPING_ONLY;
11
+ const RUN = `\\p{Nd}+(?:[${SEPARATORS}]\\p{Nd}+)*`;
12
+ /** Marks that multiply the digits in front of them: 万 億 兆, and the k/M/B of English copy. */
13
+ const MAGNITUDE = '(?:[十百千万萬億亿兆]|[kKMB](?!\\p{L}))';
14
+ /** Numeric characters that are not decimal digits: ½ ² ② Ⅲ. */
15
+ const OTHER_NUMERAL = '[\\p{No}\\p{Nl}]+';
16
+ const SCAN = new RegExp(`${RUN}${MAGNITUDE}|${RUN}|${OTHER_NUMERAL}`, 'gu');
17
+ const LOOSE = new RegExp(`[${GROUPING_ONLY}]`, 'u');
18
+ const IS_DIGIT = /^\p{Nd}$/u;
19
+ const STARTS_DIGIT = /^\p{Nd}/u;
20
+ const ENDS_DIGIT = /\p{Nd}$/u;
21
+ const zeros = new Map();
22
+ /**
23
+ * Unicode writes decimal digits in complete sets of ten, and adjacent sets abut, so
24
+ * the start of a maximal run of digit code points is a zero and the offset into that
25
+ * run, modulo ten, is the value. Walking back without that modulo reads every digit
26
+ * of the second and later sets in the maths block as a nine.
27
+ */
28
+ function digitValue(char) {
29
+ const code = char.codePointAt(0) ?? 0;
30
+ let zero = zeros.get(code);
31
+ if (zero === undefined) {
32
+ zero = code;
33
+ while (zero > 0 && IS_DIGIT.test(String.fromCodePoint(zero - 1)))
34
+ zero -= 1;
35
+ zeros.set(code, zero);
36
+ }
37
+ return (code - zero) % 10;
38
+ }
39
+ function toAscii(token) {
40
+ let out = '';
41
+ for (const char of token) {
42
+ out += SEPARATORS.includes(char) ? char : String(digitValue(char));
43
+ }
44
+ return out;
45
+ }
46
+ /** Strips the zeros that carry no value, so 39.00 and 39 are one string. */
47
+ function canonical(whole, fraction) {
48
+ let start = 0;
49
+ while (start < whole.length - 1 && whole[start] === '0')
50
+ start += 1;
51
+ let end = fraction.length;
52
+ while (end > 0 && fraction[end - 1] === '0')
53
+ end -= 1;
54
+ const head = whole.slice(start);
55
+ const tail = fraction.slice(0, end);
56
+ return tail.length === 0 ? head : `${head}.${tail}`;
57
+ }
58
+ /** Whether these runs read as one grouped whole number: 12,345,678 or 1,29,999. */
59
+ function isGrouping(groups) {
60
+ if (groups.length === 1)
61
+ return true;
62
+ if ((groups[groups.length - 1] ?? '').length !== 3)
63
+ return false;
64
+ let three = true;
65
+ let two = true;
66
+ for (let index = 1; index < groups.length - 1; index += 1) {
67
+ const size = (groups[index] ?? '').length;
68
+ if (size !== 3)
69
+ three = false;
70
+ if (size !== 2)
71
+ two = false;
72
+ }
73
+ const lead = (groups[0] ?? '').length;
74
+ if (three)
75
+ return lead >= 1 && lead <= 3;
76
+ // 1,29,999 — the Indian grouping, two digits at a time above the last three.
77
+ if (two)
78
+ return lead >= 1 && lead <= 2;
79
+ return false;
80
+ }
81
+ /** Whether every mark is the same one, and every one of them can be grouping. */
82
+ function allGrouping(separators) {
83
+ for (const separator of separators) {
84
+ if (separator !== separators[0])
85
+ return false;
86
+ if (DECIMAL_ONLY.includes(separator))
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+ /** `1,299` is 1299 in London and in Berlin. `1234,567` is a decimal in both. */
92
+ function formsOf(token) {
93
+ const ascii = toAscii(token);
94
+ const groups = [];
95
+ const separators = [];
96
+ let current = '';
97
+ for (const char of ascii) {
98
+ if (SEPARATORS.includes(char)) {
99
+ groups.push(current);
100
+ separators.push(char);
101
+ current = '';
102
+ }
103
+ else {
104
+ current += char;
105
+ }
106
+ }
107
+ groups.push(current);
108
+ if (separators.length === 0)
109
+ return [canonical(groups[0] ?? '', '')];
110
+ const forms = [];
111
+ if (allGrouping(separators) && isGrouping(groups))
112
+ forms.push(canonical(groups.join(''), ''));
113
+ const whole = groups.slice(0, -1);
114
+ const leading = separators.slice(0, -1);
115
+ const point = separators[separators.length - 1] ?? '';
116
+ const fraction = groups[groups.length - 1] ?? '';
117
+ // One dot or comma with exactly three digits behind it is grouping wherever it is
118
+ // written, so `4.800` is four thousand eight hundred and never the rating 4.8.
119
+ const groupingWins = forms.length > 0 && leading.length === 0 && fraction.length === 3;
120
+ if (!groupingWins &&
121
+ !GROUPING_ONLY.includes(point) &&
122
+ allGrouping(leading) &&
123
+ leading[0] !== point &&
124
+ isGrouping(whole)) {
125
+ forms.push(canonical(whole.join(''), fraction));
126
+ }
127
+ return forms;
128
+ }
129
+ function collect(found, token) {
130
+ if (!STARTS_DIGIT.test(token)) {
131
+ found.push({ token, forms: [], kind: 'other-numeral' });
132
+ return;
133
+ }
134
+ if (!ENDS_DIGIT.test(token)) {
135
+ found.push({ token, forms: [], kind: 'magnitude' });
136
+ return;
137
+ }
138
+ const forms = formsOf(token);
139
+ if (forms.length > 0) {
140
+ found.push({ token, forms, kind: 'digits' });
141
+ return;
142
+ }
143
+ // `8 10 12` is three numbers, not one badly grouped one.
144
+ if (LOOSE.test(token)) {
145
+ for (const part of token.split(LOOSE)) {
146
+ if (part.length > 0)
147
+ collect(found, part);
148
+ }
149
+ return;
150
+ }
151
+ // No locale reads `24.12.2026` as a number, so only a fact written the same way
152
+ // stands behind it. The raw run carries a mark, and a reading never does, so it
153
+ // can never be mistaken for one.
154
+ found.push({ token, forms: [toAscii(token)], kind: 'digits' });
155
+ }
156
+ export function numeralsIn(text) {
157
+ const found = [];
158
+ for (const match of stripMarks(stripInvisible(text)).matchAll(SCAN))
159
+ collect(found, match[0]);
160
+ return found;
161
+ }
162
+ /** A whole fact in exponent notation and nothing else, so expanding it swallows no prose. */
163
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/;
164
+ /** `String(1e21)` is `1e+21`, which reads as the two numerals 1 and 21. Lay the same digits out in place. */
165
+ function positional(written) {
166
+ const marker = written.search(/[eE]/);
167
+ if (marker < 0)
168
+ return written;
169
+ const power = Number(written.slice(marker + 1));
170
+ // The sign never reaches a numeral, so dropping it here mints no digit the host lacks.
171
+ const body = written.slice(0, marker).replace(/^[+-]/, '');
172
+ const point = body.indexOf('.');
173
+ const digits = point < 0 ? body : body.slice(0, point) + body.slice(point + 1);
174
+ const place = (point < 0 ? body.length : point) + power;
175
+ if (place <= 0)
176
+ return `0.${'0'.repeat(-place)}${digits}`;
177
+ if (place >= digits.length)
178
+ return `${digits}${'0'.repeat(place - digits.length)}`;
179
+ return `${digits.slice(0, place)}.${digits.slice(place)}`;
180
+ }
181
+ /**
182
+ * A string fact is read as the host typed it, except when the whole of it is a number
183
+ * in exponent notation: `String`, `JSON.stringify`, a CSV export and an API that writes
184
+ * 64-bit values as strings all produce that, and none of them is the host choosing digits.
185
+ */
186
+ function digitsOf(value) {
187
+ if (typeof value === 'number')
188
+ return positional(String(value));
189
+ if (typeof value !== 'string')
190
+ return String(value);
191
+ const trimmed = value.trim();
192
+ return BARE_EXPONENT.test(trimmed) ? positional(trimmed) : value;
193
+ }
194
+ /** Every reading of every numeral the host stands behind. */
195
+ export function supportedValues(values) {
196
+ const supported = new Set();
197
+ for (const value of values) {
198
+ for (const numeral of numeralsIn(digitsOf(value))) {
199
+ for (const form of numeral.forms)
200
+ supported.add(form);
201
+ }
202
+ }
203
+ return supported;
204
+ }
205
+ //# sourceMappingURL=numerals.js.map