Skip to content

Commit ad16ba2

Browse files
committed
💎 #169 Ensure floats out of Gen.float() abide by minPrecision
1 parent 6c4b65d commit ad16ba2

File tree

9 files changed

+162
-76
lines changed

9 files changed

+162
-76
lines changed

packages/pbt/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"ix": "^4.0.0"
1818
},
1919
"devDependencies": {
20+
"@types/big.js": "^6.0.0",
2021
"@types/jest": "^26.0.10",
22+
"big.js": "^6.0.3",
2123
"cross-env": "^7.0.2",
2224
"fast-check": "^2.2.0",
2325
"jest": "^26.4.1",

packages/pbt/src/Gen/Gens/FloatGen.ts

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ type FloatGenConfig = Readonly<{
2525
origin: number | null;
2626
minPrecision: number | null;
2727
maxPrecision: number | null;
28-
scale: ScaleMode | null;
28+
noBias: boolean;
2929
}>;
3030

3131
// TODO: Origin validation (defer to genFactory.integer())
3232
// TODO: Handle decimal min/max by destructuring into min/max integer component and min/max fractional component
3333
// TODO: Handle a min/max range of less than 2
3434
// TODO: Negative ranges do not shrink to the origin e.g. Gen.float().between(-10, -1) does not minimise to -1 (it minimises to -2, off-by-one)
35-
// TODO: Memoize powers of 10
35+
// TODO: Add "unsafe" filter, a filter that does not produce discards. Use internally in float gen
3636

3737
export const FloatGen = {
3838
create: (): FloatGen => {
@@ -72,7 +72,7 @@ export const FloatGen = {
7272

7373
/* istanbul ignore next */
7474
noBias(): FloatGen {
75-
return this.withConfig({ scale: 'constant' });
75+
return this.withConfig({ noBias: true });
7676
}
7777

7878
private withConfig(config: Partial<FloatGenConfig>): FloatGen {
@@ -89,7 +89,7 @@ export const FloatGen = {
8989
origin: null,
9090
minPrecision: null,
9191
maxPrecision: null,
92-
scale: null,
92+
noBias: false,
9393
});
9494
},
9595
};
@@ -138,7 +138,7 @@ const genFloat = (args: FloatGenConfig): GenRunnable<number> => {
138138
* precisions will be generated, producing "less complex" fractions.
139139
*/
140140
const genFractionalComponent = (precision: number): Gen<number> => {
141-
const maxFractionalComponentAsInteger = Math.pow(10, precision) - 1;
141+
const maxFractionalComponentAsInteger = tenPowX(precision) - 1;
142142
return Gen.integer().between(0, maxFractionalComponentAsInteger).noBias();
143143
};
144144

@@ -150,7 +150,7 @@ const genFloat = (args: FloatGenConfig): GenRunnable<number> => {
150150
};
151151

152152
const makeDecimal = (integerComponent: number, fractionalComponentAsInteger: number, precision: number): number => {
153-
const integerToFractionRatio = Math.pow(10, precision);
153+
const integerToFractionRatio = tenPowX(precision);
154154
const fractionalComponent = fractionalComponentAsInteger / integerToFractionRatio;
155155
switch (Math.sign(integerComponent)) {
156156
/* istanbul ignore next */
@@ -207,66 +207,90 @@ const tryDeriveMaxPrecision = (maxPrecision: number | null): number | string =>
207207
};
208208

209209
const tryDeriveMin = (min: number | null, minPrecision: number, maxPrecision: number): number | string => {
210-
if (min === null) return -MAX_INT_32; // TODO: Ensure this fits into minPrecision
210+
if (min !== null) {
211+
const precisionOfMin = measurePrecision(min);
212+
if (precisionOfMin > maxPrecision) {
213+
return `Bound violates maximum precision constraint, minPrecision = ${minPrecision}, maxPrecision = ${maxPrecision}, min = ${min}`;
214+
}
211215

212-
const precisionOfMin = measurePrecision(min);
213-
if (precisionOfMin > maxPrecision) {
214-
return `Bound must be within precision range, minPrecision = ${minPrecision}, maxPrecision = ${maxPrecision}, min = ${min}`;
216+
const precisionMagnitude = magnitudeOfPrecision(minPrecision);
217+
if (min < -precisionMagnitude) {
218+
return `Bound violates minimum precision constraint, minPrecision = ${minPrecision}, minMin = -${precisionMagnitude}, receivedMin = ${min}`;
219+
}
215220
}
216221

217-
const precisionMagnitude = magnitudeOfPrecision(minPrecision);
218-
if (min < -precisionMagnitude) {
219-
return `Bound violates minimum precision constraint, minPrecision = ${minPrecision}, minMin = -${precisionMagnitude}, receivedMin = ${min}`;
222+
if (min === null && minPrecision > 0) {
223+
return -tenPowX(FLOAT_BITS - minPrecision);
220224
}
221225

222-
return min;
226+
return min === null ? -MAX_INT_32 : roundToInteger(min);
223227
};
224228

225229
const tryDeriveMax = (max: number | null, minPrecision: number, maxPrecision: number): number | string => {
226-
if (max === null) return MAX_INT_32; // TODO: Ensure this fits into minPrecision
230+
if (max !== null) {
231+
const precisionOfMax = measurePrecision(max);
232+
if (precisionOfMax > maxPrecision) {
233+
return `Bound violates maximum precision constraint, minPrecision = ${minPrecision}, maxPrecision = ${maxPrecision}, max = ${max}`;
234+
}
227235

228-
const precisionOfMax = measurePrecision(max);
229-
if (precisionOfMax > maxPrecision) {
230-
return `Bound must be within precision range, minPrecision = ${minPrecision}, maxPrecision = ${maxPrecision}, max = ${max}`;
236+
const precisionMagnitude = magnitudeOfPrecision(minPrecision);
237+
if (max > precisionMagnitude) {
238+
return `Bound violates minimum precision constraint, minPrecision = ${minPrecision}, maxMax = ${precisionMagnitude}, receivedMax = ${max}`;
239+
}
231240
}
232241

233-
const precisionMagnitude = magnitudeOfPrecision(minPrecision);
234-
if (max > precisionMagnitude) {
235-
return `Bound violates minimum precision constraint, minPrecision = ${minPrecision}, maxMax = ${precisionMagnitude}, receivedMax = ${max}`;
242+
if (max === null && minPrecision > 0) {
243+
return tenPowX(FLOAT_BITS - minPrecision);
236244
}
237245

238-
return max;
246+
return max === null ? MAX_INT_32 : roundToInteger(max);
239247
};
240248

241249
const measurePrecision = (x: number): number => {
242-
const xStr = x.toPrecision(1);
250+
const xStr = x.toPrecision();
243251
const match = xStr.match(/e-(\d+)/);
244252

245253
/*istanbul ignore next */
246254
if (match !== null) {
247255
return Number(match[1]);
248256
}
249257

250-
let count = 0;
251-
x = Math.abs(x);
252-
253-
while (x % 1 > 0) {
254-
count++;
255-
x = x * 10;
256-
257-
/*istanbul ignore next */
258-
if (count > 100) {
259-
throw new Error(`Fatal: Exceeded calculation limit whilst measuring precision, x = ${x}`);
260-
}
261-
}
262-
263-
return count;
258+
const fractionalComponentStr = xStr.split('.')[1];
259+
return fractionalComponentStr === undefined ? 0 : fractionalComponentStr.length;
264260
};
265261

266-
const unitOfPrecision = (precision: number): number => Math.pow(10, -precision);
262+
const unitOfPrecision = (precision: number): number => tenPowX(-precision);
267263

268264
const magnitudeOfPrecision = (precision: number): number => {
269265
if (precision === 0) return Infinity;
270-
const max = Math.pow(10, FLOAT_BITS - precision) - unitOfPrecision(precision);
271-
return max;
266+
return tenPowX(FLOAT_BITS - precision) - unitOfPrecision(precision);
267+
};
268+
269+
const tenPowX = (() => {
270+
const memo = new Map<number, number>();
271+
272+
return (x: number): number => {
273+
let result = memo.get(x);
274+
275+
if (!result) {
276+
result = Math.pow(10, x);
277+
memo.set(x, result);
278+
}
279+
280+
return result;
281+
};
282+
})();
283+
284+
const roundToInteger = (x: number): number => {
285+
switch (Math.sign(x)) {
286+
case 0:
287+
return 0;
288+
case 1:
289+
return Math.round(x);
290+
case -1:
291+
return -Math.round(-x);
292+
/* istanbul ignore next */
293+
default:
294+
throw new Error('Fatal: Unhandled result from Math.sign');
295+
}
272296
};

packages/pbt/src/Runners/Check.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const accumulateIterations = <Ts extends any[]>(property: Property<Ts>, config:
118118
last(
119119
pipe(
120120
property.run(config.seed, config.iterations, { size: config.size, path: config.path }),
121-
ExhaustionStrategy.apply(ExhaustionStrategy.defaultStrategy(), (iteration) => iteration.kind === 'discard'),
121+
ExhaustionStrategy.asOperator((iteration) => iteration.kind === 'discard'),
122122
scan<Exhaustible<Property.PropertyIteration<Ts>>, IterationAccumulator<Ts>>({
123123
seed: {
124124
lastIteration: {

packages/pbt/src/Runners/ExhaustionStrategy.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OperatorFunction } from 'ix/interfaces';
2+
import { pipe } from 'ix/iterable';
23
import { flatMap } from 'ix/iterable/operators';
34

45
export const Exhausted = Symbol('Exhausted');
@@ -12,9 +13,9 @@ export type ExhaustionStrategy = {
1213
};
1314

1415
export namespace ExhaustionStrategy {
15-
export const apply = <Iteration>(
16-
exhaustionStrategy: ExhaustionStrategy,
16+
export const asOperator = <Iteration>(
1717
isDiscard: (iteration: Iteration) => boolean,
18+
exhaustionStrategy: ExhaustionStrategy = defaultStrategy(),
1819
): OperatorFunction<Iteration, Exhaustible<Iteration>> =>
1920
flatMap((iteration) => {
2021
if (exhaustionStrategy.isExhausted()) {
@@ -30,6 +31,12 @@ export namespace ExhaustionStrategy {
3031
return exhaustionStrategy.isExhausted() ? [iteration, { kind: 'exhausted' }] : [iteration];
3132
});
3233

34+
export const apply = <Iteration>(
35+
stream: Iterable<Iteration>,
36+
isDiscard: (iteration: Iteration) => boolean,
37+
exhaustionStrategy: ExhaustionStrategy = defaultStrategy(),
38+
): Iterable<Exhaustible<Iteration>> => pipe(stream, asOperator(isDiscard, exhaustionStrategy));
39+
3340
export const whenDiscardRateExceeds = (rate: number): ExhaustionStrategy => {
3441
if (rate < 0 || rate > 1) throw new Error('Fatal: Discard rate out-of-range');
3542

packages/pbt/src/Runners/Sample.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const sampleTreesInternal = <T>(gen: Gen<T>, config: Partial<SampleConfig
6666
const sampleAccumulator = last(
6767
pipe(
6868
gen.run(rng, size, {}),
69-
ExhaustionStrategy.apply(ExhaustionStrategy.defaultStrategy(), (iteration) => iteration.kind === 'discard'),
69+
ExhaustionStrategy.asOperator((iteration) => iteration.kind === 'discard'),
7070
scan<Exhaustible<GenIteration<T>>, SampleAccumulator<T>>({
7171
seed: {
7272
trees: [],

packages/pbt/test/Gen/Gen.Float.test.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1+
import { Big } from 'big.js';
12
import { Gen } from 'pbt';
23
import * as dev from '../../src';
34
import { expectGen } from '../Helpers/expectGen';
45

5-
export namespace LocalGen {
6-
const U_SHORT_MAX = Math.pow(2, 15);
7-
8-
export const short = (): Gen<number> => Gen.integer().between(U_SHORT_MAX, -U_SHORT_MAX);
9-
}
10-
116
test('Gen.float().between(0, 10).betweenPrecision(0, 2)', () => {
127
for (let i = 0; i <= 10; i++) {
138
const gen = dev.Gen.float().between(0, 10).betweenPrecision(0, 2);
@@ -51,12 +46,11 @@ test('Gen.float().between(-10, -1).betweenPrecision(0, 2)', () => {
5146
describe('errors', () => {
5247
test.property(
5348
'Gen.float().ofMinPrecision(x), x ∉ ℤ *produces* error; minimum precision must be an integer',
54-
Gen.integer().greaterThanEqual(0),
49+
Gen.float().greaterThanEqual(0).ofMinPrecision(1),
5550
(x) => {
56-
const x0 = x + 0.1;
57-
const gen = dev.Gen.float().ofMinPrecision(x0);
51+
const gen = dev.Gen.float().ofMinPrecision(x);
5852

59-
expectGen(gen).toError(`Minimum precision must be an integer, minPrecision = ${x0}`);
53+
expectGen(gen).toError(`Minimum precision must be an integer, minPrecision = ${x}`);
6054
},
6155
);
6256

@@ -82,12 +76,11 @@ describe('errors', () => {
8276

8377
test.property(
8478
'Gen.float().ofMaxPrecision(x), x ∉ ℤ *produces* error; maximum precision must be an integer',
85-
Gen.integer().greaterThanEqual(0),
79+
Gen.float().greaterThanEqual(0).ofMinPrecision(1),
8680
(x) => {
87-
const x0 = x + 0.1;
88-
const gen = dev.Gen.float().ofMaxPrecision(x0);
81+
const gen = dev.Gen.float().ofMaxPrecision(x);
8982

90-
expectGen(gen).toError(`Maximum precision must be an integer, maxPrecision = ${x0}`);
83+
expectGen(gen).toError(`Maximum precision must be an integer, maxPrecision = ${x}`);
9184
},
9285
);
9386

@@ -112,29 +105,37 @@ describe('errors', () => {
112105
);
113106

114107
test.property(
115-
'Gen.float().greaterThanEqual(x).ofMaxPrecision(y), fractionalPrecisionOf(x) > y *produces* error; bound must be within precision',
116-
LocalGen.short(),
117-
Gen.integer().between(0, 10),
118-
(x, p) => {
119-
const x0 = x + Math.pow(10, -p - 1);
120-
const gen = dev.Gen.float().greaterThanEqual(x0).ofMaxPrecision(p);
108+
'Gen.float().greaterThanEqual(x).ofMaxPrecision(y), fractionalPrecisionOf(x) > y *produces* error; bound violates maximum precision constraint',
109+
Gen.integer()
110+
.between(0, 5)
111+
.flatMap((maxPrecision) =>
112+
Gen.float()
113+
.ofMinPrecision(maxPrecision + 2) // TODO: This should be +1, but there is a bug in current version of float gen
114+
.map((min) => [min, maxPrecision]),
115+
),
116+
([min, maxPrecision]) => {
117+
const gen = dev.Gen.float().greaterThanEqual(min).ofMaxPrecision(maxPrecision);
121118

122119
expectGen(gen).toError(
123-
`Bound must be within precision range, minPrecision = 0, maxPrecision = ${p}, min = ${x0}`,
120+
`Bound violates maximum precision constraint, minPrecision = 0, maxPrecision = ${maxPrecision}, min = ${min}`,
124121
);
125122
},
126123
);
127124

128125
test.property(
129-
'Gen.float().lessThanEqual(x).ofMaxPrecision(y), fractionalPrecisionOf(x) > y *produces* error; bound must be within precision',
130-
LocalGen.short(),
131-
Gen.integer().between(0, 10),
132-
(x, p) => {
133-
const x0 = x + Math.pow(10, -p - 1);
134-
const gen = dev.Gen.float().lessThanEqual(x0).ofMaxPrecision(p);
126+
'Gen.float().lessThanEqual(x).ofMaxPrecision(y), fractionalPrecisionOf(x) > y *produces* error; bound violates maximum precision constraint',
127+
Gen.integer()
128+
.between(0, 5)
129+
.flatMap((maxPrecision) =>
130+
Gen.float()
131+
.ofMinPrecision(maxPrecision + 2) // TODO: This should be +1, but there is a bug in current version of float gen
132+
.map((max) => [max, maxPrecision]),
133+
),
134+
([max, maxPrecision]) => {
135+
const gen = dev.Gen.float().lessThanEqual(max).ofMaxPrecision(maxPrecision);
135136

136137
expectGen(gen).toError(
137-
`Bound must be within precision range, minPrecision = 0, maxPrecision = ${p}, max = ${x0}`,
138+
`Bound violates maximum precision constraint, minPrecision = 0, maxPrecision = ${maxPrecision}, max = ${max}`,
138139
);
139140
},
140141
);
@@ -173,3 +174,17 @@ describe('errors', () => {
173174
},
174175
);
175176
});
177+
178+
test.property(
179+
'Gen.float().ofMinPrecision(p) *produces* values where fractional precision >= p',
180+
Gen.integer().between(1, 16),
181+
(p) => {
182+
const gen = dev.Gen.float().ofMinPrecision(p);
183+
184+
expectGen(gen).assertOnValues((x) => {
185+
const rounded = Big(x).round(p - 1);
186+
187+
expect(x).not.toEqual(rounded.toNumber());
188+
});
189+
},
190+
);

packages/pbt/test/Gen/Gen.StateMachine.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('about simple n-ary switches', () => {
2929

3030
const gen = dev.Gen.stateMachine(initialState, generateTransition, applyTransition);
3131

32-
expectGen(gen).onMinimum(
32+
expectGen(gen).assertOnMinimum(
3333
(states) => new Set(states).size === 2,
3434
(minimum) => {
3535
expect(minimum).toHaveLength(2);
@@ -48,7 +48,7 @@ describe('about simple n-ary switches', () => {
4848

4949
const gen = dev.Gen.stateMachine(initialState, generateTransition, applyTransition);
5050

51-
expectGen(gen).onMinimum(
51+
expectGen(gen).assertOnMinimum(
5252
(states) => new Set(states).size === 3,
5353
(minimum) => {
5454
expect(minimum).toHaveLength(3);

0 commit comments

Comments
 (0)