alkahest
Python API

Propositional logic

Boolean connectives, normal forms, exact two-level minimisation, reduced ordered binary decision diagrams (ROBDDs) and equivalence checking. Atoms are Boolean symbols; the transformations that hold under any interpretation of their atoms (to_nnf(), to_cnf(), to_dnf(), simplify_logic()) also accept arithmetic relations such as pool.gt(x, 0) as opaque atoms. Refusals raise LogicError (E-LOGIC-002 … E-LOGIC-006); an active alkahest.Budget is honoured everywhere (BudgetExceededError).

Connectives

function
And(*args)

Logical conjunction of any number of predicates.

And(a, b, c) is flattened (nested And arguments are spliced in), True arguments are dropped, a False argument or a complementary pair x, Not(x) gives False, duplicates are removed and the arguments are put in a canonical order (atoms by name, then compound terms), so And(b, a) is the same expression as And(a, b). Python True/False are accepted alongside expressions. A single argument is returned unchanged.

Python
>>> import alkahest as ak
>>> p = ak.ExprPool(); a, b, c = p.symbol("a"), p.symbol("b"), p.symbol("c")
>>> ak.And(c, ak.And(b, a)) == ak.And(a, b, c)
True
function
Or(*args)

Logical disjunction of any number of predicates (dual of And(): flattened, False dropped, True or x, Not(x) gives True, deduplicated and canonically ordered).

function
Not(a)

Logical negation; Not(Not(x)) is x and Not(True) is False.

function
Xor(*args)

Exclusive or (odd parity) of any number of predicates.

The kernel has no Xor node, so the result is the parity chain written with And/Or/Not; consecutive stages share their sub-formula, so the expression grows linearly with the number of arguments. Xor(x, x) is False; Xor() of bools only is refused (no pool).

function
Implies(a, b)

Material implication a → b, built as Or(Not(a), b).

function
Equivalent(*args)

Equivalence of any number of predicates: true when all have the same value, built as Or(And(a₁…aₙ), And(¬a₁…¬aₙ)).

function
ITE(c, t, e)

If-then-else (c ∧ t) ∨ (¬c ∧ e) (the multiplexer).

Normal forms and minimisation

function
to_nnf(expr)

Negation normal form: negations pushed onto atoms, only And/Or above them. Arithmetic relations are opaque atoms (Not(x < 0) is kept, not rewritten to x >= 0).

Raises LogicError (E-LOGIC-002) for quantified formulas or non-Boolean terms.

function
to_cnf(expr, simplify=False)

Conjunctive normal form (product of sums).

With simplify=False (default) the form is obtained by distribution, with tautological, duplicate and subsumed clauses dropped; it can be exponentially larger than the input and is refused past 100 000 clauses (LogicError, E-LOGIC-003). simplify=True returns the minimum CNF of simplify_logic() instead.

Python
>>> import alkahest as ak
>>> p = ak.ExprPool(); a, b, c = p.symbol("a"), p.symbol("b"), p.symbol("c")
>>> print(ak.to_cnf(ak.Or(a, ak.And(b, c))))
(a ∨ b) ∧ (a ∨ c)
function
to_dnf(expr, simplify=False)

Disjunctive normal form (sum of products); the dual of to_cnf(), with the same simplify switch and size ceiling.

function
simplify_logic(expr, form="auto", dontcare=None, exact=None)

Minimise a propositional formula to a two-level form.

Parameters
expr

formula over Boolean symbols (relations such as x > 0 are accepted as opaque atoms).

form

"dnf" (sum of products), "cnf" (product of sums) or "auto" (whichever has fewer literals, DNF on a tie).

dontcare

optional formula marking assignments whose value is free; the result agrees with expr wherever dontcare is false.

exact

None (default) — exact up to 12 variables, heuristic beyond; True — exact or raise LogicError (E-LOGIC-004, more than 16 variables or a covering search past its bound); False — heuristic.

Returns

Expr — the minimised formula. minimize_logic() returns the

same with exact/terms/literals metadata.

