Skip to content

Various typed-choice-sequence shrinking patches#4755

Merged
Zac-HD merged 8 commits into
HypothesisWorks:masterfrom
Zac-HD:claude/nice-bardeen-xS6yA
May 29, 2026
Merged

Various typed-choice-sequence shrinking patches#4755
Zac-HD merged 8 commits into
HypothesisWorks:masterfrom
Zac-HD:claude/nice-bardeen-xS6yA

Conversation

@Zac-HD
Copy link
Copy Markdown
Member

@Zac-HD Zac-HD commented May 27, 2026

Closes #4006

claude added 4 commits May 27, 2026 19:05
Above MAX_PRECISE_INTEGER the gap between adjacent floats exceeds one, so
delegating to the integer shrinker stalled: its n - 1 step rounds straight
back to n. Shrink such floats directly instead, halving for coarse progress
and stepping by next_down (a unit decrement of the bit pattern) for fine
progress, with a top-byte short-circuit to land cross-boundary targets fast.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
The collection shrinker deleted elements one at a time from the back, costing
O(n) test calls to remove a run of n deletable elements. Grow the deleted chunk
via find_integer instead, so a deletable run costs O(log(n)) calls. Shrunk
results are unchanged.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
The internals contributor guide still described the old byte-buffer shrinker
(incorporate_new_buffer, minimizer.py, byte slicing). Rewrite it to describe the
typed choice sequence and current shrinker APIs, and fix a stale code comment
referencing the removed adaptive_example_deletion pass.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
Replace the per-item release notes with a single user-facing note covering the
float and collection shrinking speedups, for review as one PR.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
claude added 3 commits May 28, 2026 00:11
When an early choice controls the size of a later collection (eg
draw_integer() then draw_string(min_size=n, max_size=n)), lowering that
choice misaligns the recorded collection value. The engine canonicalizes
the misalignment to the simplest value, discarding the interesting
contents, and the result is cached as an ordinary non-misaligned run --
so try_shrinking_nodes' realignment branch was effectively dead.

Detect the realigned string/bytes node by comparing what we asked for
against what was drawn, and retry with the recorded value truncated to
fit, preserving content from either end. Also handle the case where the
now-boring collection lets the test draw further and overrun (common in
stateful tests) by re-running without the length limit to recover the
realigned tree.
Replace the coarse-halve + ULP-step big-float branch with a single delegation to
Integer over a bijection between non-negative floats and integer positions:
int(f) below MAX_PRECISE_INTEGER, and MAX_PRECISE_INTEGER + (bit-pattern offset)
above it. Adjacent representable floats become adjacent integers, so
Integer.shrink's n - 1 step always corresponds to next_down(n) and never stalls.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
Property-based tests that _float_to_position and _position_to_float round-trip
on their respective domains, that the mapping is monotonic, and that
decrementing the position of a float above MAX_PRECISE_INTEGER lands on
next_down -- the property that lets Integer.shrink make progress past the
boundary.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
Copy link
Copy Markdown
Member

@DRMacIver DRMacIver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally LGTM. I've made some comments on things I'd like clarified or fixed, but don't need to rereview once they're addressed.

and not node.was_forced
and len(node.value) > 0
for node in initial_attempt[after:]
):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I understand what this check is doing or how it relates to the comment above. Elaborate?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think part of my confusion here is that we're talking about repairing collections (which we can do here! We have bits later that delete ranges) and this bit just assumes we're only handling strings and bytes.

Comment thread hypothesis/RELEASE.rst Outdated
failing example to a minimal one - in two ways.

First, shrinking large floats (those above ``2**53``) and collections such as
:func:`~hypothesis.strategies.lists` is now substantially faster, especially for
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is correct. The Collection shrinker is terribly named, and is only actually used for shrinking strings and bytes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree shrinker.Collection is badly named! It's generically written to shrink any collection, given an element shrinker. This is the model failing to understand relative terminology.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well among other problems, it's not, it's designed to shrink any sequence.

