Backfills zero dedicated coverage on arborist/pi_star/logic.py (371 lines, 2026-05-10 zero-coverage sweep). KATs mirror the docstring's equivalence classes: commutativity, associativity, IMPL/IFF/XOR rewrites, De Morgan, double negation, distribution, idempotence, within-clause tautology drop. Pins v1's documented limitation: A AND NOT A is NOT collapsed (multi-clause contradiction detection is out of scope; only empty clauses surface as FALSE). Negative cones: >8 atoms, unrecognized token, unexpected char, unbalanced paren, unconsumed tokens, dangling operator, empty, non-bytes. Plus determinism + lexical-sort canonicalization.
276 lines
7.3 KiB
Python
276 lines
7.3 KiB
Python
"""logic-kernel@v1 π* tests.
|
|
|
|
Propositional-logic CNF canonicalizer. KATs mirror the docstring's
|
|
equivalence-class examples: commutativity, associativity, IMPL/IFF/
|
|
XOR rewrites, De Morgan, double negation, distribution, tautology /
|
|
contradiction collapse, idempotence. Plus negative cones (>8 atoms,
|
|
unrecognized tokens, unconsumed tokens, empty, non-bytes) and round-
|
|
trip idempotence.
|
|
|
|
Backfills the test-coverage gap for arborist/pi_star/logic.py
|
|
identified during the 2026-05-10 zero-coverage sweep.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from arborist.pi_star import get
|
|
from arborist.pi_star.protocol import (
|
|
PiStarError,
|
|
assert_round_trip,
|
|
equivalence_class_id,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ps():
|
|
return get("logic-kernel@v1")
|
|
|
|
|
|
def _canon(ps, expr: str) -> bytes:
|
|
return ps.canonicalize(expr.encode("utf-8"))
|
|
|
|
|
|
# --- registry presence + metadata ------------------------------------
|
|
|
|
|
|
def test_registry_contains_logic_kernel():
|
|
ps = get("logic-kernel@v1")
|
|
assert ps.name == "logic-kernel"
|
|
assert ps.version == "v1"
|
|
assert ps.domain == "logic"
|
|
|
|
|
|
# --- atom + literal serialization ------------------------------------
|
|
|
|
|
|
def test_single_atom(ps):
|
|
assert _canon(ps, "A") == b"A"
|
|
|
|
|
|
def test_negated_atom(ps):
|
|
assert _canon(ps, "NOT A") == b"NOT A"
|
|
|
|
|
|
def test_true_literal(ps):
|
|
assert _canon(ps, "TRUE") == b"TRUE"
|
|
|
|
|
|
def test_false_literal(ps):
|
|
assert _canon(ps, "FALSE") == b"FALSE"
|
|
|
|
|
|
# --- equivalence classes preserved -----------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"a,b",
|
|
[
|
|
# commutativity of AND / OR
|
|
("A AND B", "B AND A"),
|
|
("A OR B", "B OR A"),
|
|
# associativity
|
|
("(A AND B) AND C", "A AND (B AND C)"),
|
|
("(A OR B) OR C", "A OR (B OR C)"),
|
|
# IMPL rewrite
|
|
("A IMPL B", "NOT A OR B"),
|
|
# IFF rewrite
|
|
("A IFF B", "(NOT A OR B) AND (NOT B OR A)"),
|
|
# XOR rewrite
|
|
("A XOR B", "(A OR B) AND (NOT A OR NOT B)"),
|
|
# De Morgan
|
|
("NOT (A AND B)", "NOT A OR NOT B"),
|
|
("NOT (A OR B)", "NOT A AND NOT B"),
|
|
# double negation
|
|
("NOT NOT A", "A"),
|
|
# distribution
|
|
("A OR (B AND C)", "(A OR B) AND (A OR C)"),
|
|
# idempotence
|
|
("A AND A", "A"),
|
|
("A OR A", "A"),
|
|
],
|
|
)
|
|
def test_equivalent_inputs_collapse(ps, a, b):
|
|
assert _canon(ps, a) == _canon(ps, b)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"a,b",
|
|
[
|
|
("A IMPL B", "NOT A OR B"),
|
|
("NOT NOT A", "A"),
|
|
("A OR (B AND C)", "(A OR B) AND (A OR C)"),
|
|
],
|
|
)
|
|
def test_equivalence_class_id_matches(ps, a, b):
|
|
assert equivalence_class_id(ps, a.encode()) == equivalence_class_id(
|
|
ps, b.encode()
|
|
)
|
|
|
|
|
|
# --- tautology / contradiction collapse ------------------------------
|
|
|
|
|
|
def test_tautology_collapses_to_true(ps):
|
|
assert _canon(ps, "A OR NOT A") == b"TRUE"
|
|
|
|
|
|
def test_false_literal_collapses_to_false(ps):
|
|
"""Direct FALSE input → empty clause → 'FALSE'."""
|
|
assert _canon(ps, "FALSE") == b"FALSE"
|
|
|
|
|
|
def test_and_with_false_collapses_to_false(ps):
|
|
"""A AND FALSE yields an empty clause that surfaces as 'FALSE'."""
|
|
assert _canon(ps, "A AND FALSE") == b"FALSE"
|
|
|
|
|
|
def test_a_and_not_a_is_not_simplified(ps):
|
|
"""v1 kernel only catches WITHIN-clause tautologies + empty clauses.
|
|
|
|
`A AND NOT A` is two separate clauses ({A}, {NOT A}); neither
|
|
contains both polarities, so the within-clause tautology drop
|
|
doesn't fire, and neither is the empty clause. Documented limit
|
|
of the v1 algorithm — sat-solving multi-clause contradictions is
|
|
out of scope.
|
|
"""
|
|
assert _canon(ps, "A AND NOT A") == b"A AND NOT A"
|
|
|
|
|
|
def test_complex_tautology(ps):
|
|
# (A IMPL B) OR (B IMPL A) is a classical tautology.
|
|
assert _canon(ps, "(A IMPL B) OR (B IMPL A)") == b"TRUE"
|
|
|
|
|
|
# --- equivalence classes kept distinct -------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"a,b",
|
|
[
|
|
("A AND B", "A AND C"), # different atom sets
|
|
("A AND B", "A OR B"), # logically distinct
|
|
("A", "NOT A"),
|
|
("A IMPL B", "B IMPL A"),
|
|
("TRUE", "FALSE"),
|
|
],
|
|
)
|
|
def test_distinct_inputs_stay_distinct(ps, a, b):
|
|
assert _canon(ps, a) != _canon(ps, b)
|
|
|
|
|
|
# --- round-trip idempotence ------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expr",
|
|
[
|
|
"A",
|
|
"NOT A",
|
|
"A AND B",
|
|
"A OR B",
|
|
"(A OR B) AND (NOT A OR C)",
|
|
"A IMPL B",
|
|
"TRUE",
|
|
"FALSE",
|
|
],
|
|
)
|
|
def test_round_trip(ps, expr):
|
|
canonical = _canon(ps, expr)
|
|
# Re-canonicalizing the canonical form yields the same bytes.
|
|
assert ps.canonicalize(canonical) == canonical
|
|
|
|
|
|
def test_round_trip_via_helper(ps):
|
|
"""assert_round_trip exercises the protocol contract."""
|
|
assert_round_trip(ps, b"A AND B")
|
|
assert_round_trip(ps, b"TRUE")
|
|
assert_round_trip(ps, b"FALSE")
|
|
|
|
|
|
# --- determinism + sort order ----------------------------------------
|
|
|
|
|
|
def test_clauses_sorted_lexically(ps):
|
|
"""Clause order is canonical regardless of input order."""
|
|
a = _canon(ps, "C AND A AND B")
|
|
b = _canon(ps, "A AND B AND C")
|
|
assert a == b == b"A AND B AND C"
|
|
|
|
|
|
def test_literals_sorted_within_clause(ps):
|
|
"""Literals inside a disjunction are sorted by (name, negated)."""
|
|
assert _canon(ps, "C OR A OR B") == b"(A OR B OR C)"
|
|
|
|
|
|
def test_dedupe_repeated_clauses(ps):
|
|
"""Duplicate AND clauses collapse."""
|
|
assert _canon(ps, "(A OR B) AND (A OR B)") == _canon(ps, "A OR B")
|
|
|
|
|
|
# --- invalid-input cone ----------------------------------------------
|
|
|
|
|
|
def test_empty_raises(ps):
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
ps.canonicalize(b"")
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
ps.canonicalize(b" ")
|
|
|
|
|
|
def test_non_bytes_raises(ps):
|
|
with pytest.raises(PiStarError, match="expects bytes"):
|
|
ps.canonicalize("A AND B") # type: ignore[arg-type]
|
|
|
|
|
|
def test_unrecognized_multichar_token_raises(ps):
|
|
"""Multi-letter tokens that aren't AND/OR/NOT/XOR/IMPL/IFF/T/F raise."""
|
|
with pytest.raises(PiStarError, match="unrecognized token"):
|
|
ps.canonicalize(b"foo AND bar")
|
|
|
|
|
|
def test_unexpected_char_raises(ps):
|
|
with pytest.raises(PiStarError, match="unexpected char"):
|
|
ps.canonicalize(b"A + B")
|
|
|
|
|
|
def test_too_many_atoms_raises(ps):
|
|
"""v1 caps at 8 atoms (`_MAX_ATOMS`)."""
|
|
expr = " AND ".join("ABCDEFGHI") # 9 atoms
|
|
with pytest.raises(PiStarError, match="caps atoms"):
|
|
ps.canonicalize(expr.encode())
|
|
|
|
|
|
def test_exactly_max_atoms_allowed(ps):
|
|
"""8 atoms is the boundary — must succeed."""
|
|
expr = " AND ".join("ABCDEFGH") # 8 atoms
|
|
out = ps.canonicalize(expr.encode())
|
|
assert out == b"A AND B AND C AND D AND E AND F AND G AND H"
|
|
|
|
|
|
def test_unbalanced_paren_raises(ps):
|
|
with pytest.raises(PiStarError):
|
|
ps.canonicalize(b"(A AND B")
|
|
|
|
|
|
def test_unconsumed_tokens_raise(ps):
|
|
"""Trailing garbage after a complete expression is rejected."""
|
|
with pytest.raises(PiStarError):
|
|
ps.canonicalize(b"A AND B C")
|
|
|
|
|
|
def test_dangling_operator_raises(ps):
|
|
with pytest.raises(PiStarError):
|
|
ps.canonicalize(b"A AND")
|
|
|
|
|
|
# --- determinism -----------------------------------------------------
|
|
|
|
|
|
def test_repeated_canonicalize_is_stable(ps):
|
|
expr = b"(A IMPL B) AND (C IFF D) OR NOT E"
|
|
a = ps.canonicalize(expr)
|
|
b = ps.canonicalize(expr)
|
|
c = ps.canonicalize(expr)
|
|
assert a == b == c
|