Repository navigation
Description
What happens
Version: effect 4.1.2. The same eager body is in 4.1.3 and on main today
(packages/effect/src/internal/arbitrary/model.ts).
In effect 4.1.2, every string that Arbitrary.schema generates from a Schema.String goes through
sampleFromShrink (internal/arbitrary/model.ts, called from the string compiler in
internal/arbitrary/schema.ts). sampleFromShrink calls shrink(value) at once, so shrinkString runs
for every generated string, even when the property passes and the runner never asks for a shrink.
shrinkString makes three prefixes plus one full copy of the string per halving step of every
character, then dedupes them in a Set. For a 4,096-char printable string that is about 23,000
candidates and about 95 million UTF-16 units copied. The cost grows with the square of the string's
length, and all of it is thrown away when the property passes.
Numbers
One property over Arbitrary.array(Arbitrary.schema(Schema.String.check(Schema.isMaxLength(4096))), { maxLength: 8 }),
seed: 119120, runs: 300, size: 4096, a property that returns true, Node 26.13.0:
| stock 4.1.2 | with the patch below | |
|---|---|---|
| whole check | about 5,150 ms | about 45 ms |
| candidate strings built | 6,370,807 | 0 |
String.fromCharCode calls, for 955,431 generated units |
7,321,141 | 953,878 |
A CPU profile of the stock run puts 4.47 s of 5.79 s under generateAttempt > sampleFromShrink, 3.75 s
of that as self time in shrinkString, and 0.98 s more in the garbage collector.
Repro
import { Arbitrary, Effect, Schema } from "effect";
const text = Schema.String.check(Schema.isMaxLength(4096));
const cases = Arbitrary.array(Arbitrary.schema(text), { maxLength: 8 });
const start = Date.now();
await Effect.runPromise(
Arbitrary.checkEffect(cases, () => Effect.succeed(true), { seed: 119120, runs: 300, size: 4096 }),
);
console.log(Date.now() - start, "ms"); // seconds, though nothing ever shrinksOn one laptop this prints 6,238 ms on stock 4.1.2 and 26 ms with the patch below.
What we expected
The candidates are built only when the runner pulls the first shrink, after a failure. Effect already
does this one function below: sampleFromValidatedShrink goes through makeSampleWithLazyShrinks. Only
the plain-string path skips it. fast-check, which the runner's comments cite, also builds shrinks lazily.
The patch below reuses makeSampleWithLazyShrinks. It keeps the candidate list and its order exactly
(same shrink, same pullFromArray), so seeds replay the same cases and shrink to the same answers: we
checked that the generated cases at a fixed seed hash the same before and after, and that shrink paths
take the same number of steps.
A cheaper shrinkString (fewer copies) would help a failing run too, but it would change shrink paths,
so it is left out here.
The patch we apply locally
diff --git a/dist/internal/arbitrary/model.js b/dist/internal/arbitrary/model.js
--- a/dist/internal/arbitrary/model.js
+++ b/dist/internal/arbitrary/model.js
@@ -124,8 +124,10 @@
}
/** @internal */
export function sampleFromShrink(value, shrink) {
- const values = shrink(value);
- return makeSample(value, values.length === 0 ? undefined : Effect.map(pullFromArray(values), value => sampleFromShrink(value, shrink)));
+ return makeSampleWithLazyShrinks(value, () => {
+ const values = shrink(value);
+ return values.length === 0 ? undefined : Effect.map(pullFromArray(values), value => sampleFromShrink(value, shrink));
+ });
}
/** @internal */
export function sampleFromValidatedShrink(value, shrink, validate) {
diff --git a/src/internal/arbitrary/model.ts b/src/internal/arbitrary/model.ts
--- a/src/internal/arbitrary/model.ts
+++ b/src/internal/arbitrary/model.ts
@@ -240,11 +240,10 @@
/** @internal */
export function sampleFromShrink<A>(value: A, shrink: (value: A) => ReadonlyArray<A>): Sample<A> {
- const values = shrink(value)
- return makeSample(
- value,
- values.length === 0 ? undefined : Effect.map(pullFromArray(values), (value) => sampleFromShrink(value, shrink))
- )
+ return makeSampleWithLazyShrinks(value, () => {
+ const values = shrink(value)
+ return values.length === 0 ? undefined : Effect.map(pullFromArray(values), (value) => sampleFromShrink(value, shrink))
+ })
}
/** @internal */
Activity
test: cover Arbitrary string shrinking regressions