restructure project + unit tests

This commit is contained in:
2026-04-13 11:44:26 +02:00
parent 50550a1f06
commit 1fb4d30407
9 changed files with 217 additions and 122 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
*.kate-swp
.venv/
__pycache__/
.pytest_cache/
*.egg-info/
+22 -1
View File
@@ -1,7 +1,28 @@
## anf.py
# boolean-tools
## install
```
python -m venv .venv
source .venv/bin/activate
pip install -e .
```
## unit tests
```
pip install pytest
pytest
```
### anf.py
Takes a boolean expression with 'not' (!) 'and' (AND) and 'or' (OR) in input and outputs its simplified ANF form (with XOR (⊕) and AND (⸱)).
#### run:
```
anf
```
#### example:
input:
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "boolean-tools"
version = "0.1.0"
description = "Boolean tools"
authors = [{name = "Sam HADOW"}]
requires-python = ">=3.10"
[project.scripts]
anf = "anf.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
+6
View File
@@ -0,0 +1,6 @@
from .parser import Parser, tokenize
from .printer import format_poly
def convert(expr: str) -> str:
poly = Parser(tokenize(expr)).parse()
return format_poly(poly)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
import sys
from . import convert
def main() -> None:
if len(sys.argv) > 1:
expr = " ".join(sys.argv[1:])
else:
expr = input("Enter formula: ").strip()
print(convert(expr))
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import FrozenSet
Monomial = FrozenSet[str]
Poly = set[Monomial]
def zero() -> Poly:
return set()
def one() -> Poly:
return {frozenset()}
def var(name: str) -> Poly:
return {frozenset([name])}
def poly_xor(a: Poly, b: Poly) -> Poly:
return a ^ b
def poly_and(a: Poly, b: Poly) -> Poly:
out: Poly = set()
for ma in a:
for mb in b:
m = frozenset(set(ma) | set(mb))
if m in out:
out.remove(m)
else:
out.add(m)
return out
def poly_not(a: Poly) -> Poly:
return poly_xor(a, one())
+3 -120
View File
@@ -1,68 +1,9 @@
#!/bin/python3
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import FrozenSet, Iterable, List, Union
from typing import List
import sys
#####################
# ANF representation over GF(2)
#####################
#
# polynomial: set of monomials.
# monomial: frozenset of variable names.
#
# Examples:
# 0 -> set()
# 1 -> {frozenset()}
# X3 -> {frozenset({"X3"})}
# X3 XOR X4 -> {{"X3"}, {"X4"}}
# X3 AND X4 -> {{"X3","X4"}}
Monomial = FrozenSet[str]
Poly = set[Monomial]
def zero() -> Poly:
return set()
def one() -> Poly:
return {frozenset()}
def var(name: str) -> Poly:
return {frozenset([name])}
def poly_xor(a: Poly, b: Poly) -> Poly:
# symmetric difference = XOR over GF(2)
return a ^ b
def poly_and(a: Poly, b: Poly) -> Poly:
out: Poly = set()
for ma in a:
for mb in b:
m = frozenset(set(ma) | set(mb))
if m in out:
out.remove(m)
else:
out.add(m)
return out
def poly_not(a: Poly) -> Poly:
# NOT a = a XOR 1
return poly_xor(a, one())
#####################
# Tokenizer
#####################
from .core import Poly, poly_and, poly_not, poly_xor, one, var, zero
TOKEN_RE = re.compile(
r"""
@@ -79,6 +20,7 @@ TOKEN_RE = re.compile(
re.VERBOSE,
)
def tokenize(s: str) -> List[str]:
tokens = TOKEN_RE.findall(s)
stripped = re.sub(r"\s+", "", s)
@@ -87,22 +29,6 @@ def tokenize(s: str) -> List[str]:
return tokens
#####################
# Parser
#####################
# priotity order:
# !
# AND
# OR
#
# Grammar:
# expr := or_expr
# or_expr := and_expr ("OR" and_expr)*
# and_expr := not_expr ("AND" not_expr)*
# not_expr := "!" not_expr | atom
# atom := VAR | 0 | 1 | "(" expr ")"
class Parser:
def __init__(self, tokens: List[str]):
self.tokens = tokens
@@ -172,46 +98,3 @@ class Parser:
return var(tok)
raise ValueError(f"Unexpected token: {tok!r}")
#####################
# CLI PRETTY PRINT
#####################
def monomial_key(m: Monomial):
return (len(m), tuple(sorted(m)))
def format_monomial(m: Monomial) -> str:
if len(m) == 0:
return "1"
if len(m) == 1:
return next(iter(m))
parts = sorted(m, key=lambda x: (len(x), x))
return "(" + "".join(parts) + ")"
def format_poly(p: Poly) -> str:
if not p:
return "0"
monos = sorted(p, key=monomial_key)
return "".join(format_monomial(m) for m in monos)
def convert(expr: str) -> str:
tokens = tokenize(expr)
poly = Parser(tokens).parse()
return format_poly(poly)
#####################
# CLI
#####################
if __name__ == "__main__":
if len(sys.argv) > 1:
expr = " ".join(sys.argv[1:])
else:
expr = input("Enter formula: ").strip()
print(convert(expr))
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from .core import Monomial, Poly
def monomial_key(m: Monomial):
return (len(m), tuple(sorted(m)))
def format_monomial(m: Monomial) -> str:
if len(m) == 0:
return "1"
if len(m) == 1:
return next(iter(m))
parts = sorted(m, key=lambda x: (len(x), x))
return "(" + "".join(parts) + ")"
def format_poly(p: Poly) -> str:
if not p:
return "0"
monos = sorted(p, key=monomial_key)
return "".join(format_monomial(m) for m in monos)
+90
View File
@@ -0,0 +1,90 @@
import itertools
from anf import convert
from anf.parser import Parser, tokenize
def parse(expr):
return Parser(tokenize(expr)).parse()
def eval_poly(poly, env):
res = False
for mono in poly:
term = True
for v in mono:
term &= env.get(v, False)
res ^= term
return res
def eval_expr(expr, env):
py = expr
py = py.replace("!", " not ")
py = py.replace("AND", " and ")
py = py.replace("OR", " or ")
py = py.replace("+", " or ")
py = py.replace("*", " and ")
return eval(py, {"__builtins__": {}}, env)
def equivalent(expr):
poly = parse(expr)
vars_ = sorted({t for t in tokenize(expr) if t.startswith("X")})
for values in itertools.product([False, True], repeat=len(vars_)):
env = dict(zip(vars_, values))
assert eval_expr(expr, env) == eval_poly(poly, env)
# ------------------------
# Tests
# ------------------------
def test_basic_or():
assert equivalent("X1 OR X2") is None
def test_basic_and():
assert equivalent("X1 AND X2") is None
def test_not():
assert equivalent("!X1") is None
def test_plus_star_syntax():
assert equivalent("X1 * (X2 + !X3)") is None
def test_idempotent():
assert convert("X1 OR X1") == "X1"
def test_contradiction():
assert convert("X1 AND !X1") == "0"
def test_big_formula():
expr = (
"!X11 AND (!X3 AND !X4 OR X3 AND X4 OR X3 AND !X4 AND X12) OR X11 AND !X12 AND (X3 OR X4)"
)
expected = (
"1⊕X11⊕X3⊕X4⊕(X3⸱X12)⊕(X4⸱X11⸱X12)⊕(X3⸱X4⸱X11)⊕(X3⸱X4⸱X12)"
)
assert convert(expr) == expected
def test_equivalence_suite():
formulas = [
"X1 OR X2",
"X1 AND !X2",
"!(X1 OR X2)",
"(X1 + X2) * (!X1 + X3)",
"!X1 AND (!X2 OR X3) OR X1 AND X2",
]
for f in formulas:
equivalent(f)