Skip to content

Arbitrary: a Schema string builds every shrink candidate while generating (sampleFromShrink is eager) #10539

Open

Description

@usirin

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 shrinks

On 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 */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions