@rallycash/numbers 0.0.0-stage → 0.2.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.
@@ -0,0 +1,276 @@
1
+ import { coerceNumber } from './decimal.js';
2
+ import { formatUnits, parseUnits } from './units.js';
3
+ export const expToString = (value) => {
4
+ const raw = value.toString();
5
+ const negative = raw.startsWith('-');
6
+ const expanded = expandUnsignedExponent(negative ? raw.slice(1) : raw);
7
+ if (!negative || expanded === '0')
8
+ return expanded;
9
+ return `-${expanded}`;
10
+ };
11
+ const expandUnsignedExponent = (expString) => {
12
+ const [maybeNumber, exponent] = expString.split('e');
13
+ const number = maybeNumber ?? '0';
14
+ if (!exponent)
15
+ return number;
16
+ const decimalPosition = number.indexOf('.');
17
+ const removedDecimalNumber = number.replace('.', '');
18
+ const actualDecimalPosition = decimalPosition === -1 ? number.length : decimalPosition;
19
+ const decimalMovement = actualDecimalPosition + parseInt(exponent, 10);
20
+ const additionaltrailingZeros = Math.max(decimalMovement - removedDecimalNumber.length, 0);
21
+ const additionalLeadingZeros = actualDecimalPosition + parseInt(exponent, 10) < 0
22
+ ? Math.abs(decimalMovement)
23
+ : 0;
24
+ const numberArr = Array(additionalLeadingZeros)
25
+ .fill('0')
26
+ .concat(removedDecimalNumber.split(''))
27
+ .concat(Array(additionaltrailingZeros).fill('0'));
28
+ const newDecimalPosition = Math.max(decimalMovement, 0);
29
+ if (newDecimalPosition < numberArr.length) {
30
+ const decimalInsertion = newDecimalPosition === 0 ? '0.' : '.';
31
+ numberArr.splice(newDecimalPosition, 0, decimalInsertion);
32
+ }
33
+ return numberArr.join('');
34
+ };
35
+ function getMagnitude(n) {
36
+ if (n === 0)
37
+ return 0;
38
+ return Math.floor(Math.log10(Math.abs(n)));
39
+ }
40
+ function toSubscript(text) {
41
+ const subscriptMap = {
42
+ '0': '₀',
43
+ '1': '₁',
44
+ '2': '₂',
45
+ '3': '₃',
46
+ '4': '₄',
47
+ '5': '₅',
48
+ '6': '₆',
49
+ '7': '₇',
50
+ '8': '₈',
51
+ '9': '₉',
52
+ };
53
+ let result = '';
54
+ for (const char of expToString(text)) {
55
+ result += subscriptMap[char] || char;
56
+ }
57
+ return result;
58
+ }
59
+ const abbreviateNumber = (num, decimals = 2, preserveTrailingZeros = false) => {
60
+ const lookup = [
61
+ { value: 1, symbol: '' },
62
+ { value: 1e3, symbol: 'K' },
63
+ { value: 1e6, symbol: 'M' },
64
+ { value: 1e9, symbol: 'B' },
65
+ { value: 1e12, symbol: 'T' },
66
+ { value: 1e15, symbol: 'P' },
67
+ { value: 1e18, symbol: 'E' },
68
+ ];
69
+ const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
70
+ const formatFixed = (fixed) => preserveTrailingZeros ? fixed : fixed.replace(rx, '$1');
71
+ if (!Number.isFinite(num))
72
+ return formatFixed(num.toFixed(decimals));
73
+ const absNum = Math.abs(num);
74
+ let tier = 0;
75
+ for (let i = lookup.length - 1; i >= 0; i--) {
76
+ const entry = lookup[i];
77
+ if (entry && absNum >= entry.value) {
78
+ tier = i;
79
+ break;
80
+ }
81
+ }
82
+ const renderTier = (index) => {
83
+ const entry = lookup[index];
84
+ if (!entry)
85
+ return { text: formatFixed(num.toFixed(decimals)), rounded: num };
86
+ const rounded = Number((num / entry.value).toFixed(decimals) || 0);
87
+ const formattedNumber = rounded.toLocaleString('en-us', {
88
+ minimumFractionDigits: decimals,
89
+ maximumFractionDigits: decimals,
90
+ });
91
+ return { text: formatFixed(formattedNumber) + entry.symbol, rounded };
92
+ };
93
+ let rendered = renderTier(tier);
94
+ if (Math.abs(rendered.rounded) >= 1000 && tier < lookup.length - 1) {
95
+ rendered = renderTier(tier + 1);
96
+ }
97
+ return rendered.text;
98
+ };
99
+ const formatNumber = (value, { showExponents = true, minSignificantDigits = 4, maxDecimals: maxDecimalsParam, minDecimals: minDecimalsParam, abbrThreshold = 1e7, abbreviateLargeNumbers = true, abbrMinSignificantDigits = 3, prefix = '', } = {}) => {
100
+ let minDecimals;
101
+ let maxDecimals;
102
+ if (maxDecimalsParam == null && minDecimalsParam != null) {
103
+ minDecimals = minDecimalsParam;
104
+ maxDecimals = Math.max(6, minDecimalsParam);
105
+ }
106
+ else if (minDecimalsParam == null && maxDecimalsParam != null) {
107
+ maxDecimals = maxDecimalsParam;
108
+ minDecimals = Math.min(2, maxDecimalsParam);
109
+ }
110
+ else if (maxDecimalsParam != null && minDecimalsParam != null) {
111
+ maxDecimals = maxDecimalsParam;
112
+ minDecimals = Math.min(maxDecimalsParam, minDecimalsParam);
113
+ }
114
+ else {
115
+ maxDecimals = 6;
116
+ minDecimals = 2;
117
+ }
118
+ const num = coerceNumber(value);
119
+ if (num === 0) {
120
+ const zeros = '0'.repeat(minDecimals);
121
+ return `0${zeros ? `.${zeros}` : ''}`;
122
+ }
123
+ if (!num)
124
+ return '';
125
+ const absNumber = Math.abs(num);
126
+ const magnitude = getMagnitude(num);
127
+ if (magnitude <= -Math.abs(maxDecimals) && minSignificantDigits) {
128
+ if (!showExponents) {
129
+ const numberToRound = Math.abs(num) * 10 ** (Math.abs(magnitude) + minSignificantDigits - 1);
130
+ const fullNumber = expToString(Math.round(numberToRound) /
131
+ 10 ** (Math.abs(magnitude) + minSignificantDigits - 1));
132
+ const sign = num < 0 ? '-' : '';
133
+ return `${sign}${prefix}${fullNumber}`;
134
+ }
135
+ const unsignedEndPart = Math.abs(num) / 10 ** magnitude;
136
+ const formattedEndPart = Number(unsignedEndPart.toFixed(minSignificantDigits - 1))
137
+ .toLocaleString('en-us', {
138
+ minimumFractionDigits: minDecimals,
139
+ maximumFractionDigits: Math.max(0, Math.min(20, Math.max(minSignificantDigits - 2, minDecimals))),
140
+ })
141
+ .replace(/\./g, '');
142
+ const sign = num < 0 ? '-' : '';
143
+ return `${sign}${prefix}0.0${toSubscript(Math.abs(magnitude) - 1)}${formattedEndPart}`;
144
+ }
145
+ const smallestDisplayableNumber = 1 / 10 ** maxDecimals;
146
+ if (absNumber < smallestDisplayableNumber) {
147
+ const zeros = '0'.repeat(minDecimals);
148
+ return `0${zeros ? `.${zeros}` : ''}`;
149
+ }
150
+ if (abbreviateLargeNumbers && absNumber >= abbrThreshold) {
151
+ const numbersBeforeDecimal = (magnitude + 1) % 3 || 3;
152
+ return `${prefix}${abbreviateNumber(num, Math.max(abbrMinSignificantDigits - numbersBeforeDecimal, 0))}`;
153
+ }
154
+ if (absNumber < 1) {
155
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals));
156
+ return `${prefix}${Number(num.toFixed(maxDecimals)).toLocaleString('en-us', {
157
+ minimumFractionDigits: minDecimals,
158
+ maximumFractionDigits: clampedMaxDecimals,
159
+ })}`;
160
+ }
161
+ if (absNumber >= 1 && absNumber < 10000) {
162
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals));
163
+ return `${prefix}${Number(num.toFixed(Math.min(maxDecimals, 2))).toLocaleString('en-us', {
164
+ minimumFractionDigits: minDecimals,
165
+ maximumFractionDigits: clampedMaxDecimals,
166
+ })}`;
167
+ }
168
+ const rounded = maxDecimals === 0 ? Math.round(num) : num;
169
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals));
170
+ return `${prefix}${Number(rounded.toFixed(maxDecimals)).toLocaleString('en-us', {
171
+ minimumFractionDigits: minDecimals,
172
+ maximumFractionDigits: clampedMaxDecimals,
173
+ })}`;
174
+ };
175
+ const USD_BALANCE_PRESET = {
176
+ minDecimals: 2,
177
+ maxDecimals: 2,
178
+ minSignificantDigits: 0,
179
+ showExponents: false,
180
+ };
181
+ const PERCENTAGE_PRESET = {
182
+ minDecimals: 0,
183
+ maxDecimals: 2,
184
+ minSignificantDigits: 0,
185
+ showExponents: false,
186
+ };
187
+ const INTEGER_PRESET = {
188
+ minDecimals: 0,
189
+ maxDecimals: 0,
190
+ minSignificantDigits: 0,
191
+ showExponents: false,
192
+ };
193
+ function mergePreset(preset, options) {
194
+ if (options === undefined)
195
+ return preset;
196
+ return { ...preset, ...options };
197
+ }
198
+ function coerceUsd(value) {
199
+ if (typeof value === 'string') {
200
+ const parsed = Number.parseFloat(value);
201
+ return Number.isNaN(parsed) ? undefined : parsed;
202
+ }
203
+ return coerceNumber(value);
204
+ }
205
+ function formatSignedUsd(value, formatAbs) {
206
+ const numeric = coerceUsd(value);
207
+ if (numeric == null || !Number.isFinite(numeric) || numeric === 0)
208
+ return '$0.00';
209
+ const body = formatAbs(Math.abs(numeric));
210
+ return numeric < 0 ? `-$${body}` : `$${body}`;
211
+ }
212
+ const formatUsd = (value) => formatSignedUsd(value, (absolute) => abbreviateNumber(absolute, 2, true));
213
+ const formatUsdBalance = (value, options) => formatSignedUsd(value, (absolute) => formatNumber(absolute, mergePreset(USD_BALANCE_PRESET, options)));
214
+ const formatCryptoBalance = (value, options) => formatNumber(value, options);
215
+ const formatPercentage = (value, options) => formatNumber(value, mergePreset(PERCENTAGE_PRESET, options));
216
+ const formatInteger = (value, options) => formatNumber(value, mergePreset(INTEGER_PRESET, options));
217
+ const formatTokenPrice = (price) => formatNumber(price);
218
+ const formatBigInt = (input, decimals, formatOptions) => {
219
+ let atomic;
220
+ if (typeof input === 'bigint') {
221
+ atomic = input;
222
+ }
223
+ else if (typeof input === 'string') {
224
+ atomic = BigInt(input);
225
+ }
226
+ else {
227
+ atomic = parseUnits(input.toFixed(0), 0);
228
+ }
229
+ return formatNumber(Number(formatUnits(atomic, decimals)), formatOptions);
230
+ };
231
+ const formatLargeNumber = (value) => {
232
+ const numeric = typeof value === 'number' ? value : value.toNumber();
233
+ return abbreviateNumber(numeric, 2);
234
+ };
235
+ const formatSwapAmount = (value) => {
236
+ const number = typeof value === 'string' ? Number.parseFloat(value) : value;
237
+ if (!number || number === 0)
238
+ return '0';
239
+ if (number < 0)
240
+ return '<0';
241
+ const [integerPart, decimalPart = ''] = expToString(number).split('.');
242
+ if (!decimalPart)
243
+ return integerPart ?? '0';
244
+ const leadingZeroLength = (decimalPart.match(/^0+/) || [''])[0].length;
245
+ const significantDigits = decimalPart.replace(/^0+/, '').substring(0, 3);
246
+ if (!significantDigits)
247
+ return '0';
248
+ return `${integerPart}.${'0'.repeat(leadingZeroLength)}${significantDigits}`;
249
+ };
250
+ function computePercentile(sorted, percentile) {
251
+ if (sorted.length === 0)
252
+ return null;
253
+ const index = (percentile / 100) * (sorted.length - 1);
254
+ const lower = Math.floor(index);
255
+ const upper = Math.ceil(index);
256
+ if (lower === upper)
257
+ return sorted[lower] ?? null;
258
+ const lowerValue = sorted[lower] ?? 0;
259
+ const upperValue = sorted[upper] ?? lowerValue;
260
+ return lowerValue + (upperValue - lowerValue) * (index - lower);
261
+ }
262
+ export const NumberUtils = {
263
+ formatNumber,
264
+ formatCryptoBalance,
265
+ formatUsdBalance,
266
+ formatPercentage,
267
+ formatUsd,
268
+ formatInteger,
269
+ formatTokenPrice,
270
+ formatBigInt,
271
+ abbreviateNumber,
272
+ formatLargeNumber,
273
+ formatSwapAmount,
274
+ expToString,
275
+ computePercentile,
276
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `formatUnits` / `parseUnits` behavior from viem's `Value` module.
3
+ * Inlined so this package does not depend on viem.
4
+ */
5
+ export declare function formatUnits(value: bigint, decimals: number): string;
6
+ export declare function parseUnits(value: string, decimals: number): bigint;
7
+ //# sourceMappingURL=units.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"units.d.ts","sourceRoot":"","sources":["../src/units.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAanE;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8ClE"}
package/dist/units.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `formatUnits` / `parseUnits` behavior from viem's `Value` module.
3
+ * Inlined so this package does not depend on viem.
4
+ */
5
+ export function formatUnits(value, decimals) {
6
+ assertDecimals(decimals);
7
+ let display = value.toString();
8
+ const negative = display.startsWith('-');
9
+ if (negative)
10
+ display = display.slice(1);
11
+ display = display.padStart(decimals, '0');
12
+ const integer = display.slice(0, display.length - decimals);
13
+ let fraction = display.slice(display.length - decimals);
14
+ fraction = fraction.replace(/(0+)$/, '');
15
+ return `${negative ? '-' : ''}${integer || '0'}${fraction ? `.${fraction}` : ''}`;
16
+ }
17
+ export function parseUnits(value, decimals) {
18
+ assertDecimals(decimals);
19
+ if (!/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(value)) {
20
+ throw new Error(`\`${value}\` is not a valid decimal number.`);
21
+ }
22
+ let [integer = '', fraction = '0'] = value.split('.');
23
+ const negative = integer.startsWith('-');
24
+ if (negative)
25
+ integer = integer.slice(1);
26
+ if (integer === '')
27
+ integer = '0';
28
+ fraction = fraction.replace(/(0+)$/, '');
29
+ if (decimals === 0) {
30
+ const firstFractionDigit = fraction[0];
31
+ if (fraction.length > 0 &&
32
+ firstFractionDigit !== undefined &&
33
+ Number.parseInt(firstFractionDigit, 10) >= 5) {
34
+ integer = `${BigInt(integer) + 1n}`;
35
+ }
36
+ fraction = '';
37
+ }
38
+ else if (fraction.length > decimals) {
39
+ const left = fraction.slice(0, decimals);
40
+ const roundDigitChar = fraction.slice(decimals, decimals + 1);
41
+ const roundDigit = Number.parseInt(roundDigitChar, 10);
42
+ if (roundDigit >= 5) {
43
+ const carried = carry(left);
44
+ if (carried.length > decimals) {
45
+ fraction = carried.slice(1);
46
+ integer = `${BigInt(integer) + 1n}`;
47
+ }
48
+ else {
49
+ fraction = carried;
50
+ }
51
+ }
52
+ else {
53
+ fraction = left;
54
+ }
55
+ }
56
+ else {
57
+ fraction = fraction.padEnd(decimals, '0');
58
+ }
59
+ return BigInt(`${negative ? '-' : ''}${integer}${fraction}`);
60
+ }
61
+ function assertDecimals(decimals) {
62
+ if (!Number.isInteger(decimals) || decimals < 0) {
63
+ throw new Error(`decimals must be a non-negative integer. Got \`${decimals}\`.`);
64
+ }
65
+ }
66
+ function carry(digits) {
67
+ const out = digits.split('');
68
+ let i = out.length - 1;
69
+ while (i >= 0) {
70
+ const current = out[i];
71
+ if (current === undefined)
72
+ break;
73
+ const next = Number.parseInt(current, 10) + 1;
74
+ if (next < 10) {
75
+ out[i] = String(next);
76
+ return out.join('');
77
+ }
78
+ out[i] = '0';
79
+ i -= 1;
80
+ }
81
+ return `1${out.join('')}`;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@rallycash/numbers",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Number formatting and fixed-point bigint conversion",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "scripts": {
23
+ "build": "../../node_modules/.bin/tsc -b --force",
24
+ "typecheck": "../../node_modules/.bin/tsc --noEmit -p tsconfig.json",
25
+ "test": "../../node_modules/.bin/vitest run"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ }
6
33
  }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { BigIntUtils } from './bigint-utils.js'
4
+
5
+ describe('BigIntUtils.toBigInt', () => {
6
+ it('parses a decimal string at the given scale', () => {
7
+ expect(BigIntUtils.toBigInt('1.5', 6)).toBe(1_500000n)
8
+ })
9
+
10
+ it('expands scientific notation before parsing', () => {
11
+ expect(BigIntUtils.toBigInt(1e-7, 18)).toBe(100_000_000_000n)
12
+ expect(BigIntUtils.toBigInt(-1e-7, 18)).toBe(-100_000_000_000n)
13
+ })
14
+
15
+ it('leaves a bigint untouched', () => {
16
+ expect(BigIntUtils.toBigInt(5n, 6)).toBe(5n)
17
+ })
18
+
19
+ it('reads a decimal-like via toFixed', () => {
20
+ const value = {
21
+ toFixed(digits: number) {
22
+ return (1.25).toFixed(digits)
23
+ },
24
+ toNumber() {
25
+ return 1.25
26
+ },
27
+ }
28
+ expect(BigIntUtils.toBigInt(value, 2)).toBe(125n)
29
+ })
30
+ })
31
+
32
+ describe('BigIntUtils.multiplyByNumber', () => {
33
+ it('scales a float multiplier at 18 decimals by default', () => {
34
+ expect(BigIntUtils.multiplyByNumber(1000n, 1.5)).toBe(1500n)
35
+ expect(BigIntUtils.multiplyByNumber(1000n, 0.5)).toBe(500n)
36
+ })
37
+
38
+ it('returns 0 when the multiplier is 0', () => {
39
+ expect(BigIntUtils.multiplyByNumber(1000n, 0)).toBe(0n)
40
+ })
41
+ })
42
+
43
+ describe('BigIntUtils.createPercentage', () => {
44
+ it('divides two numbers directly', () => {
45
+ expect(BigIntUtils.createPercentage(0.0000000005, 0.0000000025)).toBe(0.2)
46
+ })
47
+
48
+ it('divides bigints through the float scale', () => {
49
+ expect(BigIntUtils.createPercentage(10n, 100n)).toBe(0.1)
50
+ })
51
+ })
52
+
53
+ describe('BigIntUtils.usdToTokenAmount', () => {
54
+ it('converts a USD notional when the caller passes the USD scale', () => {
55
+ expect(BigIntUtils.usdToTokenAmount(5, 2, 6, 18)).toBe(2_500_000n)
56
+ })
57
+
58
+ it('handles 18-decimal tokens without a float 1e18 multiply', () => {
59
+ expect(BigIntUtils.usdToTokenAmount(5, 2500, 18, 18)).toBe(
60
+ 2_000_000_000_000_000n,
61
+ )
62
+ })
63
+
64
+ it('returns 0 for non-positive inputs', () => {
65
+ expect(BigIntUtils.usdToTokenAmount(0, 1, 6, 18)).toBe(0n)
66
+ expect(BigIntUtils.usdToTokenAmount(5, 0, 6, 18)).toBe(0n)
67
+ })
68
+ })
69
+
70
+ describe('BigIntUtils.multiplyAsBigInt', () => {
71
+ it('multiplies at the caller scale and returns a number', () => {
72
+ expect(BigIntUtils.multiplyAsBigInt(2, 3, 2)).toBe(6)
73
+ })
74
+ })
75
+
76
+ describe('BigIntUtils.splitEvenly', () => {
77
+ it('splits evenly with the remainder absorbed by the last bucket', () => {
78
+ const buckets = BigIntUtils.splitEvenly(100n, 3)
79
+ expect(buckets).toEqual([33n, 33n, 34n])
80
+ expect(buckets.reduce((sum, bucket) => sum + bucket, 0n)).toBe(100n)
81
+ })
82
+
83
+ it('returns [amount] when count is 1', () => {
84
+ expect(BigIntUtils.splitEvenly(7n, 1)).toEqual([7n])
85
+ })
86
+
87
+ it('throws when count is <= 0', () => {
88
+ expect(() => BigIntUtils.splitEvenly(10n, 0)).toThrow()
89
+ expect(() => BigIntUtils.splitEvenly(10n, -1)).toThrow()
90
+ })
91
+ })
92
+
93
+ describe('BigIntUtils.splitWithVariance', () => {
94
+ function makeSeq(values: number[]): () => number {
95
+ let i = 0
96
+ return () => values[i++ % values.length] ?? 0
97
+ }
98
+
99
+ it('degenerates to splitEvenly when variance <= 0', () => {
100
+ const variance0 = BigIntUtils.splitWithVariance(100n, 4, 0)
101
+ expect(variance0).toEqual(BigIntUtils.splitEvenly(100n, 4))
102
+
103
+ const negative = BigIntUtils.splitWithVariance(100n, 4, -0.1)
104
+ expect(negative).toEqual(BigIntUtils.splitEvenly(100n, 4))
105
+ })
106
+
107
+ it('degenerates to splitEvenly when count is 1', () => {
108
+ expect(BigIntUtils.splitWithVariance(42n, 1, 0.5)).toEqual([42n])
109
+ })
110
+
111
+ it('degenerates to splitEvenly when amount is 0', () => {
112
+ expect(BigIntUtils.splitWithVariance(0n, 5, 0.2)).toEqual([
113
+ 0n,
114
+ 0n,
115
+ 0n,
116
+ 0n,
117
+ 0n,
118
+ ])
119
+ })
120
+
121
+ it('preserves the total sum exactly regardless of variance', () => {
122
+ const rng = makeSeq([0.0, 1.0, 0.25, 0.75, 0.5])
123
+ const buckets = BigIntUtils.splitWithVariance(1_000_000n, 5, 0.2, rng)
124
+ expect(buckets).toHaveLength(5)
125
+ expect(buckets.reduce((sum, bucket) => sum + bucket, 0n)).toBe(1_000_000n)
126
+ })
127
+
128
+ it('jitters the first N-1 buckets within ±variance of the even base', () => {
129
+ const rng = makeSeq([0.0, 1.0, 0.0, 1.0])
130
+ const amount = 1_000_000n
131
+ const count = 4
132
+ const variance = 0.2
133
+ const base = amount / BigInt(count)
134
+ const buckets = BigIntUtils.splitWithVariance(amount, count, variance, rng)
135
+
136
+ expect(buckets[0]).toBe(200_000n)
137
+ expect(buckets[1]).toBe(300_000n)
138
+ expect(buckets[2]).toBe(200_000n)
139
+ expect(buckets[3]).toBe(
140
+ amount - (buckets[0] ?? 0n) - (buckets[1] ?? 0n) - (buckets[2] ?? 0n),
141
+ )
142
+ expect(buckets[3]).toBe(300_000n)
143
+
144
+ for (let i = 0; i < count - 1; i++) {
145
+ const bucket = buckets[i] ?? 0n
146
+ const expectedMax = (base * 120n) / 100n
147
+ const expectedMin = (base * 80n) / 100n
148
+ expect(bucket).toBeGreaterThanOrEqual(expectedMin)
149
+ expect(bucket).toBeLessThanOrEqual(expectedMax)
150
+ }
151
+ })
152
+
153
+ it('never produces negative buckets even if the last-bucket drift would go negative', () => {
154
+ const rng = makeSeq([1.0, 1.0, 1.0, 1.0])
155
+ const buckets = BigIntUtils.splitWithVariance(100n, 5, 0.5, rng)
156
+ for (const bucket of buckets) expect(bucket).toBeGreaterThanOrEqual(0n)
157
+ expect(buckets.reduce((sum, bucket) => sum + bucket, 0n)).toBe(100n)
158
+ })
159
+
160
+ it('throws when count is <= 0', () => {
161
+ expect(() => BigIntUtils.splitWithVariance(10n, 0, 0.2)).toThrow()
162
+ })
163
+ })
164
+
165
+ describe('BigIntUtils.clampToCaps', () => {
166
+ it('moves overflow onto the bucket with the most headroom', () => {
167
+ expect(BigIntUtils.clampToCaps([80n, 10n], [50n, 100n])).toEqual([50n, 40n])
168
+ })
169
+
170
+ it('throws when the total cannot fit', () => {
171
+ expect(() => BigIntUtils.clampToCaps([80n, 30n], [50n, 50n])).toThrow()
172
+ })
173
+ })