Skip to content

Allow the simplifier to use facts in its can_prove() predicates. - #9400

Open
mcourteaux wants to merge 9 commits into
mainfrom
mcourteaux/can-prove-facts
Open

Allow the simplifier to use facts in its can_prove() predicates.#9400
mcourteaux wants to merge 9 commits into
mainfrom
mcourteaux/can-prove-facts

Conversation

@mcourteaux

Copy link
Copy Markdown
Contributor

Problem statement

While preparing #9371, I hit several dead ends trying to produce very neat IR. The reason is the simplifier cannot simplify max(x, y) = x when we give the assumption x >= y. Intuitively, one would write in Simplify_Max.cpp:

   rewrite(max(x, y), a, can_prove(x >= y, this))

However, the way can_prove(Expr, Prover) is implemented is to recursively mutate() the Expr with the Prover (i.e., this instance of Simplify). This however, does not substitute in the facts (truths), and therefore fails to "prove" that x > y.

A secondary problem with rewrite rules that use can_prove() is that they recursively invoke the simplifier, which leads potentially to infinite recursions. Specifically, Andrew stated:

Calling can_prove as the third arg to rewrite recursively invokes the simplifier on every expr that matches that LHS. If one of them itself creates an intermediate expression that also matches the LHS, you get infinite recursion.

Solution

This PR makes it possible to use facts in a can_prove(), prevents infinite recursion, and offers known_true() as a lightweight alternative.

Using facts / truths.

In order to match simple truths into expressions, whenever new facts are learned, they are canonicalized, such that lookup can do the same, and a > b matches with b < a.
The can_prove(Expr, Prover) entry-point now calls out to simplify_can_prove_condition(), which is the full power of the Simplifier at work, but using canonicalized facts.

As a bonus: substitute_facts benefits from this canonicalization and manages to substitute in more facts, even when the fact is not IR-tree-wise an exact match.

As an additional bonus: weaker forms of inequalities are also substituted in as facts if the stronger inequality is known as a fact. Example: x >= y (which is weak) is replaced by true if the fact that x > y is known.

Limiting recursion.

Implementing this, I indeed hit an infinite recursion quickly, so this PR also limits the recursion depth to 1. Experiments performed with higher recursion depth explode compile time for no measured benefit. The recursion limit of 1 means that a call to simplify() can call simplify() only once within a can_prove() but not more. This is done in the call simplify_can_prove_condition() which is exactly the new behavior of can_prove().

A lightweight alternative: known_true().

Instead of running the full simplifier for a can_prove(), we can now also write rewrite-predicates using a known_true() which is just the simple lookup in the Simplifier's fact list using the canonicalization. This naturally cannot recurse, and also does not spend time trying to simplify things when not needed. This is especially useful for when you would match max(x, y) on every such Expr and then try to can_prove(x > y), which is expensive. Instead the rewrite rule is:

   rewrite(max(x, y), a, known_true(x >= y, this))

Which is a very fast lookup, instead of a whole run through the simplifier.

A few such example rules are implemented in Simplify_Div.

Checklist

  • Tests added or updated (not required for docs, CI config, or typo fixes)
  • Documentation updated (if public API changed)
  • Python bindings updated (if public API changed)
  • Benchmarks are included here if the change is intended to affect performance.
  • Commits include AI attribution where applicable (see Code of Conduct)

mcourteaux and others added 5 commits August 26, 2026 22:43
The condition of a can_prove predicate in a rewrite rule was simplified
on its own, without any of the facts the simplifier has learned on the
way down the IR. Substitute those facts into the condition first, and
store facts in the same comparison direction the simplifier produces, so
that a fact stated as x > y is usable when it visits y < x.

This makes fact-driven rewrite rules possible: max/min now pick a side
when the facts order the operands, and a division can cancel a
multiplication inside a max or min.

