This is an automated email from the ASF dual-hosted git repository.

MasterJH5574 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 5fd89b52ad [Fix][Arith] Isolate Z3 contexts and make memoization 
deterministic (#20097)
5fd89b52ad is described below

commit 5fd89b52ad7e9cd5b0059f1fd447dec644eb1d01
Author: Shushi Hong <[email protected]>
AuthorDate: Wed Aug 5 18:11:41 2026 -0400

    [Fix][Arith] Isolate Z3 contexts and make memoization deterministic (#20097)
    
    This PR improves the reliability and determinism of the Z3 arithmetic
    prover by introducing scoped Z3 contexts, safely preserving context
    ownership when cloning analyzers, and replacing unordered-map-owned Z3
    expressions with a deterministic memo pool and reusable slots. It
    prevents Z3 AST allocation history and hash iteration order from
    affecting `CanProve` results under resource limits, while adding
    regression coverage for context lifetime, memo cleanup, slot reuse, and
    analyzer cloning.
    
    Related TileLang-side changes: https://github.com/tile-ai/tvm/pull/58 &
    https://github.com/tile-ai/tvm/pull/62.
---
 include/tvm/arith/analyzer.h        |  14 +++
 python/tvm/arith/__init__.py        |   1 +
 python/tvm/arith/analyzer.py        |  15 +++
 src/arith/analyzer.cc               |   3 +
 src/arith/z3_prover.cc              | 229 ++++++++++++++++++++++++------------
 tests/python/arith/test_arith_z3.py |  52 ++++++++
 6 files changed, 241 insertions(+), 73 deletions(-)

diff --git a/include/tvm/arith/analyzer.h b/include/tvm/arith/analyzer.h
index 69fb98addb..14ae9de66e 100644
--- a/include/tvm/arith/analyzer.h
+++ b/include/tvm/arith/analyzer.h
@@ -595,6 +595,20 @@ class IntSetAnalyzer {
   Impl* impl_;
 };
 
+/*!
+ * \brief Enter a thread-local Z3 context scope.
+ *
+ * The outermost scope creates a fresh Z3 context. Nested scopes on the same
+ * thread reuse that context so all Analyzers created during one compilation
+ * can share it.
+ */
+TVM_DLL void EnterZ3ContextScope();
+
+/*!
+ * \brief Exit the current thread-local Z3 context scope.
+ */
+TVM_DLL void ExitZ3ContextScope();
+
 class Z3Prover {
  public:
   /*!
diff --git a/python/tvm/arith/__init__.py b/python/tvm/arith/__init__.py
index c65d5d9805..e2a4bf664b 100644
--- a/python/tvm/arith/__init__.py
+++ b/python/tvm/arith/__init__.py
@@ -32,6 +32,7 @@ from .analyzer import (
     ProofStrength,
     Extension,
     CompareResult,
+    Z3ContextScope,
     allow_uint_as_index,
 )
 from .bound import deduce_bound
diff --git a/python/tvm/arith/analyzer.py b/python/tvm/arith/analyzer.py
index 224244da84..453d1aa33c 100644
--- a/python/tvm/arith/analyzer.py
+++ b/python/tvm/arith/analyzer.py
@@ -117,6 +117,21 @@ class ConstraintScope:
         self._fexit()
 
 
+class Z3ContextScope:
+    """Share one fresh Z3 context across Analyzers created in this scope.
+
+    The outermost scope creates the context. Nested scopes on the same thread
+    reuse it, and exiting the outermost scope releases its ownership.
+    """
+
+    def __enter__(self):
+        _ffi_api.EnterZ3ContextScope()
+        return self
+
+    def __exit__(self, ptype, value, trace):
+        _ffi_api.ExitZ3ContextScope()
+
+
 @tvm_ffi.register_object("arith.Analyzer")
 class Analyzer(Object):
     """Integer arithmetic analyzer
diff --git a/src/arith/analyzer.cc b/src/arith/analyzer.cc
index 2e6c2acda5..609eda3af0 100644
--- a/src/arith/analyzer.cc
+++ b/src/arith/analyzer.cc
@@ -274,6 +274,7 @@ Analyzer AnalyzerObj::Clone() const {
   cloned->canonical_simplify.CopyFrom(this->canonical_simplify);
   cloned->int_set.CopyFrom(this->int_set);
   cloned->transitive_comparisons.CopyFrom(this->transitive_comparisons);
+  cloned->z3_prover.CopyFrom(this->z3_prover);
   return cloned;
 }
 
@@ -283,6 +284,8 @@ TVM_FFI_STATIC_INIT_BLOCK() {
   refl::GlobalDef()
       .def("arith.Analyzer", []() { return Analyzer(); })
       .def("arith.AnalyzerClone", [](Analyzer analyzer) { return 
analyzer->Clone(); })
+      .def("arith.EnterZ3ContextScope", []() { EnterZ3ContextScope(); })
+      .def("arith.ExitZ3ContextScope", []() { ExitZ3ContextScope(); })
       .def("arith.AnalyzerConstIntBound",
            [](Analyzer analyzer, const PrimExpr& expr) { return 
analyzer->const_int_bound(expr); })
       .def("arith.AnalyzerConstIntBoundUpdate",
diff --git a/src/arith/z3_prover.cc b/src/arith/z3_prover.cc
index e608c70e8a..b898a616b7 100644
--- a/src/arith/z3_prover.cc
+++ b/src/arith/z3_prover.cc
@@ -95,28 +95,98 @@ struct Namespace {
   }
 };
 
+struct Z3ContextState {
+  std::shared_ptr<z3::context> fallback_context;
+  std::shared_ptr<z3::context> scoped_context;
+  size_t scope_depth{0};
+};
+
+Z3ContextState& GetZ3ContextState() {
+  static thread_local Z3ContextState state;
+  return state;
+}
+
+std::shared_ptr<z3::context> GetCurrentZ3Context() {
+  auto& state = GetZ3ContextState();
+  if (state.scope_depth != 0) {
+    TVM_FFI_ICHECK(state.scoped_context != nullptr);
+    return state.scoped_context;
+  }
+  if (state.fallback_context == nullptr) {
+    state.fallback_context = std::make_shared<z3::context>();
+  }
+  return state.fallback_context;
+}
+
 }  // namespace
 
+void EnterZ3ContextScope() {
+  auto& state = GetZ3ContextState();
+  if (state.scope_depth == 0) {
+    state.scoped_context = std::make_shared<z3::context>();
+  }
+  ++state.scope_depth;
+}
+
+void ExitZ3ContextScope() {
+  auto& state = GetZ3ContextState();
+  TVM_FFI_ICHECK_GT(state.scope_depth, 0U)
+      << "ExitZ3ContextScope called without a matching EnterZ3ContextScope";
+  --state.scope_depth;
+  if (state.scope_depth == 0) {
+    state.scoped_context.reset();
+  }
+}
+
 class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
  public:
   using Base = ExprFunctor<z3::expr(const Expr&)>;
   using Self = Z3Prover::Impl;
 
   AnalyzerObj* analyzer;
-  // Keep a reference to the thread-local context for the whole lifetime of 
this
-  // prover. Schedules created on worker threads may be destroyed after the
-  // worker exits, so storing only a raw reference in z3::solver is not enough.
-  static std::shared_ptr<z3::context> GetThreadLocalContext() {
-    static thread_local std::shared_ptr<z3::context> local_ctx = 
std::make_shared<z3::context>();
-    return local_ctx;
-  }
-  std::shared_ptr<z3::context> ctx{GetThreadLocalContext()};
+  // Analyzers created in one compile scope share a context. Keeping the 
pointer
+  // on each prover also lets Analyzers and their clones outlive that scope.
+  std::shared_ptr<z3::context> ctx;
 
   /// @brief Z3 solver instance
-  z3::solver solver{*ctx};
+  std::optional<z3::solver> solver;
+
+  /// @brief Memoized PrimExpr -> slot in z3_pool_. Holds no Z3 handles, so
+  /// its pointer-hashed bucket order cannot affect Z3 object lifetime.
+  std::unordered_map<PrimExpr, size_t, StructuralHash, ExprDeepEqual> memo_;
+
+  /// @brief Slots owning the memoized Z3 handles, plus a free-slot stack.
+  /// Handles are created and released only at fixed points of the execution
+  /// path (never in hash-bucket order), so Z3's AST-ID recycling -- and thus
+  /// solver behavior under rlimit -- stays deterministic across processes.
+  std::vector<std::optional<z3::expr>> z3_pool_;
+  std::vector<size_t> free_slots_;
+
+  void MemoPut(const PrimExpr& expr, const z3::expr& z3_expr) {
+    auto [it, inserted] = memo_.emplace(expr, 0);
+    if (!inserted) return;
+    if (free_slots_.empty()) {
+      it->second = z3_pool_.size();
+      z3_pool_.emplace_back(z3_expr);
+    } else {
+      it->second = free_slots_.back();
+      free_slots_.pop_back();
+      z3_pool_[it->second] = z3_expr;
+    }
+  }
 
-  /// @brief Memorize pure expressions
-  std::unordered_map<PrimExpr, z3::expr, StructuralHash, ExprDeepEqual> memo_;
+  void MemoErase(const PrimExpr& expr) {
+    auto it = memo_.find(expr);
+    if (it == memo_.end()) return;
+    z3_pool_[it->second].reset();
+    free_slots_.push_back(it->second);
+    memo_.erase(it);
+  }
+
+  const z3::expr* MemoGet(const PrimExpr& expr) const {
+    auto it = memo_.find(expr);
+    return it == memo_.end() ? nullptr : &*z3_pool_[it->second];
+  }
 
   /// @brief Namespace for variable naming
   Namespace ns;
@@ -129,17 +199,17 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
 
   /// @brief Create a z3 solver with custom options
   static z3::solver CreateSolver(z3::context& ctx) {
-    z3::solver solver(ctx);
+    z3::solver result(ctx);
     // here we disable model generation to speed up the solving process
-    solver.set("model", false);
+    result.set("model", false);
     // ensure determinstic behavior
-    solver.set("random_seed", (unsigned)42);
-    return solver;
+    result.set("random_seed", (unsigned)42);
+    return result;
   }
 
-  Impl(AnalyzerObj* parent) : analyzer(parent) {
+  Impl(AnalyzerObj* parent)
+      : analyzer(parent), ctx(GetCurrentZ3Context()), 
solver(CreateSolver(*ctx)) {
     scope_stack_.push_back({});
-    solver = CreateSolver(*ctx);
     // use rlimit, not timeout to ensure deterministic behavior
     SetRLimit(10000U);
   }
@@ -155,11 +225,11 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
     } else {
       z3::expr e = ctx->int_const(name.c_str());
       if (dtype.MatchesCode(DLDataTypeCode::kDLUInt) && dtype.bits() == 64) {
-        solver.add(ctx->int_val(0) <= e && e <= 
ctx->int_val((uint64_t)UINT64_MAX));
+        solver->add(ctx->int_val(0) <= e && e <= 
ctx->int_val((uint64_t)UINT64_MAX));
       } else {
         auto min_val = min_value(dtype).as_or_throw<IntImm>()->value;
         auto max_val = max_value(dtype).as_or_throw<IntImm>()->value;
-        solver.add(ctx->int_val(min_val) <= e && e <= ctx->int_val(max_val));
+        solver->add(ctx->int_val(min_val) <= e && e <= ctx->int_val(max_val));
       }
       return e;
     }
@@ -187,15 +257,15 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
     scope_stack_.push_back({});
     scope_stack_.back().push_back(
         Scope{Scope::Constraint, Var(), PrimExpr(), PrimExpr(), PrimExpr(), 
constraint});
-    solver.push();
-    solver.add(VisitBool(constraint));
+    solver->push();
+    solver->add(VisitBool(constraint));
     auto side_effect_exprs = std::move(side_effect_exprs_);
     side_effect_exprs_.clear();
     for (const auto& expr : side_effect_exprs) {
-      memo_.erase(expr);
+      MemoErase(expr);
     }
     return [this]() {
-      solver.pop();
+      solver->pop();
       scope_stack_.pop_back();
     };
   }
@@ -252,7 +322,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
       if (!IsZ3SupportedExpr(expr.get())) return false;
       z3::expr_vector constr(*ctx);
       constr.push_back(!ConvertBool(expr));
-      auto result = solver.check(constr);
+      auto result = solver->check(constr);
       constr.pop_back();
       return result == z3::unsat;
     } catch (const z3::exception&) {
@@ -267,7 +337,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
     scope_stack_.back().push_back(Scope{Scope::BindValue, var, value});
     // we add the binding whenever the value is pure,
     // because non-pure parts are handling by creating free variables in 
VisitExpr
-    memo_.emplace(var.as_or_throw<PrimExpr>(), ConvertInt(value));
+    MemoPut(var.as_or_throw<PrimExpr>(), ConvertInt(value));
   }
 
   /// @brief Bind a variable to a range
@@ -279,7 +349,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
     //    if the var is overrided later, we can just update the memo, and the 
old placeholder will
     //    be ignored
     auto var_expr = Create(var.get());
-    memo_.emplace(var.as_or_throw<PrimExpr>(), var_expr);
+    MemoPut(var.as_or_throw<PrimExpr>(), var_expr);
 
     // 2. Add constraint on the placeholder
     //    when min_expr >= max_expr, the range is empty, which is under 
undefined behavior
@@ -295,49 +365,59 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
       int64_t extent_value = *tirx::as_const_int(range->extent);
       int64_t max_value = min_value + extent_value;
       if (min_value < max_value) {
-        solver.add(ctx->int_val(min_value) <= var_expr);
-        solver.add(var_expr < ctx->int_val(max_value));
+        solver->add(ctx->int_val(min_value) <= var_expr);
+        solver->add(var_expr < ctx->int_val(max_value));
       }
     } else {
       PrimExpr prim_var = var.as_or_throw<PrimExpr>();
-      solver.add(ConvertBool(range->extent <= 0 ||
-                             (range->min <= prim_var && prim_var < range->min 
+ range->extent)));
+      solver->add(ConvertBool(range->extent <= 0 ||
+                              (range->min <= prim_var && prim_var < range->min 
+ range->extent)));
     }
   }
 
   void CopyFrom(const Self& other_) {
-    // 1. create a new solver
-    //    because this->solver depends on this->ctx
-    //    we need to deconstruct the old solver, and create a new one 
depending on this->ctx
-    solver = CreateSolver(*ctx);
-    // 2. ctx is owned by this Impl and pins the underlying thread-local 
context for the lifetime
-    //    of solver and memoized expressions.
-    // 3. copy other objects
+    // Z3 handles cannot move between contexts. Destroy every handle owned by
+    // this fresh clone before adopting the source Analyzer's context.
+    solver.reset();
+    memo_.clear();
+    z3_pool_.clear();
+    free_slots_.clear();
+    ctx = other_.ctx;
+    solver.emplace(CreateSolver(*ctx));
+
+    // Copy other objects. The source and destination now share one context, so
+    // copying Z3 handles is valid and both provers retain its ownership.
     ns = other_.ns;
+    std::vector<std::pair<size_t, const PrimExpr*>> live;
+    live.reserve(other_.memo_.size());
     for (auto& item : other_.memo_) {
-      memo_.emplace(item.first, item.second);
+      live.emplace_back(item.second, &item.first);
     }
-    for (auto a : other_.solver.assertions()) {
-      solver.add(a);
+    std::sort(live.begin(), live.end(),
+              [](const auto& lhs, const auto& rhs) { return lhs.first < 
rhs.first; });
+    for (auto [index, expr] : live) {
+      MemoPut(*expr, *other_.z3_pool_[index]);
     }
-    // 4. copy timeout options
-    //    but other solver options are not copied
+    for (auto a : other_.solver->assertions()) {
+      solver->add(a);
+    }
+    // Copy timeout options, but not other solver options.
     SetTimeoutMs(other_.timeout_ms);
     SetRLimit(other_.rlimit);
-    // 5. copy the scope stack, which containing comments for SMTLIB2 
generation
+    // Copy the scope stack, which contains comments for SMTLIB2 generation.
     scope_stack_ = other_.scope_stack_;
   }
 
   /// @brief Set timeout in milliseconds
   void SetTimeoutMs(unsigned timeout_ms) {
     this->timeout_ms = timeout_ms;
-    solver.set("timeout", timeout_ms);
+    solver->set("timeout", timeout_ms);
   }
 
   /// @brief Set max steps
   void SetRLimit(unsigned rlimit) {
     this->rlimit = rlimit;
-    solver.set("rlimit", rlimit);
+    solver->set("rlimit", rlimit);
   }
 
   /// @brief Get the SMTLIB2 representation of the current solver state
@@ -345,7 +425,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
     std::stringstream ss;
     ss << "(set-option :timeout " << timeout_ms << ")\n";
     AddScopeDebugMsg(ss);
-    ss << solver.to_smt2();
+    ss << solver->to_smt2();
     return ss.str();
   }
 
@@ -376,28 +456,28 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
     ss << "(set-option :timeout " << timeout_ms << ")\n";
     AddScopeDebugMsg(ss);
     ss << "; Trying to prove: " << expr << "\n";
-    solver.push();
-    solver.add(!ConvertBool(expr));
-    ss << solver.to_smt2();
-    solver.pop();
+    solver->push();
+    solver->add(!ConvertBool(expr));
+    ss << solver->to_smt2();
+    solver->pop();
     return ss.str();
   }
 
   /// @brief Get the statistics of the solver
   ffi::String GetStats() {
     std::stringstream ss;
-    ss << solver.statistics();
+    ss << solver->statistics();
     return ss.str();
   }
 
   ffi::String GetModel(const PrimExpr& expr) {
-    solver.set("model", true);
-    solver.push();
-    solver.add(!ConvertBool(expr));
-    auto result = solver.check();
+    solver->set("model", true);
+    solver->push();
+    solver->add(!ConvertBool(expr));
+    auto result = solver->check();
     ffi::String model_str;
     if (result == z3::sat) {
-      z3::model m = solver.get_model();
+      z3::model m = solver->get_model();
       std::map<std::string, z3::expr> model_map;
       for (unsigned i = 0; i < m.size(); i++) {
         z3::func_decl d = m[i];
@@ -409,8 +489,8 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
       }
       model_str = ss.str();
     }
-    solver.pop();
-    solver.set("model", false);
+    solver->pop();
+    solver->set("model", false);
     return model_str;
   }
 
@@ -432,8 +512,8 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
       return -1;
     }
 
-    solver.set("model", true);
-    solver.push();
+    solver->set("model", true);
+    solver->push();
 
     // Convert the TVM variable to Z3 expression
     z3::expr z3_var = VisitInt(var.as_or_throw<PrimExpr>());
@@ -442,12 +522,12 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
     std::vector<int64_t> found_values;
 
     while (count < max_count) {
-      auto result = solver.check();
+      auto result = solver->check();
       if (result != z3::sat) {
         break;  // No more solutions
       }
 
-      z3::model m = solver.get_model();
+      z3::model m = solver->get_model();
       z3::expr val_expr = m.eval(z3_var, true);
 
       // Extract the integer value from Z3 expression
@@ -463,15 +543,15 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
       count++;
 
       // Add blocking clause: var != val (exclude this solution)
-      solver.add(z3_var != ctx->int_val(val));
+      solver->add(z3_var != ctx->int_val(val));
     }
 
-    solver.pop();
-    solver.set("model", false);
+    solver->pop();
+    solver->set("model", false);
 
     // Clear any side effects from visiting the variable
     for (const auto& expr : side_effect_exprs_) {
-      memo_.erase(expr);
+      MemoErase(expr);
     }
     side_effect_exprs_.clear();
 
@@ -511,7 +591,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
   z3::expr ConvertBool(const PrimExpr& e) {
     auto res = VisitBool(e);
     for (auto& expr : side_effect_exprs_) {
-      memo_.erase(expr);
+      MemoErase(expr);
     }
     side_effect_exprs_.clear();
     return res;
@@ -520,7 +600,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
   z3::expr ConvertInt(const PrimExpr& e) {
     auto res = VisitInt(e);
     for (auto& expr : side_effect_exprs_) {
-      memo_.erase(expr);
+      MemoErase(expr);
     }
     side_effect_exprs_.clear();
     return res;
@@ -529,15 +609,15 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> 
{
   /// @brief Visit expression with memoization
   z3::expr VisitExpr(const Expr& expr) override {
     PrimExpr e = expr.as_or_throw<PrimExpr>();
-    if (memo_.count(e)) {
-      return memo_.at(e);
+    if (const z3::expr* hit = MemoGet(e)) {
+      return *hit;
     }
     auto res = Base::VisitExpr(e);
     auto side_effect = SideEffect(e);
     if (side_effect <= CallEffectKind::kPure) {
-      memo_.emplace(e, res);
+      MemoPut(e, res);
     } else if (side_effect <= CallEffectKind::kReadState) {
-      memo_.emplace(e, res);
+      MemoPut(e, res);
       side_effect_exprs_.emplace_back(e);
     } else {
       side_effect_exprs_.emplace_back(e);
@@ -597,7 +677,7 @@ class Z3Prover::Impl : ExprFunctor<z3::expr(const Expr&)> {
 
   z3::expr VisitExpr_(const LetNode* op) override {
     if (IsZ3SupportedExpr(op->var.get())) {
-      memo_.emplace(op->var.as_or_throw<PrimExpr>(), VisitInt(op->value));
+      MemoPut(op->var.as_or_throw<PrimExpr>(), VisitInt(op->value));
     }
     return VisitExpr(op->body);
   }
@@ -841,6 +921,9 @@ namespace tvm::arith {
 using namespace tirx;
 using namespace ffi;
 
+void EnterZ3ContextScope() {}
+void ExitZ3ContextScope() {}
+
 // Stub implementation used when Z3 support is not built. All proving queries
 // conservatively report "cannot prove" while keeping the public API available.
 class Z3Prover::Impl {};
diff --git a/tests/python/arith/test_arith_z3.py 
b/tests/python/arith/test_arith_z3.py
index a06cd9ca62..85bbc2c3c9 100644
--- a/tests/python/arith/test_arith_z3.py
+++ b/tests/python/arith/test_arith_z3.py
@@ -93,6 +93,30 @@ def test_z3_context_lifetime_outlives_worker_thread():
     gc.collect()
 
 
+def test_z3_context_scope_clone_lifetime():
+    _require_z3(Analyzer())
+
+    a = tirx.Var("a", "int32")
+    b = tirx.Var("b", "int32")
+    c = tirx.Var("c", "int32")
+    expr = ((b - a) // c) * c + a <= b
+
+    with tvm.arith.Z3ContextScope():
+        analyzer = Analyzer()
+        analyzer.bind(a, tvm.ir.Range(1, 100000))
+        analyzer.bind(b, tvm.ir.Range(1, 100000))
+        analyzer.bind(c, tvm.ir.Range(1, 100000))
+        assert analyzer.can_prove(expr, SB)
+
+    # Clone while a different scope is active. The clone must adopt the
+    # source Analyzer's context before copying any Z3 handles.
+    with tvm.arith.Z3ContextScope():
+        cloned = analyzer.clone()
+
+    del analyzer
+    assert cloned.can_prove(expr, SB)
+
+
 # ---------------------------------------------------------------------------
 # Examples the native analyzer cannot prove but Z3 can.
 #
@@ -654,6 +678,34 @@ def test_z3_opaque_call_is_safe():
     assert not analyzer.can_prove(call > 0, SB)
 
 
+def test_z3_memo_pool_reuse_survives_clone():
+    analyzer = Analyzer()
+    _require_z3(analyzer)
+
+    # BufferLoad is read-state, so these expressions are memoized only for the
+    # duration of the query and then erased.  This leaves holes in the Z3 memo
+    # pool that subsequent pure expressions must be able to reuse safely.
+    buffer = tirx.decl_buffer((16,), "int32")
+    read_expr = tirx.all(*(buffer[i] >= 0 for i in range(16)))
+    assert not analyzer.can_prove(read_expr, SB)
+
+    a = tirx.Var("a", "int32")
+    b = tirx.Var("b", "int32")
+    c = tirx.Var("c", "int32")
+    analyzer.bind(a, tvm.ir.Range(1, 100000))
+    analyzer.bind(b, tvm.ir.Range(1, 100000))
+    analyzer.bind(c, tvm.ir.Range(1, 100000))
+    expr = ((b - a) // c) * c + a <= b
+    assert analyzer.can_prove(expr, SB)
+
+    # Cloning must replay only live memo entries in slot order, even when the
+    # source pool contains erased and reused slots.
+    cloned = analyzer.clone()
+    del analyzer
+    gc.collect()
+    assert cloned.can_prove(expr, SB)
+
+
 def test_z3_shift_overflow_is_not_proven():
     # Z3 models fixed-width shifts via bit-vectors, so it correctly refuses to
     # prove `x << n >= x` for an unbounded `x` (a large `x` overflows int32 and

Reply via email to