Repository: sympy/sympy

Issue:
# The recursive ask() handlers are problematic

Right now all the "legacy" ask() handlers (in sympy/assumptions/handlers) work by recursively calling ask(). This is not a very good design. The `satask` approach where we instead gather a bunch of symbolic predicates and send them to a solver is better for many reasons. We should aim to remove these handlers once `satask` is able to handle everything they can handle.

Until then, there is a very simple refactor that should be made to them. Right now, they recursively call the `ask` function itself. However, instead this particular handler system should be a helper function, like `_ask_recursive()`. The handlers should all call `_ask_recursive()` rather than `ask()`.

This is important for two reasons:

- ask() does some preprocessing which would be unnecessarily duplicated with each recursive call. The intention is to increase the amount of preprocessing that is done.

- A bigger issue is that `ask` calls `satask` after this handler system fails. But the way this works, `satask` doesn't actually get the input of `ask`. This is because the recursive handlers split up logical expressions recursively! So something like `ask(Q.positive(x) | Q.negative(x), Q.real(x))` ends up in the following recursive call chain:

```
ask(Q.positive(x) | Q.negative(x) | Q.zero(x), Q.real(x))
    recursive_ask(Q.positive(x) | Q.negative(x) | Q.zero(x), Q.real(x))
        ask(Q.positive(x), Q.real(x))
            recursive_ask(Q.positive(x), Q.real(x)) -> None
            satask(Q.positive(x), Q.real(x)) -> None
        ask(Q.negative(x), Q.real(x))
            recursive_ask(Q.negative(x), Q.real(x)) -> None
            satask(Q.negative(x), Q.real(x)) -> None
        ask(Q.zero(x), Q.real(x))
            recursive_ask(Q.zero(x), Q.real(x)) -> None
            satask(Q.zero(x), Q.real(x)) -> None
    satask(Q.positive(x) | Q.negative(x) | Q.zero(x), Q.real(x)) -> True (finally)
```

(here `recursive_ask` is actually called `_eval_ask` in the code)

Each of those intermediate `satask` calls should not be there. 

Note that this might actually make `ask` give less answers than previously if some query relied on both recursive and sat ask working together. 

This will also make it easier to test the recursive_ask and satask "backends" separately (see the discussion at https://github.com/sympy/sympy/issues/27662#issuecomment-2945406582).

CC @TiloRC @krishnavbajoria02 this relates to what we discussed in the call today. This is another thing you can work on this week if you want. 

Implementation patch:
diff --git a/sympy/assumptions/ask.py b/sympy/assumptions/ask.py
index 72894df1e810..2b5f56b117c0 100644
--- a/sympy/assumptions/ask.py
+++ b/sympy/assumptions/ask.py
@@ -558,7 +558,7 @@ def ask(proposition, assumptions=True, context=global_assumptions):
 
 def _ask_single_fact(key, local_facts):
     """
-    Compute the truth value of single predicate using assumptions.
+    Determine whether the key is directly implied or refuted by any unit clause in local_facts.
 
     Parameters
     ==========
@@ -607,34 +607,34 @@ def _ask_single_fact(key, local_facts):
     >>> _ask_single_fact(key, local_facts)
     False
     """
-    if local_facts.clauses:
-
-        known_facts_dict = get_known_facts_dict()
-
-        if len(local_facts.clauses) == 1:
-            cl, = local_facts.clauses
-            if len(cl) == 1:
-                f, = cl
-                prop_facts = known_facts_dict.get(key, None)
-                prop_req = prop_facts[0] if prop_facts is not None else set()
-                if f.is_Not and f.arg in prop_req:
-                    # the prerequisite of proposition is rejected
-                    return False
-
-        for clause in local_facts.clauses:
-            if len(clause) == 1:
-                f, = clause
-                prop_facts = known_facts_dict.get(f.arg, None) if not f.is_Not else None
-                if prop_facts is None:
-                    continue
-
-                prop_req, prop_rej = prop_facts
-                if key in prop_req:
-                    # assumption implies the proposition
-                    return True
-                elif key in prop_rej:
-                    # proposition rejects the assumption
-                    return False
+    if not local_facts.clauses:
+        return None
+
+    known_facts_dict = get_known_facts_dict()
+    get_facts = lambda k: known_facts_dict.get(k, (set(), set()))
+
+    for clause in local_facts.clauses:
+        if len(clause) != 1:
+            continue
+        (f,) = clause
+        pred, negated = f.arg, f.is_Not
+
+        # Negative literal
+        key_req, _ = get_facts(key)
+        if negated and pred in key_req:
+            # If key implies pred and pred is false,
+            # then key must be false.
+            return False
+
+        # Positive literal
+        if not negated:
+            req, rej = get_facts(pred)
+            if key in req:
+                # key is implied by pred
+                return True
+            if key in rej:
+                # ~key is implied by pred
+                return False
 
     return None
 


Test patch:
diff --git a/sympy/assumptions/tests/test_assumptions_2.py b/sympy/assumptions/tests/test_assumptions_2.py
index 493fe4a7ed70..b157bdcf85ce 100644
--- a/sympy/assumptions/tests/test_assumptions_2.py
+++ b/sympy/assumptions/tests/test_assumptions_2.py
@@ -3,8 +3,9 @@
 """
 from sympy.abc import x, y
 from sympy.assumptions.assume import global_assumptions
-from sympy.assumptions.ask import Q
+from sympy.assumptions.ask import Q, _ask_single_fact
 from sympy.printing import pretty
+from sympy.assumptions.cnf import CNF
 
 
 def test_equal():
@@ -33,3 +34,16 @@ def test_global():
     global_assumptions.clear()
     assert not (x > 0) in global_assumptions
     assert not (y > 0) in global_assumptions
+
+
+def test_ask_single_fact():
+    assert _ask_single_fact(Q.real, CNF()) is None
+    assert _ask_single_fact(Q.even, CNF.from_prop(Q.zero)) is True
+    assert _ask_single_fact(Q.even, CNF.from_prop(Q.odd)) is False
+    assert _ask_single_fact(Q.even, CNF.from_prop(Q.real)) is None
+    assert _ask_single_fact(Q.integer, CNF.from_prop(Q.even | Q.odd)) is None
+    assert _ask_single_fact(Q.integer, CNF.from_prop(Q.prime)) is True
+    assert _ask_single_fact(Q.prime,   CNF.from_prop(Q.composite)) is False
+    assert _ask_single_fact(Q.zero, CNF.from_prop(~Q.even)) is False
+
+    assert _ask_single_fact(Q.zero, CNF.from_prop(~Q.even & Q.real)) is False