@onrail-xyz/amount 1.0.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 +201 -0
- package/README.md +617 -0
- package/dist/aggregating.d.ts +31 -0
- package/dist/aggregating.d.ts.map +1 -0
- package/dist/aggregating.js +31 -0
- package/dist/allocating.d.ts +9 -0
- package/dist/allocating.d.ts.map +1 -0
- package/dist/allocating.js +31 -0
- package/dist/amount.d.ts +68 -0
- package/dist/amount.d.ts.map +1 -0
- package/dist/amount.js +184 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +226 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/jsonCodecs.d.ts +9 -0
- package/dist/jsonCodecs.d.ts.map +1 -0
- package/dist/jsonCodecs.js +46 -0
- package/dist/kind.d.ts +126 -0
- package/dist/kind.d.ts.map +1 -0
- package/dist/kind.js +118 -0
- package/dist/rate.d.ts +58 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +178 -0
- package/dist/rational.d.ts +49 -0
- package/dist/rational.d.ts.map +1 -0
- package/dist/rational.js +378 -0
- package/dist/segmenting.d.ts +15 -0
- package/dist/segmenting.d.ts.map +1 -0
- package/dist/segmenting.js +97 -0
- package/dist/unitSpecs.d.ts +47 -0
- package/dist/unitSpecs.d.ts.map +1 -0
- package/dist/unitSpecs.js +8 -0
- package/package.json +56 -0
- package/src/aggregating.ts +160 -0
- package/src/allocating.ts +70 -0
- package/src/amount.ts +291 -0
- package/src/format.ts +330 -0
- package/src/index.ts +8 -0
- package/src/jsonCodecs.ts +63 -0
- package/src/kind.ts +442 -0
- package/src/rate.ts +316 -0
- package/src/rational.ts +471 -0
- package/src/segmenting.ts +154 -0
- package/src/unitSpecs.ts +37 -0
package/dist/rational.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { range, mapTo } from "@onrail-xyz/utils";
|
|
2
|
+
const maxTabulatedOom = 30; //should comfortably span every unit scale in practice
|
|
3
|
+
const tenPows = mapTo(range(maxTabulatedOom + 1))(exp => 10n ** BigInt(exp));
|
|
4
|
+
export const tenToThe = (exp) => tenPows[exp] ?? 10n ** BigInt(exp);
|
|
5
|
+
export class Rational {
|
|
6
|
+
static powerOfTen(oom) {
|
|
7
|
+
return oom < 0
|
|
8
|
+
? new Rational(1n, tenToThe(-oom))
|
|
9
|
+
: new Rational(tenToThe(oom), 1n);
|
|
10
|
+
}
|
|
11
|
+
n;
|
|
12
|
+
d;
|
|
13
|
+
//class invariants: n and d are always coprime, d is always positive
|
|
14
|
+
constructor(n, d) {
|
|
15
|
+
this.n = n;
|
|
16
|
+
this.d = d;
|
|
17
|
+
}
|
|
18
|
+
static from(valueOrNumerator, denominator) {
|
|
19
|
+
switch (typeof valueOrNumerator) {
|
|
20
|
+
case "number": {
|
|
21
|
+
const value = valueOrNumerator;
|
|
22
|
+
if (Number.isSafeInteger(value))
|
|
23
|
+
return new Rational(BigInt(value), 1n);
|
|
24
|
+
if (!Number.isFinite(value))
|
|
25
|
+
throw new Error("Invalid value");
|
|
26
|
+
//A double only ever approximates the value that named it, and two readings compete: the
|
|
27
|
+
// decimal it prints as, and a compact fraction it might be the rounding of (1/3 out of
|
|
28
|
+
// 0.3333...). A decimal short enough to round-trip through the double is certainly the
|
|
29
|
+
// spelling that produced it, so it wins outright - which guarantees that the number and
|
|
30
|
+
// string paths agree on every decimal literal of up to 15 significant digits.
|
|
31
|
+
const dec = value.toString();
|
|
32
|
+
if (dec.replace(/^-|\.|e[+-]?\d+$/gi, "").replace(/^0+/, "").length <= maxReliableDecimals)
|
|
33
|
+
return Rational.from(dec);
|
|
34
|
+
//Otherwise hunt for the compact fraction via continued fractions. A convergent is only
|
|
35
|
+
// credible as the intended value if it could hardly have landed within half an ulp of
|
|
36
|
+
// the double by accident: fractions with denominator <= D cover a random interval of
|
|
37
|
+
// width u with probability ~D²u, so capping D²·ulp(target) at 2^-16 bounds the odds of
|
|
38
|
+
// fitting noise at ~10^-5, while any deliberate fraction the double can faithfully carry
|
|
39
|
+
// clears the cap with a >= 2^8 margin in the denominator. With ulp ~ target·2^-52 the
|
|
40
|
+
// cap reads D = 2^18/√target - which also keeps every tried convergent exact in doubles
|
|
41
|
+
// (d <= 2^36, n ~ target·d <= 2^44). Whatever fails it defers to the exact decimal -
|
|
42
|
+
// toString hands it over by definition. Either way the value is never approximated.
|
|
43
|
+
const sign = value < 0 ? -1n : 1n;
|
|
44
|
+
const target = Math.abs(value);
|
|
45
|
+
const maxCredibleDenom = 2 ** 18 / Math.sqrt(target);
|
|
46
|
+
let cur = target;
|
|
47
|
+
let n2 = 0, n1 = 1, d2 = 1, d1 = 0;
|
|
48
|
+
while (true) {
|
|
49
|
+
const a = Math.floor(cur);
|
|
50
|
+
const n = a * n1 + n2;
|
|
51
|
+
const d = a * d1 + d2;
|
|
52
|
+
if (d > maxCredibleDenom)
|
|
53
|
+
return Rational.from(dec);
|
|
54
|
+
if (n / d === target)
|
|
55
|
+
return new Rational(sign * BigInt(n), BigInt(d));
|
|
56
|
+
const next = 1 / (cur - a);
|
|
57
|
+
if (!Number.isFinite(next)) //the recurrence ran out of double precision
|
|
58
|
+
return Rational.from(dec);
|
|
59
|
+
n2 = n1;
|
|
60
|
+
n1 = n;
|
|
61
|
+
d2 = d1;
|
|
62
|
+
d1 = d;
|
|
63
|
+
cur = next;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
case "bigint": {
|
|
67
|
+
if (denominator === undefined)
|
|
68
|
+
return new Rational(valueOrNumerator, 1n);
|
|
69
|
+
if (denominator === 0n)
|
|
70
|
+
throw new Error("Denominator cannot be zero");
|
|
71
|
+
let [num, den] = [valueOrNumerator, denominator];
|
|
72
|
+
if (den < 0n)
|
|
73
|
+
[num, den] = [-num, -den];
|
|
74
|
+
return new Rational(...Rational.normalize(num, den));
|
|
75
|
+
}
|
|
76
|
+
case "string": {
|
|
77
|
+
const mixedMatch = mixedRegex.exec(valueOrNumerator);
|
|
78
|
+
if (mixedMatch) {
|
|
79
|
+
const [, sign, intStr, numStr, denStr] = mixedMatch;
|
|
80
|
+
const den = parseBigInt(denStr);
|
|
81
|
+
if (den === 0n)
|
|
82
|
+
throw new Error("Denominator cannot be zero");
|
|
83
|
+
const num = parseBigInt(intStr) * den + parseBigInt(numStr);
|
|
84
|
+
return new Rational(...Rational.normalize(sign === "-" ? -num : num, den));
|
|
85
|
+
}
|
|
86
|
+
const ratioMatch = ratioRegex.exec(valueOrNumerator);
|
|
87
|
+
if (ratioMatch) {
|
|
88
|
+
const [, sign, numStr, denStr] = ratioMatch;
|
|
89
|
+
const num = (sign === "-" ? -1n : 1n) * parseBigInt(numStr);
|
|
90
|
+
const den = parseBigInt(denStr);
|
|
91
|
+
if (den === 0n)
|
|
92
|
+
throw new Error("Denominator cannot be zero");
|
|
93
|
+
return new Rational(...Rational.normalize(num, den));
|
|
94
|
+
}
|
|
95
|
+
const decMatch = decimalRegex.exec(valueOrNumerator);
|
|
96
|
+
if (!decMatch)
|
|
97
|
+
throw new Error(`Invalid rational value: ${valueOrNumerator}`);
|
|
98
|
+
const [, sign, intStr, frac, expStr] = decMatch;
|
|
99
|
+
//apply the sign to the assembled absolute value, not the integer part: for values in
|
|
100
|
+
// (-1, 0) the integer part is 0, which would swallow the sign (0n is not < 0n)
|
|
101
|
+
const neg = sign === "-";
|
|
102
|
+
//appending the fraction to the integer digits scales by 10^frac.length, which the exponent
|
|
103
|
+
// then offsets - so both only ever move the decimal point
|
|
104
|
+
const absValue = parseBigInt(intStr + (frac ?? ""));
|
|
105
|
+
const signed = neg ? -absValue : absValue;
|
|
106
|
+
const oom = Number(expStr ?? 0) - (frac?.length ?? 0);
|
|
107
|
+
return oom >= 0
|
|
108
|
+
? new Rational(signed * tenToThe(oom), 1n)
|
|
109
|
+
: new Rational(...Rational.normalize(signed, tenToThe(-oom)));
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
return valueOrNumerator;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
unwrap() {
|
|
116
|
+
return [this.n, this.d];
|
|
117
|
+
}
|
|
118
|
+
//the only lossy operation on Rationals: the result is the nearest double/+-inf, ties to even
|
|
119
|
+
toNumber() {
|
|
120
|
+
if (this.n === 0n)
|
|
121
|
+
return 0;
|
|
122
|
+
if (-maxExactInt <= this.n && this.n <= maxExactInt && this.d <= maxExactInt)
|
|
123
|
+
return Number(this.n) / Number(this.d);
|
|
124
|
+
const negative = this.n < 0n;
|
|
125
|
+
const num = negative ? -this.n : this.n;
|
|
126
|
+
//floor(log2(num/d)) - the difference in bit lengths is right to within one, and a single
|
|
127
|
+
// comparison settles which
|
|
128
|
+
const bitDiff = bitLength(num) - bitLength(this.d);
|
|
129
|
+
const exponent = (bitDiff >= 0 ? num >= this.d << BigInt(bitDiff) : num << BigInt(-bitDiff) >= this.d) ? bitDiff : bitDiff - 1;
|
|
130
|
+
//the exponent of the mantissa's last bit, clamped because a subnormal has fewer bits to spend
|
|
131
|
+
// and so has to be rounded further left. Fixing it up front is what reduces the whole
|
|
132
|
+
// conversion to a single division and a single rounding step.
|
|
133
|
+
const lastBitExp = Math.max(exponent - (mantissaBits - 1), minSubnormalExp);
|
|
134
|
+
const [scaledNum, scaledDen] = lastBitExp <= 0
|
|
135
|
+
? [num << BigInt(-lastBitExp), this.d]
|
|
136
|
+
: [num, this.d << BigInt(lastBitExp)];
|
|
137
|
+
let mantissa = scaledNum / scaledDen;
|
|
138
|
+
const twiceRest = (scaledNum % scaledDen) * 2n;
|
|
139
|
+
if (twiceRest > scaledDen || (twiceRest === scaledDen && (mantissa & 1n) === 1n))
|
|
140
|
+
++mantissa; //carrying into an extra bit is fine: the scaling below absorbs it
|
|
141
|
+
//mantissa fits a double exactly, and so does the power of two, so the product is exact - or
|
|
142
|
+
// Infinity, which is the right answer when the value overflows
|
|
143
|
+
const absValue = Number(mantissa) * 2 ** lastBitExp;
|
|
144
|
+
return negative ? -absValue : absValue;
|
|
145
|
+
}
|
|
146
|
+
//exact: the terminating decimal expansion when there is one, the mixed-number form
|
|
147
|
+
// otherwise ("333 1/3", "-1/3") — either way `from` parses it back to the same value
|
|
148
|
+
toString(opts) {
|
|
149
|
+
const decimals = this.decimalPlaces();
|
|
150
|
+
if (decimals !== undefined)
|
|
151
|
+
return this.toFixed(decimals, opts);
|
|
152
|
+
const thousandsSep = opts?.thousandsSep ?? "";
|
|
153
|
+
const nAbs = Rational.stripSign(this.n);
|
|
154
|
+
const int = nAbs / this.d;
|
|
155
|
+
const intStr = int === 0n ? "" : Rational.addSep(int.toString(), thousandsSep) + " ";
|
|
156
|
+
const frac = Rational.addSep((nAbs % this.d).toString(), thousandsSep) +
|
|
157
|
+
"/" + Rational.addSep(this.d.toString(), thousandsSep);
|
|
158
|
+
return (this.n < 0n ? "-" : "") + intStr + frac;
|
|
159
|
+
}
|
|
160
|
+
//same exact form — the method only exists because JSON.stringify never consults toString and
|
|
161
|
+
// would otherwise trip over the private bigint fields
|
|
162
|
+
toJSON() {
|
|
163
|
+
return this.toString();
|
|
164
|
+
}
|
|
165
|
+
toFixed(precision = 0, opts) {
|
|
166
|
+
const thousandsSep = opts?.thousandsSep ?? "";
|
|
167
|
+
const trimZeros = opts?.trimZeros ?? false;
|
|
168
|
+
const multiplier = tenToThe(precision);
|
|
169
|
+
const nAbs = Rational.stripSign(this.n);
|
|
170
|
+
//half away from zero, as in round(); d truncating on halving is harmless because a tie
|
|
171
|
+
// requires an even denominator, where the halving is exact
|
|
172
|
+
const val = (nAbs * multiplier + this.d / 2n) / this.d;
|
|
173
|
+
const sign = this.n < 0n && val !== 0n ? "-" : "";
|
|
174
|
+
if (precision === 0)
|
|
175
|
+
return sign + Rational.addSep(val.toString(), thousandsSep);
|
|
176
|
+
const valStr = val.toString().padStart(precision + 1, "0");
|
|
177
|
+
const intPartStr = Rational.addSep(valStr.slice(0, -precision), thousandsSep);
|
|
178
|
+
let fracPartStr = valStr.slice(-precision);
|
|
179
|
+
if (trimZeros) {
|
|
180
|
+
fracPartStr = fracPartStr.replace(/0+$/, "");
|
|
181
|
+
if (fracPartStr === "")
|
|
182
|
+
return sign + intPartStr;
|
|
183
|
+
}
|
|
184
|
+
return sign + intPartStr + "." + fracPartStr;
|
|
185
|
+
}
|
|
186
|
+
isInteger() {
|
|
187
|
+
return this.d === 1n;
|
|
188
|
+
}
|
|
189
|
+
//decimal places of the exact expansion, or undefined when it does not terminate (i.e. the
|
|
190
|
+
// denominator has prime factors other than 2 and 5)
|
|
191
|
+
decimalPlaces() {
|
|
192
|
+
let [d, twos, fives] = [this.d, 0, 0];
|
|
193
|
+
for (; (d & 1n) === 0n; d >>= 1n)
|
|
194
|
+
++twos;
|
|
195
|
+
for (; d % 5n === 0n; d /= 5n)
|
|
196
|
+
++fives;
|
|
197
|
+
return d === 1n ? Math.max(twos, fives) : undefined;
|
|
198
|
+
}
|
|
199
|
+
sign() {
|
|
200
|
+
return this.n < 0n ? -1 : this.n > 0n ? 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
ceil() {
|
|
203
|
+
const intPart = this.n / this.d;
|
|
204
|
+
return this.n > 0n && this.n % this.d !== 0n ? intPart + 1n : intPart;
|
|
205
|
+
}
|
|
206
|
+
//round half away from zero, so round(-x) === -round(x) (unlike Math.round's half-to-+inf)
|
|
207
|
+
round() {
|
|
208
|
+
const nAbs = Rational.stripSign(this.n);
|
|
209
|
+
const q = (nAbs * 2n + this.d) / (this.d * 2n);
|
|
210
|
+
return this.n < 0n ? -q : q;
|
|
211
|
+
}
|
|
212
|
+
floor() {
|
|
213
|
+
const intPart = this.n / this.d;
|
|
214
|
+
return this.n < 0n && this.n % this.d !== 0n ? intPart - 1n : intPart;
|
|
215
|
+
}
|
|
216
|
+
abs() {
|
|
217
|
+
return this.n < 0n ? this.neg() : this;
|
|
218
|
+
}
|
|
219
|
+
neg() {
|
|
220
|
+
return this.n === 0n ? this : new Rational(-this.n, this.d);
|
|
221
|
+
}
|
|
222
|
+
inv() {
|
|
223
|
+
if (this.n === 0n)
|
|
224
|
+
throw new Error("Cannot invert zero");
|
|
225
|
+
return (this.n < 0n)
|
|
226
|
+
? new Rational(-this.d, -this.n)
|
|
227
|
+
: new Rational(this.d, this.n);
|
|
228
|
+
}
|
|
229
|
+
eq(other) {
|
|
230
|
+
switch (typeof other) {
|
|
231
|
+
case "number":
|
|
232
|
+
return this.eq(Rational.from(other));
|
|
233
|
+
case "bigint":
|
|
234
|
+
return this.n === other && this.d === 1n;
|
|
235
|
+
default:
|
|
236
|
+
return this.n === other.n && this.d === other.d;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
ne(other) {
|
|
240
|
+
return !this.eq(other);
|
|
241
|
+
}
|
|
242
|
+
gt(other) {
|
|
243
|
+
switch (typeof other) {
|
|
244
|
+
case "number":
|
|
245
|
+
return this.gt(Rational.from(other));
|
|
246
|
+
case "bigint":
|
|
247
|
+
return this.n > other * this.d;
|
|
248
|
+
default:
|
|
249
|
+
return this.n * other.d > other.n * this.d;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
ge(other) {
|
|
253
|
+
switch (typeof other) {
|
|
254
|
+
case "number":
|
|
255
|
+
return this.ge(Rational.from(other));
|
|
256
|
+
case "bigint":
|
|
257
|
+
return this.n >= other * this.d;
|
|
258
|
+
default:
|
|
259
|
+
return this.n * other.d >= other.n * this.d;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
lt(other) {
|
|
263
|
+
return !this.ge(other);
|
|
264
|
+
}
|
|
265
|
+
//a comparator's answer from one cross-multiplication - `sub(other).sign()` would normalize a
|
|
266
|
+
// whole difference just to read its sign
|
|
267
|
+
cmp(other) {
|
|
268
|
+
switch (typeof other) {
|
|
269
|
+
case "number":
|
|
270
|
+
return this.cmp(Rational.from(other));
|
|
271
|
+
case "bigint": {
|
|
272
|
+
const rhs = other * this.d;
|
|
273
|
+
return this.n < rhs ? -1 : this.n > rhs ? 1 : 0;
|
|
274
|
+
}
|
|
275
|
+
default: {
|
|
276
|
+
const lhs = this.n * other.d;
|
|
277
|
+
const rhs = other.n * this.d;
|
|
278
|
+
return lhs < rhs ? -1 : lhs > rhs ? 1 : 0;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
le(other) {
|
|
283
|
+
return !this.gt(other);
|
|
284
|
+
}
|
|
285
|
+
add(other) {
|
|
286
|
+
switch (typeof other) {
|
|
287
|
+
case "number":
|
|
288
|
+
return this.add(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
|
|
289
|
+
case "bigint":
|
|
290
|
+
//gcd(n + k·d, d) = gcd(n, d) = 1, so the sum is already in lowest terms
|
|
291
|
+
return new Rational(this.n + other * this.d, this.d);
|
|
292
|
+
default: {
|
|
293
|
+
//Knuth's reduction: over the lcm, only a factor of gcd(d1, d2) can still cancel, so the
|
|
294
|
+
// sum is normalized against that instead of against the whole lcm
|
|
295
|
+
const g = Rational.gcd(this.d, other.d);
|
|
296
|
+
const thisCofactor = this.d / g;
|
|
297
|
+
const otherCofactor = other.d / g;
|
|
298
|
+
const num = this.n * otherCofactor + other.n * thisCofactor;
|
|
299
|
+
if (num === 0n)
|
|
300
|
+
return new Rational(0n, 1n);
|
|
301
|
+
const g2 = Rational.gcd(Rational.stripSign(num), g);
|
|
302
|
+
return new Rational(num / g2, thisCofactor * (other.d / g2));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
sub(other) {
|
|
307
|
+
return this.add(typeof other === "bigint" || typeof other === "number" ? -other : other.neg());
|
|
308
|
+
}
|
|
309
|
+
mul(other) {
|
|
310
|
+
switch (typeof other) {
|
|
311
|
+
case "number":
|
|
312
|
+
return this.mul(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
|
|
313
|
+
case "bigint": {
|
|
314
|
+
const gcd = Rational.gcd(Rational.stripSign(other), this.d);
|
|
315
|
+
return new Rational(this.n * (other / gcd), this.d / gcd);
|
|
316
|
+
}
|
|
317
|
+
default: {
|
|
318
|
+
const gcd1 = Rational.gcd(Rational.stripSign(this.n), other.d);
|
|
319
|
+
const gcd2 = Rational.gcd(Rational.stripSign(other.n), this.d);
|
|
320
|
+
const num = (this.n / gcd1) * (other.n / gcd2);
|
|
321
|
+
const den = (this.d / gcd2) * (other.d / gcd1);
|
|
322
|
+
return new Rational(num, den);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
div(other) {
|
|
327
|
+
switch (typeof other) {
|
|
328
|
+
case "number":
|
|
329
|
+
return this.div(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
|
|
330
|
+
case "bigint": {
|
|
331
|
+
if (other === 0n)
|
|
332
|
+
throw new Error("Cannot divide by zero");
|
|
333
|
+
//keep the sign on the numerator to maintain the class invariant
|
|
334
|
+
const [num, den] = Rational.normalize(this.n, Rational.stripSign(other));
|
|
335
|
+
return new Rational(other < 0n ? -num : num, den * this.d);
|
|
336
|
+
}
|
|
337
|
+
default:
|
|
338
|
+
if (other.n === 0n)
|
|
339
|
+
throw new Error("Cannot divide by zero");
|
|
340
|
+
return this.mul(other.inv());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
//floored modulo, i.e. the result carries the sign of the divisor (unlike bigint's %)
|
|
344
|
+
mod(other) {
|
|
345
|
+
const divisor = Rational.from(other);
|
|
346
|
+
return this.sub(divisor.mul(this.div(divisor).floor()));
|
|
347
|
+
}
|
|
348
|
+
static addSep(intStr, thousandsSep) {
|
|
349
|
+
return thousandsSep ? intStr.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSep) : intStr;
|
|
350
|
+
}
|
|
351
|
+
static gcd(a, b) {
|
|
352
|
+
while (b !== 0n)
|
|
353
|
+
[a, b] = [b, a % b];
|
|
354
|
+
return a;
|
|
355
|
+
}
|
|
356
|
+
static stripSign(n) {
|
|
357
|
+
return n < 0n ? -n : n;
|
|
358
|
+
}
|
|
359
|
+
static normalize(n, d) {
|
|
360
|
+
const gcd = Rational.gcd(Rational.stripSign(n), d);
|
|
361
|
+
return [n / gcd, d / gcd];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ---- IEEE double facts ----
|
|
365
|
+
//an IEEE double carries 53 significand bits, and 2^-1074 is its least representable step
|
|
366
|
+
const mantissaBits = 53;
|
|
367
|
+
const minSubnormalExp = -1074;
|
|
368
|
+
const maxExactInt = 2n ** BigInt(mantissaBits);
|
|
369
|
+
const bitLength = (value) => value.toString(2).length;
|
|
370
|
+
//the mantissa can reliably represent log10(2^53) ~= 15 decimal places
|
|
371
|
+
const maxReliableDecimals = 15;
|
|
372
|
+
// ---- Conversion/Parsing impl ----
|
|
373
|
+
//separate alternatives per separator flavor, so "," and "_" cannot mix within one integer
|
|
374
|
+
const intPat = "\\d+|\\d{1,3}(?:,\\d{3})+|\\d{1,3}(?:_\\d{3})+";
|
|
375
|
+
const parseBigInt = (s) => BigInt(s.replace(/[,_]/g, ""));
|
|
376
|
+
const mixedRegex = new RegExp(`^(-?)(${intPat}) (${intPat})/(${intPat})$`);
|
|
377
|
+
const ratioRegex = new RegExp(`^(-?)(${intPat})/(${intPat})$`);
|
|
378
|
+
const decimalRegex = new RegExp(`^(-?)(${intPat})(?:\\.(\\d+))?(?:[eE]([+-]?\\d+))?$`);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StrRecord } from "@onrail-xyz/utils";
|
|
2
|
+
type Span = [number, number];
|
|
3
|
+
export type SegmentPair = {
|
|
4
|
+
value: Span;
|
|
5
|
+
symbol: Span;
|
|
6
|
+
};
|
|
7
|
+
type SegmentResult = {
|
|
8
|
+
negative: boolean;
|
|
9
|
+
pairs: SegmentPair[];
|
|
10
|
+
};
|
|
11
|
+
export declare const symbolKey: (symbol: string) => string;
|
|
12
|
+
export declare function findUnit<U>(units: Readonly<StrRecord<U>>, symbol: string): U | undefined;
|
|
13
|
+
export declare const segment: (str: string) => SegmentResult;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=segmenting.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"segmenting.d.ts","sourceRoot":"","sources":["../src/segmenting.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEnD,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7B,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAG,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;CACd,CAAC;AAEF,KAAK,aAAa,GAAG;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAK,WAAW,EAAE,CAAC;CACzB,CAAC;AAKF,eAAO,MAAM,SAAS,WAAY,MAAM,WAA6B,CAAC;AAItE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAUxF;AAmGD,eAAO,MAAM,OAAO,QAAS,MAAM,KAAG,aAuBrC,CAAC"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//unit symbols are identifiers, so they compare as Unicode prescribes for those (UAX #31): by NFKC
|
|
2
|
+
// form, under which "m3" finds "m³", "℃" finds "°C", and the lookalikes Greek mu (U+03BC) and
|
|
3
|
+
// micro sign (U+00B5) find each other
|
|
4
|
+
export const symbolKey = (symbol) => symbol.normalize("NFKC");
|
|
5
|
+
const keyedUnits = new WeakMap();
|
|
6
|
+
export function findUnit(units, symbol) {
|
|
7
|
+
if (Object.hasOwn(units, symbol))
|
|
8
|
+
return units[symbol];
|
|
9
|
+
let keyed = keyedUnits.get(units);
|
|
10
|
+
if (keyed === undefined) {
|
|
11
|
+
keyed = new Map(Object.entries(units).map(([s, unit]) => [symbolKey(s), unit]));
|
|
12
|
+
keyedUnits.set(units, keyed);
|
|
13
|
+
}
|
|
14
|
+
return keyed.get(symbolKey(symbol));
|
|
15
|
+
}
|
|
16
|
+
const asciiSpace = 32;
|
|
17
|
+
const asciiZero = 48;
|
|
18
|
+
const asciiNine = 57;
|
|
19
|
+
const asciiComma = 44;
|
|
20
|
+
const asciiUnderscore = 95;
|
|
21
|
+
const asciiDot = 46;
|
|
22
|
+
const asciiSlash = 47;
|
|
23
|
+
const charIsDigit = (c) => asciiZero <= c && c <= asciiNine;
|
|
24
|
+
const charIsNumPart = (c) => charIsDigit(c) ||
|
|
25
|
+
c === asciiComma ||
|
|
26
|
+
c === asciiUnderscore ||
|
|
27
|
+
c === asciiDot ||
|
|
28
|
+
c === asciiSlash;
|
|
29
|
+
const isValChar = charIsNumPart;
|
|
30
|
+
const isSymChar = (c) => c !== asciiSpace && !charIsNumPart(c);
|
|
31
|
+
//a symbol that follows its value is delimited by the next space, so digits may continue it
|
|
32
|
+
const isPostfixSymChar = (c) => c !== asciiSpace && (charIsDigit(c) || !charIsNumPart(c));
|
|
33
|
+
const parseSpan = (str, pos, isValid, expected) => {
|
|
34
|
+
const start = pos;
|
|
35
|
+
while (pos < str.length && isValid(str.charCodeAt(pos)))
|
|
36
|
+
++pos;
|
|
37
|
+
if (pos === start)
|
|
38
|
+
throw new Error(`Expected ${expected} at position ${start}`);
|
|
39
|
+
return [start, pos];
|
|
40
|
+
};
|
|
41
|
+
const parseSign = (str) => {
|
|
42
|
+
let pos = 0;
|
|
43
|
+
if (str.length === 0)
|
|
44
|
+
throw new Error("Empty input");
|
|
45
|
+
const negative = str[0] === "-";
|
|
46
|
+
if (negative && ++pos === str.length)
|
|
47
|
+
throw new Error("Sign only input");
|
|
48
|
+
return { pos, negative };
|
|
49
|
+
};
|
|
50
|
+
const startsFraction = (str, at) => str.charCodeAt(at) === asciiSpace && charIsDigit(str.charCodeAt(at + 1));
|
|
51
|
+
//a value is either a plain number or a mixed number ("1,763 3/7") whose interior space is
|
|
52
|
+
// folded into one span — the digit after the space is what disambiguates a fraction from a
|
|
53
|
+
// symbol, which can never start with one. Rational.from owns the value grammar either way.
|
|
54
|
+
const parseValue = (str, pos) => {
|
|
55
|
+
const first = parseSpan(str, pos, isValChar, "value");
|
|
56
|
+
if (!startsFraction(str, first[1]))
|
|
57
|
+
return first;
|
|
58
|
+
const frac = parseSpan(str, first[1] + 1, isValChar, "fraction");
|
|
59
|
+
return [first[0], frac[1]];
|
|
60
|
+
};
|
|
61
|
+
const parsePair = (str, pos) => {
|
|
62
|
+
//the only leniency while parsing: we accept a space, even if the symbol is compact
|
|
63
|
+
const skipSpace = (p, expected) => {
|
|
64
|
+
if (str.charCodeAt(p) !== asciiSpace)
|
|
65
|
+
return p;
|
|
66
|
+
if (++p >= str.length)
|
|
67
|
+
throw new Error(`Unexpected end of input: expected ${expected} next`);
|
|
68
|
+
return p;
|
|
69
|
+
};
|
|
70
|
+
if (charIsDigit(str.charCodeAt(pos))) {
|
|
71
|
+
const value = parseValue(str, pos);
|
|
72
|
+
const symbol = parseSpan(str, skipSpace(value[1], "symbol"), isPostfixSymChar, "symbol");
|
|
73
|
+
return { pair: { value, symbol }, end: symbol[1] };
|
|
74
|
+
}
|
|
75
|
+
const symbol = parseSpan(str, pos, isSymChar, "symbol");
|
|
76
|
+
const value = parseValue(str, skipSpace(symbol[1], "value"));
|
|
77
|
+
return { pair: { value, symbol }, end: value[1] };
|
|
78
|
+
};
|
|
79
|
+
export const segment = (str) => {
|
|
80
|
+
const start = parseSign(str);
|
|
81
|
+
let { pos } = start;
|
|
82
|
+
const { negative } = start;
|
|
83
|
+
const pairs = [];
|
|
84
|
+
while (true) {
|
|
85
|
+
const { pair, end } = parsePair(str, pos);
|
|
86
|
+
pairs.push(pair);
|
|
87
|
+
pos = end;
|
|
88
|
+
if (pos === str.length)
|
|
89
|
+
break;
|
|
90
|
+
if (str.charCodeAt(pos) !== asciiSpace)
|
|
91
|
+
throw new Error("Expected space after value/symbol pair");
|
|
92
|
+
++pos;
|
|
93
|
+
if (pos === str.length)
|
|
94
|
+
throw new Error("Unexpected end of input: Expected another value/symbol pair");
|
|
95
|
+
}
|
|
96
|
+
return { negative, pairs };
|
|
97
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RoTuple, RoPair, RoNeTuple } from "@onrail-xyz/utils";
|
|
2
|
+
import type { Rationalish } from "./rational.js";
|
|
3
|
+
import type { SymbolSpec } from "./kind.js";
|
|
4
|
+
export declare const withPluralS: <S extends string>(symbol: S) => {
|
|
5
|
+
readonly symbol: S;
|
|
6
|
+
readonly plural: `${S}s`;
|
|
7
|
+
};
|
|
8
|
+
export declare const allowPluralS: <S extends string>(symbol: S) => readonly [{
|
|
9
|
+
readonly symbol: S;
|
|
10
|
+
}, {
|
|
11
|
+
readonly symbol: `${S}s`;
|
|
12
|
+
}];
|
|
13
|
+
export declare const allowOtherCap: <S extends string>(symbol: S) => readonly [{
|
|
14
|
+
readonly symbol: S;
|
|
15
|
+
}, {
|
|
16
|
+
readonly symbol: import("@onrail-xyz/utils").OtherCap<S>;
|
|
17
|
+
}];
|
|
18
|
+
export declare const withPluralSBothCaps: <S extends string>(symbol: S) => readonly [{
|
|
19
|
+
readonly symbol: S;
|
|
20
|
+
readonly plural: `${S}s`;
|
|
21
|
+
}, {
|
|
22
|
+
readonly symbol: import("@onrail-xyz/utils").OtherCap<S>;
|
|
23
|
+
readonly plural: `${import("@onrail-xyz/utils").OtherCap<S>}s`;
|
|
24
|
+
}];
|
|
25
|
+
export declare const allowPluralSBothCaps: <S extends string>(symbol: S) => readonly [{
|
|
26
|
+
readonly symbol: S;
|
|
27
|
+
}, {
|
|
28
|
+
readonly symbol: `${S}s`;
|
|
29
|
+
}, {
|
|
30
|
+
readonly symbol: import("@onrail-xyz/utils").OtherCap<S>;
|
|
31
|
+
}, {
|
|
32
|
+
readonly symbol: `${import("@onrail-xyz/utils").OtherCap<S>}s`;
|
|
33
|
+
}];
|
|
34
|
+
type UnitRow<V> = RoPair<V, RoNeTuple<SymbolSpec>>;
|
|
35
|
+
type ToUnits<K extends "scale" | "oom", T extends RoTuple<UnitRow<unknown>>> = {
|
|
36
|
+
[I in keyof T]: T[I] extends RoPair<infer V, infer Symbols> ? K extends "scale" ? {
|
|
37
|
+
scale: V;
|
|
38
|
+
symbols: Symbols;
|
|
39
|
+
} : {
|
|
40
|
+
oom: V;
|
|
41
|
+
symbols: Symbols;
|
|
42
|
+
} : never;
|
|
43
|
+
};
|
|
44
|
+
export declare const toScaleUnits: <const T extends RoTuple<UnitRow<Rationalish>>>(spec: T) => ToUnits<"scale", T>;
|
|
45
|
+
export declare const toDecimalUnits: <const T extends RoTuple<UnitRow<number>>>(spec: T) => ToUnits<"oom", T>;
|
|
46
|
+
export {};
|
|
47
|
+
//# sourceMappingURL=unitSpecs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unitSpecs.d.ts","sourceRoot":"","sources":["../src/unitSpecs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC;;qBAChB,GAAG,CAAC,GAAG;CAAY,CAAC;AAE3D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC;;;qBACb,GAAG,CAAC,GAAG;EAAY,CAAC;AAE/D,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC;;;;EACJ,CAAC;AAEtD,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC;;;;;;EACA,CAAC;AAEhE,eAAO,MAAM,oBAAoB,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC;;;;;;;;EACO,CAAC;AAExE,KAAK,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC;AAEnD,KAAK,OAAO,CAAC,CAAC,SAAS,OAAO,GAAG,KAAK,EAAE,CAAC,SAAS,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IACzE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,GACvD,CAAC,SAAS,OAAO,GACf;QAAE,KAAK,EAAE,CAAC,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GAC9B;QAAE,GAAG,EAAI,CAAC,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GAChC,KAAK;CACV,CAAC;AAEJ,eAAO,MAAM,YAAY,GACtB,KAAK,CAAC,CAAC,SAAS,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CACf,CAAC;AAEhE,eAAO,MAAM,cAAc,GACxB,KAAK,CAAC,CAAC,SAAS,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,KAAG,OAAO,CAAC,KAAK,EAAE,CAAC,CACZ,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { otherCap } from "@onrail-xyz/utils";
|
|
2
|
+
export const withPluralS = (symbol) => ({ symbol, plural: (symbol + "s") });
|
|
3
|
+
export const allowPluralS = (symbol) => [{ symbol }, { symbol: (symbol + "s") }];
|
|
4
|
+
export const allowOtherCap = (symbol) => [{ symbol }, { symbol: otherCap(symbol) }];
|
|
5
|
+
export const withPluralSBothCaps = (symbol) => [withPluralS(symbol), withPluralS(otherCap(symbol))];
|
|
6
|
+
export const allowPluralSBothCaps = (symbol) => [...allowPluralS(symbol), ...allowPluralS(otherCap(symbol))];
|
|
7
|
+
export const toScaleUnits = (spec) => spec.map(([scale, symbols]) => ({ scale, symbols }));
|
|
8
|
+
export const toDecimalUnits = (spec) => spec.map(([oom, symbols]) => ({ oom, symbols }));
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@onrail-xyz/amount",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"author": "nonergodic",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"description": "Handle amounts of different kinds with units in a type-safe manner using exact, arbitrary-precision rational arithmetic",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"amount",
|
|
10
|
+
"unit",
|
|
11
|
+
"dimension",
|
|
12
|
+
"rational",
|
|
13
|
+
"money",
|
|
14
|
+
"currency",
|
|
15
|
+
"precision"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Onrail/ts-sdk.git",
|
|
20
|
+
"directory": "packages/amount"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Onrail/ts-sdk/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/Onrail/ts-sdk/tree/main/packages/amount#readme",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@onrail-xyz/utils": "^1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@onrail-xyz/utils": "1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=25.0.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "pnpm clean && tsc",
|
|
53
|
+
"test": "tsc -p tsconfig.test.json && tsx --test tests/**/*.test.ts",
|
|
54
|
+
"clean": "rm -rf ./dist"
|
|
55
|
+
}
|
|
56
|
+
}
|