Skip to content

Choice-tape shrink engine: Conjecture-style generation recording, shrinking, and persistence - #658

Open
matthiasgoergens wants to merge 23 commits into
proptest-rs:mainfrom
matthiasgoergens:tape-engine-main
Open

Choice-tape shrink engine: Conjecture-style generation recording, shrinking, and persistence#658
matthiasgoergens wants to merge 23 commits into
proptest-rs:mainfrom
matthiasgoergens:tape-engine-main

Conversation

@matthiasgoergens

Copy link
Copy Markdown

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:

  • Floats frequently do not shrink at all. BinarySearch::reposition jumps straight to the low clamp on the first simplify(), and if that passes, complicate() walks back to the original value and the search ends. A property failing at v >= 1.7 over 0.0..10.0 typically reports something like 8.700790202766044 as its "minimal" example. The tape engine reports 2.0, via a port of Hypothesis's lexicographic float encoding that makes round numbers the simplest ones.
  • prop_filter strands the shrinker at locally minimal values, because simplify() 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 at v >= 100 shrinks to exactly 100 instead of stopping at, say, 122.
  • Each ValueTree shrinks in isolation, so nothing can lower two related values together. Issue Proptest fails to find trivial failing example (but Hypothesis does) #500 is the canonical report: a paging bug guarded by total_count % count == 0 was 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 ct1 entry 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 generates i64::MIN; the README example that says abs(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 Duration could panic on nanosecond carry overflow (#657).

How it works

  • TestRng owns a TapeState. The rand 0.10 TryRng impl 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.
  • Migrated strategies (integer and float ranges, int ANY, the class-based float Any, char, collections, union picks, and proptest-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.
  • The shrinker runs passes to a fixpoint under the existing max_shrink_iters/max_shrink_time budgets: 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 >= 10 shrinks to (10, 10)), and per-choice minimization with exponential probing from the target.
  • Everything is behind Config::shrink_engine / PROPTEST_SHRINK_ENGINE. The ValueTree engine remains fully supported, is used automatically under fork/timeout, and is one config line away for anyone who prefers it.

Compatibility and defaults

No API breaks: every existing Strategy/ValueTree implementation compiles and runs unchanged, and a small tape API on TestRunner (draw_bool, spans, forced flags) is available for out-of-crate strategy implementations. The full test suites of proptest and proptest-state-machine pass 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.md in the repo root is the full design writeup, and proptest/examples/shrink-quality.rs runs both engines side by side over a dozen representative properties if you want to see the difference locally.

@matthiasgoergens

matthiasgoergens commented Jul 12, 2026

Copy link
Copy Markdown
Author

Here is a concrete demo of what this engine does to the oldest complaint class against proptest's shrinking: shrinking through prop_flat_map, the monadic bind.

The problem, as reported by users. #181 asks for "alternative approaches to prop_flat_map due to shrinking behavior": matrices generated as (rows, cols).prop_flat_map(|(r, c)| vec(elem, r * c)) shrink their elements but rarely their dimensions. nalgebra's proptest support ships that caveat in its documentation to this day: "shrinking takes place by first shrinking the matrix elements before trying to shrink the dimensions of the matrix", so that "a large number of shrinking iterations are necessary to find a (nearly) minimal failing test case". The book's tutorial example for prop_flat_map, a vector plus a valid index into it, has the same shape.

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 minimize_individual_nodes, lowers a length choice while deleting one element span behind it, which is the edit neither side can make alone.

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.

case engine fully minimal avg test calls worst result
matrix, issue #181: (1..=8, 1..=8) flat_map vec(0..1000, r*c), fail iff any elem >= 10
ideal minimum: (1, 1, [10])
ValueTree 95/100 405 (1, 8, [0, 0, 0, 0, 0, 6, 400, 703])
tape 98/100 69 (2, 3, [0, 0, 0, 0, 0, 10])
book's vec-and-index: (1..=32) flat_map (vec(0..1000, len), 0..len), fail iff v[i] >= 10
ideal minimum: ([10], 0)
ValueTree 100/100 298
tape 100/100 51
length-prefixed sum: (1..=64) flat_map vec(0..1000, len..=len), fail iff sum >= 100
ideal minimum: [100]
ValueTree 89/100 557 [0, 0, 0, 0, 0, 10, 393, 453]
tape 100/100 109
chained binds: a in 10..1000, b in 10..=a, c in 10..=b, fail always
ideal minimum: (10, 10, 10)
ValueTree 100/100 112
tape 100/100 2

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 (400, 703, 453), while the tape's worst case is a correctly minimized value with surplus structure around it.