Co-authored-by: Claude <noreply@anthropic.com>
Facts and the conditions of can_prove predicates are now looked up in the
same canonical form: GT and GE are mapped onto LT, Not is unwrapped, and a
comparison can be settled by the other strictness of the same comparison in
either direction. This means it no longer matters how a fact was spelled
relative to how the rule that consumes it was, and a strict fact such as
x > y settles the non-strict predicate the max/min rules ask for.

Those rules ask non-strictly, since a tie makes either side of a max or min
an equally good answer, so a fact of x >= y is enough to pick a side.

Co-authored-by: Claude <noreply@anthropic.com>
Simplifying the condition of a can_prove predicate visits the operands
again, so a fact-driven rule that matches every node of its type recursed
without bound on nested min/max trees. Disable those rules while inside a
can_prove condition; the facts themselves are still substituted in at every
level.

Co-authored-by: Claude <noreply@anthropic.com>
Recursing further is occasionally useful in principle, but measurably
expensive: at a limit of 2, correctness_likely goes from 1.0s to 4.2s and
correctness_autodiff from 3.4s to 11.4s, with no test producing a better
simplification. Keep the limit at one level, but name the constant.

Co-authored-by: Claude <noreply@anthropic.com>
can_prove as a rewrite predicate recursively invokes the simplifier on every
expression matching the rule's left-hand side, so a rule whose left-hand side
also matches something built while proving the predicate recurses. It is also
simply expensive.

known_true instead looks the condition up in the facts directly. It cannot
recurse, and it is cheap enough to use on a rule that matches every node of
its type. The fact-driven max, min and division rules now use it, which is
enough for all of them: looking up a comparison already understands direction
and strictness.

Co-authored-by: Claude <noreply@anthropic.com>
@mcourteaux
mcourteaux requested a review from abadams August 27, 2026 13:06
mcourteaux and others added 2 commits August 27, 2026 15:49
The depth limit was checked in has_facts, which only protects rules that
consult it. Checking it on entry to the condition simplification instead
protects every can_prove, including the pre-existing rules and any future
one, and returning the condition unsimplified is the natural way to decline:
the predicate simply fails to prove anything.

That also frees has_facts to be a plain check, so the non-recursive
known_true rules can fire at any depth. The limit is raised to four, which
restricts nothing today: instrumenting every correctness test shows the
deepest can_prove nesting any of them reaches is one.

Co-authored-by: Claude <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.01299% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.09%. Comparing base (347b0d1) to head (032f724).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/Simplify.cpp 81.13% 6 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9400      +/-   ##
==========================================
- Coverage   70.14%   70.09%   -0.05%     
==========================================
  Files         261      261              
  Lines       79388    79468      +80     
  Branches    19357    19381      +24     
==========================================
+ Hits        55690    55707      +17     
- Misses      17874    17895      +21     
- Partials     5824     5866      +42     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mcourteaux and others added 2 commits August 27, 2026 18:18
Refusing to simplify the condition past the depth limit meant the predicate
could never be proven there, even when the fact needed was already known.
substitute_facts is a plain tree walk (mutate_with over the generic
IRMutator base traversal) that never invokes a rewrite rule, so it cannot
re-trigger can_prove or known_true and stays safe at any depth: use it as
the fallback instead of returning the condition untouched.

Added a regression test built on the pre-existing can_prove-based min/max
subtraction cancellations in Simplify_Sub.cpp (the rules that motivated
the depth limit in the first place, since their predicate constructs a
fresh subtraction that can itself match the same rule). With the limit
disabled it hangs (confirmed: 15s timeout); with it in place it completes
in under a second.

Co-authored-by: Claude <noreply@anthropic.com>
The previous fallback ran substitute_facts, a full tree walk, on the
condition. But the only thing the caller checks is whether the result is
literally the constant true, and nothing runs afterward to fold a compound
expression: an And of two individually-known-true operands stays an
unfolded And, never becoming true. So substitute_facts's ability to resolve
facts about pieces of a compound condition was wasted work here — it can't
prove anything is_known_true on the condition itself couldn't already, since
folding that partial progress into a verdict is exactly the recursive work
the cap exists to avoid.

Co-authored-by: Claude <noreply@anthropic.com>
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.

1 participant