Add internal _invert method to most strategies#4743
Open
Liam-DeVoe wants to merge 9 commits into
Open
Conversation
_invert returns a choice sequence that, when replayed against the strategy, reproduces the given value. Raises CannotInvert when the value is out-of-image or the strategy does not support inversion.
_invert method to most strategies
The conjecture-coverage CI job runs only tests/conjecture/, which did not exercise cu.invert_many. Add direct unit tests for the variable-size and fixed-size branches so the class reaches 100% branch coverage there.
Zac-HD
reviewed
May 26, 2026
Replace nested-given pattern (outer `@given(st.data())` + inner `@given(strategy)` via `check_strategy_roundtrip`) with 10x explicit `data.draw(strategy)` calls. The nested pattern is incompatible with the crosshair backend, which raises "The CrossHair provider context is not reentrant". For the separate `test_roundtrip_explicit[strategy2-3]` failure (`st.sampled_from(...).filter(...)` triggers a crosshair-internal symbolic-tracing error), wrap the parametrize entry in `xfail_on_crosshair(Why.symbolic_outside_context, as_marks=True)`.
# Conflicts: # hypothesis/src/hypothesis/strategies/_internal/recursive.py
`_invert` requires concrete values, but under the crosshair backend `data.draw(strategy)` returns symbolic values. Calling `strategy._invert` on those outside the provider context triggers "Numeric operation on symbolic while not tracing" errors. Replace the prior `xfail_on_crosshair` approach (which targeted only one parametrize case) with a file-local `skip_on_crosshair` marker applied to every `@given(st.data())` test and to the failing `test_roundtrip_explicit[strategy2-3]` parametrize entry.
Zac-HD
reviewed
May 26, 2026
`@given(st.data())` passes the strategy positionally, and Hypothesis maps positional @given args to the *rightmost* function parameters. So `def test(data, target)` was binding `st.data()` to `target` and dropping `target` from the wrapped signature — at which point pytest's `@parametrize("target", ...)` failed collection with "function uses no argument 'target'". Switch to the keyword form `@given(data=st.data())` so the strategy is unambiguously bound to `data`, leaving `target` for parametrize.
…match cases The conjecture-coverage CI job runs only tests/conjecture/ but deep_equal lives in conjecture/junkdrawer.py, so its tests need to live alongside other junkdrawer tests for the targeted-coverage check to exercise them. The two new explicit cases deterministically cover the dict-key-value and set-element for/else 'no match found' branches, which the property tests previously hit only stochastically.
Zac-HD
reviewed
May 26, 2026
Zac-HD
left a comment
Member
There was a problem hiding this comment.
some detailed comments below, but the real result is that I'm now convinced that we should do a Hole-based approach rather than raising CannotInvert (except for DefinitelyCannotInvert). More on that soon.
Comment on lines
+95
to
+99
| return tuple( | ||
| c | ||
| for s, element in zip(self.element_strategies, value, strict=True) | ||
| for c in s._invert(element) | ||
| ) |
Member
There was a problem hiding this comment.
Thinking about the experience of debugging "why doesn't this invert???", I think we might want to use an explicit loop here, so that we can exc.add_note(f"at {idx=} of {value!r}, strategy={self}"); raise and thus see the trace through the tree of strategies to the specific problem.
Annoying now, I know, but I think investing in the pattern and maybe a helper fn will pay off later.
| f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" | ||
| ) | ||
| if not self.allow_imaginary and datetime_does_not_exist(value): | ||
| raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime") |
Member
There was a problem hiding this comment.
Suggested change
| raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime") | |
| raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime, but allow_imaginary=False") |
Comment on lines
+266
to
+267
| # draw_capped_multipart emits fold *before* tz_strat draws (because | ||
| # min_value=dt.time has fold), but do_draw draws tz AFTER. |
| # know whether this was the first draw or not. | ||
| # | ||
| # Leave uninvertible for now. | ||
| raise CannotInvertYet(f"st.shared cannot be inverted locally (value={value!r})") |
Member
There was a problem hiding this comment.
Suggested change
| raise CannotInvertYet(f"st.shared cannot be inverted locally (value={value!r})") | |
| raise CannotInvertYet(f"st.shared cannot be inverted locally ({value=})") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Not actually used by anything yet. This just gets the infrastructure in place for future use.
Claude-written description
Adds an internal
_invert(value: Any) -> tuple[ChoiceT, ...]method to most strategies._invertreturns a choice sequence that, when replayed against the strategy, reproduces the given value. The baseSearchStrategy._invertraises a newCannotInvertexception, which subclasses raise for out-of-image values or unsupported scenarios. This is internal scaffolding to enable future shrinking work —_invertis not user-facing.Per-strategy implementations cover:
IntegersStrategy,FloatStrategy,NanStrategy,BooleansStrategy,JustStrategy,SampledFromStrategy,OneOfStrategy,FilteredStrategy,OneCharStringStrategy,TextStrategy,BytesStrategy,TupleStrategy,ListStrategy,PermutationStrategy,BuildsStrategy(dataclass + zero-arg paths),DateStrategy,TimeStrategy,DatetimeStrategy,TimedeltaStrategy, plus delegating wrappers (LazyStrategy,DeferredStrategy,LimitedStrategy,RecursiveStrategy).A new helper
cu.invert_many(min_size, max_size)ininternal/conjecture/utils.pymirrors the runtimewhile many.more(): bodyloop. Itsmore()anddone()methods return the choices that the corresponding runtime calls would have consumed, so caller code structurally mirrors the matchingdo_draw.Intentionally raises
CannotInvert(deferred)UniqueListStrategy._invert— annoying to invert correctly; will revisit.SharedStrategy._invert— would need global knowledge of the test function (shared strategies only contribute choices on first draw); comment explains.FixedDictStrategy._invert— was implemented but dropped as too complicated for this PR.MappedStrategy._invert—map()is not invertible in general.Open question (not addressed here)
There is an inconsistency between strict type checks (
type(value) is X) andisinstancechecks across the_invertimplementations. The strict checks are used where subclass-roundtrip would fail (e.g.DateStrategyrejectingdatetime,FloatStrategyrejecting numpy floats); the isinstance checks are used elsewhere where subclass concerns are mostly theoretical. Unifying on strict is probably more correct overall but would touch several files; deferred to a follow-up.Self-review disagreements
A self-review pass surfaced a few findings I'd leave as-is:
OneOfStrategy._invertwas flagged for "first-success vs smallest-index" ambiguity. The current implementation iteratesenumerate(self.element_strategies)and returns on first success, which is smallest-index by construction. No fix needed.TimeStrategy._invertcarries an inline comment aboutdraw_capped_multipart's fold-ordering invariant that doesn't appear onDatetimeStrategy._invert. The cleanest fix (move the comment todraw_capped_multipartitself) is out of scope here.BuildsStrategy._invert's zero-arg path accepts any value (returns()without checkingtarget() == value). Roundtrip succeeds for the canonical empty/default case; out-of-image values would silently roundtrip wrong. Tracked but deferred.values_equalin the test file uses Python'sdatetime.__eq__, which ignoresfoldfor naive datetimes and compares aware datetimes by UTC moment. Currenttest_datetimes/test_timesdon't exercise non-trivialtzinfoorfold=1, so the gap is theoretical. Tracked but deferred.assert_roundtripnow assertsdata.misaligned_at is Noneandvalues_equal(data.choices, choices)— without those, the roundtrip tests could pass on a structurally wrong_invertvia lucky default substitution.