Choice-tape shrink engine: Conjecture-style generation recording, shrinking, and persistence - #658
Choice-tape shrink engine: Conjecture-style generation recording, shrinking, and persistence#658matthiasgoergens wants to merge 23 commits into
Conversation
ef35098 to
72c6898
Compare
|
Here is a concrete demo of what this engine does to the oldest complaint class against proptest's shrinking: shrinking through The problem, as reported by users. #181 asks for "alternative approaches to Why binds are hard for ValueTree. A ValueTree shrinks the finished value, and a bind splits the value across two trees: the outer one (the dimensions) and an inner one built from its current value (the elements). Every outer shrink invalidates the inner tree's progress, so the search keeps restarting, and under the default iteration budget it often gives up with the outer value untouched. The workarounds discussed in #181 (custom ValueTree implementations, matrix-specific combinators) stay inside that paradigm. Hypothesis solved this class of problem at the engine level instead: shrink the recorded choices and replay generation, so the dependent structure regenerates consistently on every attempt. That is what this PR ports. One coordinated pass, taken from Hypothesis's Measured. 100 fixed seeds per case, both engines from this branch, default config. "Fully minimal" counts runs that reached the ideal minimum stated with each case; "worst result" is that engine's furthest miss.
Two things stand out beyond the minimality counts. The tape gets there in a fraction of the test executions (4x to 56x fewer), which matters for expensive properties. And when either engine falls short of the global minimum, the failure modes differ in kind: ValueTree's non-minimal results carry leftover random values ( The Reproducible demo code (drop into
|
design/choice-tape-shrinking.md describes the architecture: record generation as a tape of typed choices (Conjecture-style), shrink by editing the tape and replaying generation, with an RngCore compat wrapper so unmigrated strategies participate without code changes.
Opt-in via Config::shrink_engine = ShrinkEngine::Tape (or PROPTEST_SHRINK_ENGINE=tape). Generation records every random decision on a typed choice tape: integer and float range strategies (and int ANY) record typed choices via new pub(crate) draws on TestRunner; everything else is recorded transparently at the RngCore level by TestRng, so existing strategies participate with zero code changes. Shrinking rewinds the RNG to the failing case's state, replays an edited tape through Strategy::new_tree, and accepts the edit iff the test still fails and the re-recorded tape is shortlex-smaller. Phase 1 passes: all-choices-to-target, then per-choice minimization (integer bisection toward the in-range value nearest zero; floats via a port of Hypothesis's lexicographic float encoding, so minimal floats come out round, e.g. 2.0 instead of 1.7000000000000002; raw choices lex-lower). This also fixes shrink stalls on prop_filter (proposals are re-vetted by the filter during replay) and shrinks naturally through prop_flat_map (inner choices are replayed clamped into the new constraints). Off-tape behavior is unchanged: with the engine off, the new draw methods sample through the same rand::Uniform paths with identical distribution and RNG consumption, and the RngCore wrapper is a single branch. The engine does not yet support fork/timeout (falls back to ValueTree) and collections do not yet shrink by element deletion (phase 2). See design/choice-tape-shrinking.md.
…union picks Spans mark deletable logical units on the choice tape (a stack of start/end index pairs; metadata only — replay ignores them and re-records fresh ones on its output). A new delete_spans shrink pass runs at the start of every shrink round. Under the tape engine, vec generation switches to Hypothesis's 'many' protocol: one continuation Bool per element beyond the minimum, each element spanned together with its flag. Deleting a span replays as the same vec one element shorter, so all collection strategies built on vec shrink by element deletion again; flags shrink to false, truncating the tail. The length distribution in tape mode becomes truncated-geometric with the same mean as before (approved distribution change). The tape-off path is unchanged. Union/TupleUnion branch selection (pick_weighted) becomes a typed integer choice: pinned during replay, shrinks toward the first alternative, and covers prop_oneof and the float Any class pick. Off-tape the distribution is identical (Uniform::new_inclusive(0, sum-1) == Uniform::new(0, sum)). New end-to-end tests: vec(0..100, 0..10) failing at len >= 3 shrinks to exactly [0, 0, 0]; element minimization to [0, 0, 7]; prop_oneof shrinks to its first branch. Full suite at parity with master (same 10 pre-existing string::test failures).
After a successful single-span deletion, try deleting geometrically growing blocks of adjacent spans (2, 4, 8, ...) at the same position while attempts keep being accepted. A block proposal cuts the choice range covering the selected spans, so nested spans simply widen the cut. Long runs of deletable collection elements now go in O(log n) attempts instead of one by one. Adds an end-to-end test shrinking a ~50-element vec to exactly [0, 0, 0, 0, 0].
…nforming The class-based float Any strategies now record their value as one typed Float choice (the class pick was already a typed integer via pick_weighted), so any::<f64>() gets round-value float shrinking: a value failing at v >= 1.7 shrinks to exactly 2.0. Class restrictions are enforced through a new conform hook on the draw (draw_f64_in_with): replayed shrink proposals are mapped back into the strategy's allowed class set — keep the value if its class and sign are allowed, else try the sign flip, else fall back to the simplest allowed value (zero > 1.0 > smallest subnormal > infinity > NaN, sign-adjusted). The conformed value is what gets re-recorded, so accepted tapes stay self-consistent and class-restricted strategies never report out-of-support minimal examples (covered by a POSITIVE | SUBNORMAL test that shrinks to the smallest positive subnormal, not 0.0). The class-allowed check is extracted from BinarySearch::current_allowed into a free function shared with the conform hook. Off-tape behavior is unchanged (the sample closure runs the identical bit-masking procedure).
CharStrategy now records the selected code point as one typed Integer
choice under the tape engine, via a new draw_integer_in_with that
generalizes draw_integer_in with a per-value shrink target and a conform
hook (mirroring draw_f64_in_with for floats).
The shrink target is the strategy's existing convenient bottom — the
hard-wired ladder of '¡', 'a', 'A', '0', ' ', or the containing range's
base — extracted into shrink_bottom() and shared with the ValueTree path.
conform_to_ranges() maps shrink proposals to the nearest value in the
union of ranges, stepping off the surrogate hole, so class-restricted
char strategies never report out-of-support minimal examples.
The tape-off path is the original selection code verbatim. New tests:
char::range('a', 'z') failing at c >= 'd' shrinks to exactly 'd';
char::any() minimal examples land on a convenient bottom.
…arness Dogfooding found that maximum-length vecs never draw a natural 'stop' continuation flag (the generation loop exits on the length bound), so their tapes were one Bool shorter than shorter vecs' tapes and every span-deletion edit overran replay and was silently rejected — a vec failing on sum >= 50 shrank to [0, 0, ..., 24, 26] instead of [27, 23]. Fix: record a structurally-forced Bool(false) after a max-length loop (TapeState::record_forced_bool). Forced draws consume a matching input choice during replay so edits stay aligned, but never flag overrun and draw no entropy. A regression test sweeps 20 seeds so some initial failing vecs are at maximum length. Also adds examples/shrink-quality.rs, a side-by-side comparison of both engines on twelve representative properties (same seed, counting test invocations), and records the results in the status doc. Highlights: the ValueTree engine frequently fails to shrink floats at all (its BinarySearch jumps to 0 and gives up if that passes), where the tape engine reaches 2.0; the remaining tape gap ([27, 23] vs [50] on the vec-sum case) is the planned phase-4 redistribute pass.
Two cross-value shrink passes, run each round between deletion and per-choice minimization (both Integer-only for now): redistribute_pairs moves distance-toward-target from an integer choice to the next integer choice after it, preserving their sum (clamped to the receiver's constraints, halving the transfer on rejection). Shortlex prefers earlier choices being smaller, so a vec failing on sum >= 50 now goes [27, 23] -> [0, 50] -> (deletion) -> [50], matching the ValueTree engine on the one dogfood case where it was still ahead. Offset space preserves differences for all signedness, so transfers are exact for every integer type. minimize_duplicates groups integer choices with identical value and shrink target and bisects them toward the target together, catching equality-conditioned failures that no single-choice edit can preserve: (a, b) failing on a == b && a >= 10 shrinks to exactly (10, 10). New tests cover both passes plus constraint-clamped redistribution ((0..60, 0..60) failing on a + b >= 100 shrinks to (41, 59)). Full suite at parity under both engines (1487 passed / same 10 pre-existing failures).
When the tape engine shrinks a failure, the winning tape itself is persisted instead of the RNG seed, as 'ct1 <base16>' alongside the existing seed forms. Replaying a ct1 entry regenerates the exact shrunken values — independent of the RNG algorithm, rand version, and (mostly) strategy refactors — where a seed entry can only regenerate the original unshrunk case and re-shrink it from scratch. PersistedSeed now wraps a PersistedFailure enum (Seed | Tape). Tapes are stored serialized (a strict, tagged little-endian encoding of the choice list; spans are shrinking metadata and are not persisted) so the type stays Ord/Eq despite Choice containing f64. Loading dispatches on the form: tapes replay through the tape machinery regardless of the active shrink engine, tolerate staleness (misaligned draws sample fresh, dead tapes are skipped with a warning), and re-shrink on failure. Verified end-to-end: a predicate that fails only at exactly 2.0 — which random generation essentially cannot hit — is found by a fresh runner with a different RNG purely through the persisted tape.
Ports Hypothesis's generation philosophy into the typed draws, changing default distributions in BOTH engines (as decided 2026-07-07): Integers (ranges wider than 24 bits; numeric strategies and int ANY, but NOT union picks or sample::Index): 1/16 of draws take a boundary-ish value (min, min+1, max, max-1, target, target+1), 2/16 stay uniform over the full range, and 13/16 land within a weighted random bit-size (favoring 8/16 bits) of the shrink target — so small values and exact bounds both appear constantly while the full range stays reachable. any::<i64>() now finds i64::MIN within a default run: the README's own 'virtually always passes' abs(i64::MIN) example finally fails. Floats: with p = 1/20 a draw takes a boundary or special candidate (±0, ±1, 0.5, 1.5, the bounds, one ulp inside the bounds, NaN where allowed), conformed to the strategy's class set and range-filtered. The new distribution immediately found real bugs: - Duration's Arbitrary panicked on Duration::new(u64::MAX, b) with b >= 1e9 (nanos carry overflows the seconds counter) — previously unreachable at p ~= 2^-62, now generated routinely. Fixed, and the regression seeds it produced are checked in. - sample::Index must stay uniform: it is positional (a fraction of a collection), so magnitude-biasing skewed every collection index toward the first elements. Index now draws through a new crate-internal ANY_UNIFORM strategy. - float class conformance was sign-sloppy for NaNs: float_class_allowed now checks the sign bit for NaNs too, and conform_to_types produces sign-correct quiet/signaling NaN fallbacks. - test_shrink_bail relied on u64::ANY failing v > u32::MAX almost surely; under the bias only ~23% of cases fail and its 250ms sleeps blew rusty-fork's 4s timeout. It now uses an always-failing range. Also: integer and float minimization now probe exponentially outward from the shrink target before bisecting (Hypothesis's find_integer), costing O(log boundary-distance) instead of O(log range) — f64 ANY shrinks from ~1e170 in 11 attempts instead of 634, which was one bad draw from exhausting the default 1024-attempt budget.
ShrinkEngine::default() is now Tape. The classic shrinker remains available as ShrinkEngine::ValueTree / PROPTEST_SHRINK_ENGINE=valuetree, and fork/timeout configurations still fall back to it automatically. Redetermine proptest-state-machine's golden SIMPLIFICATIONS constant (699 -> 361): it counts exact simplify() steps of a deterministically generated value tree, and the phase-6 edge-case-biased generation changed what the deterministic RNG produces. The crate's property-based simplify/complicate contract test passes unchanged under both engines. Adds a CHANGELOG entry covering the engine flip, ct1 tape persistence, and the generation distribution changes. Verified: proptest suite at parity under both engines (1493 passed, the 10 known pre-existing string::test failures); proptest-state-machine green under both engines. proptest-derive's test suite requires nightly and cannot run on this toolchain (pre-existing).
…test-rs#500 The 1/16 boundary-value roll previously only applied to ranges wider than 24 bits, so upstream issue proptest-rs#500's divisibility bug (a paging computation wrong exactly when total_count % count == 0, over ranges 0..1_000_000 and 1..100_000) stayed effectively unfindable: uniform sampling produces a multiple with probability around 1e-4 per case, and both ranges are narrower than the gate. Hypothesis finds it through its constants pool, which injects 0 and 1 regardless of range width. The boundary roll now applies to every range width (with saturating arithmetic so degenerate ranges stay in bounds); the size-biased magnitude scheme remains gated to wide ranges, and narrow ranges are otherwise uniform. Boundary injection produces total_count == 0 (a multiple of everything) and count == 1 (divides everything) constantly: across 100 seeds the issue-500 property now fails every time and shrinks to the minimal witness (0, 1) every time. Added as a regression test.
Finding the divisibility bug is engine-independent (it comes from the boundary-injecting generator), but the exact minimal witness (0, 1) is a tape-engine property: the ValueTree shrinker cannot renavigate the sparse set of multiples after the other tuple component has shrunk, and stalls at values like (477872, 1) depending on the starting case. Pin the engine so the test also passes under PROPTEST_SHRINK_ENGINE=valuetree.
Under the tape engine, sequential state-machine generation now encodes its transition count as continuation flags with each transition (flag, precondition retries and all) wrapped in a deletable span, mirroring the collection strategies. Preconditions are re-checked against the re-evolved reference state during replay, so deleting a transition replays as the same sequence one transition shorter with downstream transitions clamped into their new constraints. Two semantics learned from the hand-written shrinker and matched here: - It deletes transitions below the declared minimum length (the size range bounds generation, not shrinking). A new primitive supports that: draw_bool_forced records a generation-forced flag that draws no entropy but honors the replayed value, so the tape shrinker can truncate below the minimum while generation always reaches it. - Its seen-transitions counter (the delete-unseen optimization) asserts it is zero on current(), which the tape engine re-calls after the test has consumed transitions; the counter is disabled on the tape path, where span deletion subsumes the optimization. The tape-off path is unchanged. With this, the upstream exactly-minimal shrinking test passes under the tape engine with a default config (one transition, minimal limit), and the earlier ValueTree pin is removed; all state-machine tests pass under both engines. The span/flag tape API (tape_is_on, start_span, end_span, draw_bool, draw_bool_forced, record_forced_bool) becomes public on TestRunner for out-of-crate strategy implementations like this one.
The referenced status/handoff document was development scaffolding for this work and is not part of the change.
Behavioral fixes, each with a regression test where practical: - bool::ANY and bool::weighted record a typed Bool choice, so the tape engine shrinks booleans to false. The raw fallback inverted the shrink direction (rand's Bernoulli maps a zeroed u64 to true). - Class-restricted float strategies conform tape proposals by the NaN signaling bit: a SIGNALING_NAN-only strategy no longer emits quiet NaNs via weird-value injection. The ValueTree binary search keeps treating the NaN classes as one, since its arithmetic quiets signaling NaNs on most hardware. - ct1 persistence entries carry the run's seed when known, and the legacy save_persisted_failure hook still receives it, so custom FailurePersistence implementations keep working after the engine flip. - Shrink attempts save and restore local_rejects, so filter-heavy properties no longer exhaust the run-wide reject budget mid-shrink and silently stall. - Persisted seed (xs/cc) entries replay through classic generation again instead of the tape path, which consumed randomness differently and regenerated unrelated values. - Corrupt or dead ct1 entries abort the run with instructions, matching dead-seed behavior, instead of silently passing. Cleanups: collections and state-machine sequences share one draw_element_flag helper (removing a drifted stop-marker guard); the base16 codec is hoisted to rng.rs and shared; local ULP helpers are replaced by std's next_up/next_down; tape complexity comparison is allocation-free and redistribute proposals clone once. Docs: int ANY doc comment and CHANGELOG now describe the biased generation distributions (engine-independent) and the tape engine's collection length distribution change.
When a collection's size comes through prop_flat_map, the length is an explicit choice recorded before the elements, and no single edit can shrink the collection: deleting an element span desynchronizes replay (the length still demands the old count), and lowering the length truncates elements off the end, losing the ones the failure needs. Issue proptest-rs#181 reports this for matrices (dimensions via flat_map), and nalgebra's proptest module documents it as a known limitation. The new pass proposes coordinated edits: lower an integer choice by one while deleting one span after it. This is a port of the corresponding special case in Hypothesis's minimize_individual_nodes. Measured on (1..=64).prop_flat_map(|len| vec(0..1000, len..=len)) with the failure "sum >= 100", over 100 seeds: fully minimal ([100]) went from 68/100 to 100/100, at an average 109 test calls per run. The shrink-quality harness gains issue proptest-rs#181's matrix case.
The test asserted that a randomly generated i32 never equals 420. Under the old uniform distribution that held with probability 1 - 2^-32 per case; under edge-biased generation, small-magnitude values are common enough that the suite flaked roughly once per several full runs. The test only exercises closure capture, so assert something that holds for every generated value instead.
- Dead regression entries no longer reduce a property's coverage to
zero: corrupt ct1 lines and tapes that no longer generate are
collected while every other entry and all fresh cases run, then the
run aborts with the complete list. One bit-rotted line previously
aborted before anything else executed.
- Legacy-only FailurePersistence implementations get a loud stderr
diagnostic instead of silently persisting nothing: the deprecated
hook is XorShift-only by signature ([u8; 16]), and default
configuration produces ChaCha seeds or seedless tapes, so under
defaults such implementations never received anything.
- draw_element_flag's bare bool becomes pub enum ElementMinimum
{ Hard, Soft }, with the asymmetric stop-marker conditions (max >
min vs max > 0) documented in a table on the method: the flag chose
between materially different semantics with no call-site signal.
- tape_lower_and_delete walks spans in ascending order and greedily
repeats at the accepted position with the refreshed value instead of
restarting the whole scan on every acceptance, removing the
O(choices^3) worst case (the same optimization the sibling OCaml
engine measured at 26x fewer attempts).
- The persisted-seed replay tail is extracted into run_classic_case;
gen_and_run_case keeps its own deliberately different success
accounting with a cross-reference note, so the counting contract has
one owner per variant.
Both engines pass the full suites.
The test hardcoded 0x0008_0000_0000_0000; it now computes the mask exactly as float_class_allowed does, so a layout-constant change cannot silently decouple the test from the code path it pins.
Newer std adds an unstable inherent f32/f64::SIGN_MASK, which collides with FloatLayout::SIGN_MASK. Under this crate's #![forbid(future_ incompatible)] the resulting unstable_name_collisions lint is a hard error, so CI on rolling stable went red. Fully-qualify the one bare access (num.rs:943) to match lines 755/762.
…onsts
core::f16::NAN and core::f16::INFINITY do not exist (only f32/f64 have
the deprecated module-level constants); the new float types expose NAN
and INFINITY only as associated consts. Newer nightly makes the missing
module value a hard error in the unstable/f16 path. Switch the five
::core::$typ::{NAN,INFINITY} uses to $typ::{NAN,INFINITY}, which is
valid for every float width and stable since 1.43.
d699c2b to
0fe5084
Compare
This PR ports the core idea of Python Hypothesis's Conjecture engine to proptest: record every random decision made during generation as a tape of typed choices, and implement shrinking as edits to that tape followed by re-running generation, accepting an edit iff the test still fails and the re-recorded tape is shorter or simpler. I mentioned this work in #657; this is the substantive part.
Why
The ValueTree shrinker has structural limits that show up in practice:
BinarySearch::repositionjumps straight to the low clamp on the firstsimplify(), and if that passes,complicate()walks back to the original value and the search ends. A property failing atv >= 1.7over0.0..10.0typically reports something like8.700790202766044as its "minimal" example. The tape engine reports2.0, via a port of Hypothesis's lexicographic float encoding that makes round numbers the simplest ones.prop_filterstrands the shrinker at locally minimal values, becausesimplify()cannot step over rejected values (the code even relaxes the simplify/complicate contract to cope). The tape engine re-vets every proposal through the filter during replay, so a filtered even-integer property failing atv >= 100shrinks to exactly100instead of stopping at, say,122.total_count % count == 0was near-impossible to find and, when found by hardcoding, would not shrink. With this PR the divisibility witness is generated reliably and shrinks to exactly(0, 1)(measured over 100 seeds: 100 found, 100 fully minimal). There is a regression test.The engine also changes what failures persist as: a
ct1entry in the regression file is the serialized choice tape of the shrunken failure, so replaying it regenerates the exact minimal values, independent of RNG algorithm or rand version, and tolerant of strategy refactors. Seed entries continue to work.Finally, generation itself now hunts edge cases the way Hypothesis does: integer draws occasionally take exact boundary values (this is what finds #500), wide ranges mostly produce values near the shrink target with a uniform tail, and float draws occasionally inject bounds, one-ulp-inside-bounds values, and NaN where permitted.
any::<i64>()now actually generatesi64::MIN; the README example that saysabs(i64::MIN)will "virtually always" survive testing now has a test asserting the opposite. This distribution change immediately found a real bug in this repository:Arbitrary for Durationcould panic on nanosecond carry overflow (#657).How it works
TestRngowns aTapeState. The rand 0.10TryRngimpl is the interception seam: every raw draw any strategy makes is recorded as an untyped choice, so existing and third-party strategies participate with zero code changes, and replay pins their values positionally.ANY, the class-based floatAny,char, collections, union picks, andproptest-state-machine's sequential strategy) record typed choices with constraints and shrink targets, which is where the quality comes from. Collections and state-machine sequences use a continuation-flag encoding with spans, so a generic deletion pass removes elements or transitions; state-machine preconditions are re-checked against the re-evolved state during replay.max_shrink_iters/max_shrink_timebudgets: all-choices-to-target, span deletion with exponential batching, cross-value redistribution ([27, 23]becomes[0, 50]and then[50]for a sum-guarded failure), joint lowering of duplicated values (a == b && a >= 10shrinks to(10, 10)), and per-choice minimization with exponential probing from the target.Config::shrink_engine/PROPTEST_SHRINK_ENGINE. The ValueTree engine remains fully supported, is used automatically underfork/timeout, and is one config line away for anyone who prefers it.Compatibility and defaults
No API breaks: every existing
Strategy/ValueTreeimplementation compiles and runs unchanged, and a small tape API onTestRunner(draw_bool, spans, forced flags) is available for out-of-crate strategy implementations. The full test suites ofproptestandproptest-state-machinepass under both engines.Two deliberate behavior changes, called out in the CHANGELOG: the tape engine is the default shrinker, and the numeric generation distributions changed as described above. I believe these defaults are the right ones (they are what make the engine find and report the bugs above), but I am happy to make either or both opt-in if you prefer; each is a one-line change, and the rest of the PR stands either way.
Reading guide
The commits build the engine in review-sized steps: design document, core engine, collections and unions, float and char typing, cross-value passes, tape persistence, biased generation, default flip, and the state-machine migration.
design/choice-tape-shrinking.mdin the repo root is the full design writeup, andproptest/examples/shrink-quality.rsruns both engines side by side over a dozen representative properties if you want to see the difference locally.