@rudra-js/attested 0.8.2 → 0.9.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 (2) hide show
  1. package/README.md +242 -245
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -2,34 +2,36 @@
2
2
 
3
3
  This package is part of [rudra-js](https://rudrajs.com).
4
4
 
5
- rudra-js is an open-source TypeScript library (MIT) you install from npm. A language model designs one product-recommendation block for a page in your shop, rudra-js checks it against your catalog, and React renders it.
5
+ rudra-js is an open-source TypeScript library (MIT) you install from npm. A
6
+ language model designs one product-recommendation block for a page in your shop,
7
+ rudra-js checks it against your catalog, and React renders it.
6
8
 
7
- It is not a hosted platform, a visual or low-code app builder, or a workspace you sign up for.
9
+ It is not a hosted platform, a visual or low-code app builder, or a workspace
10
+ you sign up for.
8
11
 
9
- Checks model-written product copy against the facts a shop stands behind.
12
+ This package checks model-written product copy against the facts your shop
13
+ stands behind.
10
14
 
11
- Both entry points are documented at [rudrajs.com](https://rudrajs.com/docs#verify).
15
+ The reference for both functions is on rudrajs.com:
16
+ [`verify`](https://rudrajs.com/reference/attested/verify) and
17
+ [`verifyFields`](https://rudrajs.com/reference/attested/verifyFields).
12
18
 
13
- Zero dependencies, no node builtins, no I/O. It is one pure function over text
14
- and facts, so it runs in Node, in an edge runtime and in a browser, and it drops
15
- in after anything that writes copy — json-render, the Vercel AI SDK, a raw
16
- `fetch` to a model, your own template.
19
+ It's a pure function over text and facts. It has no dependencies and does no
20
+ I/O. It uses no Node built-ins either, so it runs in a browser or an edge
21
+ runtime as well as in Node. Drop it in after anything that writes copy:
22
+ json-render, the Vercel AI SDK, a raw `fetch` to a model, or your own template.
17
23
 
18
24
  ## The guarantee
19
25
 
20
26
  **When `quantity.supported` is true, every numeral in the text is one you
21
- supplied — and a numeral the reader cannot read counts as a failure, not a
22
- pass.**
27
+ supplied. A numeral the package can't read counts as a failure.**
23
28
 
24
- That is the whole promise, and it is a promise about numerals, not about
25
- sentences. It does not say the sentence around the number is true: a price you
26
- supplied can still be written as a sales figure. It says nothing at all about
27
- `wording.supported`, which is a denylist over an open set and carries
28
- `strength: 'best-effort'` on every result for that reason.
29
+ That's the whole promise, and it covers numerals only. It doesn't say the
30
+ sentence around a number is true. It says nothing about `wording.supported`.
29
31
 
30
- Everything the guarantee leaves open is listed under
31
- [What it cannot catch](#what-it-cannot-catch). Nothing there is a bug queued for
32
- later; it is the shape of what a closed check over digits can promise.
32
+ The gaps it leaves are listed under
33
+ [What it cannot catch](#what-it-cannot-catch). None of them is a bug waiting for
34
+ a fix.
33
35
 
34
36
  ## Install
35
37
 
@@ -49,136 +51,144 @@ result.quantity.findings;
49
51
 
50
52
  ## The two layers
51
53
 
52
- They are checked separately and reported separately, because only one of them is
53
- a proof.
54
+ `verify` checks two layers and reports each one separately. Layer one,
55
+ quantities, is a proof. Layer two, wording, is best-effort.
54
56
 
55
- ### Layer one — quantities. This is the provable half.
57
+ ### Layer one: quantities
56
58
 
57
- Every run of digits in the text has to be a value you supplied. Not "does this
58
- read like a bad phrase" but "is this number one the shop stands behind". The set
59
- of digits in a string is closed and checkable, and it does not care what
60
- language the sentence is in.
59
+ Layer one finds every run of digits in the text and checks it against the values
60
+ you supplied. It works the same whatever language the sentence is in.
61
61
 
62
62
  ```ts
63
63
  verify('Nur noch 2 übrig', { values: [39] }).quantity.supported; // false
64
64
  verify('4,8 von 5', { values: [4.8, 5] }).quantity.supported; // true
65
65
  ```
66
66
 
67
- It is crude on purpose and it errs toward rejection. `"Only 2 left"` is
68
- unsupported unless you supplied a fact carrying 2, even though 2 is also the
69
- number of colourways. A false rejection costs a duller sentence; a false
70
- acceptance costs a fabricated claim.
71
-
72
- **What it reads.** Bare integers and decimals, both decimal separators (`4.8`
73
- and `4,8`), currency symbols attached or detached, percentages, and any Unicode
74
- decimal digit — Eastern Arabic `٤٫٨`, Devanagari `४.८` and mathematical `𝟰.𝟴`
75
- all read as 4.8. Grouping is read in the Western (`12,345,678`), Indian
76
- (`1,29,999`), space (`1 299`, including the no-break and narrow no-break spaces)
77
- and Swiss (`1'299`) shapes.
78
-
79
- **How an ambiguous mark is decided.** One dot or comma with exactly three digits
80
- behind it is grouping, never a decimal point. `1,299` and `1.299` both read as
81
- 1299 and nothing else, which is why a rating of 4.8 cannot stand behind a
82
- written `4.800` and a stock count of 12 cannot stand behind `12.000`. Where
83
- grouping is impossible the decimal reading survives, so `1234,567` is still
84
- 1234.567. Where the two marks differ the last one is the decimal point, so
85
- `1.299,50` is 1299.5.
86
-
87
- **What it refuses to read.** Three things become findings rather than passes:
88
-
89
- - A numeric character that is not a decimal digit — `½`, `²`, `②`, `Ⅲ`. These
90
- render as numbers and carry a value the reader has no way to check.
91
- - A magnitude mark glued to a run — `4.8万`, `12M`, `4.8k`. The digits may be
92
- yours; the multiplier is not.
93
- - A run no locale reads as a number — `24.12.2026`, `3.14.15`. These are
94
- supported only by a fact written exactly the same way.
95
-
96
- Characters that take no room on the page are dropped before any of this, so a
97
- zero-width space, a soft hyphen, a variation selector, a backspace or a
98
- combining overline cannot split `213` into a `2` and a `13` that happen to be
99
- supported separately. That class is every format character, every
100
- default-ignorable code point, every control character and every mark that hangs
101
- on the character before it — a rule over the whole of Unicode rather than a list
102
- someone kept up to date.
67
+ It leans toward rejecting. `"Only 2 left"` fails unless you supplied a fact
68
+ carrying 2, even when 2 is also the number of colourways. A wrong rejection
69
+ costs you a duller sentence. A wrong pass costs you a made-up claim.
70
+
71
+ **What it reads.**
72
+
73
+ - Whole numbers and decimals.
74
+ - Both decimal separators: `4.8` and `4,8`.
75
+ - Currency symbols, attached or detached.
76
+ - Percentages.
77
+ - Any Unicode decimal digit. Eastern Arabic `٤٫٨`, Devanagari `४.८` and
78
+ mathematical `𝟰.𝟴` all read as 4.8.
79
+ - Digit grouping in the Western (`12,345,678`), Indian (`1,29,999`), space
80
+ (`1 299`) and Swiss (`1'299`) shapes. The space shape also accepts the
81
+ no-break and narrow no-break spaces.
82
+
83
+ **How it reads a dot or a comma.**
84
+
85
+ - One dot or comma with exactly three digits after it is grouping, never a
86
+ decimal point. `1,299` and `1.299` both read as 1299 and nothing else. So a
87
+ rating of 4.8 can't back a written `4.800`, and a stock count of 12 can't back
88
+ `12.000`.
89
+ - Where grouping is impossible, the decimal reading stands. `1234,567` is
90
+ 1234.567.
91
+ - Where the two marks differ, the last one is the decimal point. `1.299,50` is
92
+ 1299.5.
93
+
94
+ **What it refuses to read.** Layer one reports these as findings:
95
+
96
+ - A numeric character that isn't a decimal digit, like `½`, `²`, `②` or `Ⅲ`.
97
+ These show up on the page as numbers, and the package has no way to check the
98
+ value they carry.
99
+ - A magnitude mark glued to a run of digits, like `4.8万`, `12M` or `4.8k`. The
100
+ digits may be yours. The multiplier isn't.
101
+ - A run no locale reads as a number, like `24.12.2026` or `3.14.15`. Only a fact
102
+ written exactly the same way backs it.
103
+
104
+ Before any of this, layer one drops characters that take no room on the page. So
105
+ a zero-width space, a soft hyphen, a variation selector, a backspace or a
106
+ combining overline can't split `213` into a `2` and a `13` that you happen to
107
+ have supplied separately. The rule works from Unicode's own categories, so it
108
+ covers all of Unicode with no hand-kept list. It drops:
109
+
110
+ - every format character
111
+ - every default-ignorable code point
112
+ - every control character except tabs and line breaks
113
+ - every mark that attaches to the character before it
103
114
 
104
115
  A character that does take room still separates two numbers. A tab, a line break
105
- and a Devanagari matra all leave a gap the shopper can see, so `"Only 2\n3 left"`
106
- is two numbers and a fact of 23 does not stand behind it.
116
+ and a Devanagari matra all leave a gap the shopper can see. So
117
+ `"Only 2\n3 left"` is two numbers, and a fact of 23 doesn't back it.
107
118
 
108
- ### Layer two — wording. This is the best-effort half.
119
+ ### Layer two: wording
109
120
 
110
- Claims with no number in them: "cheap", "bargain", "on sale", "best-selling",
111
- "selling fast", "in stock", "free delivery", "trending". There is no value to
112
- check, so there is nothing to invert. It stays a denylist, it stays open and
113
- English-first, and every result it produces says so:
121
+ Layer two looks for claims with no number in them: "cheap", "bargain", "on
122
+ sale", "best-selling", "selling fast", "in stock", "free delivery", "trending".
123
+ There's no value to check, so this layer is a denylist, a list of banned
124
+ phrases. Every result it gives carries `strength: 'best-effort'`:
114
125
 
115
126
  ```ts
116
127
  verify('Selling fast', { values: [] }).wording;
117
128
  // { supported: false, strength: 'best-effort', checked: 125, findings: [...] }
118
129
  ```
119
130
 
120
- The list also carries generic environmental claims — "eco-friendly", "carbon
121
- neutral", "sustainable". Under EU Directive 2024/825, applied through each
122
- country's own law from 27 September 2026, a generic environmental claim is
131
+ The list also covers generic environmental claims, like "eco-friendly", "carbon
132
+ neutral" and "sustainable". EU Directive 2024/825 applies through each country's
133
+ own law from 27 September 2026. Under it, a generic environmental claim is
123
134
  unfair unless the trader can show recognised excellent environmental
124
- performance, and a "neutral" claim based on offsetting is unfair outright. The
125
- list leaves out the bare word "green", which is too often a colour.
135
+ performance. A "neutral" claim based on offsetting is unfair outright. The list
136
+ leaves out the bare word "green", because it's too often a colour.
126
137
 
127
- Add your own for your own language, and name the wording you stand behind:
138
+ Add phrases for your own language, and name the wording you stand behind:
128
139
 
129
140
  ```ts
130
141
  verify('Nur noch wenige', { values: [], bannedPhrases: ['nur noch'] });
131
142
  verify('In stock, ships today', { values: [], allowedPhrases: ['in stock'] });
132
143
  ```
133
144
 
134
- `bannedPhrases` is added to the built-in list. `allowedPhrases` works on the
135
- text, not on the list: **where one of your phrases appears, a banned claim
136
- sitting wholly inside it is not reported, and the same words elsewhere in the
137
- copy still are.** So `['back in stock']` clears `"Back in stock"` and still
138
- catches the `in stock` in `"Only 2 in stock, selling fast"`. `checked` is the
139
- whole list either way, because nothing leaves it.
140
-
141
- Without an allowance a denylist bans the truthful disclosure as hard as the
142
- fabricated one, and a shop that genuinely offers free shipping cannot say so.
143
- Three things are worth knowing before you write one:
144
-
145
- - **Containment runs one way.** Allowing `in stock` does not clear
146
- `back in stock`, which claims a restock on top of it.
147
- - **Allowances do not compose, and they do not join.**
148
- `['sold', 'out']` leaves `sold out` caught, and `['back in', 'stock']` leaves
149
- both `in stock` and `back in stock` caught.
150
- - **An allowance forgives whatever nests inside it, including entries added in a
151
- later version.** `['in stock and ready to ship']` forgives nothing extra
152
- today; the day `ready to ship` joins the list, that allowance silences it
153
- wherever your sentence appears. Keep them tight.
154
- - **An allowance does not cross a line break.** The denylist reads straight
155
- through one, so `"Free\n\nshipping"` is still caught, but an allowance will
156
- not bridge it. The model picks where the paragraph breaks fall, and
157
- `"We do not offer"` above a horizontal rule, with the free shipping claim
158
- below it, is two things a shopper reads apart. The two rules pull opposite
159
- ways on purpose: the denylist catches more, the allowance forgives less, and
160
- both err toward reporting.
161
-
162
- It is also the way around negation. The list matches substrings, so
163
- `"there is no sale on this product"` reads as a sale claim; allow `no sale` and
164
- that sentence passes while a `sale` later in the same text still fails.
165
-
166
- Matching is case-insensitive. Hyphens become spaces, accents are composed,
167
- fullwidth forms are folded, characters that take no room are dropped, and a
168
- handful of Cyrillic and Greek letters that render as Latin ones are folded to
169
- Latin. Accents are composed before anything is dropped, so `café` keeps its `é`
170
- while an overline, which composes with nothing, cannot hide `free` from its own
171
- entry. Greek letters lose their accents, so `ΕΥΚΑΙΡΙΑ` matches `ευκαιρία`.
172
- Greek words told apart only by their accent read as one, so `προσφορά` also catches
173
- `πρόσφορα`. A line break stays a line break: matching reads through it, and
174
- only an allowance refuses to cross it. Spaces are dropped from both sides
175
- before matching, so `送料 無料` still
176
- reads as `送料無料`. A phrase whose first or last character is an ASCII letter or
177
- digit will not match glued inside a longer word, so `cheap` misses `cheapskate`,
178
- but a trailing `s` is allowed, so `discount` catches `discounts`. The rule reads
179
- the folded letters, so a Greek phrase that starts or ends on a letter folded to
180
- Latin is held to it too: `φθηνό` misses `φθηνότερο`. List each form you want
181
- caught.
145
+ `bannedPhrases` adds to the built-in list. `allowedPhrases` works on the text,
146
+ not on the list. **Where one of your allowed phrases appears, a banned claim
147
+ sitting wholly inside it isn't reported. The same words elsewhere in the copy
148
+ still are.** So `['back in stock']` clears `"Back in stock"` but still catches
149
+ the `in stock` in `"Only 2 in stock, selling fast"`. `checked` counts the whole
150
+ list either way, because an allowance removes nothing from it.
151
+
152
+ Without an allowance, the denylist bans a true statement as hard as a made-up
153
+ one. A shop that really offers free shipping couldn't say so. Before you write
154
+ an allowance, know this:
155
+
156
+ - **A shorter allowance doesn't clear a longer claim.** Allowing `in stock`
157
+ doesn't clear `back in stock`, which also claims a restock.
158
+ - **Allowances don't combine, and they don't join up.** `['sold', 'out']` leaves
159
+ `sold out` caught. `['back in', 'stock']` leaves both `in stock` and
160
+ `back in stock` caught.
161
+ - **An allowance forgives whatever sits inside it, including phrases added to
162
+ the list in a later version.** `['in stock and ready to ship']` forgives
163
+ nothing extra today. The day `ready to ship` joins the list, that allowance
164
+ silences it wherever your sentence appears. Keep allowances tight.
165
+ - **An allowance doesn't cross a line break.** The denylist reads straight
166
+ through one, so `"Free\n\nshipping"` is still caught. An allowance won't
167
+ bridge it. The model decides where paragraph breaks fall. A shopper reads
168
+ `"We do not offer"` above a horizontal rule and a free shipping claim below it
169
+ as two separate things. Both rules lean toward reporting a claim.
170
+
171
+ Before matching, layer two folds the copy and the phrases into one plain form:
172
+
173
+ - Case is ignored.
174
+ - Hyphens become spaces.
175
+ - Accents are composed, so `café` keeps its `é`. This happens before anything is
176
+ dropped. An overline composes with nothing, so it gets dropped and can't hide
177
+ `free` from its own entry.
178
+ - Fullwidth forms are folded to their normal-width versions.
179
+ - Characters that take no room are dropped.
180
+ - A handful of Cyrillic and Greek letters that look like Latin ones are folded
181
+ to Latin.
182
+ - Greek letters lose their accents, so `ΕΥΚΑΙΡΙΑ` matches `ευκαιρία`. Greek
183
+ words told apart only by their accent read as one, so `προσφορά` also catches
184
+ `πρόσφορα`.
185
+ - Spaces are dropped, so `送料 無料` still reads as `送料無料`.
186
+
187
+ A phrase that starts or ends with an ASCII letter or digit won't match glued
188
+ inside a longer word. So `cheap` misses `cheapskate`. A trailing `s` is allowed,
189
+ so `discount` catches `discounts`. This word-gap rule reads the folded letters.
190
+ So a Greek phrase that starts or ends on a letter folded to Latin is held to it
191
+ too: `φθηνό` misses `φθηνότερο`. List each form you want caught.
182
192
 
183
193
  ## The result
184
194
 
@@ -203,11 +213,10 @@ interface Finding {
203
213
  }
204
214
  ```
205
215
 
206
- `checked` says how much the layer actually looked at. `quantity.checked === 0`
207
- means the text held no numeral and layer one proved nothing about it — which is
208
- the honest reading of `"Only two left"`, and no assertion you can write on the
209
- result will turn that into a catch. Layer one counts the numerals it refuses to
210
- read, so `"Only ② left"` is a finding rather than a silent zero.
216
+ `checked` tells you how much a layer actually looked at. When `quantity.checked`
217
+ is 0, the text held no numeral and layer one proved nothing about it. Layer one
218
+ counts the numerals it refuses to read, so `"Only ② left"` gives a finding
219
+ instead of a silent zero.
211
220
 
212
221
  ## Facts
213
222
 
@@ -219,54 +228,46 @@ interface Facts {
219
228
  }
220
229
  ```
221
230
 
222
- `values` is a flat list, not a typed one. Write them however they are written in
223
- your own system — `39`, `'$1,299.00'`, `'4,8'`, `'ships 13 March'` — and every
224
- numeral in each one becomes a supported value.
225
-
226
- A number is laid out in positional notation first, whatever `String` would have
227
- made of it, so a fact of `1e21` stands behind
228
- `'1,000,000,000,000,000,000,000'` and behind neither `1` nor `21`. A string
229
- whose whole content is a number in exponent notation is laid out the same way:
230
- `String`, `JSON.stringify`, a spreadsheet export and an API that writes 64-bit
231
- values as strings all produce one, and none of those is you choosing the digits.
232
- Any other string is read exactly as you typed it, so `'SKU AX-220e5'` still
233
- mints 220 and 5.
234
-
235
- An exponent that would lay out past a thousand digits stands behind no numeral at
236
- all, and the length is weighed before any of it is written. `'1e1000'` is no
237
- product fact under any reading, so it is refused at the limit rather than laid
238
- out. The limit is what stops the far end: `'1e2000000000'` is twelve characters,
239
- and writing its run out would cost more memory than the process has. Every finite
240
- JavaScript number fits well inside the limit — the longest is `5e-324`, at 326
231
+ `values` is one flat list with no types. Write each value the way your own
232
+ system writes it, like `39`, `'$1,299.00'`, `'4,8'` or `'ships 13 March'`. Every
233
+ numeral in each value becomes a supported value.
234
+
235
+ A number is written out in plain digits first, whatever `String` would have made
236
+ of it. So a fact of `1e21` backs `'1,000,000,000,000,000,000,000'` and backs
237
+ neither `1` nor `21`. A string that holds nothing but a number in exponent
238
+ notation is written out the same way. `String`, `JSON.stringify`, spreadsheet
239
+ exports and APIs that write 64-bit values as strings all produce such strings.
240
+ You didn't pick those digits. Any other string is read exactly as
241
+ you typed it, so `'SKU AX-220e5'` still supplies 220 and 5.
242
+
243
+ A number in exponent notation that would run past a thousand digits when written
244
+ out backs no numeral at all.
245
+ The package checks the length before it writes anything out. `'1e1000'` isn't a
246
+ product fact under any reading, so it hits the limit and is refused. The limit
247
+ stops the extreme cases. `'1e2000000000'` is twelve characters, but writing it
248
+ out would take more memory than the process has. Every finite
249
+ JavaScript number fits well inside the limit. The longest is `5e-324`, at 326
241
250
  characters.
242
251
 
243
- Past 2^53 a number has lost its exact digits before it ever reaches here —
244
- `JSON.parse` turns `9007199254740993` into `...92` — so hand a long id over as a
245
- bigint or as a string. A null or undefined field stands behind no numeral rather
246
- than throwing, so one empty field does not take the whole call down. Anything
247
- else is read through `String()` and mints whatever digits that produces, which
248
- for a `Date` is the day, the year and every digit of the clock — so a `listedAt`
249
- handed straight through is the hazard named under **What it cannot catch**, not
250
- an ignored field.
251
-
252
- Flat rather than typed (money, count, rating, date) because typing only helps if
253
- the extractor can classify a token in the text, and it cannot. The `2` in
254
- `"Only 2 left"` and the `2` in `"2 year warranty"` are the same two characters;
255
- telling them apart means understanding the sentence, in whatever language it was
256
- written, which is the problem this package exists to stop relying on. Typed
257
- facts would also need an "unclassified, so skip it" branch, and that branch is a
258
- hole. A flat set has no hole: every numeral is checked against every value.
259
-
260
- The cost is real and it is stated under **What it cannot catch**. Two things
261
- follow from it that are worth doing:
262
-
263
- - **Supply only numbers you are happy to see anywhere.** A SKU of `AT-2199` in
264
- `values` makes `"Was $2,199"` a supported sentence. A listing date makes its
265
- day number a supported sales figure. If a number in your record is not a fact
266
- about the product a shopper would read, leave it out.
267
- - **`values` is a trust boundary.** On a marketplace the product title is
268
- written by the seller, not by you. Putting it in `values` hands the model
269
- every number the seller chose to type. Supply fields you author.
252
+ Past 2^53, a number has lost its exact digits before it ever reaches this
253
+ package. `JSON.parse` turns `9007199254740993` into `...92`. Pass a long id as a
254
+ bigint or a string.
255
+
256
+ A null or undefined field backs no numeral and doesn't throw, so one empty field
257
+ doesn't break the whole call. Anything else is read through `String()` and
258
+ supplies whatever digits that gives. For a `Date`, that's the day, the year and
259
+ every digit of the clock. So a `listedAt` passed straight through isn't ignored.
260
+ It's one of the hazards under [What it cannot catch](#what-it-cannot-catch).
261
+
262
+ That section also lists what a flat list costs you. Two habits keep the cost
263
+ down:
264
+
265
+ - **Only supply numbers you're happy to see anywhere.** A SKU of `AT-2199` in
266
+ `values` makes `"Was $2,199"` a supported sentence. If a number in your record
267
+ isn't a fact about the product that a shopper would read, leave it out.
268
+ - **Treat `values` as a trust boundary.** On a marketplace, the seller writes
269
+ the product title, not you. Putting it in `values` hands the model every
270
+ number the seller chose to type. Supply only fields you write.
270
271
 
271
272
  ## Many fields, one product
272
273
 
@@ -283,103 +284,99 @@ result.fields; // [{ field, result }, ...] in the order the fields were given
283
284
  result.acrossFields; // the wording layer over every field read as one
284
285
  ```
285
286
 
286
- The facts are read once and reused. A card renders its fields next to each
287
- other, and the model picks the field boundaries, so
288
- `{ badge: 'Free', delivery: 'delivery on every order' }` is one sentence to a
289
- shopper while neither field carries it alone. `acrossFields` reads the joined
290
- text and counts toward `supported`.
287
+ The facts are read once and reused for every field. A card shows its fields next
288
+ to each other, and the model picks where one field ends and the next begins. So
289
+ `{ badge: 'Free', delivery: 'delivery on every order' }` reads as one sentence
290
+ to a shopper, though neither field carries the claim alone. `acrossFields`
291
+ checks the joined text, and it counts toward `supported`.
291
292
 
292
293
  ## What it cannot catch
293
294
 
294
- This is the honest list.
295
-
296
295
  ### Claims with a number in them
297
296
 
298
- - **A number that is real but means something else.** This is the largest hole
299
- in the provable half. A flat fact set proves the number came from you, not
300
- that the sentence uses it correctly. Supply a price of 39 and `"39 sold in the
301
- last hour"`, `"Save $39 today"` and `"Under $39"` all pass. Supply a rating
302
- scale of 5 and `"Only 5 left"` passes. Supply a rating of 4.8 and a German
303
- `"nur 4,80 €"` passes as a price, because 4.80 and 4.8 are one number.
297
+ - **A number that is real but means something else.** This is the biggest gap in
298
+ layer one. Supply a price of 39, and `"39 sold in the last hour"`,
299
+ `"Save $39 today"` and `"Under $39"` all pass. Supply a rating scale of 5, and
300
+ `"Only 5 left"` passes. Supply a rating of 4.8, and a German `"nur 4,80 €"`
301
+ passes as a price, because 4.80 and 4.8 are the same number.
304
302
  - **Numbers written as words, in any script.** `"Only two left"` has no digit in
305
- it, so layer one never sees it. Neither does `残り二点`, where 二 is a
306
- character rather than a digit, nor Arabic `قطعتين`, where "two" is the noun's
307
- dual ending and there is no numeral at all. `quantity.checked` is 0 for all
308
- three, and that is the only signal you get.
309
- - **Magnitudes written as words.** `4.8万` and `12M` are caught. `4,8 Millionen`,
310
- `4.8 millions`, `4,8 mil`, `4.8 लाख` and `٤٫٨ مليون` are not — a word list is
311
- the denylist this package exists to get away from.
312
- - **The unit on the number.** `¥39.99` against a price of $39.99 passes: the
313
- digits are yours and the currency symbol is not a digit. So does
303
+ it, so layer one never sees it. Layer one also misses `残り二点`, where 二 is a
304
+ character rather than a digit. It misses Arabic `قطعتين` too, where "two" is
305
+ the noun's dual ending and there is no numeral at all. `quantity.checked` is 0
306
+ for all three, and that's the only signal you get.
307
+ - **Magnitudes written as words.** The multiplier in `4,8 Millionen`,
308
+ `4.8 millions`, `4,8 mil`, `4.8 लाख` or `٤٫٨ مليون` isn't caught.
309
+ - **The unit on the number.** `¥39.99` passes against a price of $39.99, because
310
+ the digits are yours and the currency symbol isn't a digit. So does
314
311
  `"39,99 Cent"`. Layer one checks the number, never what it is a number of.
315
312
  - **A number your facts happen to contain.** A fact of `'2026-09-13'` makes
316
- 2026, 9 and 13 supported everywhere in the text, including as a stock count.
317
- - **Where in the text a token was.** A finding names the token, not its offset.
313
+ 2026, 9 and 13 supported anywhere in the text, including as a stock count.
314
+ - **Where in the text a token was.** A finding names the token but not its
315
+ position.
318
316
 
319
317
  ### Claims with no number in them
320
318
 
321
- - **Wording, in general.** Layer two is a denylist over an infinite set. A
322
- careful rewording gets past it — `"Half of what it was"` for half price,
323
- `"送料は無料"` with a particle dropped in. Entries are added as they are found,
324
- and the list will never be finished.
319
+ - **Wording, in general.** There are endless ways to word a claim, and a list
320
+ can't hold them all. A careful rewording gets past layer two, like
321
+ `"Half of what it was"` for half price, or `"送料は無料"` with a particle dropped
322
+ in. Entries are added as they're found, and the list will never be finished.
325
323
  - **Your language, until you give it one.** Every built-in phrase is English.
326
324
  `"Gratis Versand"`, `"Livraison offerte"` and `"شحن مجاني"` all pass by
327
- default. `bannedPhrases` is the answer, and it is your list to keep.
328
- - **Negation.** The list matches substrings, so `"there is no sale on this
329
- product"` is caught as a sale claim on its own. There is no rule that reads the
330
- `no`; `allowedPhrases: ['no sale']` is the way out, and it is narrow by
331
- design — a `sale` elsewhere in the same text still fails.
332
- - **A character that folds into your allowance.** An allowance is matched
333
- against normalised copy, so anything that folds to those words counts as those
334
- words. Allow `no sale` and `"№ SALE"` passes, because U+2116 folds to `no`;
335
- a superscript `ⁿᵒ` does the same. The shopper reads the numero sign as
336
- decoration and the verifier reads it as a word. Keep allowances tight and
337
- written the way a shop really writes them.
338
- - **A letter glued to the end of a phrase.** `"FREE SHIPPINGᵃ see terms"` is not
339
- reported. The superscript folds to an `a`, which glues to `shipping`, and the
325
+ default. Add them with `bannedPhrases`. That list is yours to keep.
326
+ - **Negation.** The list matches substrings, so
327
+ `"there is no sale on this product"` is caught as a sale claim. No rule reads
328
+ the `no`. `allowedPhrases: ['no sale']` lets that sentence pass. That
329
+ allowance is narrow, so a `sale` elsewhere in the same text still fails.
330
+ - **A character that folds into your allowance.** Allowances match the copy
331
+ after the folding described under layer two. Anything that folds to your words
332
+ counts as your words. Allow `no sale` and `"№ SALE"` passes, because U+2116
333
+ folds to `no`. A superscript `ⁿᵒ` does the same. The shopper reads the numero
334
+ sign as decoration, but the package reads it as a word. Write allowances the
335
+ way a shop really writes them.
336
+ - **A letter glued to the end of a phrase.** `"FREE SHIPPINGᵃ see terms"` isn't
337
+ reported. The superscript folds to an `a`, which glues onto `shipping`. The
340
338
  word-gap rule that stops `cheap` matching `cheapskate` then treats the whole
341
- thing as a longer word. It holds for every entry on the list, and a footnote
339
+ thing as a longer word. This holds for every entry on the list. A footnote
342
340
  marker is exactly how a real shop writes one.
343
341
 
344
342
  ### Preconditions the package cannot check for you
345
343
 
346
344
  - **The text you check has to be the text you render.** `verify` sees a string.
347
- If anything downstream decodes it — an HTML sink turning `½` into ½, or
348
- `2` into a 2 — then the string that was checked and the string on the page
349
- are different documents, and the guarantee covers the first one.
345
+ Something downstream may decode it, like an HTML sink turning `½` into ½
346
+ or `2` into a 2. Then the string you checked and the string on the page
347
+ are different documents. The guarantee covers only the one you checked.
350
348
  - **The font has to agree with Unicode about what renders as nothing.** The
351
- layer drops what Unicode marks as taking no room. Where a font gives one of
352
- those a visible glyph the two disagree, and U+3164 HANGUL FILLER is the one
353
- people really paste as a blank: the layer reads `"1<U+3164>3"` as 13, and a
354
- font that widens it shows the shopper `1 3`.
355
- - **Everything that is not a number or a phrase.** A claim about materials,
356
- compatibility, a certification, a warranty condition or a person is entirely
357
- out of scope.
349
+ package drops what Unicode marks as taking no room. Where a font gives one of
350
+ those characters a visible glyph, the two disagree. U+3164 HANGUL FILLER is
351
+ the one people really paste as a blank. Layer one reads `"1<U+3164>3"` as 13,
352
+ while a font that widens it shows the shopper `1 3`.
353
+ - **Everything that isn't a number or a phrase.** A claim about materials,
354
+ compatibility, a certification, a warranty condition or a person is out of
355
+ scope.
358
356
 
359
357
  ## What it over-rejects
360
358
 
361
- The guarantee errs toward rejection, and that has a bill:
362
-
363
- - **Every incidental number has to be in `values`.** A VAT rate, a size, a care
364
- temperature, a spec number, a support phone number, a founding year, the
365
- denominator of a rating scale. None of them are claims, all of them are
366
- digits, and copy mentioning one you did not supply is rejected.
367
- - **A three-digit tail is always grouping.** `1.299 kg` reads as 1299, so a
368
- genuine three-decimal number — a weight, or a price in a currency with three
369
- decimal places — cannot be written with a dot or a comma. Supply it, and write
370
- it, the same way.
371
- - **Numeric characters that are not digits are refused outright.** `2 m²` and
372
- `½ inch` fail, along with the `②` and `Ⅲ` the rule is there to stop.
359
+ Leaning toward rejection has a cost:
360
+
361
+ - **Every incidental number has to be in `values`.** That means a VAT rate, a
362
+ size, a care temperature, a number from the product specification, a support
363
+ phone number, a founding year, the denominator of a rating scale. None of them
364
+ are claims, but all of them are digits. Copy that mentions one you didn't
365
+ supply is rejected.
366
+ - **Three digits after a dot or comma are always grouping.** A real
367
+ three-decimal number, like a weight or a price in a currency with three
368
+ decimal places, can't be written with a dot or a comma. `1.299 kg` reads
369
+ as 1299. Supply it and write it the same way.
370
+ - **Numeric characters that aren't digits are refused outright.** `2 m²` and
371
+ `½ inch` fail.
373
372
  - **A magnitude letter glued to a digit is refused.** `4K display` fails.
374
- `4 K display` and `1kg` do not, because a letter followed by another letter is
373
+ `4 K display` and `1kg` don't, because a letter followed by another letter is
375
374
  a unit, not a multiplier.
376
- - **A run no locale reads as a number needs its exact spelling.** `24.12.2026`
377
- is supported by a fact of `'24.12.2026'` and by nothing else.
378
- - **Scientific notation in the copy is unsupported by construction.** A fact of
379
- `1e-7` is laid out as `0.0000001`, and the text `"1e-7 g"` reads as the two
380
- numerals 1 and 7, so the two never meet. `"0.0000001 g"` passes. This is the
381
- bill for laying facts out positionally, and a lab or bulk-goods shop that
382
- prints the value the way its own data prints it pays it.
375
+ - **Scientific notation in the copy can never be supported.** A fact of `1e-7`
376
+ is written out as `0.0000001`, and the text `"1e-7 g"` reads as the two
377
+ numerals 1 and 7, so the two never meet. `"0.0000001 g"` passes. That's the
378
+ cost of writing facts out in plain digits. It falls on a lab or bulk-goods
379
+ shop that prints a value the way its own data prints it.
383
380
 
384
381
  ## Licence
385
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rudra-js/attested",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "From rudra-js: checks model-written product copy against the facts a shop stands behind",
5
5
  "keywords": [
6
6
  "rudra-js",