aboutsummaryrefslogtreecommitdiff
path: root/proofsearch/tests
diff options
context:
space:
mode:
authorRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:13:34 +0800
committerRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:13:34 +0800
commitcf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e (patch)
tree6781468fe52f02f3ab7fdc4b92bd52e53a8bf088 /proofsearch/tests
parentd2d3f343fab303374f0c625961f358e4bf776e6b (diff)
parenta26a9311ed6cc34860092252d1750428b741b116 (diff)
downloadrcc-project-cf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e.tar.gz
rcc-project-cf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e.tar.xz
Merge remote-tracking branch 'proofsearch-import/master'
Diffstat (limited to 'proofsearch/tests')
-rw-r--r--proofsearch/tests/CMakeLists.txt31
-rw-r--r--proofsearch/tests/test_clausifier.cc17
-rw-r--r--proofsearch/tests/test_discrim_tree.cc17
-rw-r--r--proofsearch/tests/test_end_to_end.cc184
-rw-r--r--proofsearch/tests/test_proof_trace.cc207
-rw-r--r--proofsearch/tests/test_prover.cc183
-rw-r--r--proofsearch/tests/test_resolution.cc257
-rw-r--r--proofsearch/tests/test_simplify.cc191
-rw-r--r--proofsearch/tests/test_term_bank.cc339
-rw-r--r--proofsearch/tests/test_unification.cc355
10 files changed, 1781 insertions, 0 deletions
diff --git a/proofsearch/tests/CMakeLists.txt b/proofsearch/tests/CMakeLists.txt
new file mode 100644
index 0000000..184357b
--- /dev/null
+++ b/proofsearch/tests/CMakeLists.txt
@@ -0,0 +1,31 @@
+# ─── GoogleTest ───────────────────────────────────────────────────────────────
+include(FetchContent)
+FetchContent_Declare(
+ googletest
+ GIT_REPOSITORY https://github.com/google/googletest.git
+ GIT_TAG v1.14.0
+)
+# Prevent GoogleTest from overriding our compiler/linker options
+set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(googletest)
+
+include(GoogleTest)
+
+# ─── Helper: define a test target ────────────────────────────────────────────
+function(atp_add_test TEST_NAME TEST_SOURCE)
+ add_executable(${TEST_NAME} ${TEST_SOURCE})
+ target_link_libraries(${TEST_NAME} PRIVATE atp_core GTest::gtest_main)
+ target_compile_options(${TEST_NAME} PRIVATE ${ATP_PROJECT_WARNING_FLAGS})
+ gtest_discover_tests(${TEST_NAME})
+endfunction()
+
+# ─── Test Targets ────────────────────────────────────────────────────────────
+atp_add_test(test_term_bank test_term_bank.cc)
+atp_add_test(test_unification test_unification.cc)
+atp_add_test(test_clausifier test_clausifier.cc)
+atp_add_test(test_resolution test_resolution.cc)
+atp_add_test(test_discrim_tree test_discrim_tree.cc)
+atp_add_test(test_prover test_prover.cc)
+atp_add_test(test_simplify test_simplify.cc)
+atp_add_test(test_proof_trace test_proof_trace.cc)
+atp_add_test(test_end_to_end test_end_to_end.cc)
diff --git a/proofsearch/tests/test_clausifier.cc b/proofsearch/tests/test_clausifier.cc
new file mode 100644
index 0000000..17c63f8
--- /dev/null
+++ b/proofsearch/tests/test_clausifier.cc
@@ -0,0 +1,17 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
diff --git a/proofsearch/tests/test_discrim_tree.cc b/proofsearch/tests/test_discrim_tree.cc
new file mode 100644
index 0000000..17c63f8
--- /dev/null
+++ b/proofsearch/tests/test_discrim_tree.cc
@@ -0,0 +1,17 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
diff --git a/proofsearch/tests/test_end_to_end.cc b/proofsearch/tests/test_end_to_end.cc
new file mode 100644
index 0000000..ceb9991
--- /dev/null
+++ b/proofsearch/tests/test_end_to_end.cc
@@ -0,0 +1,184 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/// @file test_end_to_end.cc
+/// @brief End-to-end tests: TPTP string → parse → prove → verify result.
+
+#include "atp/core/clause_store.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/frontend/tptp_parser.h"
+#include "atp/proof/proof_trace.h"
+#include "atp/search/prover.h"
+
+#include <gtest/gtest.h>
+#include <string>
+#include <utility>
+
+namespace atp {
+namespace {
+
+/// Helper: parse a TPTP string and run the prover, return the result.
+struct E2EResult {
+ ProverResult result_;
+ std::string proof_output_;
+};
+
+E2EResult runProver(const std::string& tptp_input) {
+ SymbolTable symbols;
+ TermBank bank(symbols);
+ ClauseStore store;
+
+ auto problem = parseTptpString(tptp_input, bank, symbols);
+ auto clauses = prepareForProving(problem, bank, symbols);
+
+ Prover prover(bank, store);
+ prover.addClauses(std::move(clauses));
+ ProverResult const result = prover.prove();
+
+ std::string proof_output;
+ if (result == ProverResult::kTheorem) {
+ auto eid = prover.getEmptyClauseId();
+ if (eid.has_value()) {
+ auto proof = extractProof(store, *eid);
+ proof_output = formatProof(proof, store, bank);
+ }
+ }
+
+ return {result, proof_output};
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Simple Propositional
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, SimplePropositional) {
+ auto [result, proof] = runProver(R"(
+ cnf(fact, axiom, p(a)).
+ cnf(rule, axiom, ~p(X) | q(X)).
+ cnf(goal, negated_conjecture, ~q(a)).
+ )");
+
+ EXPECT_EQ(result, ProverResult::kTheorem);
+ EXPECT_FALSE(proof.empty());
+ // Proof should contain the key elements
+ EXPECT_NE(proof.find("p(a)"), std::string::npos) << proof;
+ EXPECT_NE(proof.find("q(a)"), std::string::npos) << proof;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Trivial: direct contradiction
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, DirectContradiction) {
+ auto [result, proof] = runProver(R"(
+ cnf(pos, axiom, p(a)).
+ cnf(neg, negated_conjecture, ~p(a)).
+ )");
+
+ EXPECT_EQ(result, ProverResult::kTheorem);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Satisfiable: no contradiction possible
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, Satisfiable) {
+ auto [result, proof] = runProver(R"(
+ cnf(a1, axiom, p(a)).
+ cnf(a2, axiom, q(b)).
+ )");
+
+ EXPECT_EQ(result, ProverResult::kSaturation);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Three-step chain
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, ThreeStepChain) {
+ auto [result, proof] = runProver(R"(
+ cnf(a1, axiom, p(a)).
+ cnf(a2, axiom, ~p(X) | q(X)).
+ cnf(a3, axiom, ~q(X) | r(X)).
+ cnf(goal, negated_conjecture, ~r(a)).
+ )");
+
+ EXPECT_EQ(result, ProverResult::kTheorem);
+ EXPECT_NE(proof.find("r(a)"), std::string::npos) << proof;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Proof output format
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, ProofOutputContainsAllSteps) {
+ auto [result, proof] = runProver(R"(
+ cnf(fact, axiom, p(a)).
+ cnf(rule, axiom, ~p(X) | q(X)).
+ cnf(goal, negated_conjecture, ~q(a)).
+ )");
+
+ ASSERT_EQ(result, ProverResult::kTheorem);
+
+ // Must contain [input] lines for axioms
+ EXPECT_NE(proof.find("[input]"), std::string::npos) << proof;
+ // Must contain [resolution] for derived clauses
+ EXPECT_NE(proof.find("[resolution]"), std::string::npos) << proof;
+ // Must contain empty clause marker
+ std::string const empty_marker = "□";
+ EXPECT_NE(proof.find(empty_marker), std::string::npos) << proof;
+ // Must contain [X, Y] provenance (sequential numbers)
+ EXPECT_NE(proof.find('['), std::string::npos) << proof;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Monkey and Banana Problem (the flagship test!)
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(EndToEnd, MonkeyBanana) {
+ auto [result, proof] = runProver(R"(
+ % Initial state
+ cnf(init_state, axiom, state(a, b, c, h0, s0)).
+
+ % Action: Go(X)
+ cnf(action_go, axiom, ~state(M, B, G, h0, S) | state(X, B, G, h0, do(go(X), S))).
+
+ % Action: Push(X)
+ cnf(action_push, axiom, ~state(M, M, G, h0, S) | state(X, X, G, h0, do(push(X), S))).
+
+ % Action: Climb
+ cnf(action_climb, axiom, ~state(M, M, G, h0, S) | state(M, M, G, h1, do(climb, S))).
+
+ % Action: Down
+ cnf(action_down, axiom, ~state(M, B, G, h1, S) | state(M, B, G, h0, do(down, S))).
+
+ % Action: Reach
+ cnf(action_reach, axiom, ~state(G, G, G, h1, S) | goal_reached(do(reach, S))).
+
+ % Negated goal
+ cnf(negated_goal, negated_conjecture, ~goal_reached(S)).
+ )");
+
+ EXPECT_EQ(result, ProverResult::kTheorem) << "ATP should prove the monkey can get the banana!\n"
+ << "Proof output:\n"
+ << proof;
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_proof_trace.cc b/proofsearch/tests/test_proof_trace.cc
new file mode 100644
index 0000000..f2c3c96
--- /dev/null
+++ b/proofsearch/tests/test_proof_trace.cc
@@ -0,0 +1,207 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/clause.h"
+#include "atp/core/clause_store.h"
+#include "atp/core/literal.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+#include "atp/proof/proof_trace.h"
+#include "atp/search/prover.h"
+
+#include <gtest/gtest.h>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace atp {
+namespace {
+
+class ProofTraceTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+ ClauseStore store_;
+
+ SymbolId sym_a_{}, sym_b_{};
+ SymbolId sym_p_{}, sym_q_{};
+ SymbolId sym_x_{};
+
+ TermId a_term_{}, b_term_{}, x_term_{};
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_p_ = symbols_.intern("P", SymbolKind::kPredicate, 1);
+ sym_q_ = symbols_.intern("Q", SymbolKind::kPredicate, 1);
+ sym_x_ = symbols_.intern("X", SymbolKind::kVariable);
+
+ a_term_ = bank_.makeTerm(sym_a_, {});
+ b_term_ = bank_.makeTerm(sym_b_, {});
+ x_term_ = bank_.makeVar(sym_x_);
+ }
+
+ TermId p(TermId arg) { return bank_.makeTerm(sym_p_, {{arg}}); }
+ TermId q(TermId arg) { return bank_.makeTerm(sym_q_, {{arg}}); }
+
+ static Literal pos(TermId atom) { return {.atom_ = atom, .is_positive_ = true}; }
+ static Literal neg(TermId atom) { return {.atom_ = atom, .is_positive_ = false}; }
+
+ static Clause makeClause(std::vector<Literal> lits) {
+ Clause cl;
+ cl.literals_ = std::move(lits);
+ return cl;
+ }
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// termToString Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProofTraceTest, TermToStringConstant) {
+ EXPECT_EQ(termToString(a_term_, bank_), "a");
+}
+
+TEST_F(ProofTraceTest, TermToStringVariable) {
+ EXPECT_EQ(termToString(x_term_, bank_), "X");
+}
+
+TEST_F(ProofTraceTest, TermToStringCompound) {
+ TermId const pa = p(a_term_);
+ EXPECT_EQ(termToString(pa, bank_), "P(a)");
+}
+
+TEST_F(ProofTraceTest, TermToStringNested) {
+ SymbolId const sym_f = symbols_.intern("f", SymbolKind::kFunction, 1);
+ TermId const fa = bank_.makeTerm(sym_f, {{a_term_}});
+ TermId const pfa = p(fa);
+ EXPECT_EQ(termToString(pfa, bank_), "P(f(a))");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// clauseToString Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProofTraceTest, ClauseToStringEmpty) {
+ Clause const empty = makeClause({});
+ EXPECT_EQ(clauseToString(empty, bank_), "□");
+}
+
+TEST_F(ProofTraceTest, ClauseToStringSinglePositive) {
+ Clause const c = makeClause({pos(p(a_term_))});
+ EXPECT_EQ(clauseToString(c, bank_), "P(a)");
+}
+
+TEST_F(ProofTraceTest, ClauseToStringSingleNegative) {
+ Clause const c = makeClause({neg(p(a_term_))});
+ EXPECT_EQ(clauseToString(c, bank_), "¬P(a)");
+}
+
+TEST_F(ProofTraceTest, ClauseToStringMultipleLiterals) {
+ Clause const c = makeClause({neg(p(x_term_)), pos(q(x_term_))});
+ std::string const s = clauseToString(c, bank_);
+ EXPECT_EQ(s, "¬P(X) ∨ Q(X)");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// extractProof Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProofTraceTest, ExtractProofTrivial) {
+ // Run a trivial proof: {P(a)} + {¬P(a)} → □
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_term_))}), makeClause({neg(p(a_term_))})});
+ ProverResult const result = prover.prove();
+ ASSERT_EQ(result, ProverResult::kTheorem);
+
+ auto eid = prover.getEmptyClauseId();
+ ASSERT_TRUE(eid.has_value());
+
+ auto proof = extractProof(store_, *eid);
+ ASSERT_GE(proof.size(), 3U); // At least: 2 axioms + 1 empty clause
+
+ // First steps should be axioms (kInput)
+ EXPECT_EQ(proof.front().rule_, InferenceRule::kInput);
+ // Last step should be the empty clause
+ EXPECT_EQ(proof.back().clause_id_, *eid);
+ // Last step should be resolution
+ EXPECT_EQ(proof.back().rule_, InferenceRule::kResolution);
+}
+
+TEST_F(ProofTraceTest, ExtractProofTwoStep) {
+ // {P(a)}, {¬P(X), Q(X)}, {¬Q(a)} → □ in 2 resolution steps
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_term_))}), makeClause({neg(p(x_term_)), pos(q(x_term_))}),
+ makeClause({neg(q(a_term_))})});
+ ProverResult const result = prover.prove();
+ ASSERT_EQ(result, ProverResult::kTheorem);
+
+ auto eid = prover.getEmptyClauseId();
+ ASSERT_TRUE(eid.has_value());
+
+ auto proof = extractProof(store_, *eid);
+ // Should have axioms + intermediate + empty clause
+ ASSERT_GE(proof.size(), 4U); // 3 axioms + at least 1 intermediate + empty
+
+ // Last step is the empty clause
+ EXPECT_EQ(proof.back().clause_id_, *eid);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// formatProof Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProofTraceTest, FormatProofContainsExpectedElements) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_term_))}), makeClause({neg(p(a_term_))})});
+ prover.prove();
+
+ auto eid = prover.getEmptyClauseId();
+ ASSERT_TRUE(eid.has_value());
+
+ auto proof = extractProof(store_, *eid);
+ std::string const output = formatProof(proof, store_, bank_);
+
+ // Should contain key elements
+ EXPECT_NE(output.find("input"), std::string::npos) << output;
+ EXPECT_NE(output.find("resolution"), std::string::npos) << output;
+ EXPECT_NE(output.find("P(a)"), std::string::npos) << output;
+ // Empty clause should appear
+ std::string const empty_marker = "□";
+ EXPECT_NE(output.find(empty_marker), std::string::npos) << output;
+}
+
+TEST_F(ProofTraceTest, FormatProofNoParentsForAxioms) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_term_))}), makeClause({neg(p(a_term_))})});
+ prover.prove();
+
+ auto eid = prover.getEmptyClauseId();
+ auto proof = extractProof(store_, *eid);
+ std::string const output = formatProof(proof, store_, bank_);
+
+ // The first line (an axiom) should NOT have "[from ...]"
+ // Find the first newline
+ auto first_line = output.substr(0, output.find('\n'));
+ EXPECT_EQ(first_line.find("[from"), std::string::npos)
+ << "Axiom should not have parent info: " << first_line;
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_prover.cc b/proofsearch/tests/test_prover.cc
new file mode 100644
index 0000000..74d02ad
--- /dev/null
+++ b/proofsearch/tests/test_prover.cc
@@ -0,0 +1,183 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/clause.h"
+#include "atp/core/clause_store.h"
+#include "atp/core/literal.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+#include "atp/search/prover.h"
+
+#include <gtest/gtest.h>
+#include <utility>
+#include <vector>
+
+namespace atp {
+namespace {
+
+class ProverTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+ ClauseStore store_;
+
+ SymbolId sym_a_{}, sym_b_{};
+ SymbolId sym_f_{};
+ SymbolId sym_p_{}, sym_q_{}, sym_r_{};
+ SymbolId sym_x_{}, sym_y_{};
+
+ TermId a_{}, b_{}, x_{}, y_{};
+
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_f_ = symbols_.intern("f", SymbolKind::kFunction, 1);
+ sym_p_ = symbols_.intern("P", SymbolKind::kPredicate, 1);
+ sym_q_ = symbols_.intern("Q", SymbolKind::kPredicate, 1);
+ sym_r_ = symbols_.intern("R", SymbolKind::kPredicate, 2);
+ sym_x_ = symbols_.intern("X", SymbolKind::kVariable);
+ sym_y_ = symbols_.intern("Y", SymbolKind::kVariable);
+
+ a_ = bank_.makeTerm(sym_a_, {});
+ b_ = bank_.makeTerm(sym_b_, {});
+ x_ = bank_.makeVar(sym_x_);
+ y_ = bank_.makeVar(sym_y_);
+ }
+
+ TermId p(TermId arg) { return bank_.makeTerm(sym_p_, {{arg}}); }
+ TermId q(TermId arg) { return bank_.makeTerm(sym_q_, {{arg}}); }
+ TermId r(TermId a1, TermId a2) { return bank_.makeTerm(sym_r_, {{a1, a2}}); }
+ TermId f(TermId arg) { return bank_.makeTerm(sym_f_, {{arg}}); }
+
+ static Literal pos(TermId atom) { return {.atom_ = atom, .is_positive_ = true}; }
+ static Literal neg(TermId atom) { return {.atom_ = atom, .is_positive_ = false}; }
+
+ static Clause makeClause(std::vector<Literal> lits) {
+ Clause cl;
+ cl.literals_ = std::move(lits);
+ return cl;
+ }
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Trivial refutation: {P(a)} and {¬P(a)} → empty clause
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, TrivialRefutation) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_))}), makeClause({neg(p(a_))})});
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTheorem);
+ EXPECT_TRUE(prover.getEmptyClauseId().has_value());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Unification required: {P(X)} and {¬P(a)} → empty clause
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, RefutationWithUnification) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(x_))}), makeClause({neg(p(a_))})});
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTheorem);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Multi-step: {P(a)}, {¬P(X), Q(X)}, {¬Q(a)}
+// Step 1: resolve P(a) with ¬P(X)∨Q(X) → Q(a)
+// Step 2: resolve Q(a) with ¬Q(a) → empty
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, TwoStepRefutation) {
+ Prover prover(bank_, store_);
+ prover.addClauses(
+ {makeClause({pos(p(a_))}), makeClause({neg(p(x_)), pos(q(x_))}), makeClause({neg(q(a_))})});
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTheorem);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Saturation: {P(a)}, {Q(b)} — no complementary literals
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, Saturation) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({pos(p(a_))}), makeClause({pos(q(b_))})});
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kSaturation);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Timeout: set very low limits
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, Timeout) {
+ ProverConfig config;
+ config.max_iterations_ = 1;
+
+ // Need enough clauses to not saturate in 1 iteration
+ Prover prover(bank_, store_, config);
+ prover.addClauses({makeClause({pos(p(x_)), pos(q(x_))}), makeClause({neg(p(a_)), pos(q(a_))}),
+ makeClause({neg(q(x_)), pos(p(f(x_)))}), makeClause({neg(p(x_)), neg(q(x_))})});
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTimeout);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Chain reasoning: P(a), ¬P(X)∨Q(X), ¬Q(X)∨R(X), ¬R(a)
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, ThreeStepChain) {
+ const SymbolId kSymS = symbols_.intern("S", SymbolKind::kPredicate, 1);
+ auto s = [&](TermId arg) { return bank_.makeTerm(kSymS, {{arg}}); };
+
+ Prover prover(bank_, store_);
+ prover.addClauses({
+ makeClause({pos(p(a_))}), // P(a)
+ makeClause({neg(p(x_)), pos(q(x_))}), // ¬P(X) ∨ Q(X)
+ makeClause({neg(q(x_)), pos(s(x_))}), // ¬Q(X) ∨ S(X)
+ makeClause({neg(s(a_))}) // ¬S(a)
+ });
+
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTheorem);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Empty clause in input directly
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ProverTest, EmptyClauseInInput) {
+ Prover prover(bank_, store_);
+ prover.addClauses({makeClause({}), // empty clause directly
+ makeClause({pos(p(a_))})});
+
+ // Empty clause should be detected immediately when selected from queue
+ const ProverResult kResult = prover.prove();
+ EXPECT_EQ(kResult, ProverResult::kTheorem);
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_resolution.cc b/proofsearch/tests/test_resolution.cc
new file mode 100644
index 0000000..02250d9
--- /dev/null
+++ b/proofsearch/tests/test_resolution.cc
@@ -0,0 +1,257 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/clause.h"
+#include "atp/core/literal.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+#include "atp/infer/resolution.h"
+
+#include <gtest/gtest.h>
+#include <utility>
+#include <vector>
+
+namespace atp {
+namespace {
+
+class ResolutionTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+
+ SymbolId sym_a_{}, sym_b_{}, sym_c_{};
+ SymbolId sym_f_{};
+ SymbolId sym_p_{}, sym_q_{}, sym_r_{};
+ SymbolId sym_x_{}, sym_y_{}, sym_z_{};
+
+ TermId a_{}, b_{}, c_{};
+ TermId x_{}, y_{}, z_{};
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_c_ = symbols_.intern("c", SymbolKind::kConstant);
+ sym_f_ = symbols_.intern("f", SymbolKind::kFunction, 1);
+ sym_p_ = symbols_.intern("P", SymbolKind::kPredicate, 1);
+ sym_q_ = symbols_.intern("Q", SymbolKind::kPredicate, 1);
+ sym_r_ = symbols_.intern("R", SymbolKind::kPredicate, 2);
+ sym_x_ = symbols_.intern("X", SymbolKind::kVariable);
+ sym_y_ = symbols_.intern("Y", SymbolKind::kVariable);
+ sym_z_ = symbols_.intern("Z", SymbolKind::kVariable);
+
+ a_ = bank_.makeTerm(sym_a_, {});
+ b_ = bank_.makeTerm(sym_b_, {});
+ c_ = bank_.makeTerm(sym_c_, {});
+ x_ = bank_.makeVar(sym_x_);
+ y_ = bank_.makeVar(sym_y_);
+ z_ = bank_.makeVar(sym_z_);
+ }
+
+ TermId p(TermId arg) { return bank_.makeTerm(sym_p_, {{arg}}); }
+ TermId q(TermId arg) { return bank_.makeTerm(sym_q_, {{arg}}); }
+ TermId r(TermId a1, TermId a2) { return bank_.makeTerm(sym_r_, {{a1, a2}}); }
+ TermId f(TermId arg) { return bank_.makeTerm(sym_f_, {{arg}}); }
+
+ // Helper: make a clause from literals
+ static Clause makeClause(std::vector<Literal> lits, ClauseId id = kInvalidId) {
+ Clause c;
+ c.id_ = id;
+ c.literals_ = std::move(lits);
+ return c;
+ }
+
+ static Literal pos(TermId atom) { return {.atom_ = atom, .is_positive_ = true}; }
+ static Literal neg(TermId atom) { return {.atom_ = atom, .is_positive_ = false}; }
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Basic Resolution Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ResolutionTest, SimpleGroundResolution) {
+ // {P(a)} and {¬P(a), Q(b)} → {Q(b)}
+ Clause const c1 = makeClause({pos(p(a_))}, 0);
+ Clause const c2 = makeClause({neg(p(a_)), pos(q(b_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result->literals_.size(), 1);
+ EXPECT_EQ(result->literals_[0].atom_, q(b_));
+ EXPECT_TRUE(result->literals_[0].is_positive_);
+}
+
+TEST_F(ResolutionTest, ResolutionWithUnification) {
+ // {P(X)} and {¬P(a)} → {} (empty clause!)
+ Clause const c1 = makeClause({pos(p(x_))}, 0);
+ Clause const c2 = makeClause({neg(p(a_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_TRUE(result->isEmpty()) << "Should derive empty clause (contradiction)";
+}
+
+TEST_F(ResolutionTest, ResolutionFailsSamePolarity) {
+ // {P(a)} and {P(b)} → no resolution (both positive)
+ Clause const c1 = makeClause({pos(p(a_))}, 0);
+ Clause const c2 = makeClause({pos(p(b_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ EXPECT_FALSE(result.has_value());
+}
+
+TEST_F(ResolutionTest, ResolutionFailsNoUnifier) {
+ // {P(a)} and {¬P(b)} → can't unify a with b
+ Clause const c1 = makeClause({pos(p(a_))}, 0);
+ Clause const c2 = makeClause({neg(p(b_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ EXPECT_FALSE(result.has_value());
+}
+
+TEST_F(ResolutionTest, ResolutionAppliesSubstToRemainingLiterals) {
+ // {P(X), Q(X)} and {¬P(a)} → {Q(a)}
+ Clause const c1 = makeClause({pos(p(x_)), pos(q(x_))}, 0);
+ Clause const c2 = makeClause({neg(p(a_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result->literals_.size(), 1);
+ EXPECT_EQ(result->literals_[0].atom_, q(a_));
+}
+
+TEST_F(ResolutionTest, ResolutionMultipleLiterals) {
+ // {P(X), Q(b)} and {¬P(a), R(a, c)} → {Q(b), R(a, c)}
+ Clause const c1 = makeClause({pos(p(x_)), pos(q(b_))}, 0);
+ Clause const c2 = makeClause({neg(p(a_)), pos(r(a_, c_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result->literals_.size(), 2);
+}
+
+TEST_F(ResolutionTest, VariableRenamingWorks) {
+ // Both clauses use variable X
+ // {P(X)} and {¬P(X), Q(X)} → {Q(a)} only if X is renamed in c2
+ Clause const c1 = makeClause({pos(p(a_))}, 0);
+ Clause const c2 = makeClause({neg(p(x_)), pos(q(x_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result->literals_.size(), 1);
+ // Q(X) should become Q(a) after substitution {X→a}
+ EXPECT_EQ(result->literals_[0].atom_, q(a_));
+}
+
+TEST_F(ResolutionTest, VariableRenamingPreventsClash) {
+ // {R(X, a)} and {¬R(b, X)}
+ // Without renaming: X would clash between c1 and c2
+ // With renaming: c2's X → _R0, then unify R(X,a) with R(b,_R0) → {X→b, _R0→a}
+ Clause const c1 = makeClause({pos(r(x_, a_))}, 0);
+ Clause const c2 = makeClause({neg(r(b_, x_))}, 1);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_TRUE(result->isEmpty());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// AllResolvents Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ResolutionTest, AllResolventsFindsAll) {
+ // {P(a), Q(a)} and {¬P(a), ¬Q(a)}
+ // Can resolve on P or Q → 2 resolvents
+ Clause const c1 = makeClause({pos(p(a_)), pos(q(a_))}, 0);
+ Clause const c2 = makeClause({neg(p(a_)), neg(q(a_))}, 1);
+
+ auto results = allResolvents(bank_, c1, c2);
+ EXPECT_EQ(results.size(), 2);
+}
+
+TEST_F(ResolutionTest, AllResolventsEmpty) {
+ // {P(a)} and {Q(b)} → no complementary pairs
+ Clause const c1 = makeClause({pos(p(a_))}, 0);
+ Clause const c2 = makeClause({pos(q(b_))}, 1);
+
+ auto results = allResolvents(bank_, c1, c2);
+ EXPECT_TRUE(results.empty());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Factoring Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ResolutionTest, SimpleFactoring) {
+ // {P(X), P(a), Q(X)} → factor P(X) with P(a): {P(a), Q(a)}
+ Clause const c = makeClause({pos(p(x_)), pos(p(a_)), pos(q(x_))}, 0);
+
+ auto results = factor(bank_, c);
+ ASSERT_EQ(results.size(), 1);
+ EXPECT_EQ(results[0].literals_.size(), 2);
+ // After factoring: {P(a), Q(a)}
+ EXPECT_EQ(results[0].literals_[0].atom_, p(a_));
+ EXPECT_EQ(results[0].literals_[1].atom_, q(a_));
+}
+
+TEST_F(ResolutionTest, NoFactoring) {
+ // {P(a), Q(b)} → different predicates, can't factor
+ Clause const c = makeClause({pos(p(a_)), pos(q(b_))}, 0);
+ auto results = factor(bank_, c);
+ EXPECT_TRUE(results.empty());
+}
+
+TEST_F(ResolutionTest, FactoringDifferentPolarity) {
+ // {P(a), ¬P(a)} → different polarity, can't factor
+ Clause const c = makeClause({pos(p(a_)), neg(p(a_))}, 0);
+ auto results = factor(bank_, c);
+ EXPECT_TRUE(results.empty());
+}
+
+TEST_F(ResolutionTest, FactoringNonUnifiable) {
+ // {P(a), P(b)} → can't unify a with b
+ Clause const c = makeClause({pos(p(a_)), pos(p(b_))}, 0);
+ auto results = factor(bank_, c);
+ EXPECT_TRUE(results.empty());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Provenance Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(ResolutionTest, ResolventHasProvenance) {
+ Clause const c1 = makeClause({pos(p(x_))}, 5);
+ Clause const c2 = makeClause({neg(p(a_))}, 7);
+
+ auto result = resolve(bank_, c1, 0, c2, 0);
+ ASSERT_TRUE(result.has_value());
+ EXPECT_EQ(result->rule_, InferenceRule::kResolution);
+ EXPECT_EQ(result->parent1_, 5);
+ EXPECT_EQ(result->parent2_, 7);
+}
+
+TEST_F(ResolutionTest, FactoredHasProvenance) {
+ Clause const c = makeClause({pos(p(x_)), pos(p(a_))}, 3);
+ auto results = factor(bank_, c);
+ ASSERT_FALSE(results.empty());
+ EXPECT_EQ(results[0].rule_, InferenceRule::kFactoring);
+ EXPECT_EQ(results[0].parent1_, 3);
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_simplify.cc b/proofsearch/tests/test_simplify.cc
new file mode 100644
index 0000000..968ab3b
--- /dev/null
+++ b/proofsearch/tests/test_simplify.cc
@@ -0,0 +1,191 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/clause.h"
+#include "atp/core/literal.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+#include "atp/simplify/subsumption.h"
+#include "atp/simplify/tautology.h"
+
+#include <gtest/gtest.h>
+#include <utility>
+#include <vector>
+
+namespace atp {
+namespace {
+
+class SimplifyTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+
+ SymbolId sym_a_{}, sym_b_{}, sym_c_{};
+ SymbolId sym_p_{}, sym_q_{}, sym_r_{};
+ SymbolId sym_x_{};
+
+ TermId a_{}, b_{}, c_{}, x_{};
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_c_ = symbols_.intern("c", SymbolKind::kConstant);
+ sym_p_ = symbols_.intern("P", SymbolKind::kPredicate, 1);
+ sym_q_ = symbols_.intern("Q", SymbolKind::kPredicate, 1);
+ sym_r_ = symbols_.intern("R", SymbolKind::kPredicate, 2);
+ sym_x_ = symbols_.intern("X", SymbolKind::kVariable);
+
+ a_ = bank_.makeTerm(sym_a_, {});
+ b_ = bank_.makeTerm(sym_b_, {});
+ c_ = bank_.makeTerm(sym_c_, {});
+ x_ = bank_.makeVar(sym_x_);
+ }
+
+ TermId p(TermId arg) { return bank_.makeTerm(sym_p_, {{arg}}); }
+ TermId q(TermId arg) { return bank_.makeTerm(sym_q_, {{arg}}); }
+ TermId r(TermId a1, TermId a2) { return bank_.makeTerm(sym_r_, {{a1, a2}}); }
+
+ static Literal pos(TermId atom) { return {.atom_ = atom, .is_positive_ = true}; }
+ static Literal neg(TermId atom) { return {.atom_ = atom, .is_positive_ = false}; }
+
+ static Clause makeClause(std::vector<Literal> lits) {
+ Clause cl;
+ cl.literals_ = std::move(lits);
+ return cl;
+ }
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Tautology Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(SimplifyTest, TautologyComplementaryLiterals) {
+ // {P(a), ¬P(a)} → tautology
+ Clause const cl = makeClause({pos(p(a_)), neg(p(a_))});
+ EXPECT_TRUE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, TautologyWithExtraLiterals) {
+ // {Q(b), P(a), ¬P(a)} → tautology
+ Clause const cl = makeClause({pos(q(b_)), pos(p(a_)), neg(p(a_))});
+ EXPECT_TRUE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologySamePolarity) {
+ // {P(a), P(a)} → NOT tautology (same polarity)
+ Clause const cl = makeClause({pos(p(a_)), pos(p(a_))});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologyDifferentAtoms) {
+ // {P(a), ¬P(b)} → NOT tautology (different atoms)
+ Clause const cl = makeClause({pos(p(a_)), neg(p(b_))});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologyDifferentPredicates) {
+ // {P(a), ¬Q(a)} → NOT tautology (different predicates)
+ Clause const cl = makeClause({pos(p(a_)), neg(q(a_))});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologyEmptyClause) {
+ Clause const cl = makeClause({});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologySingleLiteral) {
+ Clause const cl = makeClause({pos(p(a_))});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, TautologyVariableAtom) {
+ // {P(X), ¬P(X)} → tautology (same variable term)
+ Clause const cl = makeClause({pos(p(x_)), neg(p(x_))});
+ EXPECT_TRUE(isTautology(cl));
+}
+
+TEST_F(SimplifyTest, NotTautologyVarVsConst) {
+ // {P(X), ¬P(a)} → NOT tautology (syntactically different)
+ Clause const cl = makeClause({pos(p(x_)), neg(p(a_))});
+ EXPECT_FALSE(isTautology(cl));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Subsumption Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(SimplifyTest, SubsumesSubset) {
+ // {P(a)} subsumes {P(a), Q(b)}
+ Clause const gen = makeClause({pos(p(a_))});
+ Clause const spec = makeClause({pos(p(a_)), pos(q(b_))});
+ EXPECT_TRUE(subsumes(gen, spec));
+}
+
+TEST_F(SimplifyTest, SubsumesIdentical) {
+ // {P(a), Q(b)} subsumes {P(a), Q(b)}
+ Clause const c1 = makeClause({pos(p(a_)), pos(q(b_))});
+ Clause const c2 = makeClause({pos(p(a_)), pos(q(b_))});
+ EXPECT_TRUE(subsumes(c1, c2));
+}
+
+TEST_F(SimplifyTest, SubsumesEmptyClause) {
+ // {} subsumes everything
+ Clause const empty = makeClause({});
+ Clause const cl = makeClause({pos(p(a_)), neg(q(b_))});
+ EXPECT_TRUE(subsumes(empty, cl));
+}
+
+TEST_F(SimplifyTest, SubsumesNotLarger) {
+ // {P(a), Q(b)} does NOT subsume {P(a)}
+ Clause const gen = makeClause({pos(p(a_)), pos(q(b_))});
+ Clause const spec = makeClause({pos(p(a_))});
+ EXPECT_FALSE(subsumes(gen, spec));
+}
+
+TEST_F(SimplifyTest, SubsumesRequiresSamePolarity) {
+ // {P(a)} does NOT subsume {¬P(a)}
+ Clause const gen = makeClause({pos(p(a_))});
+ Clause const spec = makeClause({neg(p(a_))});
+ EXPECT_FALSE(subsumes(gen, spec));
+}
+
+TEST_F(SimplifyTest, SubsumesRequiresSameAtom) {
+ // {P(a)} does NOT subsume {P(b)}
+ Clause const gen = makeClause({pos(p(a_))});
+ Clause const spec = makeClause({pos(p(b_))});
+ EXPECT_FALSE(subsumes(gen, spec));
+}
+
+TEST_F(SimplifyTest, SubsumesNegativeLiterals) {
+ // {¬P(a)} subsumes {¬P(a), Q(b)}
+ Clause const gen = makeClause({neg(p(a_))});
+ Clause const spec = makeClause({neg(p(a_)), pos(q(b_))});
+ EXPECT_TRUE(subsumes(gen, spec));
+}
+
+TEST_F(SimplifyTest, SubsumesOrderIndependent) {
+ // {Q(b), P(a)} subsumes {P(a), Q(b), R(a,b)} regardless of order
+ Clause const gen = makeClause({pos(q(b_)), pos(p(a_))});
+ Clause const spec = makeClause({pos(p(a_)), pos(q(b_)), pos(r(a_, b_))});
+ EXPECT_TRUE(subsumes(gen, spec));
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_term_bank.cc b/proofsearch/tests/test_term_bank.cc
new file mode 100644
index 0000000..0d4fcc7
--- /dev/null
+++ b/proofsearch/tests/test_term_bank.cc
@@ -0,0 +1,339 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/clause.h"
+#include "atp/core/clause_store.h"
+#include "atp/core/literal.h"
+#include "atp/core/symbol_table.h"
+#include "atp/core/term.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+
+#include <cstddef>
+#include <gtest/gtest.h>
+#include <span>
+#include <stdexcept>
+#include <utility>
+
+namespace atp {
+namespace {
+
+// ═══════════════════════════════════════════════════════════════════════════
+// SymbolTable Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(SymbolTableTest, InternReturnsConsistentId) {
+ SymbolTable st;
+ SymbolId const id1 = st.intern("foo", SymbolKind::kFunction, 2);
+ SymbolId const id2 = st.intern("foo", SymbolKind::kFunction, 2);
+ EXPECT_EQ(id1, id2);
+}
+
+TEST(SymbolTableTest, DifferentNamesGetDifferentIds) {
+ SymbolTable st;
+ SymbolId const a = st.intern("a", SymbolKind::kConstant);
+ SymbolId const b = st.intern("b", SymbolKind::kConstant);
+ EXPECT_NE(a, b);
+}
+
+TEST(SymbolTableTest, GetNameRoundTrips) {
+ SymbolTable st;
+ SymbolId const id = st.intern("hello", SymbolKind::kFunction, 1);
+ EXPECT_EQ(st.getName(id), "hello");
+}
+
+TEST(SymbolTableTest, GetInfoPreservesMetadata) {
+ SymbolTable st;
+ SymbolId const id = st.intern("P", SymbolKind::kPredicate, 3, 42);
+ const SymbolInfo& info = st.getInfo(id);
+ EXPECT_EQ(info.name_, "P");
+ EXPECT_EQ(info.kind_, SymbolKind::kPredicate);
+ EXPECT_EQ(info.arity_, 3);
+ EXPECT_EQ(info.sort_, 42);
+}
+
+TEST(SymbolTableTest, IsVariableDetectsKind) {
+ SymbolTable st;
+ SymbolId const var = st.intern("X", SymbolKind::kVariable);
+ SymbolId const con = st.intern("a", SymbolKind::kConstant);
+ SymbolId const fun = st.intern("f", SymbolKind::kFunction, 1);
+ EXPECT_TRUE(st.isVariable(var));
+ EXPECT_FALSE(st.isVariable(con));
+ EXPECT_FALSE(st.isVariable(fun));
+}
+
+TEST(SymbolTableTest, MismatchKindThrows) {
+ SymbolTable st;
+ st.intern("x", SymbolKind::kConstant);
+ EXPECT_THROW(st.intern("x", SymbolKind::kVariable), std::runtime_error);
+}
+
+TEST(SymbolTableTest, MismatchArityThrows) {
+ SymbolTable st;
+ st.intern("f", SymbolKind::kFunction, 1);
+ EXPECT_THROW(st.intern("f", SymbolKind::kFunction, 2), std::runtime_error);
+}
+
+TEST(SymbolTableTest, SizeTracksInternedSymbols) {
+ SymbolTable st;
+ EXPECT_EQ(st.size(), 0);
+ st.intern("a", SymbolKind::kConstant);
+ EXPECT_EQ(st.size(), 1);
+ st.intern("b", SymbolKind::kConstant);
+ EXPECT_EQ(st.size(), 2);
+ st.intern("a", SymbolKind::kConstant); // duplicate
+ EXPECT_EQ(st.size(), 2);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// TermBank Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+class TermBankTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+
+ SymbolId sym_a_{}, sym_b_{}, sym_f_{}, sym_g_{}, sym_x_id_{}, sym_y_id_{};
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_f_ = symbols_.intern("f", SymbolKind::kFunction, 1);
+ sym_g_ = symbols_.intern("g", SymbolKind::kFunction, 2);
+ sym_x_id_ = symbols_.intern("X", SymbolKind::kVariable);
+ sym_y_id_ = symbols_.intern("Y", SymbolKind::kVariable);
+ }
+};
+
+TEST_F(TermBankTest, HashConsingReturnsSameId) {
+ const TermId kA1 = bank_.makeTerm(sym_a_, {});
+ const TermId kA2 = bank_.makeTerm(sym_a_, {});
+ EXPECT_EQ(kA1, kA2) << "Hash consing failed: same term got different IDs";
+}
+
+TEST_F(TermBankTest, DifferentTermsGetDifferentIds) {
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ const TermId kB = bank_.makeTerm(sym_b_, {});
+ EXPECT_NE(kA, kB);
+}
+
+TEST_F(TermBankTest, CompoundTermHashConsing) {
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ const TermId kB = bank_.makeTerm(sym_b_, {});
+ // f(a)
+ const TermId kFA1 = bank_.makeTerm(sym_f_, {{kA}});
+ const TermId kFA2 = bank_.makeTerm(sym_f_, {{kA}});
+ EXPECT_EQ(kFA1, kFA2);
+ // f(b) != f(a)
+ const TermId kFB = bank_.makeTerm(sym_f_, {{kB}});
+ EXPECT_NE(kFA1, kFB);
+}
+
+TEST_F(TermBankTest, NestedTermHashConsing) {
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ const TermId kFA = bank_.makeTerm(sym_f_, {{kA}});
+ // g(f(a), a)
+ const TermId kGFAA1 = bank_.makeTerm(sym_g_, {{kFA, kA}});
+ const TermId kGFAA2 = bank_.makeTerm(sym_g_, {{kFA, kA}});
+ EXPECT_EQ(kGFAA1, kGFAA2);
+}
+
+TEST_F(TermBankTest, GetTermReturnsCorrectSymbol) {
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ const Term& t = bank_.getTerm(kA);
+ EXPECT_EQ(t.symbol_id_, sym_a_);
+ EXPECT_EQ(t.arity(), 0);
+}
+
+TEST_F(TermBankTest, GetTermReturnsCorrectArgs) {
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ const TermId kB = bank_.makeTerm(sym_b_, {});
+ const TermId kGAB = bank_.makeTerm(sym_g_, {{kA, kB}});
+
+ const Term& t = bank_.getTerm(kGAB);
+ EXPECT_EQ(t.symbol_id_, sym_g_);
+ EXPECT_EQ(t.arity(), 2);
+ ASSERT_EQ(t.args_.size(), 2);
+ EXPECT_EQ(t.args_[0], kA);
+ EXPECT_EQ(t.args_[1], kB);
+}
+
+TEST_F(TermBankTest, MakeVarCreatesZeroArityTerm) {
+ const TermId kX = bank_.makeVar(sym_x_id_);
+ const Term& t = bank_.getTerm(kX);
+ EXPECT_EQ(t.symbol_id_, sym_x_id_);
+ EXPECT_EQ(t.arity(), 0);
+ EXPECT_TRUE(t.args_.empty());
+}
+
+TEST_F(TermBankTest, IsVariableDistinguishesFromConstants) {
+ const TermId kX = bank_.makeVar(sym_x_id_);
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+
+ EXPECT_TRUE(bank_.isVariable(kX)) << "Variable should be detected as variable";
+ EXPECT_FALSE(bank_.isVariable(kA)) << "Constant should NOT be detected as variable";
+}
+
+TEST_F(TermBankTest, VariableHashConsing) {
+ const TermId kX1 = bank_.makeVar(sym_x_id_);
+ const TermId kX2 = bank_.makeVar(sym_x_id_);
+ EXPECT_EQ(kX1, kX2);
+
+ const TermId kY = bank_.makeVar(sym_y_id_);
+ EXPECT_NE(kX1, kY);
+}
+
+TEST_F(TermBankTest, SizeTracksUniqueTerms) {
+ EXPECT_EQ(bank_.size(), 0);
+ const TermId kA = bank_.makeTerm(sym_a_, {});
+ EXPECT_EQ(bank_.size(), 1);
+ bank_.makeTerm(sym_a_, {}); // duplicate
+ EXPECT_EQ(bank_.size(), 1);
+ bank_.makeTerm(sym_b_, {});
+ EXPECT_EQ(bank_.size(), 2);
+ bank_.makeTerm(sym_f_, {{kA}});
+ EXPECT_EQ(bank_.size(), 3);
+}
+
+TEST_F(TermBankTest, CompoundTermWithVariableArgs) {
+ // f(X) — a function applied to a variable
+ const TermId kX = bank_.makeVar(sym_x_id_);
+ const TermId kFX = bank_.makeTerm(sym_f_, {{kX}});
+
+ const Term& t = bank_.getTerm(kFX);
+ EXPECT_EQ(t.symbol_id_, sym_f_);
+ EXPECT_EQ(t.arity(), 1);
+ EXPECT_EQ(t.args_[0], kX);
+
+ EXPECT_FALSE(bank_.isVariable(kFX));
+ EXPECT_TRUE(bank_.isVariable(kX));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// ClauseStore Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(ClauseStoreTest, AddAndRetrieve) {
+ ClauseStore store;
+ Clause c;
+ c.literals_.push_back(Literal{.atom_ = 0, .is_positive_ = true});
+ c.literals_.push_back(Literal{.atom_ = 1, .is_positive_ = false});
+
+ const ClauseId kId = store.addClause(std::move(c));
+ EXPECT_EQ(kId, 0);
+
+ const Clause& retrieved = store.getClause(kId);
+ EXPECT_EQ(retrieved.id_, kId);
+ EXPECT_EQ(retrieved.literals_.size(), 2);
+ EXPECT_EQ(retrieved.literals_[0].atom_, 0);
+ EXPECT_TRUE(retrieved.literals_[0].is_positive_);
+ EXPECT_EQ(retrieved.literals_[1].atom_, 1);
+ EXPECT_FALSE(retrieved.literals_[1].is_positive_);
+}
+
+TEST(ClauseStoreTest, AssignsSequentialIds) {
+ ClauseStore store;
+ const ClauseId kId0 = store.addClause(Clause{});
+ const ClauseId kId1 = store.addClause(Clause{});
+ const ClauseId kId2 = store.addClause(Clause{});
+ EXPECT_EQ(kId0, 0);
+ EXPECT_EQ(kId1, 1);
+ EXPECT_EQ(kId2, 2);
+ EXPECT_EQ(store.size(), 3);
+}
+
+TEST(ClauseStoreTest, StampsClauseWithId) {
+ ClauseStore store;
+ Clause c;
+ c.id_ = kInvalidId; // unset
+ const ClauseId kId = store.addClause(std::move(c));
+ EXPECT_EQ(store.getClause(kId).id_, kId);
+}
+
+TEST(ClauseStoreTest, MutableAccess) {
+ ClauseStore store;
+ Clause c;
+ c.depth_ = 0;
+ const ClauseId kId = store.addClause(std::move(c));
+
+ store.getMutableClause(kId).depth_ = 42;
+ EXPECT_EQ(store.getClause(kId).depth_, 42);
+}
+
+TEST(ClauseStoreTest, ForEachVisitsAll) {
+ ClauseStore store;
+ store.addClause(Clause{});
+ store.addClause(Clause{});
+ store.addClause(Clause{});
+
+ size_t count = 0;
+ store.forEach([&count](const Clause&) { count++; });
+ EXPECT_EQ(count, 3);
+}
+
+TEST(ClauseStoreTest, EmptyClauseDetection) {
+ Clause c;
+ EXPECT_TRUE(c.isEmpty());
+ c.literals_.push_back(Literal{.atom_ = 0, .is_positive_ = true});
+ EXPECT_FALSE(c.isEmpty());
+ EXPECT_EQ(c.size(), 1);
+}
+
+TEST(ClauseStoreTest, ProvenancePreserved) {
+ ClauseStore store;
+ Clause c;
+ c.rule_ = InferenceRule::kResolution;
+ c.parent1_ = 3;
+ c.parent2_ = 7;
+ c.depth_ = 5;
+
+ ClauseId const id = store.addClause(std::move(c));
+ const Clause& retrieved = store.getClause(id);
+ EXPECT_EQ(retrieved.rule_, InferenceRule::kResolution);
+ EXPECT_EQ(retrieved.parent1_, 3);
+ EXPECT_EQ(retrieved.parent2_, 7);
+ EXPECT_EQ(retrieved.depth_, 5);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Literal Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST(LiteralTest, Complement) {
+ const Literal kPos{.atom_ = 42, .is_positive_ = true};
+ const Literal kNeg = kPos.complement();
+ EXPECT_EQ(kNeg.atom_, 42);
+ EXPECT_FALSE(kNeg.is_positive_);
+
+ const Literal kBack = kNeg.complement();
+ EXPECT_EQ(kBack, kPos);
+}
+
+TEST(LiteralTest, Equality) {
+ const Literal kA{.atom_ = 1, .is_positive_ = true};
+ const Literal kB{.atom_ = 1, .is_positive_ = true};
+ const Literal kC{.atom_ = 1, .is_positive_ = false};
+ const Literal kD{.atom_ = 2, .is_positive_ = true};
+ EXPECT_EQ(kA, kB);
+ EXPECT_NE(kA, kC);
+ EXPECT_NE(kA, kD);
+}
+
+} // namespace
+} // namespace atp
diff --git a/proofsearch/tests/test_unification.cc b/proofsearch/tests/test_unification.cc
new file mode 100644
index 0000000..63ac9f0
--- /dev/null
+++ b/proofsearch/tests/test_unification.cc
@@ -0,0 +1,355 @@
+/*
+ * richard-atp-engine: A saturation-based automated theorem prover for first-order logic.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "atp/core/symbol_table.h"
+#include "atp/core/term_bank.h"
+#include "atp/core/types.h"
+#include "atp/infer/substitution.h"
+#include "atp/infer/unification.h"
+
+#include <gtest/gtest.h>
+
+namespace atp {
+namespace {
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Test Fixture
+// ═══════════════════════════════════════════════════════════════════════════
+
+class UnificationTest : public ::testing::Test {
+ protected:
+ SymbolTable symbols_;
+ TermBank bank_{symbols_};
+
+ // Symbols
+ SymbolId sym_a_{}, sym_b_{}, sym_c_{};
+ SymbolId sym_f_{}, sym_g_{}, sym_h_{};
+ SymbolId sym_p_{};
+ SymbolId sym_x_{}, sym_y_{}, sym_z_{};
+
+ // Terms (created in SetUp)
+ TermId a_{}, b_{}, c_{};
+ TermId x_{}, y_{}, z_{};
+
+ void SetUp() override {
+ sym_a_ = symbols_.intern("a", SymbolKind::kConstant);
+ sym_b_ = symbols_.intern("b", SymbolKind::kConstant);
+ sym_c_ = symbols_.intern("c", SymbolKind::kConstant);
+ sym_f_ = symbols_.intern("f", SymbolKind::kFunction, 1);
+ sym_g_ = symbols_.intern("g", SymbolKind::kFunction, 2);
+ sym_h_ = symbols_.intern("h", SymbolKind::kFunction, 1);
+ sym_p_ = symbols_.intern("P", SymbolKind::kPredicate, 2);
+ sym_x_ = symbols_.intern("X", SymbolKind::kVariable);
+ sym_y_ = symbols_.intern("Y", SymbolKind::kVariable);
+ sym_z_ = symbols_.intern("Z", SymbolKind::kVariable);
+
+ a_ = bank_.makeTerm(sym_a_, {});
+ b_ = bank_.makeTerm(sym_b_, {});
+ c_ = bank_.makeTerm(sym_c_, {});
+ x_ = bank_.makeVar(sym_x_);
+ y_ = bank_.makeVar(sym_y_);
+ z_ = bank_.makeVar(sym_z_);
+ }
+
+ // Helper: make f(arg)
+ TermId f(TermId arg) { return bank_.makeTerm(sym_f_, {{arg}}); }
+ // Helper: make g(arg1, arg2)
+ TermId g(TermId a1, TermId a2) { return bank_.makeTerm(sym_g_, {{a1, a2}}); }
+ // Helper: make h(arg)
+ TermId h(TermId arg) { return bank_.makeTerm(sym_h_, {{arg}}); }
+ // Helper: make P(arg1, arg2)
+ TermId p(TermId a1, TermId a2) { return bank_.makeTerm(sym_p_, {{a1, a2}}); }
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Substitution Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(UnificationTest, SubstBindAndLookup) {
+ Substitution subst;
+ EXPECT_TRUE(subst.bind(x_, a_));
+ EXPECT_EQ(subst.lookup(x_), a_);
+ EXPECT_EQ(subst.lookup(y_), kInvalidId); // unbound
+}
+
+TEST_F(UnificationTest, SubstBindSameTermSucceeds) {
+ Substitution subst;
+ EXPECT_TRUE(subst.bind(x_, a_));
+ EXPECT_TRUE(subst.bind(x_, a_)); // same binding → OK
+}
+
+TEST_F(UnificationTest, SubstBindDifferentTermFails) {
+ Substitution subst;
+ EXPECT_TRUE(subst.bind(x_, a_));
+ EXPECT_FALSE(subst.bind(x_, b_)); // different binding → fail
+}
+
+TEST_F(UnificationTest, SubstResolveChain) {
+ // X → Y → a
+ Substitution subst;
+ subst.bind(x_, y_);
+ subst.bind(y_, a_);
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, SubstResolveUnbound) {
+ Substitution const subst;
+ EXPECT_EQ(subst.resolve(x_, bank_), x_); // unbound → returns itself
+}
+
+TEST_F(UnificationTest, SubstResolveNonVariable) {
+ Substitution const subst;
+ EXPECT_EQ(subst.resolve(a_, bank_), a_); // constant → returns itself
+}
+
+TEST_F(UnificationTest, SubstApplySimple) {
+ // σ = {X → a} apply to f(X) → f(a)
+ Substitution subst;
+ subst.bind(x_, a_);
+ TermId const fx = f(x_);
+ TermId const result = subst.apply(fx, bank_);
+ TermId const expected = f(a_);
+ EXPECT_EQ(result, expected);
+}
+
+TEST_F(UnificationTest, SubstApplyLeavesUnbound) {
+ // σ = {X → a} apply to g(X, Y) → g(a, Y)
+ Substitution subst;
+ subst.bind(x_, a_);
+ TermId const gxy = g(x_, y_);
+ TermId const result = subst.apply(gxy, bank_);
+ TermId const expected = g(a_, y_);
+ EXPECT_EQ(result, expected);
+}
+
+TEST_F(UnificationTest, SubstApplyNested) {
+ // σ = {X → f(a)} apply to g(X, b) → g(f(a), b)
+ Substitution subst;
+ subst.bind(x_, f(a_));
+ TermId const result = subst.apply(g(x_, b_), bank_);
+ EXPECT_EQ(result, g(f(a_), b_));
+}
+
+TEST_F(UnificationTest, SubstOccursIn) {
+ Substitution const subst;
+ // X occurs in f(X)
+ EXPECT_TRUE(subst.occursIn(x_, f(x_), bank_));
+ // X occurs in g(a, f(X))
+ EXPECT_TRUE(subst.occursIn(x_, g(a_, f(x_)), bank_));
+ // X does NOT occur in f(a)
+ EXPECT_FALSE(subst.occursIn(x_, f(a_), bank_));
+ // X does NOT occur in Y (different variable)
+ EXPECT_FALSE(subst.occursIn(x_, y_, bank_));
+}
+
+TEST_F(UnificationTest, SubstClearAndSize) {
+ Substitution subst;
+ EXPECT_TRUE(subst.empty());
+ subst.bind(x_, a_);
+ subst.bind(y_, b_);
+ EXPECT_EQ(subst.size(), 2);
+ subst.clear();
+ EXPECT_TRUE(subst.empty());
+ EXPECT_EQ(subst.lookup(x_), kInvalidId);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Unification Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(UnificationTest, UnifyIdenticalConstants) {
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, a_, a_, subst));
+ EXPECT_TRUE(subst.empty());
+}
+
+TEST_F(UnificationTest, UnifyDifferentConstants) {
+ Substitution subst;
+ EXPECT_FALSE(unify(bank_, a_, b_, subst));
+}
+
+TEST_F(UnificationTest, UnifyVarWithConstant) {
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, x_, a_, subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, UnifyConstantWithVar) {
+ // Symmetric: unify(a, X) should also work
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, a_, x_, subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, UnifyTwoVariables) {
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, x_, y_, subst));
+ // One should be bound to the other
+ TermId const rx = subst.resolve(x_, bank_);
+ TermId const ry = subst.resolve(y_, bank_);
+ EXPECT_EQ(rx, ry);
+}
+
+TEST_F(UnificationTest, UnifyCompoundTerms) {
+ // f(X) =? f(a) → {X → a}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, f(x_), f(a_), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, UnifyMultipleArgs) {
+ // g(X, Y) =? g(a, b) → {X → a, Y → b}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, g(x_, y_), g(a_, b_), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+ EXPECT_EQ(subst.resolve(y_, bank_), b_);
+}
+
+TEST_F(UnificationTest, UnifyConflictingBindings) {
+ // g(X, X) =? g(a, b) → fails (X can't be both a and b)
+ Substitution subst;
+ EXPECT_FALSE(unify(bank_, g(x_, x_), g(a_, b_), subst));
+}
+
+TEST_F(UnificationTest, UnifyConsistentRepeatedVar) {
+ // g(X, X) =? g(a, a) → {X → a}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, g(x_, x_), g(a_, a_), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, UnifyDifferentHeadSymbols) {
+ // f(X) =? h(X) → fails
+ Substitution subst;
+ EXPECT_FALSE(unify(bank_, f(x_), h(x_), subst));
+}
+
+TEST_F(UnificationTest, UnifyDifferentArity) {
+ // f(X) =? g(X, Y) → fails (arity 1 vs 2)
+ Substitution subst;
+ EXPECT_FALSE(unify(bank_, f(x_), g(x_, y_), subst));
+}
+
+TEST_F(UnificationTest, UnifyNestedTerms) {
+ // f(g(X, a)) =? f(Y) → {Y → g(X, a)}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, f(g(x_, a_)), f(y_), subst));
+ EXPECT_EQ(subst.resolve(y_, bank_), g(x_, a_));
+}
+
+TEST_F(UnificationTest, UnifyVarWithCompound) {
+ // X =? f(a) → {X → f(a)}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, x_, f(a_), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), f(a_));
+}
+
+TEST_F(UnificationTest, UnifyDeepNesting) {
+ // f(f(f(X))) =? f(f(f(a))) → {X → a}
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, f(f(f(x_))), f(f(f(a_))), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+}
+
+TEST_F(UnificationTest, UnifyChainedVariables) {
+ // g(X, Y) =? g(Y, a) → {X → Y, Y → a} effectively X → a
+ Substitution subst;
+ EXPECT_TRUE(unify(bank_, g(x_, y_), g(y_, a_), subst));
+ EXPECT_EQ(subst.resolve(x_, bank_), a_);
+ EXPECT_EQ(subst.resolve(y_, bank_), a_);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Occurs Check Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(UnificationTest, OccursCheckBlocks) {
+ // X =? f(X) with occurs check → fails
+ Substitution subst;
+ UnificationConfig const config{.enable_occurs_check_ = true};
+ EXPECT_FALSE(unify(bank_, x_, f(x_), subst, config));
+}
+
+TEST_F(UnificationTest, OccursCheckDisabledAllows) {
+ // X =? f(X) without occurs check → succeeds (unsound but fast)
+ Substitution subst;
+ UnificationConfig const config{.enable_occurs_check_ = false};
+ EXPECT_TRUE(unify(bank_, x_, f(x_), subst, config));
+}
+
+TEST_F(UnificationTest, OccursCheckDeep) {
+ // X =? f(g(X, a)) with occurs check → fails
+ Substitution subst;
+ UnificationConfig const config{.enable_occurs_check_ = true};
+ EXPECT_FALSE(unify(bank_, x_, f(g(x_, a_)), subst, config));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// applySubstitution Tests
+// ═══════════════════════════════════════════════════════════════════════════
+
+TEST_F(UnificationTest, ApplySubstitutionSimple) {
+ Substitution subst;
+ subst.bind(x_, a_);
+ EXPECT_EQ(applySubstitution(subst, f(x_), bank_), f(a_));
+}
+
+TEST_F(UnificationTest, ApplySubstitutionNested) {
+ Substitution subst;
+ subst.bind(x_, a_);
+ subst.bind(y_, b_);
+ EXPECT_EQ(applySubstitution(subst, g(f(x_), y_), bank_), g(f(a_), b_));
+}
+
+TEST_F(UnificationTest, ApplySubstitutionPreservesUnbound) {
+ Substitution subst;
+ subst.bind(x_, a_);
+ // Y is unbound → stays as Y
+ EXPECT_EQ(applySubstitution(subst, g(x_, y_), bank_), g(a_, y_));
+}
+
+TEST_F(UnificationTest, ApplySubstitutionToConstant) {
+ Substitution subst;
+ subst.bind(x_, a_);
+ // Constant is unchanged
+ EXPECT_EQ(applySubstitution(subst, b_, bank_), b_);
+}
+
+TEST_F(UnificationTest, ApplySubstitutionChain) {
+ // σ = {X → Y, Y → a} apply to f(X) → f(a)
+ Substitution subst;
+ subst.bind(x_, y_);
+ subst.bind(y_, a_);
+ EXPECT_EQ(applySubstitution(subst, f(x_), bank_), f(a_));
+}
+
+TEST_F(UnificationTest, UnifyThenApply) {
+ // Full round-trip: unify P(X, f(Y)) with P(a, f(b)), then apply
+ Substitution subst;
+ TermId const lhs = p(x_, f(y_));
+ TermId const rhs = p(a_, f(b_));
+ ASSERT_TRUE(unify(bank_, lhs, rhs, subst));
+
+ EXPECT_EQ(applySubstitution(subst, lhs, bank_), p(a_, f(b_)));
+ EXPECT_EQ(applySubstitution(subst, rhs, bank_), p(a_, f(b_)));
+ // Both sides should produce the same term after applying the MGU
+ EXPECT_EQ(applySubstitution(subst, lhs, bank_), applySubstitution(subst, rhs, bank_));
+}
+
+} // namespace
+} // namespace atp