Arithmetic and algebra over infinite structures#
Büchi’s and Rabin’s theorems make whole infinite structures computable: encode
their elements as finite words (or trees) and recognize the relations with finite
automata, and first-order — sometimes monadic second-order — logic becomes
decidable by automata operations. autstr turns that theory into code.
This notebook tours the number-theoretic and algebraic structures the library ships with:
Presburger arithmetic \((\mathbb{Z}, +, <)\) — infinite sets as first-class values;
Büchi arithmetic — reasoning about the binary expansions of naturals;
Skolem arithmetic \((\mathbb{N}_{>0}, \cdot)\) — a number as the tree of its prime exponents;
finite Boolean algebras and the localizations \(\mathbb{Z}[1/p]\);
the finite powerset structure MSO0, where first-order logic is Büchi’s MSO.
Groups get their own notebook (groups.ipynb); graphs another (graphs.ipynb).
Variable naming. The formula parser treats a name as a variable only if it matches
[a-df-z][0-9]*— a single lowercase letter (nevere, which is the parser’s event marker), optionally followed by digits.
Presburger arithmetic over the integers#
\((\mathbb{Z}, +, <)\) is automatic in base 2. autstr.arithmetic exposes it through
a small term algebra, so an infinite definable set is an ordinary Python value you
can intersect, complement and iterate.
from autstr.arithmetic import VariableETerm as Var
from time import time
x = Var('x')
Infinite sets as first-class values: the Sieve of Eratosthenes#
The candidate set starts as the infinite set \(\{2, 3, 4, \dots\}\). Each step reads off its smallest element (elements are enumerated in ascending absolute value) and removes that prime’s proper multiples — a symbolic set difference. The remaining candidates stay an automaton throughout.
def infinite_sieve(steps):
x = Var('x')
candidates = (x.gt(1)) # {2, 3, 4, ...}
primes = []
for _ in range(steps):
start = time()
for p in candidates: # smallest-first enumeration
primes.append(p[0])
break
print(f"next prime {primes[-1]} ({time() - start:.3f}s)")
y = Var('y')
multiples = (x.eq(primes[-1] * y)).drop('y')
candidates = candidates & ~multiples
return primes, candidates
primes, remaining = infinite_sieve(steps=3)
print('primes found:', primes)
remaining.evaluate().show_diagram()
next prime 2 (0.014s)
next prime 3 (0.050s)
next prime 5 (0.111s)
primes found: [2, 3, 5]
Integer linear systems#
Integer linear systems are NP-hard in general, but they are first-order definable over \((\mathbb{Z}, +)\), so we can build them and read off (or rule out) solutions.
x, y, z = Var('x'), Var('y'), Var('z')
eqn_sys = (x + y).eq(6) & (y + z).eq(12) & (x + z).eq(10)
for s in eqn_sys:
print('solution:', s)
solution: (2, 4, 8)
unsolvable = (3 * y + 5 * z).eq(1) & (x + y).eq(5) & (10 * z + x).eq(17)
print('has a solution:', not unsolvable.isempty())
has a solution: False
Büchi arithmetic: reasoning about binary expansions#
Büchi arithmetic extends Presburger with \(V_2(x)\) — the largest power of two dividing \(x\) — which is exactly the power to talk about the bits of a number. Many relations are generated on creation, so the first build takes a moment.
from autstr.buildin.presentations import BuechiArithmetic
ba = BuechiArithmetic()
ba.automata['A'].show_diagram() # the addition relation A(x, y, z): x + y = z
Everything else is defined first-order from addition, the same bootstrap the library uses everywhere. Zero is the unique \(x\) with \(x + x = x\); one is the only other number whose additive decompositions are all trivial; the successor and the powers of two follow.
ba.update(O='A(x,x,x)') # zero
ba.update(I='not O(x) and forall y z.(O(y) or O(z) or not A(y, z, x))') # one
ba.update(S='exists x.(I(x) and A(u, x, v))') # successor S(u, v): v = u + 1
ba.update(P='B(x,x)') # powers of two
ba.update(Smaller='exist z.(not O(z) and A(x, z, y))') # strict order
ba.automata['P'].show_diagram()
A small theorem, proved by automaton#
For every positive \(x\) there is a power of two in \([x, 2x]\). A sentence has no free variables, so its automaton is a single state — accepting exactly when the sentence is true.
sandwich = ('forall x.(O(x) or exists y u v w.('
'A(x, x, y) and A(x, u, v) and P(v) and A(v, w, y)))')
print('sandwich theorem holds:', ba.check(sandwich))
sandwich theorem holds: True
Skolem arithmetic: multiplication as a tree#
\((\mathbb{N}_{>0}, \cdot)\) is not automatic over strings, but it is tree-automatic: write \(n = \prod_i p_i^{e_i}\) as a spine of primes with each exponent hanging off as a binary chain, and multiplication becomes componentwise addition along the branches (Rabin’s theorem, made executable).
from autstr.buildin.tree_presentations import skolem_arithmetic
skolem = skolem_arithmetic()
print('360 = 2^3 * 3^2 * 5 round-trips to:', skolem.decode(skolem.encode(360)))
M = skolem.evaluate('M(x,y,z)') # one automaton, an infinite domain
enc = skolem.encode
for a, b, c in [(6, 10, 60), (7, 13, 91), (6, 11, 65)]:
print(f'{a} * {b} = {c}? {M.accepts(enc(a), enc(b), enc(c))}')
360 = 2^3 * 3^2 * 5 round-trips to: 360
6 * 10 = 60? True
7 * 13 = 91? True
6 * 11 = 65? False
# Divisibility is first-order over multiplication alone: x | y iff exists z. x*z = y
divides = skolem.evaluate('exists z.(M(x,z,y))')
for a, b in [(3, 12), (5, 12), (7, 91)]:
print(f'{a} divides {b}: {divides.accepts(enc(a), enc(b))}')
3 divides 12: True
5 divides 12: False
7 divides 91: True
Finite Boolean algebras#
autstr.algebra presents whole families of finite structures as one uniformly
automatic class: a single tuple of automata carries an extra advice tape, and
fixing the advice pins down one member. The Boolean algebra with \(n\) atoms is the
advice 1 repeated \(n\) times; elements are bitvectors of length \(n\).
from autstr.algebra import FiniteBooleanAlgebras
bool_alg = FiniteBooleanAlgebras()
n = 4
print('Meet({0,1}, {1,2}) = {1}:', bool_alg.check('Meet(x,y,z)', n, x={0, 1}, y={1, 2}, z={1}))
print('Atom({3}): ', bool_alg.check('Atom(x)', n, x={3}))
# One compiled automaton decides a property for every size at once.
proper = 'exists x.(exists y.(Compl(x,y) and (not Leq(x,y))))'
dfa, tapes = bool_alg.evaluate(proper)
for size in [0, 1, 5]:
advice = bool_alg.advice(size)
print(f'n={size}: has a complemented non-subalgebra element =',
dfa.accepts([(s,) for s in advice]))
Meet({0,1}, {1,2}) = {1}: True
Atom({3}): True
n=0: has a complemented non-subalgebra element = False
n=1: has a complemented non-subalgebra element = True
n=5: has a complemented non-subalgebra element = True
The localization \(\mathbb{Z}[1/p]\)#
z1p_localization(p) is a factory: for each prime \(p\) it produces the additive group
\(\mathbb{Z}[1/p] = \{\, a/p^k : a \in \mathbb{Z},\ k \ge 0 \,\}\) together with an automatic
presentation of \((\mathbb{Z}[1/p], +)\). The encoding interleaves the integer and
fractional digits around the radix point so both expansions grow rightward and stay
aligned; signed addition A, zero Z and equality Eq are then defined from
magnitude addition.
from autstr.algebra import z1p_localization
z2 = z1p_localization(2)
half, three_q = z2.from_fraction(1, 2), z2.from_fraction(3, 4)
print('1/2 + 3/4 = 5/4:', z2.check('A(x,y,z)', x=half, y=three_q, z=z2.add(half, three_q)))
print('encoding of 5/4:', z2.encode(z2.from_fraction(5, 4)))
1/2 + 3/4 = 5/4: True
encoding of 5/4: ['+', '1_0', '0_1']
The first-order theory tells the primes apart#
Every element of \(\mathbb{Z}[1/p]\) is divisible by \(p\) but by no other prime. Both are first-order sentences, so the presentations decide them — and disagree exactly where the primes differ.
z3 = z1p_localization(3)
div2 = 'all x.(exists y.(A(y,y,x)))' # 2 | x for all x
div3 = 'all x.(exists y.(exists w.(A(y,y,w) and A(w,y,x))))' # 3 | x for all x
for name, s in [('Z[1/2]', z2), ('Z[1/3]', z3)]:
print(f'{name}: 2-divisible={s.check(div2)} 3-divisible={s.check(div3)}')
Z[1/2]: 2-divisible=True 3-divisible=False
Z[1/3]: 2-divisible=False 3-divisible=True
The finite powerset structure MSO0#
The finite subsets of \(\mathbb{N}\), ordered by \(\subseteq\) with singletons, successor and order on singletons, form the structure MSO0. A finite set is a \(\{0,1\}\)-word (position \(i\) set iff \(i\) is a member), and — by Büchi’s theorem — first-order logic over MSO0 is precisely monadic second-order logic over \((\mathbb{N}, <)\): the definable sets are exactly the regular ones.
from autstr.buildin.presentations import MSO0
mso0 = MSO0()
print('relations:', mso0.get_relation_symbols())
mso0.automata['Subset'].show_diagram()
relations: ['U', 'Subset', 'Sing', 'Succ', 'Lt_sing', 'In', 'Eq_set', 'Leq_sing', 'Gt_sing', 'Min', 'Max', 'Intersect', 'Union', 'SetMinus', 'Geq_sing']
# Self-contained MSO sentences, each decided by a one-state automaton.
print('there is a singleton: ', mso0.check('exists x.(Sing(x))'))
print('every singleton has a successor: ',
mso0.check('all x.((not Sing(x)) or exists y.(Succ(x,y)))'))
print('there is a least singleton: ',
mso0.check('exists x.(Sing(x) and all y.((not Sing(y)) or Leq_sing(x,y)))'))
there is a singleton: True
every singleton has a successor: True
there is a least singleton: True
Every family above is an instance of autstr.uniform.UniformlyAutomaticClass (or its
tree analog), whose define() turns first-order definitions over small primitive
automata into new relations — the uniform version of the Büchi-arithmetic bootstrap.
The groups and graphs notebooks push the same machinery to non-abelian groups
and to graphs of bounded width.