Skip to content

Add internal _invert method to most strategies#4743

Open
Liam-DeVoe wants to merge 9 commits into
HypothesisWorks:masterfrom
Liam-DeVoe:add-strategy-invert
Open

Add internal _invert method to most strategies#4743
Liam-DeVoe wants to merge 9 commits into
HypothesisWorks:masterfrom
Liam-DeVoe:add-strategy-invert

Conversation

@Liam-DeVoe

@Liam-DeVoe Liam-DeVoe commented May 26, 2026

Copy link
Copy Markdown
Member

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. _invert returns a choice sequence that, when replayed against the strategy, reproduces the given value. The base SearchStrategy._invert raises a new CannotInvert exception, which subclasses raise for out-of-image values or unsupported scenarios. This is internal scaffolding to enable future shrinking work — _invert is 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) in internal/conjecture/utils.py mirrors the runtime while many.more(): body loop. Its more() and done() methods return the choices that the corresponding runtime calls would have consumed, so caller code structurally mirrors the matching do_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._invertmap() is not invertible in general.

Open question (not addressed here)

There is an inconsistency between strict type checks (type(value) is X) and isinstance checks across the _invert implementations. The strict checks are used where subclass-roundtrip would fail (e.g. DateStrategy rejecting datetime, FloatStrategy rejecting 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:

  1. OneOfStrategy._invert was flagged for "first-success vs smallest-index" ambiguity. The current implementation iterates enumerate(self.element_strategies) and returns on first success, which is smallest-index by construction. No fix needed.
  2. TimeStrategy._invert carries an inline comment about draw_capped_multipart's fold-ordering invariant that doesn't appear on DatetimeStrategy._invert. The cleanest fix (move the comment to draw_capped_multipart itself) is out of scope here.
  3. BuildsStrategy._invert's zero-arg path accepts any value (returns () without checking target() == value). Roundtrip succeeds for the canonical empty/default case; out-of-image values would silently roundtrip wrong. Tracked but deferred.
  4. values_equal in the test file uses Python's datetime.__eq__, which ignores fold for naive datetimes and compares aware datetimes by UTC moment. Current test_datetimes/test_times don't exercise non-trivial tzinfo or fold=1, so the gap is theoretical. Tracked but deferred.

assert_roundtrip now asserts data.misaligned_at is None and values_equal(data.choices, choices) — without those, the roundtrip tests could pass on a structurally wrong _invert via lucky default substitution.

_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.
@Liam-DeVoe Liam-DeVoe changed the title claude: add internal _invert method to most strategies Add internal _invert method to most strategies May 26, 2026
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.
Comment thread hypothesis/src/hypothesis/strategies/_internal/strategies.py Outdated
Comment thread hypothesis/src/hypothesis/strategies/_internal/strategies.py
Comment thread hypothesis/src/hypothesis/strategies/_internal/strategies.py Outdated
Comment thread hypothesis/src/hypothesis/strategies/_internal/strategies.py Outdated
Comment thread hypothesis/src/hypothesis/strategies/_internal/collections.py Outdated
Comment thread hypothesis/src/hypothesis/strategies/_internal/misc.py Outdated
Comment thread hypothesis/src/hypothesis/strategies/_internal/recursive.py
Comment thread hypothesis/src/hypothesis/strategies/_internal/recursive.py
Comment thread hypothesis/src/hypothesis/strategies/_internal/shared.py Outdated
Comment thread hypothesis/tests/cover/test_invert.py Outdated
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

@Zac-HD Zac-HD left a comment

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.

nitpick, but...

Comment thread hypothesis/tests/cover/test_invert.py Outdated
`_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.
Comment thread hypothesis/tests/cover/test_invert.py Outdated
Comment thread hypothesis/tests/cover/test_invert.py Outdated
`@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 Zac-HD left a comment

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.

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)
)

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.

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")

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.

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.

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 am confused by this comment.

# 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})")

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.

Suggested change
raise CannotInvertYet(f"st.shared cannot be inverted locally (value={value!r})")
raise CannotInvertYet(f"st.shared cannot be inverted locally ({value=})")

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.

2 participants