The chained binds row is the purest illustration: three dependent draws through two binds, and the tape's first shrink attempt (set every choice to its target) lands on (10, 10, 10) immediately, because replaying generation keeps the dependency a >= b >= c intact by construction.

Reproducible demo code (drop into proptest/examples/flat-map-demo.rs on this branch)
use std::cell::Cell;
use std::fmt::Debug;

use proptest::collection::vec;
use proptest::prelude::*;
use proptest::test_runner::{
    Config, RngAlgorithm, ShrinkEngine, TestError, TestRng, TestRunner,
};

fn run_case<S: Strategy>(
    engine: ShrinkEngine,
    strategy: &S,
    fail: &impl Fn(&S::Value) -> bool,
    seed_byte: u8,
) -> (Option<S::Value>, u64)
where
    S::Value: Debug,
{
    let calls = Cell::new(0u64);
    let seed = [seed_byte; 32];
    let mut runner = TestRunner::new_with_rng(
        Config {
            shrink_engine: engine,
            failure_persistence: None,
            ..Config::default()
        },
        TestRng::from_seed(RngAlgorithm::ChaCha, &seed),
    );
    let result = runner.run(strategy, |v| {
        calls.set(calls.get() + 1);
        if fail(&v) {
            Err(TestCaseError::fail("demo failure"))
        } else {
            Ok(())
        }
    });
    match result {
        Err(TestError::Fail(_, value)) => (Some(value), calls.get()),
        _ => (None, calls.get()),
    }
}

fn stats<S: Strategy>(
    name: &str,
    strategy: S,
    fail: impl Fn(&S::Value) -> bool,
    is_minimal: impl Fn(&S::Value) -> bool,
) where
    S::Value: Debug,
{
    println!("{} -- 100 seeds", name);
    for (label, engine) in [
        ("valuetree", ShrinkEngine::ValueTree),
        ("tape", ShrinkEngine::Tape),
    ] {
        let mut found = 0u32;
        let mut minimal = 0u32;
        let mut total_calls = 0u64;
        let mut worst: Option<String> = None;
        for seed_byte in 0..100u8 {
            let (value, calls) = run_case(engine, &strategy, &fail, seed_byte);
            total_calls += calls;
            if let Some(v) = value {
                found += 1;
                if is_minimal(&v) {
                    minimal += 1;
                } else {
                    let s = format!("{:?}", v);
                    if worst.as_ref().map_or(true, |w| s.len() > w.len()) {
                        worst = Some(s);
                    }
                }
            }
        }
        println!(
            "  {:<10} found {:>3}/100, fully minimal {:>3}/100, avg {:>4} test calls, worst: {}",
            label,
            found,
            minimal,
            total_calls / 100,
            worst.unwrap_or_else(|| "-".to_owned())
        );
    }
    println!();
}

fn main() {
    stats(
        "matrix (issue #181)",
        (1usize..=8, 1usize..=8).prop_flat_map(|(r, c)| {
            vec(0i32..1000, r * c).prop_map(move |data| (r, c, data))
        }),
        |(_, _, data)| data.iter().any(|&x| x >= 10),
        |(r, c, data)| *r == 1 && *c == 1 && *data == vec![10],
    );

    stats(
        "vec-and-index (book tutorial)",
        (1usize..=32)
            .prop_flat_map(|len| (vec(0i32..1000, len..=len), 0..len)),
        |(v, i)| v[*i] >= 10,
        |(v, i)| v.len() == 1 && v[0] == 10 && *i == 0,
    );

    stats(
        "length-prefixed sum",
        (1usize..=64).prop_flat_map(|len| vec(0i32..1000, len..=len)),
        |v| v.iter().sum::<i32>() >= 100,
        |v| *v == vec![100],
    );

    stats(
        "chained binds",
        (10i32..1000)
            .prop_flat_map(|a| (Just(a), 10..=a))
            .prop_flat_map(|(a, b)| (Just(a), Just(b), 10..=b)),
        |_| true,
        |t| *t == (10, 10, 10),
    );
}

The coordinated lower-and-delete pass and issue #181's matrix case in the shrink-quality example are the two most recent commits on the branch; there is a regression test asserting that the length-prefixed case shrinks to exactly [100].

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant