Skip to content

Commit 501d2ba

Browse files
authored
Merge pull request #3929 from tybug/shrinker-ir-descendant
Migrate `pass_to_descendant` and `redistribute_block_pairs` shrinker passes
2 parents 51bb792 + 01c18a9 commit 501d2ba

File tree

5 files changed

+365
-34
lines changed

5 files changed

+365
-34
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch continues our work on refactoring the shrinker (:issue:`3921`).

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ def length(self) -> int:
324324
"""The number of bytes in this example."""
325325
return self.end - self.start
326326

327+
@property
328+
def ir_length(self) -> int:
329+
"""The number of ir nodes in this example."""
330+
return self.ir_end - self.ir_start
331+
327332
@property
328333
def children(self) -> "List[Example]":
329334
"""The list of all examples with this as a parent, in increasing index
@@ -465,7 +470,11 @@ def freeze(self) -> None:
465470
def record_ir_draw(self, ir_type, value, *, kwargs, was_forced):
466471
self.trail.append(IR_NODE_RECORD)
467472
node = IRNode(
468-
ir_type=ir_type, value=value, kwargs=kwargs, was_forced=was_forced
473+
ir_type=ir_type,
474+
value=value,
475+
kwargs=kwargs,
476+
was_forced=was_forced,
477+
index=len(self.ir_nodes),
469478
)
470479
self.ir_nodes.append(node)
471480

@@ -950,18 +959,64 @@ class IRNode:
950959
value: IRType = attr.ib()
951960
kwargs: IRKWargsType = attr.ib()
952961
was_forced: bool = attr.ib()
962+
index: Optional[int] = attr.ib(default=None)
953963

954964
def copy(self, *, with_value: IRType) -> "IRNode":
955965
# we may want to allow this combination in the future, but for now it's
956966
# a footgun.
957967
assert not self.was_forced, "modifying a forced node doesn't make sense"
968+
# explicitly not copying index. node indices are only assigned via
969+
# ExampleRecord. This prevents footguns with relying on stale indices
970+
# after copying.
958971
return IRNode(
959972
ir_type=self.ir_type,
960973
value=with_value,
961974
kwargs=self.kwargs,
962975
was_forced=self.was_forced,
963976
)
964977

978+
@property
979+
def trivial(self):
980+
"""
981+
A node is trivial if it cannot be simplified any further. This does not
982+
mean that modifying a trivial node can't produce simpler test cases when
983+
viewing the tree as a whole. Just that when viewing this node in
984+
isolation, this is the simplest the node can get.
985+
"""
986+
if self.was_forced:
987+
return True
988+
989+
if self.ir_type == "integer":
990+
shrink_towards = self.kwargs["shrink_towards"]
991+
min_value = self.kwargs["min_value"]
992+
max_value = self.kwargs["max_value"]
993+
994+
if min_value is not None:
995+
shrink_towards = max(min_value, shrink_towards)
996+
if max_value is not None:
997+
shrink_towards = min(max_value, shrink_towards)
998+
999+
return self.value == shrink_towards
1000+
if self.ir_type == "float":
1001+
# floats shrink "like integers" (for now, anyway), except shrink_towards
1002+
# is not configurable and is always 0.
1003+
shrink_towards = 0
1004+
shrink_towards = max(self.kwargs["min_value"], shrink_towards)
1005+
shrink_towards = min(self.kwargs["max_value"], shrink_towards)
1006+
1007+
return ir_value_equal("float", self.value, shrink_towards)
1008+
if self.ir_type == "boolean":
1009+
return self.value is False
1010+
if self.ir_type == "string":
1011+
# smallest size and contains only the smallest-in-shrink-order character.
1012+
minimal_char = self.kwargs["intervals"].char_in_shrink_order(0)
1013+
return self.value == (minimal_char * self.kwargs["min_size"])
1014+
if self.ir_type == "bytes":
1015+
# smallest size and all-zero value.
1016+
return len(self.value) == self.kwargs["size"] and not any(self.value)
1017+
1018+
raise NotImplementedError(f"unhandled ir_type {self.ir_type}")
1019+
9651020
def __eq__(self, other):
9661021
if not isinstance(other, IRNode):
9671022
return NotImplemented

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

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020
prefix_selection_order,
2121
random_selection_order,
2222
)
23-
from hypothesis.internal.conjecture.data import ConjectureData, ConjectureResult, Status
23+
from hypothesis.internal.conjecture.data import (
24+
ConjectureData,
25+
ConjectureResult,
26+
Status,
27+
bits_to_bytes,
28+
ir_value_permitted,
29+
)
2430
from hypothesis.internal.conjecture.dfa import ConcreteDFA
2531
from hypothesis.internal.conjecture.floats import is_simple
2632
from hypothesis.internal.conjecture.junkdrawer import (
@@ -377,7 +383,6 @@ def calls(self):
377383

378384
def consider_new_tree(self, tree):
379385
data = self.engine.ir_tree_to_data(tree)
380-
381386
return self.consider_new_buffer(data.buffer)
382387

383388
def consider_new_buffer(self, buffer):
@@ -825,12 +830,10 @@ def pass_to_descendant(self, chooser):
825830
)
826831

827832
ls = self.examples_by_label[label]
828-
829833
i = chooser.choose(range(len(ls) - 1))
830-
831834
ancestor = ls[i]
832835

833-
if i + 1 == len(ls) or ls[i + 1].start >= ancestor.end:
836+
if i + 1 == len(ls) or ls[i + 1].ir_start >= ancestor.ir_end:
834837
return
835838

836839
@self.cached(label, i)
@@ -839,22 +842,22 @@ def descendants():
839842
hi = len(ls)
840843
while lo + 1 < hi:
841844
mid = (lo + hi) // 2
842-
if ls[mid].start >= ancestor.end:
845+
if ls[mid].ir_start >= ancestor.ir_end:
843846
hi = mid
844847
else:
845848
lo = mid
846-
return [t for t in ls[i + 1 : hi] if t.length < ancestor.length]
849+
return [t for t in ls[i + 1 : hi] if t.ir_length < ancestor.ir_length]
847850

848-
descendant = chooser.choose(descendants, lambda ex: ex.length > 0)
851+
descendant = chooser.choose(descendants, lambda ex: ex.ir_length > 0)
849852

850-
assert ancestor.start <= descendant.start
851-
assert ancestor.end >= descendant.end
852-
assert descendant.length < ancestor.length
853+
assert ancestor.ir_start <= descendant.ir_start
854+
assert ancestor.ir_end >= descendant.ir_end
855+
assert descendant.ir_length < ancestor.ir_length
853856

854-
self.incorporate_new_buffer(
855-
self.buffer[: ancestor.start]
856-
+ self.buffer[descendant.start : descendant.end]
857-
+ self.buffer[ancestor.end :]
857+
self.consider_new_tree(
858+
self.nodes[: ancestor.ir_start]
859+
+ self.nodes[descendant.ir_start : descendant.ir_end]
860+
+ self.nodes[ancestor.ir_end :]
858861
)
859862

860863
def lower_common_block_offset(self):
@@ -1221,7 +1224,6 @@ def minimize_floats(self, chooser):
12211224
and not is_simple(node.value),
12221225
)
12231226

1224-
i = self.nodes.index(node)
12251227
# the Float shrinker was only built to handle positive floats. We'll
12261228
# shrink the positive portion and reapply the sign after, which is
12271229
# equivalent to this shrinker's previous behavior. We'll want to refactor
@@ -1231,9 +1233,9 @@ def minimize_floats(self, chooser):
12311233
Float.shrink(
12321234
abs(node.value),
12331235
lambda val: self.consider_new_tree(
1234-
self.nodes[:i]
1236+
self.nodes[: node.index]
12351237
+ [node.copy(with_value=sign * val)]
1236-
+ self.nodes[i + 1 :]
1238+
+ self.nodes[node.index + 1 :]
12371239
),
12381240
random=self.random,
12391241
node=node,
@@ -1245,32 +1247,56 @@ def redistribute_block_pairs(self, chooser):
12451247
to exceed some bound, lowering one of them requires raising the
12461248
other. This pass enables that."""
12471249

1248-
block = chooser.choose(self.blocks, lambda b: not b.all_zero)
1250+
node = chooser.choose(
1251+
self.nodes, lambda node: node.ir_type == "integer" and not node.trivial
1252+
)
12491253

1250-
for j in range(block.index + 1, len(self.blocks)):
1251-
next_block = self.blocks[j]
1252-
if next_block.length == block.length:
1254+
# The preconditions for this pass are that the two integer draws are only
1255+
# separated by non-integer nodes, and have the same size value in bytes.
1256+
#
1257+
# This isn't particularly principled. For instance, this wouldn't reduce
1258+
# e.g. @given(integers(), integers(), integers()) where the sum property
1259+
# involves the first and last integers.
1260+
#
1261+
# A better approach may be choosing *two* such integer nodes arbitrarily
1262+
# from the list, instead of conditionally scanning forward.
1263+
1264+
for j in range(node.index + 1, len(self.nodes)):
1265+
next_node = self.nodes[j]
1266+
if next_node.ir_type == "integer" and bits_to_bytes(
1267+
node.value.bit_length()
1268+
) == bits_to_bytes(next_node.value.bit_length()):
12531269
break
12541270
else:
12551271
return
12561272

1257-
buffer = self.buffer
1273+
if next_node.was_forced:
1274+
# avoid modifying a forced node. Note that it's fine for next_node
1275+
# to be trivial, because we're going to explicitly make it *not*
1276+
# trivial by adding to its value.
1277+
return
12581278

1259-
m = int_from_bytes(buffer[block.start : block.end])
1260-
n = int_from_bytes(buffer[next_block.start : next_block.end])
1279+
m = node.value
1280+
n = next_node.value
12611281

12621282
def boost(k):
12631283
if k > m:
12641284
return False
1265-
attempt = bytearray(buffer)
1266-
attempt[block.start : block.end] = int_to_bytes(m - k, block.length)
1267-
try:
1268-
attempt[next_block.start : next_block.end] = int_to_bytes(
1269-
n + k, next_block.length
1270-
)
1271-
except OverflowError:
1285+
1286+
node_value = m - k
1287+
next_node_value = n + k
1288+
if (not ir_value_permitted(node_value, "integer", node.kwargs)) or (
1289+
not ir_value_permitted(next_node_value, "integer", next_node.kwargs)
1290+
):
12721291
return False
1273-
return self.consider_new_buffer(attempt)
1292+
1293+
return self.consider_new_tree(
1294+
self.nodes[: node.index]
1295+
+ [node.copy(with_value=node_value)]
1296+
+ self.nodes[node.index + 1 : next_node.index]
1297+
+ [next_node.copy(with_value=next_node_value)]
1298+
+ self.nodes[next_node.index + 1 :]
1299+
)
12741300

12751301
find_integer(boost)
12761302

0 commit comments

Comments
 (0)