Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-bigdecimal-arbitrary-diversity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Generate BigDecimal arbitraries across ordinary, very small and very large magnitudes even at small sizes. Vary scales under range constraints instead of collapsing one-sided bounds to a small set of tiny fractions, and construct inclusive and exclusive decimal bounds without rejection sampling.
119 changes: 81 additions & 38 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10155,49 +10155,79 @@ export interface BigDecimal extends declare<BigDecimal_.BigDecimal> {
readonly "Rebuild": BigDecimal
}
const BigDecimalString = String.annotate({ expected: "a string that will be decoded as a BigDecimal" })
const arbitraryBigDecimalMaxScale = 20
function bigIntArbitrarySchema(minimum: bigint | undefined, maximum: bigint | undefined): Codec<bigint> {
// Keep ordinary amounts common while also exercising overflow and underflow when converted to a number.
const arbitraryBigDecimalScales = [0, 1, 2, 3, 6, 9, 12, 18, 20, 50, 100, 200, 400, -20, -100, -400]
function bigDecimalCoefficientSchema(minimum: bigint | undefined, maximum: bigint | undefined): Codec<bigint> {
if (minimum !== undefined && maximum !== undefined) {
return BigInt.check(isBetweenBigInt({ minimum, maximum }))
}
if (minimum !== undefined) return BigInt.check(isGreaterThanOrEqualToBigInt(minimum))
if (maximum !== undefined) return BigInt.check(isLessThanOrEqualToBigInt(maximum))
return BigInt
const zero = globalThis.BigInt(0)
const center = minimum !== undefined && minimum > zero
? minimum
: maximum !== undefined && maximum < zero
? maximum
: zero
return Union([
globalThis.BigInt(100),
globalThis.BigInt(1_000_000),
globalThis.BigInt(2) ** globalThis.BigInt(64),
globalThis.BigInt(2) ** globalThis.BigInt(256)
].map((radius) =>
BigInt.check(isBetweenBigInt({
minimum: minimum !== undefined && minimum > center - radius ? minimum : center - radius,
maximum: maximum !== undefined && maximum < center + radius ? maximum : center + radius
}))
))
}
function bigDecimalValueAtScale(value: BigDecimal_.BigDecimal, scale: number): bigint {
return value.value * globalThis.BigInt(10) ** globalThis.BigInt(scale - value.scale)
function bigDecimalCoefficientAtScale(value: BigDecimal_.BigDecimal, scale: number, ceil: boolean): bigint {
const difference = scale - value.scale
if (difference >= 0) return value.value * globalThis.BigInt(10) ** globalThis.BigInt(difference)
const divisor = globalThis.BigInt(10) ** globalThis.BigInt(-difference)
const quotient = value.value / divisor
const remainder = value.value % divisor
const zero = globalThis.BigInt(0)
return quotient +
(ceil && remainder > zero ? globalThis.BigInt(1) : !ceil && remainder < zero ? globalThis.BigInt(-1) : zero)
}
function bigDecimalArbitrarySchema(
constraint: Annotations.ToArbitrary.GenerationConstraint<BigDecimal_.BigDecimal> | undefined
): Codec<{ readonly value: bigint; readonly scale: number }> {
if (constraint?.minimum === undefined && constraint?.maximum === undefined) {
return Struct({
value: BigInt,
scale: Int.check(isBetween({ minimum: 0, maximum: arbitraryBigDecimalMaxScale }))
})
const scales = new Set(arbitraryBigDecimalScales)
// Boundary scales make narrow intervals and bounds outside the default scale range constructible.
for (const bound of [constraint?.minimum, constraint?.maximum]) {
if (bound !== undefined) {
scales.add(bound.scale)
if (bound.scale < globalThis.Number.MAX_SAFE_INTEGER) scales.add(bound.scale + 1)
}
}
const scale = Math.max(
arbitraryBigDecimalMaxScale,
constraint.minimum?.scale ?? 0,
constraint.maximum?.scale ?? 0,
constraint.exclusiveMinimum === true && constraint.minimum !== undefined ? constraint.minimum.scale + 1 : 0,
constraint.exclusiveMaximum === true && constraint.maximum !== undefined ? constraint.maximum.scale + 1 : 0
)
const minimum = constraint.minimum === undefined
? undefined
: bigDecimalValueAtScale(constraint.minimum, scale) +
(constraint.exclusiveMinimum === true ? globalThis.BigInt(1) : globalThis.BigInt(0))
const maximum = constraint.maximum === undefined
? undefined
: bigDecimalValueAtScale(constraint.maximum, scale) -
(constraint.exclusiveMaximum === true ? globalThis.BigInt(1) : globalThis.BigInt(0))
if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
return Struct({
value: BigInt,
scale: Int.check(isBetween({ minimum: 0, maximum: arbitraryBigDecimalMaxScale }))
})
const members: Array<Codec<{ readonly value: bigint; readonly scale: number }>> = []
const ordinary: typeof members = []
for (const scale of scales) {
// Inclusive bounds require ceil(min) / floor(max); exclusive bounds require floor(min) + 1 / ceil(max) - 1.
const minimum = constraint?.minimum === undefined ?
undefined :
bigDecimalCoefficientAtScale(constraint.minimum, scale, !constraint.exclusiveMinimum) +
(constraint.exclusiveMinimum ? globalThis.BigInt(1) : globalThis.BigInt(0))
const maximum = constraint?.maximum === undefined ?
undefined :
bigDecimalCoefficientAtScale(constraint.maximum, scale, !!constraint.exclusiveMaximum) -
(constraint.exclusiveMaximum ? globalThis.BigInt(1) : globalThis.BigInt(0))
if (minimum !== undefined && maximum !== undefined && minimum > maximum) continue
members.push(Struct({ value: bigDecimalCoefficientSchema(minimum, maximum), scale: Literal(scale) }))
if (scale >= 0 && scale <= 6) {
const radius = globalThis.BigInt(1_000_000)
const low = minimum !== undefined && minimum > -radius ? minimum : -radius
const high = maximum !== undefined && maximum < radius ? maximum : radius
if (low <= high) {
ordinary.push(Struct({
value: BigInt.check(isBetweenBigInt({ minimum: low, maximum: high })),
scale: Literal(scale)
}))
}
}
}
return Struct({ value: bigIntArbitrarySchema(minimum, maximum), scale: Literal(scale) })
// Give everyday decimals their own branch so adding extreme scales does not crowd them out.
return ordinary.length > 0 ? Union([Union(ordinary), Union(members)]) : Union(members)
}
/**
* Schema for `BigDecimal` values.
Expand Down Expand Up @@ -10231,11 +10261,24 @@ export const BigDecimal: BigDecimal = declare(
importDeclarations: [`import * as BigDecimal from "effect/BigDecimal"`]
}),
expected: "BigDecimal",
toCodecArbitrary: ({ constraint }) =>
linkDecoding<BigDecimal_.BigDecimal>()(
bigDecimalArbitrarySchema(constraint),
SchemaGetter.transform(({ scale, value }) => BigDecimal_.make(value, scale))
),
toCodecArbitrary: ({ constraint }) => {
// Shrink offsets toward zero so even a coarse scale can reach the exact inclusive boundary.
const origin = constraint?.minimum !== undefined && BigDecimal_.isPositive(constraint.minimum)
? constraint.minimum
: constraint?.maximum !== undefined && BigDecimal_.isNegative(constraint.maximum)
? constraint.maximum
: BigDecimal_.fromBigInt(globalThis.BigInt(0))
return linkDecoding<BigDecimal_.BigDecimal>()(
bigDecimalArbitrarySchema(
constraint === undefined ? undefined : {
...constraint,
minimum: constraint.minimum === undefined ? undefined : BigDecimal_.subtract(constraint.minimum, origin),
maximum: constraint.maximum === undefined ? undefined : BigDecimal_.subtract(constraint.maximum, origin)
}
),
SchemaGetter.transform(({ scale, value }) => BigDecimal_.sum(origin, BigDecimal_.make(value, scale)))
)
},
toCodecJson: () =>
link<BigDecimal_.BigDecimal>()(
BigDecimalString,
Expand Down
171 changes: 171 additions & 0 deletions packages/effect/test/unstable/arbitrary/BigDecimal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { assert, describe, it } from "@effect/vitest"
import * as BigDecimal from "effect/BigDecimal"
import * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"

const decimal = BigDecimal.fromStringUnsafe
const zero = decimal("0")
const one = decimal("1")
const humanMaximum = decimal("1000000")
const safeMaximum = BigDecimal.fromBigInt(BigInt(Number.MAX_SAFE_INTEGER))
const floatMaximum = BigDecimal.fromBigInt(BigInt(Number.MAX_VALUE))
const tiny = BigDecimal.make(1n, 324)

const cases = [
{ name: "unbounded", schema: Schema.BigDecimal },
{ name: "positive", schema: Schema.BigDecimal.check(Schema.isGreaterThanBigDecimal(zero)) },
{ name: "negative", schema: Schema.BigDecimal.check(Schema.isLessThanBigDecimal(zero)) }
]

describe("BigDecimal arbitrary", () => {
for (const { name, schema } of cases) {
for (const size of [0, 1, 10]) {
it.effect(`covers ordinary and extreme ${name} decimals at size ${size}`, () =>
Effect.gen(function*() {
const values = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), {
count: 2_000,
size,
seed: `decimal-diversity:${name}:${size}`,
maxDiscards: 0
})
assert.isTrue(values.every(Schema.is(schema)))
assert.isAtLeast(new Set(values.map(BigDecimal.format)).size, 1_000)
const magnitudes = values.map(BigDecimal.abs)
const human = magnitudes.filter((value) =>
BigDecimal.isGreaterThanOrEqualTo(value, one) && BigDecimal.isLessThanOrEqualTo(value, humanMaximum)
)
assert.isAtLeast(human.length, values.length * 0.1)
assert.isTrue(magnitudes.some((value) => BigDecimal.isGreaterThan(value, safeMaximum)))
assert.isTrue(magnitudes.some((value) => BigDecimal.isGreaterThan(value, floatMaximum)))
assert.isTrue(magnitudes.some((value) => !BigDecimal.isZero(value) && BigDecimal.isLessThan(value, tiny)))
if (name === "unbounded") {
assert.isTrue(values.some(BigDecimal.isZero))
for (const sign of [BigDecimal.isPositive, BigDecimal.isNegative]) {
assert.isAtLeast(values.filter(sign).length, values.length * 0.3)
}
}
}))
}
}

const bounds = [
{ name: "positive fractional minimum", minimum: decimal("1.234"), exclusiveMinimum: true },
{ name: "negative fractional maximum", maximum: decimal("-1.234"), exclusiveMaximum: true },
{ name: "negative minimum", minimum: decimal("-1.234") },
{ name: "positive maximum", maximum: decimal("1.234") },
{ name: "positive narrow interval", minimum: decimal("1.234"), maximum: decimal("1.236") },
{ name: "negative narrow interval", minimum: decimal("-1.236"), maximum: decimal("-1.234") },
{ name: "exclusive coarse endpoints", minimum: zero, maximum: one, exclusiveMinimum: true, exclusiveMaximum: true },
{
name: "exclusive fractional endpoints",
minimum: decimal("1.234"),
maximum: decimal("1.236"),
exclusiveMinimum: true,
exclusiveMaximum: true
},
{
name: "exclusive negative endpoints",
minimum: decimal("-1.236"),
maximum: decimal("-1.234"),
exclusiveMinimum: true,
exclusiveMaximum: true
},
{ name: "different scales", minimum: BigDecimal.make(-12345n, 22), maximum: BigDecimal.make(678901n, 24) },
{ name: "singleton", minimum: decimal("1.234"), maximum: decimal("1.234") },
{
name: "very small interval",
minimum: BigDecimal.make(1n, 1_000),
maximum: BigDecimal.make(2n, 1_000),
exclusiveMinimum: true,
exclusiveMaximum: true
},
{
name: "very large interval",
minimum: BigDecimal.make(1n, -1_000),
maximum: BigDecimal.make(2n, -1_000),
exclusiveMinimum: true,
exclusiveMaximum: true
},
{ name: "very small maximum", maximum: BigDecimal.make(-1n, 1_000), exclusiveMaximum: true },
{ name: "very large minimum", minimum: BigDecimal.make(1n, -1_000), exclusiveMinimum: true }
] satisfies ReadonlyArray<
Schema.Annotations.ToArbitrary.GenerationConstraint<BigDecimal.BigDecimal> & { readonly name: string }
>

for (const bound of bounds) {
it.effect(`constructs and shrinks ${bound.name} without discards`, () =>
Effect.gen(function*() {
const constraint: Schema.Annotations.ToArbitrary.GenerationConstraint<BigDecimal.BigDecimal> = bound
let schema: Schema.Codec<BigDecimal.BigDecimal> = Schema.BigDecimal
if (constraint.minimum !== undefined) {
schema = schema.check(
constraint.exclusiveMinimum
? Schema.isGreaterThanBigDecimal(constraint.minimum)
: Schema.isGreaterThanOrEqualToBigDecimal(constraint.minimum)
)
}
if (constraint.maximum !== undefined) {
schema = schema.check(
constraint.exclusiveMaximum
? Schema.isLessThanBigDecimal(constraint.maximum)
: Schema.isLessThanOrEqualToBigDecimal(constraint.maximum)
)
}
const arbitrary = Arbitrary.schema(schema)
const values = yield* Arbitrary.sampleEffect(arbitrary, {
count: 500,
seed: bound.name,
size: 1,
maxDiscards: 0
})
assert.isTrue(values.every(Schema.is(schema)))
const property = (value: BigDecimal.BigDecimal) => {
assert.isTrue(Schema.is(schema)(value))
return false
}
const result = yield* Arbitrary.checkEffect(arbitrary, property, {
runs: 1,
seed: bound.name,
size: 1,
maxDiscards: 0
})
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.isTrue(Schema.is(schema)(result.shrunkInput))
const replay = yield* Arbitrary.checkEffect(arbitrary, property, { replay: result.replay })
assert.deepStrictEqual(replay, result)
}
}))
}

for (const boundary of [decimal("1.234"), decimal("-1.234")]) {
it.effect(`shrinks coarse scales to the exact inclusive boundary ${BigDecimal.format(boundary)}`, () =>
Effect.gen(function*() {
const schema = Schema.BigDecimal.check(
BigDecimal.isPositive(boundary)
? Schema.isGreaterThanOrEqualToBigDecimal(boundary)
: Schema.isLessThanOrEqualToBigDecimal(boundary)
)
for (const seed of [0, 9, 18, 42]) {
const result = yield* Arbitrary.checkEffect(Arbitrary.schema(schema), () => false, { runs: 1, size: 1, seed })
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.isTrue(BigDecimal.Equivalence(result.shrunkInput, boundary))
}
}
}))
}

it.effect("shrinks unconstrained decimals to zero and replays the failure", () =>
Effect.gen(function*() {
const arbitrary = Arbitrary.schema(Schema.BigDecimal)
const result = yield* Arbitrary.checkEffect(arbitrary, () => false, { runs: 1, size: 1, seed: "decimal-zero" })
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.isTrue(BigDecimal.isZero(result.shrunkInput))
const replay = yield* Arbitrary.checkEffect(arbitrary, () => false, { replay: result.replay })
assert.deepStrictEqual(replay, result)
}
}))
})