mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-04 04:01:07 +00:00
Reconciles this PR's Token-based alpha/beta matching (#300) with the rule-actions/provenance layer merged separately in #1096. That PR built bind_reasoner()/execute_matches() action-firing/_executed_activations/ reset_action_history() on top of the still-broken always-True stubs (via an interim _bindings_for_rule() regex re-extraction), so main and this branch touched the same propagation code with incompatible shapes. Kept this branch's Token(facts, bindings) model for alpha/beta propagation (the actual fix for #300) and layered main's action/ provenance plumbing on top of it, sourcing Match.bindings directly from Token.bindings instead of re-deriving them with _bindings_for_rule(), which is now redundant and removed. Also fixes a 2-tuple/3-tuple unpacking break in test_matches_reasoner_match_rule caused by Reasoner._match_rule()'s return shape changing upstream, and drops an unrelated encoding-only .gitignore diff. Verified: tests/reasoning/ (106 tests) and flake8 --max-line-length=88 both clean on the merged tree.
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""Tests for the RETE engine pattern matching (issue #300).
|
|
|
|
These tests verify that ``AlphaNode._matches`` and ``BetaNode._can_join`` no
|
|
longer behave like the old always-``True`` stubs, and that the network as a
|
|
whole only fires rules whose conditions actually unify with the facts.
|
|
"""
|
|
|
|
import itertools
|
|
import re
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from semantica.reasoning import rete_engine
|
|
from semantica.reasoning.reasoner import Fact, Rule
|
|
from semantica.reasoning.rete_engine import (
|
|
AlphaNode,
|
|
BetaNode,
|
|
ReteEngine,
|
|
unify_condition,
|
|
)
|
|
|
|
|
|
class TestUnifyCondition(unittest.TestCase):
|
|
def test_single_variable_binds(self):
|
|
fact = Fact("f1", "Person", ["John"])
|
|
bindings = unify_condition("Person(?x)", fact)
|
|
self.assertEqual(bindings, {"x": "John"})
|
|
|
|
def test_predicate_mismatch_returns_none(self):
|
|
fact = Fact("f1", "Company", ["Google"])
|
|
self.assertIsNone(unify_condition("Person(?x)", fact))
|
|
|
|
def test_two_arguments_bind(self):
|
|
fact = Fact("f2", "Parent", ["John", "Mary"])
|
|
bindings = unify_condition("Parent(?x, ?y)", fact)
|
|
self.assertEqual(bindings, {"x": "John", "y": "Mary"})
|
|
|
|
def test_literal_argument_must_match(self):
|
|
fact = Fact("f3", "Parent", ["John", "Mary"])
|
|
self.assertIsNone(unify_condition("Parent(Bob, ?y)", fact))
|
|
self.assertEqual(unify_condition("Parent(John, ?y)", fact), {"y": "Mary"})
|
|
|
|
def test_repeated_variable_requires_equal_values(self):
|
|
loves_self = Fact("f4", "Loves", ["John", "John"])
|
|
loves_other = Fact("f5", "Loves", ["John", "Mary"])
|
|
self.assertEqual(unify_condition("Loves(?x, ?x)", loves_self), {"x": "John"})
|
|
self.assertIsNone(unify_condition("Loves(?x, ?x)", loves_other))
|
|
|
|
def test_regex_error_logs_warning_and_returns_none(self):
|
|
"""A regex compilation error is logged with context and yields None."""
|
|
fact = Fact("f6", "Person", ["John"])
|
|
with mock.patch.object(
|
|
rete_engine.re,
|
|
"match",
|
|
side_effect=re.error("bad pattern"),
|
|
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
|
|
result = unify_condition("Person(?x)", fact)
|
|
self.assertIsNone(result)
|
|
joined = "\n".join(captured.output)
|
|
self.assertIn("Person(?x)", joined)
|
|
self.assertIn("Person(John)", joined)
|
|
self.assertIn("bad pattern", joined)
|
|
|
|
def test_unexpected_error_logs_warning_and_returns_none(self):
|
|
"""An unexpected error is also logged and swallowed as None."""
|
|
fact = Fact("f7", "Person", ["John"])
|
|
with mock.patch.object(
|
|
rete_engine.re,
|
|
"match",
|
|
side_effect=RuntimeError("boom"),
|
|
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
|
|
result = unify_condition("Person(?x)", fact)
|
|
self.assertIsNone(result)
|
|
self.assertIn("boom", "\n".join(captured.output))
|
|
|
|
|
|
class TestAlphaNode(unittest.TestCase):
|
|
def test_matches_stores_bindings(self):
|
|
node = AlphaNode("a1", "Person(?x)")
|
|
fact = Fact("f1", "Person", ["John"])
|
|
token = node.add_fact(fact)
|
|
self.assertIsNotNone(token)
|
|
assert token is not None # narrow type for the checker
|
|
self.assertEqual(token.facts, [fact])
|
|
self.assertEqual(token.bindings, {"x": "John"})
|
|
self.assertIn(token, node.tokens)
|
|
|
|
def test_non_matching_fact_rejected(self):
|
|
node = AlphaNode("a1", "Person(?x)")
|
|
fact = Fact("f1", "Company", ["Google"])
|
|
self.assertIsNone(node.add_fact(fact))
|
|
self.assertEqual(node.tokens, [])
|
|
|
|
def test_uses_precompiled_regex(self):
|
|
"""AlphaNode compiles its condition once and reuses it per fact."""
|
|
node = AlphaNode("a1", "Person(?x)")
|
|
self.assertIsNotNone(node._compiled)
|
|
# Matching goes through the compiled matcher, not unify_condition.
|
|
with mock.patch.object(rete_engine, "unify_condition") as unify:
|
|
fact = Fact("f1", "Person", ["John"])
|
|
token = node.add_fact(fact)
|
|
unify.assert_not_called()
|
|
self.assertIsNotNone(token)
|
|
assert token is not None
|
|
self.assertEqual(token.bindings, {"x": "John"})
|
|
|
|
def test_bad_condition_never_matches_and_logs(self):
|
|
"""A condition that fails to compile logs a warning and never fires."""
|
|
with mock.patch.object(
|
|
rete_engine,
|
|
"_build_condition_regex",
|
|
return_value="(unbalanced",
|
|
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
|
|
node = AlphaNode("bad", "Person(?x)")
|
|
self.assertIsNone(node._compiled)
|
|
self.assertIn("failed to compile", "\n".join(captured.output))
|
|
fact = Fact("f1", "Person", ["John"])
|
|
self.assertIsNone(node.add_fact(fact))
|
|
self.assertEqual(node.tokens, [])
|
|
|
|
|
|
class TestBetaNode(unittest.TestCase):
|
|
def test_join_consistent_bindings(self):
|
|
left = AlphaNode("a1", "Parent(?x, ?y)")
|
|
right = AlphaNode("a2", "Person(?x)")
|
|
beta = BetaNode("b1", left, right)
|
|
|
|
parent = Fact("f1", "Parent", ["John", "Mary"])
|
|
person = Fact("f2", "Person", ["John"])
|
|
left_token = left.add_fact(parent)
|
|
right_token = right.add_fact(person)
|
|
assert left_token is not None and right_token is not None
|
|
|
|
merged = beta.join(left_token, right_token)
|
|
self.assertIsNotNone(merged)
|
|
assert merged is not None # narrow type for the checker
|
|
self.assertEqual(merged.bindings, {"x": "John", "y": "Mary"})
|
|
# Facts are concatenated left-then-right in condition order.
|
|
self.assertEqual(merged.facts, [parent, person])
|
|
|
|
def test_join_conflicting_bindings_rejected(self):
|
|
left = AlphaNode("a1", "Parent(?x, ?y)")
|
|
right = AlphaNode("a2", "Person(?x)")
|
|
beta = BetaNode("b1", left, right)
|
|
|
|
parent = Fact("f1", "Parent", ["John", "Mary"])
|
|
# ?x conflicts: John vs Alice
|
|
person = Fact("f2", "Person", ["Alice"])
|
|
left_token = left.add_fact(parent)
|
|
right_token = right.add_fact(person)
|
|
assert left_token is not None and right_token is not None
|
|
|
|
self.assertIsNone(beta.join(left_token, right_token))
|
|
|
|
|
|
class TestReteEngineEndToEnd(unittest.TestCase):
|
|
def test_only_matching_rule_fires(self):
|
|
engine = ReteEngine()
|
|
rule = Rule(
|
|
rule_id="r1",
|
|
name="person rule",
|
|
conditions=["Person(?x)"],
|
|
conclusion="Mortal(?x)",
|
|
)
|
|
engine.build_network([rule])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Company", ["Google"])) # should NOT fire
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 1)
|
|
self.assertEqual(matches[0].bindings, {"x": "John"})
|
|
|
|
def test_multi_condition_join(self):
|
|
engine = ReteEngine()
|
|
rule = Rule(
|
|
rule_id="r1",
|
|
name="child rule",
|
|
conditions=["Person(?x)", "Parent(?x, ?y)"],
|
|
conclusion="Child(?y, ?x)",
|
|
)
|
|
engine.build_network([rule])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
|
|
# Unrelated parent whose ?x does not match any Person -> no activation.
|
|
engine.add_fact(Fact("f3", "Parent", ["Bob", "Sue"]))
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 1)
|
|
self.assertEqual(matches[0].bindings, {"x": "John", "y": "Mary"})
|
|
|
|
def test_no_activation_when_join_inconsistent(self):
|
|
engine = ReteEngine()
|
|
rule = Rule(
|
|
rule_id="r1",
|
|
name="child rule",
|
|
conditions=["Person(?x)", "Parent(?x, ?y)"],
|
|
conclusion="Child(?y, ?x)",
|
|
)
|
|
engine.build_network([rule])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["Alice", "Mary"])) # ?x mismatch
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(matches, [])
|
|
|
|
|
|
class TestThreeConditionChain(unittest.TestCase):
|
|
"""Chained beta joins across three or more conditions (issue #300).
|
|
|
|
These exercise the Token model: a token must accumulate the ordered
|
|
facts and the consistent bindings of every condition, so that deep
|
|
chains neither drop bindings nor duplicate facts, and a conflict on the
|
|
third condition correctly suppresses activation.
|
|
"""
|
|
|
|
def _three_condition_rule(self):
|
|
return Rule(
|
|
rule_id="r1",
|
|
name="location chain",
|
|
conditions=[
|
|
"Person(?x)",
|
|
"Parent(?x, ?y)",
|
|
"Located(?y, ?z)",
|
|
],
|
|
conclusion="LivesNear(?x, ?z)",
|
|
)
|
|
|
|
def test_three_condition_valid_match(self):
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
|
|
engine.add_fact(Fact("f3", "Located", ["Mary", "Paris"]))
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 1)
|
|
self.assertEqual(
|
|
matches[0].bindings,
|
|
{"x": "John", "y": "Mary", "z": "Paris"},
|
|
)
|
|
|
|
def test_three_condition_third_level_conflict(self):
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
|
|
# ?y is bound to Mary, so a Located fact about Bob must not join.
|
|
engine.add_fact(Fact("f3", "Located", ["Bob", "Paris"]))
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(matches, [])
|
|
|
|
def test_fact_insertion_order_independent(self):
|
|
# Whatever order facts arrive, the same single match must result.
|
|
base_facts = [
|
|
Fact("f1", "Person", ["John"]),
|
|
Fact("f2", "Parent", ["John", "Mary"]),
|
|
Fact("f3", "Located", ["Mary", "Paris"]),
|
|
]
|
|
expected = {"x": "John", "y": "Mary", "z": "Paris"}
|
|
|
|
for order in itertools.permutations(base_facts):
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
for fact in order:
|
|
engine.add_fact(fact)
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 1, f"order={order}")
|
|
self.assertEqual(matches[0].bindings, expected)
|
|
|
|
def test_match_facts_complete_in_condition_order(self):
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
|
|
person = Fact("f1", "Person", ["John"])
|
|
parent = Fact("f2", "Parent", ["John", "Mary"])
|
|
located = Fact("f3", "Located", ["Mary", "Paris"])
|
|
engine.add_fact(person)
|
|
engine.add_fact(parent)
|
|
engine.add_fact(located)
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 1)
|
|
# All three facts preserved, in condition order, no duplicates.
|
|
self.assertEqual(matches[0].facts, [person, parent, located])
|
|
|
|
def test_multiple_left_tokens_join_one_right_fact(self):
|
|
# Two Person/Parent chains sharing the same Located(?y, ?z) fact.
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
|
|
engine.add_fact(Fact("f3", "Person", ["Alice"]))
|
|
engine.add_fact(Fact("f4", "Parent", ["Alice", "Mary"]))
|
|
# One right fact should join with both accumulated left tokens.
|
|
engine.add_fact(Fact("f5", "Located", ["Mary", "Paris"]))
|
|
|
|
matches = engine.match_patterns()
|
|
self.assertEqual(len(matches), 2)
|
|
result = {m.bindings["x"]: m.bindings["z"] for m in matches}
|
|
self.assertEqual(result, {"John": "Paris", "Alice": "Paris"})
|
|
|
|
def test_matches_reasoner_match_rule(self):
|
|
from semantica.reasoning.reasoner import Reasoner
|
|
|
|
rule = self._three_condition_rule()
|
|
facts = [
|
|
Fact("f1", "Person", ["John"]),
|
|
Fact("f2", "Parent", ["John", "Mary"]),
|
|
Fact("f3", "Located", ["Mary", "Paris"]),
|
|
]
|
|
|
|
# Reasoner works over stringified facts and returns
|
|
# (conclusion, matched_facts, bindings) tuples from self.facts.
|
|
reasoner = Reasoner()
|
|
for fact in facts:
|
|
reasoner.add_fact(str(fact))
|
|
reasoner_matches = reasoner._match_rule(rule)
|
|
|
|
engine = ReteEngine()
|
|
engine.build_network([rule])
|
|
for fact in facts:
|
|
engine.add_fact(fact)
|
|
rete_matches = engine.match_patterns()
|
|
|
|
# Both engines must agree on the number of activations.
|
|
self.assertEqual(len(rete_matches), len(reasoner_matches))
|
|
self.assertEqual(len(rete_matches), 1)
|
|
self.assertEqual(
|
|
rete_matches[0].bindings,
|
|
{"x": "John", "y": "Mary", "z": "Paris"},
|
|
)
|
|
# The RETE match must carry the instantiated conclusion facts too.
|
|
conclusion, _, _ = reasoner_matches[0]
|
|
self.assertEqual(conclusion, "LivesNear(John, Paris)")
|
|
|
|
def test_reset_clears_all_token_memory(self):
|
|
engine = ReteEngine()
|
|
engine.build_network([self._three_condition_rule()])
|
|
|
|
engine.add_fact(Fact("f1", "Person", ["John"]))
|
|
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
|
|
engine.add_fact(Fact("f3", "Located", ["Mary", "Paris"]))
|
|
self.assertEqual(len(engine.match_patterns()), 1)
|
|
|
|
engine.reset()
|
|
|
|
# No stale facts, tokens or activations remain anywhere.
|
|
self.assertEqual(engine.facts, [])
|
|
for node in engine.network.values():
|
|
if isinstance(node, AlphaNode):
|
|
self.assertEqual(node.tokens, [])
|
|
elif isinstance(node, BetaNode):
|
|
self.assertEqual(node.left_tokens, [])
|
|
self.assertEqual(node.right_tokens, [])
|
|
self.assertEqual(engine.match_patterns(), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|