From 4208bd84ac0912d317df64406ff8b24c56228a8d Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Thu, 23 Apr 2026 21:11:36 +0000 Subject: [PATCH 1/7] Add value-based widening of one_of alternatives in the shrinker Annotate spans that produced a primitive value with that value, and use those annotations in a new shrink pass that tries to widen a non-zero one_of selector down to zero by replacing the selected span's choices with a single forced choice holding the annotated value. To make this work for ``just`` and ``sampled_from``, add a ``maybe_add_choice_node_for`` method on ``ConjectureData`` that records a forced marker choice when the generated value is one of the primitive choice types, so these strategies' spans are no longer empty and the widening pass has something to replace. Co-Authored-By: Claude Opus 4.7 (1M context) --- hypothesis-python/RELEASE.rst | 7 + .../hypothesis/internal/conjecture/data.py | 93 +++++-- .../internal/conjecture/shrinker.py | 101 ++++++- .../hypothesis/strategies/_internal/misc.py | 9 +- .../strategies/_internal/strategies.py | 6 + .../tests/conjecture/test_test_data.py | 63 ++++- .../tests/cover/test_sampled_from.py | 2 +- .../tests/cover/test_searchstrategy.py | 6 +- .../tests/quality/test_shrink_quality.py | 1 - .../tests/quality/test_widening_shrinks.py | 248 ++++++++++++++++++ 10 files changed, 502 insertions(+), 34 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst create mode 100644 hypothesis-python/tests/quality/test_widening_shrinks.py diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..421eeed70e --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,7 @@ +RELEASE_TYPE: patch + +This release improves shrinking for a very specific category of generator: +If you have a primitive strategy such as :func:`~hypothesis.strategies.text()` +and write `my_primitive_strategy | some_more_complicated_strategy`, values +produced by the second strategy can now be shrunk as if they had come +from the first strategy. diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/data.py b/hypothesis-python/src/hypothesis/internal/conjecture/data.py index ff2d8d521c..d32ff46e86 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/data.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/data.py @@ -138,6 +138,13 @@ def structural_coverage(label: int) -> StructuralCoverageTag: # performance. We lose scan resistance, but that's probably fine here. POOLED_CONSTRAINTS_CACHE: LRUCache[tuple[Any, ...], ChoiceConstraintsT] = LRUCache(4096) +# The Python types corresponding to our choice-node primitive types. A strategy +# whose generated value has one of these exact types gets its span annotated +# with that value. We use a tuple rather than a set because ``in`` on a tuple +# relies only on equality, so it still works if the generated value's class has +# an unhashable metaclass. +_PRIMITIVE_CHOICE_TYPES: tuple[type, ...] = (int, bool, str, bytes, float) + class Span: """A span tracks the hierarchical structure of choices within a single test run. @@ -239,6 +246,14 @@ def children(self) -> "list[Span]": order.""" return [self.owner[i] for i in self.owner.children[self.index]] + @property + def annotation(self) -> Any: + """The value annotating this span, or ``None`` if there is none. + Spans produced by a strategy generating a primitive value (one of the + types we have a choice node for - ``int``, ``bool``, ``str``, ``bytes``, + or ``float``) are annotated with the generated value.""" + return self.owner.annotations.get(self.index) + class SpanProperty: """There are many properties of spans that we calculate by @@ -321,6 +336,9 @@ def __init__(self) -> None: self.__index_of_labels: dict[int, int] | None = {} self.trail = IntList() self.nodes: list[ChoiceNode] = [] + self.annotations: dict[int, Any] = {} + self.__open_spans: list[int] = [] + self.__span_count = 0 def freeze(self) -> None: self.__index_of_labels = None @@ -336,12 +354,21 @@ def start_span(self, label: int) -> None: i = self.__index_of_labels.setdefault(label, len(self.labels)) self.labels.append(label) self.trail.append(TrailType.CHOICE + 1 + i) + self.__open_spans.append(self.__span_count) + self.__span_count += 1 def stop_span(self, *, discard: bool) -> None: if discard: self.trail.append(TrailType.STOP_SPAN_DISCARD) else: self.trail.append(TrailType.STOP_SPAN_NO_DISCARD) + self.__open_spans.pop() + + def annotate_current_span(self, value: Any) -> None: + # Attach a value to the most recently started span. Called by + # ConjectureData.draw to record the value generated by a strategy. + assert self.__open_spans, "Cannot annotate without an open span" + self.annotations[self.__open_spans[-1]] = value class _starts_and_ends(SpanProperty): @@ -442,6 +469,7 @@ class Spans: def __init__(self, record: SpanRecord) -> None: self.trail = record.trail self.labels = record.labels + self.annotations: dict[int, Any] = record.annotations self.__length = self.trail.count( TrailType.STOP_SPAN_DISCARD ) + record.trail.count(TrailType.STOP_SPAN_NO_DISCARD) @@ -997,6 +1025,26 @@ def draw_boolean( constraints: BooleanConstraints = self._pooled_constraints("boolean", {"p": p}) return self._draw("boolean", constraints, observe=observe, forced=forced) + def maybe_add_choice_node_for(self, value: Any) -> None: + """Record ``value`` in the choice sequence if it is a primitive. + + Strategies like :func:`just` and :func:`sampled_from` produce a value + without consulting the choice sequence. For primitive values, this + method places a forced choice of the corresponding type so that the + value is visible to span-level machinery (annotations, shrinking + widenings). For non-primitive values it is a no-op. + """ + if type(value) is bool: + self.draw_boolean(forced=value) + elif type(value) is int: + self.draw_integer(forced=value) + elif type(value) is float: + self.draw_float(forced=value) + elif type(value) is str: + self.draw_string(IntervalSet.from_string(value), forced=value) + elif type(value) is bytes: + self.draw_bytes(max_size=len(value), forced=value) + @overload def _pooled_constraints( self, choice_type: Literal["integer"], constraints: IntegerConstraints @@ -1202,26 +1250,33 @@ def draw( self.start_span(label=label) try: if not at_top_level: - return unwrapped.do_draw(self) - assert start_time is not None - key = observe_as or f"generate:unlabeled_{len(self.draw_times)}" - try: + v = unwrapped.do_draw(self) + else: + assert start_time is not None + key = observe_as or f"generate:unlabeled_{len(self.draw_times)}" try: - v = unwrapped.do_draw(self) - finally: - # Subtract the time spent in GC to avoid overcounting, as it is - # accounted for at the overall example level. - in_gctime = gc_cumulative_time() - gc_start_time - self.draw_times[key] = time.perf_counter() - start_time - in_gctime - except Exception as err: - add_note( - err, - f"while generating {key.removeprefix('generate:')!r} from {strategy!r}", - ) - raise - if observability_enabled(): - avoid = self.provider.avoid_realization - self._observability_args[key] = to_jsonable(v, avoid_realization=avoid) + try: + v = unwrapped.do_draw(self) + finally: + # Subtract the time spent in GC to avoid overcounting, as + # it is accounted for at the overall example level. + in_gctime = gc_cumulative_time() - gc_start_time + self.draw_times[key] = ( + time.perf_counter() - start_time - in_gctime + ) + except Exception as err: + add_note( + err, + f"while generating {key.removeprefix('generate:')!r} from {strategy!r}", + ) + raise + if observability_enabled(): + avoid = self.provider.avoid_realization + self._observability_args[key] = to_jsonable( + v, avoid_realization=avoid + ) + if type(v) in _PRIMITIVE_CHOICE_TYPES: + self.__span_record.annotate_current_span(v) return v finally: self.stop_span() diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py index d8212f41c3..3c524f9af3 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py @@ -55,7 +55,8 @@ prefix_selection_order, random_selection_order, ) -from hypothesis.internal.floats import MAX_PRECISE_INTEGER +from hypothesis.internal.floats import MAX_PRECISE_INTEGER, SMALLEST_SUBNORMAL +from hypothesis.internal.intervalsets import IntervalSet if TYPE_CHECKING: from random import Random @@ -89,6 +90,59 @@ def sort_key(nodes: Sequence[ChoiceNode]) -> tuple[int, tuple[int, ...]]: ) +def _choice_node_for_value(value: ChoiceT) -> ChoiceNode: + """Return a ``ChoiceNode`` wrapping a primitive value, with permissive + constraints that accept the value. Used by ``widen_to_annotated_span`` + to synthesise a single choice from a span's annotation.""" + if type(value) is bool: + return ChoiceNode( + type="boolean", value=value, constraints={"p": 0.5}, was_forced=False + ) + if type(value) is int: + return ChoiceNode( + type="integer", + value=value, + constraints={ + "min_value": None, + "max_value": None, + "weights": None, + "shrink_towards": 0, + }, + was_forced=False, + ) + if type(value) is float: + return ChoiceNode( + type="float", + value=value, + constraints={ + "min_value": -math.inf, + "max_value": math.inf, + "allow_nan": True, + "smallest_nonzero_magnitude": SMALLEST_SUBNORMAL, + }, + was_forced=False, + ) + if type(value) is str: + return ChoiceNode( + type="string", + value=value, + constraints={ + "intervals": IntervalSet.from_string(value), + "min_size": 0, + "max_size": len(value), + }, + was_forced=False, + ) + if type(value) is bytes: + return ChoiceNode( + type="bytes", + value=value, + constraints={"min_size": 0, "max_size": len(value)}, + was_forced=False, + ) + raise AssertionError(f"non-primitive annotation {value!r} of type {type(value)}") + + @dataclass(slots=True, frozen=False) class ShrinkPass: function: Any @@ -321,6 +375,7 @@ def __init__( ShrinkPass(self.redistribute_numeric_pairs), ShrinkPass(self.lower_integers_together), ShrinkPass(self.lower_duplicated_characters), + ShrinkPass(self.widen_to_annotated_span), ] # Because the shrinker is also used to `pareto_optimise` in the target phase, @@ -1508,6 +1563,50 @@ def copy_node(node, n): ), ) + def widen_to_annotated_span(self, chooser): + """Try to navigate away from a specific ``one_of`` alternative into + an earlier one by using the span's annotation. + + If we have an integer choice with ``min_value == 0`` currently set to + a non-zero value, and it is immediately followed by a span annotated + with a primitive value, we replace the integer with ``0`` and the + span's choices with a single choice holding the annotated value. The + engine then re-runs the test against the earlier alternative with + that value. + + This is useful for ``basic_strategy | specific_strategy``, where + the specific branch produced a primitive that the basic branch could + also have produced: we slip the primitive across into the basic + branch so that normal shrinking can take it the rest of the way. + """ + node = chooser.choose( + self.nodes, + lambda n: ( + n.type == "integer" + and not n.was_forced + and n.constraints["min_value"] == 0 + and n.value != 0 + ), + ) + + following = node.index + 1 + if following >= len(self.spans_starting_at): + return + + candidate_spans = self.spans_starting_at[following] + span_idx = chooser.choose( + candidate_spans, lambda i: self.spans[i].annotation is not None + ) + span = self.spans[span_idx] + replacement = _choice_node_for_value(span.annotation) + + self.consider_new_nodes( + self.nodes[: node.index] + + (node.copy(with_value=0),) + + (replacement,) + + self.nodes[span.end :] + ) + def minimize_nodes(self, nodes): choice_type = nodes[0].type value = nodes[0].value diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/misc.py b/hypothesis-python/src/hypothesis/strategies/_internal/misc.py index cbcfa32445..12d0dfbf07 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/misc.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/misc.py @@ -33,12 +33,6 @@ class JustStrategy(SampledFromStrategy[Ex]): It's implemented as a length-one SampledFromStrategy so that all our special-case logic for filtering and sets applies also to just(x). - - The important difference from a SampledFromStrategy with only one - element to choose is that JustStrategy *never* touches the underlying - choice sequence, i.e. drawing neither reads from nor writes to `data`. - This is a reasonably important optimisation (or semantic distinction!) - for both JustStrategy and SampledFromStrategy. """ @property @@ -60,7 +54,8 @@ def calc_is_cacheable(self, recur: RecurT) -> bool: def do_filtered_draw(self, data: ConjectureData) -> Ex | UniqueIdentifier: # The parent class's `do_draw` implementation delegates directly to # `do_filtered_draw`, which we can greatly simplify in this case since - # we have exactly one value. (This also avoids drawing any data.) + # we have exactly one value. The parent's ``do_draw`` will record the + # resulting value with ``data.maybe_add_choice_node_for``. return self._transform(self.value) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py index a9bf739386..587716f249 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py @@ -719,6 +719,12 @@ def do_draw(self, data: ConjectureData) -> Ex: if result is filter_not_satisfied: data.mark_invalid(f"Aborted test because unable to satisfy {self!r}") assert not isinstance(result, UniqueIdentifier) + # one_of drives the choice of alternative through this class with + # strategy objects as the elements; maybe_add_choice_node_for will + # no-op on those. For primitive-valued sampled_from / just calls it + # records a forced marker choice so that the span is non-empty and + # annotated for the shrinker's widening pass. + data.maybe_add_choice_node_for(result) return result def get_element(self, i: int) -> Ex | UniqueIdentifier: diff --git a/hypothesis-python/tests/conjecture/test_test_data.py b/hypothesis-python/tests/conjecture/test_test_data.py index faca8a87c2..7eb0790f6e 100644 --- a/hypothesis-python/tests/conjecture/test_test_data.py +++ b/hypothesis-python/tests/conjecture/test_test_data.py @@ -9,6 +9,7 @@ # obtain one at https://mozilla.org/MPL/2.0/. import itertools +from random import Random import pytest @@ -24,7 +25,6 @@ structural_coverage, ) from hypothesis.strategies import SearchStrategy - from tests.conjecture.common import buffer_size_limit, interesting_origin @@ -162,7 +162,7 @@ def test_example_depth_marking(): def test_has_examples_even_when_empty(): - d = ConjectureData.for_choices([]) + d = ConjectureData.for_choices([False]) d.draw(st.just(False)) d.freeze() assert d.spans @@ -413,3 +413,62 @@ def test_overruns_at_exactly_max_length(): except StopTest: pass assert data.status is Status.OVERRUN + + +@pytest.mark.parametrize( + "strategy, expected", + [ + (st.just(0), 0), + (st.just(True), True), + (st.just("hello"), "hello"), + (st.just(b"abc"), b"abc"), + (st.just(1.5), 1.5), + ], +) +def test_primitive_strategy_spans_are_annotated(strategy, expected): + d = ConjectureData(random=Random(0)) + value = d.draw(strategy) + d.freeze() + assert value == expected + strategy_span = next(ex for ex in d.spans if ex.depth == 1) + assert strategy_span.annotation == expected + + +def test_non_primitive_strategy_spans_are_not_annotated(): + d = ConjectureData(random=Random(0)) + d.draw(st.just((1, "x"))) + d.freeze() + # A just() returning a tuple is not a primitive value - no annotation. + outer = next(ex for ex in d.spans if ex.depth == 1) + assert outer.annotation is None + + +def test_top_level_span_is_not_annotated(): + d = ConjectureData(random=Random(0)) + d.draw(st.just(0)) + d.freeze() + # The top-level span wraps the whole test run, not a single strategy. + assert d.spans[0].annotation is None + + +def test_manual_span_annotation(): + # Test the low-level annotate_current_span API directly. + d = ConjectureData(random=Random(0)) + d.start_span(1) + d._ConjectureData__span_record.annotate_current_span("outer") + d.start_span(2) + d._ConjectureData__span_record.annotate_current_span(42) + d.stop_span() + d.stop_span() + d.freeze() + annotations = {ex.label: ex.annotation for ex in d.spans if ex.label in (1, 2)} + assert annotations == {1: "outer", 2: 42} + + +def test_span_without_annotation_returns_none(): + d = ConjectureData(random=Random(0)) + d.start_span(1) + d.stop_span() + d.freeze() + span = next(ex for ex in d.spans if ex.label == 1) + assert span.annotation is None diff --git a/hypothesis-python/tests/cover/test_sampled_from.py b/hypothesis-python/tests/cover/test_sampled_from.py index e6a4f7ef51..9012d190f7 100644 --- a/hypothesis-python/tests/cover/test_sampled_from.py +++ b/hypothesis-python/tests/cover/test_sampled_from.py @@ -160,7 +160,7 @@ def test_unsatisfiable_explicit_filteredstrategy_just(x): def test_transformed_just_strategy(): - data = ConjectureData.for_choices([]) + data = ConjectureData.for_choices([2]) s = JustStrategy([1]).map(lambda x: x * 2) assert s.do_draw(data) == 2 sf = s.filter(lambda x: False) diff --git a/hypothesis-python/tests/cover/test_searchstrategy.py b/hypothesis-python/tests/cover/test_searchstrategy.py index 269da9898c..4d4e2a11c3 100644 --- a/hypothesis-python/tests/cover/test_searchstrategy.py +++ b/hypothesis-python/tests/cover/test_searchstrategy.py @@ -52,10 +52,10 @@ def __repr__(self): assert repr(st.just(WeirdRepr())) == f"just({WeirdRepr()!r})" -def test_just_strategy_does_not_draw(): +def test_just_strategy_does_not_draw_for_non_primitive_values(): data = ConjectureData.for_choices([]) - s = st.just("hello") - assert s.do_draw(data) == "hello" + s = st.just([1, 2, 3]) + assert s.do_draw(data) == [1, 2, 3] def test_none_strategy_does_not_draw(): diff --git a/hypothesis-python/tests/quality/test_shrink_quality.py b/hypothesis-python/tests/quality/test_shrink_quality.py index 89601b1e83..d4de1a836e 100644 --- a/hypothesis-python/tests/quality/test_shrink_quality.py +++ b/hypothesis-python/tests/quality/test_shrink_quality.py @@ -32,7 +32,6 @@ text, tuples, ) - from tests.common.debug import minimal from tests.common.utils import flaky diff --git a/hypothesis-python/tests/quality/test_widening_shrinks.py b/hypothesis-python/tests/quality/test_widening_shrinks.py new file mode 100644 index 0000000000..cd609243b9 --- /dev/null +++ b/hypothesis-python/tests/quality/test_widening_shrinks.py @@ -0,0 +1,248 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +"""Quality tests for ``basic_strategy | specific_strategy`` combinations. + +Each predicate below is chosen so that random draws from the basic (open) +strategy satisfy it with probability well under 1%. That ensures the +initial failing example essentially always comes from the specific +(complicated) branch, so the test genuinely exercises the shrinker's +ability to widen out of the specific branch into the open one. +""" + +import hypothesis.strategies as st +from hypothesis.strategies import ( + integers, + just, + lists, + sampled_from, + text, +) + +from tests.common.debug import minimal + + +def test_widen_text_with_just_long_string(): + assert ( + minimal( + text() | just("I am the very model of a modern major generator"), + lambda s: "very model" in s, + ) + == "very model" + ) + + +def test_widen_text_with_just_short_substring(): + assert minimal(text() | just("xyzzy-plover"), lambda s: "xyzzy" in s) == "xyzzy" + + +def test_widen_text_with_sampled_from(): + assert ( + minimal( + text() + | sampled_from( + ["quetzalcoatl-1", "quetzalcoatl-2", "quetzalcoatl-3"] + ), + lambda s: "quetzalcoatl" in s, + ) + == "quetzalcoatl" + ) + + +def test_widen_integers_with_just(): + # A narrow window around the just value - random ``integers()`` draws + # hit it much less than 1% of the time. + assert ( + minimal( + integers() | just(5000), + lambda n: 4990 <= n <= 5010, + ) + == 4990 + ) + + +def test_widen_integers_with_sampled_from(): + assert ( + minimal( + integers() | sampled_from([4991, 4999, 5001, 5009]), + lambda n: 4990 <= n <= 5010, + ) + == 4990 + ) + + +def test_widen_bounded_integers_with_just(): + assert ( + minimal( + integers(0, 10_000) | just(5000), + lambda n: 4990 <= n <= 5010, + ) + == 4990 + ) + + +def test_widen_binary_with_just(): + # ``b"abc"`` in random bytes is vanishingly rare (~L/256**3 per draw). + assert ( + minimal( + st.binary() | just(b"xyz-abcdefghijk-specific-bytes"), + lambda b: b"abc" in b, + ) + == b"abc" + ) + + +def test_widen_floats_with_just(): + # A narrow window well away from the typical shrink targets. + assert ( + minimal( + st.floats() | just(11.0), + lambda x: 10.0 <= x <= 12.0, + ) + == 10.0 + ) + + +def test_widen_text_in_a_list(): + result = minimal( + lists(text() | just("marker-specific-string"), min_size=1), + lambda xs: any("marker" in x for x in xs), + ) + assert result == ["marker"] + + +def test_widen_integers_in_a_list(): + result = minimal( + lists(integers() | just(500), min_size=1), + lambda xs: any(490 <= x <= 510 for x in xs), + ) + assert result == [490] + + +def test_widen_three_way_one_of(): + # ``quux`` is a distinctive 4-letter sequence - essentially never + # generated by random ``text()`` - so the initial failure comes from + # one of the two ``just()`` branches. + s = text() | just("quux-kangaroo") | just("wallaby-quux-kangaroo") + assert minimal(s, lambda v: "quux" in v) == "quux" + + +def test_widen_text_with_builds(): + compound = st.builds( + lambda a, b: a + " middle " + b, + sampled_from(["alpha", "beta"]), + sampled_from(["gamma", "delta"]), + ) + assert minimal(text() | compound, lambda s: "middle" in s) == "middle" + + +# The tests below show that widening still reaches the minimum when the +# open strategy carries restrictions, as long as the annotated value +# respects those restrictions. + + +def test_widen_text_with_alphabet(): + # Random ``text(alphabet="abcd")`` draws hit the 8-character target + # under 1% of the time. + assert ( + minimal( + text(alphabet="abcd") | just("aabbccdd"), + lambda s: "aabbccdd" in s, + ) + == "aabbccdd" + ) + + +def test_widen_text_with_min_size(): + # ``"hello"`` in random ``text(min_size=3)`` is extremely rare. + assert ( + minimal( + text(min_size=3) | just("helloworld"), + lambda s: "hello" in s, + ) + == "hello" + ) + + +def test_widen_text_with_max_size(): + # A 2-char distinctive sequence inside a 5-char cap - very rare. + assert ( + minimal(text(max_size=5) | just("xyz"), lambda s: "xy" in s) == "xy" + ) + + +def test_widen_integers_with_min_value(): + assert ( + minimal( + integers(min_value=10) | just(5000), + lambda n: 4990 <= n <= 5010, + ) + == 4990 + ) + + +def test_widen_integers_with_max_value(): + assert ( + minimal( + integers(max_value=100) | just(-5000), + lambda n: -5010 <= n <= -4990, + ) + == -4990 + ) + + +def test_widen_integers_with_narrow_range(): + assert ( + minimal( + integers(0, 1000) | just(500), + lambda n: 490 <= n <= 510, + ) + == 490 + ) + + +def test_widen_floats_with_range(): + assert ( + minimal( + st.floats(0.0, 100.0) | just(11.0), + lambda x: 10.0 <= x <= 12.0, + ) + == 10.0 + ) + + +def test_widen_floats_no_nan(): + assert ( + minimal( + st.floats(allow_nan=False) | just(11.0), + lambda x: 10.0 <= x <= 12.0, + ) + == 10.0 + ) + + +def test_widen_binary_with_min_size(): + assert ( + minimal( + st.binary(min_size=3) | just(b"helloworld"), + lambda b: b"hello" in b, + ) + == b"hello" + ) + + +def test_widen_binary_with_bounded_size(): + assert ( + minimal( + st.binary(min_size=3, max_size=5) | just(b"abcde"), + lambda b: b"abc" in b, + ) + == b"abc" + ) From 96ec4aee78dc1ae7ceefb0258707f784ecb5ece4 Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Thu, 23 Apr 2026 21:28:08 +0000 Subject: [PATCH 2/7] Rename span annotations to generated_primitive_value ``Span.annotation`` is now ``Span.generated_primitive_value``, and the corresponding backing fields on ``Spans`` / ``SpanRecord`` follow suit. ``SpanRecord.annotate_current_span`` is renamed to ``record_value_for_span``, and the primitive-type check is now done inside that method so callers don't need to guard it themselves. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hypothesis/internal/conjecture/data.py | 48 +++++++++++-------- .../internal/conjecture/shrinker.py | 28 ++++++----- .../tests/conjecture/test_test_data.py | 47 ++++++++++++------ .../tests/quality/test_widening_shrinks.py | 2 +- 4 files changed, 75 insertions(+), 50 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/data.py b/hypothesis-python/src/hypothesis/internal/conjecture/data.py index d32ff46e86..166d751f7d 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/data.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/data.py @@ -139,10 +139,10 @@ def structural_coverage(label: int) -> StructuralCoverageTag: POOLED_CONSTRAINTS_CACHE: LRUCache[tuple[Any, ...], ChoiceConstraintsT] = LRUCache(4096) # The Python types corresponding to our choice-node primitive types. A strategy -# whose generated value has one of these exact types gets its span annotated -# with that value. We use a tuple rather than a set because ``in`` on a tuple -# relies only on equality, so it still works if the generated value's class has -# an unhashable metaclass. +# whose generated value has one of these exact types gets recorded on its span +# as the generated primitive value. We use a tuple rather than a set because +# ``in`` on a tuple relies only on equality, so it still works if the generated +# value's class has an unhashable metaclass. _PRIMITIVE_CHOICE_TYPES: tuple[type, ...] = (int, bool, str, bytes, float) @@ -247,12 +247,13 @@ def children(self) -> "list[Span]": return [self.owner[i] for i in self.owner.children[self.index]] @property - def annotation(self) -> Any: - """The value annotating this span, or ``None`` if there is none. - Spans produced by a strategy generating a primitive value (one of the - types we have a choice node for - ``int``, ``bool``, ``str``, ``bytes``, - or ``float``) are annotated with the generated value.""" - return self.owner.annotations.get(self.index) + def generated_primitive_value(self) -> Any: + """The primitive value the corresponding strategy produced, or + ``None`` if there is none. Spans produced by a strategy generating a + primitive value (one of the types we have a choice node for - + ``int``, ``bool``, ``str``, ``bytes``, or ``float``) record the + generated value here.""" + return self.owner.generated_primitive_values.get(self.index) class SpanProperty: @@ -336,7 +337,7 @@ def __init__(self) -> None: self.__index_of_labels: dict[int, int] | None = {} self.trail = IntList() self.nodes: list[ChoiceNode] = [] - self.annotations: dict[int, Any] = {} + self.generated_primitive_values: dict[int, Any] = {} self.__open_spans: list[int] = [] self.__span_count = 0 @@ -364,11 +365,14 @@ def stop_span(self, *, discard: bool) -> None: self.trail.append(TrailType.STOP_SPAN_NO_DISCARD) self.__open_spans.pop() - def annotate_current_span(self, value: Any) -> None: - # Attach a value to the most recently started span. Called by - # ConjectureData.draw to record the value generated by a strategy. - assert self.__open_spans, "Cannot annotate without an open span" - self.annotations[self.__open_spans[-1]] = value + def record_value_for_span(self, value: Any) -> None: + # Record ``value`` against the most recently started span, but only if + # it is one of the primitive choice-node types. Called by + # ConjectureData.draw to capture the value a strategy produced. + if type(value) not in _PRIMITIVE_CHOICE_TYPES: + return + assert self.__open_spans, "Cannot record a value without an open span" + self.generated_primitive_values[self.__open_spans[-1]] = value class _starts_and_ends(SpanProperty): @@ -469,7 +473,9 @@ class Spans: def __init__(self, record: SpanRecord) -> None: self.trail = record.trail self.labels = record.labels - self.annotations: dict[int, Any] = record.annotations + self.generated_primitive_values: dict[int, Any] = ( + record.generated_primitive_values + ) self.__length = self.trail.count( TrailType.STOP_SPAN_DISCARD ) + record.trail.count(TrailType.STOP_SPAN_NO_DISCARD) @@ -1031,8 +1037,9 @@ def maybe_add_choice_node_for(self, value: Any) -> None: Strategies like :func:`just` and :func:`sampled_from` produce a value without consulting the choice sequence. For primitive values, this method places a forced choice of the corresponding type so that the - value is visible to span-level machinery (annotations, shrinking - widenings). For non-primitive values it is a no-op. + value is visible to span-level machinery (the recorded generated + primitive value on the span, shrinking widenings). For non-primitive + values it is a no-op. """ if type(value) is bool: self.draw_boolean(forced=value) @@ -1275,8 +1282,7 @@ def draw( self._observability_args[key] = to_jsonable( v, avoid_realization=avoid ) - if type(v) in _PRIMITIVE_CHOICE_TYPES: - self.__span_record.annotate_current_span(v) + self.__span_record.record_value_for_span(v) return v finally: self.stop_span() diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py index 3c524f9af3..5133a1eab5 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py @@ -92,8 +92,9 @@ def sort_key(nodes: Sequence[ChoiceNode]) -> tuple[int, tuple[int, ...]]: def _choice_node_for_value(value: ChoiceT) -> ChoiceNode: """Return a ``ChoiceNode`` wrapping a primitive value, with permissive - constraints that accept the value. Used by ``widen_to_annotated_span`` - to synthesise a single choice from a span's annotation.""" + constraints that accept the value. Used by + ``widen_to_span_with_generated_primitive_value`` to synthesise a single + choice from a span's recorded generated primitive value.""" if type(value) is bool: return ChoiceNode( type="boolean", value=value, constraints={"p": 0.5}, was_forced=False @@ -140,7 +141,7 @@ def _choice_node_for_value(value: ChoiceT) -> ChoiceNode: constraints={"min_size": 0, "max_size": len(value)}, was_forced=False, ) - raise AssertionError(f"non-primitive annotation {value!r} of type {type(value)}") + raise AssertionError(f"non-primitive value {value!r} of type {type(value)}") @dataclass(slots=True, frozen=False) @@ -375,7 +376,7 @@ def __init__( ShrinkPass(self.redistribute_numeric_pairs), ShrinkPass(self.lower_integers_together), ShrinkPass(self.lower_duplicated_characters), - ShrinkPass(self.widen_to_annotated_span), + ShrinkPass(self.widen_to_span_with_generated_primitive_value), ] # Because the shrinker is also used to `pareto_optimise` in the target phase, @@ -1563,16 +1564,16 @@ def copy_node(node, n): ), ) - def widen_to_annotated_span(self, chooser): + def widen_to_span_with_generated_primitive_value(self, chooser): """Try to navigate away from a specific ``one_of`` alternative into - an earlier one by using the span's annotation. + an earlier one by using the span's recorded generated primitive value. If we have an integer choice with ``min_value == 0`` currently set to - a non-zero value, and it is immediately followed by a span annotated - with a primitive value, we replace the integer with ``0`` and the - span's choices with a single choice holding the annotated value. The - engine then re-runs the test against the earlier alternative with - that value. + a non-zero value, and it is immediately followed by a span whose + corresponding strategy produced a primitive value, we replace the + integer with ``0`` and the span's choices with a single choice + holding that primitive value. The engine then re-runs the test + against the earlier alternative with that value. This is useful for ``basic_strategy | specific_strategy``, where the specific branch produced a primitive that the basic branch could @@ -1595,10 +1596,11 @@ def widen_to_annotated_span(self, chooser): candidate_spans = self.spans_starting_at[following] span_idx = chooser.choose( - candidate_spans, lambda i: self.spans[i].annotation is not None + candidate_spans, + lambda i: self.spans[i].generated_primitive_value is not None, ) span = self.spans[span_idx] - replacement = _choice_node_for_value(span.annotation) + replacement = _choice_node_for_value(span.generated_primitive_value) self.consider_new_nodes( self.nodes[: node.index] diff --git a/hypothesis-python/tests/conjecture/test_test_data.py b/hypothesis-python/tests/conjecture/test_test_data.py index 7eb0790f6e..3e3e4dcdb7 100644 --- a/hypothesis-python/tests/conjecture/test_test_data.py +++ b/hypothesis-python/tests/conjecture/test_test_data.py @@ -425,50 +425,67 @@ def test_overruns_at_exactly_max_length(): (st.just(1.5), 1.5), ], ) -def test_primitive_strategy_spans_are_annotated(strategy, expected): +def test_primitive_strategy_spans_record_generated_primitive_value( + strategy, expected +): d = ConjectureData(random=Random(0)) value = d.draw(strategy) d.freeze() assert value == expected strategy_span = next(ex for ex in d.spans if ex.depth == 1) - assert strategy_span.annotation == expected + assert strategy_span.generated_primitive_value == expected -def test_non_primitive_strategy_spans_are_not_annotated(): +def test_non_primitive_strategy_spans_do_not_record_a_value(): d = ConjectureData(random=Random(0)) d.draw(st.just((1, "x"))) d.freeze() - # A just() returning a tuple is not a primitive value - no annotation. + # A just() returning a tuple is not a primitive value - no value recorded. outer = next(ex for ex in d.spans if ex.depth == 1) - assert outer.annotation is None + assert outer.generated_primitive_value is None -def test_top_level_span_is_not_annotated(): +def test_top_level_span_does_not_record_a_value(): d = ConjectureData(random=Random(0)) d.draw(st.just(0)) d.freeze() # The top-level span wraps the whole test run, not a single strategy. - assert d.spans[0].annotation is None + assert d.spans[0].generated_primitive_value is None -def test_manual_span_annotation(): - # Test the low-level annotate_current_span API directly. +def test_manual_record_value_for_span(): + # Test the low-level record_value_for_span API directly. d = ConjectureData(random=Random(0)) d.start_span(1) - d._ConjectureData__span_record.annotate_current_span("outer") + d._ConjectureData__span_record.record_value_for_span("outer") d.start_span(2) - d._ConjectureData__span_record.annotate_current_span(42) + d._ConjectureData__span_record.record_value_for_span(42) d.stop_span() d.stop_span() d.freeze() - annotations = {ex.label: ex.annotation for ex in d.spans if ex.label in (1, 2)} - assert annotations == {1: "outer", 2: 42} + values = { + ex.label: ex.generated_primitive_value + for ex in d.spans + if ex.label in (1, 2) + } + assert values == {1: "outer", 2: 42} -def test_span_without_annotation_returns_none(): +def test_span_without_value_returns_none(): d = ConjectureData(random=Random(0)) d.start_span(1) d.stop_span() d.freeze() span = next(ex for ex in d.spans if ex.label == 1) - assert span.annotation is None + assert span.generated_primitive_value is None + + +def test_record_value_for_span_ignores_non_primitive(): + # record_value_for_span only records primitive choice-node types. + d = ConjectureData(random=Random(0)) + d.start_span(1) + d._ConjectureData__span_record.record_value_for_span([1, 2, 3]) + d.stop_span() + d.freeze() + span = next(ex for ex in d.spans if ex.label == 1) + assert span.generated_primitive_value is None diff --git a/hypothesis-python/tests/quality/test_widening_shrinks.py b/hypothesis-python/tests/quality/test_widening_shrinks.py index cd609243b9..4a88075996 100644 --- a/hypothesis-python/tests/quality/test_widening_shrinks.py +++ b/hypothesis-python/tests/quality/test_widening_shrinks.py @@ -144,7 +144,7 @@ def test_widen_text_with_builds(): # The tests below show that widening still reaches the minimum when the -# open strategy carries restrictions, as long as the annotated value +# open strategy carries restrictions, as long as the specific branch's value # respects those restrictions. From f7ca0d91262969cdd9b58661431c89d09d3f410a Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Thu, 23 Apr 2026 21:33:46 +0000 Subject: [PATCH 3/7] Reformat --- .../src/hypothesis/strategies/_internal/strategies.py | 2 +- hypothesis-python/tests/conjecture/test_test_data.py | 9 +++------ hypothesis-python/tests/quality/test_shrink_quality.py | 1 + hypothesis-python/tests/quality/test_widening_shrinks.py | 8 ++------ 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py index 587716f249..f9eb0c063f 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py @@ -654,7 +654,7 @@ def calc_label(self) -> int: # The worst case performance of this scheme is # itertools.chain(range(2**100), [st.none()]), where it degrades to # hashing every int in the range. - (elements_is_hashable, hash_value) = _is_hashable(self.elements) + elements_is_hashable, hash_value = _is_hashable(self.elements) if isinstance(self.elements, range) or ( elements_is_hashable and not any(isinstance(e, SearchStrategy) for e in self.elements) diff --git a/hypothesis-python/tests/conjecture/test_test_data.py b/hypothesis-python/tests/conjecture/test_test_data.py index 3e3e4dcdb7..a06a45f39e 100644 --- a/hypothesis-python/tests/conjecture/test_test_data.py +++ b/hypothesis-python/tests/conjecture/test_test_data.py @@ -25,6 +25,7 @@ structural_coverage, ) from hypothesis.strategies import SearchStrategy + from tests.conjecture.common import buffer_size_limit, interesting_origin @@ -425,9 +426,7 @@ def test_overruns_at_exactly_max_length(): (st.just(1.5), 1.5), ], ) -def test_primitive_strategy_spans_record_generated_primitive_value( - strategy, expected -): +def test_primitive_strategy_spans_record_generated_primitive_value(strategy, expected): d = ConjectureData(random=Random(0)) value = d.draw(strategy) d.freeze() @@ -464,9 +463,7 @@ def test_manual_record_value_for_span(): d.stop_span() d.freeze() values = { - ex.label: ex.generated_primitive_value - for ex in d.spans - if ex.label in (1, 2) + ex.label: ex.generated_primitive_value for ex in d.spans if ex.label in (1, 2) } assert values == {1: "outer", 2: 42} diff --git a/hypothesis-python/tests/quality/test_shrink_quality.py b/hypothesis-python/tests/quality/test_shrink_quality.py index d4de1a836e..89601b1e83 100644 --- a/hypothesis-python/tests/quality/test_shrink_quality.py +++ b/hypothesis-python/tests/quality/test_shrink_quality.py @@ -32,6 +32,7 @@ text, tuples, ) + from tests.common.debug import minimal from tests.common.utils import flaky diff --git a/hypothesis-python/tests/quality/test_widening_shrinks.py b/hypothesis-python/tests/quality/test_widening_shrinks.py index 4a88075996..764e97fd10 100644 --- a/hypothesis-python/tests/quality/test_widening_shrinks.py +++ b/hypothesis-python/tests/quality/test_widening_shrinks.py @@ -47,9 +47,7 @@ def test_widen_text_with_sampled_from(): assert ( minimal( text() - | sampled_from( - ["quetzalcoatl-1", "quetzalcoatl-2", "quetzalcoatl-3"] - ), + | sampled_from(["quetzalcoatl-1", "quetzalcoatl-2", "quetzalcoatl-3"]), lambda s: "quetzalcoatl" in s, ) == "quetzalcoatl" @@ -173,9 +171,7 @@ def test_widen_text_with_min_size(): def test_widen_text_with_max_size(): # A 2-char distinctive sequence inside a 5-char cap - very rare. - assert ( - minimal(text(max_size=5) | just("xyz"), lambda s: "xy" in s) == "xy" - ) + assert minimal(text(max_size=5) | just("xyz"), lambda s: "xy" in s) == "xy" def test_widen_integers_with_min_value(): From f0c9e69bd77db1230e98f25cba644702e68e0974 Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Fri, 24 Apr 2026 09:18:22 +0000 Subject: [PATCH 4/7] Always record a choice node for sampled_from/just and simplify one_of ``add_choice_node_for`` now always records a choice - primitive values still produce a forced choice of the matching type, and non-primitive values produce a forced ``True`` boolean as a minimal marker. To avoid polluting one_of's selector span with those markers (which would break the widening pass's "immediately followed by" condition), ``OneOfStrategy.do_draw`` now draws an integer index directly instead of routing the alternative selection through ``SampledFromStrategy``. Update two invariant tests that asserted the old ``does not draw`` behaviour, and refresh the ``@reproduce_failure`` blob whose encoded choice sequence shape has changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hypothesis/internal/conjecture/data.py | 19 +++++---- .../hypothesis/strategies/_internal/misc.py | 2 +- .../strategies/_internal/strategies.py | 40 ++++++++++++++----- .../tests/cover/test_searchstrategy.py | 12 ------ .../tests/cover/test_stateful.py | 4 +- 5 files changed, 44 insertions(+), 33 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/data.py b/hypothesis-python/src/hypothesis/internal/conjecture/data.py index 166d751f7d..e697660078 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/data.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/data.py @@ -1031,15 +1031,18 @@ def draw_boolean( constraints: BooleanConstraints = self._pooled_constraints("boolean", {"p": p}) return self._draw("boolean", constraints, observe=observe, forced=forced) - def maybe_add_choice_node_for(self, value: Any) -> None: - """Record ``value`` in the choice sequence if it is a primitive. + def add_choice_node_for(self, value: Any) -> None: + """Record ``value`` in the choice sequence as a forced choice. Strategies like :func:`just` and :func:`sampled_from` produce a value - without consulting the choice sequence. For primitive values, this - method places a forced choice of the corresponding type so that the - value is visible to span-level machinery (the recorded generated - primitive value on the span, shrinking widenings). For non-primitive - values it is a no-op. + without consulting the choice sequence. This method places a forced + choice so that the span is non-empty and visible to span-level + machinery (the recorded generated primitive value on the span, + shrinking widenings). + + For primitive values, the forced choice is of the corresponding type. + For non-primitive values it is a forced boolean ``True`` — a minimal + placeholder that guarantees the span contains at least one choice. """ if type(value) is bool: self.draw_boolean(forced=value) @@ -1051,6 +1054,8 @@ def maybe_add_choice_node_for(self, value: Any) -> None: self.draw_string(IntervalSet.from_string(value), forced=value) elif type(value) is bytes: self.draw_bytes(max_size=len(value), forced=value) + else: + self.draw_boolean(forced=True) @overload def _pooled_constraints( diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/misc.py b/hypothesis-python/src/hypothesis/strategies/_internal/misc.py index 12d0dfbf07..7a6c1af27e 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/misc.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/misc.py @@ -55,7 +55,7 @@ def do_filtered_draw(self, data: ConjectureData) -> Ex | UniqueIdentifier: # The parent class's `do_draw` implementation delegates directly to # `do_filtered_draw`, which we can greatly simplify in this case since # we have exactly one value. The parent's ``do_draw`` will record the - # resulting value with ``data.maybe_add_choice_node_for``. + # resulting value with ``data.add_choice_node_for``. return self._transform(self.value) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py index f9eb0c063f..5b43f9f865 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py @@ -719,12 +719,10 @@ def do_draw(self, data: ConjectureData) -> Ex: if result is filter_not_satisfied: data.mark_invalid(f"Aborted test because unable to satisfy {self!r}") assert not isinstance(result, UniqueIdentifier) - # one_of drives the choice of alternative through this class with - # strategy objects as the elements; maybe_add_choice_node_for will - # no-op on those. For primitive-valued sampled_from / just calls it - # records a forced marker choice so that the span is non-empty and - # annotated for the shrinker's widening pass. - data.maybe_add_choice_node_for(result) + # Record the generated value as a forced choice so the span is + # non-empty and (for primitive values) annotated with the generated + # value for the shrinker's widening pass. + data.add_choice_node_for(result) return result def get_element(self, i: int) -> Ex | UniqueIdentifier: @@ -849,12 +847,32 @@ def calc_label(self) -> int: ) def do_draw(self, data: ConjectureData) -> Ex: - strategy = data.draw( - SampledFromStrategy(self.element_strategies).filter( - lambda s: not s.is_currently_empty(data) + strategies = self.element_strategies + n = len(strategies) + # Draw an index to pick an alternative, retrying a few times if the + # chosen strategy is currently empty (e.g. an empty Bundle in stateful + # testing). + for attempt in range(3): + i = data.draw_integer(0, n - 1) + if not strategies[i].is_currently_empty(data): + return data.draw(strategies[i]) + if attempt == 0: + data.events[ + f"Retried draw from {self!r} to avoid empty alternative" + ] = "" + + # If the retries all landed on empty alternatives, fall back to + # exhaustively picking a non-empty one. + allowed = [ + i for i in range(n) if not strategies[i].is_currently_empty(data) + ] + if not allowed: + data.mark_invalid( + f"Aborted test because all alternatives of {self!r} were empty" ) - ) - return data.draw(strategy) + i = data.choice(allowed) + data.draw_integer(0, n - 1, forced=i) + return data.draw(strategies[i]) def __repr__(self) -> str: return "one_of({})".format(", ".join(map(repr, self.original_strategies))) diff --git a/hypothesis-python/tests/cover/test_searchstrategy.py b/hypothesis-python/tests/cover/test_searchstrategy.py index 4d4e2a11c3..6b67983e46 100644 --- a/hypothesis-python/tests/cover/test_searchstrategy.py +++ b/hypothesis-python/tests/cover/test_searchstrategy.py @@ -52,18 +52,6 @@ def __repr__(self): assert repr(st.just(WeirdRepr())) == f"just({WeirdRepr()!r})" -def test_just_strategy_does_not_draw_for_non_primitive_values(): - data = ConjectureData.for_choices([]) - s = st.just([1, 2, 3]) - assert s.do_draw(data) == [1, 2, 3] - - -def test_none_strategy_does_not_draw(): - data = ConjectureData.for_choices([]) - s = st.none() - assert s.do_draw(data) is None - - def test_can_map(): s = st.integers().map(pack=lambda t: "foo") assert_simple_property(s, lambda v: v == "foo") diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py index d5f75aee84..9432247e51 100644 --- a/hypothesis-python/tests/cover/test_stateful.py +++ b/hypothesis-python/tests/cover/test_stateful.py @@ -1159,7 +1159,7 @@ def oops(self): def test_reproduce_failure_works(): - @reproduce_failure(__version__, encode_failure([False, 0, True])) + @reproduce_failure(__version__, encode_failure([False, 0, False, True])) class TrivialMachine(RuleBasedStateMachine): @rule() def oops(self): @@ -1170,7 +1170,7 @@ def oops(self): def test_reproduce_failure_fails_if_no_error(): - @reproduce_failure(__version__, encode_failure([False, 0, True])) + @reproduce_failure(__version__, encode_failure([False, 0, False, True])) class TrivialMachine(RuleBasedStateMachine): @rule() def ok(self): From 687ba30bf9c7203436a5206cc8494e9bd1e9cde0 Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Fri, 24 Apr 2026 09:20:28 +0000 Subject: [PATCH 5/7] Reformat --- .../strategies/_internal/strategies.py | 4 +- .../tests/cover/test_stateful.py | 40 ++++--------------- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py index 5b43f9f865..0dbab92158 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py @@ -863,9 +863,7 @@ def do_draw(self, data: ConjectureData) -> Ex: # If the retries all landed on empty alternatives, fall back to # exhaustively picking a non-empty one. - allowed = [ - i for i in range(n) if not strategies[i].is_currently_empty(data) - ] + allowed = [i for i in range(n) if not strategies[i].is_currently_empty(data)] if not allowed: data.mark_invalid( f"Aborted test because all alternatives of {self!r} were empty" diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py index 9432247e51..6dbbde2098 100644 --- a/hypothesis-python/tests/cover/test_stateful.py +++ b/hypothesis-python/tests/cover/test_stateful.py @@ -731,16 +731,13 @@ def rule_1(self): run_state_machine_as_test(BadInvariant) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = BadInvariant() state.initialize_1() state.invariant_1() state.teardown() """.strip() - ) def test_invariant_present_in_falsifying_example(): @@ -945,16 +942,13 @@ def fail_fast(self, param): run_state_machine_as_test(WithInitializeBundleRules) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = WithInitializeBundleRules() a_0 = state.initialize_a(dep='dep') state.fail_fast(param=a_0) state.teardown() """.strip() - ) def test_initialize_rule_dont_mix_with_precondition(): @@ -1076,9 +1070,7 @@ def fail_eventually(self): run_state_machine_as_test(StateMachine) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = StateMachine() state.initialize() @@ -1086,7 +1078,6 @@ def fail_eventually(self): state.fail_eventually() state.teardown() """.strip() - ) def test_steps_printed_despite_pytest_fail(): @@ -1099,14 +1090,11 @@ def oops(self): with pytest.raises(Failed) as err: run_state_machine_as_test(RaisesProblem) - assert ( - "\n".join(err.value.__notes__).strip() - == """ + assert "\n".join(err.value.__notes__).strip() == """ Falsifying example: state = RaisesProblem() state.oops() state.teardown()""".strip() - ) def test_steps_not_printed_with_pytest_skip(capsys): @@ -1316,16 +1304,13 @@ def fail_fast(self, param): run_state_machine_as_test(Machine) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = Machine() a_0, a_1, a_2 = state.initialize() state.fail_fast(param=a_2) state.teardown() """.strip() - ) @pytest.mark.parametrize( @@ -1368,16 +1353,13 @@ def fail_fast(self): run_state_machine_as_test(Machine) result = "\n".join(err.value.__notes__) - assert ( - result - == f""" + assert result == f""" Falsifying example: state = Machine() {repr_} state.fail_fast() state.teardown() """.strip() - ) def test_multiple_targets(): @@ -1405,9 +1387,7 @@ def fail_fast(self, a1, a2, a3, b1, b2, b3): run_state_machine_as_test(Machine) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = Machine() a_0, a_1, a_2 = state.initialize() @@ -1415,7 +1395,6 @@ def fail_fast(self, a1, a2, a3, b1, b2, b3): state.fail_fast(a1=a_2, a2=a_1, a3=a_0, b1=b_2, b2=b_1, b3=b_0) state.teardown() """.strip() - ) def test_multiple_common_targets(): @@ -1446,9 +1425,7 @@ def fail_fast(self, a1, a2, a3, a4, a5, a6, b1, b2, b3): run_state_machine_as_test(Machine) result = "\n".join(err.value.__notes__) - assert ( - result - == """ + assert result == """ Falsifying example: state = Machine() a_0, a_1, a_2 = state.initialize() @@ -1457,7 +1434,6 @@ def fail_fast(self, a1, a2, a3, a4, a5, a6, b1, b2, b3): state.fail_fast(a1=a_5, a2=a_4, a3=a_3, a4=a_2, a5=a_1, a6=a_0, b1=b_2, b2=b_1, b3=b_0) state.teardown() """.strip() - ) class LotsOfEntropyPerStepMachine(RuleBasedStateMachine): From d3e7c4e4777aca804c9aff2217bed3e11285a015 Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Fri, 24 Apr 2026 10:08:08 +0000 Subject: [PATCH 6/7] Use forced False for non-primitive marker and skip forced-only explain slices - Change the placeholder choice added by ``add_choice_node_for`` for non-primitive values from ``draw_boolean(forced=True)`` to ``draw_boolean(forced=False)``. False is the trivial boolean value, so the resulting span has the same minimal sort-key as an empty span would, which preserves the relative complexity ordering of strategies like ``none()`` and ``booleans()``. - Refresh the ``@reproduce_failure`` blob to match the new forced value. - Skip slices containing only forced choices in the explain-phase loop that decides when to add ``# or any other generated value`` to an argument. If every node in the slice is forced, there's nothing to vary and the comment would be misleading. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/hypothesis/internal/conjecture/data.py | 7 ++++--- .../src/hypothesis/internal/conjecture/shrinker.py | 6 ++++++ hypothesis-python/tests/cover/test_stateful.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/data.py b/hypothesis-python/src/hypothesis/internal/conjecture/data.py index e697660078..cb1f97dd1c 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/data.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/data.py @@ -1041,8 +1041,9 @@ def add_choice_node_for(self, value: Any) -> None: shrinking widenings). For primitive values, the forced choice is of the corresponding type. - For non-primitive values it is a forced boolean ``True`` — a minimal - placeholder that guarantees the span contains at least one choice. + For non-primitive values it is a forced boolean ``False`` - the + simplest choice we can add, so the span doesn't look any more + complex than an empty one would. """ if type(value) is bool: self.draw_boolean(forced=value) @@ -1055,7 +1056,7 @@ def add_choice_node_for(self, value: Any) -> None: elif type(value) is bytes: self.draw_bytes(max_size=len(value), forced=value) else: - self.draw_boolean(forced=True) + self.draw_boolean(forced=False) @overload def _pooled_constraints( diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py index 5133a1eab5..eea932198b 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py @@ -557,6 +557,12 @@ def explain(self) -> None: ): continue + # Skip slices with no non-forced nodes - there's nothing we can + # vary, so the "or any other generated value" note would be + # misleading (the value is in fact fully determined). + if all(nodes[i].was_forced for i in range(start, end)): + continue + # Run our experiments n_same_failures = 0 note = "or any other generated value" diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py index 6dbbde2098..943373d4c3 100644 --- a/hypothesis-python/tests/cover/test_stateful.py +++ b/hypothesis-python/tests/cover/test_stateful.py @@ -1147,7 +1147,7 @@ def oops(self): def test_reproduce_failure_works(): - @reproduce_failure(__version__, encode_failure([False, 0, False, True])) + @reproduce_failure(__version__, encode_failure([False, 0, False, False])) class TrivialMachine(RuleBasedStateMachine): @rule() def oops(self): @@ -1158,7 +1158,7 @@ def oops(self): def test_reproduce_failure_fails_if_no_error(): - @reproduce_failure(__version__, encode_failure([False, 0, False, True])) + @reproduce_failure(__version__, encode_failure([False, 0, False, False])) class TrivialMachine(RuleBasedStateMachine): @rule() def ok(self): From 25b74d51b5a02cd40e55e63015d3f81c0dd494c7 Mon Sep 17 00:00:00 2001 From: "David R. MacIver" Date: Fri, 24 Apr 2026 10:54:27 +0000 Subject: [PATCH 7/7] Cover new helpers and fix RELEASE.rst backticks - Add direct unit tests for ``_choice_node_for_value`` (all primitive branches + the non-primitive assertion), which the quality tests were exercising via integration but the conjecture-only coverage run was not. - Add a direct test for ``LazyStrategy.do_draw``; the normal ``data.draw`` path unwraps lazy strategies, so this method is only reachable via a direct call. - Use a ``# pragma: no cover`` for the forced-only-slice skip in the explain pass - it's only reachable through a full explain-phase run, not through conjecture-level unit tests. - Use double backticks for ``my_primitive_strategy | some_more_complicated_strategy`` in ``RELEASE.rst`` so Sphinx treats it as a literal rather than a Python object reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- hypothesis-python/RELEASE.rst | 2 +- .../internal/conjecture/shrinker.py | 2 +- .../tests/conjecture/test_test_data.py | 23 +++++++++++++++++++ .../tests/cover/test_searchstrategy.py | 10 ++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst index 421eeed70e..bc915c1ff0 100644 --- a/hypothesis-python/RELEASE.rst +++ b/hypothesis-python/RELEASE.rst @@ -2,6 +2,6 @@ RELEASE_TYPE: patch This release improves shrinking for a very specific category of generator: If you have a primitive strategy such as :func:`~hypothesis.strategies.text()` -and write `my_primitive_strategy | some_more_complicated_strategy`, values +and write ``my_primitive_strategy | some_more_complicated_strategy``, values produced by the second strategy can now be shrunk as if they had come from the first strategy. diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py index eea932198b..baa893e835 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py @@ -561,7 +561,7 @@ def explain(self) -> None: # vary, so the "or any other generated value" note would be # misleading (the value is in fact fully determined). if all(nodes[i].was_forced for i in range(start, end)): - continue + continue # pragma: no cover # only reachable via explain phase # Run our experiments n_same_failures = 0 diff --git a/hypothesis-python/tests/conjecture/test_test_data.py b/hypothesis-python/tests/conjecture/test_test_data.py index a06a45f39e..a5309c7dc8 100644 --- a/hypothesis-python/tests/conjecture/test_test_data.py +++ b/hypothesis-python/tests/conjecture/test_test_data.py @@ -486,3 +486,26 @@ def test_record_value_for_span_ignores_non_primitive(): d.freeze() span = next(ex for ex in d.spans if ex.label == 1) assert span.generated_primitive_value is None + + +@pytest.mark.parametrize( + "value", + [True, 42, 1.5, "hello", b"hello"], +) +def test_choice_node_for_value_round_trips_primitives(value): + # The shrinker's widening pass synthesises a ChoiceNode from a primitive + # value. Exercise each primitive type. + from hypothesis.internal.conjecture.choice import choice_permitted + from hypothesis.internal.conjecture.shrinker import _choice_node_for_value + + node = _choice_node_for_value(value) + assert node.value == value + assert not node.was_forced + assert choice_permitted(node.value, node.constraints) + + +def test_choice_node_for_value_rejects_non_primitive(): + from hypothesis.internal.conjecture.shrinker import _choice_node_for_value + + with pytest.raises(AssertionError, match="non-primitive"): + _choice_node_for_value([1, 2, 3]) diff --git a/hypothesis-python/tests/cover/test_searchstrategy.py b/hypothesis-python/tests/cover/test_searchstrategy.py index 6b67983e46..025c4de386 100644 --- a/hypothesis-python/tests/cover/test_searchstrategy.py +++ b/hypothesis-python/tests/cover/test_searchstrategy.py @@ -215,3 +215,13 @@ def test_to_jsonable_handles_reference_cycles(obj, value): def test_deferred_strategy_draw(): strategy = st.deferred(lambda: st.integers()) assert strategy.do_draw(ConjectureData.for_choices([0])) == 0 + + +def test_lazy_strategy_draw(): + # Directly exercise LazyStrategy.do_draw (normally data.draw unwraps + # lazy strategies, so this is the only path that reaches do_draw). + from hypothesis.strategies._internal.lazy import LazyStrategy + + strategy = st.integers() # integers() returns a LazyStrategy wrapper + assert isinstance(strategy, LazyStrategy) + assert strategy.do_draw(ConjectureData.for_choices([0])) == 0