Implicit evaluation: querying members whose automata are too big to build

Implicit evaluation: querying members whose automata are too big to build#

Every uniformly automatic class answers a first-order query by compiling it to one automaton and then running each member’s advice through it. That is unbeatable when you ask many queries — but it has two failure modes:

  1. the query automaton is expensive to build (you pay it once, then amortise), and

  2. for some members the base automata themselves are infeasible — the advice alphabet is astronomical, so the \(O(|\Sigma|^{\text{arity}})\) transition tables cannot even be written down.

The autstr.implicit layer sidesteps both by never building anything: it evaluates a formula bottom-up on the fly over the base transition functions. Boolean connectives keep composite states and step the base automata; exists is an on-the-fly powerset over the (tiny) element alphabet; not flips acceptance. Because the advice is fixed input, the cost is driven by quantifier alternation, not by alphabet size.

Every class exposes check_implicit (model checking) and evaluate_implicit (the whole satisfying set). This notebook shows what that buys — and what it costs.

A member whose automaton cannot be built#

CutRankGroups(2, d=3) is the bounded-rank-width class-2 groups over the chain ring \(\mathbb{Z}/8\). The advice alphabet is so large that the explicit presentation refuses to build:

from autstr.groups import CutRankGroups

G8 = CutRankGroups(2, d=3)                 # exponent-8 center
adv8 = G8.advice(3, G8.clique_form(3))
try:
    _ = G8.cls                              # attempt to build the automata
except ValueError as e:
    print('building the automaton fails:', e)
building the automaton fails: advice alphabet too large to build the explicit presentation automata: 512 letters, ~2e+07 transition enumerations (cap 2e+07); use check_implicit or simulate instead
# ... yet the group law is still decided, implicitly, straight from the transitions.
g, h = ((5,), (3, 1, 6)), ((2,), (7, 4, 1))
z = G8.multiply(3, G8.clique_form(3), g, h)
print('g * h =', z)
print('M(g, h, z) holds:        ', G8.check_implicit('M(x,y,z)', adv8, x=g, y=h, z=z))
print('g is non-central:        ',
      G8.check_implicit('exists y.(exists z.(M(x,y,z) and (not M(y,x,z))))', adv8, x=g))
print('the member is non-abelian:',
      G8.check_implicit('exists x.(exists y.(exists z.(M(x,y,z) and (not M(y,x,z)))))', adv8))
g * h = ((0,), (2, 5, 7))
M(g, h, z) holds:         True
g is non-central:         True
the member is non-abelian: True

The runtime trade-off#

On a member whose automaton can be built — the field \(\mathbb{F}_2\) version — we can time both paths. The compiled path pays a one-time compile, then each query is a fast DFA run; the implicit path skips the compile but pays a per-query on-the-fly cost that grows with quantifier alternation.

import time, random, itertools

G = CutRankGroups(2); n = 3; form = G.clique_form(n); adv = G.advice(n, form)
elems = [((b,), a) for b in range(G.q) for a in itertools.product(range(G.q), repeat=n)]
rng = random.Random(0)

def per_query_us(fn, args, repeat=100):
    sample = [rng.choice(args) for _ in range(repeat)]
    t = time.perf_counter()
    for a in sample:
        fn(*a)
    return (time.perf_counter() - t) / repeat * 1e6

def compiled_runner(phi, free):
    dfa, variables = G.evaluate(phi)        # compile once
    def run(*vals):
        cols = {'advice': list(adv)}
        cols.update({v: G.encode(val, n) for v, val in zip(free, vals)})
        return dfa.accepts([tuple(cols[v][i] for v in variables) for i in range(len(adv))])
    return run
pairs = [(g, h) for g in elems for h in elems]
singles = [(g,) for g in elems]

