Skip to content

Commit 4582957

Browse files
authored
Merge pull request #4216 from HypothesisWorks/DRMacIver/more-shrinking-of-alternatives
Add initial coarse reduction pass for reducing alternatives
2 parents 5565708 + 15f040d commit 4582957

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)