diff --git a/perf/benchmark.cjs b/perf/benchmark.cjs index e76f62ec..16c2ab54 100644 --- a/perf/benchmark.cjs +++ b/perf/benchmark.cjs @@ -57,7 +57,8 @@ const argv = yargs(hideBin(process.argv)) .option('print-confidence', { type: 'boolean', default: false, - description: 'Print 95 % confidence range in reports instead of +X% (increase the number of samples to reduce this range)', + description: + 'Print 95 % confidence range in reports instead of +X% (increase the number of samples to reduce this range)', }) .option('verbose', { alias: 'v', diff --git a/src/distribution/UniformIntDistribution.ts b/src/distribution/UniformIntDistribution.ts index 49164fbf..5698e031 100644 --- a/src/distribution/UniformIntDistribution.ts +++ b/src/distribution/UniformIntDistribution.ts @@ -1,11 +1,43 @@ import Distribution from './Distribution'; import RandomGenerator from '../generator/RandomGenerator'; import { uniformIntDistributionInternal } from './internals/UniformIntDistributionInternal'; +import { ArrayInt64, fromNumberToArrayInt64, substractArrayInt64, toNumber } from './internals/ArrayInt'; +import { uniformArrayIntDistributionInternal } from './internals/UniformArrayIntDistributionInternal'; -function uniformIntInternal(from: number, rangeSize: number, rng: RandomGenerator): [number, RandomGenerator] { - const g = uniformIntDistributionInternal(rangeSize, rng); - g[0] += from; - return g; +const sharedA: ArrayInt64 = { sign: 1, data: [0, 0] }; +const sharedB: ArrayInt64 = { sign: 1, data: [0, 0] }; +const sharedC: ArrayInt64 = { sign: 1, data: [0, 0] }; +const sharedD: ArrayInt64 = { sign: 1, data: [0, 0] }; + +function uniformIntInternal(from: number, to: number, rng: RandomGenerator): [number, RandomGenerator] { + const rangeSize = to - from; + if (rangeSize <= 0xffffffff) { + // Calling uniformIntDistributionInternal can be considered safe + // up-to 2**32 values. Above this range it may miss values. + const g = uniformIntDistributionInternal(rangeSize + 1, rng); + g[0] += from; + return g; + } + + const rangeSizeArrayIntValue = + rangeSize <= Number.MAX_SAFE_INTEGER + ? fromNumberToArrayInt64(sharedC, rangeSize) // no possible overflow given rangeSize is in a safe range + : substractArrayInt64(sharedC, fromNumberToArrayInt64(sharedA, to), fromNumberToArrayInt64(sharedB, from)); // rangeSize might be incorrect, we compute a safer range + + // Adding 1 to the range + if (rangeSizeArrayIntValue.data[1] === 0xffffffff) { + // rangeSizeArrayIntValue.length === 2 by construct + // rangeSize >= 0x00000001_00000000 and rangeSize <= 0x003fffff_fffffffe + // with Number.MAX_SAFE_INTEGER - Number.MIN_SAFE_INTEGER = 0x003fffff_fffffffe + rangeSizeArrayIntValue.data[0] += 1; + rangeSizeArrayIntValue.data[1] = 0; + } else { + rangeSizeArrayIntValue.data[1] += 1; + } + + sharedD.sign = 1; + const g = uniformArrayIntDistributionInternal(sharedD.data, rangeSizeArrayIntValue.data, rng); + return [from + toNumber(sharedD), g[1]]; } /** @@ -28,12 +60,11 @@ function uniformIntDistribution(from: number, to: number): Distribution; */ function uniformIntDistribution(from: number, to: number, rng: RandomGenerator): [number, RandomGenerator]; function uniformIntDistribution(from: number, to: number, rng?: RandomGenerator) { - const rangeSize = to - from + 1; if (rng != null) { - return uniformIntInternal(from, rangeSize, rng); + return uniformIntInternal(from, to, rng); } return function (rng: RandomGenerator) { - return uniformIntInternal(from, rangeSize, rng); + return uniformIntInternal(from, to, rng); }; } diff --git a/src/distribution/internals/ArrayInt.ts b/src/distribution/internals/ArrayInt.ts new file mode 100644 index 00000000..cde0f8a0 --- /dev/null +++ b/src/distribution/internals/ArrayInt.ts @@ -0,0 +1,109 @@ +/** + * An ArrayInt represents an integer larger than what can be represented in classical JavaScript. + * The values stored in data must be in the range [0, 0xffffffff]. + * + * @example + * ```js + * { data: [ 42 ] } // = 42 + * { sign: -1, data: [ 42 ] } // = -42 + * { sign: -1, data: [ 5, 42 ] } // = -1 * (5 * 2**32 + 42) + * { sign: -1, data: [ 1, 5, 42 ] } // = -1 * (1 * 2**64 + 5 * 2**32 + 42) + * ``` + */ +export type ArrayInt = { + /** + * Sign of the represented number + * @defaultValue 1 + */ + sign?: -1 | 1; + /** + * Value of the number, must only contain numbers in the range [0, 0xffffffff] + */ + data: number[]; +}; + +/** @internal */ +export function toNumber(arrayInt: ArrayInt): number { + let current = arrayInt.data[0]; + const arrayIntLength = arrayInt.data.length; + for (let index = 1; index < arrayIntLength; ++index) { + current *= 0x100000000; + current += arrayInt.data[index]; + } + return current * (arrayInt.sign || 1); +} + +// Helpers specific to 64 bits versions + +/** @internal */ +export type ArrayInt64 = ArrayInt & { data: [number, number] }; + +/** + * We only accept safe integers here + * @internal + */ +export function fromNumberToArrayInt64(out: ArrayInt64, n: number): ArrayInt64 { + if (n < 0) { + const posN = -n; + out.sign = -1; + out.data[0] = ~~(posN / 0x100000000); + out.data[1] = posN >>> 0; + } else { + out.sign = 1; + out.data[0] = ~~(n / 0x100000000); + out.data[1] = n >>> 0; + } + return out; +} + +/** + * Substract two ArrayInt of 64 bits on 64 bits. + * With arrayIntA - arrayIntB >= 0 + * @internal + */ +export function substractArrayInt64(out: ArrayInt64, arrayIntA: ArrayInt64, arrayIntB: ArrayInt64): ArrayInt64 { + const lowA = arrayIntA.data[1]; + const highA = arrayIntA.data[0]; + const signA = arrayIntA.sign || 1; + const lowB = arrayIntB.data[1]; + const highB = arrayIntB.data[0]; + const signB = arrayIntB.sign || 1; + + // Requirement: arrayIntA - arrayIntB >= 0 + out.sign = 1; + + if (signA === 1 && signB === -1) { + // Operation is a simple sum of arrayIntA + abs(arrayIntB) + const low = lowA + lowB; + const high = highA + highB + (low > 0xffffffff ? 1 : 0); + out.data[0] = high >>> 0; + out.data[1] = low >>> 0; + return out; + } + // signA === -1 with signB === 1 is impossible given: arrayIntA - arrayIntB >= 0 + + // Operation is a substraction + let lowFirst = lowA; + let highFirst = highA; + let lowSecond = lowB; + let highSecond = highB; + if (signA === -1) { + lowFirst = lowB; + highFirst = highB; + lowSecond = lowA; + highSecond = highA; + } + let reminderLow = 0; + let low = lowFirst - lowSecond; + if (low < 0) { + reminderLow = 1; + low = low >>> 0; + } + let high = highFirst - highSecond - reminderLow; + if (high < 0) { + high = high >>> 0; + } + out.data[0] = high; + out.data[1] = low; + return out; +} diff --git a/src/distribution/internals/UniformArrayIntDistributionInternal.ts b/src/distribution/internals/UniformArrayIntDistributionInternal.ts new file mode 100644 index 00000000..6ccd7570 --- /dev/null +++ b/src/distribution/internals/UniformArrayIntDistributionInternal.ts @@ -0,0 +1,44 @@ +import RandomGenerator from '../../generator/RandomGenerator'; +import { ArrayInt } from './ArrayInt'; +import { uniformIntDistributionInternal } from './UniformIntDistributionInternal'; + +/** + * Uniformly generate ArrayInt in range [0 ; rangeSize[ + * + * @remarks + * In the worst case scenario it may discard half of the randomly generated value. + * Worst case being: most significant number is 1 and remaining part evaluates to 0. + * + * @internal + */ +export function uniformArrayIntDistributionInternal( + out: ArrayInt['data'], + rangeSize: ArrayInt['data'], + rng: RandomGenerator +): [ArrayInt['data'], RandomGenerator] { + const rangeLength = rangeSize.length; + let nrng = rng; + + // We iterate until we find a valid value for arrayInt + while (true) { + // We compute a new value for arrayInt + for (let index = 0; index !== rangeLength; ++index) { + const indexRangeSize = index === 0 ? rangeSize[0] + 1 : 0x100000000; + const g = uniformIntDistributionInternal(indexRangeSize, nrng); + out[index] = g[0]; + nrng = g[1]; + } + + // If in the correct range we can return it + for (let index = 0; index !== rangeLength; ++index) { + const current = out[index]; + const currentInRange = rangeSize[index]; + if (current < currentInRange) { + return [out, nrng]; // arrayInt < rangeSize + } else if (current > currentInRange) { + break; // arrayInt > rangeSize + } + } + // Otherwise we need to try another one + } +} diff --git a/test/unit/distribution/UniformIntDistribution.noreg.spec.ts b/test/unit/distribution/UniformIntDistribution.noreg.spec.ts index 34012d3f..85466bd2 100644 --- a/test/unit/distribution/UniformIntDistribution.noreg.spec.ts +++ b/test/unit/distribution/UniformIntDistribution.noreg.spec.ts @@ -1,3 +1,5 @@ +import fc from 'fast-check'; + import { uniformIntDistribution } from '../../../src/distribution/UniformIntDistribution'; import mersenne from '../../../src/generator/MersenneTwister'; @@ -37,4 +39,13 @@ describe('uniformIntDistribution [non regression]', () => { } expect(values).toMatchSnapshot(); }); + + it('Should always generate values within the range [from ; to]', () => + fc.assert( + fc.property(fc.integer().noShrink(), fc.maxSafeInteger(), fc.maxSafeInteger(), (seed, a, b) => { + const [from, to] = a < b ? [a, b] : [b, a]; + const [v, _nrng] = uniformIntDistribution(from, to)(mersenne(seed)); + return v >= from && v <= to; + }) + )); }); diff --git a/test/unit/distribution/UniformIntDistribution.spec.ts b/test/unit/distribution/UniformIntDistribution.spec.ts index 824d1272..c5d57b47 100644 --- a/test/unit/distribution/UniformIntDistribution.spec.ts +++ b/test/unit/distribution/UniformIntDistribution.spec.ts @@ -5,7 +5,9 @@ import { uniformIntDistribution } from '../../../src/distribution/UniformIntDist import RandomGenerator from '../../../src/generator/RandomGenerator'; import * as UniformIntDistributionInternalMock from '../../../src/distribution/internals/UniformIntDistributionInternal'; +import * as UniformArrayIntDistributionInternalMock from '../../../src/distribution/internals/UniformArrayIntDistributionInternal'; jest.mock('../../../src/distribution/internals/UniformIntDistributionInternal'); +jest.mock('../../../src/distribution/internals/UniformArrayIntDistributionInternal'); function buildUniqueRng() { return {} as RandomGenerator; @@ -17,38 +19,78 @@ function clean() { beforeEach(clean); describe('uniformIntDistribution', () => { - it('Should call uniformIntDistributionInternal with correct size of range and source rng', () => - fc.assert( - fc - .property(settingsArbitrary, (settings) => { - // Arrange - const { from, to, rng, uniformIntDistributionInternal } = mockInternals(settings); - - // Act - uniformIntDistribution(from, to)(rng); - - // Assert - expect(uniformIntDistributionInternal).toHaveBeenCalledTimes(1); - expect(uniformIntDistributionInternal).toHaveBeenCalledWith(to - from + 1, rng); - }) - .beforeEach(clean) - )); - - it('Should offset by "from" the value produced by uniformIntDistributionInternal', () => - fc.assert( - fc - .property(settingsArbitrary, (settings) => { - // Arrange - const { from, to, rng, outputs } = mockInternals(settings); - - // Act - const [v, _nrng] = uniformIntDistribution(from, to)(rng); + describe('Small ranges (<= 2**32)', () => { + it('Should call uniformIntDistributionInternal with correct size of range and source rng', () => + fc.assert( + fc + .property(settingsArbitrary, (settings) => { + // Arrange + const { from, to, rng, uniformIntDistributionInternal } = mockInternals(settings); + + // Act + uniformIntDistribution(from, to)(rng); + + // Assert + expect(uniformIntDistributionInternal).toHaveBeenCalledTimes(1); + expect(uniformIntDistributionInternal).toHaveBeenCalledWith(to - from + 1, rng); + }) + .beforeEach(clean) + )); + + it('Should offset by "from" the value produced by uniformIntDistributionInternal', () => + fc.assert( + fc + .property(settingsArbitrary, (settings) => { + // Arrange + const { from, to, rng, outputs } = mockInternals(settings); + + // Act + const [v, _nrng] = uniformIntDistribution(from, to)(rng); + + // Assert + expect(v).toBe(outputs[0][0] + from); + }) + .beforeEach(clean) + )); + }); - // Assert - expect(v).toBe(outputs[0][0] + from); - }) - .beforeEach(clean) - )); + describe('Large ranges (> 2**32)', () => { + it('Should call uniformIntDistributionInternal with correct size of range and source rng', () => + fc.assert( + fc + .property(settingsLargeArbitrary, (settings) => { + // Arrange + const { from, to, rng, uniformArrayIntDistributionInternal } = mockLargeInternals(settings); + + // Act + uniformIntDistribution(from, to)(rng); + + // Assert + expect(uniformArrayIntDistributionInternal).toHaveBeenCalledTimes(1); + expect(uniformArrayIntDistributionInternal).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), rng); + const params = uniformArrayIntDistributionInternal.mock.calls[0]; + const rangeSize = params[1]; + expect(rangeSize[0] * 2 ** 32 + rangeSize[1]).toBe(to - from + 1); + }) + .beforeEach(clean) + )); + + it('Should offset by "from" the value produced by uniformIntDistributionInternal', () => + fc.assert( + fc + .property(settingsLargeArbitrary, (settings) => { + // Arrange + const { from, to, rng, outputs } = mockLargeInternals(settings); + + // Act + const [v, _nrng] = uniformIntDistribution(from, to)(rng); + + // Assert + expect(v).toBe(outputs[0][0][0] * 2 ** 32 + outputs[0][0][1] + from); + }) + .beforeEach(clean) + )); + }); it('Should return the rng produced by uniformIntDistributionInternal', () => fc.assert( @@ -86,15 +128,17 @@ describe('uniformIntDistribution', () => { // Helpers -const settingsArbitrary = fc.record( - { - from: fc.integer(), - gap: fc.integer({ min: 0, max: 0xffffffff }), - rangeRandom: fc.integer({ min: 0, max: 0xffffffff }), - ctx: fc.context(), - }, - { withDeletedKeys: false } -); +const settingsArbitrary = fc + .record( + { + from: fc.maxSafeInteger(), + gap: fc.integer({ min: 0, max: 0xffffffff }), + rangeRandom: fc.nat().noShrink(), + ctx: fc.context(), + }, + { withDeletedKeys: false } + ) + .filter(({ from, gap }) => Number.isSafeInteger(from + gap)); type SettingsType = typeof settingsArbitrary extends fc.Arbitrary ? U : never; @@ -114,3 +158,34 @@ function mockInternals(settings: SettingsType) { return { from, to, rng, outputs, uniformIntDistributionInternal }; } + +const settingsLargeArbitrary = fc + .record( + { + from: fc.maxSafeInteger(), + gap: fc.integer({ min: 0x100000000, max: Number.MAX_SAFE_INTEGER }), + rangeRandom: fc.nat().noShrink(), + ctx: fc.context(), + }, + { withDeletedKeys: false } + ) + .filter(({ from, gap }) => Number.isSafeInteger(from + gap)); + +type SettingsLargeType = typeof settingsLargeArbitrary extends fc.Arbitrary ? U : never; + +function mockLargeInternals(settings: SettingsLargeType) { + const { uniformArrayIntDistributionInternal } = mocked(UniformArrayIntDistributionInternalMock); + + const { from, gap, rangeRandom, ctx } = settings; + const to = from + gap; + const rng = buildUniqueRng(); + const outputs: [number[], RandomGenerator][] = []; + uniformArrayIntDistributionInternal.mockImplementation((rangeSize) => { + const out = [rangeSize.map((r) => rangeRandom % (r || 1)), buildUniqueRng()] as [number[], RandomGenerator]; + ctx.log(`uniformArrayIntDistributionInternal(${JSON.stringify(rangeSize)}) -> [${JSON.stringify(out[0])}, ]`); + outputs.push([...out]); // we clone it, nothing forbid caller to mutate it + return out; + }); + + return { from, to, rng, outputs, uniformArrayIntDistributionInternal }; +} diff --git a/test/unit/distribution/__snapshots__/UniformIntDistribution.noreg.spec.ts.snap b/test/unit/distribution/__snapshots__/UniformIntDistribution.noreg.spec.ts.snap index 57a59765..0a1629c5 100644 --- a/test/unit/distribution/__snapshots__/UniformIntDistribution.noreg.spec.ts.snap +++ b/test/unit/distribution/__snapshots__/UniformIntDistribution.noreg.spec.ts.snap @@ -2,16 +2,16 @@ exports[`uniformIntDistribution [non regression] Should not change its output in range (-9007199254740991, 9007199254740991) except for major bumps 1`] = ` Array [ - 8737460674930689, - -2631724699627519, - -4869445551763455, - 8293357854228481, - 5809860774060033, - -1881172549904383, - -7398457846580223, - -5844702921965567, - 5236253499641857, - 2596198112838657, + 8737460674931249, + -2631724699628351, + -4869445551762692, + 8293357854227817, + 5809860774059733, + -1881172549904653, + -7398457846580648, + -5844702921966119, + 5236253499641229, + 2596198112838337, ] `; @@ -92,61 +92,61 @@ Array [ exports[`uniformIntDistribution [non regression] Should not change its output in range (0, 4294967296) except for major bumps 1`] = ` Array [ - 189112320, - 554379264, - 1095999488, - 1298663424, - 858602496, - 3171780608, - 3693446144, - 708411392, - 1327118336, - 404687872, + 2546248239, + 1277901399, + 243580376, + 1171049868, + 2051556033, + 3488238119, + 1686997841, + 602801999, + 2034131043, + 3439885489, ] `; exports[`uniformIntDistribution [non regression] Should not change its output in range (0, 1099511627774) except for major bumps 1`] = ` Array [ - 741289830400, - 506149267456, - 291457769472, - 841166684160, - 41339999232, - 91856056320, - 155904065536, - 300906252288, - 379144339456, - 251166092288, + 741280623151, + 506137267392, + 291447657211, + 841157541223, + 41332891347, + 91845220082, + 155896724055, + 300891291096, + 379128171916, + 251159659201, ] `; exports[`uniformIntDistribution [non regression] Should not change its output in range (0, 1099511627775) except for major bumps 1`] = ` Array [ - 741280622592, - 506137268224, - 291447656448, - 841157541888, - 41332891648, - 91845220352, - 155896724480, - 300891291648, - 379128172544, - 251159659520, + 642496375343, + 205489556672, + 300037591803, + 274221858151, + 475124588243, + 521341949682, + 14162803287, + 489869852120, + 529452027276, + 865339982529, ] `; exports[`uniformIntDistribution [non regression] Should not change its output in range (0, 1099511627776) except for major bumps 1`] = ` Array [ - 741271414784, - 506125268992, - 291437543424, - 841148399616, - 41325784064, - 91834384384, - 155889383424, - 300876331008, - 379112005632, - 251153226752, + 642496375343, + 205489556672, + 300037591803, + 274221858151, + 475124588243, + 521341949682, + 14162803287, + 489869852120, + 529452027276, + 865339982529, ] `; diff --git a/test/unit/distribution/internals/ArrayInt.spec.ts b/test/unit/distribution/internals/ArrayInt.spec.ts new file mode 100644 index 00000000..ec6239a6 --- /dev/null +++ b/test/unit/distribution/internals/ArrayInt.spec.ts @@ -0,0 +1,117 @@ +import * as fc from 'fast-check'; + +import { + ArrayInt, + ArrayInt64, + fromNumberToArrayInt64, + substractArrayInt64, + toNumber, +} from '../../../../src/distribution/internals/ArrayInt'; + +describe('ArrayInt', () => { + describe('fromNumberToArrayInt64', () => { + it('Should be able to convert any 32 bits positive integer to an ArrayInt64', () => + fc.assert( + fc.property(fc.integer({ min: 0, max: 0xffffffff }), (value) => { + const arrayInt = fromNumberToArrayInt64(arrayInt64Buffer(), value); + expect(arrayInt).toEqual({ sign: 1, data: [0, value] }); + }) + )); + + it('Should be able to convert any 32 bits negative integer to an ArrayInt64', () => + fc.assert( + fc.property(fc.integer({ min: 1, max: 0xffffffff }), (value) => { + const arrayInt = fromNumberToArrayInt64(arrayInt64Buffer(), -value); + expect(arrayInt).toEqual({ sign: -1, data: [0, value] }); + }) + )); + + it('Should be able to convert any safe integer to an ArrayInt64', () => + fc.assert( + fc.property(fc.maxSafeInteger(), (value) => { + const arrayInt = fromNumberToArrayInt64(arrayInt64Buffer(), value); + + expect(arrayInt.sign).toBe(value < 0 ? -1 : 1); + expect(arrayInt.data).toHaveLength(2); + + const arrayIntHexaRepr = + arrayInt.data[0].toString(16).padStart(8, '0') + arrayInt.data[1].toString(16).padStart(8, '0'); + const valueHexaRepr = Math.abs(value).toString(16).padStart(16, '0'); + expect(arrayIntHexaRepr).toBe(valueHexaRepr); + }) + )); + + it('Should be able to read back itself using toNumber', () => + fc.assert( + fc.property(fc.maxSafeInteger(), (value) => { + const arrayInt = fromNumberToArrayInt64(arrayInt64Buffer(), value); + expect(toNumber(arrayInt)).toBe(value); + }) + )); + }); + + describe('toNumber', () => { + it('Should be able to read ArrayInt of length 1', () => + fc.assert( + fc.property( + fc.constantFrom(undefined, -1, 1), + fc.integer({ min: 0, max: 0xffffffff }), + (sign: 1 | -1 | undefined, value) => { + const arrayInt: ArrayInt = { sign, data: [value] }; + expect(toNumber(arrayInt)).toBe(sign === -1 ? -value : value); + } + ) + )); + + it('Should be able to read ArrayInt of length 2 (in safe zone)', () => + fc.assert( + fc.property(fc.maxSafeInteger(), (value) => { + const valueHexaRepr = Math.abs(value).toString(16).padStart(16, '0'); + const arrayInt: ArrayInt = { + sign: value < 0 ? -1 : (1 as const), + data: [parseInt(valueHexaRepr.substring(0, 8), 16), parseInt(valueHexaRepr.substring(8), 16)], + }; + expect(toNumber(arrayInt)).toBe(value); + }) + )); + }); + + describe('substractArrayInt64', () => { + if (typeof BigInt === 'undefined') { + it('no test', () => { + expect(true).toBe(true); + }); + return; + } + if (typeof BigInt !== 'undefined') { + const fromBigIntToArrayInt64 = (n: bigint): ArrayInt64 => { + const posN = n < BigInt(0) ? -n : n; + return { + sign: n < BigInt(0) ? -1 : 1, + data: [Number(posN >> BigInt(32)), Number(posN % (BigInt(1) << BigInt(32)))], + }; + }; + + it('Should be able to substract two non-overflowing ArrayInt64', () => + fc.assert( + fc.property(fc.bigIntN(64), fc.bigIntN(64), (a, b) => { + const min = a < b ? a : b; + const max = a < b ? b : a; + const result = max - min; + fc.pre(result < BigInt(1) << BigInt(64)); + + const minArrayInt = fromBigIntToArrayInt64(min); + const maxArrayInt = fromBigIntToArrayInt64(max); + const resultArrayInt = fromBigIntToArrayInt64(result); + expect(substractArrayInt64(arrayInt64Buffer(), maxArrayInt, minArrayInt)).toEqual(resultArrayInt); + }) + )); + } + }); +}); + +// Helpers + +function arrayInt64Buffer(): ArrayInt64 { + return { sign: 1, data: [0, 0] }; +} diff --git a/test/unit/distribution/internals/UniformArrayIntDistributionInternal.spec.ts b/test/unit/distribution/internals/UniformArrayIntDistributionInternal.spec.ts new file mode 100644 index 00000000..9ccff50a --- /dev/null +++ b/test/unit/distribution/internals/UniformArrayIntDistributionInternal.spec.ts @@ -0,0 +1,204 @@ +import * as fc from 'fast-check'; +import { mocked } from 'ts-jest/utils'; + +import { uniformArrayIntDistributionInternal } from '../../../../src/distribution/internals/UniformArrayIntDistributionInternal'; +import { ArrayInt } from '../../../../src/distribution/internals/ArrayInt'; +import RandomGenerator from '../../../../src/generator/RandomGenerator'; + +import * as UniformIntDistributionInternalMock from '../../../../src/distribution/internals/UniformIntDistributionInternal'; +jest.mock('../../../../src/distribution/internals/UniformIntDistributionInternal'); + +function buildUniqueRng() { + return {} as RandomGenerator; +} +function clean() { + jest.resetAllMocks(); + jest.clearAllMocks(); +} + +beforeEach(clean); +describe('uniformArrayIntDistributionInternal', () => { + it.each` + rangeSize | resultingArrayInt | description + ${[10, 20, 30]} | ${[1, 1, 1]} | ${'all generated values are smaller'} + ${[10, 20, 30]} | ${[8, 520, 1000]} | ${'some generated values are greater but resulting array is smaller'} + ${[10, 20, 30]} | ${[10, 20, 29]} | ${'resulting array is rangeSize minus one'} + ${[1]} | ${[0]} | ${'smallest possible rangeSize'} + ${[0, 0, 1, 0, 1, 0]} | ${[0, 0, 1, 0, 0, 1]} | ${'rangeSize starting by and including zeros'} + `('Should only call the rangeSize.length times when $description', ({ rangeSize, resultingArrayInt }) => { + // Arrange + const { uniformIntDistributionInternal } = mocked(UniformIntDistributionInternalMock); + const initialRng = buildUniqueRng(); + let expectedRng = initialRng; + for (let idx = 0; idx !== resultingArrayInt.length; ++idx) { + // In terms of calls, the expectedRangeSize is: + // [ rangeSize[0] + 1 , 0x1_00000000 , ... ] + // In other words, we can generate (with the same probability) any number in: + // [ between 0 (inc) and rangeSize[0] (inc) , between 0 (inc) and 0xffffffff (inc) , ... ] + // Values outside greater or equal to rangeSize will be rejected. We will retry until we get a valid value. + const expectedRangeSize = idx === 0 ? rangeSize[0] + 1 : 0x100000000; + const generatedItem = resultingArrayInt[idx]; + const nextRng = buildUniqueRng(); + uniformIntDistributionInternal.mockImplementationOnce((askedRangeSize, _rng) => { + expect(askedRangeSize).toBe(expectedRangeSize); + return [generatedItem, nextRng]; + }); + expectedRng = nextRng; + } + + // Act + const g = uniformArrayIntDistributionInternal(arrayIntBuffer(rangeSize.length).data, rangeSize, initialRng); + + // Assert + expect(g[0]).toEqual(resultingArrayInt); + expect(g[1]).toBe(expectedRng); + }); + + it.each` + rangeSize | rejections | resultingArrayInt | description + ${[10, 20, 30]} | ${[10, 20, 30]} | ${[1, 1, 1]} | ${'first generated value is the rangeSize itself'} + ${[10, 20, 30]} | ${[10, 50, 0]} | ${[1, 1, 1]} | ${'first generated value is greater than the rangeSize due to middle item'} + ${[10, 20, 30]} | ${[10, 20, 50]} | ${[1, 1, 1]} | ${'first generated value is greater than the rangeSize due to last item'} + ${[10, 20, 30]} | ${[10, 100, 1000, 10, 100, 1000, 10, 100, 1000]} | ${[1, 1, 1]} | ${'multiple rejections in a row'} + `('Should retry until we get a valid value when $description', ({ rangeSize, rejections, resultingArrayInt }) => { + // Arrange + const { uniformIntDistributionInternal } = mocked(UniformIntDistributionInternalMock); + const initialRng = buildUniqueRng(); + let expectedRng = initialRng; + for (let idx = 0; idx !== rejections.length; ++idx) { + // Rq: We check on `idx % rangeSize.length === 0` as our surrent implementation does not quit earlier. + // It will generate a full value before rejecting it. + const expectedRangeSize = idx % rangeSize.length === 0 ? rangeSize[0] + 1 : 0x100000000; + const generatedItem = rejections[idx]; + const nextRng = buildUniqueRng(); + uniformIntDistributionInternal.mockImplementationOnce((askedRangeSize, _rng) => { + expect(askedRangeSize).toBe(expectedRangeSize); + return [generatedItem, nextRng]; + }); + expectedRng = nextRng; + } + for (let idx = 0; idx !== resultingArrayInt.length; ++idx) { + const expectedRangeSize = idx === 0 ? rangeSize[0] + 1 : 0x100000000; + const generatedItem = resultingArrayInt[idx]; + const nextRng = buildUniqueRng(); + uniformIntDistributionInternal.mockImplementationOnce((askedRangeSize, _rng) => { + expect(askedRangeSize).toBe(expectedRangeSize); + return [generatedItem, nextRng]; + }); + expectedRng = nextRng; + } + + // Act + const g = uniformArrayIntDistributionInternal(arrayIntBuffer(rangeSize.length).data, rangeSize, initialRng); + + // Assert + expect(g[0]).toEqual(resultingArrayInt); + expect(g[1]).toBe(expectedRng); + }); + + it('Should call uniformIntDistributionInternal until it produces a valid ArrayInt', () => + // Identical to the test above "Should retry until we get a valid value when $description" but with property based testing + fc.assert( + fc + .property( + fc + .bigInt({ min: BigInt(1) }) + .chain((rangeSize) => + fc.record( + { + rangeSize: fc.constant(rangeSize), + rejectedValues: fc.array(fc.bigInt({ min: rangeSize })), + validValue: fc.bigInt({ min: BigInt(0), max: rangeSize - BigInt(1) }), + }, + { withDeletedKeys: false } + ) + ) + .map(({ rangeSize, rejectedValues, validValue }) => { + let rangeSizeArrayIntData = fromBigUintToArrayIntData(rangeSize); + let validValueArrayIntData = fromBigUintToArrayIntData(validValue); + let rejectedValuesArrayIntData = rejectedValues.map(fromBigUintToArrayIntData); + const maxDataLength = [ + rangeSizeArrayIntData, + validValueArrayIntData, + ...rejectedValuesArrayIntData, + ].reduce((acc, data) => Math.max(acc, data.length), 0); + rangeSizeArrayIntData = padDataOfArrayInt(rangeSizeArrayIntData, maxDataLength); + validValueArrayIntData = padDataOfArrayInt(validValueArrayIntData, maxDataLength); + rejectedValuesArrayIntData = rejectedValuesArrayIntData.map((v) => padDataOfArrayInt(v, maxDataLength)); + return { + rangeSize: rangeSizeArrayIntData, + rejectedValues: rejectedValuesArrayIntData, + validValue: validValueArrayIntData, + }; + }), + fc.context(), + ({ rangeSize, rejectedValues, validValue }, ctx) => { + // Arrange + const initialRng = buildUniqueRng(); + let expectedRng = initialRng; + for (const rejected of rejectedValues) { + // Our mock for uniformIntDistributionInternal starts by producing invalid values + // (too large for the requested range). + // All those values should be rejected by uniformArrayIntDistributionInternal. + // Internally uniformArrayIntDistributionInternal do not optimize calls (for the moment) + // it always queries for all the data then checks if the data is ok or not given rangeSize. + // In other words, if rangeSize = [1,1,1], the algorithm will query for at least three entries + // even if it can stop earlier [1,2,1,...] (when receiving the 2, we know it will not fit our needs). + expectedRng = mockResponse(rejected, expectedRng, ctx); + } + expectedRng = mockResponse(validValue, expectedRng, ctx); + mockRejectNextCalls(ctx); + + // Act + const g = uniformArrayIntDistributionInternal(arrayIntBuffer(rangeSize.length).data, rangeSize, initialRng); + + // Assert + expect(g[0]).toEqual(validValue); + expect(g[1]).toBe(expectedRng); + } + ) + .beforeEach(clean) + )); +}); + +// Helpers + +function arrayIntBuffer(size: number): ArrayInt { + return { sign: 1, data: Array(size).fill(0) }; +} + +function fromBigUintToArrayIntData(n: bigint): ArrayInt['data'] { + const data: number[] = []; + const repr = n.toString(16); + for (let sectionEnd = repr.length; sectionEnd > 0; sectionEnd -= 8) { + data.push(parseInt(repr.substring(sectionEnd - 8, sectionEnd), 16)); + } + return data.reverse(); +} + +function padDataOfArrayInt(arrayIntData: ArrayInt['data'], length: number): ArrayInt['data'] { + return [...Array(length - arrayIntData.length).fill(0), ...arrayIntData]; +} + +function mockResponse(arrayIntData: ArrayInt['data'], previousRng: RandomGenerator, ctx: fc.ContextValue) { + const { uniformIntDistributionInternal } = mocked(UniformIntDistributionInternalMock); + let currentRng = previousRng; + for (const item of arrayIntData) { + const nextRng = buildUniqueRng(); + uniformIntDistributionInternal.mockImplementationOnce((rangeSize, _rng) => { + const out = [item, nextRng] as [number, RandomGenerator]; + ctx.log(`uniformIntDistributionInternal(${rangeSize}) -> [${out[0]}, ]`); + return out; + }); + currentRng = nextRng; + } + return currentRng; +} + +function mockRejectNextCalls(ctx: fc.ContextValue) { + const { uniformIntDistributionInternal } = mocked(UniformIntDistributionInternalMock); + uniformIntDistributionInternal.mockImplementationOnce((rangeSize, _rng) => { + ctx.log(`uniformIntDistributionInternal(${rangeSize}) -> [..., ]`); + throw new Error('No more calls expected'); + }); +}