The exact method is Quine–McCluskey prime implicants followed by a minimum cover (Petrick's method, run as branch-and-bound): fewest terms, then fewest literals. The heuristic is an espresso-style expand / irredundant pass on the ROBDD of the function; its cover is always equivalent but not necessarily minimum. Every result is checked against the input before it is returned.

Raises LogicError (E-LOGIC-002 not propositional, E-LOGIC-003 size ceiling, E-LOGIC-004 exactness unavailable, E-LOGIC-005 bad form) and BudgetExceededError under an active budget.

Python
>>> import alkahest as ak
>>> p = ak.ExprPool(); a, b = p.symbol("a"), p.symbol("b")
>>> print(ak.simplify_logic(ak.Or(ak.And(a, b), ak.And(a, ak.Not(b)))))
a
function
minimize_logic(expr, form="dnf", dontcare=None, exact=None)

simplify_logic() returning a MinimizedLogic record (expr, form, exact, terms, literals) so a caller can see whether minimality was proven. Default form is "dnf".

Multi-output. Pass a list or tuple of formulas to minimise them jointly: the result is a MultiMinimizedLogic whose exprs are two-level forms built from product terms (clauses for form="cnf") shared between outputs, so terms counts each distinct term once — never more than the sum of the separately minimised outputs. dontcare applies to every output. Up to 12 variables (16 with exact=True) the shared cover is exact (multi-output Quine–McCluskey primes, then a minimum cover of the (output, minterm) pairs: fewest distinct terms, then fewest literals); beyond that the espresso-style pass runs per output and the pooled cubes are cut down to an irredundant shared set, flagged exact=False. Every output is verified against its input before the result is returned. At most 64 outputs (LogicError, E-LOGIC-003).

Python
>>> import alkahest as ak
>>> p = ak.ExprPool(); a, b = p.symbol("a"), p.symbol("b")
>>> r = ak.minimize_logic([ak.Xor(a, b), ak.And(a, b), ak.Or(a, b)])
>>> r.terms, r.exact
(3, True)
class
class MultiMinimizedLogic

Result of minimize_logic() on a list of formulas: one two-level formula per output, built from product terms (or clauses) shared between outputs.

Properties
exprs

One formula per input, in order.

"dnf" or "cnf".

exact

True when the shared cover is proven minimum (fewest distinct terms, then fewest literals); False for the heuristic cover.

terms

Distinct product terms (DNF) or clauses (CNF) over all outputs; a term feeding several outputs counts once.

literals

Literal occurrences over the distinct terms.

per_output_terms

Terms in each output's own formula.

shared_terms

Distinct terms used by more than one output.

Decision diagrams

function
bdd(expr, order=None, max_nodes=None, auto_reorder=None)

Build the reduced ordered BDD of a formula over Boolean symbols.

Parameters
expr

formula (And/Or/Not/Xor/… over symbols).

order

optional list of symbols (or names) fixing the variable order, root first; symbols of expr not listed are appended. None uses depth-first first-visit order over the canonically sorted formula, which interleaves the operand bits of bit-sliced arithmetic (the order adders and comparators need).

max_nodes

node ceiling (default 2 000 000). A BDD that would exceed it is refused with LogicError (E-LOGIC-003); an active alkahest.Budget is honoured too (BudgetExceededError).

auto_reorder

dynamic variable reordering (Rudell sifting) once the manager holds this many live nodes: False off, True or an int threshold on. None (default) means on at 50 000 nodes unless an explicit order was given. It never changes what a BDD means, only its order and size; see BDD.reorder() to sift on demand.

Returns
Python
>>> import alkahest as ak
Python
>>> p = ak.ExprPool(); a, b, c = p.symbol("a"), p.symbol("b"), p.symbol("c")
Python
>>> f = ak.bdd(ak.Or(ak.And(a, b), ak.And(b, c), ak.And(a, c)))
Python
>>> f.sat_count(), f.node_count()

(4, 4)

class
class BDD

A reduced ordered binary decision diagram, returned by bdd().

Canonical for its variable order: two BDDs of the same manager are equivalent exactly when equivalent() says so, in time independent of the formulas. &, |, ^ and ~ combine BDDs (or a BDD and an Expr); operands built by separate bdd() calls are merged by variable name.

Properties
auto_reorder

The automatic-reordering threshold in nodes, or None when off.

order

Variable names in the manager's order (root first).

Methods
implies(other)

self → other.

iff(other)

self ↔ other.

ite(g, h)

if self then g else h (the ITE operator every BDD operation reduces to).

restrict(assignment)

Cofactor: fix variables to constants. assignment maps symbols (or names) to bools.

exists(variables)

Existential quantification ∃ vars . self.

forall(variables)

Universal quantification ∀ vars . self.

sat_count(variables=None)

Number of satisfying assignments (a Python int, exact).

With variables=None the count is over every variable of the BDD's manager — the formula's symbols plus any brought in by combining it with other BDDs. Pass variables (symbols or names, a superset of support()) to count over exactly that set.

reorder(max_growth=1.2, max_swaps=...)

Re-sort the manager's variable order by Rudell sifting to shrink the diagrams.

Every function of the manager (this BDD and any combined with it) keeps its meaning: sat_count, equivalent() and to_expr() are unchanged; order and node_count() change. Unreferenced intermediate nodes are collected.

Parameters
max_growth

abandon a variable's excursion in one direction once the manager exceeds this multiple of the best size seen for it (default 1.2; at least 1.0).

max_swaps

stop after this many adjacent-variable swaps (default 200 000); complete is then False.

Returns
Python
>>> import alkahest as ak
Python
>>> p = ak.ExprPool()
Python
>>> xs = [p.symbol(f"x{i}") for i in range(6)]
Python
>>> ys = [p.symbol(f"y{i}") for i in range(6)]
Python
>>> f = ak.bdd(ak.Or(*[ak.And(x, y) for x, y in zip(xs, ys)]),

... order=xs + ys, auto_reorder=False)

Python
>>> before = f.node_count()
Python
>>> _ = f.reorder()
Python
>>> f.node_count() < before

True

set_auto_reorder(threshold)

Turn automatic reordering of this BDD's manager on or off: False (off), True (sift once the manager holds 50 000 live nodes) or an int threshold. Checked after every operation that produces a BDD.

node_count()

Non-terminal nodes reachable from this function.

support()

Names of the variables the function depends on, in BDD order.

satisfy_one()

One satisfying assignment {name: bool} (variables left out are free), or None if unsatisfiable.

is_true()

True when the function is the constant true.

is_false()

True when the function is the constant false.

equivalent(other)

Canonical equivalence: same node after merging the operand into this manager.

to_expr()

The function as a formula (Shannon expansion at every node).

Equivalence and truth tables

function
equivalent(f, g, method="auto", counterexample=False)

Decide whether two propositional formulas are equivalent.

Parameters

f, g: formulas (same pool).

method

"auto" (truth table up to 16 variables, then a BDD capped at 200 000 nodes, then SAT), "truth_table", "bdd" or "sat" (DPLL on the miter f ⊕ g).

counterexample

return (verdict, assignment_or_None) instead of a bare bool; the assignment maps symbol names to bools and is checked to separate f and g.

Relations (x > 0) are accepted as opaque atoms: True is then still a proof, but a propositional difference is refused with LogicError (E-LOGIC-002) rather than reported as False, because x > 0 and Not(x <= 0) are propositionally independent yet equal over ℝ. A search that exhausts its bound raises LogicError (E-LOGIC-003) — never a guessed verdict.

Python
>>> import alkahest as ak
>>> p = ak.ExprPool(); a, b = p.symbol("a"), p.symbol("b")
>>> ak.equivalent(ak.Not(ak.And(a, b)), ak.Or(ak.Not(a), ak.Not(b)))
True
function
truth_table(expr, variables=None)

Truth table of a formula over Boolean symbols.

Parameters
expr

formula.

variables

the symbols to tabulate, in column order; must include every symbol of expr. None uses the formula's symbols sorted by name.

Returns

list[tuple[tuple[bool, ...], bool]] — one (assignment, value)

row per assignment, from all-False to all-True with the last

variable varying fastest. At most 22 variables (E-LOGIC-003).

satisfiable() decides any purely propositional formula, including ones wider than 64 symbols or with heavily shared sub-formulas (parity chains, carry chains), on the formula's DAG.

Satisfiability

function
satisfiable(formula)

Decide satisfiability of a predicate Expr.

Returns False if unsatisfiable, True if satisfiable with no witness variables, a dict of symbol → value string if a witness is found, or None if the fragment is unsupported. None never means "unsatisfiable" — only False is a proof of that.

Two fragments are decided, and they are disjoint:

  • Purely propositional — every leaf is a bare symbol (read as a Boolean variable, whatever Domain it was declared with) or pool.pred_true() / pool.pred_false(), combined with pred_and / pred_or / pred_not (equivalently And() / Or() / Not()). This fragment is decided completely by DPLL: it returns None only if the search exceeds its budget of 2 000 000 branching decisions, never because it gave up thinking. Wide formulas (more than 64 symbols) and formulas with shared sub-formulas (Xor parity chains, carry chains) are encoded on their DAG, so their size is what they share, not what they unfold to. → and ↔ are in it once written out: a → b is Or(Not(a), b) and a ↔ b is And(Or(Not(a), b), Or(Not(b), a)); the kernel has no separate implication node (Implies(), Equivalent(), Xor() and ITE() build these forms).
  • One real symbol against rational constants — x < 3, x >= 1/2 and Boolean combinations of them, decided by interval intersection. This one is incomplete: None is common, and expected.

Everything else is None, including — deliberately — any formula that mixes a Boolean symbol with an arithmetic relation (A ∧ x > 0), any formula over two or more real symbols, and anything quantified. Deciding those needs a Boolean-plus-theory combination this function does not implement; the cheap alternative, abstracting each relation to a fresh proposition, is sound for "unsatisfiable" only and would call x > 0 ∧ x < 0 satisfiable. Send them to decide() (real quantifier elimination by CAD) or to smt instead.

A witness dict is homogeneous, so a caller can tell which path answered from any one value: propositional witnesses map every symbol to "true" or "false", interval witnesses map every symbol to a rational literal such as "1/2". The two never appear in the same dict.

Python
>>> import alkahest as ak
>>> p = ak.ExprPool()
>>> A, B = p.symbol("A"), p.symbol("B")
>>> ak.satisfiable(p.pred_and([p.pred_or([A, B]), p.pred_not(A), p.pred_not(B)]))
False
>>> sorted(ak.satisfiable(p.pred_and([p.pred_or([A, B]), p.pred_not(A)])).items())
[('A', 'false'), ('B', 'true')]
class
class MinimizedLogic

Result of minimize_logic(): the two-level formula and how it was obtained.

Properties

"dnf" or "cnf".

exact

True when the cover is proven minimum (fewest terms, then fewest literals); False for the heuristic cover.

terms

Product terms (DNF) or clauses (CNF).

literals

Literal occurrences.