Skip to content

Commit 15f040d

Browse files
committed
Add initial coarse reduction pass for reducing alternatives
This adds an initial phase to shrinking that is allowed to make changes that would be bad to make as part of the main shrink pass, with the main goal of producing better results for ``one_of``.
1 parent 8250483 commit 15f040d

File tree

5 files changed

+191
-7
lines changed

5 files changed

+191
-7
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
RELEASE_TYPE: patch
2+
3+
This release further improves shrinking of strategies using :func:`~hypothesis.strategies.one_of`,
4+
allowing the shrinker to more reliably move between branches of the strategy.

hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ def shrink(self):
483483
"""
484484

485485
try:
486+
self.initial_coarse_reduction()
486487
self.greedy_shrink()
487488
except StopShrinking:
488489
# If we stopped shrinking because we're making slow progress (instead of
@@ -689,6 +690,123 @@ def greedy_shrink(self):
689690
]
690691
)
691692

693+
def initial_coarse_reduction(self):
694+
"""Performs some preliminary reductions that should not be
695+
repeated as part of the main shrink passes.
696+
697+
The main reason why these can't be included as part of shrink
698+
passes is that they have much more ability to make the test
699+
case "worse". e.g. they might rerandomise part of it, significantly
700+
increasing the value of individual nodes, which works in direct
701+
opposition to the lexical shrinking and will frequently undo
702+
its work.
703+
"""
704+
self.reduce_each_alternative()
705+
706+
@derived_value # type: ignore
707+
def examples_starting_at(self):
708+
result = [[] for _ in self.shrink_target.ir_nodes]
709+
for i, ex in enumerate(self.examples):
710+
# We can have zero-length examples that start at the end
711+
if ex.ir_start < len(result):
712+
result[ex.ir_start].append(i)
713+
return tuple(map(tuple, result))
714+
715+
def reduce_each_alternative(self):
716+
"""This is a pass that is designed to rerandomise use of the
717+
one_of strategy or things that look like it, in order to try
718+
to move from later strategies to earlier ones in the branch
719+
order.
720+
721+
It does this by trying to systematically lower each value it
722+
finds that looks like it might be the branch decision for
723+
one_of, and then attempts to repair any changes in shape that
724+
this causes.
725+
"""
726+
i = 0
727+
while i < len(self.shrink_target.ir_nodes):
728+
nodes = self.shrink_target.ir_nodes
729+
node = nodes[i]
730+
if (
731+
node.ir_type == "integer"
732+
and not node.was_forced
733+
and node.value <= 10
734+
and node.kwargs["min_value"] == 0
735+
):
736+
assert isinstance(node.value, int)
737+
738+
# We've found a plausible candidate for a ``one_of`` choice.
739+
# We now want to see if the shape of the test case actually depends
740+
# on it. If it doesn't, then we don't need to do this (comparatively
741+
# costly) pass, and can let much simpler lexicographic reduction
742+
# handle it later.
743+
#
744+
# We test this by trying to set the value to zero and seeing if the
745+
# shape changes, as measured by either changing the number of subsequent
746+
# nodes, or changing the nodes in such a way as to cause one of the
747+
# previous values to no longer be valid in its position.
748+
zero_attempt = self.cached_test_function_ir(
749+
nodes[:i] + (nodes[i].copy(with_value=0),) + nodes[i + 1 :]
750+
)
751+
if (
752+
zero_attempt is not self.shrink_target
753+
and zero_attempt is not None
754+
and zero_attempt.status >= Status.VALID
755+
):
756+
changed_shape = len(zero_attempt.ir_nodes) != len(nodes)
757+
758+
if not changed_shape:
759+
for j in range(i + 1, len(nodes)):
760+
zero_node = zero_attempt.ir_nodes[j]
761+
orig_node = nodes[j]
762+
if (
763+
zero_node.ir_type != orig_node.ir_type
764+
or not ir_value_permitted(
765+
orig_node.value, zero_node.ir_type, zero_node.kwargs
766+
)
767+
):
768+
changed_shape = True
769+
break
770+
if changed_shape:
771+
for v in range(node.value):
772+
if self.try_lower_node_as_alternative(i, v):
773+
break
774+
i += 1
775+
776+
def try_lower_node_as_alternative(self, i, v):
777+
"""Attempt to lower `self.shrink_target.ir_nodes[i]` to `v`,
778+
while rerandomising and attempting to repair any subsequent
779+
changes to the shape of the test case that this causes."""
780+
nodes = self.shrink_target.ir_nodes
781+
initial_attempt = self.cached_test_function_ir(
782+
nodes[:i] + (nodes[i].copy(with_value=v),) + nodes[i + 1 :]
783+
)
784+
if initial_attempt is self.shrink_target:
785+
return True
786+
787+
prefix = nodes[:i] + (nodes[i].copy(with_value=v),)
788+
initial = self.shrink_target
789+
examples = self.examples_starting_at[i]
790+
for _ in range(3):
791+
random_attempt = self.engine.cached_test_function_ir(
792+
prefix, extend=len(nodes) * 2
793+
)
794+
if random_attempt.status < Status.VALID:
795+
continue
796+
self.incorporate_test_data(random_attempt)
797+
for j in examples:
798+
initial_ex = initial.examples[j]
799+
attempt_ex = random_attempt.examples[j]
800+
contents = random_attempt.ir_nodes[
801+
attempt_ex.ir_start : attempt_ex.ir_end
802+
]
803+
self.consider_new_tree(
804+
nodes[:i] + contents + nodes[initial_ex.ir_end :]
805+
)
806+
if initial is not self.shrink_target:
807+
return True
808+
return False
809+
692810
@derived_value # type: ignore
693811
def shrink_pass_choice_trees(self):
694812
return defaultdict(ChoiceTree)

hypothesis-python/tests/conjecture/test_engine.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def generate_new_examples(self):
134134
runner.run()
135135
(last_data,) = runner.interesting_examples.values()
136136
assert last_data.status == Status.INTERESTING
137+
assert runner.exit_reason == ExitReason.max_shrinks
137138
assert runner.shrinks == n
138139
in_db = set(db.data[runner.secondary_key])
139140
assert len(in_db) == n

hypothesis-python/tests/conjecture/test_shrinker.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,30 @@ def shrinker(data: ConjectureData):
518518
# shrinking. Since the second draw is forced, this isn't possible to shrink
519519
# with just this pass.
520520
assert shrinker.choices == (15, 10)
521+
522+
523+
def test_alternative_shrinking_will_lower_to_alternate_value():
524+
# We want to reject the first integer value we see when shrinking
525+
# this alternative, because it will be the result of transmuting the
526+
# bytes value, and we want to ensure that we can find other values
527+
# there when we detect the shape change.
528+
seen_int = None
529+
530+
@shrinking_from(ir(1, b"hello world"))
531+
def shrinker(data: ConjectureData):
532+
nonlocal seen_int
533+
i = data.draw_integer(min_value=0, max_value=1)
534+
if i == 1:
535+
if data.draw_bytes():
536+
data.mark_interesting()
537+
else:
538+
n = data.draw_integer(0, 100)
539+
if n == 0:
540+
return
541+
if seen_int is None:
542+
seen_int = n
543+
elif n != seen_int:
544+
data.mark_interesting()
545+
546+
shrinker.initial_coarse_reduction()
547+
assert shrinker.choices[0] == 0

hypothesis-python/tests/nocover/test_precise_shrinking.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,12 @@ def test_function(data):
135135

136136

137137
@lru_cache
138+
def minimal_for_strategy(s):
139+
return precisely_shrink(s, end_marker=st.none())
140+
141+
138142
def minimal_buffer_for_strategy(s):
139-
return precisely_shrink(s, end_marker=st.none())[0].buffer
143+
return minimal_for_strategy(s)[0].buffer
140144

141145

142146
def test_strategy_list_is_in_sorted_order():
@@ -274,12 +278,11 @@ def shortlex(s):
274278
result_list = []
275279

276280
for k, v in sorted(results.items(), key=lambda x: shortlex(x[0])):
277-
if shortlex(k) < shortlex(buffer):
278-
t = repr(v)
279-
if t in seen:
280-
continue
281-
seen.add(t)
282-
result_list.append((k, v))
281+
t = repr(v)
282+
if t in seen:
283+
continue
284+
seen.add(t)
285+
result_list.append((k, v))
283286
return result_list
284287

285288

@@ -296,3 +299,34 @@ def test_always_shrinks_to_none(a, seed, block_falsey, allow_sloppy):
296299
combined_strategy, result.buffer, allow_sloppy=allow_sloppy, seed=seed
297300
)
298301
assert shrunk_values[0][1] is None
302+
303+
304+
@pytest.mark.parametrize(
305+
"i,alts", [(i, alt) for alt in alternatives for i in range(1, len(alt))]
306+
)
307+
@pytest.mark.parametrize("force_small", [False, True])
308+
@pytest.mark.parametrize("seed", [0, 2452, 99085240570])
309+
def test_can_shrink_to_every_smaller_alternative(i, alts, seed, force_small):
310+
types = [t for t, _ in alts]
311+
strats = [s for _, s in alts]
312+
combined_strategy = st.one_of(*strats)
313+
if force_small:
314+
result, value = precisely_shrink(
315+
combined_strategy, is_interesting=lambda x: type(x) is types[i], seed=seed
316+
)
317+
else:
318+
result, value = find_random(
319+
combined_strategy, lambda x: type(x) is types[i], seed=seed
320+
)
321+
322+
shrunk = shrinks(
323+
combined_strategy,
324+
result.buffer,
325+
allow_sloppy=False,
326+
# Arbitrary change so we don't use the same seed for each Random.
327+
seed=seed * 17,
328+
)
329+
shrunk_values = [t for _, t in shrunk]
330+
331+
for j in range(i):
332+
assert any(isinstance(x, types[j]) for x in shrunk_values)

0 commit comments

Comments
 (0)