for label, phi, free, args in [
        ('1 quantifier   exists z.(M(x,y,z) and M(y,x,z))',
         'exists z.(M(x,y,z) and M(y,x,z))', ['x', 'y'], pairs),
        ('2 quantifiers  exists y z.(M(x,y,z) and not M(y,x,z))',
         'exists y.(exists z.(M(x,y,z) and (not M(y,x,z))))', ['x'], singles)]:
    t = time.perf_counter(); run = compiled_runner(phi, free); compile_ms = (time.perf_counter() - t) * 1e3
    c = per_query_us(run, args)
    imp = per_query_us(lambda *v: G.check_implicit(phi, adv, **dict(zip(free, v))), args)
    print(f'{label}\n    compile {compile_ms:5.0f} ms   '
          f'compiled {c:6.0f} us/query   implicit {imp:6.0f} us/query   '
          f'({imp / c:.0f}x slower)\n')
1 quantifier   exists z.(M(x,y,z) and M(y,x,z))
    compile   108 ms   compiled    141 us/query   implicit    565 us/query   (4x slower)
2 quantifiers  exists y z.(M(x,y,z) and not M(y,x,z))
    compile    84 ms   compiled     23 us/query   implicit    955 us/query   (41x slower)

So the rule of thumb: compile when you will ask many queries (the per-query DFA run is tiny and the compile amortises), and reach for the implicit path when either the compile is disproportionate to how often you will query, or — as with \(\mathbb{Z}/8\) above — the automaton cannot be built at all. The implicit slowdown is one query’s worth of on-the-fly work, and it grows with quantifier alternation.

Satisfying sets without enumeration#

evaluate_implicit returns the whole satisfying set of a formula’s open variables. A single forward pass plus a backward counting DP give the exact solution count without enumerating anything (len), and iteration yields the assignments lazily.

# The unique inverse of g in the (unbuildable) Z/8 member.
inv = G8.evaluate_implicit('M(x,y,u)', adv8, x=g, u=G8.identity(3))
print('number of inverses:', len(inv))            # exactly 1, known without enumerating
y = next(iter(inv))['y']
print('g^{-1} =', y, ' and g * g^{-1} = e:',
      G8.multiply(3, G8.clique_form(3), g, y) == G8.identity(3))
number of inverses: 1
g^{-1} = ((6,), (5, 7, 2))  and g * g^{-1} = e: True
# The centralizer of a field element, cross-checked against brute force.
gf = ((1,), (1, 0, 1))
cent = G.evaluate_implicit('exists z.(M(x,y,z) and M(y,x,z))', adv, x=gf)
brute = [e for e in elems if G.multiply(n, form, gf, e) == G.multiply(n, form, e, gf)]
print('centralizer size (DP count):', len(cent), '  brute force:', len(brute))
print('whole domain size:', len(G.evaluate_implicit('Eq(x,x)', adv)), '=', G.q ** (n + 1))
centralizer size (DP count): 8   brute force: 8
whole domain size: 16 = 16

Fully implicit presentations#

ImplicitClass / ImplicitTreeClass package functional atoms and an element alphabet as a first-class presentation — a uniformly automatic class given purely by transition functions, for members where even writing the presentation down is infeasible. Nothing is ever compiled; it offers only check and evaluate.

from autstr import implicit as im

icls = im.ImplicitClass(G8._implicit_atoms(), list(G8.digits))
w = G8.encode(g, 3)
print('Eq(g, g):        ', icls.check('Eq(x,x)', adv8, x=w))
print('non-abelian:     ',
      icls.check('exists x.(exists y.(exists z.(M(x,y,z) and (not M(y,x,z)))))', adv8))
print('domain has 8^4 = %d elements:' % (8 ** 4),
      len(icls.evaluate('Eq(x,x)', adv8)) == 8 ** 4)
Eq(g, g):         True
non-abelian:      True
domain has 8^4 = 4096 elements: True

The heavy group classes (CutRankGroups, CutRankTreeGroups, CocycleRankWidthGroups) and the graph class RankWidthClass all route check_implicit / evaluate_implicit through their implicit_cls, so the largest members — the whole point of a uniform presentation — stay queryable even when no single automaton for them can be built.