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-bigint-arbitrary-diversity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Generate unbounded and one-sided BigInt arbitraries from a mixture of small, ordinary and large magnitude ranges independently of collection size. Include values beyond JavaScript's safe integer and floating-point ranges, exercise explicit boundaries even outside the default ranges, and retain bounded generation. Try smaller-magnitude shrink candidates before halving huge roots so common counterexamples can be reduced within the default shrink budget.
75 changes: 54 additions & 21 deletions packages/effect/src/internal/arbitrary/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,40 @@ function numberSample(
)
}

function bigIntGenerator(
minimum: bigint | undefined,
maximum: bigint | undefined
): (state: Model.GenerationState) => bigint {
if (minimum !== undefined && maximum !== undefined) return Model.makeRandomNumericBigInt(minimum, maximum)
const zero = BigInt(0)
const center = minimum !== undefined && minimum > zero
? minimum
: maximum !== undefined && maximum < zero
? maximum
: zero
// A single wide uniform interval would almost always produce huge values. Mix magnitude ranges instead,
// independently of collection size, and cap default coefficient widths rather than attempting an infinite range.
const radii = [
BigInt(1),
BigInt(100),
BigInt(1_000_000),
...[53, 64, 256, 1024, 2048].map((bits) => (BigInt(1) << BigInt(bits)) - BigInt(1))
]
const generators = radii.map((radius) =>
Model.makeRandomNumericBigInt(
minimum !== undefined && minimum > center - radius ? minimum : center - radius,
maximum !== undefined && maximum < center + radius ? maximum : center + radius
)
)
// A bound on the far side of zero may lie outside every default range. Still exercise that explicit boundary.
if (minimum !== undefined && minimum < zero) {
generators.push(Model.makeRandomNumericBigInt(minimum, minimum + BigInt(100)))
} else if (maximum !== undefined && maximum > zero) {
generators.push(Model.makeRandomNumericBigInt(maximum - BigInt(100), maximum))
}
return (state) => generators[Model.randomIndex(state, generators.length)](state)
}