Drop the narrow string/bytes-only gate before retrying with extend="full":
removing it makes the comment match what the code does, and the small extra
cost (an occasional retry that the repair logic below then declines) is bounded
by cached_test_function's cache.

Also fix the release note: the Collection shrinker only drives strings and
bytestrings, not lists.

https://claude.ai/code/session_01KbWXEu5qRTThaA61whuS2Y
@Zac-HD Zac-HD enabled auto-merge May 29, 2026 22:16
@Zac-HD Zac-HD merged commit 1dafffb into HypothesisWorks:master May 29, 2026
151 of 153 checks passed
Comment on lines +24 to +26
def _float_to_position(f: float) -> int:
"""Map a non-negative float to a linear integer position such that adjacent
representable floats correspond to adjacent integers.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is wrong: we are mapping such that adjacent integer portions of floats correspond to adjacent integers. As written, this comment implies the invariant _float_to_position(f) + 1 == _float_to_position(next_up(f)), which is not true for f < MAX_PRECISE_INTEGER.

Comment on lines 1185 to 1198
# here rather than trying to persevere.
if attempt.status is Status.OVERRUN:
return False
# Lowering a size-controlling choice can make the realigned (and
# now boring) collection stop triggering the failure, so the test
# draws further and overruns before we see the realignment -- this
# is common in stateful tests, where a non-failing step is followed
# by more steps. Re-run without the length limit to recover the
# realigned tree, which the repair logic below can then act on.
attempt = self.engine.cached_test_function(
[n.value for n in initial_attempt], extend="full"
)
if attempt.status is Status.OVERRUN:
return False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: I somewhat worry this could have bad performance implications in pathological cases. extend="full" can get quite expensive, because it can draw many more choices relative to the current size of our shrink target (up to BUFFER_SIZE in the absolute worst case, which I agree is unlikely)

Comment on lines +1208 to +1214
# the recorded value for that collection no longer fits the constraints
# the test function actually used, so the engine realigns the tree by
# substituting a freshly-generated (simplest) value -- discarding
# whatever made the collection interesting. (We can't rely on
# ``attempt.misaligned_at`` to detect this, because the realigned choice
# sequence is often independently cached as an ordinary, non-misaligned
# result.) We detect a string/bytes node whose recorded value is now too
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand this comment. Is the implication here that:

  • We have some choice sequence A corresponding to some data data_A
  • In this shrinking pass, we modify A to get B, corresponding to data data_B (= attempt)
  • data_B would have misaligned_at if it were allowed to run fully, but we actually had B in cache, so the returned data_B doesn't have misaligned_at, and therefore we can't rely on misaligned_at because we don't know if B is cached or not

If this is the implication, I think this is either a bug or a misunderstanding from the model, because I disagree with it. B should run to the same data_B every time, including misaligned_at, so we shouldn't have to think about whether B was cached when checking misaligned_at.

Comment on lines +1217 to +1219
# end (see test_can_shrink_variable_string_draws).
for i in range(min(len(initial_attempt), len(attempt.nodes))):
node = initial_attempt[i]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, we only tried editing the misaligned_at node. Now we try modifying all {string, bytes} nodes whose max_size decreased in the attempt.

It's almost certainly only valuable to do this editing on the first misaligned node. Dropping the misaligned_at check here means we now edit nodes after the first misalignment:

  • if the first misalignment was a non-candidate node (where "candidate" means "is a {string, byte} node with a decreased max_size)
  • if the first misalignment was a candidate node, but there are multiple such candidate nodes, so we try editing all candidate nodes

Comment on lines +1226 to +1228
):
max_size = attempt_node.constraints["max_size"]
for truncated in (node.value[:max_size], node.value[-max_size:]):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, we anchored on min_size. Now, we anchor on max_size. This doesn't make a difference for draw_string(min_size=n, max_size=n). But I actually think draw_string(min_size=n) is more common than draw_string(min_size=n, max_size=n) (or even draw_string(max_size=n)).

I'm surprised this change didn't result in any regressions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't set max_size, I don't think we need this logic to get unstuck though, right?

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.

Follow up on typed choice sequence shrinking tasks

4 participants