mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
- Remove forced progress_tracker.enabled=True (was mutating global singleton) - Wrap derive_all() fixpoint loop in try/finally so stop_tracking is always called - Add _derived flag to cache fixpoint result; query() no longer re-runs derive_all() on every call - Reset _derived to False in add_fact(), add_rule(), and clear() - Warn (instead of silently drop) when add_fact() receives an unrecognised dict format - Fix syntax error on line 9 of test file (stray dashes caused SyntaxError, broke CI) - Add missing TestContextGraphIntegration tests: test_edge_becomes_fact and test_derive_after_load - All 18 tests pass Co-Authored-By: KaifAhmad1 <kaifahmad087@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
"""
|
|
Test suite for the DatalogReasoner module.
|
|
"""
|
|
|
|
import pytest
|
|
from typing import List, Dict, Any
|
|
|
|
from semantica.reasoning.datalog_reasoner import DatalogReasoner, DatalogFact
|
|
|
|
@pytest.fixture
|
|
def reasoner():
|
|
"""Provides a fresh DatalogReasoner instance for each test."""
|
|
return DatalogReasoner()
|
|
|
|
class MockContextGraph:
|
|
"""A mock to simulate Semantica's actual ContextGraph structure."""
|
|
def __init__(self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]):
|
|
self._nodes = nodes
|
|
self._edges = edges
|
|
|
|
def find_nodes(self, node_type=None):
|
|
return self._nodes
|
|
|
|
def find_edges(self, edge_type=None):
|
|
return self._edges
|
|
|
|
|
|
class TestBasicFacts:
|
|
def test_add_string_fact(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
assert len(reasoner._all_facts) == 1
|
|
fact = list(reasoner._all_facts)[0]
|
|
assert fact.predicate == "parent"
|
|
assert fact.args == ("tom", "bob")
|
|
|
|
def test_add_dict_fact(self, reasoner):
|
|
reasoner.add_fact({"subject": "bob", "predicate": "parent", "object": "ann"})
|
|
assert len(reasoner._all_facts) == 1
|
|
fact = list(reasoner._all_facts)[0]
|
|
assert fact.predicate == "parent"
|
|
assert fact.args == ("bob", "ann")
|
|
|
|
def test_duplicate_fact_ignored(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
assert len(reasoner._all_facts) == 1
|
|
|
|
def test_empty_arguments_raise_error(self, reasoner):
|
|
# Proves Issue #6 is fixed
|
|
with pytest.raises(ValueError, match="Empty argument"):
|
|
reasoner.add_fact("parent( )")
|
|
with pytest.raises(ValueError, match="Empty argument"):
|
|
reasoner.add_fact("parent(tom, )")
|
|
|
|
|
|
class TestRules:
|
|
def test_single_rule(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
|
|
derived = reasoner.derive_all()
|
|
assert "ancestor(tom, bob)" in derived
|
|
|
|
def test_recursive_ancestor(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_fact("parent(bob, ann)")
|
|
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).")
|
|
|
|
derived = reasoner.derive_all()
|
|
assert "ancestor(tom, bob)" in derived
|
|
assert "ancestor(bob, ann)" in derived
|
|
assert "ancestor(tom, ann)" in derived
|
|
|
|
def test_multi_hop_three_levels(self, reasoner):
|
|
reasoner.add_fact("edge(1, 2)")
|
|
reasoner.add_fact("edge(2, 3)")
|
|
reasoner.add_fact("edge(3, 4)")
|
|
|
|
reasoner.add_rule("reachable(X, Y) :- edge(X, Y).")
|
|
reasoner.add_rule("reachable(X, Y) :- edge(X, Z), reachable(Z, Y).")
|
|
|
|
derived = reasoner.derive_all()
|
|
assert "reachable(1, 4)" in derived
|
|
|
|
def test_two_body_atoms(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_fact("parent(bob, ann)")
|
|
|
|
reasoner.add_rule("grandparent(X, Y) :- parent(X, Z), parent(Z, Y).")
|
|
|
|
derived = reasoner.derive_all()
|
|
assert "grandparent(tom, ann)" in derived
|
|
assert "grandparent(tom, bob)" not in derived
|
|
|
|
|
|
class TestQuery:
|
|
def test_variable_binding(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_fact("parent(tom, alex)")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
|
|
results = reasoner.query("ancestor(tom, ?Y)")
|
|
y_bindings = sorted([res["Y"] for res in results])
|
|
assert y_bindings == ["alex", "bob"]
|
|
|
|
def test_lowercase_variable_query(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
results = reasoner.query("parent(tom, ?y)")
|
|
assert len(results) == 1
|
|
assert results[0]["y"] == "bob"
|
|
|
|
def test_pre_bound_variable(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
|
|
results_bob = reasoner.query("ancestor(tom, ?Y)", bindings={"Y": "bob"})
|
|
assert len(results_bob) == 1
|
|
assert results_bob[0]["Y"] == "bob"
|
|
|
|
results_ann = reasoner.query("ancestor(tom, ?Y)", bindings={"Y": "ann"})
|
|
assert len(results_ann) == 0
|
|
|
|
def test_no_match_returns_empty(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
results = reasoner.query("parent(sarah, ?Y)")
|
|
assert results == []
|
|
|
|
|
|
class TestContextGraphIntegration:
|
|
def test_load_from_graph(self, reasoner):
|
|
graph = MockContextGraph(
|
|
nodes=[{"id": "microsoft", "type": "company"}],
|
|
edges=[{"source": "microsoft", "target": "openai", "type": "invested_in"}]
|
|
)
|
|
|
|
added = reasoner.load_from_graph(graph)
|
|
assert added == 2
|
|
|
|
assert DatalogFact("company", ("microsoft",)) in reasoner._all_facts
|
|
assert DatalogFact("invested_in", ("microsoft", "openai")) in reasoner._all_facts
|
|
|
|
def test_edge_becomes_fact(self, reasoner):
|
|
graph = MockContextGraph(
|
|
nodes=[],
|
|
edges=[
|
|
{"source": "alice", "target": "bob", "type": "manages"},
|
|
{"source": "bob", "target": "carol", "type": "manages"},
|
|
]
|
|
)
|
|
reasoner.load_from_graph(graph)
|
|
|
|
assert DatalogFact("manages", ("alice", "bob")) in reasoner._all_facts
|
|
assert DatalogFact("manages", ("bob", "carol")) in reasoner._all_facts
|
|
|
|
def test_derive_after_load(self, reasoner):
|
|
graph = MockContextGraph(
|
|
nodes=[],
|
|
edges=[
|
|
{"source": "alice", "target": "bob", "type": "manages"},
|
|
{"source": "bob", "target": "carol", "type": "manages"},
|
|
]
|
|
)
|
|
reasoner.load_from_graph(graph)
|
|
|
|
reasoner.add_rule("transitive_manages(X, Y) :- manages(X, Y).")
|
|
reasoner.add_rule("transitive_manages(X, Y) :- manages(X, Z), transitive_manages(Z, Y).")
|
|
|
|
derived = reasoner.derive_all()
|
|
assert "transitive_manages(alice, carol)" in derived
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_program(self, reasoner):
|
|
derived = reasoner.derive_all()
|
|
assert derived == []
|
|
|
|
def test_derive_all_idempotent(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
|
|
first_run = len(reasoner.derive_all())
|
|
second_run = len(reasoner.derive_all())
|
|
|
|
assert first_run == second_run
|
|
assert first_run == 2
|
|
|
|
def test_clear_resets_state(self, reasoner):
|
|
reasoner.add_fact("parent(tom, bob)")
|
|
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
|
reasoner.derive_all()
|
|
|
|
reasoner.clear()
|
|
|
|
assert len(reasoner._all_facts) == 0
|
|
assert len(reasoner._rules) == 0
|
|
assert len(reasoner._delta_new) == 0
|
|
assert len(reasoner._delta_old) == 0
|
|
assert len(reasoner._fact_index) == 0 |