interface BigIntShrink {
readonly value: bigint
readonly context: bigint | undefined
Expand All @@ -975,14 +1009,28 @@ function shrinkBigInt(current: bigint, target: bigint, tryTargetAsap: boolean):
const out: Array<BigIntShrink> = []
const realGap = current - target
let previous = tryTargetAsap ? undefined : target
for (
let toRemove = tryTargetAsap ? realGap : realGap / BigInt(2);
toRemove !== BigInt(0);
toRemove /= BigInt(2)
) {
let toRemove = tryTargetAsap ? realGap : realGap / BigInt(2)
const magnitude = realGap < BigInt(0) ? -realGap : realGap
if (tryTargetAsap && magnitude > (BigInt(1) << BigInt(64))) {
// Halving a thousand-bit root can exhaust the default shrink budget before reaching ordinary magnitudes.
// Try useful smaller offsets first, retaining the preceding candidate as the passing-value context.
out.push({ value: target, context: previous })
previous = target
const sign = realGap < BigInt(0) ? BigInt(-1) : BigInt(1)
for (const bits of [0, 4, 16, 32, 53, 64, 128, 256, 512, 1024]) {
const offset = BigInt(1) << BigInt(bits)
if (offset >= magnitude / BigInt(2)) break
const value = target + sign * offset
out.push({ value, context: previous })
previous = value
}
toRemove = realGap / BigInt(2)
}
while (toRemove !== BigInt(0)) {
const value = current - toRemove
out.push({ value, context: previous })
previous = value
toRemove /= BigInt(2)
}
return out
}
Expand Down Expand Up @@ -1199,26 +1247,11 @@ export function compile<S extends Schema.Constraint>(schema: S): Model.Compiled<
if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
throw arbitraryError("bigint constraints", path)
}
let previousLow: bigint | undefined
let previousHigh: bigint | undefined
let randomBigInt: ((state: Model.GenerationState) => bigint) | undefined
const randomBigInt = bigIntGenerator(minimum, maximum)
return Model.makeCompiled(
[],
() => 0,
(state) => {
const magnitude = BigInt(Math.max(1, state.size * state.size))
const center = minimum !== undefined && minimum > BigInt(0)
? minimum
: maximum !== undefined && maximum < BigInt(0)
? maximum
: BigInt(0)
const low = minimum ?? center - magnitude
const high = maximum ?? center + magnitude
if (randomBigInt === undefined || low !== previousLow || high !== previousHigh) {
previousLow = low
previousHigh = high
randomBigInt = Model.makeRandomNumericBigInt(low, high)
}
const value = randomBigInt(state)
return state.shrinks
? bigIntSample(value, minimum, maximum)
Expand Down
156 changes: 156 additions & 0 deletions packages/effect/test/unstable/arbitrary/BigInt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { assert, describe, it } from "@effect/vitest"
import * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"

const safeMaximum = BigInt(Number.MAX_SAFE_INTEGER)
const floatMaximum = BigInt(Number.MAX_VALUE)
const abs = (value: bigint) => value < 0n ? -value : value

const cases = [
{ name: "unbounded", schema: Schema.BigInt },
{ name: "positive", schema: Schema.BigInt.check(Schema.isGreaterThanBigInt(0n)) },
{ name: "negative", schema: Schema.BigInt.check(Schema.isLessThanBigInt(0n)) }
]

describe("BigInt arbitrary", () => {
for (const { name, schema } of cases) {
for (const size of [0, 1, 10]) {
it.effect(`covers ordinary and extreme ${name} integers at size ${size}`, () =>
Effect.gen(function*() {
const values = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), {
count: 2_000,
size,
seed: `bigint-diversity:${name}:${size}`,
maxDiscards: 0
})
assert.isTrue(values.every(Schema.is(schema)))
assert.isAtLeast(new Set(values).size, 1_000)
const magnitudes = values.map(abs)
assert.isAtLeast(magnitudes.filter((value) => value >= 1n && value <= 1_000_000n).length, values.length * 0.2)
assert.isTrue(magnitudes.some((value) => value > 1_000_000n && value < safeMaximum))
assert.isTrue(magnitudes.some((value) => value > safeMaximum))
assert.isTrue(magnitudes.some((value) => value > floatMaximum))
if (name === "unbounded") {
for (const value of [-1n, 0n, 1n]) assert.include(values, value)
assert.isAtLeast(values.filter((value) => value > 0n).length, values.length * 0.3)
assert.isAtLeast(values.filter((value) => value < 0n).length, values.length * 0.3)
}
}))
}
}

const distant = 10n ** 1_000n
const bounds = [
{ name: "positive minimum", minimum: 123n },
{ name: "negative maximum", maximum: -123n },
{ name: "negative minimum", minimum: -123n },
{ name: "positive maximum", maximum: 123n },
{ name: "distant positive minimum", minimum: distant },
{ name: "distant negative maximum", maximum: -distant },
{ name: "distant negative minimum", minimum: -distant },
{ name: "distant positive maximum", maximum: distant }
] satisfies ReadonlyArray<{ readonly name: string; readonly minimum?: bigint; readonly maximum?: bigint }>

for (const bound of bounds) {
for (const exclusive of [false, true]) {
it.effect(`constructs and shrinks ${exclusive ? "exclusive" : "inclusive"} ${bound.name}`, () =>
Effect.gen(function*() {
const schema = Schema.BigInt.check(
"minimum" in bound
? exclusive
? Schema.isGreaterThanBigInt(bound.minimum)
: Schema.isGreaterThanOrEqualToBigInt(bound.minimum)
: exclusive
? Schema.isLessThanBigInt(bound.maximum)
: Schema.isLessThanOrEqualToBigInt(bound.maximum)
)
const arbitrary = Arbitrary.schema(schema)
const values = yield* Arbitrary.sampleEffect(arbitrary, {
count: 2_000,
seed: bound.name,
size: 0,
maxDiscards: 0
})
assert.isTrue(values.every(Schema.is(schema)))
const boundary = "minimum" in bound ?
bound.minimum + (exclusive ? 1n : 0n)
: bound.maximum - (exclusive ? 1n : 0n)
assert.include(values, boundary)
const target = "minimum" in bound && boundary > 0n ?
boundary
: "maximum" in bound && boundary < 0n
? boundary
: 0n
const property = (value: bigint) => {
assert.isTrue(Schema.is(schema)(value))
return false
}
const result = yield* Arbitrary.checkEffect(arbitrary, property, {
runs: 1,
seed: bound.name,
size: 0,
maxDiscards: 0
})
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.strictEqual(result.shrunkInput, target)
const replay = yield* Arbitrary.checkEffect(arbitrary, property, { replay: result.replay })
assert.deepStrictEqual(replay, result)
}
}))
}
}

it.effect("shrinks large gaps relative to nonzero bounds within the default budget", () =>
Effect.gen(function*() {
for (const sign of [-1n, 1n]) {
const origin = sign * distant
const end = origin + sign * (1n << 2048n)
const schema = Schema.BigInt.check(Schema.isBetweenBigInt({
minimum: sign > 0n ? origin : end,
maximum: sign > 0n ? end : origin
}))
const arbitrary = Arbitrary.schema(schema)
const property = (value: bigint) => {
assert.isTrue(Schema.is(schema)(value))
return abs(value - origin) <= 1_000_003n
}
const result = yield* Arbitrary.checkEffect(arbitrary, property, {
runs: 100,
size: 0,
seed: `bigint-offset:${sign}`
})
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.isTrue(abs(result.initialInput - origin) > (1n << 64n))
assert.strictEqual(result.shrunkInput, origin + sign * 1_000_004n)
const replay = yield* Arbitrary.checkEffect(arbitrary, property, { replay: result.replay })
assert.deepStrictEqual(replay, { ...result, runs: 1 })
}
}
}))

it.effect("shrinks large integers to a failure boundary and replays it", () =>
Effect.gen(function*() {
const arbitrary = Arbitrary.schema(Schema.BigInt)
for (const sign of [-1n, 1n]) {
const limit = sign * safeMaximum
const property = (value: bigint) => sign > 0n ? value <= limit : value >= limit
const result = yield* Arbitrary.checkEffect(arbitrary, property, {
runs: 100,
size: 1,
seed: `bigint-failure-boundary:${sign}`
})
assert.strictEqual(result._tag, "Falsified")
if (result._tag === "Falsified") {
assert.strictEqual(result.shrunkInput, limit + sign)
const replay = yield* Arbitrary.checkEffect(arbitrary, property, {
replay: result.replay
})
// Replay runs only the failing case, not the preceding successful cases.
assert.deepStrictEqual(replay, { ...result, runs: 1 })
}
}
}))
})