From 1fb4d3040711f1c82e1083ee466cda49a292f197 Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Mon, 13 Apr 2026 11:44:26 +0200 Subject: [PATCH] restructure project + unit tests --- .gitignore | 5 +- README.md | 23 ++++++- pyproject.toml | 12 ++++ src/anf/__init__.py | 6 ++ src/anf/cli.py | 18 ++++++ src/anf/core.py | 38 +++++++++++ anf.py => src/anf/parser.py | 123 +----------------------------------- src/anf/printer.py | 24 +++++++ tests/test_anf.py | 90 ++++++++++++++++++++++++++ 9 files changed, 217 insertions(+), 122 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/anf/__init__.py create mode 100644 src/anf/cli.py create mode 100644 src/anf/core.py rename anf.py => src/anf/parser.py (51%) create mode 100644 src/anf/printer.py create mode 100644 tests/test_anf.py diff --git a/.gitignore b/.gitignore index 06c5e4a..84b48b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.kate-swp - +.venv/ +__pycache__/ +.pytest_cache/ +*.egg-info/ diff --git a/README.md b/README.md index 58e4590..9798a8f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a392ba7 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/anf/__init__.py b/src/anf/__init__.py new file mode 100644 index 0000000..fd57f07 --- /dev/null +++ b/src/anf/__init__.py @@ -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) diff --git a/src/anf/cli.py b/src/anf/cli.py new file mode 100644 index 0000000..25c6f69 --- /dev/null +++ b/src/anf/cli.py @@ -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() diff --git a/src/anf/core.py b/src/anf/core.py new file mode 100644 index 0000000..93c945d --- /dev/null +++ b/src/anf/core.py @@ -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()) diff --git a/anf.py b/src/anf/parser.py similarity index 51% rename from anf.py rename to src/anf/parser.py index 5c9293d..1ff3617 100644 --- a/anf.py +++ b/src/anf/parser.py @@ -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)) diff --git a/src/anf/printer.py b/src/anf/printer.py new file mode 100644 index 0000000..ad5fd41 --- /dev/null +++ b/src/anf/printer.py @@ -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) diff --git a/tests/test_anf.py b/tests/test_anf.py new file mode 100644 index 0000000..4072e00 --- /dev/null +++ b/tests/test_anf.py @@ -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)